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.
@.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.
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.