diff --git a/modules/wristband/README.md b/modules/wristband/README.md index 6d5b9d67..813b34b3 100644 --- a/modules/wristband/README.md +++ b/modules/wristband/README.md @@ -28,6 +28,7 @@ Use cases: - Consent operations for the application's own consent screen: `wristband.Server.PendingRequest` (what to show), `wristband.Server.IssueCode` (grant, returning the redirect URL with `code`, `iss` and `state`) and `wristband.Server.DenyPending` (returning an `access_denied` redirect). Missing, foreign, used and expired requests all report the same `wristband.ErrPendingNotFound`. - Token endpoint (`wristband.Server.Token`): code exchange with PKCE verification, then an access token minted by the application plus a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the linked access tokens. Expired codes and refresh tokens are swept on each call. - 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. - 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. ## Usage @@ -103,6 +104,35 @@ func Approve(ctx context.Context, w http.ResponseWriter, srv *wristband.Server, | `wristband.ErrPendingNotFound` | The pending request does not exist for this user or is no longer usable. | | `wristband.ErrNoGrantableScopes` | `wristband.Server.IssueCode` was called with no scopes. | | `wristband.ErrClientCapReached` | Returned by `wristband.ClientStore.CreateWithCap` when the client cap is reached. | +| `wristband.NewOIDCServer` | Builds a `wristband.Server` with the OpenID Connect flavour from `wristband.Options` and `wristband.OIDCOptions`. | +| `wristband.Server.SetOIDCBackend` | Attaches the `wristband.OIDCBackend`, the `wristband.KeyStore` and the `wristband.OIDCHost`; errors on an MCP server or a nil argument. | +| `wristband.Server.Discovery` | Handler for `GET /.well-known/openid-configuration` (OIDC servers only). | +| `wristband.Server.JWKS` | Handler for `GET /oauth/jwks`: the active key plus retired keys inside their grace window. | +| `wristband.Server.Resume` | Handler for `GET /oauth/authorize/resume/{request}`: silent code redirect when consent covers the request, otherwise the host's consent page. | +| `wristband.Server.UserInfo` | Handler for `GET /oauth/userinfo`. | +| `wristband.OIDCOptions` | Login URL, lifetimes, refresh switch, advertised scopes and claims, the identity-claim allowlist, subject types and the `oas_` prefixes. | +| `wristband.DefaultOIDCOptions` | The PHP-compatible defaults for `wristband.OIDCOptions`. | +| `wristband.OIDCHost` | The host seam: `wristband.OIDCHost.SignedInUser`, `wristband.OIDCHost.RenderAuthorizeError`, `wristband.OIDCHost.RenderConsent`, `wristband.OIDCHost.EndSession`. | +| `wristband.ConsentView` | What a consent page shows: request, one-shot consent token, client and the scopes not yet consented to. | +| `wristband.AuthorizeErrorUnknownClient` | Rendered error code `unknown_client`; likewise `wristband.AuthorizeErrorRedirectMismatch`, `wristband.AuthorizeErrorInactiveClient` and `wristband.AuthorizeErrorExpiredRequest`. | +| `wristband.ErrAuthRequestExpired` | The parked request is unusable or the consent token did not verify. | +| `wristband.OIDCBackend` | Runs a function inside one transaction with a `wristband.OIDCTx`. | +| `wristband.OIDCTx` | Transaction-scoped bundle of `wristband.OIDCClientStore`, `wristband.AuthRequestStore`, `wristband.OIDCCodeStore`, `wristband.OIDCAccessTokenStore`, `wristband.ConsentStore`, `wristband.SubjectStore`, `wristband.IdentityStore` and `wristband.OIDCRefreshTokenStore`. | +| `wristband.OIDCClientRecord` | An administrator-registered OIDC client: redirect URIs, scopes, active and dev flags, offline access, subject type and sector. | +| `wristband.AuthRequestRecord` | A parked authorization request (no user column) with its consent token digest. | +| `wristband.OIDCCodeRecord` | An authorization code, stored hashed, carrying the nonce. | +| `wristband.OIDCAccessTokenRecord` | An opaque access token, stored hashed, linked to its code. | +| `wristband.OIDCRefreshTokenRecord` | A refresh token, stored hashed, with its rotation link. | +| `wristband.ConsentRecord` | The scopes one user consented to for one client. | +| `wristband.KeyStore` | Supplies the active `wristband.SigningKey` and the published `wristband.PublicKey` ring. | +| `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.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: @@ -125,8 +155,8 @@ Always set `wristband.Options.Issuer` and `wristband.Options.Resource` for your ## Dependencies - SummerCMS modules: none. -- Third-party: none. -- Standard library: `bytes`, `context`, `crypto/rand`, `crypto/sha256`, `crypto/subtle`, `encoding/base64`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `net/http`, `net/url`, `strings`, `time`. +- 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`. ## Testing diff --git a/modules/wristband/authorize.go b/modules/wristband/authorize.go index ffbabbf0..6e6f4c0f 100644 --- a/modules/wristband/authorize.go +++ b/modules/wristband/authorize.go @@ -28,7 +28,14 @@ var authorizeAllowedScopes = map[string]bool{ // redirect response, enforces S256 PKCE syntax, the client's scope ceiling, // and the RFC 8707 resource check, then persists an opaque pending request // and redirects to the app's /connect handoff. +// +// On an OIDC server it dispatches to the OIDC flavour before any MCP code +// runs: GET starts an authorization request, POST is the consent decision. func (s *Server) Authorize(w http.ResponseWriter, r *http.Request) { + if s.flavour == flavourOIDC { + s.oidcAuthorize(w, r) + return + } if s.backend == nil { w.WriteHeader(http.StatusInternalServerError) return diff --git a/modules/wristband/oidc_authorize.go b/modules/wristband/oidc_authorize.go new file mode 100644 index 00000000..fb7a9424 --- /dev/null +++ b/modules/wristband/oidc_authorize.go @@ -0,0 +1,520 @@ +// OpenID Connect authorization endpoint (19.4 D-04, D-14), ported from the +// PHP provider's AuthorizeController: start parks a validated request and +// hands the browser to resume (signed in) or to the login page (anonymous); +// resume answers silently when consent already covers the request and asks +// the host to render a consent page otherwise; decide and switch account +// close the loop. +// +// Unknown clients, unregistered redirect URIs and inactive clients are +// rendered through the host and never redirected (T-19.4-01). Later +// failures redirect to the trusted redirect_uri with a PHP http_build_query +// encoded query and no iss parameter. +package wristband + +import ( + "context" + "errors" + "net/http" + "net/url" + "strings" + "unicode/utf8" + "uuid" +) + +const resumePathPrefix = "/oauth/authorize/resume/" + +// maxOpaqueLength is the PHP validator's cap on state, nonce and scope. +const maxOpaqueLength = 255 + +// errAuthorizeRender carries one of the AuthorizeError* codes from the +// shared consent logic to the HTML handlers. The JSON operations report +// all of them as ErrAuthRequestExpired. +type errAuthorizeRender string + +func (e errAuthorizeRender) Error() string { return "wristband: authorize " + string(e) } + +// oidcAuthorize is the OIDC flavour of Authorize: GET starts a request, +// POST is the consent decision. +func (s *Server) oidcAuthorize(w http.ResponseWriter, r *http.Request) { + if !s.oidcReady() { + w.WriteHeader(http.StatusInternalServerError) + return + } + if r.Method == http.MethodPost { + s.oidcDecide(w, r) + return + } + s.oidcStart(w, r) +} + +// oidcAuthorizeData is a validated authorization request. +type oidcAuthorizeData struct { + Scopes []string + State *string + Nonce *string + Challenge string +} + +// oidcStart handles GET /oauth/authorize in the PHP order: client, redirect +// URI, active flag (all rendered), then the request parameters (redirected +// errors), then the parked request and the handoff. +func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + q := r.URL.Query() + clientID := lastValue(q, "client_id") + redirectURI := lastValue(q, "redirect_uri") + + var client *OIDCClientRecord + if clientID != "" { + err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { + c, err := tx.OIDCClientByClientID(ctx, clientID) + client = c + return err + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + } + if client == nil { + s.renderAuthorizeError(w, r, AuthorizeErrorUnknownClient) + return + } + if !s.redirectMatches(client, redirectURI) { + s.renderAuthorizeError(w, r, AuthorizeErrorRedirectMismatch) + return + } + if !client.IsActive { + s.renderAuthorizeError(w, r, AuthorizeErrorInactiveClient) + return + } + + data, errCode, desc := s.validateOIDCAuthorize(q, client) + if errCode != "" { + s.oidcErrorRedirect(w, redirectURI, errCode, desc, lastValue(q, "state")) + return + } + + rec := &AuthRequestRecord{ + RequestID: uuid.NewV4().String(), + ClientID: client.ClientID, + RedirectURI: redirectURI, + Scopes: data.Scopes, + State: data.State, + Nonce: data.Nonce, + CodeChallenge: data.Challenge, + CodeChallengeMethod: "S256", + ExpiresAt: s.now().Add(s.oidc.AuthRequestTTL), + } + if err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { + return tx.CreateAuthRequest(ctx, rec) + }); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + resume := resumePathPrefix + rec.RequestID + if _, ok := s.signedInUser(r); ok { + s.redirectNoStore(w, resume) + return + } + s.redirectNoStore(w, s.loginHandoffURL(resume)) +} + +// validateOIDCAuthorize ports AuthorizeRequestValidator::validate with its +// order and messages. It returns an error code and description, or the +// validated data. +func (s *Server) validateOIDCAuthorize(q url.Values, client *OIDCClientRecord) (oidcAuthorizeData, string, string) { + if lastValue(q, "response_type") != "code" { + return oidcAuthorizeData{}, "unsupported_response_type", "This provider only supports the authorization code response type." + } + challenge := lastValue(q, "code_challenge") + if !isPKCEChallenge(challenge) { + return oidcAuthorizeData{}, "invalid_request", "A PKCE code challenge is required and must be a base64url-encoded SHA-256 digest." + } + if lastValue(q, "code_challenge_method") != "S256" { + return oidcAuthorizeData{}, "invalid_request", "The code challenge method is required and must be S256." + } + scopeRaw := lastValue(q, "scope") + if strings.TrimSpace(scopeRaw) == "" || utf8.RuneCountInString(scopeRaw) > maxOpaqueLength { + return oidcAuthorizeData{}, "invalid_scope", "A scope parameter is required." + } + requested := parseOIDCScopes(scopeRaw) + if !stringSliceContains(requested, "openid") { + return oidcAuthorizeData{}, "invalid_scope", "The scope parameter must include openid." + } + return oidcAuthorizeData{ + Scopes: intersectScopes(requested, client.Scopes, s.oidcSupportedScopes()), + State: lastValuePtr(q, "state"), + Nonce: lastValuePtr(q, "nonce"), + Challenge: challenge, + }, "", "" +} + +// Resume handles GET /oauth/authorize/resume/{request} on an OIDC server +// (404 on an MCP server). The request ID comes from r.PathValue("request"). +// A missing, expired or consumed request is rendered as expired_request; an +// anonymous user goes to the login page again; a signed-in user whose +// consent already covers the request gets the code redirect at once, +// otherwise the host renders a consent page with a fresh one-shot consent +// token. +func (s *Server) Resume(w http.ResponseWriter, r *http.Request) { + if s.flavour != flavourOIDC { + w.WriteHeader(http.StatusNotFound) + return + } + if !s.oidcReady() { + w.WriteHeader(http.StatusInternalServerError) + return + } + ctx := r.Context() + requestID := r.PathValue("request") + + req, err := s.loadAuthRequest(ctx, requestID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if !s.authRequestUsable(req) { + s.renderAuthorizeError(w, r, AuthorizeErrorExpiredRequest) + return + } + userID, ok := s.signedInUser(r) + if !ok { + s.redirectNoStore(w, s.loginHandoffURL(resumePathPrefix+requestID)) + return + } + + var view ConsentView + var target string + var failure errAuthorizeRender + err = s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { + client, code, err := s.usableClient(ctx, tx, req.ClientID) + if err != nil { + return err + } + if code != "" { + failure = code + return nil + } + if _, err := subjectFor(ctx, tx, client, userID); err != nil { + return err + } + consent, err := tx.ConsentFor(ctx, userID, req.ClientID) + if err != nil { + return err + } + if consent != nil && len(missingScopes(req.Scopes, consent.Scopes)) == 0 { + now := s.now() + consumed, err := tx.ConsumeAuthRequest(ctx, req.ID, now) + if err != nil { + return err + } + if !consumed { + failure = AuthorizeErrorExpiredRequest + return nil + } + if err := tx.TouchConsent(ctx, consent.ID, now); err != nil { + return err + } + code, err := s.mintCode(ctx, tx, req, userID, req.Scopes) + if err != nil { + return err + } + target = codeRedirect(req, code) + return nil + } + v, err := s.prepareConsent(ctx, tx, req, client, userID, consent) + view = v + return err + }) + switch { + case err != nil: + w.WriteHeader(http.StatusInternalServerError) + case failure != "": + s.renderAuthorizeError(w, r, string(failure)) + case target != "": + s.redirectNoStore(w, target) + default: + setRenderHeaders(w) + s.host.RenderConsent(w, r, view) + } +} + +// oidcDecide handles POST /oauth/authorize: request_id, consent_token and +// decision ("allow" exactly allows, anything else denies) from the form, +// the user from the host. Every failure of the request or the consent +// token is rendered as expired_request. +func (s *Server) oidcDecide(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + requestID := inputValue(r, "request_id") + token := inputValue(r, "consent_token") + allow := inputValue(r, "decision") == "allow" + + userID, ok := s.signedInUser(r) + if !ok { + s.renderAuthorizeError(w, r, AuthorizeErrorExpiredRequest) + return + } + target, err := s.decideConsent(r.Context(), requestID, userID, token, allow) + var failure errAuthorizeRender + switch { + case errors.As(err, &failure): + s.renderAuthorizeError(w, r, string(failure)) + case err != nil: + w.WriteHeader(http.StatusInternalServerError) + default: + s.redirectNoStore(w, target) + } +} + +// usableClient loads the request's client and reports the rendered error +// code when it is gone or inactive. +func (s *Server) usableClient(ctx context.Context, tx OIDCTx, clientID string) (*OIDCClientRecord, errAuthorizeRender, error) { + client, err := tx.OIDCClientByClientID(ctx, clientID) + if err != nil { + return nil, "", err + } + if client == nil { + return nil, AuthorizeErrorUnknownClient, nil + } + if !client.IsActive { + return nil, AuthorizeErrorInactiveClient, nil + } + return client, "", nil +} + +// loadAuthRequest reads a parked request by its request ID (nil when +// missing). +func (s *Server) loadAuthRequest(ctx context.Context, requestID string) (*AuthRequestRecord, error) { + if requestID == "" { + return nil, nil + } + var req *AuthRequestRecord + err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { + rec, err := tx.AuthRequestByRequestID(ctx, requestID) + req = rec + return err + }) + return req, err +} + +// authRequestUsable mirrors AuthRequest::isUsable: present, not expired, +// not consumed. +func (s *Server) authRequestUsable(req *AuthRequestRecord) bool { + return req != nil && req.ConsumedAt == nil && req.ExpiresAt.After(s.now()) +} + +// signedInUser asks the host; an error counts as anonymous. +func (s *Server) signedInUser(r *http.Request) (uint, bool) { + id, ok, err := s.host.SignedInUser(r) + if err != nil || !ok { + return 0, false + } + return id, true +} + +// redirectMatches reports whether the presented redirect URI is registered +// for the client byte for byte. +func (s *Server) redirectMatches(client *OIDCClientRecord, presented string) bool { + if presented == "" { + return false + } + for _, uri := range client.RedirectURIs { + if uri != "" && constantEqual(uri, presented) { + return true + } + } + return false +} + +// loginHandoffURL is rtrim(LoginURL, "/") plus a single redirect parameter +// carrying the resume path (D-04): the OIDC query never travels through the +// login page. +func (s *Server) loginHandoffURL(resumePath string) string { + login := strings.TrimRight(s.oidc.LoginURL, "/") + if login == "" { + login = "/" + } + sep := "?" + if strings.Contains(login, "?") { + sep = "&" + } + return login + sep + "redirect=" + phpURLEncode(resumePath) +} + +// setRenderHeaders sets the headers of every rendered authorize page +// (clickjacking and caching protection, T-19.4-01). +func setRenderHeaders(w http.ResponseWriter) { + h := w.Header() + h.Set("X-Frame-Options", "DENY") + h.Set("Content-Security-Policy", "frame-ancestors 'none'") + h.Set("Cache-Control", "no-store, private") + h.Set("Referrer-Policy", "no-referrer") +} + +func (s *Server) renderAuthorizeError(w http.ResponseWriter, r *http.Request, code string) { + setRenderHeaders(w) + s.host.RenderAuthorizeError(w, r, code) +} + +// redirectNoStore writes a 302 with the Symfony redirect body. +func (s *Server) redirectNoStore(w http.ResponseWriter, target string) { + w.Header().Set("Cache-Control", "no-store, private") + writeRedirectHTML(w, http.StatusFound, target) +} + +// oidcErrorRedirect ports AuthorizeController::redirectWithError: error, +// error_description (dropped when empty) and state (dropped when empty), +// http_build_query encoded, no iss. +func (s *Server) oidcErrorRedirect(w http.ResponseWriter, redirectURI, errCode, description, state string) { + s.redirectNoStore(w, errorRedirectURL(redirectURI, errCode, description, state)) +} + +func errorRedirectURL(redirectURI, errCode, description, state string) string { + pairs := [][2]string{{"error", errCode}} + if description != "" { + pairs = append(pairs, [2]string{"error_description", description}) + } + if state != "" { + pairs = append(pairs, [2]string{"state", state}) + } + return appendPHPQuery(redirectURI, pairs) +} + +// codeRedirect is the success redirect: code, then state when the request +// carried a non-empty one. +func codeRedirect(req *AuthRequestRecord, code string) string { + pairs := [][2]string{{"code", code}} + if req.State != nil && *req.State != "" { + pairs = append(pairs, [2]string{"state", *req.State}) + } + return appendPHPQuery(req.RedirectURI, pairs) +} + +// mintCode creates an authorization code for the request (CodePrefix plus +// base64url of 32 random bytes, stored hashed, CodeTTL) and returns it. +func (s *Server) mintCode(ctx context.Context, tx OIDCTx, req *AuthRequestRecord, userID uint, scopes []string) (string, error) { + raw, err := s.randomBytes(32) + if err != nil { + return "", err + } + code := s.oidc.CodePrefix + raw + rec := &OIDCCodeRecord{ + CodeHash: sha256Hex(code), + ClientID: req.ClientID, + UserID: userID, + RedirectURI: req.RedirectURI, + Scopes: scopes, + CodeChallenge: req.CodeChallenge, + CodeChallengeMethod: req.CodeChallengeMethod, + Nonce: req.Nonce, + ExpiresAt: s.now().Add(s.oidc.CodeTTL), + } + if err := tx.CreateCode(ctx, rec); err != nil { + return "", err + } + return code, nil +} + +// isPKCEChallenge matches ^[A-Za-z0-9_-]{43,128}$. +func isPKCEChallenge(c string) bool { + if len(c) < 43 || len(c) > 128 { + return false + } + for i := 0; i < len(c); i++ { + b := c[i] + if !((b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' || b == '_') { + return false + } + } + return true +} + +// parseOIDCScopes ports ScopeCatalog::parse: split on ASCII whitespace, +// drop duplicates, keep order. +func parseOIDCScopes(raw string) []string { + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\v' || r == '\f' + }) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if !stringSliceContains(out, p) { + out = append(out, p) + } + } + return out +} + +// oidcSupportedScopes is OIDCOptions.ScopesSupported plus offline_access +// when refresh is enabled. +func (s *Server) oidcSupportedScopes() []string { + scopes := s.oidc.ScopesSupported + if s.oidc.RefreshEnabled && !stringSliceContains(scopes, "offline_access") { + scopes = append(append([]string(nil), scopes...), "offline_access") + } + return scopes +} + +// intersectScopes ports ScopeCatalog::intersect: requested order, no +// duplicates, only supported scopes the client may have. +func intersectScopes(requested, allowed, supported []string) []string { + out := []string{} + for _, sc := range requested { + if sc == "" || stringSliceContains(out, sc) || !stringSliceContains(supported, sc) || !stringSliceContains(allowed, sc) { + continue + } + out = append(out, sc) + } + return out +} + +// missingScopes is requested minus granted (Consent::missing). +func missingScopes(requested, granted []string) []string { + out := []string{} + for _, sc := range requested { + if !stringSliceContains(granted, sc) && !stringSliceContains(out, sc) { + out = append(out, sc) + } + } + return out +} + +// lastValue returns the last value of a repeated parameter (PHP parse_str +// keeps the last one); array-style keys such as state[] are other keys and +// so count as absent. +func lastValue(q url.Values, key string) string { + vs := q[key] + if len(vs) == 0 { + return "" + } + return vs[len(vs)-1] +} + +// lastValuePtr is lastValue with nil for an absent parameter. +func lastValuePtr(q url.Values, key string) *string { + vs := q[key] + if len(vs) == 0 { + return nil + } + v := vs[len(vs)-1] + return &v +} + +// inputValue mirrors Laravel's $request->input() for a parsed form: the +// body wins over the query string, the last value of each wins. +func inputValue(r *http.Request, key string) string { + if vs := r.PostForm[key]; len(vs) > 0 { + return vs[len(vs)-1] + } + return lastValue(r.URL.Query(), key) +} + +// redirectHost is the host of a redirect URI for the consent page. +func redirectHost(uri string) string { + u, err := url.Parse(uri) + if err != nil { + return "" + } + return u.Hostname() +} diff --git a/modules/wristband/oidc_consent.go b/modules/wristband/oidc_consent.go new file mode 100644 index 00000000..ab749dd5 --- /dev/null +++ b/modules/wristband/oidc_consent.go @@ -0,0 +1,127 @@ +// Consent for the OpenID Connect flavour: the one-shot consent token +// (PHP ConsentCsrf) and the decision logic shared by the HTML handlers and +// the JSON operations a host's own consent page uses. +package wristband + +import ( + "context" + "strconv" +) + +// consentDigest binds a consent token to one user: SHA-256 hex of +// token + "|" + user ID, byte-compatible with PHP ConsentCsrf::bind, so a +// digest copied from the PHP tables stays valid (T-19.4-04). +func consentDigest(token string, userID uint) string { + return sha256Hex(token + "|" + strconv.FormatUint(uint64(userID), 10)) +} + +// prepareConsent mints a new consent token for the request (replacing any +// stored digest) and builds the consent view listing the scopes the user +// has not consented to yet; openid is never listed. +func (s *Server) prepareConsent(ctx context.Context, tx OIDCTx, req *AuthRequestRecord, client *OIDCClientRecord, userID uint, consent *ConsentRecord) (ConsentView, error) { + showing := req.Scopes + if consent != nil { + showing = missingScopes(req.Scopes, consent.Scopes) + } + listed := []string{} + for _, sc := range showing { + if sc != "openid" && !stringSliceContains(listed, sc) { + listed = append(listed, sc) + } + } + token, err := s.randomBytes(32) + if err != nil { + return ConsentView{}, err + } + digest := consentDigest(token, userID) + if err := tx.SetConsentTokenHash(ctx, req.ID, &digest); err != nil { + return ConsentView{}, err + } + return ConsentView{ + RequestID: req.RequestID, + ConsentToken: token, + ClientID: client.ClientID, + ClientName: client.Name, + Scopes: listed, + UserID: userID, + RedirectHost: redirectHost(req.RedirectURI), + Incremental: consent != nil, + }, nil +} + +// decideConsent verifies and burns the consent token, then denies +// (consume, access_denied redirect) or allows (consume first, merge the +// consent, mint the code) in one OIDC transaction. It returns the redirect +// URL, or an errAuthorizeRender carrying the code to render. The token is +// cleared even when a later check fails: the transaction commits whenever +// the answer is a protocol error. +func (s *Server) decideConsent(ctx context.Context, requestID string, userID uint, token string, allow bool) (string, error) { + if requestID == "" { + return "", errAuthorizeRender(AuthorizeErrorExpiredRequest) + } + var target string + var failure errAuthorizeRender + err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { + req, err := tx.AuthRequestByRequestIDForUpdate(ctx, requestID) + if err != nil { + return err + } + if !s.authRequestUsable(req) { + failure = AuthorizeErrorExpiredRequest + return nil + } + if req.ConsentTokenHash == nil || *req.ConsentTokenHash == "" || token == "" || + !constantEqual(*req.ConsentTokenHash, consentDigest(token, userID)) { + failure = AuthorizeErrorExpiredRequest + return nil + } + if err := tx.SetConsentTokenHash(ctx, req.ID, nil); err != nil { + return err + } + client, code, err := s.usableClient(ctx, tx, req.ClientID) + if err != nil { + return err + } + if code != "" { + failure = code + return nil + } + now := s.now() + if !allow { + if _, err := tx.ConsumeAuthRequest(ctx, req.ID, now); err != nil { + return err + } + state := "" + if req.State != nil { + state = *req.State + } + target = errorRedirectURL(req.RedirectURI, "access_denied", "", state) + return nil + } + granted := intersectScopes(req.Scopes, client.Scopes, s.oidcSupportedScopes()) + consumed, err := tx.ConsumeAuthRequest(ctx, req.ID, now) + if err != nil { + return err + } + if !consumed { + failure = AuthorizeErrorExpiredRequest + return nil + } + if err := tx.SaveConsent(ctx, userID, client.ClientID, granted, now); err != nil { + return err + } + raw, err := s.mintCode(ctx, tx, req, userID, granted) + if err != nil { + return err + } + target = codeRedirect(req, raw) + return nil + }) + if err != nil { + return "", err + } + if failure != "" { + return "", failure + } + return target, nil +} diff --git a/modules/wristband/oidc_discovery.go b/modules/wristband/oidc_discovery.go new file mode 100644 index 00000000..0a29668f --- /dev/null +++ b/modules/wristband/oidc_discovery.go @@ -0,0 +1,168 @@ +package wristband + +import ( + "net/http" + "slices" + "strings" +) + +// discoveryDocument is the OpenID Provider Configuration in the PHP +// provider's field order (DiscoveryDocument.php). encoding/json keeps +// declaration order, so this struct is the wire order. +type discoveryDocument struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserinfoEndpoint string `json:"userinfo_endpoint"` + JwksURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + ScopesSupported []string `json:"scopes_supported"` + ClaimsSupported []string `json:"claims_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + ResponseModesSupported []string `json:"response_modes_supported"` +} + +type jwksDocument struct { + Keys []jwkDocument `json:"keys"` +} + +// serverMessage is the body of a fail-loud 500 (PHP abort(500, ...)). +type serverMessage struct { + Message string `json:"message"` +} + +const ( + discoveryCacheControl = "max-age=3600, public" + msgIssuerNotConfigured = "The OpenID Connect issuer is not configured." + msgKeysUnavailable = "Signing keys are unavailable." +) + +// oidcIssuer is the configured issuer without trailing slashes (PHP +// rtrim($issuer, '/')). +func (s *Server) oidcIssuer() string { + return strings.TrimRight(s.opts.Issuer, "/") +} + +// writeServerMessage answers a fixed 500 message. Underlying errors never +// reach the body (T-19.4-09). +func writeServerMessage(w http.ResponseWriter, message string) { + writeExactJSON(w, http.StatusInternalServerError, serverMessage{Message: message}, map[string]string{"Cache-Control": "no-cache, private"}) +} + +// Discovery handles GET /.well-known/openid-configuration on an OIDC +// server (404 on an MCP server). It writes the PHP field set in PHP order +// with Cache-Control: max-age=3600, public. subject_types_supported comes +// from OIDCOptions (public and pairwise, D-15) and +// id_token_signing_alg_values_supported lists the algorithms the published +// key ring holds (D-12). refresh_token and offline_access are advertised +// only when OIDCOptions.RefreshEnabled. +func (s *Server) Discovery(w http.ResponseWriter, r *http.Request) { + if s.flavour != flavourOIDC { + w.WriteHeader(http.StatusNotFound) + return + } + iss := s.oidcIssuer() + if iss == "" { + writeServerMessage(w, msgIssuerNotConfigured) + return + } + if !s.oidcReady() { + writeServerMessage(w, msgKeysUnavailable) + return + } + keys, err := s.publishedKeys(r) + if err != nil { + writeServerMessage(w, msgKeysUnavailable) + return + } + var algs []string + for _, alg := range []string{AlgRS256, AlgES256} { + for _, k := range keys { + if k.Alg == alg { + algs = append(algs, alg) + break + } + } + } + if algs == nil { + algs = []string{} + } + + grants := []string{"authorization_code"} + scopes := slices.Clone(s.oidc.ScopesSupported) + if s.oidc.RefreshEnabled { + grants = append(grants, "refresh_token") + if !slices.Contains(scopes, "offline_access") { + scopes = append(scopes, "offline_access") + } + } + doc := discoveryDocument{ + Issuer: iss, + AuthorizationEndpoint: iss + "/oauth/authorize", + TokenEndpoint: iss + "/oauth/token", + UserinfoEndpoint: iss + "/oauth/userinfo", + JwksURI: iss + "/oauth/jwks", + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: grants, + SubjectTypesSupported: nonNil(s.oidc.SubjectTypesSupported), + IDTokenSigningAlgValuesSupported: algs, + ScopesSupported: nonNil(scopes), + ClaimsSupported: nonNil(s.oidc.ClaimsSupported), + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"}, + ResponseModesSupported: []string{"query"}, + } + writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": discoveryCacheControl}) +} + +// JWKS handles GET /oauth/jwks on an OIDC server (404 on an MCP server): +// the active key plus retired keys inside their grace window, with +// Cache-Control: max-age=3600, public. A key-store failure answers 500 +// "Signing keys are unavailable." without the error text (T-19.4-09). +func (s *Server) JWKS(w http.ResponseWriter, r *http.Request) { + if s.flavour != flavourOIDC { + w.WriteHeader(http.StatusNotFound) + return + } + if !s.oidcReady() { + writeServerMessage(w, msgKeysUnavailable) + return + } + keys, err := s.publishedKeys(r) + if err != nil { + writeServerMessage(w, msgKeysUnavailable) + return + } + doc := jwksDocument{Keys: make([]jwkDocument, 0, len(keys))} + for _, k := range keys { + j, err := jwkFor(k) + if err != nil { + writeServerMessage(w, msgKeysUnavailable) + return + } + doc.Keys = append(doc.Keys, j) + } + writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": discoveryCacheControl}) +} + +// publishedKeys asks for the active key first, so an empty ring gets its +// first key lazily (as the PHP key ring does), then reads the published +// ring. +func (s *Server) publishedKeys(r *http.Request) ([]PublicKey, error) { + ctx := r.Context() + if _, err := s.keys.ActiveSigningKey(ctx); err != nil { + return nil, err + } + return s.keys.PublishedKeys(ctx, s.now()) +} + +func nonNil(ss []string) []string { + if ss == nil { + return []string{} + } + return ss +} diff --git a/modules/wristband/oidc_fakes_test.go b/modules/wristband/oidc_fakes_test.go new file mode 100644 index 00000000..aa4ef605 --- /dev/null +++ b/modules/wristband/oidc_fakes_test.go @@ -0,0 +1,582 @@ +package wristband + +import ( + "context" + "crypto" + "fmt" + "net/http" + "slices" + "sync" + "testing" + "time" +) + +// memoryOIDCBackend is the in-memory OIDCBackend/OIDCTx for wristband's +// own tests. One mutex guards the whole transaction closure; it never rolls +// back, which the engine tolerates because it only returns an error from a +// transaction for server failures. +type memoryOIDCBackend struct { + mu sync.Mutex + clients []*OIDCClientRecord + requests []*AuthRequestRecord + codes []*OIDCCodeRecord + tokens []*OIDCAccessTokenRecord + refresh []*OIDCRefreshTokenRecord + consents []*ConsentRecord + subjects map[string]string // "|" -> sub + identities map[uint]map[string]any + nextID uint +} + +func newMemoryOIDCBackend() *memoryOIDCBackend { + return &memoryOIDCBackend{subjects: map[string]string{}, identities: map[uint]map[string]any{}} +} + +func (b *memoryOIDCBackend) WithinOIDCTx(ctx context.Context, fn func(OIDCTx) error) error { + b.mu.Lock() + defer b.mu.Unlock() + return fn(&memoryOIDCTx{b: b}) +} + +func (b *memoryOIDCBackend) id() uint { + b.nextID++ + return b.nextID +} + +// addClient registers an OIDC client whose secret is secret. +func (b *memoryOIDCBackend) addClient(c OIDCClientRecord, secret string) *OIDCClientRecord { + b.mu.Lock() + defer b.mu.Unlock() + if secret != "" { + h := sha256Hex(secret) + c.SecretHash = &h + } + c.ID = b.id() + cp := c + b.clients = append(b.clients, &cp) + return &cp +} + +func (b *memoryOIDCBackend) setIdentity(userID uint, claims map[string]any) { + b.mu.Lock() + defer b.mu.Unlock() + b.identities[userID] = claims +} + +func (b *memoryOIDCBackend) subject(userID uint, sector string) string { + b.mu.Lock() + defer b.mu.Unlock() + return b.subjects[fmt.Sprintf("%d|%s", userID, sector)] +} + +func (b *memoryOIDCBackend) accessTokens() []OIDCAccessTokenRecord { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]OIDCAccessTokenRecord, len(b.tokens)) + for i, t := range b.tokens { + out[i] = *t + } + return out +} + +type memoryOIDCTx struct{ b *memoryOIDCBackend } + +var _ OIDCTx = (*memoryOIDCTx)(nil) + +func (t *memoryOIDCTx) OIDCClientByClientID(ctx context.Context, clientID string) (*OIDCClientRecord, error) { + for _, c := range t.b.clients { + if c.ClientID == clientID { + cp := *c + return &cp, nil + } + } + return nil, nil +} + +func (t *memoryOIDCTx) CreateAuthRequest(ctx context.Context, rec *AuthRequestRecord) error { + rec.ID = t.b.id() + cp := *rec + t.b.requests = append(t.b.requests, &cp) + return nil +} + +func (t *memoryOIDCTx) findRequest(requestID string) *AuthRequestRecord { + for _, r := range t.b.requests { + if r.RequestID == requestID { + return r + } + } + return nil +} + +func (t *memoryOIDCTx) AuthRequestByRequestID(ctx context.Context, requestID string) (*AuthRequestRecord, error) { + if r := t.findRequest(requestID); r != nil { + cp := *r + return &cp, nil + } + return nil, nil +} + +func (t *memoryOIDCTx) AuthRequestByRequestIDForUpdate(ctx context.Context, requestID string) (*AuthRequestRecord, error) { + return t.AuthRequestByRequestID(ctx, requestID) +} + +func (t *memoryOIDCTx) ConsumeAuthRequest(ctx context.Context, id uint, now time.Time) (bool, error) { + for _, r := range t.b.requests { + if r.ID == id { + if r.ConsumedAt != nil || !r.ExpiresAt.After(now) { + return false, nil + } + n := now + r.ConsumedAt = &n + return true, nil + } + } + return false, nil +} + +func (t *memoryOIDCTx) SetConsentTokenHash(ctx context.Context, id uint, hash *string) error { + for _, r := range t.b.requests { + if r.ID == id { + if hash == nil { + r.ConsentTokenHash = nil + } else { + h := *hash + r.ConsentTokenHash = &h + } + } + } + return nil +} + +func (t *memoryOIDCTx) CreateCode(ctx context.Context, rec *OIDCCodeRecord) error { + rec.ID = t.b.id() + cp := *rec + t.b.codes = append(t.b.codes, &cp) + return nil +} + +func (t *memoryOIDCTx) ConsumeCode(ctx context.Context, codeHash string, now time.Time) (*OIDCCodeRecord, error) { + for _, c := range t.b.codes { + if c.CodeHash == codeHash { + if c.ConsumedAt != nil || !c.ExpiresAt.After(now) { + return nil, nil + } + n := now + c.ConsumedAt = &n + cp := *c + return &cp, nil + } + } + return nil, nil +} + +func (t *memoryOIDCTx) CodeByHash(ctx context.Context, codeHash string) (*OIDCCodeRecord, error) { + for _, c := range t.b.codes { + if c.CodeHash == codeHash { + cp := *c + return &cp, nil + } + } + return nil, nil +} + +func (t *memoryOIDCTx) CreateAccessToken(ctx context.Context, rec *OIDCAccessTokenRecord) error { + rec.ID = t.b.id() + cp := *rec + t.b.tokens = append(t.b.tokens, &cp) + return nil +} + +func (t *memoryOIDCTx) AccessTokenByHash(ctx context.Context, tokenHash string) (*OIDCAccessTokenRecord, error) { + for _, tok := range t.b.tokens { + if tok.TokenHash == tokenHash { + cp := *tok + return &cp, nil + } + } + return nil, nil +} + +func (t *memoryOIDCTx) TouchAccessToken(ctx context.Context, id uint, now time.Time) error { + for _, tok := range t.b.tokens { + if tok.ID == id { + n := now + tok.LastUsedAt = &n + } + } + return nil +} + +func (t *memoryOIDCTx) revokeAccessToken(id uint, now time.Time) { + for _, tok := range t.b.tokens { + if tok.ID == id && tok.RevokedAt == nil { + n := now + tok.RevokedAt = &n + } + } +} + +func (t *memoryOIDCTx) RevokeAccessTokensForCode(ctx context.Context, codeID uint, now time.Time) error { + for _, tok := range t.b.tokens { + if tok.AuthCodeID != nil && *tok.AuthCodeID == codeID { + t.revokeAccessToken(tok.ID, now) + for _, rt := range t.b.refresh { + if rt.AccessTokenID != nil && *rt.AccessTokenID == tok.ID { + if err := t.RevokeRefreshLineage(ctx, rt.ID, now); err != nil { + return err + } + } + } + } + } + return nil +} + +func (t *memoryOIDCTx) ConsentFor(ctx context.Context, userID uint, clientID string) (*ConsentRecord, error) { + for _, c := range t.b.consents { + if c.UserID == userID && c.ClientID == clientID { + cp := *c + cp.Scopes = slices.Clone(c.Scopes) + return &cp, nil + } + } + return nil, nil +} + +func (t *memoryOIDCTx) SaveConsent(ctx context.Context, userID uint, clientID string, scopes []string, now time.Time) error { + n := now + for _, c := range t.b.consents { + if c.UserID == userID && c.ClientID == clientID { + for _, sc := range scopes { + if !slices.Contains(c.Scopes, sc) { + c.Scopes = append(c.Scopes, sc) + } + } + c.LastUsedAt = &n + return nil + } + } + t.b.consents = append(t.b.consents, &ConsentRecord{ + ID: t.b.id(), UserID: userID, ClientID: clientID, + Scopes: slices.Clone(scopes), CreatedAt: now, LastUsedAt: &n, + }) + return nil +} + +func (t *memoryOIDCTx) TouchConsent(ctx context.Context, id uint, now time.Time) error { + for _, c := range t.b.consents { + if c.ID == id { + n := now + c.LastUsedAt = &n + } + } + return nil +} + +func (t *memoryOIDCTx) SubjectFor(ctx context.Context, userID uint, sector, candidate string) (string, error) { + key := fmt.Sprintf("%d|%s", userID, sector) + if sub, ok := t.b.subjects[key]; ok { + return sub, nil + } + t.b.subjects[key] = candidate + return candidate, nil +} + +func (t *memoryOIDCTx) IdentityClaims(ctx context.Context, userID uint, scopes []string) (map[string]any, bool, error) { + all, ok := t.b.identities[userID] + if !ok { + return nil, false, nil + } + out := map[string]any{} + if slices.Contains(scopes, "profile") { + for _, k := range []string{"name", "picture", "groups", "communities"} { + if v, ok := all[k]; ok { + out[k] = v + } + } + } + if slices.Contains(scopes, "email") { + for _, k := range []string{"email", "email_verified"} { + if v, ok := all[k]; ok { + out[k] = v + } + } + } + // Extra keys let tests prove the allowlist and protocol-claim guard. + for k, v := range all { + if k == "iss" || k == "aud" || k == "x_extra" { + out[k] = v + } + } + return out, true, nil +} + +func (t *memoryOIDCTx) CreateRefreshToken(ctx context.Context, rec *OIDCRefreshTokenRecord) error { + rec.ID = t.b.id() + cp := *rec + t.b.refresh = append(t.b.refresh, &cp) + return nil +} + +func (t *memoryOIDCTx) RefreshTokenByHashForUpdate(ctx context.Context, tokenHash string) (*OIDCRefreshTokenRecord, error) { + for _, rt := range t.b.refresh { + if rt.TokenHash == tokenHash { + cp := *rt + return &cp, nil + } + } + return nil, nil +} + +func (t *memoryOIDCTx) MarkRefreshRotated(ctx context.Context, id, successorID uint) error { + for _, rt := range t.b.refresh { + if rt.ID == id { + s := successorID + rt.RotatedToID = &s + } + } + return nil +} + +func (t *memoryOIDCTx) RevokeRefreshLineage(ctx context.Context, startID uint, now time.Time) error { + next := &startID + for next != nil { + var cur *OIDCRefreshTokenRecord + for _, rt := range t.b.refresh { + if rt.ID == *next { + cur = rt + } + } + if cur == nil { + return nil + } + if cur.RevokedAt == nil { + n := now + cur.RevokedAt = &n + } + if cur.AccessTokenID != nil { + t.revokeAccessToken(*cur.AccessTokenID, now) + } + next = cur.RotatedToID + } + return nil +} + +// memoryKeyStore is an in-memory KeyStore built from GenerateSigningKey. +type memoryKeyStore struct { + mu sync.Mutex + keys []*memoryKey + grace time.Duration + // alg is the algorithm of a lazily generated first key. + alg string +} + +type memoryKey struct { + kid string + alg string + signer crypto.Signer + public crypto.PublicKey + active bool + retiredAt *time.Time +} + +func newMemoryKeyStore() *memoryKeyStore { + return &memoryKeyStore{grace: 24 * time.Hour, alg: AlgRS256} +} + +// rotate generates a new active key for alg and retires the old one at +// retiredAt. +func (k *memoryKeyStore) rotate(t *testing.T, alg string, retiredAt time.Time) string { + t.Helper() + k.mu.Lock() + defer k.mu.Unlock() + key, err := generateMemoryKey(alg) + if err != nil { + t.Fatal(err) + } + for _, old := range k.keys { + if old.active { + old.active = false + r := retiredAt + old.retiredAt = &r + } + } + k.keys = append(k.keys, key) + return key.kid +} + +func generateMemoryKey(alg string) (*memoryKey, error) { + kid, privPEM, pubPEM, err := GenerateSigningKey(alg) + if err != nil { + return nil, err + } + signer, err := ParsePrivateKeyPEM(privPEM) + if err != nil { + return nil, err + } + pub, err := ParsePublicKeyPEM(pubPEM) + if err != nil { + return nil, err + } + return &memoryKey{kid: kid, alg: alg, signer: signer, public: pub, active: true}, nil +} + +func (k *memoryKeyStore) ActiveSigningKey(ctx context.Context) (SigningKey, error) { + k.mu.Lock() + defer k.mu.Unlock() + for _, key := range k.keys { + if key.active { + return SigningKey{Kid: key.kid, Alg: key.alg, Signer: key.signer}, nil + } + } + key, err := generateMemoryKey(k.alg) + if err != nil { + return SigningKey{}, err + } + k.keys = append(k.keys, key) + return SigningKey{Kid: key.kid, Alg: key.alg, Signer: key.signer}, nil +} + +func (k *memoryKeyStore) PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error) { + k.mu.Lock() + defer k.mu.Unlock() + var out []PublicKey + for _, key := range k.keys { + if key.active || (key.retiredAt != nil && now.Before(key.retiredAt.Add(k.grace))) { + out = append(out, PublicKey{Kid: key.kid, Alg: key.alg, Key: key.public}) + } + } + return out, nil +} + +// fakeOIDCHost is a settable OIDCHost that records what the engine asked +// it to render. +type fakeOIDCHost struct { + mu sync.Mutex + userID uint + signedIn bool + views []ConsentView + errors []string + endSessions int +} + +func (h *fakeOIDCHost) signIn(userID uint) { + h.mu.Lock() + defer h.mu.Unlock() + h.userID, h.signedIn = userID, true +} + +func (h *fakeOIDCHost) signOut() { + h.mu.Lock() + defer h.mu.Unlock() + h.signedIn = false +} + +func (h *fakeOIDCHost) SignedInUser(r *http.Request) (uint, bool, error) { + h.mu.Lock() + defer h.mu.Unlock() + return h.userID, h.signedIn, nil +} + +func (h *fakeOIDCHost) RenderAuthorizeError(w http.ResponseWriter, r *http.Request, code string) { + h.mu.Lock() + h.errors = append(h.errors, code) + h.mu.Unlock() + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(code)) +} + +func (h *fakeOIDCHost) RenderConsent(w http.ResponseWriter, r *http.Request, view ConsentView) { + h.mu.Lock() + h.views = append(h.views, view) + h.mu.Unlock() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("consent")) +} + +func (h *fakeOIDCHost) EndSession(w http.ResponseWriter, r *http.Request) error { + h.mu.Lock() + defer h.mu.Unlock() + h.endSessions++ + h.signedIn = false + return nil +} + +func (h *fakeOIDCHost) lastView(t *testing.T) ConsentView { + t.Helper() + h.mu.Lock() + defer h.mu.Unlock() + if len(h.views) == 0 { + t.Fatal("no consent view was rendered") + } + return h.views[len(h.views)-1] +} + +// oidcFixture is an OIDC server over the memory fakes plus one active, +// confidential, public-subject client. +type oidcFixture struct { + srv *Server + backend *memoryOIDCBackend + keys *memoryKeyStore + host *fakeOIDCHost + client *OIDCClientRecord + clientSecret string +} + +const ( + testOIDCIssuer = "https://issuer.example.test" + testOIDCRedirect = "https://rp.example.test/callback" + testOIDCSecret = "oas_cs_test-secret-0123456789abcdefghijklmnopqrstuvwx" +) + +func newOIDCFixture(t *testing.T, issuer string, mutate func(*OIDCOptions)) *oidcFixture { + t.Helper() + oidc := DefaultOIDCOptions() + if mutate != nil { + mutate(&oidc) + } + opts := DefaultOptions() + opts.Issuer = issuer + srv := NewOIDCServer(opts, oidc) + f := &oidcFixture{ + srv: srv, + backend: newMemoryOIDCBackend(), + keys: newMemoryKeyStore(), + host: &fakeOIDCHost{}, + clientSecret: testOIDCSecret, + } + if err := srv.SetOIDCBackend(f.backend, f.keys, f.host); err != nil { + t.Fatal(err) + } + f.client = f.backend.addClient(OIDCClientRecord{ + ClientID: "oas_0123456789abcdef01234567", + Name: "Example RP", + Kind: "oidc", + RedirectURIs: []string{testOIDCRedirect}, + Scopes: []string{"openid", "profile", "email"}, + IsActive: true, + SubjectType: SubjectTypePublic, + }, testOIDCSecret) + f.backend.setIdentity(7, map[string]any{ + "name": "Ada Example", "email": "ada@example.test", "email_verified": true, + "groups": []string{"editors"}, "communities": []any{}, + "iss": "https://evil.example.test", "x_extra": "dropped", + }) + return f +} + +// mux mounts the OIDC routes the way a host application would. +func (f *oidcFixture) mux() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /.well-known/openid-configuration", f.srv.Discovery) + mux.HandleFunc("GET /oauth/jwks", f.srv.JWKS) + mux.HandleFunc("GET /oauth/authorize", f.srv.Authorize) + mux.HandleFunc("POST /oauth/authorize", f.srv.Authorize) + mux.HandleFunc("GET /oauth/authorize/resume/{request}", f.srv.Resume) + mux.HandleFunc("POST /oauth/token", f.srv.Token) + mux.HandleFunc("GET /oauth/userinfo", f.srv.UserInfo) + return mux +} diff --git a/modules/wristband/oidc_keys.go b/modules/wristband/oidc_keys.go new file mode 100644 index 00000000..854647b5 --- /dev/null +++ b/modules/wristband/oidc_keys.go @@ -0,0 +1,238 @@ +package wristband + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "math/big" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// The id_token signing algorithms (D-12). +const ( + // AlgRS256 is RSASSA-PKCS1-v1_5 with SHA-256 over an RSA key, the + // default and the algorithm of imported PHP keys. + AlgRS256 = "RS256" + // AlgES256 is ECDSA over P-256 with SHA-256. + AlgES256 = "ES256" +) + +// SigningKey is the active signing key. Signer must be the +// *rsa.PrivateKey or *ecdsa.PrivateKey matching Alg, as returned by +// ParsePrivateKeyPEM. Key material reaches the engine only here and is +// never logged (T-19.4-09). +type SigningKey struct { + Kid string + Alg string + Signer crypto.Signer +} + +// PublicKey is a published verification key. +type PublicKey struct { + Kid string + Alg string + Key crypto.PublicKey +} + +// KeyStore supplies the signing-key ring (D-10..D-13). +type KeyStore interface { + // ActiveSigningKey returns the active key, generating the first key + // when the ring is empty. + ActiveSigningKey(ctx context.Context) (SigningKey, error) + // PublishedKeys returns the active key plus retired keys still inside + // their grace window at now, for JWKS and discovery. + PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error) +} + +// GenerateSigningKey creates a new key for alg: RSA 2048 for RS256. 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. +func GenerateSigningKey(alg string) (kid string, privatePEM, publicPEM []byte, err error) { + var priv crypto.Signer + switch alg { + case AlgRS256: + priv, err = rsa.GenerateKey(rand.Reader, 2048) + default: + return "", nil, nil, fmt.Errorf("wristband: unsupported signing algorithm %q", alg) + } + if err != nil { + return "", nil, nil, fmt.Errorf("wristband: generate signing key: %w", err) + } + privDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return "", nil, nil, fmt.Errorf("wristband: encode private key: %w", err) + } + pubDER, err := x509.MarshalPKIXPublicKey(priv.Public()) + if err != nil { + return "", nil, nil, fmt.Errorf("wristband: encode public key: %w", err) + } + raw := make([]byte, 8) + if _, err := rand.Read(raw); err != nil { + return "", nil, nil, fmt.Errorf("wristband: generate kid: %w", err) + } + privatePEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER}) + publicPEM = pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}) + return hex.EncodeToString(raw), privatePEM, publicPEM, nil +} + +// ParsePrivateKeyPEM parses a PKCS#8 private key. +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") + } + signer, ok := key.(crypto.Signer) + if !ok { + return nil, errors.New("wristband: unsupported private key type") + } + if _, ok := signer.(*rsa.PrivateKey); !ok { + return nil, errors.New("wristband: unsupported private key type") + } + return signer, nil +} + +// ParsePublicKeyPEM parses a PKIX public key. +func ParsePublicKeyPEM(data []byte) (crypto.PublicKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("wristband: no PEM block in public key") + } + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, errors.New("wristband: unreadable public key") + } + switch key.(type) { + case *rsa.PublicKey: + return key, nil + } + return nil, errors.New("wristband: unsupported public key type") +} + +// jwkDocument is one JWKS entry. encoding/json keeps declaration order and +// omitempty drops the members of the other key type, so RSA keys serialise +// as kty, use, alg, kid, n, e and EC keys as kty, use, alg, kid, crv, x, y. +type jwkDocument struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + Crv string `json:"crv,omitempty"` + N string `json:"n,omitempty"` + E string `json:"e,omitempty"` + X string `json:"x,omitempty"` + Y string `json:"y,omitempty"` +} + +// 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 +// and its alg; an Alg that does not match the key type is refused +// (T-19.4-08). +func jwkFor(k PublicKey) (jwkDocument, error) { + b64 := base64.RawURLEncoding.EncodeToString + switch key := k.Key.(type) { + case *rsa.PublicKey: + if k.Alg != AlgRS256 { + return jwkDocument{}, errors.New("wristband: RSA key with a non-RSA algorithm") + } + return jwkDocument{ + Kty: "RSA", Use: "sig", Alg: k.Alg, Kid: k.Kid, + N: b64(key.N.Bytes()), E: b64(big.NewInt(int64(key.E)).Bytes()), + }, nil + } + return jwkDocument{}, errors.New("wristband: unsupported public key type") +} + +// idTokenInput carries the claims of one id_token (PHP TokenIssuer). +type idTokenInput struct { + Issuer string + Subject string + Audience string + TTL time.Duration + Now time.Time + Nonce string + AccessToken string + Identity map[string]any + Allowed []string +} + +// protocolClaims are the claims the engine owns; an identity claim with one +// of these names is never copied (T-19.4-11). +var protocolClaims = map[string]bool{ + "iss": true, "sub": true, "aud": true, "exp": true, "iat": true, + "nonce": true, "at_hash": true, "auth_time": true, "azp": true, "nbf": true, "jti": true, +} + +// 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 +// 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) { + method, err := signingMethodFor(key) + if err != nil { + return "", err + } + claims := jwt.MapClaims{} + for _, name := range in.Allowed { + if protocolClaims[name] { + continue + } + if v, ok := in.Identity[name]; ok { + claims[name] = v + } + } + claims["iss"] = in.Issuer + claims["sub"] = in.Subject + claims["aud"] = in.Audience + claims["exp"] = in.Now.Add(in.TTL).Unix() + claims["iat"] = in.Now.Unix() + if in.Nonce != "" { + claims["nonce"] = in.Nonce + } + if in.AccessToken != "" { + claims["at_hash"] = atHash(in.AccessToken) + } + claims["sub"] = in.Subject + + tok := jwt.NewWithClaims(method, claims) + tok.Header["kid"] = key.Kid + signed, err := tok.SignedString(key.Signer) + if err != nil { + return "", errors.New("wristband: id_token signing failed") + } + return signed, nil +} + +// signingMethodFor derives the JWS method from the key type and refuses a +// key whose Alg does not match it (T-19.4-08). +func signingMethodFor(key SigningKey) (jwt.SigningMethod, error) { + switch key.Alg { + case AlgRS256: + if _, ok := key.Signer.(*rsa.PrivateKey); ok { + return jwt.SigningMethodRS256, nil + } + } + return nil, errors.New("wristband: signing key does not match its algorithm") +} + +// atHash is base64url of the left half of SHA-256 over the access token +// (OIDC Core §3.1.3.6; RS256 and ES256 both hash with SHA-256). +func atHash(accessToken string) string { + sum := sha256.Sum256([]byte(accessToken)) + return base64.RawURLEncoding.EncodeToString(sum[:16]) +} diff --git a/modules/wristband/oidc_options.go b/modules/wristband/oidc_options.go new file mode 100644 index 00000000..2bdcd3f1 --- /dev/null +++ b/modules/wristband/oidc_options.go @@ -0,0 +1,233 @@ +// 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 ( + "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 +} diff --git a/modules/wristband/oidc_redirect.go b/modules/wristband/oidc_redirect.go new file mode 100644 index 00000000..0dd66ee9 --- /dev/null +++ b/modules/wristband/oidc_redirect.go @@ -0,0 +1,79 @@ +package wristband + +import ( + "fmt" + "strings" +) + +// phpURLEncode ports PHP urlencode (the encoder behind http_build_query's +// default PHP_QUERY_RFC1738): A-Z a-z 0-9 . _ - pass, a space becomes +, +// every other byte becomes an uppercase %XX triplet, so a tilde is %7E. +// The MCP flavour's rfc3986Escape must not be used for OIDC redirects +// (D-14, Pitfall 2). +func phpURLEncode(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-': + b.WriteByte(c) + case c == ' ': + b.WriteByte('+') + default: + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} + +// appendPHPQuery appends pairs to uri in the given order with +// http_build_query semantics, using & when uri already carries a query and +// ? otherwise (PHP str_contains($uri, '?') ? '&' : '?'). +func appendPHPQuery(uri string, pairs [][2]string) string { + parts := make([]string, len(pairs)) + for i, p := range pairs { + parts[i] = phpURLEncode(p[0]) + "=" + phpURLEncode(p[1]) + } + sep := "?" + if strings.Contains(uri, "?") { + sep = "&" + } + return uri + sep + strings.Join(parts, "&") +} + +// phpURLDecode ports PHP urldecode: + becomes a space, a % followed by two +// hex digits becomes that byte, anything else (a lone or malformed %) is +// kept as it is. +func phpURLDecode(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == '+': + b.WriteByte(' ') + case c == '%' && i+2 < len(s) && isHex(s[i+1]) && isHex(s[i+2]): + b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2])) + i += 2 + default: + b.WriteByte(c) + } + } + return b.String() +} + +func isHex(c byte) bool { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') +} + +func unhex(c byte) byte { + switch { + case c >= '0' && c <= '9': + return c - '0' + case c >= 'a' && c <= 'f': + return c - 'a' + 10 + default: + return c - 'A' + 10 + } +} diff --git a/modules/wristband/oidc_stores.go b/modules/wristband/oidc_stores.go new file mode 100644 index 00000000..63fa6a0e --- /dev/null +++ b/modules/wristband/oidc_stores.go @@ -0,0 +1,227 @@ +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 +} diff --git a/modules/wristband/oidc_subject.go b/modules/wristband/oidc_subject.go new file mode 100644 index 00000000..9e1475b6 --- /dev/null +++ b/modules/wristband/oidc_subject.go @@ -0,0 +1,39 @@ +package wristband + +import ( + "context" + "errors" + "uuid" +) + +// The subject types a client can have (D-15). +const ( + // SubjectTypePublic gives a user the same sub at every public client. + SubjectTypePublic = "public" + // SubjectTypePairwise gives a user a different sub per sector. + SubjectTypePairwise = "pairwise" +) + +// SubjectSector returns the subject sector of a client: "" for a public +// client. +func SubjectSector(c *OIDCClientRecord) (string, error) { + if c == nil { + return "", errors.New("wristband: no client") + } + switch c.SubjectType { + case "", SubjectTypePublic: + return "", nil + } + return "", errors.New("wristband: unknown subject type") +} + +// subjectFor allocates (or reads) the subject of userID at client: a random +// UUIDv4 per (user, sector), never derived from an account attribute +// (T-19.4-13). +func subjectFor(ctx context.Context, tx OIDCTx, client *OIDCClientRecord, userID uint) (string, error) { + sector, err := SubjectSector(client) + if err != nil { + return "", err + } + return tx.SubjectFor(ctx, userID, sector, uuid.NewV4().String()) +} diff --git a/modules/wristband/oidc_token.go b/modules/wristband/oidc_token.go new file mode 100644 index 00000000..5573cb61 --- /dev/null +++ b/modules/wristband/oidc_token.go @@ -0,0 +1,328 @@ +// OpenID Connect token endpoint (19.4 SC-2, D-14, D-17), ported from the +// PHP provider's TokenController: grant check, client authentication +// (Basic first, urldecoded, then the form), then code redemption in one +// transaction that burns the code before any other check (T-19.4-03). The +// answer carries an opaque access token and an id_token signed by the +// active key. +package wristband + +import ( + "context" + "encoding/base64" + "log/slog" + "net/http" + "strings" +) + +const ( + oidcBasicRealm = `Basic realm="oauth"` + msgClientAuthFailed = "Client authentication failed." + 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." +) + +// dummySecretHash is compared against when the client is unknown, so the +// unknown and the wrong-secret cases cost the same (T-19.4-06). +var dummySecretHash = strings.Repeat("0", 64) + +type oidcTokenBody struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope"` +} + +type oidcErrorBody struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// oidcNoStore is the header set of every token answer, errors included. +// Symfony rewrites the PHP provider's bare no-store to "no-store, private". +func oidcNoStore() map[string]string { + return map[string]string{"Cache-Control": "no-store, private", "Pragma": "no-cache"} +} + +func writeOIDCTokenError(w http.ResponseWriter, status int, code, description string) { + writeExactJSON(w, status, oidcErrorBody{Error: code, ErrorDescription: description}, oidcNoStore()) +} + +// 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 +} + +// oidcToken is the OIDC flavour of Token. +func (s *Server) oidcToken(w http.ResponseWriter, r *http.Request) { + if !s.oidcReady() { + w.WriteHeader(http.StatusInternalServerError) + return + } + _ = r.ParseForm() + grant := inputValue(r, "grant_type") + if grant != "authorization_code" { + writeOIDCTokenError(w, http.StatusBadRequest, "unsupported_grant_type", msgUnsupportedGrant) + return + } + + ctx := r.Context() + clientID, secret := oidcClientCredentials(r) + client, err := s.authenticateOIDCClient(ctx, clientID, secret) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if client == nil { + s.logTokenFailure(r, "invalid_client", clientID) + w.Header().Set("WWW-Authenticate", oidcBasicRealm) + writeOIDCTokenError(w, http.StatusUnauthorized, "invalid_client", msgClientAuthFailed) + return + } + + code := inputValue(r, "code") + redirectURI := inputValue(r, "redirect_uri") + verifier := inputValue(r, "code_verifier") + if code == "" || redirectURI == "" || verifier == "" { + s.logTokenFailure(r, "invalid_request", clientID) + writeOIDCTokenError(w, http.StatusBadRequest, "invalid_request", msgCodeParamsMissing) + return + } + + key, err := s.keys.ActiveSigningKey(ctx) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + out, err := s.redeemCode(ctx, client, key, code, redirectURI, verifier) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if out.reason != "" { + s.logTokenFailure(r, "invalid_grant:"+out.reason, clientID) + writeOIDCTokenError(w, http.StatusBadRequest, "invalid_grant", msgInvalidGrant) + return + } + writeExactJSON(w, http.StatusOK, out.body, oidcNoStore()) +} + +// oidcClientCredentials ports TokenController::clientCredentials: an +// Authorization header whose scheme is "Basic " in any case, strictly +// base64, split on the first colon, both halves urldecoded; an empty id +// falls back to the client_id and client_secret form fields. +func oidcClientCredentials(r *http.Request) (string, string) { + h := r.Header.Get("Authorization") + if len(h) >= 6 && strings.EqualFold(h[:6], "basic ") { + payload := h[6:] + decoded, err := base64.StdEncoding.Strict().DecodeString(payload) + if err != nil { + decoded, err = base64.RawStdEncoding.Strict().DecodeString(payload) + } + if err == nil { + if id, sec, ok := strings.Cut(string(decoded), ":"); ok { + id = phpURLDecode(id) + if id != "" { + return id, phpURLDecode(sec) + } + } + } + } + return inputValue(r, "client_id"), inputValue(r, "client_secret") +} + +// authenticateOIDCClient returns the client when the credentials are +// valid, nil otherwise. Unknown, inactive, non-OIDC, a secret without the +// configured prefix and a wrong secret all fail alike; an unknown client +// still runs one constant-time comparison (T-19.4-06). +func (s *Server) authenticateOIDCClient(ctx context.Context, clientID, secret string) (*OIDCClientRecord, error) { + var client *OIDCClientRecord + if clientID != "" { + if err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { + c, err := tx.OIDCClientByClientID(ctx, clientID) + client = c + return err + }); err != nil { + return nil, err + } + } + if client == nil { + constantEqual(dummySecretHash, sha256Hex(secret)) + return nil, nil + } + if !client.IsActive || !s.verifyClientSecret(client, secret) { + return nil, nil + } + return client, nil +} + +// verifyClientSecret ports Client::verifySecret: a non-empty secret with +// the configured prefix whose SHA-256 hex matches in constant time. +func (s *Server) verifyClientSecret(client *OIDCClientRecord, secret string) bool { + if secret == "" || client.SecretHash == nil || *client.SecretHash == "" { + return false + } + if !strings.HasPrefix(secret, s.oidc.ClientSecretPrefix) { + return false + } + return constantEqual(*client.SecretHash, sha256Hex(secret)) +} + +// redeemCode runs the authorization_code grant in one OIDC transaction. +// The code is consumed first; a replay of an already consumed code revokes +// the tokens issued from it and the revocation commits although the answer +// is invalid_grant (T-19.4-03). +func (s *Server) redeemCode(ctx context.Context, client *OIDCClientRecord, key SigningKey, code, redirectURI, verifier string) (oidcTokenOutcome, error) { + var out oidcTokenOutcome + err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { + now := s.now() + if !strings.HasPrefix(code, s.oidc.CodePrefix) { + out.reason = "unknown_or_expired" + return nil + } + hash := sha256Hex(code) + rec, err := tx.ConsumeCode(ctx, hash, now) + if err != nil { + return err + } + if rec == nil { + spent, err := tx.CodeByHash(ctx, hash) + if err != nil { + return err + } + if spent != nil && spent.ConsumedAt != nil { + if err := tx.RevokeAccessTokensForCode(ctx, spent.ID, now); err != nil { + return err + } + out.reason = "replay" + return nil + } + out.reason = "unknown_or_expired" + return nil + } + switch { + case rec.ClientID != client.ClientID: + out.reason = "client_mismatch" + return nil + case rec.RedirectURI != redirectURI: + out.reason = "redirect_uri_mismatch" + return nil + case !verifyOIDCPkce(verifier, rec.CodeChallenge, rec.CodeChallengeMethod): + out.reason = "pkce" + return nil + } + identity, ok, err := tx.IdentityClaims(ctx, rec.UserID, rec.Scopes) + if err != nil { + return err + } + if !ok { + out.reason = "no_user" + return nil + } + + access, err := s.mintAccessToken(ctx, tx, client.ClientID, rec.UserID, &rec.ID, rec.Scopes) + if err != nil { + return err + } + sub, err := subjectFor(ctx, tx, client, rec.UserID) + if err != nil { + return err + } + nonce := "" + if rec.Nonce != nil { + nonce = *rec.Nonce + } + idToken, err := signIDToken(key, idTokenInput{ + Issuer: s.oidcIssuer(), + Subject: sub, + Audience: client.ClientID, + TTL: s.oidc.IDTokenTTL, + Now: now, + Nonce: nonce, + AccessToken: access.raw, + Identity: identity, + Allowed: s.oidc.IdentityClaims, + }) + if err != nil { + return err + } + consent, err := tx.ConsentFor(ctx, rec.UserID, client.ClientID) + if err != nil { + return err + } + if consent != nil { + if err := tx.TouchConsent(ctx, consent.ID, now); err != nil { + return err + } + } + out.body = oidcTokenBody{ + AccessToken: access.raw, + TokenType: "Bearer", + ExpiresIn: int64(s.oidc.AccessTokenTTL.Seconds()), + IDToken: idToken, + Scope: strings.Join(rec.Scopes, " "), + } + return nil + }) + return out, err +} + +// mintedAccessToken is a freshly created access token. +type mintedAccessToken struct { + raw string + id uint +} + +// mintAccessToken creates an opaque access token (AccessTokenPrefix plus +// base64url of 32 random bytes, stored hashed, AccessTokenTTL). +func (s *Server) mintAccessToken(ctx context.Context, tx OIDCTx, clientID string, userID uint, codeID *uint, scopes []string) (mintedAccessToken, error) { + raw, err := s.randomBytes(32) + if err != nil { + return mintedAccessToken{}, err + } + token := s.oidc.AccessTokenPrefix + raw + rec := &OIDCAccessTokenRecord{ + TokenHash: sha256Hex(token), + ClientID: clientID, + UserID: userID, + AuthCodeID: codeID, + Scopes: scopes, + ExpiresAt: s.now().Add(s.oidc.AccessTokenTTL), + } + if err := tx.CreateAccessToken(ctx, rec); err != nil { + return mintedAccessToken{}, err + } + return mintedAccessToken{raw: token, id: rec.ID}, nil +} + +// verifyOIDCPkce ports PkceVerifier::verify: S256 only, a non-empty +// challenge, a verifier of 43 to 128 characters from A-Z a-z 0-9 - . _ ~, +// compared in constant time (T-19.4-02). +func verifyOIDCPkce(verifier, challenge, method string) bool { + if method != "S256" || challenge == "" { + return false + } + if len(verifier) < 43 || len(verifier) > 128 { + return false + } + for i := 0; i < len(verifier); i++ { + c := verifier[i] + if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~') { + return false + } + } + return constantEqual(s256Challenge(verifier), challenge) +} + +// logTokenFailure logs a rejected exchange with the client ID, the client +// IP and a fixed reason only; codes, tokens and secrets are never logged +// (T-19.4-09). +func (s *Server) logTokenFailure(r *http.Request, reason, clientID string) { + if clientID == "" { + clientID = "(absent)" + } + slog.Warn("wristband: oidc token exchange rejected", "reason", reason, "client_id", clientID, "ip", clientIP(r)) +} diff --git a/modules/wristband/oidc_tracer_test.go b/modules/wristband/oidc_tracer_test.go new file mode 100644 index 00000000..aed40617 --- /dev/null +++ b/modules/wristband/oidc_tracer_test.go @@ -0,0 +1,270 @@ +package wristband + +import ( + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/golang-jwt/jwt/v5" +) + +// oidcTestVerifier is a fixed PKCE verifier (43 characters). +const oidcTestVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + +// noRedirectClient returns an HTTP client that never follows redirects. +func noRedirectClient() *http.Client { + return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} +} + +func readBody(t *testing.T, res *http.Response) []byte { + t.Helper() + defer res.Body.Close() + b, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + return b +} + +// startOIDCTestServer serves the fixture's routes on an httptest server +// whose URL is the issuer. +func startOIDCTestServer(t *testing.T, mutate func(*OIDCOptions)) (*oidcFixture, *httptest.Server) { + t.Helper() + ts := httptest.NewUnstartedServer(nil) + f := newOIDCFixture(t, "http://"+ts.Listener.Addr().String(), mutate) + ts.Config.Handler = f.mux() + ts.Start() + t.Cleanup(ts.Close) + return f, ts +} + +// rsaKeyFromJWK rebuilds an RSA public key from a served JWK with the +// standard library only. +func rsaKeyFromJWK(t *testing.T, k map[string]any) *rsa.PublicKey { + t.Helper() + n, err := base64.RawURLEncoding.DecodeString(k["n"].(string)) + if err != nil { + t.Fatal(err) + } + e, err := base64.RawURLEncoding.DecodeString(k["e"].(string)) + if err != nil { + t.Fatal(err) + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())} +} + +// TestOIDCTracerSmoke drives a relying party end to end over the memory +// fakes: discovery, JWKS, authorize as a signed-in user, resume, consent, +// code exchange with client_secret_basic, id_token verification with +// golang-jwt against the served JWKS, and userinfo. +func TestOIDCTracerSmoke(t *testing.T) { + f, ts := startOIDCTestServer(t, nil) + hc := noRedirectClient() + + // Discovery: the relying party uses only the URLs it reads here. + res, err := hc.Get(ts.URL + "/.well-known/openid-configuration") + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK { + t.Fatalf("discovery status = %d", res.StatusCode) + } + if cc := res.Header.Get("Cache-Control"); cc != "max-age=3600, public" { + t.Fatalf("discovery Cache-Control = %q", cc) + } + var doc map[string]any + if err := json.Unmarshal(readBody(t, res), &doc); err != nil { + t.Fatal(err) + } + if doc["issuer"] != ts.URL { + t.Fatalf("issuer = %v, want %s", doc["issuer"], ts.URL) + } + if fmt.Sprint(doc["id_token_signing_alg_values_supported"]) != "[RS256]" { + t.Fatalf("algs = %v", doc["id_token_signing_alg_values_supported"]) + } + + // JWKS. + res, err = hc.Get(doc["jwks_uri"].(string)) + if err != nil { + t.Fatal(err) + } + var jwks struct { + Keys []map[string]any `json:"keys"` + } + if err := json.Unmarshal(readBody(t, res), &jwks); err != nil { + t.Fatal(err) + } + if len(jwks.Keys) != 1 || jwks.Keys[0]["kty"] != "RSA" || jwks.Keys[0]["use"] != "sig" || jwks.Keys[0]["alg"] != "RS256" { + t.Fatalf("jwks = %v", jwks.Keys) + } + + // Authorize as a signed-in user. + f.host.signIn(7) + authz := doc["authorization_endpoint"].(string) + q := url.Values{ + "response_type": {"code"}, + "client_id": {f.client.ClientID}, + "redirect_uri": {testOIDCRedirect}, + "scope": {"openid profile email"}, + "state": {"st-1"}, + "nonce": {"n-123"}, + "code_challenge": {s256Challenge(oidcTestVerifier)}, + "code_challenge_method": {"S256"}, + } + res, err = hc.Get(authz + "?" + q.Encode()) + if err != nil { + t.Fatal(err) + } + readBody(t, res) + loc := res.Header.Get("Location") + if res.StatusCode != http.StatusFound || !strings.HasPrefix(loc, "/oauth/authorize/resume/") { + t.Fatalf("authorize = %d %q", res.StatusCode, loc) + } + + // Resume renders consent through the host. + res, err = hc.Get(ts.URL + loc) + if err != nil { + t.Fatal(err) + } + readBody(t, res) + if res.StatusCode != http.StatusOK || res.Header.Get("X-Frame-Options") != "DENY" { + t.Fatalf("resume = %d, X-Frame-Options %q", res.StatusCode, res.Header.Get("X-Frame-Options")) + } + view := f.host.lastView(t) + if strings.Join(view.Scopes, " ") != "profile email" || view.ConsentToken == "" { + t.Fatalf("view = %+v", view) + } + + // Allow. + res, err = hc.PostForm(authz, url.Values{ + "request_id": {view.RequestID}, + "consent_token": {view.ConsentToken}, + "decision": {"allow"}, + }) + if err != nil { + t.Fatal(err) + } + readBody(t, res) + back, err := url.Parse(res.Header.Get("Location")) + if err != nil || res.StatusCode != http.StatusFound || !strings.HasPrefix(back.String(), testOIDCRedirect+"?code=oas_ac_") { + t.Fatalf("decide = %d %q", res.StatusCode, res.Header.Get("Location")) + } + if back.Query().Get("state") != "st-1" || back.Query().Has("iss") { + t.Fatalf("decide query = %v", back.Query()) + } + code := back.Query().Get("code") + + // Exchange with client_secret_basic, credentials form-encoded as the + // x/oauth2 client sends them. + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {testOIDCRedirect}, + "code_verifier": {oidcTestVerifier}, + } + req, _ := http.NewRequest(http.MethodPost, doc["token_endpoint"].(string), strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(url.QueryEscape(f.client.ClientID), url.QueryEscape(f.clientSecret)) + res, err = hc.Do(req) + if err != nil { + t.Fatal(err) + } + body := readBody(t, res) + if res.StatusCode != http.StatusOK { + t.Fatalf("token = %d %s", res.StatusCode, body) + } + if res.Header.Get("Cache-Control") != "no-store, private" || res.Header.Get("Pragma") != "no-cache" { + t.Fatalf("token headers = %v", res.Header) + } + var tok struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + IDToken string `json:"id_token"` + Scope string `json:"scope"` + } + if err := json.Unmarshal(body, &tok); err != nil { + t.Fatal(err) + } + if tok.TokenType != "Bearer" || tok.ExpiresIn != 3600 || tok.Scope != "openid profile email" || !strings.HasPrefix(tok.AccessToken, "oas_at_") { + t.Fatalf("token body = %s", body) + } + + // Verify the id_token with golang-jwt against the served JWKS only. + keyfunc := func(tk *jwt.Token) (any, error) { + kid, _ := tk.Header["kid"].(string) + for _, k := range jwks.Keys { + if k["kid"] == kid { + return rsaKeyFromJWK(t, k), nil + } + } + return nil, fmt.Errorf("unknown kid %q", kid) + } + claims := jwt.MapClaims{} + if _, err := jwt.ParseWithClaims(tok.IDToken, claims, keyfunc, + jwt.WithValidMethods([]string{"RS256"}), jwt.WithIssuer(ts.URL), jwt.WithAudience(f.client.ClientID)); err != nil { + t.Fatalf("id_token does not verify: %v", err) + } + payload, err := base64.RawURLEncoding.DecodeString(strings.Split(tok.IDToken, ".")[1]) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(payload, &raw); err != nil { + t.Fatal(err) + } + if string(raw["aud"]) != `"`+f.client.ClientID+`"` { + t.Fatalf("aud = %s, want a JSON string", raw["aud"]) + } + wantSub := f.backend.subject(7, "") + if wantSub == "" || claims["sub"] != wantSub { + t.Fatalf("sub = %v, want %q", claims["sub"], wantSub) + } + if claims["nonce"] != "n-123" { + t.Fatalf("nonce = %v", claims["nonce"]) + } + sum := sha256.Sum256([]byte(tok.AccessToken)) + if claims["at_hash"] != base64.RawURLEncoding.EncodeToString(sum[:16]) { + t.Fatalf("at_hash = %v", claims["at_hash"]) + } + if claims["iss"] != ts.URL || claims["email"] != "ada@example.test" || claims["x_extra"] != nil { + t.Fatalf("claims = %v", claims) + } + if _, ok := claims["auth_time"]; ok { + t.Fatal("auth_time must not be emitted") + } + if _, ok := claims["azp"]; ok { + t.Fatal("azp must not be emitted") + } + + // Userinfo answers the same subject. + req, _ = http.NewRequest(http.MethodGet, doc["userinfo_endpoint"].(string), nil) + req.Header.Set("Authorization", "Bearer "+tok.AccessToken) + res, err = hc.Do(req) + if err != nil { + t.Fatal(err) + } + body = readBody(t, res) + if res.StatusCode != http.StatusOK { + t.Fatalf("userinfo = %d %s", res.StatusCode, body) + } + var info map[string]any + if err := json.Unmarshal(body, &info); err != nil { + t.Fatal(err) + } + if info["sub"] != wantSub || info["email"] != "ada@example.test" || info["iss"] != nil { + t.Fatalf("userinfo = %s", body) + } + if !strings.HasPrefix(string(body), `{"sub":`) { + t.Fatalf("userinfo must start with sub: %s", body) + } +} diff --git a/modules/wristband/oidc_userinfo.go b/modules/wristband/oidc_userinfo.go new file mode 100644 index 00000000..55201654 --- /dev/null +++ b/modules/wristband/oidc_userinfo.go @@ -0,0 +1,151 @@ +package wristband + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" +) + +// UserInfo handles GET /oauth/userinfo on an OIDC server (404 on an MCP +// server). A valid bearer access token answers sub (the same subject the +// id_token carried for that client) plus the identity claims of the +// token's scopes, filtered by OIDCOptions.IdentityClaims. Every failure is +// one uniform 401 with an empty body and WWW-Authenticate: Bearer +// error="invalid_token" (T-19.4-10). +func (s *Server) UserInfo(w http.ResponseWriter, r *http.Request) { + if s.flavour != flavourOIDC { + w.WriteHeader(http.StatusNotFound) + return + } + if !s.oidcReady() { + w.WriteHeader(http.StatusInternalServerError) + return + } + presented := bearerToken(r) + if presented == "" || !strings.HasPrefix(presented, s.oidc.AccessTokenPrefix) { + writeUserInfoUnauthorized(w) + return + } + + ctx := r.Context() + var claims map[string]any + var sub string + err := s.oidcBackend.WithinOIDCTx(ctx, func(tx OIDCTx) error { + now := s.now() + tok, err := tx.AccessTokenByHash(ctx, sha256Hex(presented)) + if err != nil { + return err + } + if tok == nil || tok.RevokedAt != nil || !tok.ExpiresAt.After(now) { + return nil + } + client, err := tx.OIDCClientByClientID(ctx, tok.ClientID) + if err != nil { + return err + } + if client == nil || !client.IsActive { + return nil + } + identity, ok, err := tx.IdentityClaims(ctx, tok.UserID, tok.Scopes) + if err != nil { + return err + } + if !ok { + return nil + } + subject, err := subjectFor(ctx, tx, client, tok.UserID) + if err != nil { + return err + } + if err := tx.TouchAccessToken(ctx, tok.ID, now); err != nil { + return err + } + claims, sub = identity, subject + return nil + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if sub == "" { + writeUserInfoUnauthorized(w) + return + } + body, err := orderedClaimsJSON(sub, claims, s.oidc.IdentityClaims) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + h := w.Header() + h.Set("Content-Type", "application/json") + h.Set("Cache-Control", "no-store, private") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +func writeUserInfoUnauthorized(w http.ResponseWriter) { + h := w.Header() + h.Set("WWW-Authenticate", `Bearer error="invalid_token"`) + h.Set("Cache-Control", "no-store, private") + w.WriteHeader(http.StatusUnauthorized) +} + +// bearerToken mirrors Laravel's Request::bearerToken: the text after the +// last "Bearer " (any case) in the Authorization header, cut at a comma. +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + idx := strings.LastIndex(strings.ToLower(h), "bearer ") + if idx < 0 { + return "" + } + tok := h[idx+len("bearer "):] + if i := strings.IndexByte(tok, ','); i >= 0 { + tok = tok[:i] + } + return tok +} + +// orderedClaimsJSON writes sub and the allowlisted identity claims in the +// allowlist's order (the PHP ClaimBuilder order). Protocol claim names are +// never copied from the identity map (T-19.4-11). +func orderedClaimsJSON(sub string, identity map[string]any, allowed []string) ([]byte, error) { + var buf bytes.Buffer + enc := func(v any) error { + var b bytes.Buffer + e := json.NewEncoder(&b) + e.SetEscapeHTML(false) + if err := e.Encode(v); err != nil { + return err + } + buf.Write(bytes.TrimSuffix(b.Bytes(), []byte("\n"))) + return nil + } + buf.WriteByte('{') + if err := enc("sub"); err != nil { + return nil, err + } + buf.WriteByte(':') + if err := enc(sub); err != nil { + return nil, err + } + for _, name := range allowed { + if protocolClaims[name] { + continue + } + v, ok := identity[name] + if !ok { + continue + } + buf.WriteByte(',') + if err := enc(name); err != nil { + return nil, err + } + buf.WriteByte(':') + if err := enc(v); err != nil { + return nil, err + } + } + buf.WriteByte('}') + return buf.Bytes(), nil +} diff --git a/modules/wristband/register.go b/modules/wristband/register.go index d5ff4538..2fdafaba 100644 --- a/modules/wristband/register.go +++ b/modules/wristband/register.go @@ -54,7 +54,14 @@ type rfcErrorBody struct { // the endpoint's ordinary invalid_client_metadata response, not a generic // error. Sweep, the cap check and the create all run inside one transaction // (T-08-DCR-FLOOD). +// +// On an OIDC server it answers 404 with an empty body: OIDC clients are +// registered by an administrator, never dynamically (D-08). func (s *Server) Register(w http.ResponseWriter, r *http.Request) { + if s.flavour == flavourOIDC { + w.WriteHeader(http.StatusNotFound) + return + } if !isJSONContentType(r.Header.Get("Content-Type")) { writeRegisterError(w, http.StatusBadRequest, "invalid_client_metadata", "Request must be application/json") return diff --git a/modules/wristband/server.go b/modules/wristband/server.go index 98ee3242..e7cccaed 100644 --- a/modules/wristband/server.go +++ b/modules/wristband/server.go @@ -111,6 +111,14 @@ type Server struct { opts Options backend Backend + // flavour selects MCP (zero value, NewServer) or OIDC (NewOIDCServer). + // The OIDC fields stay zero on an MCP server. + flavour flavour + oidc OIDCOptions + oidcBackend OIDCBackend + keys KeyStore + host OIDCHost + // now and randomBytes are deterministic clock/entropy seams so tests // can control timestamps and generated secrets without depending on // wall-clock time or true randomness (08-02-PLAN.md Task 2). @@ -160,7 +168,14 @@ type metadataDocument struct { // protocol constants, not Options: this phase's authorization server only // ever supports the authorization_code/refresh_token grants with S256 PKCE // (D-01), so there is nothing app-specific to configure there. +// +// On an OIDC server it answers 404 with an empty body: the OIDC flavour +// publishes Discovery instead. func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) { + if s.flavour == flavourOIDC { + w.WriteHeader(http.StatusNotFound) + return + } doc := metadataDocument{ Issuer: s.opts.Issuer, AuthorizationEndpoint: s.opts.Issuer + "/oauth/mcp/authorize", diff --git a/modules/wristband/token.go b/modules/wristband/token.go index f2f7c08e..b498a29b 100644 --- a/modules/wristband/token.go +++ b/modules/wristband/token.go @@ -60,7 +60,14 @@ type tokenErrorBody struct { // ParseForm precedence puts body values ahead of query values — verified // against the Go 1.27 stdlib source, not assumed). Basic credentials, when // present, always override client_id/client_secret form values. +// +// On an OIDC server it dispatches to the OIDC flavour before any MCP code +// runs. func (s *Server) Token(w http.ResponseWriter, r *http.Request) { + if s.flavour == flavourOIDC { + s.oidcToken(w, r) + return + } if isJSONContentType(r.Header.Get("Content-Type")) { writeTokenError(w, http.StatusBadRequest, "invalid_request") return