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:
2026-10-19 18:32:03 +02:00
parent e25e73378c
commit ddda59a12b
12 changed files with 274 additions and 66 deletions

View File

@@ -119,7 +119,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands
| [tide](modules/tide/README.md) | HTTP parity toolkit that records request and response fixtures from a reference backend, replays them against a new one and reports normalized differences. | The tide goes out and comes back, and you compare the waterline; it records traffic from one backend, replays it against another and compares. |
| [towel](modules/towel/README.md) | Request-scoped actor, organization, collection and locale values carried through `context.Context`. | You carry it everywhere and it marks your spot; it carries the actor, organization and locale through each request. |
| [wire](modules/wire/README.md) | JSON response helpers and value types that keep API bodies byte-compatible with a PHP (WinterCMS/Laravel) backend. | What goes over the wire; it keeps response bodies byte-for-byte the same as the PHP backend's. |
| [wristband](modules/wristband/README.md) | 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. | The festival wristband that lets you in for the event; it issues OAuth access for MCP clients. |
| [wristband](modules/wristband/README.md) | An OAuth authorization server and OpenID Connect provider: RFC 8414 metadata, dynamic client registration, discovery, JWKS, id_token, userinfo, resumable consent and refresh-token rotation over application-supplied storage. | The festival wristband that lets you in for the event; it issues OAuth access for MCP clients and signs users in to other sites with OpenID Connect. |
## Using SummerCMS in an application

View File

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

View File

@@ -1,23 +1,26 @@
# 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.
An OAuth authorization server and OpenID Connect provider: RFC 8414 metadata, dynamic client registration, discovery, JWKS, id_token, userinfo, resumable consent 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.
**Name:** The festival wristband that lets you in for the event; it issues OAuth access for MCP clients and signs users in to other sites with OpenID Connect.
## 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.
wristband implements the protocol side of an OAuth authorization server in two flavours that share one engine. The MCP flavour (`wristband.NewServer`) lets MCP clients (AI assistants and connectors) act on behalf of the application's users: metadata, registration, authorize and token handlers plus Go methods the application's own consent screen calls. The OpenID Connect flavour (`wristband.NewOIDCServer`) lets other sites sign users in with the application's accounts ("Login with ..."): discovery, JWKS, authorize with a resumable request and consent, a token endpoint that adds a signed `id_token`, and userinfo. It is wire-compatible with the PHP (WinterCMS) OpenID Connect provider it replaces, so existing relying parties switch without noticing.
Both flavours reuse the same primitives: PKCE, SHA-256 hashing of every secret, constant-time comparison, random identifiers and the redirect writer. Everything application-specific stays outside the package. The MCP flavour gets storage through `wristband.Backend` and `wristband.Tx` and access tokens from the application's `wristband.AccessTokenIssuer`. The OIDC flavour gets storage through `wristband.OIDCBackend` and `wristband.OIDCTx`, signing keys through `wristband.KeyStore`, and the signed-in user, the consent and error pages and sign-out through `wristband.OIDCHost`. Issuer, scopes and lifetimes come from `wristband.Options` and `wristband.OIDCOptions`. 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.
A standalone protocol leaf: it imports no framework module and no ORM. The application (or a shared plugin) supplies storage, keys and pages through interfaces. It is separate because OAuth and OpenID Connect are security code that should be reviewed on its own, and only applications that serve MCP clients or relying parties 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.
- Be the identity provider other sites use for "Login with ...", with consent, account switching, public or pairwise subjects and key rotation.
- Rotate refresh tokens and drive the application's own consent screen.
## Features
@@ -82,16 +85,49 @@ func Approve(ctx context.Context, w http.ResponseWriter, srv *wristband.Server,
}
```
The OpenID Connect flavour is a second server on the same engine. The host (usually a shared plugin) implements `wristband.OIDCBackend`, `wristband.KeyStore` and `wristband.OIDCHost`, then mounts the handlers at the paths discovery advertises:
```go
func NewProvider(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" // anonymous users land here with ?redirect=<resume path>
srv := wristband.NewOIDCServer(opts, oidc)
if err := srv.SetOIDCBackend(backend, keys, host); err != nil {
return nil, err // nil argument, or called on an MCP server
}
return srv, nil
}
func ProviderRoutes(r pact.Router, srv *wristband.Server) {
r.GroupRaw("", nil, func(g pact.Router) {
g.Get("/.well-known/openid-configuration", srv.Discovery)
g.Get("/oauth/jwks", srv.JWKS)
g.Get("/oauth/authorize", srv.Authorize) // start
g.Post("/oauth/authorize", srv.Authorize) // consent decision
g.Get("/oauth/authorize/resume/{request}", srv.Resume)
g.Post("/oauth/authorize/switch-account", srv.SwitchAccount)
g.Post("/oauth/token", srv.Token)
g.Get("/oauth/userinfo", srv.UserInfo)
})
}
```
`wristband.OIDCHost.RenderConsent` receives a `wristband.ConsentView`; the page posts `request_id`, `consent_token` and `decision` (`allow` or anything else) back to `POST /oauth/authorize`, or `request_id` and `consent_token` to the switch-account route. A host whose consent page lives in its own frontend redirects there from `wristband.OIDCHost.RenderConsent` and finishes through `wristband.Server.ConsentRequest` and `wristband.Server.DecideConsent`.
## API reference
| Identifier | Description |
|------------|-------------|
| `wristband.Server` | The authorization server; `wristband.NewServer` builds it from `wristband.Options`. |
| `wristband.Server` | The authorization server; `wristband.NewServer` builds the MCP flavour from `wristband.Options`, `wristband.NewOIDCServer` the OpenID Connect flavour. |
| `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.Authorize` | Handler for `GET /oauth/mcp/authorize`; on an OIDC server `GET /oauth/authorize` (start) and `POST /oauth/authorize` (consent decision). |
| `wristband.Server.Token` | Handler for `POST /oauth/mcp/token` (authorization code and refresh token grants); on an OIDC server `POST /oauth/token` with an `id_token`. |
| `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. |
@@ -157,12 +193,31 @@ wristband reads no config keys or environment variables; the application passes
| `wristband.Options.DCRClientCap` | `200` |
| `wristband.Options.DCRUnconsentedSweepAge` | 24 hours |
| `wristband.Options.RegisterMaxBodyBytes` | 65536 |
| `wristband.Options.Resource` | none (empty); set it to the URL of the resource your tokens are for |
| `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.
Always set `wristband.Options.Issuer` and `wristband.Options.Resource` for your deployment. While `wristband.Options.Resource` is empty, an MCP authorization request that sends `resource` is refused with `invalid_target`.
`wristband.DefaultOIDCOptions` sets:
| Field | Default |
|-------|---------|
| `wristband.OIDCOptions.LoginURL` | `/login` |
| `wristband.OIDCOptions.IDTokenTTL` | 1 hour |
| `wristband.OIDCOptions.AccessTokenTTL` | 1 hour |
| `wristband.OIDCOptions.CodeTTL` | 60 seconds |
| `wristband.OIDCOptions.AuthRequestTTL` | 10 minutes |
| `wristband.OIDCOptions.RefreshEnabled` | `false` |
| `wristband.OIDCOptions.RefreshTokenTTL` | 30 days |
| `wristband.OIDCOptions.ScopesSupported` | `openid`, `profile`, `email` |
| `wristband.OIDCOptions.ClaimsSupported` | `sub`, `name`, `picture`, `groups`, `communities`, `email`, `email_verified`, `iss`, `aud`, `exp`, `iat`, `nonce` |
| `wristband.OIDCOptions.IdentityClaims` | `sub`, `name`, `picture`, `groups`, `communities`, `email`, `email_verified` |
| `wristband.OIDCOptions.SubjectTypesSupported` | `public`, `pairwise` |
| `wristband.OIDCOptions.ClientIDPrefix`, `wristband.OIDCOptions.ClientSecretPrefix` | `oas_`, `oas_cs_` |
| `wristband.OIDCOptions.CodePrefix`, `wristband.OIDCOptions.AccessTokenPrefix`, `wristband.OIDCOptions.RefreshTokenPrefix` | `oas_ac_`, `oas_at_`, `oas_rt_` |
## Dependencies
@@ -176,4 +231,4 @@ Always set `wristband.Options.Issuer` and `wristband.Options.Resource` for your
go test ./modules/wristband/...
```
The tests use in-memory fakes for the stores and need no external services.
The tests use in-memory fakes for the stores (MCP and OIDC) and need no external services. `TestOIDCTracerSmoke` drives a relying party end to end (discovery, JWKS, authorize, resume, consent, code exchange, id_token verification against the served JWKS, userinfo); the OIDC smoke tests cover authorize and consent, kind isolation, PHP redirect encoding, token errors and code replay, userinfo, mixed RS256 and ES256 key rings, pairwise subjects and opt-in refresh.

View File

@@ -91,7 +91,7 @@ func TestPhase8RedAuthorize(t *testing.T) {
"code_challenge_method": "S256",
"scope": "read write",
"state": "must-not-appear-on-our-url",
"resource": "https://mcp.plytarium.com/mcp",
"resource": "https://resource.example.test/mcp",
})
rec := httptest.NewRecorder()
srv.Authorize(rec, req)
@@ -100,8 +100,8 @@ func TestPhase8RedAuthorize(t *testing.T) {
t.Fatalf("PHASE8_RED:authorize: status = %d, want %d (body=%s)", rec.Code, http.StatusFound, rec.Body.String())
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "https://plytarium.com/connect?") {
t.Fatalf("PHASE8_RED:authorize: Location = %q, want prefix \"https://plytarium.com/connect?\"", loc)
if !strings.HasPrefix(loc, "https://issuer.example.test/connect?") {
t.Fatalf("PHASE8_RED:authorize: Location = %q, want prefix \"https://issuer.example.test/connect?\"", loc)
}
q := queryOf(t, loc)
if q["request"] == "" {
@@ -241,8 +241,8 @@ func TestAuthorizeUnsupportedResponseTypeRedirectsWithIssAndState(t *testing.T)
if q["error"] != "unsupported_response_type" {
t.Fatalf("error = %q, want unsupported_response_type", q["error"])
}
if q["iss"] != "https://plytarium.com" {
t.Fatalf("iss = %q, want https://plytarium.com", q["iss"])
if q["iss"] != "https://issuer.example.test" {
t.Fatalf("iss = %q, want https://issuer.example.test", q["iss"])
}
if q["state"] != "iss-state" {
t.Fatalf("state = %q, want iss-state", q["state"])
@@ -275,8 +275,8 @@ func TestPKCEChallengeMethodMustBeS256(t *testing.T) {
if q["state"] != state {
t.Fatalf("state = %q, want %q", q["state"], state)
}
if q["iss"] != "https://plytarium.com" {
t.Fatalf("iss = %q, want https://plytarium.com", q["iss"])
if q["iss"] != "https://issuer.example.test" {
t.Fatalf("iss = %q, want https://issuer.example.test", q["iss"])
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
t.Fatalf("Cache-Control = %q, want no-store, private", cc)
@@ -333,7 +333,7 @@ func TestAuthorizeValidRequestRedirectsToConnectWithOpaqueHandleOnly(t *testing.
"code_challenge_method": "S256",
"scope": "read write",
"state": state,
"resource": "https://mcp.plytarium.com/mcp",
"resource": "https://resource.example.test/mcp",
})
rec := httptest.NewRecorder()
srv.Authorize(rec, req)
@@ -342,8 +342,8 @@ func TestAuthorizeValidRequestRedirectsToConnectWithOpaqueHandleOnly(t *testing.
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusFound, rec.Body.String())
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "https://plytarium.com/connect?") {
t.Fatalf("Location = %q, want prefix https://plytarium.com/connect?", loc)
if !strings.HasPrefix(loc, "https://issuer.example.test/connect?") {
t.Fatalf("Location = %q, want prefix https://issuer.example.test/connect?", loc)
}
q := queryOf(t, loc)
if q["request"] == "" {
@@ -400,8 +400,8 @@ func TestAuthorizeIssIsPresentOnEveryErrorRedirect(t *testing.T) {
rec := httptest.NewRecorder()
srv.Authorize(rec, req)
q := queryOf(t, rec.Header().Get("Location"))
if q["iss"] != "https://plytarium.com" {
t.Fatalf("iss = %q, want https://plytarium.com", q["iss"])
if q["iss"] != "https://issuer.example.test" {
t.Fatalf("iss = %q, want https://issuer.example.test", q["iss"])
}
if q["state"] != "iss-state" {
t.Fatalf("state = %q, want iss-state", q["state"])
@@ -597,8 +597,8 @@ func TestAuthorizeCeilingRejectsAiOnlyAsInvalidScopeOnTrustedRedirect(t *testing
if q["state"] != state {
t.Fatalf("state = %q, want %q", q["state"], state)
}
if q["iss"] != "https://plytarium.com" {
t.Fatalf("iss = %q, want https://plytarium.com", q["iss"])
if q["iss"] != "https://issuer.example.test" {
t.Fatalf("iss = %q, want https://issuer.example.test", q["iss"])
}
if got := pendingCount(backend); got != before {
t.Fatalf("pending rows = %d, want unchanged %d", got, before)
@@ -697,10 +697,10 @@ func TestOrderedRedirectQueryEncodingIsRFC3986(t *testing.T) {
got := buildOrderedQuery([][2]string{
{"error", "invalid_scope"},
{"error_description", "requested scope is outside this client's ceiling"},
{"iss", "https://plytarium.com"},
{"iss", "https://issuer.example.test"},
{"state", "a b&c=d"},
})
want := "error=invalid_scope&error_description=requested%20scope%20is%20outside%20this%20client%27s%20ceiling&iss=https%3A%2F%2Fplytarium.com&state=a%20b%26c%3Dd"
want := "error=invalid_scope&error_description=requested%20scope%20is%20outside%20this%20client%27s%20ceiling&iss=https%3A%2F%2Fissuer.example.test&state=a%20b%26c%3Dd"
if got != want {
t.Fatalf("got: %s\nwant: %s", got, want)
}
@@ -709,9 +709,9 @@ func TestOrderedRedirectQueryEncodingIsRFC3986(t *testing.T) {
func TestOrderedRedirectAppendsToExistingQuery(t *testing.T) {
got := appendOrderedQuery("https://client.example.test/cb?already=here", [][2]string{
{"error", "invalid_request"},
{"iss", "https://plytarium.com"},
{"iss", "https://issuer.example.test"},
})
want := "https://client.example.test/cb?already=here&error=invalid_request&iss=https%3A%2F%2Fplytarium.com"
want := "https://client.example.test/cb?already=here&error=invalid_request&iss=https%3A%2F%2Fissuer.example.test"
if got != want {
t.Fatalf("got: %s\nwant: %s", got, want)
}
@@ -719,7 +719,7 @@ func TestOrderedRedirectAppendsToExistingQuery(t *testing.T) {
func TestAuthorizeBackendUnavailableIsOpaque500(t *testing.T) {
opts := DefaultOptions()
opts.Issuer = "https://plytarium.com"
opts.Issuer = "https://issuer.example.test"
srv := NewServer(opts)
req := authorizeRequest(map[string]string{"client_id": "anything"})
rec := httptest.NewRecorder()

View File

@@ -1,6 +1,6 @@
// Shared client-issuing primitives used by both RFC 7591 registration
// (register.go) and the app-owned fonoteka:oauth-client operator command
// (08-CONTEXT.md D-19; T-08-SECRET-TIMING: "Shared hash/validation path and
// (register.go) and the host application's operator command that issues
// clients (08-CONTEXT.md D-19; T-08-SECRET-TIMING: "Shared hash/validation path and
// one-time secret"). Exporting these rather than letting the command
// re-derive its own random-id/hash/redirect-URI-validation logic keeps
// exactly one code path responsible for how an OAuth client secret is

View File

@@ -13,7 +13,7 @@ import (
func newConsentFixture(t *testing.T) (*Server, string) {
t.Helper()
s := NewServer(DefaultOptions())
s.opts.Issuer = "https://plytarium-consent-test.example"
s.opts.Issuer = "https://consent-test.example.test"
b := newMemoryBackend()
s.SetBackend(b)
ctx := context.Background()

View File

@@ -82,3 +82,40 @@ func ExampleIssueClientCredentials() {
// true true true <nil>
// true true <nil>
}
// 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
}
func ExampleNewOIDCServer() {
// Without storage, keys and a host the server refuses to start.
_, err := newOIDCServer(nil, nil, nil)
fmt.Println(err)
// An OIDC server serves discovery instead of the MCP metadata document.
srv := wristband.NewOIDCServer(wristband.DefaultOptions(), wristband.DefaultOIDCOptions())
rec := httptest.NewRecorder()
srv.Metadata(rec, httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil))
fmt.Println("metadata", rec.Code)
defaults := wristband.DefaultOIDCOptions()
fmt.Println(defaults.ScopesSupported, defaults.CodeTTL, defaults.RefreshEnabled)
// Output:
// wristband: OIDC backend is nil
// metadata 404
// [openid profile email] 1m0s false
}

View File

@@ -273,7 +273,10 @@ func (t *memoryTx) Revoke(ctx context.Context, tokenID uint) error {
func newTestServer(backend Backend) *Server {
opts := DefaultOptions()
opts.Issuer = "https://plytarium.com"
opts.Issuer = "https://issuer.example.test"
// Resource has no default; tests that send resource set it like a host
// application would.
opts.Resource = "https://resource.example.test/mcp"
srv := NewServer(opts)
srv.SetBackend(backend)
return srv

View File

@@ -1,9 +1,11 @@
// Package wristband implements the app-agnostic RFC 8414 / OAuth
// authorization-server surface ported from Płytarium's hand-rolled PHP OAuth
// server (08-CONTEXT.md D-05). It never imports an application package, a
// GORM type, or any fonoteka model: every deployment-specific value (issuer,
// scopes, endpoint paths, TTLs) arrives through Options, and every app-owned
// concern (users, collections, persistence) stays out of this package.
// Package wristband implements the app-agnostic OAuth authorization-server
// surface: the MCP flavour (RFC 8414 metadata, RFC 7591 registration,
// authorization code with PKCE, refresh rotation) ported from a hand-rolled
// PHP OAuth server (08-CONTEXT.md D-05), and the OpenID Connect flavour
// (19.4). It never imports an application package, a GORM type or any
// application model: every deployment-specific value (issuer, scopes,
// endpoint paths, TTLs) arrives through Options, and every concern of the
// host application (users, persistence, pages) stays out of this package.
//
// D-06: PHP's RFC-minimal response shapes are wristband's defaults. There
// are no response hooks; callers cannot alter the wire bytes beyond the
@@ -58,10 +60,12 @@ type Options struct {
// before JSON decoding (D-21, T-08-DCR-FLOOD). PHP default: 65536 (64 KiB).
RegisterMaxBodyBytes int64
// Resource is the expected RFC 8707 resource indicator value authorize
// checks an optional resource query parameter against (PHP
// config('fonoteka.mcp.resource'), D-03). PHP default:
// "https://mcp.plytarium.com/mcp".
// Resource is the expected RFC 8707 resource indicator value the MCP
// authorize endpoint checks an optional resource query parameter
// against. It has no default: the host application sets the URL of the
// resource its tokens are for. While it is empty, a request that sends
// resource is refused with invalid_target and a request without it is
// unaffected. The OIDC flavour ignores resource.
Resource string
// PendingRequestTTL is how long a pre-consent pending authorization row
@@ -87,7 +91,8 @@ type Options struct {
}
// DefaultOptions returns PHP-parity defaults for every metadata option
// other than Issuer, which the caller must set from app.url.
// other than Issuer, which the caller must set from app.url, and Resource,
// which the host application must set for its deployment.
func DefaultOptions() Options {
return Options{
ServiceDocumentationPath: "/help",
@@ -97,9 +102,8 @@ func DefaultOptions() Options {
DCRClientCap: 200,
DCRUnconsentedSweepAge: 24 * time.Hour,
RegisterMaxBodyBytes: 65536,
Resource: "https://mcp.plytarium.com/mcp",
PendingRequestTTL: 600 * time.Second,
CodeTTL: 600 * time.Second,
CodeTTL: 600 * time.Second,
AccessTokenTTL: 3600 * time.Second,
RefreshTokenTTL: 30 * 24 * time.Hour,
}

View File

@@ -4,8 +4,10 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
)
// TestPhase8RedMetadata is the Phase 8 Wave 1 RED anchor (08-CONTEXT.md
@@ -15,7 +17,7 @@ import (
// stub. scripts/check-phase8-red.sh verifies this failure is fail-closed.
func TestPhase8RedMetadata(t *testing.T) {
opts := DefaultOptions()
opts.Issuer = "https://plytarium.com"
opts.Issuer = "https://issuer.example.test"
srv := NewServer(opts)
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)
@@ -27,7 +29,7 @@ func TestPhase8RedMetadata(t *testing.T) {
t.Fatalf("PHASE8_RED:metadata: status = %d, want %d", rec.Code, http.StatusOK)
}
const want = `{"issuer":"https://plytarium.com","authorization_endpoint":"https://plytarium.com/oauth/mcp/authorize","token_endpoint":"https://plytarium.com/oauth/mcp/token","registration_endpoint":"https://plytarium.com/oauth/mcp/register","response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"token_endpoint_auth_methods_supported":["none","client_secret_post","client_secret_basic"],"scopes_supported":["read","write","ai","offline_access"],"service_documentation":"https://plytarium.com/help","authorization_response_iss_parameter_supported":true}`
const want = `{"issuer":"https://issuer.example.test","authorization_endpoint":"https://issuer.example.test/oauth/mcp/authorize","token_endpoint":"https://issuer.example.test/oauth/mcp/token","registration_endpoint":"https://issuer.example.test/oauth/mcp/register","response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"token_endpoint_auth_methods_supported":["none","client_secret_post","client_secret_basic"],"scopes_supported":["read","write","ai","offline_access"],"service_documentation":"https://issuer.example.test/help","authorization_response_iss_parameter_supported":true}`
if got := rec.Body.String(); got != want {
t.Fatalf("PHASE8_RED:metadata: body mismatch\n got: %s\nwant: %s", got, want)
@@ -44,7 +46,7 @@ func TestPhase8RedMetadata(t *testing.T) {
// TestPhase8RedMetadata: same PHP-fixture bytes, now expected to pass.
func TestMetadataExactBytes(t *testing.T) {
opts := DefaultOptions()
opts.Issuer = "https://plytarium.com"
opts.Issuer = "https://issuer.example.test"
srv := NewServer(opts)
req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)
@@ -56,7 +58,7 @@ func TestMetadataExactBytes(t *testing.T) {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
const want = `{"issuer":"https://plytarium.com","authorization_endpoint":"https://plytarium.com/oauth/mcp/authorize","token_endpoint":"https://plytarium.com/oauth/mcp/token","registration_endpoint":"https://plytarium.com/oauth/mcp/register","response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"token_endpoint_auth_methods_supported":["none","client_secret_post","client_secret_basic"],"scopes_supported":["read","write","ai","offline_access"],"service_documentation":"https://plytarium.com/help","authorization_response_iss_parameter_supported":true}`
const want = `{"issuer":"https://issuer.example.test","authorization_endpoint":"https://issuer.example.test/oauth/mcp/authorize","token_endpoint":"https://issuer.example.test/oauth/mcp/token","registration_endpoint":"https://issuer.example.test/oauth/mcp/register","response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"token_endpoint_auth_methods_supported":["none","client_secret_post","client_secret_basic"],"scopes_supported":["read","write","ai","offline_access"],"service_documentation":"https://issuer.example.test/help","authorization_response_iss_parameter_supported":true}`
if got := rec.Body.String(); got != want {
t.Fatalf("body mismatch\n got: %s\nwant: %s", got, want)
@@ -121,3 +123,29 @@ func decodeAsMap(t *testing.T, body []byte) map[string]any {
}
return m
}
// TestDefaultOptionsResourceEmpty pins the folded 19.4 todo: the framework
// default names no resource (the host application sets its own), and every
// other default is unchanged.
func TestDefaultOptionsResourceEmpty(t *testing.T) {
got := DefaultOptions()
if got.Resource != "" {
t.Fatalf("Resource = %q, want empty", got.Resource)
}
want := Options{
ServiceDocumentationPath: "/help",
ScopesSupported: []string{"read", "write", "ai", "offline_access"},
TokenEndpointAuthMethodsSupported: []string{"none", "client_secret_post", "client_secret_basic"},
AuthorizationResponseIssParameterSupported: true,
DCRClientCap: 200,
DCRUnconsentedSweepAge: 24 * time.Hour,
RegisterMaxBodyBytes: 65536,
PendingRequestTTL: 600 * time.Second,
CodeTTL: 600 * time.Second,
AccessTokenTTL: 3600 * time.Second,
RefreshTokenTTL: 30 * 24 * time.Hour,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("DefaultOptions() = %+v, want %+v", got, want)
}
}

View File

@@ -13,8 +13,8 @@ import (
var ErrClientCapReached = errors.New("wristband: dynamic client registration cap reached")
// ClientRecord is the app-agnostic persisted shape of an OAuth client row.
// fonoteka's GORM adapter (classes/auth/oauth_store.go) converts to/from
// its models.OAuthClient; wristband never imports GORM or fonoteka.
// The host application's storage adapter converts to and from its own
// model; wristband never imports GORM or an application package.
type ClientRecord struct {
ID uint
ClientID string
@@ -167,9 +167,9 @@ type RefreshTokenStore interface {
DeleteExpiredRefreshTokens(ctx context.Context, now time.Time) error
}
// AccessTokenIssuer mints/revokes the app's ordinary personal access token
// (fonoteka: an inv_ token via ApiTokenManager) and stamps the owning OAuth
// client id.
// AccessTokenIssuer mints/revokes the host application's ordinary personal
// access token (for example an inv_ token from its token manager) and
// stamps the owning OAuth client id.
type AccessTokenIssuer interface {
Mint(ctx context.Context, userID uint, name string, scopes []string, expiresAt time.Time, collectionIDs []uint, clientID string) (IssuedToken, error)
Revoke(ctx context.Context, tokenID uint) error
@@ -185,9 +185,9 @@ type Tx interface {
AccessTokenIssuer
}
// Backend opens one transaction-scoped Tx per call. The GORM adapter lives
// in fonoteka.go's classes/auth package (D-07): wristband never imports
// gorm.io/gorm or a fonoteka model.
// Backend opens one transaction-scoped Tx per call. The storage adapter
// lives in the host application (D-07): wristband never imports
// gorm.io/gorm or an application model.
type Backend interface {
WithinTx(ctx context.Context, fn func(Tx) error) error
}

View File

@@ -386,7 +386,7 @@ func TestTokenResourceMismatchIsInvalidGrant(t *testing.T) {
srv := newTestServer(backend)
insertTokenTestClient(backend, "cli-res", "none", nil, nil)
verifier, challenge := s256Pair(t)
res := "https://mcp.plytarium.com/mcp"
res := "https://resource.example.test/mcp"
rawCode, _ := insertTokenTestCode(t, backend, "cli-res", challenge, func(rec *AuthCodeRecord) {
rec.Resource = &res
})
@@ -404,7 +404,7 @@ func TestTokenResourceOmittedIsAccepted(t *testing.T) {
srv := newTestServer(backend)
insertTokenTestClient(backend, "cli-res-omit", "none", nil, nil)
verifier, challenge := s256Pair(t)
res := "https://mcp.plytarium.com/mcp"
res := "https://resource.example.test/mcp"
rawCode, _ := insertTokenTestCode(t, backend, "cli-res-omit", challenge, func(rec *AuthCodeRecord) {
rec.Resource = &res
})
@@ -477,8 +477,8 @@ func TestTokenCodeSequentialReplayIsInvalidGrantSecondTime(t *testing.T) {
// exchange the same code must produce exactly one 200 and one invalid_grant,
// never two successes. memoryBackend serializes the whole WithinTx closure
// behind one mutex (08-PATTERNS.md), which is exactly the seam this test
// exercises; the real-Postgres row-lock proof lives in fonoteka.go's
// classes/auth package.
// exercises; the real-Postgres row-lock proof lives in the host application's
// storage adapter.
func TestTokenCodeConcurrentReplayHasExactlyOneWinner(t *testing.T) {
srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t)
form := validExchangeForm(rawCode, verifier, "cli-tok")
@@ -972,7 +972,7 @@ func TestServerRevokeKillsAccessAndLineage(t *testing.T) {
func TestTokenBackendUnavailableIsOpaque500(t *testing.T) {
opts := DefaultOptions()
opts.Issuer = "https://plytarium.com"
opts.Issuer = "https://issuer.example.test"
srv := NewServer(opts)
req := tokenRequest(url.Values{"grant_type": {"authorization_code"}}, "")
rec := httptest.NewRecorder()