From e25e73378ce1db0a0b51a73a77af101efdd6e5b0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 19 Oct 2026 18:24:45 +0200 Subject: [PATCH] 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 --- modules/wristband/README.md | 15 +- modules/wristband/oidc_keys.go | 73 +++- modules/wristband/oidc_options.go | 21 + modules/wristband/oidc_subject.go | 31 +- modules/wristband/oidc_token.go | 193 ++++++++- modules/wristband/oidc_token_smoke_test.go | 430 +++++++++++++++++++++ 6 files changed, 735 insertions(+), 28 deletions(-) create mode 100644 modules/wristband/oidc_token_smoke_test.go diff --git a/modules/wristband/README.md b/modules/wristband/README.md index ebbcf461..ba1be1c0 100644 --- a/modules/wristband/README.md +++ b/modules/wristband/README.md @@ -30,6 +30,9 @@ Use cases: - Connected-app revocation: `wristband.Server.Revoke` kills an access token and the refresh lineage attached to it. - OpenID Connect provider flavour: `wristband.NewOIDCServer` builds a second `wristband.Server` on the same engine with discovery (`wristband.Server.Discovery`), a JWKS endpoint (`wristband.Server.JWKS`), authorization with a resumable request and consent (`wristband.Server.Authorize`, `wristband.Server.Resume`), a token endpoint that adds an `id_token` signed by the active key, and userinfo (`wristband.Server.UserInfo`). Storage arrives through `wristband.OIDCBackend` and `wristband.OIDCTx`, signing keys through `wristband.KeyStore`, and pages and the signed-in user through `wristband.OIDCHost`. The MCP flavour and its interfaces are unchanged. - OIDC authorization follows the PHP provider's contract: validation in its order with its messages; unknown clients, unregistered redirect URIs and inactive clients rendered by the host (never redirected); error redirects encoded like PHP `http_build_query` without `iss`; an anonymous user sent to the login URL with only `redirect=`; silent resume when consent already covers the request, incremental consent listing only new scopes, a one-shot consent token bound to the user, deny, and `wristband.Server.SwitchAccount`. `wristband.Server.ConsentRequest` and `wristband.Server.DecideConsent` give a host's own consent page the same logic as JSON operations. Dev clients may vary the port of a loopback redirect URI. +- OIDC token and userinfo follow the PHP contract: client authentication by Basic credentials (urldecoded) or form fields with one uniform `invalid_client` answer and `Basic realm="oauth"`, codes burned before any other check so a replayed code revokes the tokens issued from it, `{error, error_description}` bodies with `no-store, private` and `Pragma: no-cache`, and one uniform userinfo 401 with an empty body. The `id_token` carries `iss`, `sub`, `aud` as a string, `exp`, `iat`, `nonce` when sent, `at_hash` and only allowlisted identity claims (no `auth_time`, no `azp`). +- Keys and subjects: RS256 and ES256 keys (`wristband.GenerateSigningKey`), a mixed ring during a switch with discovery advertising what the ring holds, retired keys published through their grace window; public subjects by default and pairwise subjects per sector (`wristband.SubjectSector`, `wristband.ErrSectorRequired`), always random UUIDv4 values stored per user and sector. +- Opt-in refresh for OIDC (`wristband.OIDCOptions.RefreshEnabled`, off by default): only a client with `wristband.OIDCClientRecord.OfflineAccess` granted `offline_access` receives a rotating `oas_rt_` refresh token; a refresh returns a fresh `id_token` without `nonce`, and replaying a rotated token revokes the lineage and its access tokens. - Client kinds: `wristband.ClientRecord.Kind` is `wristband.ClientKindMCP` (or empty) for MCP clients and `wristband.ClientKindOIDC` for administrator-registered OIDC clients. Each flavour accepts only its own kind at authorize, token and userinfo, and the MCP flavour keeps refusing the `openid` scope, so a dynamically registered client never receives identity scopes or an `id_token`. - Secret handling: client secrets, codes and refresh tokens are random base64url strings, persisted only as SHA-256 hashes and compared in constant time. `wristband.IssueClientCredentials` and `wristband.RejectRedirectURI` expose the same issuing and validation rules to operator tooling that creates clients outside registration. @@ -135,10 +138,12 @@ func Approve(ctx context.Context, w http.ResponseWriter, srv *wristband.Server, | `wristband.SigningKey` | Kid, algorithm and signer of the active key. | | `wristband.PublicKey` | Kid, algorithm and public key of a published key. | | `wristband.AlgRS256` | The RS256 algorithm name; `wristband.AlgES256` names ES256. | -| `wristband.GenerateSigningKey` | Creates a key and returns its kid, PKCS#8 private PEM and PKIX public PEM. | -| `wristband.ParsePrivateKeyPEM` | Parses a private key PEM into a signer. | -| `wristband.ParsePublicKeyPEM` | Parses a PKIX public key PEM. | -| `wristband.SubjectSector` | Returns a client's subject sector (empty for public clients). | +| `wristband.GenerateSigningKey` | Creates an RS256 (RSA 2048) or ES256 (P-256) key and returns its kid, PKCS#8 private PEM and PKIX public PEM. | +| `wristband.ParsePrivateKeyPEM` | Parses a PKCS#8, PKCS#1 RSA or SEC1 EC private key PEM into a signer. | +| `wristband.ParsePublicKeyPEM` | Parses a PKIX RSA or P-256 public key PEM. | +| `wristband.SubjectSector` | Returns a client's subject sector: empty for public clients, the sector identifier or the single redirect host for pairwise clients. | +| `wristband.ErrSectorRequired` | A pairwise client's redirect URIs span several hosts and no sector identifier is set. | +| `wristband.IssueOIDCClientCredentials` | Mints an `oas_` client ID, a one-time `oas_cs_` secret and its SHA-256 hex hash. | | `wristband.SubjectTypePublic` | Subject type `public`; `wristband.SubjectTypePairwise` is `pairwise`. | wristband reads no config keys or environment variables; the application passes a `wristband.Options` value. `wristband.DefaultOptions` sets: @@ -163,7 +168,7 @@ Always set `wristband.Options.Issuer` and `wristband.Options.Resource` for your - SummerCMS modules: none. - Third-party: `github.com/golang-jwt/jwt/v5` (id_token signing). -- Standard library: `bytes`, `context`, `crypto`, `crypto/rand`, `crypto/rsa`, `crypto/sha256`, `crypto/subtle`, `crypto/x509`, `encoding/base64`, `encoding/hex`, `encoding/json`, `encoding/pem`, `errors`, `fmt`, `log/slog`, `math/big`, `net/http`, `net/url`, `slices`, `strconv`, `strings`, `time`, `unicode/utf8`, `uuid`. +- Standard library: `bytes`, `context`, `crypto`, `crypto/ecdsa`, `crypto/elliptic`, `crypto/rand`, `crypto/rsa`, `crypto/sha256`, `crypto/subtle`, `crypto/x509`, `encoding/base64`, `encoding/hex`, `encoding/json`, `encoding/pem`, `errors`, `fmt`, `log/slog`, `math/big`, `net/http`, `net/url`, `slices`, `strconv`, `strings`, `time`, `unicode/utf8`, `uuid`. ## Testing diff --git a/modules/wristband/oidc_keys.go b/modules/wristband/oidc_keys.go index 854647b5..f024786b 100644 --- a/modules/wristband/oidc_keys.go +++ b/modules/wristband/oidc_keys.go @@ -3,6 +3,8 @@ package wristband import ( "context" "crypto" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -54,7 +56,8 @@ type KeyStore interface { PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error) } -// GenerateSigningKey creates a new key for alg: RSA 2048 for RS256. It +// GenerateSigningKey creates a new key for alg: RSA 2048 for RS256, P-256 +// for ES256; any other algorithm is refused. It // returns a kid of 8 random bytes in lowercase hex (16 characters, like the // PHP key ring), the private key as PKCS#8 PEM and the public key as PKIX // PEM. @@ -63,6 +66,8 @@ func GenerateSigningKey(alg string) (kid string, privatePEM, publicPEM []byte, e switch alg { case AlgRS256: priv, err = rsa.GenerateKey(rand.Reader, 2048) + case AlgES256: + priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) default: return "", nil, nil, fmt.Errorf("wristband: unsupported signing algorithm %q", alg) } @@ -86,27 +91,35 @@ func GenerateSigningKey(alg string) (kid string, privatePEM, publicPEM []byte, e return hex.EncodeToString(raw), privatePEM, publicPEM, nil } -// ParsePrivateKeyPEM parses a PKCS#8 private key. +// ParsePrivateKeyPEM parses a private key: PKCS#8 first, then PKCS#1 RSA, +// then SEC1 EC. It returns an *rsa.PrivateKey or a P-256 +// *ecdsa.PrivateKey; errors never contain key material (T-19.4-09). func ParsePrivateKeyPEM(data []byte) (crypto.Signer, error) { block, _ := pem.Decode(data) if block == nil { return nil, errors.New("wristband: no PEM block in private key") } - key, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return nil, errors.New("wristband: unreadable private key") + var key any + var err error + if key, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil { + if key, err = x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + if key, err = x509.ParseECPrivateKey(block.Bytes); err != nil { + return nil, errors.New("wristband: unreadable private key") + } + } } - signer, ok := key.(crypto.Signer) - if !ok { - return nil, errors.New("wristband: unsupported private key type") + switch k := key.(type) { + case *rsa.PrivateKey: + return k, nil + case *ecdsa.PrivateKey: + if k.Curve == elliptic.P256() { + return k, nil + } } - if _, ok := signer.(*rsa.PrivateKey); !ok { - return nil, errors.New("wristband: unsupported private key type") - } - return signer, nil + return nil, errors.New("wristband: unsupported private key type") } -// ParsePublicKeyPEM parses a PKIX public key. +// ParsePublicKeyPEM parses a PKIX public key (RSA or P-256 EC). func ParsePublicKeyPEM(data []byte) (crypto.PublicKey, error) { block, _ := pem.Decode(data) if block == nil { @@ -116,9 +129,13 @@ func ParsePublicKeyPEM(data []byte) (crypto.PublicKey, error) { if err != nil { return nil, errors.New("wristband: unreadable public key") } - switch key.(type) { + switch k := key.(type) { case *rsa.PublicKey: - return key, nil + return k, nil + case *ecdsa.PublicKey: + if k.Curve == elliptic.P256() { + return k, nil + } } return nil, errors.New("wristband: unsupported public key type") } @@ -139,7 +156,10 @@ type jwkDocument struct { } // jwkFor encodes a published key with the standard library (RFC 7518 -// §6.3.1: n and e are minimal big-endian bytes). Every JWK carries use sig +// §6.3.1: n and e are minimal big-endian bytes; §6.2.1: x and y are the +// fixed-length halves of the uncompressed P-256 point from +// ecdsa.PublicKey.Bytes, so no padding bug is possible). Every JWK carries +// use sig // and its alg; an Alg that does not match the key type is refused // (T-19.4-08). func jwkFor(k PublicKey) (jwkDocument, error) { @@ -153,6 +173,18 @@ func jwkFor(k PublicKey) (jwkDocument, error) { Kty: "RSA", Use: "sig", Alg: k.Alg, Kid: k.Kid, N: b64(key.N.Bytes()), E: b64(big.NewInt(int64(key.E)).Bytes()), }, nil + case *ecdsa.PublicKey: + if k.Alg != AlgES256 || key.Curve != elliptic.P256() { + return jwkDocument{}, errors.New("wristband: EC key that is not P-256 with ES256") + } + raw, err := key.Bytes() + if err != nil || len(raw) != 65 { + return jwkDocument{}, errors.New("wristband: unsupported EC key") + } + return jwkDocument{ + Kty: "EC", Use: "sig", Alg: k.Alg, Kid: k.Kid, + Crv: "P-256", X: b64(raw[1:33]), Y: b64(raw[33:65]), + }, nil } return jwkDocument{}, errors.New("wristband: unsupported public key type") } @@ -178,8 +210,9 @@ var protocolClaims = map[string]bool{ } // signIDToken signs an id_token with golang-jwt map claims, so aud stays a -// JSON string (the package global MarshalSingleStringAsArray only affects -// jwt.ClaimStrings and is never touched). Allowlisted identity claims are +// JSON string as in the PHP provider (jwt.RegisteredClaims would marshal it +// as an array under a package-global default that bouncer shares and that +// wristband never changes). Allowlisted identity claims are // written first, then the protocol claims, then sub once more, so a user // attribute can never overwrite a protocol claim (T-19.4-11). func signIDToken(key SigningKey, in idTokenInput) (string, error) { @@ -226,6 +259,10 @@ func signingMethodFor(key SigningKey) (jwt.SigningMethod, error) { if _, ok := key.Signer.(*rsa.PrivateKey); ok { return jwt.SigningMethodRS256, nil } + case AlgES256: + if k, ok := key.Signer.(*ecdsa.PrivateKey); ok && k.Curve == elliptic.P256() { + return jwt.SigningMethodES256, nil + } } return nil, errors.New("wristband: signing key does not match its algorithm") } diff --git a/modules/wristband/oidc_options.go b/modules/wristband/oidc_options.go index 2bdcd3f1..f2394b46 100644 --- a/modules/wristband/oidc_options.go +++ b/modules/wristband/oidc_options.go @@ -8,6 +8,8 @@ package wristband import ( + "crypto/rand" + "encoding/hex" "errors" "net/http" "time" @@ -231,3 +233,22 @@ type ConsentView struct { // client's scopes and is asked only for the new ones. Incremental bool } + +// IssueOIDCClientCredentials mints credentials for a new OIDC client the +// way the PHP provider's Client model does: a client ID of +// OIDCOptions.ClientIDPrefix plus 12 random bytes in hex, a secret of +// OIDCOptions.ClientSecretPrefix plus base64url of 32 random bytes, and the +// SHA-256 hex digest to store. The secret is returned once; only secretHash +// is meant to be persisted. +func IssueOIDCClientCredentials(o OIDCOptions) (clientID, secret, secretHash string, err error) { + raw := make([]byte, 12) + if _, err := rand.Read(raw); err != nil { + return "", "", "", err + } + body, err := randomBase64URL(32) + if err != nil { + return "", "", "", err + } + secret = o.ClientSecretPrefix + body + return o.ClientIDPrefix + hex.EncodeToString(raw), secret, sha256Hex(secret), nil +} diff --git a/modules/wristband/oidc_subject.go b/modules/wristband/oidc_subject.go index 9e1475b6..6996e69e 100644 --- a/modules/wristband/oidc_subject.go +++ b/modules/wristband/oidc_subject.go @@ -3,6 +3,8 @@ package wristband import ( "context" "errors" + "net/url" + "strings" "uuid" ) @@ -14,8 +16,15 @@ const ( SubjectTypePairwise = "pairwise" ) +// ErrSectorRequired is returned by SubjectSector for a pairwise client +// without a sector identifier whose redirect URIs do not share one host: +// an administrator must set OIDCClientRecord.SectorIdentifier (D-15). +var ErrSectorRequired = errors.New("wristband: pairwise client needs a sector identifier") + // SubjectSector returns the subject sector of a client: "" for a public -// client. +// client; for a pairwise client its trimmed SectorIdentifier when set, +// otherwise the host of its redirect URIs when they all share one host, +// otherwise ErrSectorRequired. No remote sector_identifier_uri is fetched. func SubjectSector(c *OIDCClientRecord) (string, error) { if c == nil { return "", errors.New("wristband: no client") @@ -23,6 +32,26 @@ func SubjectSector(c *OIDCClientRecord) (string, error) { switch c.SubjectType { case "", SubjectTypePublic: return "", nil + case SubjectTypePairwise: + if sector := strings.TrimSpace(c.SectorIdentifier); sector != "" { + return sector, nil + } + host := "" + for _, uri := range c.RedirectURIs { + u, err := url.Parse(uri) + if err != nil || u.Hostname() == "" { + return "", ErrSectorRequired + } + h := strings.ToLower(u.Hostname()) + if host != "" && h != host { + return "", ErrSectorRequired + } + host = h + } + if host == "" { + return "", ErrSectorRequired + } + return host, nil } return "", errors.New("wristband: unknown subject type") } diff --git a/modules/wristband/oidc_token.go b/modules/wristband/oidc_token.go index 83283775..90cabb71 100644 --- a/modules/wristband/oidc_token.go +++ b/modules/wristband/oidc_token.go @@ -20,6 +20,12 @@ const ( 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 @@ -52,8 +58,9 @@ func writeOIDCTokenError(w http.ResponseWriter, status int, code, description st // 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 + 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. @@ -64,8 +71,13 @@ func (s *Server) oidcToken(w http.ResponseWriter, r *http.Request) { } _ = r.ParseForm() grant := inputValue(r, "grant_type") - if grant != "authorization_code" { - writeOIDCTokenError(w, http.StatusBadRequest, "unsupported_grant_type", msgUnsupportedGrant) + 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 } @@ -82,6 +94,10 @@ func (s *Server) oidcToken(w http.ResponseWriter, r *http.Request) { 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") @@ -265,11 +281,180 @@ func (s *Server) redeemCode(ctx context.Context, client *OIDCClientRecord, key S 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 diff --git a/modules/wristband/oidc_token_smoke_test.go b/modules/wristband/oidc_token_smoke_test.go new file mode 100644 index 00000000..da43d058 --- /dev/null +++ b/modules/wristband/oidc_token_smoke_test.go @@ -0,0 +1,430 @@ +package wristband + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// obtainCode runs authorize, resume and (when asked) consent for userID at +// client and returns the authorization code. +func (f *oidcFixture) obtainCode(t *testing.T, client *OIDCClientRecord, userID uint, scope string) string { + t.Helper() + f.host.signIn(userID) + q := oidcAuthorizeParams(f, func(q url.Values) { + q.Set("client_id", client.ClientID) + q.Set("redirect_uri", client.RedirectURIs[0]) + q.Set("scope", scope) + }) + rec := f.startAndResume(t, q) + if rec.Code == http.StatusOK { + view := f.host.lastView(t) + rec = f.post("/oauth/authorize", url.Values{"request_id": {view.RequestID}, "consent_token": {view.ConsentToken}, "decision": {"allow"}}) + } + u, err := url.Parse(rec.Header().Get("Location")) + if err != nil || u.Query().Get("code") == "" { + t.Fatalf("no code: %d %q %s", rec.Code, rec.Header().Get("Location"), rec.Body.String()) + } + return u.Query().Get("code") +} + +// tokenCall posts form to the token endpoint, with Basic credentials when +// id is not empty. +func (f *oidcFixture) tokenCall(form url.Values, id, secret string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if id != "" { + req.SetBasicAuth(url.QueryEscape(id), url.QueryEscape(secret)) + } + rec := httptest.NewRecorder() + f.mux().ServeHTTP(rec, req) + return rec +} + +func (f *oidcFixture) exchange(client *OIDCClientRecord, secret, code, verifier string) *httptest.ResponseRecorder { + return f.tokenCall(url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {client.RedirectURIs[0]}, + "code_verifier": {verifier}, + }, client.ClientID, secret) +} + +type tokenAnswer struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +func decodeToken(t *testing.T, rec *httptest.ResponseRecorder) tokenAnswer { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("token = %d %s", rec.Code, rec.Body.String()) + } + var a tokenAnswer + if err := json.Unmarshal(rec.Body.Bytes(), &a); err != nil { + t.Fatal(err) + } + return a +} + +func (f *oidcFixture) userinfo(token string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/oauth/userinfo", nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + f.mux().ServeHTTP(rec, req) + return rec +} + +func assertNoStore(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + if rec.Header().Get("Cache-Control") != "no-store, private" || rec.Header().Get("Pragma") != "no-cache" { + t.Fatalf("headers = %v", rec.Header()) + } +} + +func assertUserInfo401(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + if rec.Code != http.StatusUnauthorized || rec.Body.Len() != 0 || + rec.Header().Get("WWW-Authenticate") != `Bearer error="invalid_token"` || + rec.Header().Get("Cache-Control") != "no-store, private" { + t.Fatalf("userinfo = %d %q %v", rec.Code, rec.Body.String(), rec.Header()) + } +} + +// parseIDToken verifies an id_token against the fixture's served JWKS. +func (f *oidcFixture) parseIDToken(t *testing.T, raw string) jwt.MapClaims { + t.Helper() + var jwks struct { + Keys []map[string]any `json:"keys"` + } + if err := json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks); err != nil { + t.Fatal(err) + } + claims := jwt.MapClaims{} + _, err := jwt.ParseWithClaims(raw, claims, func(tk *jwt.Token) (any, error) { + for _, k := range jwks.Keys { + if k["kid"] != tk.Header["kid"] { + continue + } + switch k["kty"] { + case "RSA": + return rsaKeyFromJWK(t, k), nil + case "EC": + x, _ := base64.RawURLEncoding.DecodeString(k["x"].(string)) + y, _ := base64.RawURLEncoding.DecodeString(k["y"].(string)) + return ecdsa.ParseUncompressedPublicKey(elliptic.P256(), append(append([]byte{4}, x...), y...)) + } + } + return nil, fmt.Errorf("kid %v not published", tk.Header["kid"]) + }, jwt.WithValidMethods([]string{"RS256", "ES256"}), jwt.WithTimeFunc(f.srv.now)) + if err != nil { + t.Fatalf("id_token does not verify: %v", err) + } + return claims +} + +func TestOIDCTokenErrorsSmoke(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + + // Wrong secret: 401 with the lowercase realm. + rec := f.tokenCall(url.Values{"grant_type": {"authorization_code"}, "code": {"oas_ac_x"}, "redirect_uri": {testOIDCRedirect}, "code_verifier": {oidcTestVerifier}}, f.client.ClientID, "oas_cs_wrong") + if rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") != `Basic realm="oauth"` || + rec.Body.String() != `{"error":"invalid_client","error_description":"Client authentication failed."}` { + t.Fatalf("wrong secret = %d %v %s", rec.Code, rec.Header(), rec.Body.String()) + } + assertNoStore(t, rec) + + // Unknown client and a secret without the prefix fail the same way. + plain := f.backend.addClient(OIDCClientRecord{ClientID: "oas_plain", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, "plain-secret") + for _, creds := range [][2]string{{"oas_unknown", testOIDCSecret}, {plain.ClientID, "plain-secret"}} { + if rec := f.tokenCall(url.Values{"grant_type": {"authorization_code"}}, creds[0], creds[1]); rec.Code != http.StatusUnauthorized { + t.Fatalf("%v = %d", creds, rec.Code) + } + } + + // Unsupported grant: 400 with no-store, private and Pragma. + rec = f.tokenCall(url.Values{"grant_type": {"refresh_token"}}, "", "") + if rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"unsupported_grant_type","error_description":"Only the authorization_code grant is supported."}` { + t.Fatalf("unsupported grant = %d %s", rec.Code, rec.Body.String()) + } + assertNoStore(t, rec) + + // Missing parameters. + rec = f.tokenCall(url.Values{"grant_type": {"authorization_code"}}, f.client.ClientID, f.clientSecret) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "code, redirect_uri and code_verifier are required.") { + t.Fatalf("missing params = %d %s", rec.Code, rec.Body.String()) + } + + // Basic credentials with percent-encoded characters authenticate. + odd := "oas_cs_a+b/c=d~e%f" + oddClient := f.backend.addClient(OIDCClientRecord{ClientID: "oas_odd", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, Scopes: []string{"openid"}, IsActive: true}, odd) + code := f.obtainCode(t, oddClient, 7, "openid") + decodeToken(t, f.exchange(oddClient, odd, code, oidcTestVerifier)) + + // The same secret in the form works too. + code = f.obtainCode(t, oddClient, 7, "openid") + decodeToken(t, f.tokenCall(url.Values{ + "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testOIDCRedirect}, + "code_verifier": {oidcTestVerifier}, "client_id": {oddClient.ClientID}, "client_secret": {odd}, + }, "", "")) + + // An inactive client cannot exchange. + code = f.obtainCode(t, f.client, 7, "openid") + f.backend.clients[0].IsActive = false + if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusUnauthorized { + t.Fatalf("inactive client = %d", rec.Code) + } +} + +func TestOIDCReplayRevokesSmoke(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + code := f.obtainCode(t, f.client, 7, "openid profile") + first := decodeToken(t, f.exchange(f.client, f.clientSecret, code, oidcTestVerifier)) + if rec := f.userinfo(first.AccessToken); rec.Code != http.StatusOK { + t.Fatalf("userinfo before replay = %d", rec.Code) + } + + rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier) + if rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"invalid_grant","error_description":"The authorization code is invalid, expired, already used, or does not match this request."}` { + t.Fatalf("replay = %d %s", rec.Code, rec.Body.String()) + } + assertUserInfo401(t, f.userinfo(first.AccessToken)) + + // Burn then check: a wrong verifier burns the code. + code = f.obtainCode(t, f.client, 7, "openid profile") + if rec := f.exchange(f.client, f.clientSecret, code, strings.Repeat("x", 43)); rec.Code != http.StatusBadRequest { + t.Fatalf("wrong verifier = %d", rec.Code) + } + if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest { + t.Fatalf("burned code = %d", rec.Code) + } + + // An expired code is invalid. + code = f.obtainCode(t, f.client, 7, "openid profile") + base := time.Now() + f.srv.now = func() time.Time { return base.Add(2 * time.Minute) } + if rec := f.exchange(f.client, f.clientSecret, code, oidcTestVerifier); rec.Code != http.StatusBadRequest { + t.Fatalf("expired code = %d", rec.Code) + } +} + +func TestOIDCUserInfoSmoke(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + assertUserInfo401(t, f.userinfo("")) + assertUserInfo401(t, f.userinfo("not-a-token")) + assertUserInfo401(t, f.userinfo("oas_at_unknown")) + + // Without the email scope there are no email claims. + tok := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid profile"), oidcTestVerifier)) + rec := f.userinfo(tok.AccessToken) + var info map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + if info["name"] != "Ada Example" || info["email"] != nil || info["email_verified"] != nil || rec.Header().Get("Cache-Control") != "no-store, private" { + t.Fatalf("userinfo = %s", rec.Body.String()) + } + if f.backend.accessTokens()[0].LastUsedAt == nil { + t.Fatal("userinfo must touch LastUsedAt") + } + + // Revoked. + now := time.Now() + f.backend.tokens[0].RevokedAt = &now + assertUserInfo401(t, f.userinfo(tok.AccessToken)) + + // Expired. + tok = decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid profile"), oidcTestVerifier)) + f.srv.now = func() time.Time { return now.Add(2 * time.Hour) } + assertUserInfo401(t, f.userinfo(tok.AccessToken)) + f.srv.now = time.Now + + // Gone user. + delete(f.backend.identities, 7) + assertUserInfo401(t, f.userinfo(tok.AccessToken)) +} + +func TestMixedKeyRingSmoke(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + before := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)) + + // Rotate to ES256; the RS256 key stays published inside the grace window. + f.keys.rotate(t, AlgES256, time.Now()) + var jwks struct { + Keys []map[string]any `json:"keys"` + } + if err := json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks); err != nil { + t.Fatal(err) + } + if len(jwks.Keys) != 2 || jwks.Keys[0]["kty"] != "RSA" || jwks.Keys[1]["kty"] != "EC" || jwks.Keys[1]["crv"] != "P-256" || jwks.Keys[1]["alg"] != "ES256" { + t.Fatalf("jwks = %v", jwks.Keys) + } + var doc map[string]any + if err := json.Unmarshal(f.get("/.well-known/openid-configuration").Body.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if fmt.Sprint(doc["id_token_signing_alg_values_supported"]) != "[RS256 ES256]" { + t.Fatalf("algs = %v", doc["id_token_signing_alg_values_supported"]) + } + + // A token signed before rotation still verifies; a new one is ES256. + f.parseIDToken(t, before.IDToken) + after := decodeToken(t, f.exchange(f.client, f.clientSecret, f.obtainCode(t, f.client, 7, "openid"), oidcTestVerifier)) + tok, _, err := jwt.NewParser().ParseUnverified(after.IDToken, jwt.MapClaims{}) + if err != nil || tok.Header["alg"] != "ES256" { + t.Fatalf("new id_token alg = %v (%v)", tok.Header["alg"], err) + } + f.parseIDToken(t, after.IDToken) + + // After the grace window only the EC key is published. + f.srv.now = func() time.Time { return time.Now().Add(25 * time.Hour) } + if err := json.Unmarshal(f.get("/oauth/jwks").Body.Bytes(), &jwks); err != nil { + t.Fatal(err) + } + if len(jwks.Keys) != 1 || jwks.Keys[0]["kty"] != "EC" { + t.Fatalf("jwks after grace = %v", jwks.Keys) + } + + // The key helpers refuse what they do not support. + if _, _, _, err := GenerateSigningKey("HS256"); err == nil { + t.Fatal("GenerateSigningKey must refuse HS256") + } + _, ecPriv, _, _ := GenerateSigningKey(AlgES256) + signer, _ := ParsePrivateKeyPEM(ecPriv) + if _, err := signIDToken(SigningKey{Kid: "k", Alg: AlgRS256, Signer: signer}, idTokenInput{}); err == nil { + t.Fatal("an EC key labelled RS256 must be refused") + } +} + +func TestPairwiseSubjectSmoke(t *testing.T) { + f := newOIDCFixture(t, testOIDCIssuer, nil) + a := f.backend.addClient(OIDCClientRecord{ClientID: "oas_a", Kind: ClientKindOIDC, RedirectURIs: []string{"https://a.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret) + b := f.backend.addClient(OIDCClientRecord{ClientID: "oas_b", Kind: ClientKindOIDC, RedirectURIs: []string{"https://b.example.test/cb"}, Scopes: []string{"openid"}, IsActive: true, SubjectType: SubjectTypePairwise}, testOIDCSecret) + + subOf := func(c *OIDCClientRecord) (string, string) { + tok := decodeToken(t, f.exchange(c, testOIDCSecret, f.obtainCode(t, c, 7, "openid"), oidcTestVerifier)) + claims := f.parseIDToken(t, tok.IDToken) + var info map[string]any + if err := json.Unmarshal(f.userinfo(tok.AccessToken).Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + return claims["sub"].(string), info["sub"].(string) + } + subA, infoA := subOf(a) + subB, infoB := subOf(b) + subA2, _ := subOf(a) + subPublic, _ := subOf(f.client) + if subA == subB || subA == subPublic || subB == subPublic { + t.Fatalf("pairwise subs must differ: a=%s b=%s public=%s", subA, subB, subPublic) + } + if subA != subA2 || subA != infoA || subB != infoB { + t.Fatalf("subs must be stable and match userinfo: %s %s %s / %s %s", subA, subA2, infoA, subB, infoB) + } + + mixed := &OIDCClientRecord{SubjectType: SubjectTypePairwise, RedirectURIs: []string{"https://a.example.test/cb", "https://b.example.test/cb"}} + if _, err := SubjectSector(mixed); !errors.Is(err, ErrSectorRequired) { + t.Fatalf("mixed hosts = %v", err) + } + mixed.SectorIdentifier = " sector.example.test " + if s, err := SubjectSector(mixed); err != nil || s != "sector.example.test" { + t.Fatalf("sector = %q %v", s, err) + } + if s, _ := SubjectSector(&OIDCClientRecord{SubjectType: SubjectTypePairwise, RedirectURIs: []string{"https://A.example.test/x", "https://a.example.test/y"}}); s != "a.example.test" { + t.Fatalf("single host sector = %q", s) + } +} + +func TestOIDCRefreshOptInSmoke(t *testing.T) { + enabled := newOIDCFixture(t, testOIDCIssuer, func(o *OIDCOptions) { o.RefreshEnabled = true }) + offline := enabled.backend.addClient(OIDCClientRecord{ + ClientID: "oas_offline", Kind: ClientKindOIDC, RedirectURIs: []string{testOIDCRedirect}, + Scopes: []string{"openid", "profile", "offline_access"}, IsActive: true, OfflineAccess: true, + }, testOIDCSecret) + + var doc map[string]any + if err := json.Unmarshal(enabled.get("/.well-known/openid-configuration").Body.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if fmt.Sprint(doc["grant_types_supported"]) != "[authorization_code refresh_token]" || !strings.Contains(fmt.Sprint(doc["scopes_supported"]), "offline_access") { + t.Fatalf("discovery = %v %v", doc["grant_types_supported"], doc["scopes_supported"]) + } + + // A client without OfflineAccess cannot ask for offline_access, gets no + // refresh token and may not use the grant. + rec := enabled.get("/oauth/authorize?" + oidcAuthorizeParams(enabled, func(q url.Values) { q.Set("scope", "openid offline_access") }).Encode()) + if !strings.Contains(rec.Header().Get("Location"), "not+allowed+for+this+client") { + t.Fatalf("offline_access without OfflineAccess = %q", rec.Header().Get("Location")) + } + plain := decodeToken(t, enabled.exchange(enabled.client, enabled.clientSecret, enabled.obtainCode(t, enabled.client, 7, "openid"), oidcTestVerifier)) + if plain.RefreshToken != "" { + t.Fatal("a client without OfflineAccess must not get a refresh token") + } + rec = enabled.tokenCall(url.Values{"grant_type": {"refresh_token"}, "refresh_token": {"oas_rt_x"}}, enabled.client.ClientID, enabled.clientSecret) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"unauthorized_client"`) { + t.Fatalf("refresh without OfflineAccess = %d %s", rec.Code, rec.Body.String()) + } + + // OfflineAccess client granted offline_access: rotation works. + first := decodeToken(t, enabled.exchange(offline, testOIDCSecret, enabled.obtainCode(t, offline, 7, "openid profile offline_access"), oidcTestVerifier)) + if !strings.HasPrefix(first.RefreshToken, "oas_rt_") { + t.Fatalf("refresh token = %q", first.RefreshToken) + } + refresh := func(token, scope string) *httptest.ResponseRecorder { + form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {token}} + if scope != "" { + form.Set("scope", scope) + } + return enabled.tokenCall(form, offline.ClientID, testOIDCSecret) + } + if rec := refresh(first.RefreshToken, "openid email"); rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_scope"`) { + t.Fatalf("scope beyond grant = %d %s", rec.Code, rec.Body.String()) + } + second := decodeToken(t, refresh(first.RefreshToken, "openid profile")) + if second.RefreshToken == "" || second.RefreshToken == first.RefreshToken || second.Scope != "openid profile" { + t.Fatalf("rotation = %+v", second) + } + claims := enabled.parseIDToken(t, second.IDToken) + if _, ok := claims["nonce"]; ok || claims["at_hash"] == nil { + t.Fatalf("refreshed id_token claims = %v", claims) + } + if rec := enabled.userinfo(second.AccessToken); rec.Code != http.StatusOK { + t.Fatalf("userinfo with refreshed token = %d", rec.Code) + } + + // Replaying the rotated token revokes the lineage and its access tokens. + if rec := refresh(first.RefreshToken, ""); rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_grant"`) { + t.Fatalf("replay = %d %s", rec.Code, rec.Body.String()) + } + if rec := refresh(second.RefreshToken, ""); rec.Code != http.StatusBadRequest { + t.Fatalf("successor after replay = %d", rec.Code) + } + assertUserInfo401(t, enabled.userinfo(second.AccessToken)) + + // A disabled server answers the grant exactly like the PHP provider. + disabled := newOIDCFixture(t, testOIDCIssuer, nil) + rec = disabled.tokenCall(url.Values{"grant_type": {"refresh_token"}, "refresh_token": {first.RefreshToken}}, disabled.client.ClientID, disabled.clientSecret) + if rec.Body.String() != `{"error":"unsupported_grant_type","error_description":"Only the authorization_code grant is supported."}` { + t.Fatalf("disabled = %s", rec.Body.String()) + } + + // Credentials for a new client follow the PHP shapes. + id, secret, hash, err := IssueOIDCClientCredentials(DefaultOIDCOptions()) + if err != nil || len(id) != len("oas_")+24 || !strings.HasPrefix(id, "oas_") || !strings.HasPrefix(secret, "oas_cs_") || hash != sha256Hex(secret) { + t.Fatalf("credentials = %q %q %q %v", id, secret, hash, err) + } +}