Files
summercms/docs/services/oauth-server.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

16 KiB

title, description, section, order
title description section order
OAuth server Let MCP clients act for your users and other sites sign them in with wristband, an OAuth server and OpenID Connect provider with PKCE, consent and JWKS. services 60

OAuth server

wristband is the protocol side of an OAuth 2 authorization server, built for MCP clients such as AI assistants and connectors that act on behalf of the application's users. WinterCMS core has no counterpart. wristband provides the HTTP handlers and the consent operations; the application provides the storage, the access tokens it already uses for its API, and the consent screen.

The same package is also an OpenID Connect provider, so other sites can sign users in with the application's accounts. That flavour is described in OpenID Connect provider below.

What it implements

  • The RFC 8414 metadata document, advertising the endpoints, the authorization_code and refresh_token grants, S256 PKCE and the RFC 9207 iss response parameter.
  • RFC 7591 dynamic client registration: public clients (none) and confidential ones (client_secret_post, client_secret_basic), redirect URI validation, a cap on unrevoked clients and a sweep of old clients that never got consent.
  • The authorization endpoint, which validates the client and its exact registered redirect URI before it redirects anywhere, then checks PKCE, the client's scopes and the RFC 8707 resource value, stores a pending request and sends the browser to the application's consent page.
  • The token endpoint: code exchange with PKCE verification, then an access token from the application and a rotating refresh token. Reusing a spent refresh token revokes its whole lineage and the access tokens issued from it.

Client secrets, codes and refresh tokens are random strings stored only as SHA-256 hashes and compared in constant time.

Configuring the server

wristband reads no configuration keys. The application builds a wristband.Options value from wristband.DefaultOptions and sets at least wristband.Options.Issuer, its own URL without a trailing slash, and wristband.Options.Resource, the URL of the protected resource its tokens are for. Both are required; neither has a default:

// newServer builds the authorization server of an application served at
// https://blog.example.com. The application sets Issuer and Resource for
// its own deployment; the defaults cover everything else.
func newServer() *wristband.Server {
	opts := wristband.DefaultOptions()
	opts.Issuer = "https://blog.example.com"
	opts.Resource = "https://blog.example.com/mcp"
	opts.ScopesSupported = []string{"read", "write", "offline_access"}
	return wristband.NewServer(opts)
}

Warning

Always set wristband.Options.Resource for your deployment. wristband.DefaultOptions leaves it empty, and while it is empty every authorization request that sends a resource parameter is refused with invalid_target.

The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. The metadata document serves the configured values:

srv := newServer()
// srv.SetBackend(backend) attaches the application's stores; the
// metadata document does not need them.
rec := httptest.NewRecorder()
srv.Metadata(rec, httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil))

var doc map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
	fmt.Println(err)
	return
}
for _, key := range []string{
	"issuer",
	"authorization_endpoint",
	"token_endpoint",
	"registration_endpoint",
	"scopes_supported",
	"grant_types_supported",
	"code_challenge_methods_supported",
} {
	fmt.Println(key, doc[key])
}
// Output:
// issuer https://blog.example.com
// authorization_endpoint https://blog.example.com/oauth/mcp/authorize
// token_endpoint https://blog.example.com/oauth/mcp/token
// registration_endpoint https://blog.example.com/oauth/mcp/register
// scopes_supported [read write offline_access]
// grant_types_supported [authorization_code refresh_token]
// code_challenge_methods_supported [S256]

Storage

The server has no database code. The application attaches its storage with wristband.Server.SetBackend; until it does, handlers that need storage answer 500. A wristband.Backend runs a function inside one database transaction with a wristband.Tx, which bundles the stores and the token issuer:

Interface Stores
wristband.ClientStore Registered clients (wristband.ClientRecord): lookup, capped create, the sweep and the consent stamp.
wristband.AuthCodeStore Pending requests and issued codes (wristband.AuthCodeRecord).
wristband.RefreshTokenStore Refresh token lineages (wristband.RefreshTokenRecord), including rotation and revocation.
wristband.AccessTokenIssuer Mints and revokes the application's own API tokens.

Registration, code exchange and refresh each run in one transaction through this interface, so the writes of each step commit or roll back together: a code is never marked used without its tokens, and a refresh token is never spent without its replacement.

Routes

Mount the handlers in a raw group, because the OAuth endpoints define their own response formats and must not be wrapped in the JSON envelope middleware (see Routing):

Route Handler
GET /.well-known/oauth-authorization-server wristband.Server.Metadata
GET /oauth/mcp/authorize wristband.Server.Authorize
POST /oauth/mcp/token wristband.Server.Token
POST /oauth/mcp/register wristband.Server.Register

The metadata document advertises these paths under the issuer, so mount them at exactly these paths.

The authorization endpoint sends the browser to <issuer>/connect?request=<id>. That page belongs to the application: it signs the user in, shows what the client asks for and posts the decision to the application's own consent handler, which calls:

  • wristband.Server.PendingRequest to read what to show, as a wristband.PendingRequestView;
  • wristband.Server.IssueCode to grant, which returns the redirect URL carrying the code, iss and state;
  • wristband.Server.DenyPending to refuse, which returns the access_denied redirect URL.

wristband.Server.IssueCode stores exactly the scopes it is given. The consent handler must pass only scopes that the pending request asked for, the user accepted and the application can grant. A missing, foreign, used or expired request is wristband.ErrPendingNotFound in every case, so the handler cannot tell another user's request ID from an invalid one.

wristband.Server.Revoke disconnects an app: it revokes an access token and the refresh lineage behind it.

Clients created outside registration

Operator tooling that creates clients directly uses the same rules as registration. wristband.RejectRedirectURI accepts https:// URIs and loopback http:// URIs only:

for _, uri := range []string{
	"https://client.example.org/callback",
	"http://127.0.0.1:33418/callback",
	"http://client.example.org/callback",
} {
	if reason := wristband.RejectRedirectURI(uri); reason != "" {
		fmt.Println("rejected:", reason)
		continue
	}
	fmt.Println("accepted:", uri)
}
// Output:
// accepted: https://client.example.org/callback
// accepted: http://127.0.0.1:33418/callback
// rejected: Redirect URI must be https:// or loopback http://127.0.0.1 / http://localhost: http://client.example.org/callback

wristband.IssueClientCredentials generates the client ID and, for a confidential client, a secret that is returned once and its hash, which is what you store:

// A confidential client gets a secret, shown once; store only the hash.
id, secret, hash, err := wristband.IssueClientCredentials("client_secret_post")
fmt.Println(id != "", secret != "", hash != nil && *hash != secret, err)

// A public client (PKCE only) gets no secret.
_, secret, hash, err = wristband.IssueClientCredentials("none")
fmt.Println(secret == "", hash == nil, err)
// Output:
// true true true <nil>
// true true <nil>

OpenID Connect provider

wristband.NewOIDCServer builds a second wristband.Server with the OpenID Connect flavour. It runs on the same engine as the MCP server (PKCE, hashed secrets, constant-time comparison, the redirect writer), but its validation and its responses follow the PHP (WinterCMS) OpenID Connect provider byte for byte, so relying parties that used that provider switch without noticing. An application that serves both builds two servers that share the issuer; the MCP server and its interfaces are unchanged.

What it implements

  • Discovery at /.well-known/openid-configuration with the provider's field set and order and Cache-Control: max-age=3600, public. It advertises public and pairwise subjects, the signing algorithms the published key ring holds, and the refresh grant only when it is switched on.
  • A JWKS endpoint with the active key and every retired key still inside its grace window, as RSA or EC JWKs carrying use, alg and kid. RS256 is the default; ES256 keys can be added, and a ring may hold both during a switch.
  • Authorization with a resumable request: the request is validated in the provider's order and parked; a signed-in user goes to /oauth/authorize/resume/{request}, an anonymous one to the login URL with only redirect=<resume path>. Resume answers silently when the user's consent already covers the request, otherwise the host renders a consent page listing only the scopes not yet granted. Consent, deny and switch account post a one-shot consent token bound to the user.
  • A token endpoint for the authorization code grant: Basic or form client authentication, codes burned before any other check (a replayed code revokes the tokens issued from it), an opaque access token and an id_token signed by the active key with iss, sub, aud, exp, iat, nonce, at_hash and the allowlisted identity claims.
  • Userinfo answering the same subject as the id_token and the claims of the token's scopes, with one uniform 401 for every failure.
  • Public subjects by default and pairwise subjects per sector, always random values stored per user and sector, never derived from account data.
  • Refresh tokens as an opt-in: wristband.OIDCOptions.RefreshEnabled switches the grant on for clients with wristband.OIDCClientRecord.OfflineAccess; tokens rotate on every use and a replayed one revokes its lineage.

Unknown clients, unregistered redirect URIs and inactive clients are rendered as error pages, never redirected. Error redirects carry error, error_description and state encoded like PHP http_build_query, and no iss.

Configuring it

The application builds the server from wristband.DefaultOptions (for the issuer) and wristband.DefaultOIDCOptions, then attaches storage, keys and its host with wristband.Server.SetOIDCBackend, which fails on a nil argument:

// newOIDCServer builds the OpenID Connect provider of an application served
// at https://id.example.com. The host application supplies storage, the
// signing-key ring and its pages; SetOIDCBackend refuses a nil one.
func newOIDCServer(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"
	oidc.RefreshEnabled = true // only clients with OfflineAccess get refresh tokens

	srv := wristband.NewOIDCServer(opts, oidc)
	if err := srv.SetOIDCBackend(backend, keys, host); err != nil {
		return nil, err
	}
	return srv, nil
}

The defaults match the PHP provider: codes live 60 seconds, parked requests 10 minutes, access tokens and id_tokens 1 hour, and every secret carries an oas_ prefix (oas_cs_ client secrets, oas_ac_ codes, oas_at_ access tokens, oas_rt_ refresh tokens). wristband.IssueOIDCClientCredentials mints a client ID and a one-time secret in that format.

The host

The engine owns validation, redirects, headers and the consent token; the host owns HTML and users through wristband.OIDCHost:

Method Called for
wristband.OIDCHost.SignedInUser Who is signed in; an error counts as anonymous.
wristband.OIDCHost.RenderAuthorizeError The error page for wristband.AuthorizeErrorUnknownClient, wristband.AuthorizeErrorRedirectMismatch, wristband.AuthorizeErrorInactiveClient or wristband.AuthorizeErrorExpiredRequest, with status 400.
wristband.OIDCHost.RenderConsent The consent page for a wristband.ConsentView, with status 200.
wristband.OIDCHost.EndSession Signing the user out when they switch account.

Before either render method runs, the engine sets X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', Cache-Control: no-store, private and Referrer-Policy: no-referrer. The consent page posts request_id, consent_token and decision to POST /oauth/authorize; decision=allow grants, anything else denies. A host whose consent page lives in its own frontend redirects there from wristband.OIDCHost.RenderConsent and finishes with wristband.Server.ConsentRequest and wristband.Server.DecideConsent, which share the HTML path's logic and report every request or token failure as wristband.ErrAuthRequestExpired.

Storage and keys

wristband.OIDCBackend runs a function inside one transaction with a wristband.OIDCTx, which bundles the clients (wristband.OIDCClientStore), parked requests (wristband.AuthRequestStore), codes (wristband.OIDCCodeStore), access tokens (wristband.OIDCAccessTokenStore), consents (wristband.ConsentStore), subjects (wristband.SubjectStore), identity claims (wristband.IdentityStore) and refresh tokens (wristband.OIDCRefreshTokenStore). Requests and codes are consumed atomically, so a code is redeemed once and the loser of a race gets an error.

wristband.KeyStore supplies the active wristband.SigningKey (generating the first key when the ring is empty) and the published wristband.PublicKey ring. wristband.GenerateSigningKey, wristband.ParsePrivateKeyPEM and wristband.ParsePublicKeyPEM keep key generation and parsing inside the reviewed package; private keys reach the engine only as signers and are never logged.

Client kinds

wristband.ClientRecord.Kind and wristband.OIDCClientRecord.Kind tell the two kinds of client apart: wristband.ClientKindMCP for MCP clients (dynamically registered or issued by an operator command; an empty kind means the same) and wristband.ClientKindOIDC for clients an administrator registers. Each server accepts only its own kind at authorize, token and userinfo, and the MCP server keeps refusing the openid scope, so a dynamically registered client can never obtain identity scopes or an id_token.

Routes

Route Handler
GET /.well-known/openid-configuration wristband.Server.Discovery
GET /oauth/jwks wristband.Server.JWKS
GET /oauth/authorize, POST /oauth/authorize wristband.Server.Authorize
GET /oauth/authorize/resume/{request} wristband.Server.Resume
POST /oauth/authorize/switch-account wristband.Server.SwitchAccount
POST /oauth/token wristband.Server.Token
GET /oauth/userinfo wristband.Server.UserInfo

Discovery advertises these paths under the issuer. On an OIDC server the MCP-only handlers (wristband.Server.Metadata, wristband.Server.Register) answer 404, and on an MCP server the OIDC-only handlers do. A shared OAuth server plugin supplies the stores, the key ring, the consent and error pages and these routes, so an application normally installs that plugin instead of implementing the interfaces itself.