docs(08-04): complete token-exchange plan
This commit is contained in:
@@ -337,7 +337,7 @@ Plans:
|
||||
|
||||
**Wave 4** *(blocked on 08-03)*
|
||||
|
||||
- [ ] 08-04-PLAN.md — Implement atomic PKCE-bound authorization-code exchange
|
||||
- [x] 08-04-PLAN.md — Implement atomic PKCE-bound authorization-code exchange
|
||||
|
||||
**Wave 5** *(blocked on 08-04)*
|
||||
|
||||
@@ -496,7 +496,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
||||
| 5. Data layer full fidelity | 6/6 | Complete | 2026-09-18 |
|
||||
| 6. HTTP routing, auth groups and rate limiting | 14/14 | Complete | 2026-09-21 |
|
||||
| 7. User plugin and authentication | 8/8 | Complete | 2026-09-23 |
|
||||
| 8. OAuth2.1 authorization server | 3/10 | In Progress| |
|
||||
| 8. OAuth2.1 authorization server | 4/10 | In Progress| |
|
||||
| 9. Backend admin authentication and schema pipeline | 0/TBD | Not started | - |
|
||||
| 10. Admin Vue SPA | 0/TBD | Not started | - |
|
||||
| 11. Jobs, realtime and search infrastructure | 0/TBD | Not started | - |
|
||||
|
||||
@@ -3,14 +3,14 @@ gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: executing
|
||||
stopped_at: Completed 08-03-PLAN.md
|
||||
last_updated: "2026-09-23T18:11:44.182Z"
|
||||
stopped_at: Completed 08-04-PLAN.md
|
||||
last_updated: "2026-09-23T18:35:28.069Z"
|
||||
last_activity: 2026-09-23
|
||||
progress:
|
||||
total_phases: 15
|
||||
completed_phases: 7
|
||||
total_plans: 55
|
||||
completed_plans: 48
|
||||
completed_plans: 49
|
||||
percent: 47
|
||||
---
|
||||
|
||||
@@ -26,11 +26,11 @@ See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
## Current Position
|
||||
|
||||
Phase: 08 (oauth2-1-authorization-server) — EXECUTING
|
||||
Plan: 4 of 10
|
||||
Plan: 5 of 10
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-09-23
|
||||
|
||||
Progress: [█████████░] 87%
|
||||
Progress: [█████████░] 89%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
@@ -94,6 +94,7 @@ Progress: [█████████░] 87%
|
||||
| Phase 08 P01 | 25min | 2 tasks | 6 files |
|
||||
| Phase 08 P02 | 30min | 3 tasks | 14 files |
|
||||
| Phase 08 P03 | 20min | 2 tasks | 6 files |
|
||||
| Phase 08 P04 | 15min | 2 tasks | 5 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -223,6 +224,9 @@ Recent decisions affecting current work:
|
||||
- [Phase 08]: [Phase 08 P03]: authorize's allowed-scope set is a package-level authorizeAllowedScopes constant, not Options.ScopesSupported (RFC 8414 metadata field) -- conceptually distinct config surfaces matching PHP's own separation
|
||||
- [Phase 08]: [Phase 08 P03]: Client lookup and pending-row creation each open their own WithinTx call, matching PHP's lack of a wrapping transaction around authorize; only DCR and later code-exchange/refresh-rotation need single-transaction atomicity
|
||||
- [Phase 08]: [Phase 08 P03]: AUTH-05/AUTH-06/AUTH-07 remain Pending in REQUIREMENTS.md, continuing 08-01/08-02's decision: this plan ships authorize only, not the full RFC surface
|
||||
- [Phase 08]: [Phase 08 P04]: 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 08-06's job per ROADMAP.md Wave 6
|
||||
- [Phase 08]: [Phase 08 P04]: No routes.go/plugin.go changes -- POST /oauth/mcp/token is not mounted on the assembled app this plan; mounting happens once 08-05 wires consent and produces a real issued code
|
||||
- [Phase 08]: [Phase 08 P04]: MintablePrefix changed from const to var (D-11 groundwork) with no config wiring added yet; default stays byte-identical inv_
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -244,6 +248,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-09-23T18:11:30.777Z
|
||||
Stopped at: Completed 08-03-PLAN.md
|
||||
Last session: 2026-09-23T18:35:28.050Z
|
||||
Stopped at: Completed 08-04-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user