diff --git a/.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md new file mode 100644 index 00000000..8e53ba76 --- /dev/null +++ b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md @@ -0,0 +1,146 @@ +# Phase 19.4: OIDC provider on wristband - Context + +**Gathered:** 2026-10-17 +**Status:** Ready for planning + + +## Phase Boundary + +An application on SummerCMS can be an OpenID Connect provider that the relying parties of the PHP `Golem15.OAuthServer` plugin ("Login with …") switch to without noticing: discovery, JWKS with asymmetric signing and key rotation, `id_token`, userinfo, resumable authorization with account switching, a consent store, public and pairwise subjects, the user's authorized-apps list and revoke, and a scheduled prune. The existing issuer, signing keys, client secrets and the six PHP tables carry over. + +The work splits in two (D-01): + +- **`summercms.go` / `wristband`**: the OIDC protocol on top of the existing MCP authorization engine. Discovery, JWKS, `id_token`, userinfo, nonce, subjects, consent and key interfaces. It stays a protocol leaf with no ORM and no application names. +- **New shared plugin `sm-oauthserver-plugin`** (`git.golem15.com/golem15/sm-oauthserver-plugin`, plugin ID `golem15.oauthserver`): the GORM stores for both MCP and OIDC, the six tables plus a keys table, the Winter import, the cabana Clients screen, branding settings, consent and error pages, `oauthserver:rotate-keys`, `oauthserver:prune` and the routes at the PHP paths. It requires `sm-user-plugin`. + +Out of scope: +- WavePath's own app plugin and its frontend. The WavePath port milestone installs this plugin. +- Social login (Phase 19.5), 2FA and device flow (19.6), and the generic MCP module (19.7). 19.7 can later enable `openid` on the MCP server (D-07). +- Moving fonoteka's own `wristband.Backend` adapter onto the plugin's stores (deferred). + +**Roadmap corrections** (make them in the first planning commit): +- "Repos" line: add `sm-oauthserver-plugin` (new) next to `summercms.go`. +- SC-1: the decision note also names the JOSE/JWT dependency, if research picks the hand-written path (D-16). + + + + +## Implementation Decisions + +### Where the pieces live +- **D-01:** The protocol lives in `wristband`. Storage, UI, import and commands go in a new shared `sm-oauthserver-plugin`, which mirrors PHP `golem15/oauthserver`. The point is one reviewed authorize/token engine for both MCP and OIDC, which the framework-level MCP module (19.7) can also use. Without the plugin wristband serves nothing on its own, and that is accepted. — **Reversibility:** costly — moving the protocol later touches both repos and fonoteka. +- **D-02:** The plugin's stores serve **both** flows: MCP clients (created by dynamic registration) and OIDC clients (registered by an admin). An app that wants MCP sign-in installs the plugin instead of writing its own adapter. Fonoteka keeps its own adapter in this phase. +- **D-03:** Consent and error pages: by default the plugin renders HTML on the server, like PHP's `consent.htm` and `error.htm`, with the branding settings (site name, logo, colours, font CSS URL and family, custom CSS) and a one-shot CSRF token on the consent POST. A config option redirects to the application's own consent page instead (a JSON API in the style of wristband's existing `/connect?request=` flow). +- **D-04:** Login handoff stays as in PHP. An anonymous user goes to the configured login URL (config key mirroring `OAUTHSERVER_LOGIN_URL`) with a single `?redirect=/oauth/authorize/resume/{request}`. The OIDC query string never travels through the login page. The signed-in user is read from bouncer's `token` JWT cookie, whose attributes match PHP since 19.3. The switch-account flow matches PHP's `/oauth/authorize/switch-account`. +- **D-05:** The plugin **requires `sm-user-plugin`**: claims (name, email, avatar) come from its users, the subject and consent cascade hangs off its user-delete event, and authorizations routes use its JWT guard. No claims-provider interface. Changes to `sm-user-plugin` are additive only (core-plugin rule), and both of its consuming apps must still build. + +### MCP and OIDC together +- **D-06:** **Two `wristband.Server` values, one engine.** The app builds one Server for MCP (`/oauth/mcp/*`, RFC 8414 metadata, DCR, `read`/`write`/`ai`/`offline_access`) and one for OIDC (`/.well-known/openid-configuration`, `/oauth/authorize`, `/oauth/token`, `/oauth/userinfo`, `/oauth/jwks`, `openid`/`profile`/`email`). Both share the Backend and the issuer, and each mounts its own routes and metadata. Fonoteka's existing single MCP server compiles and behaves unchanged. +- **D-07:** MCP clients **cannot** request `openid` or receive an `id_token` in 19.4. The engine supports it, so 19.7 may switch it on. +- **D-08:** Each client row has a **kind** (`mcp` | `oidc`). Each Server accepts only its own kind at authorize, token and userinfo, so dynamic registration can never produce a client that gets OIDC identity scopes. +- **D-09:** **Additive interfaces.** OIDC storage needs (consents, subjects, nonce on codes and requests, signing keys, the resume/switch-account state on pending requests) arrive as new, separate interfaces. They are required only when a Server enables OIDC and are checked at construction or `SetBackend`. Existing `wristband.Backend`/`wristband.Tx` and fonoteka's adapter stay untouched, and a framework minor version bump is enough. — **Reversibility:** costly — the published wristband API is consumed by fonoteka. + +### Signing keys +- **D-10:** Keys live in the **database**: a keys table with kid, algorithm, the public key in plain text, the private PEM encrypted with the app key (`lagoon.Encrypted` or equivalent), created/retired timestamps and an active marker. Every instance sees the same ring, which is what 19.3's multi-instance setup needs. As in PHP, a first key is generated lazily if none exists. Private key material is never logged and never leaves the signing path. +- **D-11:** The Winter import reads PHP's `key_dir` (`keys.json` + `.key`/`.pub`) and inserts every key with its **original kid** and retired state, so relying parties' cached JWKS and tokens already in flight keep verifying. — **Reversibility:** one-way — a cutover with new kids breaks every relying party's verification until it refetches. +- **D-12:** **RS256 by default** (imported PHP keys are RS256 2048). **ES256 is configurable** for new keys, and the JWKS may hold both kinds during a switch. Discovery advertises what the ring holds. +- **D-13:** Rotation: an `oauthserver:rotate-keys` command as in PHP. The retired key stays in the JWKS for a grace window (default 24h, config, mirroring `rotation_grace_hours`), and the prune job removes it afterwards. An opt-in scheduled rotation (interval in config, off by default) uses the framework scheduler. + +### Parity bar +- **D-14:** Parity means the **relying-party-visible contract**, recorded from PHP with `tide` and replayed against Go: the discovery document, JWKS shape, token and userinfo JSON, error redirects and error bodies, the `id_token` header (`alg`, `kid`) and claim set (including `nonce`, `at_hash`, `auth_time`/`azp` if PHP emits them), the authorize/resume/decide redirects, and the authorizations list/revoke JSON. The PHP test suite (`plugins/golem15/oauthserver/tests/`) is mapped case by case. Consent and error HTML must work the same and honour branding, but need not be byte-identical. +- **D-15:** Subjects: **public by default**. Imported PHP subs are random UUIDv4 values that never rotate, one per user, allocated lazily on first authorize and hard-deleted with the user (PHP D-98-01..04). A client can opt into **`subject_type=pairwise`** (sector identifier derived from its redirect host, or a configured sector identifier URI). Discovery advertises `public` and `pairwise`. This goes beyond PHP (D-98-02 chose public only) because the roadmap asks for it. +- **D-16:** The library question is **left to research**. The decision note (SC-1) compares extending the hand-written wristband (plus a JOSE/JWK library for RS256/ES256 and JWKS) against putting zitadel/oidc v3 behind it. Whichever is chosen must keep D-06 (one engine for MCP and OIDC) and D-09 (additive interfaces, fonoteka unchanged). An interop test with a real RP library (the equivalent of PHP's `JumbojettInteropTest`) is required either way, and the RP library may be test-only. +- **D-17:** Refresh tokens for OIDC are **opt-in per client**. An OIDC client allowed `offline_access` gets wristband's existing rotating refresh tokens with lineage revocation. Other clients get only the code grant, as in PHP (D-98-14). Imported PHP clients default to no `offline_access`. +- **D-18:** Throttles are ported with **the same buckets and keys** as named surf limiters on the 19.3 Redis-backed store: authorize 30/min per user, falling back to IP; token 20/min per `client_id`, falling back to IP; userinfo 60/min per SHA-256 of the bearer token, falling back to IP; authorizations 120/min per user, falling back to IP. Discovery and JWKS stay unthrottled and cacheable. + +### Import and secrets +- **D-19:** An import command in the plugin (pattern of `pgstripe:import-winter` / the D-02 runner) copies the six PHP tables: clients, auth_codes, access_tokens, auth_requests, consents and subjects. PHP hashes client secrets, codes and access tokens as SHA-256 hex, the same scheme as wristband's `sha256Hex`, so they are copied as-is and live access tokens keep working. The researcher confirms byte-for-byte compatibility. The issuer is set from config to the PHP `OAUTHSERVER_ISSUER` value. + +### Claude's Discretion +- Table names (keep `golem15_oauthserver_*` so the import is a straight copy unless there is a reason not to), the exact Options/interface names, the cabana screens' layout (stock fields, like PHP's "stock widgets only"), how the consent-page redirect option is spelled in config, and the scheduled-rotation default interval. + +### Folded Todos +- **Neutral default resource URL in `wristband.DefaultOptions`** (`.planning/todos/pending/wristband-neutral-resource-default.md`, g15office `iarcg5dk`): `DefaultOptions().Resource` and comments in `server.go`, `stores.go` and `client_issue.go` name a consuming application. 19.4 reworks wristband's Options anyway, so the default becomes empty or neutral, and the comments say "the host application". Check how authorize treats an empty `Resource` first, and set `Resource` explicitly in fonoteka (a behaviour change for the host, done in the same release). + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### The PHP original (port source and parity target) +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/oauthserver/routes.php`: complete route surface, middleware groups, throttle buckets, route-shadow note against the user plugin's `/oauth/{provider}` +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/oauthserver/controllers/oidc/`: Authorize (start/resume/decide/switchAccount), Token, UserInfo, Jwks, Discovery, Authorizations +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/oauthserver/classes/`: KeyRing (rotation, grace, on-disk layout), TokenIssuer (opaque access token, RS256 id_token, `at_hash`), ClaimBuilder, ScopeCatalog, RedirectUriMatcher, PkceVerifier, ConsentCsrf, UserCascade, DiscoveryDocument +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/oauthserver/models/` and `updates/`: the six tables and the initial-key seeder +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/oauthserver/config/`: `login_url`, `key_dir`, TTLs, grace hours +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/oauthserver/views/`: consent.htm, error.htm +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/oauthserver/tests/`: the test map source (feature, security, unit; JumbojettInteropTest) + +### Why this phase exists +- `/media/nvme/dev/jz/wavepath.org/.planning/notes/summercms-framework-asks-for-wavepath.md` §A1: WavePath's ask +- `.planning/notes/wavepath-framework-asks-division.md`: where the asks land, 19.x before the cutover + +### Framework code being extended +- `modules/wristband/README.md`: current API, options, security properties +- `.planning/notes/oauth2-wristband-direct-port.md`: why wristband was written by hand (input to D-16) +- `.planning/todos/pending/wristband-neutral-resource-default.md`: folded todo +- `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go`: the existing `wristband.Backend` adapter that must keep compiling unchanged (D-09) +- `.planning/research/STACK.md`: decided dependency set; a JOSE library or zitadel/oidc needs a decision note + +### Related phases +- `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/`: bouncer cookie transport, Redis limiter store, CORS profiles +- `.planning/notes/2026-10-17-generic-mcp-module.md`: 19.7 signs in through wristband; it may later turn on `openid` for MCP (D-07) + + + + +## Existing Code Insights + +### Reusable Assets +- `wristband` authorize/token engine: PKCE S256, exact redirect matching with a local 400 for unknown clients, pending requests, single-use codes, refresh rotation with lineage revocation, constant-time SHA-256 hex secrets (`crypto.go`). OIDC reuses all of it. +- `lagoon.Encrypted`: app-key-encrypted column for the private key PEM (D-10). Check its write-only semantics against the need to read the key in Go. +- `tide`: record PHP, replay against Go (D-14); the upstream sidecars come from Phase 14. +- `surf` named limiters on the `cooler` Redis store from 19.3 (D-18). +- `bouncer` JWT cookie (19.3) for the signed-in user on authorize (D-04). +- cabana YAML admin (list, form, `fileupload` for logos, colorpicker if available, settings pages, danger record actions from Phase 18) for Clients and branding. +- Winter import pattern: `pgstripe:import-winter`, `paymentgateway:import-winter`, the sm-faq-plugin import. + +### Established Patterns +- Shared plugins live in their own `sm-*-plugin` repo and are mounted as submodules. Commit and push the plugin first, then bump the pointer in each app. +- Security code gets its own review, and threat IDs go in the plans (T-19.4-xx). +- A new dependency needs a decision note before code lands. +- Framework READMEs and docs name no application, and the docs checker enforces identifiers. + +### Integration Points +- `sm-user-plugin`: user-delete event (cascade subjects, consents, tokens), JWT guard for `/oauth/authorizations`, user fields for claims. It already owns `/oauth/{provider}` (google|facebook|github) for social login, and the PHP route-shadow rule applies: literal `/oauth/*` OIDC paths must not collide, and 19.5 adds social login on the same prefix. +- Framework scheduler: `oauthserver:prune` and the opt-in rotation. +- fonoteka.go: must build and pass unchanged, apart from setting `Resource` explicitly (folded todo). + + + + +## Specific Ideas + +- PHP's route header comment explains three auth models side by side (public discovery/JWKS; server-to-server token/userinfo with no session; browser authorize with session and CSRF; SPA authorizations with JWT). Keep that separation in the Go route groups. +- `sub` is never derived from the user id, the email or any account attribute. +- Error handling at authorize follows RFC 6749 §4.1.2.1 as in PHP and wristband: unknown client or mismatched redirect is rendered locally, never redirected. + + + + +## Deferred Ideas + +- Move fonoteka's own `wristband.Backend` adapter onto `sm-oauthserver-plugin`'s stores (todo after 19.4). +- `openid` / `id_token` for MCP clients (19.7 decides). + +### Reviewed Todos (not folded) +- The keyword matcher also surfaced docs, lagoon, scaffold, FAQ/menu/sitemap and user-plugin todos. None are about OIDC or wristband, so they stay in the backlog. + + + +--- + +*Phase: 19.4-oidc-provider-on-wristband* +*Context gathered: 2026-10-17* diff --git a/.planning/phases/19.4-oidc-provider-on-wristband/19.4-DISCUSSION-LOG.md b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-DISCUSSION-LOG.md new file mode 100644 index 00000000..65c58bde --- /dev/null +++ b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-DISCUSSION-LOG.md @@ -0,0 +1,134 @@ +# Phase 19.4: OIDC provider on wristband - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-10-17 +**Phase:** 19.4-oidc-provider-on-wristband +**Areas discussed:** Where the pieces live, MCP and OIDC together, Signing keys, Parity bar + +--- + +## Where the pieces live + +| Option | Description | Selected | +|--------|-------------|----------| +| wristband + sm-oauthserver-plugin | Protocol in wristband; stores, tables, import, admin, branding, pages, commands in a new shared plugin | ✓ | +| Everything in the plugin | Port PHP 1:1 including the protocol; wristband stays MCP-only | | +| Protocol only in 19.4 | wristband OIDC with fakes; the rest waits for the WavePath milestone | | + +**User's choice:** wristband + sm-oauthserver-plugin. +**Notes:** The user first asked why the protocol should stay in wristband when it serves nothing without the plugin. Answer given: wristband is already the shared, reviewed engine (fonoteka's MCP flow runs on it through its own GORM adapter); OIDC reuses most of authorize/token; and 19.7, a framework module, signs in through wristband and cannot import a plugin. + +| Option | Description | Selected | +|--------|-------------|----------| +| Yes, one store for both | Plugin tables cover MCP (DCR) and OIDC (admin) clients | ✓ | +| OIDC only | MCP-only apps keep writing adapters | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Plugin renders HTML | Server-rendered branded consent/error pages like PHP | | +| SPA renders, plugin serves JSON | Like wristband's `/connect` flow | | +| Both, configurable | Default HTML, option to redirect to the app's page | ✓ | + +| Option | Description | Selected | +|--------|-------------|----------| +| Keep PHP handoff | Login URL + `?redirect=/oauth/authorize/resume/{id}`, user from `token` cookie | ✓ | +| Plugin's own login form | Server-rendered login inside the plugin | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Require sm-user-plugin | Direct access to users, delete event, JWT guard | ✓ | +| Claims interface | Plugin-defined provider interface | | + +--- + +## MCP and OIDC together + +| Option | Description | Selected | +|--------|-------------|----------| +| Two Server values, one engine | Two configured Servers sharing Backend and issuer | ✓ | +| One Server, two profiles | Single Server with an OIDC section | | + +| Option | Description | Selected | +|--------|-------------|----------| +| No, a client has a kind | kind=mcp\|oidc, endpoints reject the other kind | ✓ | +| Shared clients | Any client may use either flow | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Not in 19.4 | MCP unchanged; 19.7 may enable openid | ✓ | +| Yes, opt-in per Server | Options switch for id_tokens on MCP | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Additive, separate OIDC interfaces | Required only when OIDC is enabled; fonoteka unchanged | ✓ | +| Extend Tx, update fonoteka | Breaking change for every adapter | | + +--- + +## Signing keys + +| Option | Description | Selected | +|--------|-------------|----------| +| Database, private key encrypted | Keys table, app-key encryption, import keeps kids | ✓ | +| Files like PHP | Same layout, needs a shared volume for several instances | | +| DB default, files optional | Both KeyStore implementations | | + +| Option | Description | Selected | +|--------|-------------|----------| +| RS256 default, ES256 configurable | Imported keys stay RS256 | ✓ | +| RS256 only | Exactly PHP | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Command plus optional schedule | rotate-keys, 24h grace, opt-in scheduled rotation | ✓ | +| Manual command only | Exactly PHP | | + +**Notes:** PHP hashes client secrets, codes and access tokens with SHA-256 hex, the same as wristband, so import is a straight copy (researcher to confirm). + +--- + +## Parity bar + +| Option | Description | Selected | +|--------|-------------|----------| +| RP-visible contract via tide | Discovery, JWKS, token/userinfo JSON, errors, id_token claims, redirects; PHP test map | ✓ | +| Byte parity everywhere | Including consent/error HTML | | +| Spec conformance only | Conformance + interop, PHP parity where RPs depend on it | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Public default, pairwise per client | Imported subs public; per-client pairwise opt-in | ✓ | +| Public only, like PHP | Drop pairwise, amend roadmap | | + +| Option | Description | Selected | +|--------|-------------|----------| +| None, like PHP | Code grant only | | +| Opt-in per client via offline_access | Existing rotating refresh tokens | ✓ | + +| Option | Description | Selected | +|--------|-------------|----------| +| Hand-written, stdlib + one JOSE lib | Extend wristband; RP library in tests only | | +| Let research decide | Researcher compares with zitadel/oidc v3; the note records it | ✓ | +| zitadel/oidc v3 behind wristband | Certified library, adapt stores | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Same buckets and keys | PHP limits as named surf limiters on the Redis store | ✓ | +| You decide | Planner picks limits | | + +--- + +## Claude's Discretion + +- Table names, Options/interface names, cabana screen layout, the config spelling of the consent-redirect option, the default scheduled-rotation interval. + +## Deferred Ideas + +- Move fonoteka's own wristband adapter onto the plugin's stores. +- openid/id_token for MCP clients (19.7). + +## Folded Todos + +- Neutral default resource URL in `wristband.DefaultOptions`.