// 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= 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 }