Files
summercms/.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md
2026-09-23 17:13:47 +02:00

23 KiB

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=<opaque>. 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_refs>

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.

</canonical_refs>

<code_context>

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.

</code_context>

## 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: <issuer>/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 <app.url>/connect?request=<opaque> 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": <redirect_uri>?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