Files
summercms/.planning/phases/08-oauth2-1-authorization-server/08-05-SUMMARY.md
2026-09-23 21:11:45 +02:00

178 lines
19 KiB
Markdown

---
phase: 08-oauth2-1-authorization-server
plan: 05
subsystem: auth
tags: [oauth2, rfc6749, consent, wristband, gorm, surf, jwt-group, playwright]
# Dependency graph
requires:
- phase: 08-oauth2-1-authorization-server
plan: 04
provides: "wristband.Server.Token (authorization_code grant), the post-consent AuthCodeRecord shape (CodeHash set, RequestID nil, UserID set), and the unmounted POST /oauth/mcp/token route"
provides:
- "wristband.Server.PendingRequest/IssueCode/DenyPending: app-agnostic consent operations (owner-bound lookup, code issuance, denial) that collapse missing/foreign/used/expired/already-issued pending rows to one ErrPendingNotFound"
- "fonoteka JWT-group consent API: GET oauth/request/{request_id}, POST oauth/consent, POST oauth/deny, wired on the existing jwt.auth/locale.from-principal/inv.must-change-password group"
- "POST /oauth/mcp/token mounted on the raw group with its named throttle, closing the 08-04 D-09 gap -- a full authorize->consent->token PKCE round trip now works end to end against the assembled app"
- "scripts/check-phase8-ui.mjs: a self-validating, read-only 32-scenario UI-contract harness for the unchanged Nuxt consent/connected-app surfaces, with a --final-gate seam reserved for 08-10"
affects: [08-06-lifecycle-and-sweeps, 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:
- "Protocol-owned mutation + app-owned policy split: wristband.IssueCode trusts the caller's already-computed granted-scope intersection and server-resolved collection ids (persist + redirect only); the app controller owns MINTABLE_SCOPES intersection and ActiveCollectionResolver, matching the PHP OAuthConsentController/OAuthCodeManager boundary exactly"
- "surf Where() binds to the immediately preceding route only, not the whole enclosing group -- constraints on a group with multiple path-parameterized routes must be interleaved route-by-route, not batched at the end"
- "RED/GREEN staged via a saved-then-reverted working tree (stub handlers + reverted routes.go/plugin.go for the RED commit, restored for GREEN) so both scripts/check-phase8-red.sh sentinels observe a genuine fail-closed failure even though the full implementation was written and verified before either commit"
- "check-phase8-ui.mjs: a locked per-category scenario-count manifest (EXPECTED_COUNTS) catches accidental UI-SPEC coverage drift at self-test time rather than relying on manual review"
key-files:
created:
- wristband/consent.go
- wristband/consent_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/oauth_connect_test.go
- scripts/check-phase8-ui.mjs
modified:
- wristband/stores.go
- wristband/server.go
- wristband/registration_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
key-decisions:
- "AuthCodeStore.MarkIssued gained scopes/collectionIDs/expiresAt parameters (was codeHash/userID only) because PHP's issueCode overwrites all of code_hash/request_id/user_id/scopes/collection_ids/expires_at in one UPDATE, not just the first two; the narrower 08-02 signature could not persist consent's granted-scope subset or the server-resolved collection pin"
- "ClientStore gained MarkConsented (idempotent via a WHERE consented_at IS NULL guard) to stamp OAuthClient.consented_at once, matching D-08 and DCR's SweepUnconsented dependency on that column"
- "wristband.Options gained CodeTTL (600s default) so IssueCode sets a fresh code expiry from consent time rather than reusing the pending row's original 08-03 expiry, matching PHP's independent CODE_TTL_SECONDS constant; plugin.go wires it from the already-declared-but-previously-unconsumed golem15.fonoteka.oauth.code_ttl_seconds config key"
- "Consent's canonical scope ordering (read -> write -> ai) is computed app-side against auth.MintableScopes's own declared order, not by replicating PHP's array_intersect (which preserves the first array's order); 08-UI-SPEC.md's explicit 'canonical read -> write -> ai order' language is treated as the authoritative contract over the PHP source's incidental ordering"
- "POST /oauth/mcp/token is mounted in this plan (not 08-04) per 08-04-SUMMARY.md's own explicit deferral: only once consent produces a real issued code does an end-to-end /token call through the real route have anything to exchange"
- "check-phase8-ui.mjs's --final-gate mode is scaffolded (verify:oauth-return-path, verify:oauth-i18n, and a Playwright-matrix seam) but deliberately fatal-errors unless PHASE8_UI_ALLOW_FINAL_GATE=1 is set, and is never invoked by this plan; 08-10 is its sole execution site per the plan's own success criteria"
- "AUTH-05/06/07 remain Pending in REQUIREMENTS.md: this plan ships consent plus the token mount, but refresh rotation (08-06) and connected-apps list/revoke (also deferred, not in this plan's file list despite the phase directory's 'consent-and-connected-apps' name) are still outstanding pieces of those requirements"
patterns-established:
- "oauthDeps(w, r, app) is the JWT-group consent controller's shared dependency resolver (gdb + bouncer.Principal + *wristband.Server via app.Lookup), the seam any later connected-apps controller in this package should reuse"
requirements-completed: []
# Metrics
duration: ~55min
completed: 2026-09-23
---
# Phase 08 Plan 05: Consent and Connected Apps (Consent Slice) Summary
**Owner-bound JWT consent (show/allow/deny) backed by new wristband.Server.PendingRequest/IssueCode/DenyPending operations, POST /oauth/mcp/token finally mounted to close 08-04's deferred gap, and a self-validating 32-scenario read-only Playwright-contract harness for the unchanged Nuxt consent/connected-app UI.**
## Performance
- **Duration:** ~55 min
- **Started:** ~2026-09-23T19:05:00Z (approx., continuing from 08-04)
- **Completed:** 2026-09-23T21:05:00Z (approx.)
- **Tasks:** 3 completed (5 commits: RED/GREEN pair in fonoteka.go for Task 1/2, one feat commit each in summercms.go for the wristband prerequisite and the UI harness)
- **Files modified:** 12 (5 created + 3 modified in summercms.go's wristband/scripts; 3 created + 3 modified in fonoteka.go)
## Accomplishments
- `wristband.Server.PendingRequest`/`IssueCode`/`DenyPending` port PHP `OAuthConsentController::pendingFor`/`OAuthCodeManager::issueCode` as app-agnostic protocol operations: every missing, foreign-owner, used, expired, or already-issued pending row collapses to the identical `ErrPendingNotFound` (T-08-CROSS-USER/T-08-REQUEST-LEAK); `IssueCode` trusts the caller's already-computed granted scopes/collection ids and returns the ordered `redirect_to` URL through the existing RFC 3986 encoder
- `AuthCodeStore.MarkIssued` was extended (scopes/collectionIDs/expiresAt) and `ClientStore.MarkConsented` was added because the narrower 08-02 store interface could not express PHP's full single-UPDATE `issueCode` semantics; both changes are covered by real-Postgres assertions in `oauth_store.go`'s existing test package
- `fonoteka`'s JWT-group `ConsentShow`/`ConsentStore`/`ConsentDeny` grant exactly `submitted ∩ pending-request ∩ MINTABLE_SCOPES`, pin collection ids exclusively through `ResolveActiveCollection`, and never accept a client-supplied collection id; an empty granted intersection is an exact 422 `{"error":"No grantable scopes"}`
- `POST /oauth/mcp/token` is now mounted on the raw group with `throttle:fonoteka-oauth-token`, closing the gap 08-04 deliberately left open -- a full `authorize -> consent(JWT) -> token` PKCE round trip now produces a real `inv_`-prefixed access token through the assembled app and real Postgres
- `scripts/check-phase8-ui.mjs` encodes the complete `08-UI-SPEC.md` consent/connected-app state/accessibility/responsive/i18n matrix as a 32-scenario, 7-category catalog; `--contract-self-test` validates catalog completeness, guarded-Nuxt-file hash stability, and `@playwright/test` resolvability in ~50ms without booting anything; `--final-gate` is scaffolded but refuses to run without an explicit opt-in env var, reserved for 08-10
- Both `scripts/check-phase8-red.sh` sentinel gates (`PHASE8_RED:consent-controller`, `PHASE8_RED:consent-app`) were verified fail-closed against genuine 501-stub/unmounted-route RED states before GREEN was restored; the full GREEN suite (`go vet`/`go test`/`go test -race`) is green in both `fonoteka.go/plugins/golem15/fonoteka` and `summercms.go`
## Task Commits
Each task was committed atomically (TDD RED then GREEN where applicable, split per repo):
1. **Task 1: consent RED anchor + wristband prerequisite** -- `a1fa9c6` (feat, summercms.go): `wristband.Server.PendingRequest/IssueCode/DenyPending`, the `MarkIssued`/`MarkConsented` interface extensions, `Options.CodeTTL`; `306b06e` (test, fonoteka.go): `TestPhase8RedConsentController`/`TestPhase8RedConsentApp` fail against 501 stub handlers and unmounted routes (`PHASE8_RED:consent-controller`/`PHASE8_RED:consent-app`), plus the compile-forced `oauth_store.go` adapter update. Both sentinels verified fail-closed via `scripts/check-phase8-red.sh`.
2. **Task 2: implement owner-bound JWT consent and mount raw token route** -- `a52e8fa` (feat, fonoteka.go): the real `ConsentShow`/`ConsentStore`/`ConsentDeny` handlers, `routes.go`'s consent-route and `/oauth/mcp/token` mounts, `plugin.go`'s `*wristband.Server` publish and `code_ttl_seconds` wiring, and the full GREEN test matrix (T-08-CROSS-USER, T-08-SCOPE-CEILING, empty-scope 422, deny redirect + single-use, missing-handle 404, JWT/raw route-surface isolation).
3. **Task 3: read-only UI-contract harness** -- `fac9648` (feat, summercms.go): `scripts/check-phase8-ui.mjs`.
**Plan metadata:** committed as part of this summary/state-update commit.
_Note: Tasks 1/2 carry `tdd="true"`; the RED commit's fonoteka.go changes required including `oauth_store.go` (a hard compile dependency on wristband's extended `Tx` interface) even though the plan's Task 1 file list names only the two test files -- see Deviations._
## Files Created/Modified
- `wristband/consent.go` -- `Server.PendingRequest`, `Server.IssueCode`, `Server.DenyPending`, `lookupOwnedPending`, `ErrPendingNotFound`, `ErrNoGrantableScopes`
- `wristband/consent_test.go` -- in-memory-backend unit matrix: client/scope resolution, foreign-owner/missing-handle not-found, ordered redirect construction, empty-grant rejection, deny consumption + single-use
- `wristband/stores.go` -- `AuthCodeStore.MarkIssued` signature extension, `ClientStore.MarkConsented`
- `wristband/server.go` -- `Options.CodeTTL` (600s default)
- `wristband/registration_test.go` -- `memoryTx.MarkIssued`/`MarkConsented` updated to satisfy the extended interface
- `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go` -- `oauthTx.MarkIssued`/`MarkConsented` GORM implementations
- `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go` -- `ConsentShow`/`ConsentStore`/`ConsentDeny`, `oauthDeps`, `canonicalMintableIntersection`, `intersectStrings`, `readConsentScopes`, `untrustedName`
- `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller_test.go` -- package-local real-Postgres harness (`TestMain`, `apiDB`) plus `TestPhase8RedConsentController`
- `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` -- publishes `*wristband.Server`; wires `code_ttl_seconds`
- `../fonoteka.go/plugins/golem15/fonoteka/routes.go` -- mounts the three JWT-group consent routes and `POST /oauth/mcp/token`
- `../fonoteka.go/plugins/golem15/fonoteka/oauth_connect_test.go` -- `TestPhase8RedConsentApp` plus `TestOAuthConsentCrossUserIsNotFound`, `TestOAuthConsentScopeCeilingViaShow`, `TestOAuthConsentEmptySubmittedScopeIntersectionIs422`, `TestOAuthDenyRedirectsAccessDeniedAndConsumesRequest`, `TestOAuthConsentMissingHandleIsNotFoundWithNoRequest`, `TestOAuthConsentSurfaceIsolation`
- `scripts/check-phase8-ui.mjs` -- the read-only UI-contract harness (Task 3)
## Decisions Made
See frontmatter `key-decisions`. The two most load-bearing: (1) `MarkIssued`'s signature had to grow beyond 08-02's original codeHash/userID-only shape because PHP's `issueCode` is a single UPDATE touching six columns, not two -- consent could not otherwise persist the user's actual granted-scope subset or the server-resolved collection pin; (2) canonical scope ordering in the consent show/allow response follows `auth.MintableScopes`'s declared order (read, write, ai) per `08-UI-SPEC.md`'s explicit language, not PHP's `array_intersect`, which happens to preserve first-array order and would leak submission-order-dependent behavior into a contract the UI-SPEC documents as fixed.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Missing functionality] Extended `wristband.AuthCodeStore.MarkIssued` and added `ClientStore.MarkConsented`**
- **Found during:** Task 1, while designing `wristband.Server.IssueCode`
- **Issue:** 08-02's `MarkIssued(ctx, id, codeHash, userID)` could not express PHP `OAuthCodeManager::issueCode`'s full write (it also overwrites `scopes`, `collection_ids`, and `expires_at`), and no store method existed to stamp `OAuthClient.consented_at` once (D-08's explicit "consented_at stamping" requirement, and a precondition for DCR's `SweepUnconsented` to correctly skip consented clients)
- **Fix:** Extended `MarkIssued`'s signature to accept `scopes []string, collectionIDs []uint, expiresAt time.Time`; added `MarkConsented(ctx, clientID) error` with an idempotent `WHERE consented_at IS NULL` guard. Updated the GORM adapter (`oauth_store.go`) and the framework's in-memory test double (`registration_test.go`'s `memoryTx`) to match
- **Files modified:** `wristband/stores.go`, `wristband/registration_test.go`, `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go`
- **Verification:** `wristband/consent_test.go`'s `TestIssueCodeGrantsOnlySubmittedScopesAndReturnsOrderedRedirect`; `classes/auth` package's real-Postgres suite stays green (`go test ./...`, `go test -race ./...`)
- **Committed in:** `a1fa9c6` (summercms.go), `306b06e` (fonoteka.go, compile-forced alongside the RED test commit)
**2. [Rule 2 - Missing functionality] Added `wristband.Options.CodeTTL`**
- **Found during:** Task 1, implementing `IssueCode`'s fresh expiry
- **Issue:** PHP's `OAuthCodeManager::CODE_TTL_SECONDS` is a distinct constant from `PENDING_TTL_SECONDS`; wristband's `Options` had no field for it, and 08-02's `config.yaml` already declared `code_ttl_seconds` with no Go consumer (flagged as an open seam in 08-02-SUMMARY.md)
- **Fix:** Added `Options.CodeTTL` (600s PHP-parity default) and wired `golem15.fonoteka.oauth.code_ttl_seconds` into it in `plugin.go`
- **Files modified:** `wristband/server.go`, `../fonoteka.go/plugins/golem15/fonoteka/plugin.go`
- **Verification:** `wristband/consent_test.go` exercises the default; no config-key behavior change to any currently-passing test
- **Committed in:** `a1fa9c6` (summercms.go), `a52e8fa` (fonoteka.go)
**3. [Rule 1 - Bug] Fixed `surf.Where("request_id", ...)` binding to the wrong route**
- **Found during:** Task 2, first GREEN test run (`TestPhase8RedConsentApp` failed with `surf: Where("request_id") is not a path parameter of /_fonoteka/api/v1/oauth/deny`)
- **Issue:** `g.Where()` constrains only the immediately preceding registered route (matching PHP's `->where()`), not every route registered since the last `Where()` call; the three consent routes were registered before a single trailing `Where("request_id", ...)`, so the constraint attached to `/oauth/deny` (the last-added route, which has no `{request_id}` parameter) instead of `/oauth/request/{request_id}`
- **Fix:** Moved `g.Where("request_id", ...)` to sit immediately after `g.Get("/oauth/request/{request_id}", ...)`, before the two POST routes
- **Files modified:** `../fonoteka.go/plugins/golem15/fonoteka/routes.go`
- **Verification:** Full GREEN suite (`go test ./...`, `go test -race ./...`) passes in `fonoteka.go/plugins/golem15/fonoteka`
- **Committed in:** `a52e8fa`
---
**Total deviations:** 3 auto-fixed (2 missing functionality, 1 bug)
**Impact on plan:** No scope change beyond the plan's own stated D-08 behavior (consent's scope/collection persistence and consented_at stamping); the `Where()` bug was self-caught by the plan's own GREEN test suite before commit.
## Issues Encountered
None beyond the auto-fixed items above. Full `go vet`/`go test ./...`/`go test -race ./...` are green in `fonoteka.go/plugins/golem15/fonoteka` (and its sibling `classes`, `classes/auth`, `controllers/api`, `middleware`, `models`, `updates` packages) and in `summercms.go`. The root `fonoteka.go`/`parity` module and `plugins/golem15/user` module were also checked (`go vet`/`go test`) and remain green; no new failures were introduced outside this plan's files.
## User Setup Required
None -- no external service configuration required.
## Next Phase Readiness
- `POST /oauth/mcp/token` is now live end to end through the assembled app; 08-06 (lifecycle and sweeps) can build refresh rotation directly against the same mounted route rather than needing to mount it itself.
- `wristband.Server.IssueCode`/`DenyPending`'s pattern (protocol persists + redirects, app computes policy) is the established seam for any future consent-adjacent operation.
- Connected-apps list/revoke (`GET/DELETE .../oauth/connected-apps`) and `fonoteka-mcp`'s `/api/v1/fonoteka/me` prerequisite remain unbuilt -- neither was in this plan's file list despite the phase directory's "consent-and-connected-apps" name; 08-06/08-08 (per ROADMAP.md) own them.
- `scripts/check-phase8-ui.mjs`'s scenario catalog and guarded-file list are ready for 08-10 to wire into a real Playwright spec via `--final-gate`; no further catalog changes should be needed unless a new UI-SPEC state is added.
- AUTH-05/06/07 remain Pending in REQUIREMENTS.md, continuing 08-01 through 08-04's decision: refresh rotation and connected-apps list/revoke are still outstanding pieces of those requirements.
- No blockers.
## Self-Check: PASSED
- FOUND: wristband/consent.go, wristband/consent_test.go, wristband/stores.go, wristband/server.go, wristband/registration_test.go
- FOUND: scripts/check-phase8-ui.mjs
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go, oauth_consent_controller_test.go
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, routes.go, oauth_connect_test.go
- FOUND commits (summercms.go): a1fa9c6, fac9648
- FOUND commits (fonoteka.go): 306b06e, a52e8fa
---
*Phase: 08-oauth2-1-authorization-server*
*Completed: 2026-09-23*