# wristband An OAuth authorization server for MCP clients: RFC 8414 metadata, RFC 7591 dynamic client registration, the authorization-code flow with PKCE, consent operations 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. ## Overview wristband implements the protocol side of an OAuth authorization server so an application can let MCP clients (AI assistants and connectors) act on behalf of its users. A `wristband.Server` provides ready-made `net/http` handlers for the metadata, authorize, token and registration endpoints, plus Go methods the application's own consent screen calls. Everything application-specific stays outside the package: persistence arrives through the `wristband.Backend` and `wristband.Tx` interfaces, access tokens are minted by the application's `wristband.AccessTokenIssuer`, and issuer, scopes and lifetimes come from `wristband.Options`. 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 supplies storage and token minting through interfaces. It is separate because OAuth is security code that should be reviewed on its own, and only applications that serve MCP clients 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. - 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 `/connect?request=`. 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. - 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}) } ``` ## API reference | Identifier | Description | |------------|-------------| | `wristband.Server` | The authorization server; `wristband.NewServer` builds it from `wristband.Options`. | | `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`. | | `wristband.Server.Token` | Handler for `POST /oauth/mcp/token` (authorization code and refresh token grants). | | `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.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 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.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. ## 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`. ## Testing ```sh go test ./modules/wristband/... ``` The tests use in-memory fakes for the stores and need no external services.