--- phase: 08-oauth2-1-authorization-server plan: 04 subsystem: auth tags: [oauth2, rfc6749, pkce, code-exchange, wristband, gorm, transactions, postgres, row-lock] # Dependency graph requires: - phase: 08-oauth2-1-authorization-server plan: 03 provides: "wristband.Server.Authorize (durable PKCE-bound pending AuthCodeRecord rows) and the transaction-scoped Backend/Tx store bundle from 08-02" provides: - "wristband.Server.Token: the RFC 6749 token endpoint for grant_type=authorization_code — exact D-02 parser (JSON rejection, ParseForm body-over-query, Basic-over-form), client authentication (public/confidential, constant-time secret compare), one WithinTx code lock/consume/mint/refresh-create, exact success/error bodies" - "wristband.Options gains AccessTokenTTL/RefreshTokenTTL (PHP-parity 3600s/30d defaults), continuing 08-03's Options-extension pattern" - "fonoteka classes/auth.MintablePrefix as a var (D-11 groundwork: config-backable, default stays exact \"inv_\"), proven end to end against real Postgres through the assembled OAuthStore-backed wristband.Server.Token" affects: [08-05-consent-and-connected-apps, 08-06-lifecycle-and-sweeps, 08-09-parity-and-real-mcp-gate, 08-10-unit-tests-and-security-review] # Tech tracking tech-stack: added: [] patterns: - "grant_type=refresh_token is dispatched with the exact PHP validity check (accepted, not unsupported_grant_type) but rotateRefreshToken is a deliberate invalid_grant placeholder in this plan's scope; full lineage-kill rotation is ROADMAP.md Wave 6 (08-06-PLAN.md), matching this plan's threat register (no T-08-REFRESH-REPLAY here)" - "Code-exchange validation failures return errInvalidGrant before any mutation inside WithinTx, so a rejected exchange rolls back cleanly with nothing to undo — unlike refresh rotation's future commit-then-error replay pattern, code exchange never needs it" key-files: created: - ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_token_issuer_test.go modified: - wristband/token.go - wristband/token_test.go - wristband/server.go - ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go key-decisions: - "Token dispatch accepts grant_type=refresh_token per PHP's exact validity check but rotateRefreshToken always returns invalid_grant in this plan's scope; full rotation/lineage-kill (T-08-REFRESH-REPLAY) is explicitly 08-06's job per ROADMAP.md Wave 6, matching the plan's own threat register which omits T-08-REFRESH-REPLAY" - "No routes.go/plugin.go changes: this plan does not mount POST /oauth/mcp/token on the assembled app. The plan's own files_modified list and Task 2 verify command (classes/auth package only, not a route/controller test) scope this plan to the wristband handler plus store-adapter proof; mounting happens once 08-05 wires consent and there is a real issued code to drive end to end" - "MintablePrefix changed from const to var (D-11 groundwork) with no config wiring added yet — plugin.go/config.yaml are not in this plan's file list; the default stays byte-identical \"inv_\" so nothing on the wire changes until a later plan wires it from golem15.fonoteka.oauth.* config" - "Options gained AccessTokenTTL (3600s) and RefreshTokenTTL (30 days) following 08-03's precedent of extending Options for deployment-configurable TTLs" patterns-established: - "Real-Postgres code-exchange proof lives in classes/auth/oauth_token_issuer_test.go, seeding an already-issued (post-consent-shaped) AuthCodeRecord directly via GORM rather than driving the not-yet-built consent flow — the exact seam 08-05's consent handler will populate through AuthCodeStore.MarkIssued" requirements-completed: [] # Metrics duration: ~15min completed: 2026-09-23 --- # Phase 08 Plan 04: Token Exchange Summary **`wristband.Server.Token` ports the exact PHP token-endpoint parser, client authentication, and atomic authorization-code exchange (lock, consume, mint inv_ access token, create refresh row) in one transaction, proven against both an in-memory backend and real Postgres including a synchronized concurrent-replay test with exactly one winner.** ## Performance - **Duration:** ~15 min - **Started:** 2026-09-23T20:16:00+02:00 (approx.) - **Completed:** 2026-09-23T20:31:00+02:00 (approx.) - **Tasks:** 2 completed (3 commits: RED/GREEN pair in summercms.go, GREEN companion in fonoteka.go) - **Files modified:** 5 (3 in summercms.go's wristband package, 1 created + 1 modified in fonoteka.go) ## Accomplishments - `wristband.Server.Token` is a byte-for-byte port of `OAuthTokenController::token`/`OAuthCodeManager::exchangeCode`'s validation order: JSON-body rejection before any form parsing (closing Pitfall 4 — a JSON-labeled request cannot smuggle a grant through the query string), `net/http.Request.ParseForm`'s own body-over-query precedence (verified directly against the Go 1.27 stdlib source, not assumed), Basic-credentials-override-form-credentials client authentication, and exact `invalid_request`/`unsupported_grant_type`/`invalid_client`/`invalid_grant` bodies - Client authentication dispatches public (`token_endpoint_auth_method: "none"`) vs. confidential clients exactly like PHP, comparing the confidential secret's sha256 hex through `crypto/subtle.ConstantTimeCompare` (T-08-SECRET-TIMING) and returning `WWW-Authenticate: Basic realm="OAuth"` only on `invalid_client` - `exchangeAuthorizationCode` runs the entire lock/consume/mint/refresh-create sequence inside one `WithinTx` callback: `ByCodeHashForUpdate` row-locks the code, every PHP binding (used/expired/client/redirect/resource/PKCE S256) is checked before any mutation, `MarkUsed` consumes the code, `Mint` produces the `inv_` access token (name truncated to 120 runes exactly like PHP's `mb_substr`), and a refresh-token row is created linked to it — all committed together (T-08-CODE-REPLAY) - A synchronized concurrent-replay test proves exactly one winner both at the framework level (in-memory backend, mutex-serialized `WithinTx`) and against real Postgres (`ByCodeHashForUpdate`'s `FOR UPDATE` row lock): two goroutines racing the same code yield exactly one 200 and one `invalid_grant`, and exactly one persisted access token - `MintablePrefix` became a var (D-11 groundwork toward a config-backed prefix) with its default byte-identical to the existing `"inv_"`, proven unchanged by `TestOAuthCodeExchangeMintsInvAccessAndRefreshTokens` and `TestOAuthIssuerMintsConfiguredPrefixAndStampsClient` against real Postgres ## Task Commits Each task was committed atomically (TDD RED then GREEN): 1. **Task 1: code-exchange RED anchor** — `5ca830b` (test, summercms.go): `Server.Token` 501 stub, `TestPhase8RedCodeExchange` fails the exact valid-S256-exchange success contract against it (`PHASE8_RED:code-exchange`), plus `Options.AccessTokenTTL`/`RefreshTokenTTL`. Verified fail-closed via `scripts/check-phase8-red.sh`. 2. **Task 2: implement and prove atomic code exchange** — `4bd3b3d` (feat, summercms.go): the real `Token` handler, `authenticateClient`, `verifyPkce`, `exchangeAuthorizationCode`, the `rotateRefreshToken` placeholder, and the full `wristband` behavior matrix (`TestToken*`); `510c5b4` (feat, fonoteka.go): `MintablePrefix` var and `oauth_token_issuer_test.go`'s real-Postgres `TestOAuthCode*`/`TestOAuthIssuer*` proofs. **Plan metadata:** committed as part of this summary/state-update commit. _Note: both tasks carry `tdd="true"`; RED/GREEN pairs land as separate commits, and Task 2's GREEN splits across the two repositories it touches._ ## Files Created/Modified - `wristband/token.go` — `Server.Token`, `authenticateClient`, `verifyPkce`, `exchangeAuthorizationCode`, `rotateRefreshToken` (placeholder), `writeTokenError`, `tokenSuccessBody`/`tokenErrorBody`/`tokenIssueResult`, `errInvalidClient`/`errInvalidGrant` - `wristband/token_test.go` — `TestPhase8RedCodeExchange` (RED anchor) plus the full unit matrix: JSON rejection, body-over-query precedence, Basic-over-form credentials, missing/unsupported grant type, unknown/revoked/wrong-secret/missing-secret client (with exact `WWW-Authenticate` assertion), public-client-ignores-secret, wrong verifier, client/redirect/resource binding mismatches, resource-omitted acceptance, expired code, missing required fields, sequential and concurrent replay (exactly one winner), offline_access scope appending, no-envelope/no-trailing-newline/exact-headers, refresh-grant-dispatch-accepted-but-not-implemented, backend-unavailable 500 - `wristband/server.go` — `Options` gains `AccessTokenTTL`/`RefreshTokenTTL` with PHP-parity defaults - `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go` — `MintablePrefix` changed from `const` to `var` (D-11) - `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_token_issuer_test.go` — `TestOAuthCodeExchangeMintsInvAccessAndRefreshTokens`, `TestOAuthCodeExchangeWrongVerifierIsInvalidGrantAndMintsNothing`, `TestOAuthCodeExchangeConcurrentReplayHasExactlyOneWinner`, `TestOAuthIssuerMintsConfiguredPrefixAndStampsClient` ## Decisions Made See frontmatter `key-decisions`. Most notable: this plan deliberately does not mount `POST /oauth/mcp/token` on the assembled `fonoteka.go` app (no `routes.go`/`plugin.go` changes) — the plan's own `files_modified` list and Task 2's verify command (`classes/auth` package tests only, not a route/controller/parity test) scope 08-04 to the wristband handler plus a direct real-Postgres store-adapter proof. Mounting the route happens once 08-05 wires consent, since only then does a real issued code (as opposed to a directly-seeded test fixture) exist to drive the endpoint end to end through the app. ## Deviations from Plan None — plan executed as written. The `grant_type=refresh_token` placeholder and the "no route mounting" scoping were both already implied by the plan's own action text (D-05 owns refresh rotation as future scope per the roadmap wave split) and file list respectively, not additions beyond it. ## Issues Encountered None. Full `go vet`/`go test ./...` in `summercms.go` is green; `go vet`/`go test ./...` in `fonoteka.go`'s `plugins/golem15/fonoteka` module is green including the new real-Postgres tests. The root `parity` module's two pre-existing migration-count test failures (documented in 08-03-SUMMARY.md's Issues Encountered and `deferred-items.md`) are unchanged and out of this plan's scope (no migration/model files touched here). ## User Setup Required None — no external service configuration required. ## Next Phase Readiness - `AuthCodeRecord`'s post-consent shape (`CodeHash` set, `RequestID` nil, `UserID` set) is exactly what 08-05's consent flow must produce via `AuthCodeStore.MarkIssued` before a real `/token` call can succeed end to end through the assembled app. - `Options.AccessTokenTTL`/`RefreshTokenTTL` are ready for 08-06's refresh rotation to reuse the same TTL fields rather than adding new ones. - `rotateRefreshToken`'s placeholder is the explicit seam 08-06 must replace with real lock/rotate/lineage-kill logic (T-08-REFRESH-REPLAY); it is not wired into any route yet, so there is no user-visible regression to fix, only a function body to complete. - `POST /oauth/mcp/token` is still unmounted on the assembled app; 08-05 (or whichever plan first needs an end-to-end `/token` call through the real route) must add the `routes.go`/`plugin.go` wiring 08-03 established for `/authorize`. - AUTH-05/AUTH-06 remain Pending in REQUIREMENTS.md, continuing 08-01/08-02/08-03's decision: this plan ships code exchange only; consent, refresh rotation, DCR client command, and the `/me` prerequisite remain for later Phase 8 plans. - No blockers. ## Self-Check: PASSED - FOUND: wristband/token.go, wristband/token_test.go, wristband/server.go - FOUND: ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go, oauth_token_issuer_test.go - FOUND commits (summercms.go): 5ca830b, 4bd3b3d - FOUND commits (fonoteka.go): 510c5b4