Files
summercms/.planning/phases/08-oauth2-1-authorization-server/08-06-PLAN.md

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
08-oauth2-1-authorization-server 06 execute 6
08-05
wristband/token.go
wristband/token_test.go
wristband/stores.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/controllers/api/connected_app_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go
true
AUTH-05
AUTH-06
AUTH-07
truths artifacts key_links
D-04: A valid refresh token rotates to a new access/refresh pair while revoking the prior access token.
D-17: Replaying a spent refresh token commits revocation of the whole lineage and unexpired evidence remains, leaving no usable branch.
D-08: A user sees only their live connected OAuth apps and can revoke one access token plus its refresh lineage atomically.
path provides
wristband/token.go Refresh grant rotation, replay detection, and committed lineage kill
path provides
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go Row-locked refresh traversal, revoke, and expiry sweep storage
path provides
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go Owner-scoped connected-app list and revoke handlers
from to via pattern
wristband/token.go oauth_store.go transaction outcome commits lineage kill before returning invalid_grant WithinTx
from to via pattern
connected_app_controller.go wristband.Server cascade revoke operation rather than direct refresh-row deletion Revoke
from to via pattern
connected_app_controller.go token_api_controller.go reuse of positive allow-list token serializer serializeToken
Deliver the durable OAuth lifecycle slice: safe refresh rotation/replay handling and user-visible connected-app listing/revocation.

Purpose: Ensure stolen or replayed refresh tokens cannot create surviving branches and the unchanged Settings UI controls the same grant lineage. Output: Refresh-grant state machine, row-locked store operations, connected-app controllers/routes, and concurrency-backed lifecycle tests.

Phase Goal

As a connected-app user, I want to refresh access safely and revoke applications from Settings, so that replayed or revoked credentials immediately lose access.

<execution_context> @/home/jin/.codex/get-shit-done/workflows/execute-plan.md @/home/jin/.codex/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md @.planning/phases/08-oauth2-1-authorization-server/08-RESEARCH.md @.planning/phases/08-oauth2-1-authorization-server/08-PATTERNS.md @.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md @.planning/phases/08-oauth2-1-authorization-server/08-05-SUMMARY.md From Plan 08-05: - Token handler already dispatches `authorization_code` and recognizes the configured refresh grant. - `wristband.Tx` provides row-lock-capable refresh/code stores and the transaction-bound access-token issuer. - OAuth access tokens are `models.ApiToken` rows distinguished by non-null `OAuthClientID`.

Existing serializer:

  • serializeToken(gdb, *models.ApiToken) map[string]any is the required positive allow-list for connected-app responses.
Task 1: Specify rotation, replay, list, and revoke as one lifecycle wristband/token_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go .planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md .planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md wristband/token.go wristband/stores.go ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/OAuthCodeManager.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/ConnectedAppController.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/OAuthRefreshRotationTest.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/OAuthRevocationTest.php - Normal refresh revokes the old access token, marks predecessor `rotated_to_id`, and returns a new same-scope/same-collection pair. - Sequential or concurrent spent-token replay returns `invalid_grant` only after the complete lineage and current access token are durably revoked. - Expiry sweep removes only expired pending/code/refresh rows and retains unexpired rotated/revoked refresh rows as replay evidence. - Connected-app list is newest-first, owner-only, live OAuth tokens only, with manual count separate and no secret/client-id fields. - Revoke of an owned OAuth token kills its refresh lineage; foreign, missing, and manual token IDs share the exact 404. D-04 and D-18: extend the RED suite with deterministic in-memory tests and synchronized real-Postgres contention tests for T-08-REFRESH-REPLAY, T-08-CROSS-USER, T-08-SCOPE-CEILING, T-08-REQUEST-LEAK, and T-08-SURFACE. D-16: cover the lifecycle sequence later recorded by parity. D-17: prove exact sweep retention. Include an assembled lifecycle that starts with the grant from 08-05, refreshes, replays the spent predecessor, verifies the new branch and access token are dead, creates another grant, lists it, revokes it, and proves refresh afterward fails. Use compiling stubs and exact `TestPhase8RedLifecycleFramework`/`PHASE8_RED:lifecycle-framework` and `TestPhase8RedLifecycleApp`/`PHASE8_RED:lifecycle-app` triples under `go test -json`; reject every unexpected failing action/package/test, compile/setup/panic/no-test case, and missing/duplicate sentinel. Assert exact UI response allow-lists and 404 bytes. scripts/check-phase8-red.sh go PHASE8_RED:lifecycle-framework git.golem15.com/golem15/summercms/wristband TestPhase8RedLifecycleFramework -- go test -json ./wristband -run '^TestPhase8RedLifecycleFramework$' -count=1 && scripts/check-phase8-red.sh go PHASE8_RED:lifecycle-app git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka TestPhase8RedLifecycleApp -- bash -lc "cd ../fonoteka.go && go test -json ./plugins/golem15/fonoteka -run '^TestPhase8RedLifecycleApp$' -count=1" - Tests include sequential replay, a barrier-synchronized double refresh, committed lineage kill, expiry retention, owner isolation, manual-token exclusion, list ordering, and post-revoke refresh failure. - The replay test explicitly reloads database rows after the `invalid_grant` response and asserts revoked lineage/access state, preventing rollback-hidden false positives. - Tests fail on missing refresh/connected-app implementation while all 08-02 happy-path tests remain green. The RED lifecycle suite detects branch survival, rollback of replay revocation, ownership leaks, serialization leaks, and route misplacement. Task 2: Implement refresh rotation, committed replay kill, and exact sweeps wristband/token.go, wristband/stores.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go wristband/token_test.go wristband/token.go wristband/stores.go ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go ../fonoteka.go/plugins/golem15/fonoteka/models/oauth_refresh_token.go ../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go - Presented refresh lookup uses SHA-256 hash and a row lock inside the single transaction boundary. - Replay revocation returns success from the transaction callback, then maps the recorded outcome to `invalid_grant` outside it. - Rotation keeps predecessor/successor relationships and unexpired evidence rows. Implement D-04, D-05, D-07, and D-17's refresh branch in wristband and the GORM adapter. Authenticate the client using the same Basic-over-form rule, hash the presented refresh secret, lock its row, reject expired/revoked/wrong-client grants, and rotate atomically by revoking the old access token, minting/persisting its successor, creating the next refresh secret/hash, and linking `rotated_to_id`. If a spent token is presented, traverse and revoke the whole lineage and associated access tokens, return nil from the transaction so the kill commits, then return `invalid_grant` from the handler. Keep the old scopes, collection IDs, offline flag, and client binding. Sweep only rows whose `expires_at` is past; do not delete unexpired replay evidence. go test ./wristband -run 'Test(Refresh|Replay|Sweep)' -count=1 && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/classes/auth -run 'TestOAuth(Refresh|Replay|Sweep)' -count=1) - Normal refresh and sequential/concurrent replay tests pass under real Postgres. - A spent-token replay leaves every lineage refresh row and its live access token revoked after the response transaction commits. - The focused app contention test produces a single usable branch; full race execution is reserved for 08-10's final checkpoint. - Sweep tests prove expired rows are removed and unexpired rotated/revoked rows remain. Refresh rotation is atomic, preserves replay evidence, and commits whole-lineage revocation before emitting the protocol error. Task 3: Expose connected-app list and atomic revoke to the unchanged Settings UI ../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/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go ../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go ../fonoteka.go/plugins/golem15/fonoteka/routes.go /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/components/fonoteka/ConnectedAppsManager.vue /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts - List returns `data` and numeric `manual_tokens_count`, filters to owner/live/OAuth tokens, and orders created_at descending. - Every row is `serializeToken` plus sanitized client name and contains no raw credential, hash, OAuth client id, redirect URI, or other-user data. - Revoke completes access-token and refresh-lineage revocation before returning `{"data":{"revoked":true}}`. Per D-08 and the UI-SPEC, add GET and DELETE connected-app controllers in the JWT group. Reuse `serializeToken`; append only the sanitized/truncated client name, initialize collection/scope arrays as arrays, count live manual tokens separately, and order OAuth tokens newest first. Scope every query by `bouncer.User`. For DELETE, require an owned OAuth token, invoke the wristband lineage-revoke operation in the same committed transaction, and collapse missing/foreign/manual IDs to exact `{"error":"Token not found"}` 404. Mount only under `/_fonoteka/api/v1/oauth`; do not expose these routes on the personal-token or raw groups. (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/controllers/api ./plugins/golem15/fonoteka -run '^TestOAuth(ConnectedApps|Revoke|Lifecycle|Surface)$' -count=1) - Empty, populated, manual-count, newest-first, foreign/manual 404, and successful atomic revoke tests pass with exact bytes. - The lifecycle test proves the revoked app disappears on the next list and its refresh token returns `invalid_grant`. - JSON assertions reject `token`, `token_hash`, `oauth_client_id`, `client_secret`, `refresh_token`, `request_id`, and `redirect_uris` anywhere in list output. - The Nuxt repository remains unchanged. The existing Settings → Integrations UI can list and revoke only the current user's connected applications, and revoke kills the entire grant lineage.

<threat_model>

Trust Boundaries

Boundary Description
Refresh credential → token endpoint A bearer-like long-lived credential requests a new grant branch.
JWT principal → connected-app API User-controlled ids request listing/revocation of durable credentials.
Transaction outcome → OAuth error Security revocation must commit even though the protocol response is an error.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-08-REFRESH-REPLAY Spoofing/Elevation refresh state machine mitigate Row lock, rotation chain, single-winner concurrency, commit lineage kill before invalid_grant.
T-08-CROSS-USER Elevation connected-app controllers mitigate Owner-scoped reads/deletes and indistinguishable missing/foreign/manual 404.
T-08-SCOPE-CEILING Elevation refresh rotation mitigate Copy only stored granted scopes/collection ids; refresh cannot add request-provided authority.
T-08-REQUEST-LEAK Information Disclosure list response/logs mitigate Positive allow-list serializer and explicit forbidden-field tests.
T-08-SURFACE Elevation route groups mitigate JWT-only management route inspection; token endpoint remains raw.
T-08-SC Tampering dependencies mitigate No install; standard library and existing GORM only.
</threat_model>
- `go test ./wristband -run 'Test(Refresh|Replay|Sweep)' -count=1` - `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/classes/auth ./plugins/golem15/fonoteka/controllers/api ./plugins/golem15/fonoteka -run '^TestOAuth(Refresh|Replay|ConnectedApps|Revoke|Lifecycle|Surface)$' -count=1` - Complete race execution is reserved exclusively for Plan 08-10's final blocking checkpoint.

<success_criteria>

  • Refresh rotation has exactly one usable successor and replay durably kills the entire lineage.
  • Connected-app output matches the UI contract and cannot disclose secrets or other users.
  • Revocation removes the app from the list and invalidates both access and refresh credentials before success is returned. </success_criteria>
Create `.planning/phases/08-oauth2-1-authorization-server/08-06-SUMMARY.md` when done.