Files
summercms/modules/wristband
Jakub Zych e25e73378c feat(19.4-01): OIDC token, keys, subjects and opt-in refresh
- ES256 keys, PKCS#1/SEC1 parsing fallbacks, EC JWKs, mixed RS256/ES256 rings
- Pairwise subjects per sector (SubjectSector, ErrSectorRequired)
- Opt-in refresh_token grant (OIDCOptions.RefreshEnabled): OfflineAccess clients
  only, rotation, lineage revocation on replay, fresh id_token without nonce
- IssueOIDCClientCredentials with the PHP oas_/oas_cs_ shapes
- Smoke tests: token errors, replay revocation, userinfo 401s, mixed key ring,
  pairwise subjects, refresh opt-in
2026-10-19 18:24:45 +02:00
..

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 <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

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.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.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: 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

go test ./modules/wristband/...

The tests use in-memory fakes for the stores and need no external services.