docs(modules): rewrite wristband, bouncer, surf, bonfire, phrasebook, postcard READMEs

This commit is contained in:
Jakub Zych
2026-09-28 15:48:02 +02:00
parent 3142aebc75
commit aaa892f046
6 changed files with 780 additions and 6 deletions

View File

@@ -1,3 +1,125 @@
# wristband
`wristband` implements the MCP OAuth server surface: client registration, authorization, consent, token issuance, and backing-store contracts. Fonoteka's OAuth plugin and API controllers import it; the server entry point is `wristband.Server` in `server.go`.
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"`
## 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.
## 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.
- 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
```go
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.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 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: none.
- Standard library: `bytes`, `context`, `crypto/rand`, `crypto/sha256`, `crypto/subtle`, `encoding/base64`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `net/http`, `net/url`, `strings`, `time`.
## Testing
```sh
go test ./modules/wristband/...
```
The tests use in-memory fakes for the stores and need no external services.