# Phase 8: OAuth2.1 authorization server - Context **Gathered:** 2026-09-23 **Status:** Ready for planning ## Phase Boundary Port Płytarium's MCP OAuth 2.1 authorization server so fonoteka-mcp, Claude, ChatGPT and Grok connect to the Go backend unchanged. The surface is the unauthenticated RFC group already declared raw in Phase 6 (`GET /.well-known/oauth-authorization-server`, `GET /oauth/mcp/authorize`, `POST /oauth/mcp/token`, `POST /oauth/mcp/register`) plus the five JWT-group endpoints the Nuxt `/connect` page and settings page call (`GET oauth/request/{request_id}`, `POST oauth/consent`, `POST oauth/deny`, `GET oauth/connected-apps`, `DELETE oauth/connected-apps/{id}`), the `fonoteka:oauth-client` console command, and the `scope_ceiling` logic Phase 7 C-03 deferred here. Phase 8 also includes the minimal token-authenticated `GET /api/v1/fonoteka/me` prerequisite required for the unchanged `fonoteka-mcp` process to start and complete D-14's real MCP tool-call acceptance gate; this is an explicit exception to the otherwise OAuth-only endpoint boundary. The PHP server is hand-rolled inside the `golem15/fonoteka` plugin (not a separate `oauthserver` plugin and not league/oauth2-server): `OAuthCodeManager` plus four API controllers, about 1,000 lines with exact bodies. Every OAuth access token is an ordinary `inv_` personal token minted by `ApiTokenManager` and verified by the existing `inv_token` guard. Grant types are `authorization_code` and `refresh_token` only; there is no client-credentials or token-exchange grant. This closes the STATE.md blocker about `ClientCredentialsStorage`/`TokenExchangeStorage`: neither is needed. Out of scope: social login (`/oauth/{provider}`, `oauth-identities` routes, Phase 7 deferred), any change to fonoteka-mcp or the Nuxt app, live vendor connects, a River sweep job (Phase 11). ## Implementation Decisions ### Server engine - **D-01:** Direct port on the standard library (`crypto/rand`, `crypto/sha256`, `crypto/subtle`, `net/url`, `encoding/base64` RawURLEncoding). zitadel/oidc is **not** added. It was evaluated and dropped because it is an OIDC provider whose metadata document, error bodies and token model differ from the PHP bytes and cannot mint `inv_` tokens; PITFALLS.md already warned against trusting its defaults. A decision note (`.planning/notes/`) records this, and the "on zitadel/oidc" wording in ROADMAP.md Phase 8 and REQUIREMENTS.md AUTH-05 is corrected at plan time. STACK.md/ARCHITECTURE.md rows naming zitadel are superseded by this decision, not edited. - **D-02:** Parameter sources follow PHP per endpoint. `/token` reads the urlencoded body plus the query string (Go `ParseForm` semantics, matching Laravel `input()` for the clients that exist); a JSON body on `/token` is `invalid_request`. `/register` is JSON-only: a non-JSON content type is `invalid_client_metadata` "Request must be application/json". `/authorize` reads the query only. - **D-03:** Lifetimes and caps are config keys with PHP values as defaults (pending request 600 s, code 600 s, access token 3600 s, refresh token 30 days, DCR cap 200 clients, unconsented-client sweep after 24 h). Issuer is `app.url` with trailing slash trimmed; the expected RFC 8707 resource is `fonoteka.mcp.resource` (default `https://mcp.plytarium.com/mcp`); the consent URL is `app.url` + `/connect?request=`. Exact key layout (the option discussed was `plugins.golem15.fonoteka.oauth.*` for the app side, with wristband taking an options struct) is Claude's discretion. - **D-04:** Security-load-bearing treatment as in Phases 3 and 6: every plan carries `T-08-xx` threats (PKCE bypass, code replay, refresh replay and lineage kill, open redirect on authorize, client-secret timing, scope-ceiling escalation, cross-user consent, request_id leakage in logs, DCR flooding) and the phase closes with `08-SECURITY-REVIEW.md` mapping each threat to a failing-when-broken test. Client-secret comparison is `crypto/subtle.ConstantTimeCompare` over the sha256 hex, PKCE S256 comparison is constant-time too. ### Package home and interfaces - **D-05:** A new framework package **`summercms.go/wristband`** (festival wristband, pairs with `bouncer`) owns the RFC surface: metadata document, authorize validation and pending-request creation, token endpoint (client authentication with `client_secret_post`/`client_secret_basic`/`none`, code exchange, refresh rotation with lineage kill on replay), RFC 7591 registration with its validation rules and caps, PKCE, scope parsing and scope ceiling, redirect-URI allow-list rules, the unconsented-client sweep and the expiry sweep (D-16). It is app-agnostic: scope list, endpoint paths, issuer, resource, consent-URL builder and TTLs are options; it never imports fonoteka. - **D-06:** PHP's RFC-minimal response shapes are wristband's defaults. There are no response hooks; fonoteka adds nothing to reach byte parity. Metadata field values (`service_documentation`, `scopes_supported`, auth methods, `authorization_response_iss_parameter_supported`) are options with the PHP values as fonoteka's config. - **D-07:** Storage seam: the app implements `ClientStore`, `AuthCodeStore`, `RefreshTokenStore` (backed by the Phase 5 GORM models `OAuthClient`, `OAuthAuthCode`, `OAuthRefreshToken`) and an `AccessTokenIssuer` that fonoteka satisfies with its Phase 7 `ApiTokenManager` (mint with name, scopes, expiry, collection ids; revoke by id), then stamps `oauth_client_id`. Code exchange and refresh rotation run in one transaction with row locks as PHP's `lockForUpdate` does, so the store seam must expose a transaction boundary. Exact interface names and signatures are Claude's discretion; wristband ships an in-memory store for its own tests. - **D-08:** The app keeps what touches app shapes: consent show/store/deny on the JWT group (using `ActiveCollectionResolver` for `collection_name` and `collection_ids`, `MINTABLE_SCOPES` intersection, `consented_at` stamping), connected-apps index/destroy (Phase 7 `serializeToken` plus `client_name`, revoke cascading through wristband's lineage kill), the `/connect` URL and route mounting. Consent calls wristband operations (issue code, deny pending) rather than touching code rows directly. - **D-09:** Routes mount on the Phase 6 raw group from `fonoteka.go` `routes.go`, with `throttle:fonoteka-oauth-token` on `/token` and `throttle:fonoteka-oauth-register` on `/register` as per-route middleware (P6 D-01 buckets already exist). Handlers are net/http handlers with swag annotations like the rest of the app. - **D-10:** The `oauth` guard reserved by Phase 6 D-09 is **retired**. OAuth-issued tokens are `inv_` tokens the `inv_token` guard already authenticates; `oauth_client_id` is what distinguishes them in connected-apps. Wristband registers no guard. The Phase 6 reservation is closed with a note in the plan and in the Phase 6 docs wording. - **D-11:** The `inv_` prefix stays for Płytarium because fonoteka-mcp's install script rejects any other prefix and its HTTP transport accepts only `Bearer inv_`. The Phase 7 token manager reads its prefix from config with `inv_` as Płytarium's value, so nothing on the wire changes. A summer-themed framework default is deferred. ### End-to-end proof and the header contract - **D-12:** Success criterion 3 is reworded at plan time. The rich `WWW-Authenticate: Bearer ... resource_metadata=...` header and the `/.well-known/oauth-protected-resource` document are emitted by fonoteka-mcp (the resource server), not by the backend. The backend's contract is: exactly `WWW-Authenticate: Basic realm="OAuth"` on `invalid_client` at `/token`, and the unchanged Phase 6 token-surface 401 `{"error":"Invalid token"}` with no new headers. Adding RFC 6750 headers to backend 401s is rejected as a contract change. - **D-13:** Recorded MCP flows (`mcp-oauth`, `mcp-tools`, and the new `mcp-lifecycle` from D-15) replay in the Go test suite in `fonoteka.go/parity`, the way `nuxt_flow_test.go` replays `nuxt-auth`. - **D-14:** A `scripts/check-phase8.sh` gate (same family as `check-phase2.sh`/`check-phase3.sh`) boots the Go app on Postgres, starts the real Node fonoteka-mcp from `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp` with `FONOTEKA_API_URL`, `FONOTEKA_MCP_PUBLIC_URL` and `FONOTEKA_MCP_AUTH_SERVER` pointed at the Go backend, and drives the discovery chain with a scripted client: protected-resource metadata, the MCP's 401 hint, authorization-server metadata, DCR, PKCE authorize, login and consent through the JWT API, token, an MCP tool call with the issued `inv_` token, then refresh. fonoteka-mcp is not modified. The scripted client's language and home are Claude's discretion (`parity/capture_clients.mjs` exists as a precedent). - **D-15:** No live vendor connect (Claude, ChatGPT, Grok) is part of acceptance; the automated gates cover the same DCR-plus-PKCE chain those apps use. The first real vendor connect happens at cutover. ### Parity corpus and lifecycle - **D-16:** (corpus) A second PHP flow, `mcp-lifecycle`, is recorded against the isolated PHP instance with the Phase 2 `tide` tooling (capture rules, private 0600 vars store, `pkce.vars`, no live secrets in git): DCR → authorize → consent → token → refresh → replay of the spent refresh token (`invalid_grant`, lineage dead) → connected-apps list showing the app → revoke → refresh after revoke fails → a deny path; plus a confidential client issued by `fonoteka:oauth-client` with a scope ceiling using `client_secret_basic`, showing ceiling truncation and the `invalid_scope` redirect. The four RFC route entries and five JWT-group entries in `parity/manifest.yaml` flip pending → ported through the usual gate; pending never counts as passing. - **D-17:** (sweep) Wristband adds an expiry sweep that PHP lacks, run where PHP runs its client sweep (`/register`) and also on `/token`, deleting only rows past `expires_at`: pending requests, codes (used or not) and refresh rows. Revoked or rotated rows that have not expired stay, so replay detection and connected-apps semantics match PHP. No timer, no goroutine; Phase 11 may move it into a River job. It is unobservable in recorded replays because nothing expires within a run; the schema-diff and db-capture harness must not be affected. - **D-18:** (tests) All PHP OAuth tests are ported (functional: OAuthAuthorizeTest, OAuthClientCommandTest, OAuthMetadataTest, OAuthMigrationTest, OAuthRegisterTest, OAuthTokenTest; security: OAuthConsentScopeCeilingTest, OAuthRefreshRotationTest, OAuthRevocationTest, TokenSurfaceIsolationTest). Framework behaviour tests run on wristband with the in-memory store; app tests run on real Postgres through the existing `classes` TestMain harness, and route-surface isolation tests inspect the surf route table as Phase 6 did. Each PHP test method maps to a named Go test so coverage can be audited. - **D-19:** (command) `fonoteka:oauth-client` is ported in `fonoteka.go` as a bonfire command scaffolded the Phase 4 way with the same signature (`name`, `--redirect-uri=*`, `--scope=*`, `--auth-method`, `--client-id`, `--list`) and the same output lines (`client_id=`, `client_secret=` printed once, the non-recoverable warning, `--list` never printing a secret). It is thin over wristband's client issuing helper and the app `ClientStore`. - **D-20:** (MCP prerequisite) Phase 8 ports the minimal exact `GET /api/v1/fonoteka/me` personal-token endpoint required by `fonoteka-mcp/src/http.ts` before it constructs the MCP server. The endpoint authenticates through the existing `inv_token` surface and implements only the response contract needed by the unchanged MCP client. This prerequisite is in scope solely to preserve D-14's real MCP tool-call gate; broader user/profile API work remains deferred. - **D-21:** (DCR body bound) `POST /oauth/mcp/register` accepts at most 64 KiB of JSON request body. An oversized document returns the endpoint's normal `invalid_client_metadata` response rather than a house envelope or generic HTML error. The bound is enforced before unbounded JSON decoding and is covered by the `T-08-DCR-FLOOD` failing-when-broken test. ### Claude's Discretion - Interface names and signatures in wristband, the transaction seam, in-memory store design, and where shared helpers (base64url, sha256 hex, constant-time compare) live. - Config key layout for wristband options on the fonoteka side (D-03). - The gate's scripted client (Go program vs Node script) and how the gate obtains Postgres. - Logging policy inside wristband (never log `request_id`, codes, secrets, verifiers), error-text constants, and how `client_name` control characters are stripped (port PHP's regex). - How `Content-Type` is judged for `/register` (PHP `isJson()` is "contains /json"). ## Canonical References **Downstream agents MUST read these before planning or implementing.** ### PHP source of truth (byte contract) - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php` §"Phase 07.9 MCP OAuth 2.1 authorization server" (lines ~520-570) and the JWT-group block (lines ~277-289) — route surface, middleware, throttles, RFC 6750 layering note. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/OAuthCodeManager.php` — pending request, code issue, PKCE S256, code exchange, refresh rotation and lineage kill, TTL constants. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthMetadataController.php` — exact RFC 8414 body. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthAuthorizeController.php` — validation order, local text/plain 400s, error 302s with `iss`, scope ceiling (Phase 07.13), resource check, consent redirect. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthTokenController.php` — grant dispatch, client authentication (Basic overrides form), bare `{"error":code}` bodies, `WWW-Authenticate: Basic realm="OAuth"`, `Cache-Control: no-store` + `Pragma: no-cache`. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthRegisterController.php` — RFC 7591 validation, 200-client cap, orphan sweep, 201 body. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthConsentController.php` — show/store/deny bodies, `pendingFor` rules, `untrustedName`. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/ConnectedAppController.php` — list and revoke bodies, `manual_tokens_count`. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/OAuthClient.php` — `isUsable`, `isPublic`, `ceilingScopes`, `rejectRedirectUri` rules. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/console/IssueOAuthClient.php` — command signature and output lines (D-19). - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/config/fonoteka.php` — `mcp.resource` default. - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/create_oauth_tables.php`, `updates/v1.1.7/add_oauth_client_id_to_api_tokens.php`, `updates/v1.1.9/add_scope_ceiling_to_oauth_clients.php` — schema (already ported in Phase 5). - `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuth*.php` and `tests/security/{OAuthConsentScopeCeilingTest,OAuthRefreshRotationTest,OAuthRevocationTest,TokenSurfaceIsolationTest}.php` — the tests to port (D-18). ### The unchanged clients - `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts` — protected-resource metadata, the 401 `WWW-Authenticate` hint, `inv_`-only bearer extraction (D-11, D-12). - `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/config.ts` — `FONOTEKA_API_URL`, `FONOTEKA_MCP_PUBLIC_URL`, `FONOTEKA_MCP_AUTH_SERVER` (D-14). - `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/install.ts` — `^inv_` token check in the install script. - `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useFonoteka.ts` and `app/stores/fonoteka.ts` — the Nuxt calls to `oauth/request`, `consent`, `deny`, `connected-apps`. ### Parity harness - `../fonoteka.go/parity/manifest.yaml` — the nine pending OAuth entries (`auth_group: oauth` and the JWT-group `oauth/*` routes). - `../fonoteka.go/parity/fixtures/mcp/mcp-oauth.yaml`, `mcp-tools.yaml` — recorded MCP flows (20 and 3 steps). - `../fonoteka.go/parity/nuxt_flow_test.go`, `capture_clients.mjs`, `capture-rules.yaml`, `php_parity.sh` — recording and replay precedent for D-13/D-16. - `scripts/check-phase2.sh`, `scripts/check-phase3.sh` — gate script family for D-14. ### Prior phase decisions that bind this phase - `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md` — D-01 buckets, D-06 guard registry, D-07/D-08 `inv_token` guard and `inv.scope`, D-09 reserved `oauth` guard (now retired), D-16 raw group, D-17 response helpers. - `.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md` — C-02 token manager and Bearer-only extraction (D-09), C-03 scope ceiling deferred here. - `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md` — format for `08-SECURITY-REVIEW.md` (D-04). - `.planning/research/PITFALLS.md` — zitadel default-shape pitfall and the header-contract pitfall (Pitfall 10). - `.planning/research/STACK.md` §zitadel/oidc and `.planning/research/ARCHITECTURE.md` bouncer row — superseded by D-01; read for what was assumed, not as instructions. ## Existing Code Insights ### Reusable Assets - `../fonoteka.go/plugins/golem15/fonoteka/models/{oauth_client,oauth_auth_code,oauth_refresh_token}.go` — ported Phase 5 models with `lagoon.Jsonable` JSON columns; `api_token.go` already carries `OAuthClientID` and `CollectionIDs`. - `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go` — Phase 7 mint/revoke, the `AccessTokenIssuer` implementation (D-07); `token_guard.go` — the `inv_token` guard that authenticates OAuth-issued tokens (D-10). - `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go` `serializeToken` — reused by connected-apps (D-08). - `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` — `fonoteka-oauth-token` and `fonoteka-oauth-register` buckets already declared. - `surf/router.go` `GroupRaw` — the raw group API; `../fonoteka.go/plugins/golem15/fonoteka/routes.go` line ~41 holds the empty raw group comment "throttles attach per-route in Phase 8". - `../fonoteka.go/config/http.yaml` — CORS already lists `oauth/mcp/*`. - `wire` package JSON writer and Carbon-format time helpers (P6 D-17) for the consent `expires_at` ISO 8601 value; `lagoon.Validate` for the consent `request_id`/`scopes` rules. - `bonfire` command registry and the Phase 4 `summer make:command` scaffold for D-19. ### Established Patterns - Framework stays app-agnostic (`summercms.go` never imports fonoteka); app-specific tables and resolvers stay in `fonoteka.go`. - Response conventions are helpers, not middleware; raw groups refuse house middleware and emit bare 500s on panic. - Security phases end with a threat-to-test review document; high-severity threats are closed with failing-when-broken tests. - Recorded PHP flows drive Go replay tests; manifest entries flip pending → ported only on a passing subtest. - Bearer-only token extraction; tokens never in URLs or logs. ### Integration Points - `routes.go` raw group (mount wristband handlers) and the JWT group (consent and connected-apps). - `plugin.go` Boot: construct wristband with the app stores, token issuer and options; register the console command. - `parity/manifest.yaml` and `parity/fixtures/mcp/` for the new flow; `parity/` replay tests. - `scripts/check-phase8.sh` next to the existing gates. ## Specific Ideas Contract facts verified in PHP source during discussion, to be treated as bytes: - Metadata: `issuer`, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, `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: /help`, `authorization_response_iss_parameter_supported: true`. No `jwks_uri`, no OIDC fields, no envelope. - Token success: `{access_token, token_type: "Bearer", expires_in, refresh_token, scope}` where `scope` is the granted scopes joined by space plus ` offline_access` when requested; headers `Cache-Control: no-store`, `Pragma: no-cache`. Errors: `{"error":"invalid_request"|"unsupported_grant_type"|"invalid_grant"}` 400 with no description; `{"error":"invalid_client"}` 401 with `WWW-Authenticate: Basic realm="OAuth"`. - Authorize: unknown client or unregistered `redirect_uri` → 400 `text/plain; charset=UTF-8` "Unknown client." / "Unregistered redirect URI." with `Cache-Control: no-store` and no `Location`; later failures 302 to `redirect_uri` with `error`, `error_description`, `iss` and `state` (RFC 3986 encoding); `code_challenge` length 43..128, method must be `S256`; default scope `["read"]`; ceiling truncation keeps `offline_access` outside the ceiling; `resource` optional and must equal the configured value; success 302 to `/connect?request=` with `Cache-Control: no-store`. - Register: 201 `{client_id, client_id_issued_at, client_name, redirect_uris, grant_types, response_types, token_endpoint_auth_method[, client_secret, client_secret_expires_at: 0]}` with `Cache-Control: no-store`; errors 400 `{error, error_description}`; default name "MCP client"; at most 5 unique URIs, each https or http on 127.0.0.1/localhost, ≤512 chars; `client_id` = base64url(16 random bytes), secret = base64url(32) stored as sha256 hex; `registration_ip` set (artisan clients have it null and are never swept). - Consent: `GET oauth/request/{id}` → `{"data":{client_name, redirect_host, scopes_requested, collection_name, expires_at}}` or 404 `{"error":"Request not found"}`; `POST consent` validates `request_id` and `scopes.*` in `read,write,ai`, 422 `{"error":"No grantable scopes"}` when the intersection is empty, returns `{"data":{"redirect_to": ?code=&iss=[&state=]}}` and stamps `consented_at` once; `POST deny` marks the pending row used and returns `redirect_to` with `error=access_denied&iss[&state]`. - Connected apps: `{"data":[serializeToken + client_name], "manual_tokens_count": n}` ordered by `created_at` desc; `DELETE` → 404 `{"error":"Token not found"}` for foreign or manual tokens, else revokes the access token and the whole refresh lineage, `{"data":{"revoked":true}}`. - Refresh rotation: presenting a spent (rotated) refresh token revokes the whole lineage and returns `invalid_grant`; rotation revokes the old access token and mints a new `inv_` token named after the client (≤120 chars) with the same scopes and collection ids. - Codes, refresh secrets and PKCE share one base64url + sha256 transform; codes are single-use with `used_at`; `request_id` is nulled when the code is issued. ## Deferred Ideas - Summer-themed default token prefix for the framework and other projects; `inv_` is the Inventory-era legacy and Płytarium must keep it while fonoteka-mcp is the client (D-11). - Moving the on-request expiry sweep into a River job — Phase 11. - A generic framework-side client-issuing command (the app-side port was chosen, D-19). - Live Claude, ChatGPT and Grok connects against the Go backend — cutover UAT, not this phase (D-15). - Social login `/oauth/{provider}` and the `oauth-identities` JWT routes — still deferred from Phase 7; not part of this OAuth server. --- *Phase: 08-oauth2-1-authorization-server* *Context gathered: 2026-09-23*