Files
summercms/.planning/phases/08-oauth2-1-authorization-server/08-06-SUMMARY.md
2026-09-23 21:42:32 +02:00

18 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 06 auth
oauth2
rfc6749
refresh-rotation
replay-detection
wristband
gorm
transactions
postgres
row-lock
connected-apps
phase plan provides
08-oauth2-1-authorization-server 05 wristband.Server.PendingRequest/IssueCode/DenyPending, the fonoteka JWT-group consent API, POST /oauth/mcp/token mounted on the raw group, and rotateRefreshToken's invalid_grant placeholder this plan replaces
wristband.Server.rotateRefreshToken: real refresh-grant rotation (revoke old access token, mint successor, link rotated_to_id) and replay-kill (revoke the entire lineage and commit that kill before mapping the outcome to invalid_grant outside the transaction)
wristband.Server.Revoke: the cascade-revoke seam (access token + linked refresh lineage, one committed transaction) a connected-app controller calls instead of touching refresh rows directly
D-17 expiry sweep on /token (DeleteExpiredCodes/DeleteExpiredRefreshTokens), in addition to /register's existing SweepUnconsented
fonoteka OAuthStore adapter: ByAPITokenIDForUpdate, MarkRotated, DeleteExpiredCodes, DeleteExpiredRefreshTokens, and a fixed RevokeLineage that now also revokes each visited row's linked access token
fonoteka JWT-group connected-apps API: GET/DELETE /_fonoteka/api/v1/oauth/connected-apps(/{id}), reusing serializeToken plus a sanitized client_name, owner/live/OAuth-only filtering, and the identical 404 for foreign/missing/manual token ids
08-07-oauth-client-command
08-08-mcp-me-prerequisite
08-09-parity-and-real-mcp-gate
08-10-unit-tests-and-security-review
added patterns
Commit-then-map-to-error: replay detection returns nil (success) from the WithinTx callback so the lineage-kill revocation commits, then the caller maps a captured 'replayed' flag to errInvalidGrant outside the transaction -- mirrors PHP rotateRefresh's own commit-then-throw shape and is now the second instance of this pattern in wristband after 08-05's IssueCode boundary
RevokeLineage walks forward only through RotatedToID starting at the presented/terminal row; because every normal rotation already revokes its own predecessor's access token, and a connected-app revoke always starts from the terminal (never-yet-rotated) row, a forward-only walk is sufficient for both replay-kill and connected-app-revoke without needing a backward predecessor search (unlike PHP's own bidirectional lineageIds)
RED/GREEN staged via a saved-then-reverted rotateRefreshToken body (not a full route/handler revert) for the framework RED commit, since the store-interface extensions and the Token handler's sweep/Revoke additions are compile prerequisites shared by both RED and GREEN test states
created modified
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go
wristband/token.go
wristband/token_test.go
wristband/stores.go
wristband/registration_test.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
AUTH-05/AUTH-06/AUTH-07 remain Pending in REQUIREMENTS.md even though this plan ships the refresh-rotation and connected-apps pieces their text names: AUTH-05/AUTH-07 both also require an 'unchanged fonoteka-mcp install/auth flow' verification that only 08-08 (mcp-me prerequisite) and 08-09 (parity-and-real-mcp-gate) can prove; AUTH-06's CSRF/rate-limit/cache-header claims were largely already true from earlier plans, not newly established here. Marking any of the three Complete now would misrepresent phase progress against their full requirement text.
RevokeLineage's fix (also revoking each visited row's linked access token) is Rule 1 (bug), not Rule 2: the 08-02-era method already existed and was already wired into the replay path by 08-04's placeholder-era interface, but only ever stamped the refresh row itself -- a replayed lineage's live access token would have stayed usable had this plan not caught it while implementing the real rotation caller.
ByAPITokenIDForUpdate/MarkRotated/DeleteExpiredCodes/DeleteExpiredRefreshTokens were added to RefreshTokenStore/AuthCodeStore as new interface methods (not folded into existing ones) so the same Tx bundle continues to serve every later Phase 8 plan without another interface change, following 08-02's precedent for the store seam.
The D-17 sweep on /token runs as its own WithinTx call before authenticateClient/grant processing, not folded into the exchange/rotation transaction, so a sweep failure surfaces as a plain opaque 500 independent of which grant type was requested.
grantThenExchange (classes/auth package) and oauthLifecycleGrant (app package) are the shared 'drive a real grant through the real endpoints' fixtures later Phase 8 plans' refresh/revoke tests should reuse rather than re-deriving PKCE/consent/exchange boilerplate.
~50min 2026-09-23

Phase 08 Plan 06: Lifecycle and Sweeps (Refresh Rotation + Connected Apps) Summary

Refresh-token rotation with commit-then-kill replay detection, a D-17 expiry sweep on /token, and owner-scoped connected-app list/revoke that cascades through a new wristband.Server.Revoke seam -- all proven against real Postgres including synchronized concurrent-replay and concurrent-rotation row-lock tests.

Performance

  • Duration: ~50 min
  • Started: ~2026-09-23T19:00:00Z (approx.)
  • Completed: ~2026-09-23T19:40:00Z (approx.)
  • Tasks: 3 completed (5 commits: RED/GREEN pair in summercms.go for the wristband lifecycle framework, RED test + two GREEN commits in fonoteka.go for the store adapter and the connected-apps controller)
  • Files modified: 10 (4 modified in summercms.go's wristband package; 3 created + 3 modified in fonoteka.go)

Accomplishments

  • wristband.Server.rotateRefreshToken replaces 08-04's invalid_grant placeholder with a real port of OAuthCodeManager::rotateRefresh: a fresh refresh token row-locks, revokes its own old access token, mints a same-scope/same-collection successor pair, and links rotated_to_id; a replayed (already-rotated) token instead revokes the entire lineage -- every successor row and every linked access token -- and commits that kill before the handler reports invalid_grant, so a stolen predecessor can never resurrect a live branch (T-08-REFRESH-REPLAY)
  • RevokeLineage (both the fonoteka GORM adapter and wristband's in-memory test double) was fixed to also revoke each visited row's linked access token -- the 08-02-era method only ever stamped the refresh row's own revoked_at, leaving a replayed lineage's currently-live access token usable; this was caught while wiring the real rotation caller, not left as a known gap
  • wristband.Server.Revoke is the new cascade-revoke seam: it revokes an access token and, if a refresh row is still linked to it, kills that row's whole lineage too, in one committed transaction -- the exact operation the connected-app controller needs and never has to reimplement with its own refresh-table SQL
  • D-17's expiry sweep now also runs on /token (AuthCodeStore.DeleteExpiredCodes/RefreshTokenStore.DeleteExpiredRefreshTokens), deleting only rows already past expires_at while unexpired rotated/revoked rows survive as replay evidence -- proven both against the in-memory backend and real Postgres
  • fonoteka's ConnectedAppsIndex/ConnectedAppsDestroy expose the existing Settings -> Integrations UI's backing API on the JWT group only: live/owner/OAuth-only tokens newest-first via serializeToken plus a sanitized client_name, and a foreign/missing/manual token id all collapse to the identical {"error":"Token not found"} 404 (T-08-CROSS-USER) -- a revoke removes the app from the next list read and kills both the access token and the refresh token in the same request
  • Both scripts/check-phase8-red.sh sentinels (PHASE8_RED:lifecycle-framework in wristband, PHASE8_RED:lifecycle-app in the assembled fonoteka app) were verified fail-closed against the genuine pre-implementation state (rotation placeholder; unmounted connected-apps routes) before their GREEN commits; the full GREEN suite (go vet/go test/go test -race) is green across summercms.go and every fonoteka.go workspace module (fonoteka/parity, plugins/golem15/fonoteka and its subpackages, plugins/golem15/user)

Task Commits

Each task's RED test was committed and verified fail-closed via scripts/check-phase8-red.sh before its GREEN implementation:

  1. Task 1: lifecycle RED anchors -- b2c2cc0 (test, summercms.go): TestPhase8RedLifecycleFramework fails against the rotateRefreshToken placeholder (PHASE8_RED:lifecycle-framework); extends RefreshTokenStore/AuthCodeStore with the store seams Task 2 needs and updates the in-memory test double to compile against them. b3c235d (test, fonoteka.go): TestPhase8RedLifecycleApp fails against the unmounted connected-apps routes (PHASE8_RED:lifecycle-app), plus the list/revoke contract test matrix Task 3 turns green.
  2. Task 2: implement refresh rotation, committed replay kill, and exact sweeps -- dab2b8f (feat, summercms.go): the real rotateRefreshToken, the /token expiry sweep, and Server.Revoke. f62a952 (feat, fonoteka.go): ByAPITokenIDForUpdate/MarkRotated/DeleteExpiredCodes/DeleteExpiredRefreshTokens on the GORM adapter, the RevokeLineage access-token-revocation fix, and real-Postgres proofs (rotation linking, full lineage-kill on replay, concurrent-rotation row-lock winner, sweep retention, connected-app-revoke cascade).
  3. Task 3: connected-app list/revoke on the JWT group -- 3b5fda5 (feat, fonoteka.go): ConnectedAppsIndex/ConnectedAppsDestroy, their route mount, package-local handler unit tests, and the app-level lifecycle/surface-isolation test suite.

Plan metadata: committed as part of this summary/state-update commit.

Note: Tasks 1/2 carry tdd="true"; RED/GREEN pairs land as separate commits, split per repo. Task 3's RED anchor (TestPhase8RedLifecycleApp) was written and verified alongside Task 1's framework RED commit (both are the plan's single Task 1), then turned green by Task 3's own commit once the controller/routes existed.

Files Created/Modified

  • wristband/token.go -- real rotateRefreshToken (rotation + replay-kill), the /token D-17 sweep call, Server.Revoke
  • wristband/token_test.go -- TestPhase8RedLifecycleFramework plus TestRefreshRotationIssuesNewPairAndKeepsPredecessorAsEvidence, TestRefreshWrongClientIsInvalidGrant, TestRefreshExpiredIsInvalidGrant, TestRefreshUnknownTokenIsInvalidGrant, TestRefreshMissingTokenIsInvalidGrant, TestRefreshConcurrentReplayHasExactlyOneWinner, TestTokenSweepDeletesExpiredRowsButKeepsUnexpiredEvidence, TestServerRevokeKillsAccessAndLineage
  • wristband/stores.go -- RefreshTokenStore gains ByAPITokenIDForUpdate/MarkRotated/DeleteExpiredRefreshTokens; AuthCodeStore gains DeleteExpiredCodes
  • wristband/registration_test.go -- memoryTx/memoryBackend updated for the extended interfaces, a revoked tracking map, a RevokeLineage fix mirroring the GORM adapter, and a Mint secret-uniqueness fix (the old "mem_" + name secret collided across two mints for the same client name, which a rotation test would have otherwise silently mismatched)
  • ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go -- ByAPITokenIDForUpdate, MarkRotated, DeleteExpiredCodes, DeleteExpiredRefreshTokens, and RevokeLineage's access-token-revocation fix
  • ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go -- TestOAuthRefreshRotationLinksPredecessorToSuccessor, TestOAuthRefreshReplayRevokesLineageAndBothAccessTokens, TestOAuthRefreshConcurrentReplayHasExactlyOneWinner, TestOAuthSweepDeletesExpiredCodesAndRefreshTokensKeepsUnexpiredEvidence, TestOAuthConnectedAppRevokeStoreKillsAccessAndLineage
  • ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go -- ConnectedAppsIndex, ConnectedAppsDestroy, serializeConnectedApp
  • ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go -- package-local handler unit tests (list allow-list/forbidden-fields, foreign/missing/owned destroy)
  • ../fonoteka.go/plugins/golem15/fonoteka/routes.go -- mounts GET/DELETE /_fonoteka/api/v1/oauth/connected-apps(/{id}) on the existing JWT group
  • ../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go -- TestPhase8RedLifecycleApp, TestConnectedAppsListEmptyThenPopulatedWithManualCount, TestConnectedAppsRevokeForeignAndMissingAndManualShareExact404, TestConnectedAppsRevokeKillsAccessAndRefreshThenDisappearsFromList, TestOAuthConnectedAppsSurfaceIsolation

Decisions Made

See frontmatter key-decisions. The most load-bearing: RevokeLineage's access-token gap was a genuine Rule 1 bug in code that predates this plan (08-02's Tx interface shipped RevokeLineage's signature, and 08-04's placeholder already wired the replay branch to call it) -- without this plan's fix, a replayed refresh token would have correctly killed the refresh-row lineage but left the currently-live access token minted by the last legitimate rotation fully usable, defeating T-08-REFRESH-REPLAY's entire point.

Deviations from Plan

Auto-fixed Issues

1. [Rule 1 - Bug] RevokeLineage did not revoke linked access tokens

  • Found during: Task 2, while implementing rotateRefreshToken's replay branch and designing its verification
  • Issue: RevokeLineage (both the GORM adapter from 08-02 and wristband's in-memory test double) only stamped revoked_at on visited refresh rows; it never touched the ApiToken row each refresh row's APITokenID points at. A replay would correctly mark the whole refresh chain dead but leave the last legitimate access token minted by rotation still verifiable.
  • Fix: RevokeLineage now also row-locks and revokes each visited row's linked access token (if unrevoked), matching PHP OAuthCodeManager::revokeLineage's own per-row access-token revoke
  • Files modified: ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go, wristband/registration_test.go
  • Verification: TestOAuthRefreshReplayRevokesLineageAndBothAccessTokens, TestPhase8RedLifecycleFramework, TestServerRevokeKillsAccessAndLineage
  • Committed in: dab2b8f (summercms.go in-memory double), f62a952 (fonoteka.go GORM adapter)

2. [Rule 1 - Bug] In-memory Mint test double minted colliding secrets across rotations

  • Found during: Task 1, while designing the lifecycle RED anchor
  • Issue: memoryTx.Mint's secret was "mem_" + name with no per-mint uniqueness; a rotation test minting a second access token for the same client name would receive the identical secret string as the first, making secret-based identification in tests silently ambiguous
  • Fix: The secret now embeds the freshly-allocated id ("mem_<id>_<name>")
  • Files modified: wristband/registration_test.go
  • Verification: TestPhase8RedLifecycleFramework and the other rotation tests correctly distinguish old vs. new access tokens by secret
  • Committed in: b2c2cc0

Total deviations: 2 auto-fixed (2 bugs, both pre-existing test-infrastructure/store gaps caught while building this plan's own verification) Impact on plan: No scope change beyond the plan's own stated D-04/D-17/D-08 behavior; both fixes were necessary for the plan's own tests to correctly prove T-08-REFRESH-REPLAY, not separate feature additions.

Issues Encountered

None beyond the auto-fixed items above. Full go vet/go test ./.../go test -race ./... are green in summercms.go (wristband and every other package) and in every fonoteka.go workspace module: the root fonoteka/parity module, plugins/golem15/fonoteka and its classes, classes/auth, controllers/api, middleware, models, updates subpackages, and plugins/golem15/user. scripts/check-phase8-ui.mjs --contract-self-test remains green (no Nuxt-facing change).

User Setup Required

None -- no external service configuration required.

Next Phase Readiness

  • wristband.Server.Revoke is the established cascade-revoke seam; any future plan needing to kill an access token's OAuth lineage should call it rather than reimplementing refresh-table SQL.
  • RefreshTokenStore/AuthCodeStore now carry every method 08-02's own "no DeleteExpired-shaped method yet" note flagged as outstanding; no further store-interface changes are anticipated for the remaining Phase 8 plans' known scope.
  • AUTH-05/06/07 remain Pending in REQUIREMENTS.md: this plan closes the refresh-rotation and connected-apps pieces of their text, but each requirement's full text also depends on an "unchanged fonoteka-mcp install/auth flow" proof that 08-08/08-09 own, plus 08-10's security review reconciling AUTH-06's CSRF/rate-limit/cache-header claims against the complete threat register.
  • 08-07 (OAuth client command) and 08-08 (mcp-me prerequisite) can build directly on this plan's Server.Revoke/sweep seams without another wristband interface change.
  • No blockers.

Self-Check: PASSED

  • FOUND: wristband/token.go, wristband/token_test.go, wristband/stores.go, wristband/registration_test.go
  • FOUND: ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go, oauth_store_test.go
  • FOUND: ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go, connected_app_controller_test.go
  • FOUND: ../fonoteka.go/plugins/golem15/fonoteka/routes.go, oauth_lifecycle_test.go
  • FOUND commits (summercms.go): b2c2cc0, dab2b8f
  • FOUND commits (fonoteka.go): b3c235d, f62a952, 3b5fda5

Phase: 08-oauth2-1-authorization-server Completed: 2026-09-23