From 27845490e8e903812761679199ff823639b26b63 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 23 Sep 2026 22:05:44 +0200 Subject: [PATCH] docs(08-07): complete oauth-client-command plan --- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 20 ++- .../08-07-SUMMARY.md | 169 ++++++++++++++++++ 3 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 .planning/phases/08-oauth2-1-authorization-server/08-07-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ff75cb2..67418af 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -349,7 +349,7 @@ Plans: **Wave 7** *(parallel; blocked on 08-06)* -- [ ] 08-07-PLAN.md — Provision confidential clients through the exact operator command +- [x] 08-07-PLAN.md — Provision confidential clients through the exact operator command - [ ] 08-08-PLAN.md — Serve the MCP personal-token bootstrap on the existing token surface **Wave 8** *(blocked on 08-07 and 08-08)* @@ -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 | 6/10 | In Progress| | +| 8. OAuth2.1 authorization server | 7/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 2874064..28c57c4 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-06-PLAN.md -last_updated: "2026-09-23T19:42:20.033Z" +stopped_at: Completed 08-07-PLAN.md +last_updated: "2026-09-23T20:05:28.781Z" last_activity: 2026-09-23 progress: total_phases: 15 completed_phases: 7 total_plans: 55 - completed_plans: 51 + completed_plans: 52 percent: 47 --- @@ -26,11 +26,11 @@ See: .planning/PROJECT.md (updated 2026-09-16) ## Current Position Phase: 08 (oauth2-1-authorization-server) — EXECUTING -Plan: 7 of 10 +Plan: 8 of 10 Status: Ready to execute Last activity: 2026-09-23 -Progress: [█████████░] 93% +Progress: [██████████] 95% ## Performance Metrics @@ -97,6 +97,7 @@ Progress: [█████████░] 93% | Phase 08 P04 | 15min | 2 tasks | 5 files | | Phase 08 P05 | 55min | 3 tasks | 12 files | | Phase 08 P06 | 50min | 3 tasks | 10 files | +| Phase 08 P07 | 35min | 2 tasks | 9 files | ## Accumulated Context @@ -238,6 +239,11 @@ Recent decisions affecting current work: - [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 +- [Phase ?]: [Phase 08 P07]: bonfire.Flag.Repeatable / Input.Flags(name) is the new shape for PHP-parity =* console options (Cobra StringSlice), with no change to existing scalar/bare Flag callers +- [Phase ?]: [Phase 08 P07]: fonoteka:oauth-client create reuses wristband.Tx.CreateWithCap(ctx, rec, math.MaxInt32) instead of adding an uncapped Create method -- operator-issued clients are never subject to DCR's 200-client cap +- [Phase ?]: [Phase 08 P07]: list/update operate directly on models.OAuthClient via *gorm.DB rather than extending wristband.ClientStore -- only client issuance needs the shared wristband hash/validation path (T-08-SECRET-TIMING) +- [Phase ?]: [Phase 08 P07]: Fixed clientRecordToModel to persist ScopeCeiling, a mapping gap silent since 08-02 because DCR never sets a ceiling +- [Phase ?]: [Phase 08 P07]: AUTH-05/AUTH-07 remain Pending in REQUIREMENTS.md -- both requirements' full text still needs 08-08/08-09's unchanged fonoteka-mcp install/auth flow proof ### Pending Todos @@ -259,6 +265,6 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-09-23T19:42:20.015Z -Stopped at: Completed 08-06-PLAN.md +Last session: 2026-09-23T20:05:28.751Z +Stopped at: Completed 08-07-PLAN.md Resume file: None diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-07-SUMMARY.md b/.planning/phases/08-oauth2-1-authorization-server/08-07-SUMMARY.md new file mode 100644 index 0000000..189be7d --- /dev/null +++ b/.planning/phases/08-oauth2-1-authorization-server/08-07-SUMMARY.md @@ -0,0 +1,169 @@ +--- +phase: 08-oauth2-1-authorization-server +plan: 07 +subsystem: auth +tags: [oauth2, rfc7591, dcr, bonfire, cobra, cli, wristband, gorm] + +# Dependency graph +requires: + - phase: 08-oauth2-1-authorization-server + plan: 06 + provides: "wristband.Server.Revoke, D-17 expiry sweep, refresh rotation/lineage-kill, and the fonoteka classes/auth.OAuthStore GORM adapter this plan's command reuses for client persistence" +provides: + - "bonfire.Flag.Repeatable / Input.Flags(name): an ordered, multi-occurrence string flag (Cobra StringSlice) alongside the existing scalar/bare Flag contract, with no change to existing callers" + - "wristband.IssueClientCredentials / wristband.RejectRedirectURI: the exact random-id/secret/hash and redirect-URI validation RFC 7591 registration uses, exported so an app-owned command can share the identical path instead of re-deriving it" + - "fonoteka:oauth-client (OAuthClientCommand): create a confidential, ceiling-bounded OAuth client with a one-time secret; --client-id adds redirect URIs without rotating the secret; --list prints id/name/revocation/redirects/ceiling and never a secret or hash" + - "Fixed clientRecordToModel to persist ScopeCeiling, closing a gap silent since 08-02 (DCR never sets a ceiling, so nothing had exercised the missing mapping until this plan's command needed it)" +affects: [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: + - "Repeatable bonfire flags: Command.Flags declares Repeatable:true, wrap() registers a Cobra StringSlice instead of a scalar String flag, and Input.Flags(name) reads it back in insertion order; scalar/bare flags on the same command are unaffected. Any future PHP-parity `=*` console option should follow this same shape." + - "Shared client-issuing path: wristband.IssueClientCredentials/RejectRedirectURI are the one place client_id/client_secret generation, hashing and redirect-URI validation happen; both RFC 7591 registration and the operator command call into them rather than each re-deriving the rule (T-08-SECRET-TIMING)." + - "CreateWithCap reused with math.MaxInt32 for operator-issued clients: the atomic locked count+insert wristband.Tx.CreateWithCap already provides is reused for its insert path, with the cap effectively disabled, rather than adding a second uncapped Create method to the ClientStore interface." + +key-files: + created: + - ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go + - ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go + - wristband/client_issue.go + modified: + - bonfire/command.go + - bonfire/root.go + - bonfire/output_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/plugin.go + - ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go + - ../fonoteka.go/plugins/golem15/user/console_test.go + +key-decisions: + - "List/update operate directly on models.OAuthClient via *gorm.DB rather than extending wristband.ClientStore with List/Update methods: only Create needs the shared wristband hash/validation path (T-08-SECRET-TIMING); list/redirect-URI-merge are ordinary app-layer queries, matching how other app controllers (token_api_controller.go) already query models directly instead of going through wristband." + - "Create reuses wristband.Tx.CreateWithCap(ctx, rec, math.MaxInt32) instead of adding an uncapped Create method to ClientStore: an operator-issued client is never subject to DCR's 200-client cap (D-03/D-21 caps DCR flooding only), and reusing CreateWithCap keeps exactly one atomic insert path instead of two." + - "wristband/client_issue.go is a new exported file (not in the plan's literal files_modified list) because D-19's threat mitigation explicitly requires a shared hash/validation path between DCR and the operator command; wristband.IssueClientCredentials/RejectRedirectURI wrap the existing unexported randomBase64URL/sha256Hex/rejectRedirectURI so there is still exactly one implementation of each rule." + - "Fixed clientRecordToModel (classes/auth/oauth_store.go) to map ClientRecord.ScopeCeiling onto the persisted model: this field has existed on wristband.ClientRecord since 08-02 but nothing ever set it on a ClientRecord before this plan, so the missing mapping was silent until the operator command's ceiling needed to survive CreateWithCap." + - "AUTH-05/AUTH-07 remain Pending in REQUIREMENTS.md, continuing 08-02..08-06's decision: both requirements' full text also depends on an unchanged fonoteka-mcp install/auth flow proof that only 08-08/08-09 can establish; this plan ships the D-19 operator command only." + +patterns-established: + - "bonfire.Flag.Repeatable / Input.Flags(name) is the established shape for any future PHP `=*` console option; use it instead of inventing a second flag-collection mechanism." + +requirements-completed: [] + +# Metrics +duration: ~35min (approx.) +completed: 2026-09-23 +--- + +# Phase 08 Plan 07: OAuth Client Operator Command Summary + +**fonoteka:oauth-client bonfire command (create/--client-id/--list) sharing wristband's exact client-issuing hash/validation path, on top of a new repeatable-flag contract in bonfire (Flag.Repeatable / Input.Flags).** + +## Performance + +- **Duration:** ~35 min (approx.) +- **Started:** ~2026-09-23T19:25:00Z (approx.) +- **Completed:** ~2026-09-23T20:00:08Z (approx.) +- **Tasks:** 2 completed (4 commits: RED/GREEN pair in summercms.go for bonfire's repeatable-flag seam, RED test + one GREEN commit in fonoteka.go for the command) +- **Files modified:** 9 (3 created + 3 modified in summercms.go's bonfire/wristband packages; 3 modified in fonoteka.go, one of them a pre-existing sibling test file fixed for the new Input method) + +## Accomplishments + +- `bonfire.Flag` gained `Repeatable` and `Input` gained `Flags(name) []string`: a Repeatable flag registers as a Cobra `StringSlice` instead of a scalar `String` flag, so `--redirect-uri=a --redirect-uri=b` is readable back in insertion order without disturbing any existing scalar/bare flag on the same command (`TestFlagAndArgumentParsing` and every other pre-existing bonfire test stayed green unchanged) +- `wristband.IssueClientCredentials`/`RejectRedirectURI` export the exact random-id/secret/sha256-hash and redirect-URI validation RFC 7591 registration already used internally, so the operator command shares one implementation of "how a client secret is generated and hashed" with DCR (T-08-SECRET-TIMING) instead of re-deriving it +- `fonoteka:oauth-client` (`OAuthClientCommand`) ports `IssueOAuthClient.php` byte-for-byte in shape: create prints `client_id=`/`client_secret=` once plus the non-recoverable warning, `--client-id ` merges new `--redirect-uri` values into an existing client without touching its secret hash, and `--list` prints `client_id=`/`client_name=`/`revoked=`/`redirect_uri=`/`scope_ceiling=` for every client and never a secret or hash +- Fixed a silent gap in `clientRecordToModel` (fonoteka's GORM adapter): `wristband.ClientRecord.ScopeCeiling` has existed since 08-02 but was never mapped onto the persisted `models.OAuthClient` row, because DCR-created clients never set a ceiling; this plan's command is the first caller that needs the ceiling to actually survive `CreateWithCap`, and the gap would have silently dropped every `--scope` value without the fix +- Both Phase 8 RED sentinels (`PHASE8_RED:bonfire-flags` in `bonfire`, `PHASE8_RED:oauth-command` in the fonoteka `console` package) were verified fail-closed via `scripts/check-phase8-red.sh` against the genuine pre-implementation state (unwired `Repeatable`; a "not implemented" command stub) before their GREEN commits; `go vet`/`go test`/`go test -race` are green across `summercms.go` and every touched `fonoteka.go` workspace module (root `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: repeatable-flag and command RED anchors** + - `7096a90` (test, summercms.go): `TestPhase8RedBonfireFlags` fails against unwired `Repeatable` (`PHASE8_RED:bonfire-flags`); adds the compiling seam (`Flag.Repeatable`, `Input.Flags`, `cobraInput.Flags`) + - `4efce33` (test, fonoteka.go): `TestPhase8RedOAuthClientCommand` fails against the "not implemented" command stub (`PHASE8_RED:oauth-command`); adds `console/oauth_client.go`'s exact flag/argument contract and fixes `user/console_test.go`'s `emailArg` to satisfy the extended `bonfire.Input` interface +2. **Task 2: implement repeatable flags and the exact OAuth client command** + - `398353b` (feat, summercms.go): wires `Repeatable` into Cobra `StringSlice` registration in `wrap()`; exports `wristband.IssueClientCredentials`/`RejectRedirectURI`; adds `TestRepeatableFlagUnsetReturnsEmpty`/`TestRepeatableFlagCoexistsWithBareAndScalar` + - `9e82cac` (feat, fonoteka.go): the real `OAuthClientCommand` create/update/list implementation, `Plugin.Commands()` registration, the `clientRecordToModel` `ScopeCeiling` fix, and the full `OAuthClientCommandTest` parity test matrix + +**Plan metadata:** committed as part of this summary/state-update commit. + +_Note: both tasks carry `tdd="true"`; RED/GREEN pairs land as separate commits, split per repo._ + +## Files Created/Modified + +- `bonfire/command.go` — `Flag.Repeatable`, `Input.Flags(name) []string`, `cobraInput.Flags` +- `bonfire/root.go` — `wrap()` registers a Repeatable flag as Cobra `StringSliceP`/`StringSlice` +- `bonfire/output_test.go` — `TestPhase8RedBonfireFlags`, `TestRepeatableFlagUnsetReturnsEmpty`, `TestRepeatableFlagCoexistsWithBareAndScalar` +- `wristband/client_issue.go` — `IssueClientCredentials`, `RejectRedirectURI` (exported wrappers over existing unexported primitives) +- `../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go` — `OAuthClientCommand`: create/`--client-id`/`--list`, plus `oauthClientCollectRedirectURIs`/`oauthClientCollectScopeCeiling`/`oauthClientMergeUniqueURIs` helpers +- `../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go` — `TestPhase8RedOAuthClientCommand` plus the `OAuthClientCommandTest` parity matrix (list-never-leaks-secret, add-redirect-uri-keeps-secret, unknown client_id, scope-ceiling persistence/listing, invalid-scope rejects without creating a client, 1..5-redirect-uri bound) and the package's own real-Postgres `TestMain` harness +- `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` — `Commands()` registers `console.OAuthClientCommand(p.app)`; `pact.HasCommands` assertion +- `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go` — `clientRecordToModel` now maps `ScopeCeiling` +- `../fonoteka.go/plugins/golem15/user/console_test.go` — `emailArg.Flags(string) []string` to satisfy the extended `bonfire.Input` interface + +## Decisions Made + +See frontmatter `key-decisions`. The most load-bearing: list/update deliberately stay outside the `wristband.ClientStore` abstraction (ordinary `*gorm.DB` queries against `models.OAuthClient`), while create deliberately goes through `wristband.Tx.CreateWithCap` with `math.MaxInt32` as the cap — only client *issuance* needs the shared hash/validation path the threat model calls for; list/redirect-URI-merge are plain app-layer reads/writes with no security-sensitive generation logic to share. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] `user/console_test.go`'s `emailArg` did not satisfy the extended `bonfire.Input` interface** +- **Found during:** Task 1, immediately after adding `Input.Flags` to `bonfire/command.go` +- **Issue:** `bonfire.Input` gaining a new method broke compilation of every existing structural implementer; `user/console_test.go`'s `emailArg` (used by `TestRequirePasswordChange`) is the only other one in either repo +- **Fix:** Added `func (e emailArg) Flags(string) []string { return nil }` +- **Files modified:** `../fonoteka.go/plugins/golem15/user/console_test.go` +- **Verification:** `go build ./...` and `go test ./...` green in the `plugins/golem15/user` module +- **Committed in:** `4efce33` (Task 1 RED commit, fonoteka.go) + +**2. [Rule 2 - Missing Critical] `clientRecordToModel` never persisted `ScopeCeiling`** +- **Found during:** Task 2, while wiring the create path's scope-ceiling persistence +- **Issue:** `wristband.ClientRecord.ScopeCeiling` has existed since 08-02, and `clientModelToRecord` already read it back, but the reverse mapping in `clientRecordToModel` was missing — every ceiling passed to `CreateWithCap` would have been silently dropped, defeating the ceiling this plan's command exists to set (T-08-SCOPE-CEILING) +- **Fix:** `clientRecordToModel` now maps `ScopeCeiling` onto the persisted `models.OAuthClient` row +- **Files modified:** `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go` +- **Verification:** `TestOAuthClientCommandScopeCeilingPersistsAndListsNeverTheSecret` +- **Committed in:** `9e82cac` (Task 2 GREEN commit, fonoteka.go) + +**3. [Rule 2 - Missing Critical] `wristband/client_issue.go` added beyond the plan's literal `files_modified` list** +- **Found during:** Task 2, implementing the create path +- **Issue:** The plan's threat model (T-08-SECRET-TIMING) requires the command to share DCR's exact hash/validation path, but no exported wristband function existed for random client-id/secret generation, hashing, or redirect-URI validation +- **Fix:** Added `wristband/client_issue.go` exporting `IssueClientCredentials`/`RejectRedirectURI` as thin wrappers over the existing unexported `randomBase64URL`/`sha256Hex`/`rejectRedirectURI`, adding no new logic +- **Files modified:** `wristband/client_issue.go` (new) +- **Verification:** `go test ./wristband/... -race`; `TestOAuthClientCommandListPrintsClientIDAndURIsNeverTheSecret` et al. exercise the shared path end to end +- **Committed in:** `398353b` (Task 2 GREEN commit, summercms.go) + +--- + +**Total deviations:** 3 auto-fixed (1 blocking compile fix, 2 missing-critical-functionality additions required by the plan's own threat model and objective) +**Impact on plan:** No scope change; all three were necessary for the plan's stated D-19/T-08-SCOPE-CEILING/T-08-SECRET-TIMING behavior to actually work, not separate feature additions. + +## Issues Encountered + +None beyond the auto-fixed items above. Full `go vet`/`go test ./...` (and `go test -race` on the touched packages) are green in `summercms.go` and in every `fonoteka.go` workspace module: the root `fonoteka`/`parity` module, `plugins/golem15/fonoteka` and its `classes`, `classes/auth`, `console`, `controllers/api`, `middleware`, `models`, `updates` subpackages, and `plugins/golem15/user` and its `classes`/`updates` subpackages. + +## User Setup Required + +None — no external service configuration required. + +## Next Phase Readiness + +- `fonoteka:oauth-client` is available for operators to issue confidential clients for chat-app connectors ahead of 08-09's real-MCP gate, and for the D-16 lifecycle corpus's confidential-client-with-ceiling fixture. +- `bonfire.Flag.Repeatable`/`Input.Flags` is the established shape for any future PHP `=*` console option; no further bonfire interface changes are anticipated for the remaining Phase 8 plans' known scope. +- AUTH-05/AUTH-07 remain Pending in REQUIREMENTS.md: this plan ships the D-19 operator command, but both requirements' full text also needs 08-08/08-09's unchanged-fonoteka-mcp-install/auth-flow proof. +- `08-08` (mcp-me prerequisite) and `08-09` (parity-and-real-mcp-gate) can proceed without any further change to this plan's surface. +- No blockers. + +## Self-Check: PASSED + +- FOUND: bonfire/command.go, bonfire/root.go, bonfire/output_test.go +- FOUND: wristband/client_issue.go +- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go, oauth_client_test.go +- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, classes/auth/oauth_store.go +- FOUND: ../fonoteka.go/plugins/golem15/user/console_test.go +- FOUND commits (summercms.go): 7096a90, 398353b +- FOUND commits (fonoteka.go): 4efce33, 9e82cac + +--- +*Phase: 08-oauth2-1-authorization-server* +*Completed: 2026-09-23*