Files
summercms/modules/wristband/README.md
Jakub Zych ddda59a12b feat(19.4-01): neutral wristband resource default and OIDC provider docs
- DefaultOptions().Resource is empty; comments name the host application,
  no consuming application (folded todo)
- Tests set an explicit neutral resource; TestDefaultOptionsResourceEmpty
- wristband README: new summary, two flavours, OIDC usage, API reference,
  OIDC defaults, dependencies, testing; root README row matches
- docs/services/oauth-server.md: OpenID Connect provider section;
  Resource described as required with an empty default
- ExampleNewOIDCServer and the newOIDCServer docs region
2026-10-19 18:32:03 +02:00

235 lines
22 KiB
Markdown

# wristband
An OAuth authorization server and OpenID Connect provider: RFC 8414 metadata, dynamic client registration, discovery, JWKS, id_token, userinfo, resumable consent and refresh-token rotation over application-supplied storage.
`import "git.golem15.com/golem15/summercms/modules/wristband"`
**Name:** The festival wristband that lets you in for the event; it issues OAuth access for MCP clients and signs users in to other sites with OpenID Connect.
## Overview
wristband implements the protocol side of an OAuth authorization server in two flavours that share one engine. The MCP flavour (`wristband.NewServer`) lets MCP clients (AI assistants and connectors) act on behalf of the application's users: metadata, registration, authorize and token handlers plus Go methods the application's own consent screen calls. The OpenID Connect flavour (`wristband.NewOIDCServer`) lets other sites sign users in with the application's accounts ("Login with ..."): discovery, JWKS, authorize with a resumable request and consent, a token endpoint that adds a signed `id_token`, and userinfo. It is wire-compatible with the PHP (WinterCMS) OpenID Connect provider it replaces, so existing relying parties switch without noticing.
Both flavours reuse the same primitives: PKCE, SHA-256 hashing of every secret, constant-time comparison, random identifiers and the redirect writer. Everything application-specific stays outside the package. The MCP flavour gets storage through `wristband.Backend` and `wristband.Tx` and access tokens from the application's `wristband.AccessTokenIssuer`. The OIDC flavour gets storage through `wristband.OIDCBackend` and `wristband.OIDCTx`, signing keys through `wristband.KeyStore`, and the signed-in user, the consent and error pages and sign-out through `wristband.OIDCHost`. Issuer, scopes and lifetimes come from `wristband.Options` and `wristband.OIDCOptions`. It imports no ORM and no application package. WinterCMS core has no counterpart.
## Role in the framework
A standalone protocol leaf: it imports no framework module and no ORM. The application (or a shared plugin) supplies storage, keys and pages through interfaces. It is separate because OAuth and OpenID Connect are security code that should be reviewed on its own, and only applications that serve MCP clients or relying parties need it.
Use cases:
- Let AI assistants and MCP connectors act on behalf of the application's users.
- Register clients dynamically and complete the authorization-code flow with PKCE.
- Be the identity provider other sites use for "Login with ...", with consent, account switching, public or pairwise subjects and key rotation.
- Rotate refresh tokens and drive the application's own consent screen.
## Features
- RFC 8414 metadata document (`wristband.Server.Metadata`) advertising the authorize, token and registration endpoints under the issuer, the `authorization_code` and `refresh_token` grants, S256 PKCE and the RFC 9207 `iss` response parameter.
- RFC 7591 dynamic client registration (`wristband.Server.Register`): JSON only, a bounded request body, redirect URI validation (HTTPS, or loopback HTTP), public (`none`) and confidential (`client_secret_post`, `client_secret_basic`) clients, a cap on unrevoked clients and a sweep of old clients that never got consent, all in one transaction.
- Authorization endpoint (`wristband.Server.Authorize`): the client and its exact registered redirect URI are validated before any redirect is sent (an unknown client gets a local plain-text 400, never an open redirect); then S256 PKCE, the client's scope ceiling and the RFC 8707 `resource` value are checked, a pending request is stored and the browser is sent to the application's consent page at `<issuer>/connect?request=<id>`. Accepted scopes are `read`, `write`, `ai` and `offline_access`.
- 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.
- OIDC authorization follows the PHP provider's contract: validation in its order with its messages; unknown clients, unregistered redirect URIs and inactive clients rendered by the host (never redirected); error redirects encoded like PHP `http_build_query` without `iss`; an anonymous user sent to the login URL with only `redirect=<resume path>`; silent resume when consent already covers the request, incremental consent listing only new scopes, a one-shot consent token bound to the user, deny, and `wristband.Server.SwitchAccount`. `wristband.Server.ConsentRequest` and `wristband.Server.DecideConsent` give a host's own consent page the same logic as JSON operations. Dev clients may vary the port of a loopback redirect URI.
- OIDC token and userinfo follow the PHP contract: client authentication by Basic credentials (urldecoded) or form fields with one uniform `invalid_client` answer and `Basic realm="oauth"`, codes burned before any other check so a replayed code revokes the tokens issued from it, `{error, error_description}` bodies with `no-store, private` and `Pragma: no-cache`, and one uniform userinfo 401 with an empty body. The `id_token` carries `iss`, `sub`, `aud` as a string, `exp`, `iat`, `nonce` when sent, `at_hash` and only allowlisted identity claims (no `auth_time`, no `azp`).
- Keys and subjects: RS256 and ES256 keys (`wristband.GenerateSigningKey`), a mixed ring during a switch with discovery advertising what the ring holds, retired keys published through their grace window; public subjects by default and pairwise subjects per sector (`wristband.SubjectSector`, `wristband.ErrSectorRequired`), always random UUIDv4 values stored per user and sector.
- Opt-in refresh for OIDC (`wristband.OIDCOptions.RefreshEnabled`, off by default): only a client with `wristband.OIDCClientRecord.OfflineAccess` granted `offline_access` receives a rotating `oas_rt_` refresh token; a refresh returns a fresh `id_token` without `nonce`, and replaying a rotated token revokes the lineage and its access tokens.
- Client kinds: `wristband.ClientRecord.Kind` is `wristband.ClientKindMCP` (or empty) for MCP clients and `wristband.ClientKindOIDC` for administrator-registered OIDC clients. Each flavour accepts only its own kind at authorize, token and userinfo, and the MCP flavour keeps refusing the `openid` scope, so a dynamically registered client never receives identity scopes or an `id_token`.
- 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
```go
package oauth
import (
"context"
"encoding/json"
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/wristband"
)
func NewServer(backend wristband.Backend) *wristband.Server {
opts := wristband.DefaultOptions()
opts.Issuer = "https://blog.example.com" // app URL without a trailing slash
opts.Resource = "https://blog.example.com/mcp"
opts.ScopesSupported = []string{"read", "write", "offline_access"}
srv := wristband.NewServer(opts)
srv.SetBackend(backend) // the application's transactional store adapter
return srv
}
// Routes mounts the RFC endpoints in a raw group: no JSON envelope middleware.
func Routes(r pact.Router, srv *wristband.Server) {
r.GroupRaw("", nil, func(g pact.Router) {
g.Get("/.well-known/oauth-authorization-server", srv.Metadata)
g.Get("/oauth/mcp/authorize", srv.Authorize)
g.Post("/oauth/mcp/token", srv.Token)
g.Post("/oauth/mcp/register", srv.Register)
})
}
// Approve is called by the application's consent handler for a signed-in user.
func Approve(ctx context.Context, w http.ResponseWriter, srv *wristband.Server, requestID string, userID uint) error {
redirectTo, err := srv.IssueCode(ctx, requestID, userID, []string{"read"}, nil)
if err != nil {
return err // wristband.ErrPendingNotFound, wristband.ErrNoGrantableScopes, ...
}
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(map[string]string{"redirect_to": redirectTo})
}
```
The OpenID Connect flavour is a second server on the same engine. The host (usually a shared plugin) implements `wristband.OIDCBackend`, `wristband.KeyStore` and `wristband.OIDCHost`, then mounts the handlers at the paths discovery advertises:
```go
func NewProvider(backend wristband.OIDCBackend, keys wristband.KeyStore, host wristband.OIDCHost) (*wristband.Server, error) {
opts := wristband.DefaultOptions()
opts.Issuer = "https://id.example.com"
oidc := wristband.DefaultOIDCOptions()
oidc.LoginURL = "/account/login" // anonymous users land here with ?redirect=<resume path>
srv := wristband.NewOIDCServer(opts, oidc)
if err := srv.SetOIDCBackend(backend, keys, host); err != nil {
return nil, err // nil argument, or called on an MCP server
}
return srv, nil
}
func ProviderRoutes(r pact.Router, srv *wristband.Server) {
r.GroupRaw("", nil, func(g pact.Router) {
g.Get("/.well-known/openid-configuration", srv.Discovery)
g.Get("/oauth/jwks", srv.JWKS)
g.Get("/oauth/authorize", srv.Authorize) // start
g.Post("/oauth/authorize", srv.Authorize) // consent decision
g.Get("/oauth/authorize/resume/{request}", srv.Resume)
g.Post("/oauth/authorize/switch-account", srv.SwitchAccount)
g.Post("/oauth/token", srv.Token)
g.Get("/oauth/userinfo", srv.UserInfo)
})
}
```
`wristband.OIDCHost.RenderConsent` receives a `wristband.ConsentView`; the page posts `request_id`, `consent_token` and `decision` (`allow` or anything else) back to `POST /oauth/authorize`, or `request_id` and `consent_token` to the switch-account route. A host whose consent page lives in its own frontend redirects there from `wristband.OIDCHost.RenderConsent` and finishes through `wristband.Server.ConsentRequest` and `wristband.Server.DecideConsent`.
## API reference
| Identifier | Description |
|------------|-------------|
| `wristband.Server` | The authorization server; `wristband.NewServer` builds the MCP flavour from `wristband.Options`, `wristband.NewOIDCServer` the OpenID Connect flavour. |
| `wristband.Server.SetBackend` | Attaches the application's store bundle; handlers that need storage return 500 until it is set. |
| `wristband.Server.Metadata` | Handler for `GET /.well-known/oauth-authorization-server`. |
| `wristband.Server.Register` | Handler for `POST /oauth/mcp/register` (RFC 7591). |
| `wristband.Server.Authorize` | Handler for `GET /oauth/mcp/authorize`; on an OIDC server `GET /oauth/authorize` (start) and `POST /oauth/authorize` (consent decision). |
| `wristband.Server.Token` | Handler for `POST /oauth/mcp/token` (authorization code and refresh token grants); on an OIDC server `POST /oauth/token` with an `id_token`. |
| `wristband.Server.PendingRequest` | Returns a `wristband.PendingRequestView` for a user's pending request. |
| `wristband.Server.IssueCode` | Grants consent and returns the redirect URL carrying the code. |
| `wristband.Server.DenyPending` | Refuses consent and returns the `access_denied` redirect URL. |
| `wristband.Server.Revoke` | Revokes an access token and its refresh-token lineage. |
| `wristband.Options` | Issuer, advertised scopes and auth methods, registration limits, resource indicator and token lifetimes. |
| `wristband.DefaultOptions` | Defaults for every option except `wristband.Options.Issuer`. |
| `wristband.Backend` | Runs a function inside one transaction with a `wristband.Tx`. |
| `wristband.Tx` | Transaction-scoped bundle of the stores and the token issuer. |
| `wristband.ClientStore` | Persists `wristband.ClientRecord` rows: lookup, capped create, sweep, consent stamp. `wristband.ClientRecord.Kind` tells MCP and OIDC clients apart. |
| `wristband.AuthCodeStore` | Persists `wristband.AuthCodeRecord` rows: pending requests and issued codes. |
| `wristband.RefreshTokenStore` | Persists `wristband.RefreshTokenRecord` lineage rows, including rotation and lineage revocation. |
| `wristband.AccessTokenIssuer` | Mints and revokes the application's access tokens, returning a `wristband.IssuedToken`. |
| `wristband.IssueClientCredentials` | Generates a client ID and, for confidential clients, a one-time secret and its hash. |
| `wristband.RejectRedirectURI` | Returns why a redirect URI is not acceptable, or an empty string. |
| `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.Server.SwitchAccount` | Handler for `POST /oauth/authorize/switch-account`: burns the consent token, ends the session through the host and returns to the resume path. |
| `wristband.Server.ConsentRequest` | JSON-mode consent: mints a consent token and returns the `wristband.ConsentView` for a signed-in user. |
| `wristband.Server.DecideConsent` | JSON-mode decision: verifies the consent token and returns the code or `access_denied` redirect URL. |
| `wristband.ClientKindMCP` | Kind of an MCP client (`mcp`; an empty `wristband.ClientRecord.Kind` means the same). |
| `wristband.ClientKindOIDC` | Kind of an administrator-registered OIDC client (`oidc`). |
| `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 an RS256 (RSA 2048) or ES256 (P-256) key and returns its kid, PKCS#8 private PEM and PKIX public PEM. |
| `wristband.ParsePrivateKeyPEM` | Parses a PKCS#8, PKCS#1 RSA or SEC1 EC private key PEM into a signer. |
| `wristband.ParsePublicKeyPEM` | Parses a PKIX RSA or P-256 public key PEM. |
| `wristband.SubjectSector` | Returns a client's subject sector: empty for public clients, the sector identifier or the single redirect host for pairwise clients. |
| `wristband.ErrSectorRequired` | A pairwise client's redirect URIs span several hosts and no sector identifier is set. |
| `wristband.IssueOIDCClientCredentials` | Mints an `oas_` client ID, a one-time `oas_cs_` secret and its SHA-256 hex hash. |
| `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:
| Field | Default |
|-------|---------|
| `wristband.Options.ServiceDocumentationPath` | `/help` |
| `wristband.Options.ScopesSupported` | `read`, `write`, `ai`, `offline_access` |
| `wristband.Options.TokenEndpointAuthMethodsSupported` | `none`, `client_secret_post`, `client_secret_basic` |
| `wristband.Options.AuthorizationResponseIssParameterSupported` | `true` |
| `wristband.Options.DCRClientCap` | `200` |
| `wristband.Options.DCRUnconsentedSweepAge` | 24 hours |
| `wristband.Options.RegisterMaxBodyBytes` | 65536 |
| `wristband.Options.Resource` | none (empty); set it to the URL of the resource your tokens are for |
| `wristband.Options.PendingRequestTTL` | 10 minutes |
| `wristband.Options.CodeTTL` | 10 minutes |
| `wristband.Options.AccessTokenTTL` | 1 hour |
| `wristband.Options.RefreshTokenTTL` | 30 days |
Always set `wristband.Options.Issuer` and `wristband.Options.Resource` for your deployment. While `wristband.Options.Resource` is empty, an MCP authorization request that sends `resource` is refused with `invalid_target`.
`wristband.DefaultOIDCOptions` sets:
| Field | Default |
|-------|---------|
| `wristband.OIDCOptions.LoginURL` | `/login` |
| `wristband.OIDCOptions.IDTokenTTL` | 1 hour |
| `wristband.OIDCOptions.AccessTokenTTL` | 1 hour |
| `wristband.OIDCOptions.CodeTTL` | 60 seconds |
| `wristband.OIDCOptions.AuthRequestTTL` | 10 minutes |
| `wristband.OIDCOptions.RefreshEnabled` | `false` |
| `wristband.OIDCOptions.RefreshTokenTTL` | 30 days |
| `wristband.OIDCOptions.ScopesSupported` | `openid`, `profile`, `email` |
| `wristband.OIDCOptions.ClaimsSupported` | `sub`, `name`, `picture`, `groups`, `communities`, `email`, `email_verified`, `iss`, `aud`, `exp`, `iat`, `nonce` |
| `wristband.OIDCOptions.IdentityClaims` | `sub`, `name`, `picture`, `groups`, `communities`, `email`, `email_verified` |
| `wristband.OIDCOptions.SubjectTypesSupported` | `public`, `pairwise` |
| `wristband.OIDCOptions.ClientIDPrefix`, `wristband.OIDCOptions.ClientSecretPrefix` | `oas_`, `oas_cs_` |
| `wristband.OIDCOptions.CodePrefix`, `wristband.OIDCOptions.AccessTokenPrefix`, `wristband.OIDCOptions.RefreshTokenPrefix` | `oas_ac_`, `oas_at_`, `oas_rt_` |
## Dependencies
- SummerCMS modules: none.
- Third-party: `github.com/golang-jwt/jwt/v5` (id_token signing).
- Standard library: `bytes`, `context`, `crypto`, `crypto/ecdsa`, `crypto/elliptic`, `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
```sh
go test ./modules/wristband/...
```
The tests use in-memory fakes for the stores (MCP and OIDC) and need no external services. `TestOIDCTracerSmoke` drives a relying party end to end (discovery, JWKS, authorize, resume, consent, code exchange, id_token verification against the served JWKS, userinfo); the OIDC smoke tests cover authorize and consent, kind isolation, PHP redirect encoding, token errors and code replay, userinfo, mixed RS256 and ES256 key rings, pairwise subjects and opt-in refresh.