// OpenID Connect token endpoint (19.4 SC-2, D-14, D-17), ported from the // PHP provider's TokenController: grant check, client authentication // (Basic first, urldecoded, then the form), then code redemption in one // transaction that burns the code before any other check (T-19.4-03). The // answer carries an opaque access token and an id_token signed by the // active key. package wristband import ( "context" "encoding/base64" "net/http" "strings" "git.golem15.com/golem15/summercms/modules/diary" ) // moduleLog is wristband's logger: records carry component=wristband and follow // log.components.wristband (diary). var moduleLog = diary.For("wristband") const ( oidcBasicRealm = `Basic realm="oauth"` msgClientAuthFailed = "Client authentication failed." msgCodeParamsMissing = "code, redirect_uri and code_verifier are required." msgInvalidGrant = "The authorization code is invalid, expired, already used, or does not match this request." msgUnsupportedGrant = "Only the authorization_code grant is supported." msgUnsupportedGrantRefresh = "Only the authorization_code and refresh_token grants are supported." msgRefreshNotAllowed = "The client is not allowed to use the refresh_token grant." msgRefreshMissing = "refresh_token is required." msgInvalidRefresh = "The refresh token is invalid, expired, revoked, or does not belong to this client." msgScopeExceeds = "The requested scope exceeds the scope granted by the resource owner." ) // dummySecretHash is compared against when the client is unknown, so the // unknown and the wrong-secret cases cost the same (T-19.4-06). var dummySecretHash = strings.Repeat("0", 64) type oidcTokenBody struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int64 `json:"expires_in"` IDToken string `json:"id_token"` RefreshToken string `json:"refresh_token,omitempty"` Scope string `json:"scope"` } type oidcErrorBody struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` } // oidcNoStore is the header set of every token answer, errors included. // Symfony rewrites the PHP provider's bare no-store to "no-store, private". func oidcNoStore() map[string]string { return map[string]string{"Cache-Control": "no-store, private", "Pragma": "no-cache"} } func writeOIDCTokenError(w http.ResponseWriter, status int, code, description string) { writeExactJSON(w, status, oidcErrorBody{Error: code, ErrorDescription: description}, oidcNoStore()) } // oidcTokenOutcome is the result of a grant inside its transaction. type oidcTokenOutcome struct { body oidcTokenBody reason string // non-empty: invalid_grant with this log reason scopeInvalid bool // refresh asked for more than the original grant } // oidcToken is the OIDC flavour of Token. func (s *Server) oidcToken(w http.ResponseWriter, r *http.Request) { if !s.oidcReady() { w.WriteHeader(http.StatusInternalServerError) return } _ = r.ParseForm() grant := inputValue(r, "grant_type") refresh := grant == "refresh_token" && s.oidc.RefreshEnabled if grant != "authorization_code" && !refresh { msg := msgUnsupportedGrant if s.oidc.RefreshEnabled { msg = msgUnsupportedGrantRefresh } writeOIDCTokenError(w, http.StatusBadRequest, "unsupported_grant_type", msg) return } ctx := r.Context() clientID, secret := oidcClientCredentials(r) client, err := s.authenticateOIDCClient(ctx, clientID, secret) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if client == nil { s.logTokenFailure(r, "invalid_client", clientID) w.Header().Set("WWW-Authenticate", oidcBasicRealm) writeOIDCTokenError(w, http.StatusUnauthorized, "invalid_client", msgClientAuthFailed) return } if refresh { s.oidcRefreshGrant(w, r, client, clientID) return } code := inputValue(r, "code") redirectURI := inputValue(r, "redirect_uri") verifier := inputValue(r, "code_verifier") if code == "" || redirectURI == "" || verifier == "" { s.logTokenFailure(r, "invalid_request", clientID) writeOIDCTokenError(w, http.StatusBadRequest, "invalid_request", msgCodeParamsMissing) return } key, err := s.keys.ActiveSigningKey(ctx) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } out, err := s.redeemCode(ctx, client, key, code, redirectURI, verifier) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if out.reason != "" { s.logTokenFailure(r, "invalid_grant:"+out.reason, clientID) writeOIDCTokenError(w, http.StatusBadRequest, "invalid_grant", msgInvalidGrant) return } writeExactJSON(w, http.StatusOK, out.body, oidcNoStore()) } // oidcClientCredentials ports TokenController::clientCredentials: an // Authorization header whose scheme is "Basic " in any case, strictly // base64, split on the first colon, both halves urldecoded; an empty id // falls back to the client_id and client_secret form fields. func oidcClientCredentials(r *http.Request) (string, string) { h := r.Header.Get("Authorization") if len(h) >= 6 && strings.EqualFold(h[:6], "basic ") { payload := h[6:] decoded, err := base64.StdEncoding.Strict().DecodeString(payload) if err != nil { decoded, err = base64.RawStdEncoding.Strict().DecodeString(payload) } if err == nil { if id, sec, ok := strings.Cut(string(decoded), ":"); ok { id = phpURLDecode(id) if id != "" { return id, phpURLDecode(sec) } } } } return inputValue(r, "client_id"), inputValue(r, "client_secret") } // authenticateOIDCClient returns the client when the credentials are // valid, nil otherwise. Unknown, inactive, non-OIDC, a secret without the // configured prefix and a wrong secret all fail alike; an unknown client // still runs one constant-time comparison (T-19.4-06). func (s *Server) authenticateOIDCClient(ctx context.Context, clientID, secret string) (*OIDCClientRecord, error) { var client *OIDCClientRecord if clientID != "" { if err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { c, err := tx.OIDCClientByClientID(ctx, clientID) client = c return err }); err != nil { return nil, err } } if client == nil { constantEqual(dummySecretHash, sha256Hex(secret)) return nil, nil } if client.Kind != ClientKindOIDC || !client.IsActive || !s.verifyClientSecret(client, secret) { return nil, nil } return client, nil } // verifyClientSecret ports Client::verifySecret: a non-empty secret with // the configured prefix whose SHA-256 hex matches in constant time. func (s *Server) verifyClientSecret(client *OIDCClientRecord, secret string) bool { if secret == "" || client.SecretHash == nil || *client.SecretHash == "" { return false } if !strings.HasPrefix(secret, s.oidc.ClientSecretPrefix) { return false } return constantEqual(*client.SecretHash, sha256Hex(secret)) } // redeemCode runs the authorization_code grant in one OIDC transaction. // The code is consumed first; a replay of an already consumed code revokes // the tokens issued from it and the revocation commits although the answer // is invalid_grant (T-19.4-03). func (s *Server) redeemCode(ctx context.Context, client *OIDCClientRecord, key SigningKey, code, redirectURI, verifier string) (oidcTokenOutcome, error) { var out oidcTokenOutcome err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { now := s.now() if !strings.HasPrefix(code, s.oidc.CodePrefix) { out.reason = "unknown_or_expired" return nil } hash := sha256Hex(code) rec, err := tx.ConsumeCode(ctx, hash, now) if err != nil { return err } if rec == nil { spent, err := tx.CodeByHash(ctx, hash) if err != nil { return err } if spent != nil && spent.ConsumedAt != nil { if err := tx.RevokeAccessTokensForCode(ctx, spent.ID, now); err != nil { return err } out.reason = "replay" return nil } out.reason = "unknown_or_expired" return nil } switch { case rec.ClientID != client.ClientID: out.reason = "client_mismatch" return nil case rec.RedirectURI != redirectURI: out.reason = "redirect_uri_mismatch" return nil case !verifyOIDCPkce(verifier, rec.CodeChallenge, rec.CodeChallengeMethod): out.reason = "pkce" return nil } identity, ok, err := tx.IdentityClaims(ctx, rec.UserID, rec.Scopes) if err != nil { return err } if !ok { out.reason = "no_user" return nil } access, err := s.mintAccessToken(ctx, tx, client.ClientID, rec.UserID, &rec.ID, rec.Scopes) if err != nil { return err } sub, err := subjectFor(ctx, tx, client, rec.UserID) if err != nil { return err } nonce := "" if rec.Nonce != nil { nonce = *rec.Nonce } idToken, err := signIDToken(key, idTokenInput{ Issuer: s.oidcIssuer(), Subject: sub, Audience: client.ClientID, TTL: s.oidc.IDTokenTTL, Now: now, Nonce: nonce, AccessToken: access.raw, Identity: identity, Allowed: s.oidc.IdentityClaims, }) if err != nil { return err } consent, err := tx.ConsentFor(ctx, rec.UserID, client.ClientID) if err != nil { return err } if consent != nil { if err := tx.TouchConsent(ctx, consent.ID, now); err != nil { return err } } out.body = oidcTokenBody{ AccessToken: access.raw, TokenType: "Bearer", ExpiresIn: int64(s.oidc.AccessTokenTTL.Seconds()), IDToken: idToken, Scope: strings.Join(rec.Scopes, " "), } // D-17: only an OfflineAccess client granted offline_access on a // server with refresh enabled receives a refresh token. if s.oidc.RefreshEnabled && client.OfflineAccess && stringSliceContains(rec.Scopes, "offline_access") { rt, err := s.mintRefreshToken(ctx, tx, client.ClientID, rec.UserID, access.id, rec.Scopes) if err != nil { return err } out.body.RefreshToken = rt } return nil }) return out, err } // oidcRefreshGrant answers grant_type=refresh_token (D-17, server switch // OIDCOptions.RefreshEnabled): only an OfflineAccess client may use it // (unauthorized_client otherwise); the presented token rotates once, a // replay of a rotated token revokes the whole lineage and its access tokens // (T-19.4-12), and the answer carries a fresh id_token without nonce. func (s *Server) oidcRefreshGrant(w http.ResponseWriter, r *http.Request, client *OIDCClientRecord, clientID string) { if !client.OfflineAccess { s.logTokenFailure(r, "unauthorized_client", clientID) writeOIDCTokenError(w, http.StatusBadRequest, "unauthorized_client", msgRefreshNotAllowed) return } raw := inputValue(r, "refresh_token") if raw == "" { s.logTokenFailure(r, "invalid_request", clientID) writeOIDCTokenError(w, http.StatusBadRequest, "invalid_request", msgRefreshMissing) return } ctx := r.Context() key, err := s.keys.ActiveSigningKey(ctx) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } var requested []string if scope := inputValue(r, "scope"); strings.TrimSpace(scope) != "" { requested = parseOIDCScopes(scope) } out, err := s.rotateOIDCRefresh(ctx, client, key, raw, requested) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } switch { case out.scopeInvalid: s.logTokenFailure(r, "invalid_scope", clientID) writeOIDCTokenError(w, http.StatusBadRequest, "invalid_scope", msgScopeExceeds) case out.reason != "": s.logTokenFailure(r, "invalid_grant:"+out.reason, clientID) writeOIDCTokenError(w, http.StatusBadRequest, "invalid_grant", msgInvalidRefresh) default: writeExactJSON(w, http.StatusOK, out.body, oidcNoStore()) } } // rotateOIDCRefresh rotates a refresh token in one OIDC transaction. A // replayed (already rotated) token revokes its lineage, and that revocation // commits although the answer is invalid_grant. func (s *Server) rotateOIDCRefresh(ctx context.Context, client *OIDCClientRecord, key SigningKey, raw string, requested []string) (oidcTokenOutcome, error) { var out oidcTokenOutcome err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { now := s.now() if !strings.HasPrefix(raw, s.oidc.RefreshTokenPrefix) { out.reason = "unknown" return nil } rec, err := tx.RefreshTokenByHashForUpdate(ctx, sha256Hex(raw)) if err != nil { return err } if rec == nil || rec.ClientID != client.ClientID || rec.RevokedAt != nil || !rec.ExpiresAt.After(now) { out.reason = "unknown_or_expired" return nil } if rec.RotatedToID != nil { if err := tx.RevokeRefreshLineage(ctx, rec.ID, now); err != nil { return err } out.reason = "replay" return nil } scopes := rec.Scopes if requested != nil { for _, sc := range requested { if !stringSliceContains(rec.Scopes, sc) { out.scopeInvalid = true return nil } } scopes = requested } identity, ok, err := tx.IdentityClaims(ctx, rec.UserID, scopes) if err != nil { return err } if !ok { out.reason = "no_user" return nil } access, err := s.mintAccessToken(ctx, tx, client.ClientID, rec.UserID, nil, scopes) if err != nil { return err } successor, successorID, err := s.createRefreshToken(ctx, tx, client.ClientID, rec.UserID, access.id, rec.Scopes) if err != nil { return err } if err := tx.MarkRefreshRotated(ctx, rec.ID, successorID); err != nil { return err } sub, err := subjectFor(ctx, tx, client, rec.UserID) if err != nil { return err } idToken, err := signIDToken(key, idTokenInput{ Issuer: s.oidcIssuer(), Subject: sub, Audience: client.ClientID, TTL: s.oidc.IDTokenTTL, Now: now, AccessToken: access.raw, Identity: identity, Allowed: s.oidc.IdentityClaims, }) if err != nil { return err } out.body = oidcTokenBody{ AccessToken: access.raw, TokenType: "Bearer", ExpiresIn: int64(s.oidc.AccessTokenTTL.Seconds()), IDToken: idToken, RefreshToken: successor, Scope: strings.Join(scopes, " "), } return nil }) return out, err } // mintRefreshToken creates a refresh token linked to an access token and // returns it. func (s *Server) mintRefreshToken(ctx context.Context, tx OIDCTx, clientID string, userID, accessTokenID uint, scopes []string) (string, error) { raw, _, err := s.createRefreshToken(ctx, tx, clientID, userID, accessTokenID, scopes) return raw, err } // createRefreshToken stores a refresh token (RefreshTokenPrefix plus // base64url of 32 random bytes, stored hashed, RefreshTokenTTL) and returns // it with its row ID. func (s *Server) createRefreshToken(ctx context.Context, tx OIDCTx, clientID string, userID, accessTokenID uint, scopes []string) (string, uint, error) { body, err := s.randomBytes(32) if err != nil { return "", 0, err } raw := s.oidc.RefreshTokenPrefix + body at := accessTokenID rec := &OIDCRefreshTokenRecord{ TokenHash: sha256Hex(raw), ClientID: clientID, UserID: userID, AccessTokenID: &at, Scopes: scopes, ExpiresAt: s.now().Add(s.oidc.RefreshTokenTTL), } if err := tx.CreateRefreshToken(ctx, rec); err != nil { return "", 0, err } return raw, rec.ID, nil } // mintedAccessToken is a freshly created access token. type mintedAccessToken struct { raw string id uint } // mintAccessToken creates an opaque access token (AccessTokenPrefix plus // base64url of 32 random bytes, stored hashed, AccessTokenTTL). func (s *Server) mintAccessToken(ctx context.Context, tx OIDCTx, clientID string, userID uint, codeID *uint, scopes []string) (mintedAccessToken, error) { raw, err := s.randomBytes(32) if err != nil { return mintedAccessToken{}, err } token := s.oidc.AccessTokenPrefix + raw rec := &OIDCAccessTokenRecord{ TokenHash: sha256Hex(token), ClientID: clientID, UserID: userID, AuthCodeID: codeID, Scopes: scopes, ExpiresAt: s.now().Add(s.oidc.AccessTokenTTL), } if err := tx.CreateAccessToken(ctx, rec); err != nil { return mintedAccessToken{}, err } return mintedAccessToken{raw: token, id: rec.ID}, nil } // verifyOIDCPkce ports PkceVerifier::verify: S256 only, a non-empty // challenge, a verifier of 43 to 128 characters from A-Z a-z 0-9 - . _ ~, // compared in constant time (T-19.4-02). func verifyOIDCPkce(verifier, challenge, method string) bool { if method != "S256" || challenge == "" { return false } if len(verifier) < 43 || len(verifier) > 128 { return false } for i := 0; i < len(verifier); i++ { c := verifier[i] if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~') { return false } } return constantEqual(s256Challenge(verifier), challenge) } // logTokenFailure logs a rejected exchange with the client ID, the client // IP and a fixed reason only; codes, tokens and secrets are never logged // (T-19.4-09). func (s *Server) logTokenFailure(r *http.Request, reason, clientID string) { if clientID == "" { clientID = "(absent)" } moduleLog.Warn("wristband: oidc token exchange rejected", "reason", reason, "client_id", clientID, "ip", clientIP(r)) }