# 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"` ## 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. ## 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.