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
This commit is contained in:
2026-10-19 18:24:45 +02:00
parent dfa3c3e8b4
commit e25e73378c
6 changed files with 735 additions and 28 deletions

View File

@@ -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=<resume path>`; 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

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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

View File

@@ -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)
}
}