120 lines
12 KiB
Markdown
120 lines
12 KiB
Markdown
---
|
|
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
|