Files
summercms/.planning/phases/08-oauth2-1-authorization-server/08-04-SUMMARY.md
2026-09-23 20:35:49 +02:00

12 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed duration completed
08-oauth2-1-authorization-server 04 auth
oauth2
rfc6749
pkce
code-exchange
wristband
gorm
transactions
postgres
row-lock
phase plan provides
08-oauth2-1-authorization-server 03 wristband.Server.Authorize (durable PKCE-bound pending AuthCodeRecord rows) and the transaction-scoped Backend/Tx store bundle from 08-02
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
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
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
created modified
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_token_issuer_test.go
wristband/token.go
wristband/token_test.go
wristband/server.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go
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
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
~15min 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