23 KiB
Phase 8: OAuth2.1 authorization server - Context
Gathered: 2026-09-23 Status: Ready for planning
## Phase BoundaryPort 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).
Server engine
- D-01: Direct port on the standard library (
crypto/rand,crypto/sha256,crypto/subtle,net/url,encoding/base64RawURLEncoding). 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 mintinv_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.
/tokenreads the urlencoded body plus the query string (GoParseFormsemantics, matching Laravelinput()for the clients that exist); a JSON body on/tokenisinvalid_request./registeris JSON-only: a non-JSON content type isinvalid_client_metadata"Request must be application/json"./authorizereads 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.urlwith trailing slash trimmed; the expected RFC 8707 resource isfonoteka.mcp.resource(defaulthttps://mcp.plytarium.com/mcp); the consent URL isapp.url+/connect?request=<opaque>. Exact key layout (the option discussed wasplugins.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-xxthreats (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 with08-SECURITY-REVIEW.mdmapping each threat to a failing-when-broken test. Client-secret comparison iscrypto/subtle.ConstantTimeCompareover 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 withbouncer) owns the RFC surface: metadata document, authorize validation and pending-request creation, token endpoint (client authentication withclient_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 modelsOAuthClient,OAuthAuthCode,OAuthRefreshToken) and anAccessTokenIssuerthat fonoteka satisfies with its Phase 7ApiTokenManager(mint with name, scopes, expiry, collection ids; revoke by id), then stampsoauth_client_id. Code exchange and refresh rotation run in one transaction with row locks as PHP'slockForUpdatedoes, 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
ActiveCollectionResolverforcollection_nameandcollection_ids,MINTABLE_SCOPESintersection,consented_atstamping), connected-apps index/destroy (Phase 7serializeTokenplusclient_name, revoke cascading through wristband's lineage kill), the/connectURL 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.goroutes.go, withthrottle:fonoteka-oauth-tokenon/tokenandthrottle:fonoteka-oauth-registeron/registeras 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
oauthguard reserved by Phase 6 D-09 is retired. OAuth-issued tokens areinv_tokens theinv_tokenguard already authenticates;oauth_client_idis 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 onlyBearer inv_. The Phase 7 token manager reads its prefix from config withinv_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-resourcedocument are emitted by fonoteka-mcp (the resource server), not by the backend. The backend's contract is: exactlyWWW-Authenticate: Basic realm="OAuth"oninvalid_clientat/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 newmcp-lifecyclefrom D-15) replay in the Go test suite infonoteka.go/parity, the waynuxt_flow_test.goreplaysnuxt-auth. - D-14: A
scripts/check-phase8.shgate (same family ascheck-phase2.sh/check-phase3.sh) boots the Go app on Postgres, starts the real Node fonoteka-mcp from/media/nvme/dev/golem15/fonoteka/fonoteka-mcpwithFONOTEKA_API_URL,FONOTEKA_MCP_PUBLIC_URLandFONOTEKA_MCP_AUTH_SERVERpointed 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 issuedinv_token, then refresh. fonoteka-mcp is not modified. The scripted client's language and home are Claude's discretion (parity/capture_clients.mjsexists 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 2tidetooling (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 byfonoteka:oauth-clientwith a scope ceiling usingclient_secret_basic, showing ceiling truncation and theinvalid_scoperedirect. The four RFC route entries and five JWT-group entries inparity/manifest.yamlflip 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 pastexpires_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
classesTestMain 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-clientis ported infonoteka.goas 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,--listnever printing a secret). It is thin over wristband's client issuing helper and the appClientStore. - D-20 (MCP prerequisite): Phase 8 ports the minimal exact
GET /api/v1/fonoteka/mepersonal-token endpoint required byfonoteka-mcp/src/http.tsbefore it constructs the MCP server. The endpoint authenticates through the existinginv_tokensurface 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/registeraccepts at most 64 KiB of JSON request body. An oversized document returns the endpoint's normalinvalid_client_metadataresponse rather than a house envelope or generic HTML error. The bound is enforced before unbounded JSON decoding and is covered by theT-08-DCR-FLOODfailing-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 howclient_namecontrol characters are stripped (port PHP's regex). - How
Content-Typeis judged for/register(PHPisJson()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 withiss, 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,pendingForrules,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,rejectRedirectUrirules./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.resourcedefault./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*.phpandtests/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 401WWW-Authenticatehint,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.tsandapp/stores/fonoteka.ts— the Nuxt calls tooauth/request,consent,deny,connected-apps.
Parity harness
../fonoteka.go/parity/manifest.yaml— the nine pending OAuth entries (auth_group: oauthand the JWT-groupoauth/*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-08inv_tokenguard andinv.scope, D-09 reservedoauthguard (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 for08-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.mdbouncer 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 withlagoon.JsonableJSON columns;api_token.goalready carriesOAuthClientIDandCollectionIDs.../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go— Phase 7 mint/revoke, theAccessTokenIssuerimplementation (D-07);token_guard.go— theinv_tokenguard that authenticates OAuth-issued tokens (D-10).../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.goserializeToken— reused by connected-apps (D-08).../fonoteka.go/plugins/golem15/fonoteka/plugin.go—fonoteka-oauth-tokenandfonoteka-oauth-registerbuckets already declared.surf/router.goGroupRaw— the raw group API;../fonoteka.go/plugins/golem15/fonoteka/routes.goline ~41 holds the empty raw group comment "throttles attach per-route in Phase 8".../fonoteka.go/config/http.yaml— CORS already listsoauth/mcp/*.wirepackage JSON writer and Carbon-format time helpers (P6 D-17) for the consentexpires_atISO 8601 value;lagoon.Validatefor the consentrequest_id/scopesrules.bonfirecommand registry and the Phase 4summer make:commandscaffold for D-19.
Established Patterns
- Framework stays app-agnostic (
summercms.gonever imports fonoteka); app-specific tables and resolvers stay infonoteka.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.goraw group (mount wristband handlers) and the JWT group (consent and connected-apps).plugin.goBoot: construct wristband with the app stores, token issuer and options; register the console command.parity/manifest.yamlandparity/fixtures/mcp/for the new flow;parity/replay tests.scripts/check-phase8.shnext to the existing gates.
</code_context>
## Specific IdeasContract 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. Nojwks_uri, no OIDC fields, no envelope. - Token success:
{access_token, token_type: "Bearer", expires_in, refresh_token, scope}wherescopeis the granted scopes joined by space plusoffline_accesswhen requested; headersCache-Control: no-store,Pragma: no-cache. Errors:{"error":"invalid_request"|"unsupported_grant_type"|"invalid_grant"}400 with no description;{"error":"invalid_client"}401 withWWW-Authenticate: Basic realm="OAuth". - Authorize: unknown client or unregistered
redirect_uri→ 400text/plain; charset=UTF-8"Unknown client." / "Unregistered redirect URI." withCache-Control: no-storeand noLocation; later failures 302 toredirect_uriwitherror,error_description,issandstate(RFC 3986 encoding);code_challengelength 43..128, method must beS256; default scope["read"]; ceiling truncation keepsoffline_accessoutside the ceiling;resourceoptional and must equal the configured value; success 302 to<app.url>/connect?request=<opaque>withCache-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]}withCache-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_ipset (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 consentvalidatesrequest_idandscopes.*inread,write,ai, 422{"error":"No grantable scopes"}when the intersection is empty, returns{"data":{"redirect_to": <redirect_uri>?code=&iss=[&state=]}}and stampsconsented_atonce;POST denymarks the pending row used and returnsredirect_towitherror=access_denied&iss[&state]. - Connected apps:
{"data":[serializeToken + client_name], "manual_tokens_count": n}ordered bycreated_atdesc;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 newinv_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_idis nulled when the code is issued.
- 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 theoauth-identitiesJWT routes — still deferred from Phase 7; not part of this OAuth server.
Phase: 08-oauth2-1-authorization-server Context gathered: 2026-09-23