mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 19:41:57 +00:00
Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid, at_hash, signed userinfo, RP-initiated and back-channel logout), managed refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens, DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592 registration, RFC 9207 iss, signing keyring with rotation. State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct backends, four dialect DDLs, conformance cases). Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL. PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse. Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
376 lines
12 KiB
Go
376 lines
12 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
|
|
)
|
|
|
|
const (
|
|
grantDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
|
|
grantTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // RFC 8693 URN, not a credential
|
|
|
|
// accessGrantFamily marks oauth_refresh_tokens rows that record an issued access token
|
|
// (scope, client, DPoP binding). They share the table so logout revokes both.
|
|
accessGrantPrefix = "at_"
|
|
)
|
|
|
|
// isAccessRecord reports whether a stored token is the record of an access token.
|
|
func isAccessRecord(t *lookup.RefreshToken) bool {
|
|
return strings.HasPrefix(t.FamilyID, accessGrantPrefix)
|
|
}
|
|
|
|
// accessKey is the token_hash of the record of an access token (keyed by the session token or the
|
|
// JWT's jti). The prefix is not hex, so it can never equal the hash of a refresh token.
|
|
func accessKey(id string) string { return accessGrantPrefix + hashToken(id)[:61] }
|
|
|
|
// tokenGrant describes the tokens to issue.
|
|
type tokenGrant struct {
|
|
Client *OAuthServerClient
|
|
UserID int
|
|
Scopes []string
|
|
Resource []string
|
|
Nonce string
|
|
AuthTime int64
|
|
ACR string
|
|
AMR []string
|
|
SID string
|
|
Claims map[string]any
|
|
DPoPJKT string
|
|
|
|
// LegacyAccess is an existing session token that is used as the access token (codes saved
|
|
// by earlier versions).
|
|
LegacyAccess string
|
|
LegacyRefresh string // refresh token passed through from the DatabaseAuthenticator
|
|
|
|
IssueRefresh bool // issue a managed refresh token (new family)
|
|
FamilyID string // family of the new refresh token
|
|
NextRefresh string // already rotated refresh token to return
|
|
FamilyExp time.Time // absolute end of the family when rotating
|
|
IDToken bool
|
|
}
|
|
|
|
// mintTokens issues the tokens of g and returns the token endpoint response.
|
|
func (s *OAuthServer) mintTokens(ctx context.Context, g *tokenGrant) (map[string]any, *oauthError) {
|
|
a := s.anyAuth()
|
|
if a == nil {
|
|
return nil, oerr("server_error", "no authenticator configured", http.StatusInternalServerError)
|
|
}
|
|
if g.LegacyAccess != "" && g.UserID == 0 {
|
|
info, err := a.OAuthIntrospectToken(ctx, g.LegacyAccess)
|
|
if err != nil || !info.Active {
|
|
return nil, oerr("invalid_grant", "the session of this code has ended", http.StatusBadRequest)
|
|
}
|
|
g.UserID, _ = strconv.Atoi(info.Sub)
|
|
}
|
|
now := time.Now()
|
|
exp := now.Add(s.cfg.AccessTokenTTL)
|
|
sub := strconv.Itoa(g.UserID)
|
|
tokenType := "Bearer"
|
|
if g.DPoPJKT != "" {
|
|
tokenType = "DPoP"
|
|
}
|
|
|
|
var access, recordID string
|
|
switch {
|
|
case s.cfg.JWTAccessTokens:
|
|
jti, err := randomOAuthToken()
|
|
if err != nil {
|
|
return nil, serverErr()
|
|
}
|
|
aud := jwt.ClaimStrings{s.cfg.AccessTokenAudience}
|
|
if len(g.Resource) > 0 {
|
|
aud = g.Resource
|
|
}
|
|
claims := jwt.MapClaims{
|
|
"iss": s.cfg.Issuer, "sub": sub, "aud": aud, "exp": exp.Unix(), "iat": now.Unix(),
|
|
"nbf": now.Unix(), "jti": jti, "client_id": g.Client.ClientID,
|
|
}
|
|
if len(g.Scopes) > 0 {
|
|
claims["scope"] = strings.Join(g.Scopes, " ")
|
|
}
|
|
if g.AuthTime != 0 {
|
|
claims["auth_time"] = g.AuthTime
|
|
}
|
|
if g.SID != "" {
|
|
claims["sid"] = g.SID
|
|
}
|
|
if g.DPoPJKT != "" {
|
|
claims["cnf"] = map[string]any{"jkt": g.DPoPJKT}
|
|
}
|
|
var err2 error
|
|
if access, err2 = s.keys.forAlg("").sign(claims, "at+jwt"); err2 != nil {
|
|
return nil, serverErr()
|
|
}
|
|
recordID = jti
|
|
case g.LegacyAccess != "":
|
|
access, recordID = g.LegacyAccess, g.LegacyAccess
|
|
default:
|
|
tok, err := randomOAuthToken()
|
|
if err != nil {
|
|
return nil, serverErr()
|
|
}
|
|
if err := a.oauth2CreateSession(ctx, tok, g.UserID, &oauth2.Token{AccessToken: tok, TokenType: "Bearer"}, exp, "oauth2_server"); err != nil {
|
|
return nil, serverErr()
|
|
}
|
|
access, recordID = tok, tok
|
|
}
|
|
|
|
if gs := s.grants(); gs != nil {
|
|
extra := map[string]any{}
|
|
if g.DPoPJKT != "" {
|
|
extra["jkt"] = g.DPoPJKT
|
|
}
|
|
if len(g.Resource) > 0 {
|
|
extra["aud"] = g.Resource
|
|
}
|
|
if len(g.Claims) > 0 {
|
|
extra["claims"] = g.Claims
|
|
}
|
|
if err := gs.SaveRefresh(ctx, lookup.RefreshToken{
|
|
TokenHash: accessKey(recordID), FamilyID: accessKey(recordID), ClientID: g.Client.ClientID, UserID: g.UserID,
|
|
SessionToken: g.SID, Scopes: g.Scopes, Extra: extra, ExpiresAt: exp,
|
|
}); err != nil {
|
|
return nil, serverErr()
|
|
}
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"access_token": access,
|
|
"token_type": tokenType,
|
|
"expires_in": int64(s.cfg.AccessTokenTTL.Seconds()),
|
|
}
|
|
if len(g.Scopes) > 0 {
|
|
resp["scope"] = strings.Join(g.Scopes, " ")
|
|
}
|
|
|
|
switch {
|
|
case g.NextRefresh != "":
|
|
resp["refresh_token"] = g.NextRefresh
|
|
case g.IssueRefresh && s.cfg.ManagedRefreshTokens:
|
|
rt, e := s.newRefreshToken(ctx, g, now)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
resp["refresh_token"] = rt
|
|
case g.LegacyRefresh != "":
|
|
resp["refresh_token"] = g.LegacyRefresh
|
|
}
|
|
|
|
if g.IDToken && oauthSliceContains(g.Scopes, "openid") {
|
|
idt, err := s.buildIDToken(ctx, idTokenParams{
|
|
Client: g.Client, UserID: g.UserID, Scopes: g.Scopes, Nonce: g.Nonce, AuthTime: g.AuthTime,
|
|
ACR: g.ACR, AMR: g.AMR, SID: g.SID, AccessToken: access, Claims: g.Claims,
|
|
})
|
|
if err != nil {
|
|
return nil, oerr("server_error", "could not sign the id_token", http.StatusInternalServerError)
|
|
}
|
|
resp["id_token"] = idt
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (s *OAuthServer) writeTokenResponse(w http.ResponseWriter, resp map[string]any) {
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Token endpoint — POST /oauth/token
|
|
// --------------------------------------------------------------------------
|
|
|
|
func (s *OAuthServer) tokenHandler(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
writeOAuthError(w, "invalid_request", "cannot parse form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var resp map[string]any
|
|
var e *oauthError
|
|
switch r.PostFormValue("grant_type") {
|
|
case "authorization_code":
|
|
resp, e = s.handleAuthCodeGrant(r)
|
|
case "refresh_token":
|
|
resp, e = s.handleRefreshGrant(r)
|
|
case "client_credentials":
|
|
resp, e = s.handleClientCredentialsGrant(r)
|
|
case grantDeviceCode:
|
|
if !s.cfg.EnableDeviceFlow {
|
|
e = oerr("unsupported_grant_type", "", http.StatusBadRequest)
|
|
} else {
|
|
resp, e = s.handleDeviceGrant(r)
|
|
}
|
|
case grantTokenExchange:
|
|
if !s.cfg.EnableTokenExchange {
|
|
e = oerr("unsupported_grant_type", "", http.StatusBadRequest)
|
|
} else {
|
|
resp, e = s.handleTokenExchange(r)
|
|
}
|
|
default:
|
|
e = oerr("unsupported_grant_type", "", http.StatusBadRequest)
|
|
}
|
|
if e != nil {
|
|
e.write(w)
|
|
return
|
|
}
|
|
s.writeTokenResponse(w, resp)
|
|
}
|
|
|
|
// grantAllowed reports whether the client may use grantType. Clients that declare no grant
|
|
// types keep every authorization-code style grant.
|
|
func grantAllowed(c *OAuthServerClient, grantType string) bool {
|
|
if len(c.GrantTypes) == 0 {
|
|
return grantType == "authorization_code" || grantType == "refresh_token"
|
|
}
|
|
return oauthSliceContains(c.GrantTypes, grantType)
|
|
}
|
|
|
|
// dpopForClient validates the DPoP header of a token request and returns the key to bind to.
|
|
func (s *OAuthServer) dpopForClient(r *http.Request, c *OAuthServerClient) (string, *oauthError) {
|
|
proof, e := s.verifyDPoP(r, "")
|
|
if e != nil {
|
|
return "", e
|
|
}
|
|
if proof == nil {
|
|
if c.DPoPBoundAccessTokens {
|
|
return "", oerr("invalid_dpop_proof", "this client must present a DPoP proof", http.StatusBadRequest)
|
|
}
|
|
return "", nil
|
|
}
|
|
return proof.JKT, nil
|
|
}
|
|
|
|
func (s *OAuthServer) handleAuthCodeGrant(r *http.Request) (map[string]any, *oauthError) {
|
|
ac, e := s.requireClient(r)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
client := ac.Client
|
|
if !grantAllowed(client, "authorization_code") {
|
|
return nil, oerr("unauthorized_client", "client may not use authorization_code", http.StatusBadRequest)
|
|
}
|
|
code := r.PostFormValue("code")
|
|
verifier := r.PostFormValue("code_verifier")
|
|
if code == "" || verifier == "" {
|
|
return nil, oerr("invalid_request", "code and code_verifier required", http.StatusBadRequest)
|
|
}
|
|
jkt, e := s.dpopForClient(r, client)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
|
|
c, ok := s.takeCode(r.Context(), code)
|
|
if !ok {
|
|
// A code that is presented twice means it leaked: end the tokens issued from it.
|
|
if s.cfg.ManagedRefreshTokens {
|
|
if g := s.grants(); g != nil {
|
|
_ = g.RevokeRefreshFamily(r.Context(), codeFamily(code))
|
|
}
|
|
}
|
|
return nil, oerr("invalid_grant", "code expired or invalid", http.StatusBadRequest)
|
|
}
|
|
switch {
|
|
case c.ClientID != client.ClientID:
|
|
return nil, oerr("invalid_grant", "code was issued to another client", http.StatusBadRequest)
|
|
case c.RedirectURI != r.PostFormValue("redirect_uri"):
|
|
return nil, oerr("invalid_grant", "redirect_uri mismatch", http.StatusBadRequest)
|
|
case !validatePKCESHA256(c.CodeChallenge, verifier):
|
|
return nil, oerr("invalid_grant", "code_verifier invalid", http.StatusBadRequest)
|
|
case c.DPoPJKT != "" && c.DPoPJKT != jkt:
|
|
return nil, oerr("invalid_dpop_proof", "the DPoP key does not match dpop_jkt of the authorization request", http.StatusBadRequest)
|
|
}
|
|
|
|
g := &tokenGrant{
|
|
Client: client, UserID: c.UserID, Scopes: c.Scopes, Resource: c.Resource, Nonce: c.Nonce,
|
|
AuthTime: c.AuthTime, ACR: c.ACR, AMR: c.AMR, SID: c.SessionID, Claims: c.Claims, DPoPJKT: jkt,
|
|
IDToken: true, FamilyID: codeFamily(code),
|
|
IssueRefresh: refreshAllowed(client, c.Scopes),
|
|
}
|
|
if c.UserID == 0 { // saved by an earlier version: the code carries the session itself
|
|
g.LegacyAccess, g.LegacyRefresh = c.SessionToken, c.RefreshToken
|
|
g.IssueRefresh = false
|
|
}
|
|
return s.mintTokens(r.Context(), g)
|
|
}
|
|
|
|
// codeFamily derives the refresh family of the tokens issued from a code.
|
|
func codeFamily(code string) string { return "c" + hashToken(code)[:30] }
|
|
|
|
// refreshAllowed reports whether a managed refresh token is issued for the grant.
|
|
func refreshAllowed(c *OAuthServerClient, scopes []string) bool {
|
|
return oauthSliceContains(scopes, "offline_access") || grantAllowed(c, "refresh_token")
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// RFC 6749 §4.4 — Client credentials grant
|
|
// --------------------------------------------------------------------------
|
|
|
|
func (s *OAuthServer) handleClientCredentialsGrant(r *http.Request) (map[string]any, *oauthError) {
|
|
if s.auth == nil {
|
|
return nil, oerr("unsupported_grant_type", "client_credentials requires a local user store", http.StatusBadRequest)
|
|
}
|
|
ac, e := s.requireClient(r)
|
|
if e != nil {
|
|
e.WWWAuth = `Basic realm="oauth"`
|
|
return nil, e
|
|
}
|
|
client := ac.Client
|
|
if ac.Method == "none" {
|
|
return nil, invalidClient("client_credentials requires a confidential client", true)
|
|
}
|
|
if !oauthSliceContains(client.GrantTypes, "client_credentials") {
|
|
return nil, oerr("unauthorized_client", "client is not authorized for client_credentials", http.StatusBadRequest)
|
|
}
|
|
|
|
requested := strings.Fields(r.PostFormValue("scope"))
|
|
effective := client.AllowedScopes
|
|
if len(requested) > 0 {
|
|
effective = nil
|
|
for _, sc := range requested {
|
|
if oauthSliceContains(client.AllowedScopes, sc) {
|
|
effective = append(effective, sc)
|
|
}
|
|
}
|
|
if len(effective) == 0 {
|
|
return nil, oerr("invalid_scope", "no requested scope is allowed for this client", http.StatusBadRequest)
|
|
}
|
|
}
|
|
jkt, e := s.dpopForClient(r, client)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
|
|
// client_credentials tokens have no end user, but the rest of the stack (RLS-scoping
|
|
// hooks, introspection) expects every access token to resolve to a user_sessions row
|
|
// with a user_id. Represent the client as a deterministic synthetic "service account"
|
|
// user so the existing get-or-create/create-session/introspection pipeline handles it
|
|
// unchanged — no new tables or code paths required.
|
|
userID, err := s.auth.oauth2GetOrCreateUser(r.Context(), &UserContext{
|
|
UserName: "client:" + client.ClientID,
|
|
Email: "oauth-client-" + client.ClientID + "@service.internal",
|
|
RemoteID: client.ClientID,
|
|
Roles: effective,
|
|
}, "oauth2_client")
|
|
if err != nil {
|
|
return nil, serverErr()
|
|
}
|
|
// No refresh token per RFC 6749 §4.4.3, and no id_token — client_credentials has no
|
|
// end-user subject to represent in OIDC terms.
|
|
var resource []string
|
|
if res := r.PostForm["resource"]; len(res) > 0 {
|
|
resource = res
|
|
}
|
|
return s.mintTokens(r.Context(), &tokenGrant{
|
|
Client: client, UserID: userID, Scopes: effective, Resource: resource, DPoPJKT: jkt,
|
|
})
|
|
}
|