diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index b50043b..ff75cb2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -345,7 +345,7 @@ Plans: **Wave 6** *(blocked on 08-05)* -- [ ] 08-06-PLAN.md — Rotate refresh grants, kill replayed lineages, and manage connected apps +- [x] 08-06-PLAN.md — Rotate refresh grants, kill replayed lineages, and manage connected apps **Wave 7** *(parallel; blocked on 08-06)* @@ -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 | 5/10 | In Progress| | +| 8. OAuth2.1 authorization server | 6/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 | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 6b47c91..2874064 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone status: executing -stopped_at: Completed 08-05-PLAN.md -last_updated: "2026-09-23T19:11:27.527Z" +stopped_at: Completed 08-06-PLAN.md +last_updated: "2026-09-23T19:42:20.033Z" last_activity: 2026-09-23 progress: total_phases: 15 completed_phases: 7 total_plans: 55 - completed_plans: 50 + completed_plans: 51 percent: 47 --- @@ -26,11 +26,11 @@ See: .planning/PROJECT.md (updated 2026-09-16) ## Current Position Phase: 08 (oauth2-1-authorization-server) — EXECUTING -Plan: 6 of 10 +Plan: 7 of 10 Status: Ready to execute Last activity: 2026-09-23 -Progress: [█████████░] 91% +Progress: [█████████░] 93% ## Performance Metrics @@ -96,6 +96,7 @@ Progress: [█████████░] 91% | Phase 08 P03 | 20min | 2 tasks | 6 files | | Phase 08 P04 | 15min | 2 tasks | 5 files | | Phase 08 P05 | 55min | 3 tasks | 12 files | +| Phase 08 P06 | 50min | 3 tasks | 10 files | ## Accumulated Context @@ -233,6 +234,10 @@ Recent decisions affecting current work: - [Phase 08]: [Phase 08 P05] Consent scope ordering follows auth.MintableScopes's declared read/write/ai order, not PHP array_intersect's incidental first-array order — 08-UI-SPEC.md explicitly documents canonical read -> write -> ai order as the fixed contract - [Phase 08]: [Phase 08 P05] POST /oauth/mcp/token mounted in this plan, closing 08-04's deliberate D-09 deferral — Only once consent produces a real issued code does an end-to-end /token call through the real route have anything to exchange - [Phase 08]: [Phase 08 P05] AUTH-05/06/07 remain Pending in REQUIREMENTS.md — Refresh rotation (08-06) and connected-apps list/revoke are still outstanding pieces of those requirements; this plan ships consent plus the token mount only +- [Phase 08 P06]: rotateRefreshToken commits lineage-kill revocation inside WithinTx and returns nil (success) on replay, mapping a captured replayed flag to invalid_grant outside the transaction -- mirrors PHP rotateRefresh's own commit-then-throw shape +- [Phase 08 P06]: RevokeLineage walks forward only through RotatedToID; sufficient for both replay-kill and connected-app-revoke because every normal rotation already revokes its own predecessor's access token and connected-app revoke always starts from the terminal row +- [Phase 08 P06]: Fixed RevokeLineage (GORM adapter and in-memory test double) to also revoke each visited row's linked access token -- the 08-02-era method only stamped the refresh row itself, leaving a replayed lineage's live access token usable +- [Phase 08 P06]: AUTH-05/06/07 remain Pending in REQUIREMENTS.md: refresh-rotation and connected-apps pieces are done, but AUTH-05/07's unchanged-fonoteka-mcp clause needs 08-08/08-09, and AUTH-06's CSRF/rate-limit/cache-header claims are reconciled by 08-10's security review ### Pending Todos @@ -254,6 +259,6 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-09-23T19:11:27.509Z -Stopped at: Completed 08-05-PLAN.md +Last session: 2026-09-23T19:42:20.015Z +Stopped at: Completed 08-06-PLAN.md Resume file: None diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-06-SUMMARY.md b/.planning/phases/08-oauth2-1-authorization-server/08-06-SUMMARY.md new file mode 100644 index 0000000..969fa9f --- /dev/null +++ b/.planning/phases/08-oauth2-1-authorization-server/08-06-SUMMARY.md @@ -0,0 +1,160 @@ +--- +phase: 08-oauth2-1-authorization-server +plan: 06 +subsystem: auth +tags: [oauth2, rfc6749, refresh-rotation, replay-detection, wristband, gorm, transactions, postgres, row-lock, connected-apps] + +# Dependency graph +requires: + - phase: 08-oauth2-1-authorization-server + plan: 05 + provides: "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" +provides: + - "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" +affects: [08-07-oauth-client-command, 08-08-mcp-me-prerequisite, 08-09-parity-and-real-mcp-gate, 08-10-unit-tests-and-security-review] + +# Tech tracking +tech-stack: + 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" + +key-files: + created: + - ../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 + modified: + - 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 + +key-decisions: + - "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." + +patterns-established: + - "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." + +requirements-completed: [] + +# Metrics +duration: ~50min +completed: 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__"`) +- **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*