docs(19.4): capture phase context
This commit is contained in:
146
.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md
Normal file
146
.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Phase 19.4: OIDC provider on wristband - Context
|
||||
|
||||
**Gathered:** 2026-10-17
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## 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).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## 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 `<issuer>/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` + `<kid>.key`/`<kid>.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).
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## 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)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## 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).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## 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.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## 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.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 19.4-oidc-provider-on-wristband*
|
||||
*Context gathered: 2026-10-17*
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user