Services
OAuth server
Let MCP clients act for your users with the wristband OAuth server, covering metadata, dynamic client registration, PKCE, consent and refresh token rotation.
On this page
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.
What it implements#
- The RFC 8414 metadata document, advertising the endpoints, the
authorization_codeandrefresh_tokengrants, S256 PKCE and the RFC 9207issresponse 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
resourcevalue, 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:
// 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)
}
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.
Consent#
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.PendingRequestto read what to show, as awristband.PendingRequestView;wristband.Server.IssueCodeto grant, which returns the redirect URL carrying the code,issandstate;wristband.Server.DenyPendingto refuse, which returns theaccess_deniedredirect 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>