Files
summercms/modules/wristband/oidc_token.go
Jakub Zych e25e73378c feat(19.4-01): OIDC token, keys, subjects and opt-in refresh
- ES256 keys, PKCS#1/SEC1 parsing fallbacks, EC JWKs, mixed RS256/ES256 rings
- Pairwise subjects per sector (SubjectSector, ErrSectorRequired)
- Opt-in refresh_token grant (OIDCOptions.RefreshEnabled): OfflineAccess clients
  only, rotation, lineage revocation on replay, fresh id_token without nonce
- IssueOIDCClientCredentials with the PHP oas_/oas_cs_ shapes
- Smoke tests: token errors, replay revocation, userinfo 401s, mixed key ring,
  pairwise subjects, refresh opt-in
2026-10-19 18:24:45 +02:00

514 lines
17 KiB
Go

// 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"
"log/slog"
"net/http"
"strings"
)
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)"
}
slog.Warn("wristband: oidc token exchange rejected", "reason", reason, "client_id", clientID, "ip", clientIP(r))
}