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:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
430
modules/wristband/oidc_token_smoke_test.go
Normal file
430
modules/wristband/oidc_token_smoke_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user