Files
summercms/modules/wristband/oidc_stores.go
Jakub Zych 659b42aaa8 feat(19.4-01): OIDC flavour of wristband with an end-to-end tracer
- NewOIDCServer/SetOIDCBackend build a second Server on the same engine;
  Authorize and Token dispatch by flavour, Metadata/Register 404 on OIDC
- OIDCBackend/OIDCTx, KeyStore and OIDCHost seams; MCP Backend untouched
- Discovery (PHP field order), JWKS (stdlib JWK encoding), authorize with
  resume and one-shot consent token, token with an RS256 id_token signed
  through golang-jwt map claims, userinfo
- TestOIDCTracerSmoke verifies the id_token against the served JWKS
2026-10-19 18:18:05 +02:00

228 lines
8.7 KiB
Go

package wristband
import (
"context"
"time"
)
// OIDCClientRecord is the persisted shape of an administrator-registered
// OpenID Connect client. The OIDC flavour reads clients only through
// OIDCClientStore and refuses any record whose Kind is not ClientKindOIDC
// (D-08, T-19.4-05).
type OIDCClientRecord struct {
ID uint
ClientID string
Name string
Kind string
// SecretHash is the SHA-256 hex digest of the client secret (secrets
// carry OIDCOptions.ClientSecretPrefix and are never stored in clear).
SecretHash *string
RedirectURIs []string
// Scopes is the set of scopes the client may request.
Scopes []string
IsActive bool
// IsDev lets an http loopback redirect URI match a registered one that
// differs only in the port (T-19.4-01).
IsDev bool
// OfflineAccess allows the client to receive refresh tokens when the
// server has OIDCOptions.RefreshEnabled (D-17).
OfflineAccess bool
// SubjectType is SubjectTypePublic (or empty) or SubjectTypePairwise.
SubjectType string
// SectorIdentifier is the pairwise sector an administrator set. Empty
// means the single host of RedirectURIs (D-15).
SectorIdentifier string
}
// AuthRequestRecord is a parked authorization request. It deliberately has
// no user column: the user is resolved again on every resume, and the
// consent token digest binds a pending decision to one user (T-19.4-04).
type AuthRequestRecord struct {
ID uint
RequestID string
ClientID string
RedirectURI string
Scopes []string
State *string
Nonce *string
CodeChallenge string
CodeChallengeMethod string
// ConsentTokenHash is SHA-256 hex of token + "|" + user ID while a
// consent page is outstanding, nil otherwise.
ConsentTokenHash *string
ExpiresAt time.Time
ConsumedAt *time.Time
}
// OIDCCodeRecord is an issued authorization code, stored by the SHA-256 hex
// digest of the code.
type OIDCCodeRecord struct {
ID uint
CodeHash string
ClientID string
UserID uint
RedirectURI string
Scopes []string
CodeChallenge string
CodeChallengeMethod string
Nonce *string
ExpiresAt time.Time
ConsumedAt *time.Time
}
// OIDCAccessTokenRecord is an opaque access token, stored by the SHA-256
// hex digest of the token. AuthCodeID links tokens issued from a code so a
// replayed code can revoke them (T-19.4-03).
type OIDCAccessTokenRecord struct {
ID uint
TokenHash string
ClientID string
UserID uint
AuthCodeID *uint
Scopes []string
ExpiresAt time.Time
RevokedAt *time.Time
LastUsedAt *time.Time
}
// OIDCRefreshTokenRecord is a refresh token (D-17), stored by the SHA-256
// hex digest of the token. A rotated token points at its successor through
// RotatedToID and stays as replay evidence (T-19.4-12).
type OIDCRefreshTokenRecord struct {
ID uint
TokenHash string
ClientID string
UserID uint
AccessTokenID *uint
Scopes []string
ExpiresAt time.Time
RevokedAt *time.Time
RotatedToID *uint
}
// ConsentRecord is the one row per (user, client) pair holding every scope
// the user consented to.
type ConsentRecord struct {
ID uint
UserID uint
ClientID string
Scopes []string
CreatedAt time.Time
LastUsedAt *time.Time
}
// OIDCClientStore reads OIDC clients. Not found is (nil, nil).
type OIDCClientStore interface {
OIDCClientByClientID(ctx context.Context, clientID string) (*OIDCClientRecord, error)
}
// AuthRequestStore persists parked authorization requests. Not found is
// (nil, nil).
type AuthRequestStore interface {
// CreateAuthRequest stores rec and fills rec.ID.
CreateAuthRequest(ctx context.Context, rec *AuthRequestRecord) error
AuthRequestByRequestID(ctx context.Context, requestID string) (*AuthRequestRecord, error)
// AuthRequestByRequestIDForUpdate reads the row under a row lock, so a
// consent token is verified and cleared by one caller only (T-19.4-04).
AuthRequestByRequestIDForUpdate(ctx context.Context, requestID string) (*AuthRequestRecord, error)
// ConsumeAuthRequest marks the request consumed atomically (UPDATE ...
// WHERE id = ? AND consumed_at IS NULL AND expires_at > now) and
// reports whether exactly one row moved. The loser of a race gets
// false.
ConsumeAuthRequest(ctx context.Context, id uint, now time.Time) (bool, error)
// SetConsentTokenHash replaces (or, with nil, clears) the consent token
// digest of the request.
SetConsentTokenHash(ctx context.Context, id uint, hash *string) error
}
// OIDCCodeStore persists authorization codes. Not found is (nil, nil).
type OIDCCodeStore interface {
// CreateCode stores rec and fills rec.ID.
CreateCode(ctx context.Context, rec *OIDCCodeRecord) error
// ConsumeCode marks the code consumed atomically (UPDATE ... WHERE
// code_hash = ? AND consumed_at IS NULL AND expires_at > now) and
// returns the row when exactly one row moved, nil otherwise. The token
// endpoint calls it before any other check (burn then check,
// T-19.4-03).
ConsumeCode(ctx context.Context, codeHash string, now time.Time) (*OIDCCodeRecord, error)
// CodeByHash reads a code regardless of its state, so a replay of a
// consumed code can be told apart from an unknown one.
CodeByHash(ctx context.Context, codeHash string) (*OIDCCodeRecord, error)
}
// OIDCAccessTokenStore persists opaque access tokens. Not found is
// (nil, nil).
type OIDCAccessTokenStore interface {
// CreateAccessToken stores rec and fills rec.ID.
CreateAccessToken(ctx context.Context, rec *OIDCAccessTokenRecord) error
AccessTokenByHash(ctx context.Context, tokenHash string) (*OIDCAccessTokenRecord, error)
// TouchAccessToken stamps LastUsedAt.
TouchAccessToken(ctx context.Context, id uint, now time.Time) error
// RevokeAccessTokensForCode revokes every access token issued from the
// code, and every refresh token linked to one of them (T-19.4-03).
RevokeAccessTokensForCode(ctx context.Context, codeID uint, now time.Time) error
}
// ConsentStore persists consents, one row per (user, client) pair. Not
// found is (nil, nil).
type ConsentStore interface {
ConsentFor(ctx context.Context, userID uint, clientID string) (*ConsentRecord, error)
// SaveConsent merges scopes into the pair's row (creating it when
// absent) and stamps LastUsedAt with now.
SaveConsent(ctx context.Context, userID uint, clientID string, scopes []string, now time.Time) error
// TouchConsent stamps LastUsedAt.
TouchConsent(ctx context.Context, id uint, now time.Time) error
}
// SubjectStore allocates subjects (D-15, T-19.4-13).
type SubjectStore interface {
// SubjectFor returns the stored subject of (userID, sector), inserting
// candidate (a random UUIDv4 the engine generated) only when none
// exists. The public sector is "".
SubjectFor(ctx context.Context, userID uint, sector, candidate string) (string, error)
}
// IdentityStore hands the user's identity claims to the engine.
type IdentityStore interface {
// IdentityClaims returns the claims the scopes release (name, picture,
// email, ...). ok is false when the user no longer exists. The engine
// filters the map through OIDCOptions.IdentityClaims and sets sub
// itself.
IdentityClaims(ctx context.Context, userID uint, scopes []string) (claims map[string]any, ok bool, err error)
}
// OIDCRefreshTokenStore persists refresh tokens (D-17). Not found is
// (nil, nil).
type OIDCRefreshTokenStore interface {
// CreateRefreshToken stores rec and fills rec.ID.
CreateRefreshToken(ctx context.Context, rec *OIDCRefreshTokenRecord) error
// RefreshTokenByHashForUpdate reads the row under a row lock, so one
// refresh token rotates once (T-19.4-12).
RefreshTokenByHashForUpdate(ctx context.Context, tokenHash string) (*OIDCRefreshTokenRecord, error)
// MarkRefreshRotated links a rotated token to its successor.
MarkRefreshRotated(ctx context.Context, id, successorID uint) error
// RevokeRefreshLineage revokes startID and every token it was rotated
// to (following RotatedToID), and the access token linked to each.
RevokeRefreshLineage(ctx context.Context, startID uint, now time.Time) error
}
// OIDCTx bundles every OIDC store on one transaction-scoped handle.
type OIDCTx interface {
OIDCClientStore
AuthRequestStore
OIDCCodeStore
OIDCAccessTokenStore
ConsentStore
SubjectStore
IdentityStore
OIDCRefreshTokenStore
}
// OIDCBackend opens one transaction per call. When fn returns an error the
// transaction rolls back; the engine returns nil from fn whenever a write
// must commit although the protocol answer is an error (a replayed code
// revokes its tokens, T-19.4-03).
type OIDCBackend interface {
WithinOIDCTx(ctx context.Context, fn func(OIDCTx) error) error
}