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
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: OAuth server
|
||||
description: Let MCP clients act for your users with the wristband OAuth server, covering metadata, dynamic client registration, PKCE, consent and refresh token rotation.
|
||||
description: 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.
|
||||
section: services
|
||||
order: 60
|
||||
---
|
||||
@@ -8,6 +8,8 @@ order: 60
|
||||
|
||||
[wristband](../../modules/wristband/README.md) 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](#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.
|
||||
@@ -19,7 +21,7 @@ Client secrets, codes and refresh tokens are random strings stored only as SHA-2
|
||||
|
||||
## 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:
|
||||
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:
|
||||
|
||||
```go src=modules/wristband/example_test.go#newServer
|
||||
// newServer builds the authorization server of an application served at
|
||||
@@ -35,7 +37,7 @@ func newServer() *wristband.Server {
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Always set `wristband.Options.Resource` for your deployment. Do not rely on the value `wristband.DefaultOptions` returns.
|
||||
> 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:
|
||||
|
||||
@@ -146,3 +148,82 @@ fmt.Println(secret == "", hash == nil, err)
|
||||
// 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:
|
||||
|
||||
```go src=modules/wristband/example_test.go#newOIDCServer
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user