- 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
255 lines
10 KiB
Go
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
|
|
}
|