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

255 lines
10 KiB
Go

// OpenID Connect flavour of the authorization server (19.4 D-01, D-06,
// D-09). One package serves two flavours: NewServer builds the MCP
// authorization server exactly as before; NewOIDCServer builds a second
// *Server whose flavour selects the OIDC validation and response code. Both
// share the primitives in crypto.go and redirect_html.go. OIDC storage,
// signing keys and the host's HTML arrive through the separate OIDCBackend,
// KeyStore and OIDCHost seams, so the MCP Backend and Tx are untouched.
package wristband
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"time"
)
// flavour selects which orchestration code a Server runs. The zero value
// is the MCP flavour, so every Server built by NewServer stays MCP.
type flavour int
const (
flavourMCP flavour = iota
flavourOIDC
)
// OIDCOptions configures the OpenID Connect flavour. The issuer is shared
// with the MCP flavour through Options.Issuer. DefaultOIDCOptions returns
// the defaults of the PHP provider this flavour is compatible with.
type OIDCOptions struct {
// LoginURL is where an anonymous user is sent with a single
// redirect=<resume path> query parameter (D-04). A trailing slash is
// trimmed; an empty value sends the user to "/". Default: "/login".
LoginURL string
// IDTokenTTL is the id_token lifetime (its exp claim). Default: 1 hour.
IDTokenTTL time.Duration
// AccessTokenTTL is the lifetime of an opaque access token, which is
// only usable at the userinfo endpoint. Default: 1 hour.
AccessTokenTTL time.Duration
// CodeTTL is the lifetime of an authorization code. Default: 60 seconds.
CodeTTL time.Duration
// AuthRequestTTL is how long a parked authorization request can be
// resumed after the login handoff. Default: 10 minutes.
AuthRequestTTL time.Duration
// RefreshEnabled turns on the refresh_token grant for clients with
// OfflineAccess that were granted the offline_access scope (D-17). Off,
// the token endpoint answers the grant exactly like the PHP provider
// (unsupported_grant_type) and discovery does not advertise it.
// Default: false.
RefreshEnabled bool
// RefreshTokenTTL is the lifetime of a refresh token. Default: 30 days.
RefreshTokenTTL time.Duration
// ScopesSupported is the discovery scopes_supported list and the set of
// scopes authorize accepts (offline_access is added when
// RefreshEnabled). Default: openid, profile, email.
ScopesSupported []string
// ClaimsSupported is the discovery claims_supported list. Default: sub,
// name, picture, groups, communities, email, email_verified, iss, aud,
// exp, iat, nonce.
ClaimsSupported []string
// IdentityClaims is the allowlist of identity claims copied from
// IdentityStore.IdentityClaims into the id_token and the userinfo
// answer (T-19.4-11). Default: sub, name, picture, groups, communities,
// email, email_verified.
IdentityClaims []string
// SubjectTypesSupported is the discovery subject_types_supported list
// (D-15). Default: public, pairwise.
SubjectTypesSupported []string
// ClientIDPrefix is the prefix IssueOIDCClientCredentials puts on new
// client IDs. Default: "oas_".
ClientIDPrefix string
// ClientSecretPrefix is the prefix every client secret carries; a
// presented secret without it is refused before hashing. Default:
// "oas_cs_".
ClientSecretPrefix string
// CodePrefix is the prefix every authorization code carries. Default:
// "oas_ac_".
CodePrefix string
// AccessTokenPrefix is the prefix every access token carries. Default:
// "oas_at_".
AccessTokenPrefix string
// RefreshTokenPrefix is the prefix every refresh token carries.
// Default: "oas_rt_".
RefreshTokenPrefix string
}
// DefaultOIDCOptions returns the defaults of the PHP OpenID Connect
// provider this flavour is compatible with.
func DefaultOIDCOptions() OIDCOptions {
return OIDCOptions{
LoginURL: "/login",
IDTokenTTL: 3600 * time.Second,
AccessTokenTTL: 3600 * time.Second,
CodeTTL: 60 * time.Second,
AuthRequestTTL: 600 * time.Second,
RefreshEnabled: false,
RefreshTokenTTL: 30 * 24 * time.Hour,
ScopesSupported: []string{"openid", "profile", "email"},
ClaimsSupported: []string{"sub", "name", "picture", "groups", "communities", "email", "email_verified", "iss", "aud", "exp", "iat", "nonce"},
IdentityClaims: []string{"sub", "name", "picture", "groups", "communities", "email", "email_verified"},
SubjectTypesSupported: []string{SubjectTypePublic, SubjectTypePairwise},
ClientIDPrefix: "oas_",
ClientSecretPrefix: "oas_cs_",
CodePrefix: "oas_ac_",
AccessTokenPrefix: "oas_at_",
RefreshTokenPrefix: "oas_rt_",
}
}
// NewOIDCServer builds a Server with the OpenID Connect flavour. It shares
// Options (the issuer above all) and the clock and entropy seams with the
// MCP flavour. Storage, keys and the host arrive through SetOIDCBackend;
// until then every handler answers 500. The MCP-only handlers (Metadata,
// Register) answer 404 on an OIDC server.
func NewOIDCServer(opts Options, oidc OIDCOptions) *Server {
return &Server{
opts: opts,
flavour: flavourOIDC,
oidc: oidc,
now: time.Now,
randomBytes: randomBase64URL,
}
}
// SetOIDCBackend attaches the OIDC storage, the signing-key store and the
// host seam (D-09). It returns an error on an MCP server or when any
// argument is nil, so a misconfigured application fails at boot rather than
// on the first sign-in.
func (s *Server) SetOIDCBackend(b OIDCBackend, keys KeyStore, host OIDCHost) error {
if s.flavour != flavourOIDC {
return errors.New("wristband: SetOIDCBackend called on an MCP server")
}
if b == nil {
return errors.New("wristband: OIDC backend is nil")
}
if keys == nil {
return errors.New("wristband: key store is nil")
}
if host == nil {
return errors.New("wristband: OIDC host is nil")
}
s.oidcBackend = b
s.keys = keys
s.host = host
return nil
}
// oidcReady reports whether SetOIDCBackend has run on an OIDC server.
func (s *Server) oidcReady() bool {
return s.flavour == flavourOIDC && s.oidcBackend != nil && s.keys != nil && s.host != nil
}
// The rendered authorize error cases. The engine never redirects for these
// (RFC 6749 §4.1.2.1, T-19.4-01): the host renders a page with status 400.
const (
// AuthorizeErrorUnknownClient: the client_id is missing, unknown or not
// an OIDC client.
AuthorizeErrorUnknownClient = "unknown_client"
// AuthorizeErrorRedirectMismatch: the redirect_uri is not registered
// for the client.
AuthorizeErrorRedirectMismatch = "redirect_mismatch"
// AuthorizeErrorInactiveClient: the client exists but is switched off.
AuthorizeErrorInactiveClient = "inactive_client"
// AuthorizeErrorExpiredRequest: the parked request is missing, expired
// or already used, or the consent token did not verify.
AuthorizeErrorExpiredRequest = "expired_request"
)
// ErrAuthRequestExpired is returned by ConsentRequest and DecideConsent
// when the parked request is missing, expired or consumed, when its client
// is gone or inactive, and for every consent token failure (missing,
// forged, minted for another user, reused). They all collapse to one error
// so a caller cannot tell them apart (T-19.4-04).
var ErrAuthRequestExpired = errors.New("wristband: authorization request expired")
// OIDCHost is the seam through which the engine reaches the application:
// who is signed in, how pages look and how a session ends. The engine owns
// validation, redirects, headers and the consent CSRF token; the host owns
// HTML and users.
//
// Before calling RenderAuthorizeError or RenderConsent the engine sets
// X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none',
// Cache-Control: no-store, private and Referrer-Policy: no-referrer. The
// host writes the status and the body.
type OIDCHost interface {
// SignedInUser returns the signed-in user of the request. An error is
// treated like an anonymous request (the user is sent to log in).
SignedInUser(r *http.Request) (userID uint, ok bool, err error)
// RenderAuthorizeError writes the error page for one of the
// AuthorizeError* codes. It must write status 400.
RenderAuthorizeError(w http.ResponseWriter, r *http.Request, code string)
// RenderConsent writes the consent page for view with status 200. A
// host whose consent page lives in its own frontend may write its own
// redirect there instead and finish through ConsentRequest and
// DecideConsent.
RenderConsent(w http.ResponseWriter, r *http.Request, view ConsentView)
// EndSession signs the current user out (switch account). It may set
// cookies on w but must not write the status or the body.
EndSession(w http.ResponseWriter, r *http.Request) error
}
// ConsentView is what a consent page needs. ConsentToken is a one-shot
// CSRF token bound to the request and the user (T-19.4-04): the page posts
// it back with request_id and decision (or to switch account).
type ConsentView struct {
RequestID string
ConsentToken string
ClientID string
ClientName string
// Scopes lists only the scopes the user has not consented to yet;
// openid is never listed.
Scopes []string
UserID uint
// RedirectHost is the host of the client's redirect URI, for the "you
// will be sent to" line.
RedirectHost string
// Incremental is true when the user already consented to some of the
// 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
}