From df0d8c1d55a88d3dd07fabf1c04cf9e5611621ca Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 19 Oct 2026 17:06:42 +0200 Subject: [PATCH] docs(phase-19.4): add research and validation strategy --- .../19.4-RESEARCH.md | 710 ++++++++++++++++++ .../19.4-VALIDATION.md | 84 +++ 2 files changed, 794 insertions(+) create mode 100644 .planning/phases/19.4-oidc-provider-on-wristband/19.4-RESEARCH.md create mode 100644 .planning/phases/19.4-oidc-provider-on-wristband/19.4-VALIDATION.md diff --git a/.planning/phases/19.4-oidc-provider-on-wristband/19.4-RESEARCH.md b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-RESEARCH.md new file mode 100644 index 00000000..d053f273 --- /dev/null +++ b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-RESEARCH.md @@ -0,0 +1,710 @@ +# Phase 19.4: OIDC provider on wristband - Research + +**Researched:** 2026-10-19 +**Domain:** OpenID Connect provider (authorization-code + PKCE, JWS/JWKS, consent, subjects) on a hand-written Go OAuth engine, plus a new shared WinterCMS-port plugin +**Confidence:** HIGH for the PHP contract and the in-repo seams (read this session); MEDIUM for wire-level Laravel header/encoding details that only a `tide` recording can settle; MEDIUM for the D-16 recommendation (reasoned from verified facts, user confirms via the decision note) + + +## User Constraints (from CONTEXT.md) + +### Locked 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). + +### Deferred Ideas (OUT OF SCOPE) +- 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. + +**Roadmap corrections** (make them in the first planning commit): "Repos" line adds `sm-oauthserver-plugin` (new) next to `summercms.go`; SC-1 decision note also names the JOSE/JWT dependency, if research picks the hand-written path (D-16). + + + +## Phase Requirements + +No REQ-IDs are mapped (ROADMAP says "Requirements: TBD"). The source items are the five success criteria and D-01..D-19. + +| ID | Description | Research Support | +|----|-------------|------------------| +| SC-1 | Decision note settles hand-written wristband vs zitadel/oidc v3, before code lands; names the JOSE/JWT dependency | §"D-16 decision: recommendation" (extend wristband; `golang-jwt/jwt/v5` v5.3.1, already in go.mod; go-oidc v3.21.0 + x/oauth2 v0.37.0 test-only) | +| SC-2 | Discovery, JWKS, userinfo, `id_token` with RS256 or ES256; rotation publishes old+new | §PHP contract map (discovery, JWKS, token, userinfo); §Pattern 3 (key ring); Code Examples (JWK encoding, id_token signing) | +| SC-3 | Resume, switch account, consent list + revoke, pairwise + public subjects, scheduled prune | §PHP contract map (authorize/resume/decide/switch, authorizations); §Pattern 5 (subjects); §prune; `pact.HasSchedule` via conga | +| SC-4 | Import existing issuer, keys, client secrets; security review | §D-19 hash compatibility (verified); §D-10 lagoon.Encrypted (verified readable); §Runtime State Inventory; §Security Domain | +| SC-5 | wristband README + docs updated; unit tests in last plan | §Validation Architecture (PHP test map → Go), docs pages `docs/services/oauth-server.md`, `docs/architecture/introduction.md` | +| D-01..D-19 | Locked decisions | Each referenced inline; conflicts/gaps listed in Open Questions | + + +## Project Constraints (from CLAUDE.md) + +Directives the planner must honour (same authority as locked decisions): + +- **Standard library first. A new dependency needs a research doc or decision note naming it** (`.planning/research/STACK.md` has the decided set). → The SC-1 note names every new module and version. +- **YAML via `goccy/go-yaml`, never `gopkg.in/yaml.v3`.** GORM on Postgres only, per-plugin gormigrate migrations (`lagoon`). +- **Framework code never names a consuming application** (READMEs, docs, examples use neutral names). The folded todo exists because `wristband/server.go:100` violates this today. +- **Module docs rule:** a change to a module's exported API, config keys, CLI commands or dependencies updates that module's README and affected `docs/` pages **in the same commit**; every identifier in README/docs must exist (docs checker `summer docs:build --check`). +- **Keep `go vet` and `go test ./...` green at every commit.** Docker tests skip under `-short`; a `TestMain` starting containers must `flag.Parse()` and read `testing.Short()`. +- **Shared plugins live in their own `sm-*-plugin` repo** (module path = repo path), mounted as submodules at `plugins//`; commit/push the plugin first, then the pointer bump in each app as a separate commit. +- **Lean planning:** few, large plans; present the plan count with one-line scope each and **wait for confirmation** before writing PLAN.md files. +- **Unit tests are the last plan of every phase.** +- **State through `msd_run query state.*` handlers**, not raw edits. **g15office sync** on phase advance. +- **Commits:** no co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by explicit path; never `git add -A`; don't push/tag/deploy without the user's go-ahead. Every pushed release tag gets a summercms.io changelog post the same day. +- **Security code gets its own review; threat IDs in plans (`T-19.4-xx`).** +- User memory: **for protocol and security code, recommend the maintained Go library plus a decision note.** + +## Summary + +The PHP `Golem15.OAuthServer` plugin (~5.5k LOC, ~150 PHPUnit cases) is an identity-only OIDC provider: authorization-code + mandatory S256 PKCE, confidential clients only, opaque `oas_at_` access tokens usable only at `/oauth/userinfo`, an RS256 `id_token` signed with `firebase/php-jwt` carrying `iss, sub, aud, exp, iat`, optional `nonce`, `at_hash` and the scope-filtered identity claims `name, picture, groups, communities, email, email_verified`. It emits **no** `auth_time` and **no** `azp`. Requests are parked in `auth_requests` (no `user_id`, by design) behind a login handoff; consent is a one-row-per-(user, client) scope set with a silent path and incremental re-consent; the consent POST carries a one-shot CSRF token hashed with the user id. Keys live on disk as `keys.json` + `.key/.pub` with a 24 h grace window. + +Wristband today is an MCP-shaped engine. Almost every relying-party-visible detail of PHP's OIDC flow differs from it: paths, scope rules (required `openid`, reject-not-truncate), validation order, error redirect parameters (no `iss`, `http_build_query` form encoding instead of RFC 3986), rendered HTML local errors, resume/login handoff, 60 s codes with an `oas_ac_` prefix, burn-then-check code redemption with replay revocation, `{error, error_description}` token errors and `Basic realm="oauth"` (lowercase). "One engine" (D-06) is therefore one package with shared primitives (client lookup, redirect matching, PKCE, hashing, constant-time compare, random IDs, redirect HTML writer, refresh lineage, sweeps) and **two flavours** of request validation and response shaping selected per `Server` value. That is about a third shared code, and it is the honest reading of D-06. + +**Primary recommendation:** Extend the hand-written `wristband` with an OIDC flavour behind new, separate interfaces (D-09), sign `id_token`s with **`github.com/golang-jwt/jwt/v5` v5.3.1** (already in `go.mod`, already used for ES256 in `flare` and HS256 in `bouncer`), encode the JWKS with the standard library (`rsa.PublicKey.N/E`, `ecdsa.PublicKey.Bytes()`), and prove interop with **`github.com/coreos/go-oidc/v3` v3.21.0 + `golang.org/x/oauth2` v0.37.0 as test-only** dependencies. Reject zitadel/oidc v3: it cannot reproduce the PHP discovery document or error shapes (D-14), it would be a second engine next to the MCP one (D-06), and its 25-method `Storage` contract cannot be added without reshaping the adapter (D-09). + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Discovery document, JWKS JSON | API / Backend (`wristband`) | CDN (Cache-Control `max-age=3600, public`) | Pure protocol output from Options + key ring; edges may cache | +| Authorize validation, parking, resume/decide/switch decisions | API / Backend (`wristband` OIDC flavour) | Plugin (HTML rendering, user resolution) | Security decisions stay in the reviewed engine; HTML and users are app concerns | +| Consent/error HTML, branding, CSRF form | API / Backend (plugin, server-rendered `html/template`) | Browser | D-03: server-rendered by default; SPA JSON mode optional | +| Signed-in user detection on authorize | Plugin (via sm-user-plugin JWT guard, `token` cookie) | — | D-04/D-05; wristband imports no user model | +| Token exchange, id_token signing, at_hash | API / Backend (`wristband`) | Database (plugin stores) | Protocol; signing key comes through a new key interface | +| Identity claims (name, picture, groups, communities, email) | Plugin (reads sm-user-plugin rows) | Database | wristband receives a claim map and filters it to the configured allowlist | +| Signing key storage, encryption at rest, rotation, prune | Database (plugin keys table, `lagoon.Encrypted`) | Plugin commands + conga scheduler | D-10, D-13 | +| Subjects (public, pairwise), consents, codes, requests, tokens | Database (plugin, `golem15_oauthserver_*`) | — | D-02, D-15, D-19 | +| Rate limiting | API (surf named buckets on cooler Redis store) | — | D-18 | +| Authorized-apps list/revoke | API (plugin JSON, `jwt.auth`) | Browser (SPA) | PHP parity | +| Winter import | Plugin console command | Database | D-11, D-19 | + +## D-16 decision: recommendation (input for the SC-1 decision note) + +### What the options must satisfy (from the locked decisions) + +| Constraint | Source | +|------------|--------| +| One engine for MCP and OIDC, two `Server` values sharing a Backend and issuer | D-06 | +| Additive interfaces; `wristband.Backend`/`wristband.Tx` and fonoteka's adapter compile unchanged | D-09 | +| RP-visible parity with PHP (discovery doc, JWKS, token/userinfo JSON, error redirects/bodies, id_token header/claims) | D-14 | +| RS256 default, ES256 configurable, imported PHP kids preserved | D-11, D-12 | +| Interop test with a real RP library | D-16 | + +### Comparison + +| Criterion | A: extend hand-written wristband + JOSE lib | B: zitadel/oidc v3 (v3.51.16) behind wristband | +|-----------|---------------------------------------------|------------------------------------------------| +| D-06 one engine | Yes: OIDC flavour lives in the same package, reuses PKCE/hash/redirect/lineage code | No: zitadel's `op` package is its own authorize/token engine; MCP stays on wristband (Phase 8 rejected zitadel for MCP byte parity — `.planning/notes/oauth2-wristband-direct-port.md`) → two engines | +| D-09 additive | Yes: new `OIDC*` interfaces; existing ones untouched | Adapter must implement `op.Storage` (= `AuthStorage` + `OPStorage` + `Health`, 25 methods, see below) with zitadel-shaped types (`*oidc.AuthRequest`, `jose.SignatureAlgorithm`) | +| D-14 discovery parity | Exact: struct mirrors PHP field set | `CreateDiscoveryConfig` always emits introspection/revocation/end_session/check_session/device endpoints and more [VERIFIED: zitadel/oidc v3.51.16 `pkg/op/discovery.go:37-68`]; PHP emits 16 fields only | +| D-14 error shapes, redirect encoding, login handoff | Exact (we write the bytes) | zitadel owns error JSON, redirect building and the login URL contract (`Client.LoginURL(string)` [VERIFIED: `pkg/op/client.go:43`]) | +| New runtime deps | **None** (`golang-jwt/jwt/v5` v5.3.1 already in go.mod) | zitadel + chi v5, gorilla/securecookie, rs/cors, go-jose v4, otel 1.46 (framework pins 1.44), golang/mock, go-github v31… [VERIFIED: zitadel v3.51.16 go.mod] | +| Maintained-library coverage of security-critical parts | JWS signing/verification: golang-jwt (maintained, already vetted). PKCE/redirect/CSRF: wristband code already reviewed in Phase 8. JWK encode: ~40 lines of stdlib, cross-checked by go-jose inside go-oidc in tests | Whole protocol in a certified library | +| Effort | Medium: flavour paths + new interfaces | High: adapter + fighting defaults to reach PHP bytes; still need wristband for MCP | + +zitadel `op.Storage` methods [VERIFIED: `pkg/op/storage.go` v3.51.16]: `CreateAuthRequest, AuthRequestByID, AuthRequestByCode, SaveAuthCode, DeleteAuthRequest, CreateAccessToken, CreateAccessAndRefreshTokens, TokenRequestByRefreshToken, TerminateSession, RevokeToken, GetRefreshTokenInfo, SigningKey, SignatureAlgorithms, KeySet` (AuthStorage) and `GetClientByClientID, AuthorizeClientIDSecret, SetUserinfoFromScopes, SetUserinfoFromToken, SetIntrospectionFromToken, GetPrivateClaimsFromScopes, GetKeyByIDAndClientID, ValidateJWTProfileScopes` (OPStorage) plus `Health`. + +### Recommendation + +**Option A.** The user's standing preference ("maintained library plus a decision note for protocol and security code") is honoured where it matters most: the cryptographic part (JWS sign/verify, RS256/ES256) uses the maintained `golang-jwt/jwt/v5`, and an independent maintained RP stack (`go-oidc` → `go-jose`) verifies every token the engine emits in tests. The protocol state machine stays the already-reviewed wristband code, because the certified library cannot meet D-06/D-09/D-14 at the same time. The decision note should say this trade-off in those words, and should state that it supersedes the `zitadel/oidc` row of `.planning/research/STACK.md` (line 21) for OIDC as the Phase 8 note already did for MCP. + +**JWK encoding:** stdlib, not a library. RSA `n`/`e` are the minimal big-endian bytes of `N` and `E`; EC uses `ecdsa.PublicKey.Bytes()` (fixed-length uncompressed point, so the classic left-padding bug cannot happen) [VERIFIED: `go doc crypto/ecdsa.PublicKey.Bytes`, Go 1.27.2]. Alternative if the user wants a library here too: `github.com/go-jose/go-jose/v4` v4.1.6 (2026-10-14) at runtime — it is already pulled in as a test dependency by go-oidc. Record the choice in the note (Assumption A2). + +**Optional extra oracle:** the OpenID Foundation conformance suite (Docker) could run the "Basic OP" plan against a dev instance. Not required by D-16; useful for the security review. [ASSUMED] + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Go standard library (`crypto/rsa`, `crypto/ecdsa`, `crypto/x509`, `encoding/pem`, `crypto/sha256`, `crypto/subtle`, `crypto/rand`, `uuid`, `html/template`, `net/http`) | Go 1.27.2 | Key generation/parsing, JWK encode, at_hash, CSRF, UUIDv4 request ids, consent HTML | Stdlib first (CLAUDE.md). Go 1.27 ships `uuid.NewV4()` [VERIFIED: `go doc uuid`] | +| `github.com/golang-jwt/jwt/v5` | v5.3.1 (2026-01-28) | Sign `id_token` (RS256, ES256); parse in tests | Already decided and in go.mod [VERIFIED: go.mod `github.com/golang-jwt/jwt/v5 v5.3.1`]; used by `modules/bouncer`, `modules/flare/vapid.go` (ES256), `modules/lighthouse/centrifugo` [VERIFIED: grep] | +| `wristband` (in-tree) | — | Engine | D-01 | +| `lagoon.Encrypted` (in-tree) | — | Private PEM at rest | D-10, verified readable (see below) | +| `surf.Bucket` / `surf.BucketProvider` (in-tree) | — | Named throttles | D-18 | +| `conga` scheduler via `pact.HasSchedule` (in-tree) | — | Daily prune, opt-in rotation | `pact.ScheduledCommand{Command, Args, Cadence}`, `pact.Daily()`, `pact.Every(d)` [VERIFIED: `modules/pact/capabilities.go:124-134,165-174`] | +| `tide` (in-tree) | — | Record PHP, replay Go | D-14 | + +### Supporting (test-only) +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `github.com/coreos/go-oidc/v3` | v3.21.0 (2026-09-01; go 1.25.0; requires go-jose v4.1.4, x/oauth2 v0.36.0) [VERIFIED: `go list -m -json`, module go.mod] | Stock RP: discovery, JWKS fetch, id_token verification, `at_hash` check | Interop test (PHP `JumbojettInteropTest` equivalent) | +| `golang.org/x/oauth2` | v0.37.0 (2026-08-25) | Stock RP authorize URL + code exchange with PKCE (`GenerateVerifier`, `S256ChallengeOption`, `VerifierOption`) [CITED: pkg.go.dev/golang.org/x/oauth2] | Interop test | +| `github.com/testcontainers/testcontainers-go/modules/postgres` | v0.44.0 (in go.mod) | Plugin store tests | Plugin smoke/unit tests | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| stdlib JWK encoder | `github.com/go-jose/go-jose/v4` v4.1.6 | Library-for-everything; adds a second JOSE library at runtime next to golang-jwt (STACK.md's "one JWT library" policy) | +| hand-written engine | `github.com/zitadel/oidc/v3` v3.51.16 | See comparison; fails D-06/D-14 | +| go-oidc RP | `lestrrat-go/jwx/v3` v3.3.0 | Not an RP library; would hand-roll the RP flow in the test | + +**Installation (test-only, in the module that holds the interop test):** +```bash +go get github.com/coreos/go-oidc/v3@v3.21.0 golang.org/x/oauth2@v0.37.0 +``` +Test-only imports still land in `go.mod`'s require list; consumers don't compile them. Put the interop test in `modules/wristband` (`_test.go`) or in the plugin repo — the plugin is the better home if the root module should stay free of them (Open Question 6). + +**Version verification:** `go list -m -versions` this session: go-oidc v3 latest v3.21.0, x/oauth2 latest v0.37.0, go-jose v4 latest v4.1.6, zitadel v3 latest v3.51.16, golang-jwt v5 latest v5.3.1, jwx v3 latest v3.3.0. + +## Package Legitimacy Audit + +The `msd-tools package-legitimacy check` seam supports only `npm|pypi|crates` (it refused `--ecosystem go` this session), so the audit below uses the Go module proxy (`go list -m -json`) and the checksum database that `go get` enforces. + +| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition | +|---------|----------|-----|-----------|-------------|---------|-------------| +| `github.com/golang-jwt/jwt/v5` | proxy.golang.org | v5 line since 2023; v5.3.1 2026-01-28 | n/a (Go proxy has no counts) | github.com/golang-jwt/jwt | already in go.mod | Approved (existing dependency) | +| `github.com/coreos/go-oidc/v3` | proxy.golang.org | v3.21.0 2026-09-01 | n/a | github.com/coreos/go-oidc [CITED: pkg.go.dev] | not seam-checked | Test-only; `[ASSUMED]` per provenance rule; planner adds `checkpoint:human-verify` before `go get` | +| `golang.org/x/oauth2` | proxy.golang.org | v0.37.0 2026-08-25 | n/a | go.googlesource.com/oauth2 | not seam-checked | Test-only; `[ASSUMED]`; same checkpoint | +| `github.com/go-jose/go-jose/v4` | proxy.golang.org | v4.1.6 2026-10-14 | n/a | github.com/go-jose/go-jose | transitive of go-oidc | Transitive (test); runtime only if A2 flips | +| `github.com/zitadel/oidc/v3` | proxy.golang.org | v3.51.16 2026-10-15 | n/a | github.com/zitadel/oidc | — | Not recommended | + +**Packages removed due to [SLOP] verdict:** none +**Packages flagged as suspicious [SUS]:** none. The two test-only packages carry `[ASSUMED]` only because the seam cannot check Go modules; both are long-established upstream projects. + +## Architecture Patterns + +### System Architecture Diagram + +``` + Relying party (server) Browser (user) SPA (login, authorized apps) + | | | + | GET /.well-known/openid-configuration, GET /oauth/jwks (no middleware, Cache-Control max-age=3600, public) + v | | + +--------------------------- surf router (plugin routes at PHP paths) --------------------------------+ + | public group | browser group: throttle oauth-authorize | jwt.auth + throttle | + | discovery, jwks | GET /oauth/authorize ------------------+ | GET /oauth/authorizations | + | server-to-server group | GET /oauth/authorize/resume/{request} | | DELETE /oauth/authorizations/| + | POST /oauth/token (thr) | POST /oauth/authorize (decide) | | {client_id} | + | GET /oauth/userinfo | POST /oauth/authorize/switch-account | | cors: (OPTIONS) | + +--------------|-----------+-----------------------|-----------------+-+---------------|--------------+ + | | | + v v v + +--------------------------- wristband OIDC Server (flavour = oidc) ---------------------------------+ + | Start: client(kind=oidc) -> redirect match (exact | is_dev loopback port) -> is_active | + | -> allowlist validation -> park AuthRequest(uuid) -> signed in? resume path : login handoff | + | Resume/Decide: request usable? -> user? -> subject(lazy) -> consent covers? -> silent code | + | else consent view (+ one-shot CSRF digest) -> decide(allow|deny) -> code redirect | + | Token: grant -> client auth (Basic first, urldecode) -> consume code (burn first) -> replay? revoke | + | -> client/redirect/PKCE -> mint oas_at_ -> claims -> sign id_token(kid) -> JSON | + | UserInfo: bearer -> token by hash (prefix gate) -> claims (granted scopes only) -> JSON | + | Discovery/JWKS: Options + KeyRing (active + retired within grace) | + +------------|---------------------------|-----------------------------|-----------------------------+ + | OIDCBackend (new) | KeyStore (new) | ClaimsSource (new, per user) + v v v + +-------------------------- sm-oauthserver-plugin (GORM stores) ------------------------------------+ + | golem15_oauthserver_{clients,auth_requests,auth_codes,access_tokens,consents,subjects} | + | + _keys (lagoon.Encrypted private PEM) + _refresh_tokens (D-17, MCP) | + | user rows/groups/avatar from sm-user-plugin; communities from userfriends tables if present | + +-----------------------------------------------------------------------------------------------------+ + commands: oauthserver:rotate-keys, oauthserver:prune (conga daily), oauthserver:import-winter + events: user.Deleting (hard delete) -> cascade; soft-delete gap (Open Question 3) +``` + +### Recommended Project Structure + +``` +summercms.go/modules/wristband/ +├── server.go # Options (Resource default neutral), Server, NewServer (MCP, unchanged) +├── oidc_options.go # OIDCOptions + NewOIDCServer / SetOIDCBackend (validates interfaces) +├── oidc_stores.go # new interfaces: OIDCBackend, OIDCTx, AuthRequestRecord, OIDCCodeRecord, ... +├── oidc_discovery.go # Discovery handler (PHP field set) +├── oidc_keys.go # KeyStore interface, JWK encoding, signer cache +├── oidc_authorize.go # Start handler + Resume/Decide/SwitchAccount operations +├── oidc_token.go # OIDC token path (+ refresh when client allows offline_access) +├── oidc_userinfo.go # UserInfo handler +├── oidc_redirect.go # loopback-port matcher, php http_build_query encoder +├── crypto.go # shared: sha256Hex, constantEqual, s256Challenge (unchanged) +└── *_test.go # memory fakes + go-oidc interop tracer + +sm-oauthserver-plugin/ (git.golem15.com/golem15/sm-oauthserver-plugin, plugin ID golem15.oauthserver) +├── plugin.go # Requires golem15.user; Register/Boot builds MCP + OIDC Servers +├── routes.go # PHP paths, groups, buckets (BucketProvider), cors profile for authorizations +├── config/config.yaml # issuer, login_url, rotation_grace_hours, ttl.*, picture_size, consent_mode, key alg +├── models/ # Client, AuthRequest, AuthCode, AccessToken, Consent, Subject, SigningKey, RefreshToken, Settings +├── updates/ # gormigrate migrations (registry.go) +├── classes/ # stores (wristband.Backend + OIDCBackend), keyring, claims, cascade, branding +├── controllers/ # consent/error HTML handlers, authorizations JSON, cabana Clients controller +├── views/ # consent.html.tmpl, error.html.tmpl (embed) +├── console/ # rotate_keys.go, prune.go, import_winter.go +├── lang/ # en, pl (port PHP lang keys) +├── scripts/winter-export.sql +└── testmain_test.go # testcontainers Postgres, -short aware +``` + +### Pattern 1: Flavoured Server, shared primitives (D-06, D-09) + +**What:** `wristband.NewServer(opts)` keeps building the MCP flavour exactly as today. A new constructor (name at discretion, e.g. `wristband.NewOIDCServer(opts, oidcOpts)`) builds a `*Server` with `flavour=oidc`. The same `Authorize`/`Token` method names dispatch on flavour to separate validation and response code; `Discovery`, `JWKS`, `UserInfo` are new handlers that answer 404/500 on an MCP-flavour server. A new `SetOIDCBackend(b OIDCBackend) error` (or the constructor) checks the backend at boot; `SetBackend` keeps its signature (fonoteka calls it without a return value, `plugin.go:175`). + +**Why not one `Authorize` code path with flags:** the two flavours differ in almost every RP-visible step (see the contract map). Sharing the step functions and keeping two short orchestration functions is easier to review and keeps MCP bytes provably unchanged (existing tests stay green). + +**Kind enforcement (D-08), defence in depth:** add `Kind string` to `ClientRecord` (keyed literals in fonoteka → source-compatible [VERIFIED: `fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go:322-335` uses keyed fields]); the MCP flavour rejects `Kind == "oidc"` and treats `""` as MCP (fonoteka rows); the OIDC flavour reads clients only through a new OIDC client lookup returning a separate record type, and requires `Kind == "oidc"`. The plugin's stores also filter by kind in SQL. + +**Interface sketch (names are discretion):** +```go +// Recommendation, not existing code. +type OIDCBackend interface { + WithinOIDCTx(ctx context.Context, fn func(OIDCTx) error) error +} +type OIDCTx interface { + OIDCClientByClientID(ctx context.Context, clientID string) (*OIDCClientRecord, error) + CreateAuthRequest(ctx context.Context, rec *AuthRequestRecord) error + AuthRequestByID(ctx context.Context, requestID string) (*AuthRequestRecord, error) + ConsumeAuthRequest(ctx context.Context, id uint, now time.Time) (bool, error) // atomic UPDATE ... WHERE consumed_at IS NULL AND expires_at > now + SetConsentTokenHash(ctx context.Context, id uint, hash *string) error + CreateCode(ctx context.Context, rec *OIDCCodeRecord) error + ConsumeCode(ctx context.Context, codeHash string, now time.Time) (*OIDCCodeRecord, error) // nil when not exactly one row moved + CodeWasConsumed(ctx context.Context, codeHash string) (*OIDCCodeRecord, error) + MintOIDCAccessToken(ctx context.Context, rec *OIDCAccessTokenRecord) error + AccessTokenByHash(ctx context.Context, hash string) (*OIDCAccessTokenRecord, error) + RevokeAccessTokensForCode(ctx context.Context, codeID uint, now time.Time) error + ConsentFor(ctx context.Context, userID uint, clientID string) (*ConsentRecord, error) + GrantConsent(ctx context.Context, userID uint, clientID string, scopes []string) (*ConsentRecord, error) // merge, one row per pair + TouchConsent(ctx context.Context, id uint, now time.Time) error + SubjectFor(ctx context.Context, userID uint, sector string) (string, error) // lazy allocate UUIDv4 + IdentityClaims(ctx context.Context, userID uint, scopes []string) (map[string]any, bool, error) // false = user gone +} +type KeyStore interface { + ActiveSigningKey(ctx context.Context) (kid, alg string, key crypto.Signer, err error) // lazily generates the first key + PublishedKeys(ctx context.Context, now time.Time) ([]PublicKey, error) // active + retired within grace +} +``` +`IdentityClaims` is the seam through which the plugin hands sm-user-plugin data to wristband. It is not the "claims-provider interface" D-05 rejected: that decision is about the plugin not offering a pluggable provider to other plugins; wristband needs some seam because it imports no user model. + +### Pattern 2: PHP contract map (port target, RP-visible) + +All rows below were read from the PHP source this session. File paths are relative to `/media/nvme/dev/jz/wavepath.org/plugins/golem15/oauthserver/`. + +**Routes** [VERIFIED: `routes.php:115-196`] + +| Method + path | Auth model | Throttle bucket (key) | +|---------------|-----------|-----------------------| +| `GET /.well-known/openid-configuration` | public | none | +| `GET /oauth/jwks` | public | none | +| `POST /oauth/token` | none (server-to-server, no session) | `oauth-token` 20/min, `'oac:' . client_id` from input else `'oac-ip:' . ip` | +| `GET /oauth/userinfo` | bearer | `oauth-userinfo` 60/min, `'oau:' . hash('sha256', $bearer)` else `'oau-ip:' . ip` | +| `GET /oauth/authorize` (start), `POST /oauth/authorize` (decide), `GET /oauth/authorize/resume/{request}`, `POST /oauth/authorize/switch-account` | `web` (session + `token` cookie), one-shot CSRF | `oauth-authorize` 30/min, user id else ip | +| `GET /oauth/authorizations`, `DELETE /oauth/authorizations/{client_id}` | `jwt.auth` | `oauth-authorizations` 120/min, user id else ip | +| `OPTIONS /oauth/authorizations/{any?}` | none, 204 | — (Go: `cors:` registers OPTIONS automatically, 19.3) | + +Note the token key reads `client_id` from the **request input only**: a `client_secret_basic` RP (the go-oidc/x-oauth2 default) lands in the IP bucket. "Same keys" (D-18) means porting that quirk; flag it in the plan. + +**Discovery** [VERIFIED: `classes/DiscoveryDocument.php:40-66`, `controllers/oidc/DiscoveryController.php:38-43`] — fields in order: `issuer`, `authorization_endpoint` (`$iss . '/oauth/authorize'`), `token_endpoint` (`/oauth/token`), `userinfo_endpoint` (`/oauth/userinfo`), `jwks_uri` (`/oauth/jwks`), `response_types_supported` `['code']`, `grant_types_supported` `['authorization_code']`, `subject_types_supported` `['public']`, `id_token_signing_alg_values_supported` `['RS256']`, `scopes_supported` `['openid', 'profile', 'email']`, `claims_supported` `['sub','name','picture','groups','communities','email','email_verified','iss','aud','exp','iat','nonce']`, `code_challenge_methods_supported` `['S256']`, `token_endpoint_auth_methods_supported` `['client_secret_basic', 'client_secret_post']`, `response_modes_supported` `['query']`. Emitted with `JSON_UNESCAPED_SLASHES` and `['Cache-Control' => 'public, max-age=3600']`; issuer is `rtrim(config issuer, '/')`; an empty issuer aborts 500 `'The OpenID Connect issuer is not configured.'`. +Planned deviations (record in tide fixtures as accepted diffs): `subject_types_supported` → `["public","pairwise"]` (D-15); `id_token_signing_alg_values_supported` → algorithms present in the ring (D-12, `["RS256"]` after import); `grant_types_supported`/`scopes_supported` gain `refresh_token`/`offline_access` only if OIDC refresh is enabled in config (Open Question 4). + +**JWKS** [VERIFIED: `classes/KeyRing.php:92-121,273-302`, `controllers/oidc/JwksController.php:93-125`] — `{"keys":[{kty:"RSA", use:"sig", alg:"RS256", kid, n, e}]}` for the active key plus retired keys whose `retired_at` is inside the grace window (filtered at read time, not by prune); no process cache; `Cache-Control: public, max-age=3600`; generates the first key if the ring is empty; failure → 500 `'Signing keys are unavailable.'`. ES256 JWK (Go addition): `{kty:"EC", use:"sig", alg:"ES256", kid, crv:"P-256", x, y}`. + +**id_token** [VERIFIED: `classes/TokenIssuer.php:91-122,129-132`] — `JWT::encode($payload, $privatePem, 'RS256', $kid)`; payload `iss` (configured issuer, rtrim), `sub`, `aud` (client_id **string**), `exp` (now + `ttl.id_token`, default 3600), `iat`, `nonce` only when non-empty, `at_hash` = base64url(left 16 bytes of SHA-256(plaintext access token)) only when the access token is non-empty, then identity claims filtered to `ClaimBuilder::ALLOWED`, then `sub` forced again. No `auth_time`, no `azp`, no `jti`. Header from firebase/php-jwt v7.1.0 [VERIFIED: wavepath `composer.lock:1798-1799`]: `{"typ":"JWT","alg":"RS256","kid":""}` [ASSUMED — header order from php-jwt source memory]. golang-jwt serialises `Header` as a map, so Go emits `{"alg":..,"kid":..,"typ":"JWT"}`: same members, different order, harmless (JOSE headers are JSON objects). + +**Identity claims** [VERIFIED: `classes/ClaimBuilder.php:54` quote: `public const ALLOWED = ['sub', 'name', 'picture', 'groups', 'communities', 'email', 'email_verified'];`] — `openid` → `sub`; `profile` → `name` (first non-empty of `name`, `username`, `email`, trimmed; omitted if empty), `picture` (absolute: `//x` → `https://x`, relative → `url()`; omitted if empty; size `picture_size` default 128), `groups` (always present: sorted unique non-empty user-group `code`s), `communities` (always present: `[{id,name,role}]` from `Golem15\UserFriends\Models\GroupMember` when the class exists, soft-deleted groups dropped, sorted by name then id; `[]` otherwise); `email` → `email` + `email_verified` (bool of `is_activated`) only when the email is non-empty. Go user model has `Name`, `Surname`, `Email`, `IsActivated`, no `username` column [VERIFIED: `fonoteka.go/plugins/golem15/user/models/user.go` lines 15-53]; port `name` from `Name` only (not "name surname") to match PHP. + +**Authorize start** [VERIFIED: `controllers/oidc/AuthorizeController.php:97-167`, `classes/AuthorizeRequestValidator.php:415-536`, `classes/RedirectUriMatcher.php:158-257`] — order: client by `client_id` (unknown → rendered `unknown_client`) → redirect match (byte-exact against registered list; `is_dev` clients additionally accept a loopback `http` URI differing only in port, hosts `localhost`, `127.0.0.1`, `[::1]`, every other component identical) else rendered `redirect_mismatch` → `is_active` else rendered `inactive_client` → `response_type == 'code'` else `unsupported_response_type` / `'This provider only supports the authorization code response type.'` → `code_challenge` matches `/^[A-Za-z0-9\-_]{43,128}$/` else `invalid_request` / `'A PKCE code challenge is required and must be a base64url-encoded SHA-256 digest.'` → `code_challenge_method == 'S256'` else `invalid_request` / `'The code challenge method is required and must be S256.'` → `state`,`nonce` ≤ 255 chars else `invalid_request` / `'The state and nonce parameters must be strings of at most 255 characters.'` → `scope` required (≤255) else `invalid_scope` / `'A scope parameter is required.'`; must contain `openid` else `'The scope parameter must include openid.'`; all in `['openid','profile','email']` else `'One or more requested scopes are not supported.'`; all in client's `scopes` else `'One or more requested scopes are not allowed for this client.'` → park `AuthRequest` (UUIDv4 `request_id`, intersected scopes, state, nonce, challenge, method, TTL `ttl.auth_request` 600 s) → signed in: `302` to the **relative** path `/oauth/authorize/resume/{uuid}`; else `302` to `rtrim(login_url,'/') . ('?'|'&') . 'redirect=' . urlencode($resumePath)` (default login_url `/login`). Error redirects use the raw-query `state`. `resource`, `prompt`, `max_age`, `login_hint` are ignored. + +**Redirect query encoding** [VERIFIED: `AuthorizeController.php:411-420,638-659`] — `http_build_query` (PHP_QUERY_RFC1738: space → `+`, `~` → `%7E`), params `error`, `error_description` (dropped when empty, so `access_denied` carries none), `state` (dropped when null or `''`); success: `code`, `state`. **No `iss`** (wristband MCP adds one). Separator `&` if the URI already has `?`. Wristband's `rfc3986Escape` must not be reused here; port a PHP `urlencode` encoder (Pitfall 2). + +**Rendered pages** [VERIFIED: `AuthorizeController.php:82-87,605-626`] — status 400 (errors) or 200 (consent), headers `X-Frame-Options: DENY`, `Content-Security-Policy: frame-ancestors 'none'`, `Cache-Control: no-store, private`, `Referrer-Policy: no-referrer`. Error cases: `unknown_client`, `redirect_mismatch`, `inactive_client`, `expired_request`. Consent form fields: `request_id`, `consent_token`, `decision` (`allow` exactly, anything else denies); switch-account form posts `request_id`, `consent_token`. + +**Resume / decide / switch** [VERIFIED: `AuthorizeController.php:181-349,364-421`, `classes/ConsentCsrf.php:54-103`] — resume: request missing/expired/consumed → `expired_request`; no user → login handoff again; client gone/inactive → rendered; `Subject::forUser` lazily; consent covers all requested scopes → **silent**: consume request, touch consent, mint code, 302 (no HTML); else render consent listing only missing scopes (`openid` never listed), mint CSRF token = base64url(32 bytes), store `hash('sha256', $token . '|' . $userId)`. Decide: CSRF verify (burns digest) failures all → `expired_request`; non-`allow` → consume request, `access_denied` redirect; `allow` → granted = stored scopes ∩ client's **current** scopes, consent merged (one row per pair), code minted in one transaction with `consume()` first (loser of a race gets `expired_request`). Switch-account: verify CSRF (burned), best-effort JWT blacklist + logout, `Cookie::forget('token')`, 302 back to the resume path; request **not** consumed. + +**Token** [VERIFIED: `controllers/oidc/TokenController.php:78-366`] — `grant_type != 'authorization_code'` → 400 `unsupported_grant_type` / `'Only the authorization_code grant is supported.'`; client creds: `Authorization: Basic` first (case-insensitive prefix, strict base64, split on first `:`, `urldecode` both halves, empty id → fall back to form), else `client_id`/`client_secret` form; unknown client → dummy compare (`burnComparison`), unknown/inactive/wrong secret (secret must start `oas_cs_`) → 401 `invalid_client` / `'Client authentication failed.'` + `WWW-Authenticate: Basic realm="oauth"`; missing `code`/`redirect_uri`/`code_verifier` → 400 `invalid_request` / `'code, redirect_uri and code_verifier are required.'`; then in one transaction: `AuthCode::consume` (prefix `oas_ac_`, atomic `UPDATE … WHERE consumed_at IS NULL AND expires_at > now()`), replay → `AccessToken::revokeForCode` (revocation **commits**), client/redirect mismatch, PKCE (verifier 43–128 chars `[A-Za-z0-9\-._~]`, S256, `hash_equals`), user missing — all → 400 `invalid_grant` / `'The authorization code is invalid, expired, already used, or does not match this request.'`; success body `{access_token, token_type:"Bearer", expires_in, id_token, scope}` with `Cache-Control: no-store`, `Pragma: no-cache` (errors too). Logs `client_id` + IP + fixed reason only. + +**UserInfo** [VERIFIED: `controllers/oidc/UserInfoController.php:411-460`] — bearer missing/not `oas_at_`/unknown/expired/revoked/user gone → 401, **empty body**, `WWW-Authenticate: Bearer error="invalid_token"`, `Cache-Control: no-store`; success → claims JSON (granted scopes from the token row), touches `last_used_at`. + +**Authorizations** [VERIFIED: `controllers/oidc/AuthorizationsController.php:13-54` (comment-stripped view)] — `GET` → `{"authorizations":[{client_id,name,logo_url,scopes,first_authorized_at (ISO 8601),last_used_at (ISO 8601|null)}]}` newest consent first, missing clients skipped; `DELETE /{client_id}` → in a transaction, lock consent row; none → 404 `{"error":"not_found"}`; else delete consent, the pair's auth codes and access tokens (hard delete) → `{"revoked":true}`. + +**Wire headers that `tide` compares.** `tide` compares `Cache-Control`, `Pragma`, `WWW-Authenticate`, `Location` (plus CORS headers) and diffs JSON bodies structurally (key order ignored), other bodies byte-for-byte [VERIFIED: `modules/tide/diff.go:44-51`, tide README]. Symfony rewrites Cache-Control: `public, max-age=3600` is re-serialised as `max-age=3600, public` [VERIFIED: PHP test comment `tests/feature/DiscoveryEndpointTest.php:30-31`], and an explicit `no-store` becomes `no-store, private` [VERIFIED: STATE.md Phase 08 P09 decision, live PHP recording]. So Go must emit `max-age=3600, public` for discovery/JWKS and `no-store, private` for token/userinfo. Consent/error HTML steps need a body-ignoring normalizer (D-14: not byte-identical). + +### Pattern 3: Key ring in the database (D-10..D-13) + +- Table `golem15_oauthserver_keys` (name at discretion): `id`, `kid` (unique), `alg` (`RS256`|`ES256`), `public_pem` (text, PKIX `PUBLIC KEY`), `private_pem` (`lagoon.Encrypted`, PKCS#8), `created_at`, `retired_at` (nullable), `active` (bool) with a **partial unique index** `WHERE active` so two instances racing the lazy first-key path cannot both win; generate inside a transaction and on unique-violation re-read. +- kid format: PHP `bin2hex(random_bytes(8))` (16 hex chars) [VERIFIED: `KeyRing.php:251`]; keep it for new keys. +- `lagoon.Encrypted` **is readable**: `Scan` decrypts with the published primary key then each `app.previous_keys` entry, `Reveal()` returns plaintext; `MarshalJSON`/`String`/`GoString` redact [VERIFIED: `modules/lagoon/encrypted.go:29-58,60-102`, quote: `// Encrypted is an AES-256-GCM at-rest cast. Plaintext is unexported; the only` / `// greppable accessor is Reveal. MarshalJSON, String, and GoString always redact.`]. AES-256-GCM with an HKDF-derived column key from `app.key`. Consequence: `app.key` rotation must keep the old key in `app.previous_keys` or the ring becomes unreadable (Pitfall 7). Never expose the keys model through cabana fields (cabana `fill.go:268-279` treats request input for an `Encrypted` column as plaintext). +- Signer cache: parse the active PEM at most once per kid per process; re-read the active kid from the DB on a short TTL (e.g. 60 s) or on every token exchange (one indexed SELECT) so a rotation on one instance reaches the others. JWKS: read per request (PHP has no cache) — Cache-Control does the caching. +- Imported PEMs: `.pub` from `openssl_pkey_get_details()['key']` is a PKIX `-----BEGIN PUBLIC KEY-----`; `.key` from `openssl_pkey_export` is normally PKCS#8 `-----BEGIN PRIVATE KEY-----` on OpenSSL ≥ 1.1 [ASSUMED — the local key_dir is mode 0700 and unreadable to this session]. Parse with `x509.ParsePKCS8PrivateKey`, fall back to `x509.ParsePKCS1PrivateKey`; check the parsed public key equals the `.pub` (`Equal`) before inserting. +- Rotation (`oauthserver:rotate-keys [--prune]`): output lines as PHP (`New signing key: `, `Retired previous key %s at %s (verifiable for %d more hours).`, non-leaking error text) [VERIFIED: `console/RotateKeys.php`]. Opt-in schedule: `Schedule()` returns `{Command: "oauthserver:rotate-keys", Cadence: pact.Every(interval)}` only when the config interval is set. + +### Pattern 4: Login handoff + signed-in user (D-04) + +The authorize routes need **optional** authentication (proceed anonymously), but bouncer's `Registry` exposes only `Middleware(name)` (which 401s) [VERIFIED: `modules/bouncer/registry.go:22-104`, grep]. sm-user-plugin registers `bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token")` [VERIFIED: `fonoteka.go/plugins/golem15/user/plugin.go:98`]. Additive options: (a) sm-user-plugin publishes a small typed resolver on the app (`app.Publish`) wrapping that guard's `Authenticate`; (b) bouncer gains `Registry.Guard(name)`. Either is additive. PHP checks the JWT first, then the Winter session; Go has no Winter session, so JWT only. Switch-account: blacklist the JWT's jti through the published `bouncer.BlacklistStore` (`Add(ctx, jti, expiresAt, validUntil)`) — needs jti/exp from the token, which `bouncer.Principal` does not carry [VERIFIED: `modules/bouncer/context.go:26-35`]; reuse sm-user-plugin's logout revoke function (export additively) and clear the cookie with `bouncer.LoadCookieConfig(cfg)` → `CookieConfig.ForgetTokenCookie` so Domain/Path match the 19.3 cookie. + +### Pattern 5: Subjects (D-15) + +- Public: keep PHP semantics — UUIDv4, lazily allocated at resume, never derived from any account attribute, never rotated [VERIFIED: `models/Subject.php` `firstOrCreate(['user_id'=>…], ['sub'=>Str::uuid()])`]. +- Pairwise (new): **store-based**, not hash-derived. Add `sector` (text, default `''`) to `golem15_oauthserver_subjects`, unique `(user_id, sector)` and unique `sub`; public = `sector ''`; pairwise = random UUIDv4 per (user, sector), lazily allocated. A hash-derived sub (OIDC Core §8.1 example) needs a salt secret whose loss silently changes every pairwise sub; a stored random value avoids that and keeps hard-delete simple. Sector = host of the client's redirect URIs; if the URIs span several hosts, an admin must set the sector identifier (no remote `sector_identifier_uri` fetch in 19.4 — Open Question 5). +- Imported rows map to `sector ''`. Userinfo `sub` must equal the id_token `sub` for the same client (PHP test `test_sub_matches_the_id_token_sub`), so userinfo must know the token's client to pick the sector — the access token row has `client_id`. +- Cascade: PHP soft delete keeps the subject (restore keeps the same sub), hard delete removes it [VERIFIED: `classes/UserCascade.php:127-141`]. + +### Pattern 6: Throttle buckets (D-18) + +```go +// Recommendation: plugin implements surf.BucketProvider. +func (p *Plugin) Buckets() map[string]surf.Bucket { + return map[string]surf.Bucket{ + "oauth-authorize": {Max: 30, Decay: time.Minute, Key: p.userOrIP("oauth-authorize")}, + "oauth-token": {Max: 20, Decay: time.Minute, Key: tokenKey}, // r.FormValue("client_id") or IP; Basic header NOT consulted (PHP) + "oauth-userinfo": {Max: 60, Decay: time.Minute, Key: userinfoKey}, // "oau:"+sha256Hex(bearer) or IP + "oauth-authorizations": {Max: 120, Decay: time.Minute, Key: p.userOrIP("oauth-authorizations")}, + } +} +``` +`surf.Bucket{Name, Max, Decay, Key func(*http.Request) string}` [VERIFIED: `modules/surf/limiter.go:21-33`]. Bucket names are global across plugins (`RegisterBucket` refuses duplicates). Use `surf.ClientIP(r, surf.TrustedProxies(cfg))` for the fallback. On the authorize group the Key func runs before any guard, so it resolves the user itself (same optional resolver as Pattern 4). Keep keys prefixed per bucket so token and userinfo IP fallbacks don't share counters. + +### Anti-Patterns to Avoid +- **Reusing MCP authorize/token paths with `if oidc` sprinkled in:** breaks MCP byte parity silently. Use flavour-level orchestration. +- **`jwt.RegisteredClaims` for the id_token:** `Audience` marshals as an array while `jwt.MarshalSingleStringAsArray` is `true` (package global, default) [VERIFIED: golang-jwt v5.3.1 `types.go:28`]; PHP sends a string. Use `jwt.MapClaims`. Never flip the global (bouncer shares it). +- **Publishing two `*wristband.Server` values:** `backpack` keeps the first value of a type and rejects duplicates [VERIFIED: `modules/backpack/services_test.go` `TestPublishDuplicateRejected`, `TestDuplicatePublishKeepsFirstValue`]; fonoteka looks up `app.Lookup[*wristband.Server]()`. The plugin publishes its own wrapper type holding both. +- **Storing the private PEM on disk or in config** (D-10), or logging any key material / codes / tokens / secrets (PHP T-98-23). +- **`net/url.Values.Encode` for PHP redirects:** sorts keys and leaves `~`; PHP keeps insertion order and encodes `~`. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| JWS signing RS256/ES256 | `rsa.SignPKCS1v15` + manual base64 segments | `golang-jwt/jwt/v5` `SigningMethodRS256/ES256` | Segment encoding, ES256 raw R‖S fixed length (handled in `ecdsa.go` `Sign`) [VERIFIED: v5.3.1 source] | +| RP verification in tests | custom JWT parser | `go-oidc` `Provider.Verifier` + `IDToken.VerifyAccessToken` | Independent oracle; checks iss/aud/exp/alg/kid/at_hash | +| UUIDv4 | hex formatting by hand | stdlib `uuid.NewV4()` (Go 1.27) | Version/variant bits | +| At-rest encryption of private keys | own AES wrapper | `lagoon.Encrypted` | Key rotation via `app.previous_keys` already handled | +| Rate limiting | own counters | `surf.BucketProvider` + cooler Redis store | Multi-instance (19.3) | +| CSRF on consent POST | framework CSRF middleware | port `ConsentCsrf` (digest bound to user, one-shot) | PHP model is the spec; the request has no user column by design | +| Redirect HTML body | `http.Redirect` | `wristband.writeRedirectHTML` (Symfony body) | Byte parity of 302 bodies | +| Scheduling | goroutine timers | `pact.HasSchedule` (conga leader-elected) | Multi-instance safety | + +**Key insight:** the security-relevant novelty in this phase is small (JWS + JWKS + CSRF digest); most risk is in faithfully porting PHP's ordering and encodings. Spend effort on the contract map and tide, not on clever abstractions. + +## Runtime State Inventory + +This phase includes a cutover/migration (D-11, D-19), so the inventory applies to the WavePath cutover the plugin enables. + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | Six PHP tables `golem15_oauthserver_{clients,subjects,consents,auth_codes,access_tokens,auth_requests}` [VERIFIED: `updates/create_*_table.php` `Schema::create(...)` lines]; Winter `system_settings` item `golem15_oauthserver_settings` (branding) + its `system_files` logo, and client logos (`attachOne logo`) | Data migration: `oauthserver:import-winter` copies rows keeping ids and hashes, moves id sequences; branding settings + logo files are an extra import step (not in D-19's list — Open Question 8) | +| Live service config | Every relying party's registered issuer URL, cached discovery and cached JWKS (up to 1 h); RP-side client secrets (`oas_cs_…`) | None if issuer, kids and secret hashes carry over (D-11, D-19). Plan a JWKS cache window: do not rotate keys within 1 h of cutover | +| OS-registered state | PHP Laravel scheduler cron running `oauthserver:prune` daily; on-disk `storage/app/oauthserver` (0700) with `keys.json`, `.key`, `.pub` | Stop the PHP cron at cutover; import keys from key_dir (D-11); after verification, securely delete the PHP key dir (operator task) | +| Secrets/env vars | `OAUTHSERVER_ISSUER`, `OAUTHSERVER_LOGIN_URL`, `OAUTHSERVER_ROTATION_GRACE_HOURS`, `OAUTHSERVER_TTL_*`, `OAUTHSERVER_PICTURE_SIZE` [VERIFIED: `config/oauthserver.php`]; Go `app.key` now encrypts the private PEMs | Map to `golem15.oauthserver.*` config keys (env `SUMMER_GOLEM15__OAUTHSERVER__…`); document the mapping in the plugin README; `app.key` rotation must keep `app.previous_keys` | +| Build artifacts | None for OIDC (PHP plugin is source); Go: new plugin submodule pointer in the consuming app | Submodule add in the app (WavePath port milestone, out of scope here) | + +Live parked requests (10 min) and codes (60 s) at cutover are disposable; access tokens (1 h) keep working because hashes copy as-is (D-19 verified below). + +## D-19: hash compatibility — confirmed + +- PHP: `Client::hashSecret`, `AuthCode::hashSecret`, `AccessToken::hashSecret` all `return hash('sha256', $secret);` [VERIFIED: `models/Client.php:116`, `models/AuthCode.php:63`, `models/AccessToken.php:53`]. `hash()` with `binary=false` returns lowercase hex of SHA-256 over the raw bytes of the full string, **including** the `oas_cs_` / `oas_ac_` / `oas_at_` prefix. +- Go: `func sha256Hex(raw string) string { sum := sha256.Sum256([]byte(raw)); return hex.EncodeToString(sum[:]) }` [VERIFIED: `modules/wristband/crypto.go:26-29`]; `hex.EncodeToString` is lowercase. Byte-identical for any input string. +- Prefix constants to port verbatim [VERIFIED]: `public const SECRET_PREFIX = 'oas_cs_';` (`models/Client.php:32`), `public const CLIENT_ID_PREFIX = 'oas_';` (`models/Client.php:35`), `public const PREFIX = 'oas_ac_';` (`models/AuthCode.php:37`), `public const PREFIX = 'oas_at_';` (`models/AccessToken.php:29`). The prefix gate rejects a presented value without the prefix **before** hashing; keep it (configurable in OIDCOptions, set by the plugin). +- Client ids are `CLIENT_ID_PREFIX . bin2hex(random_bytes(12))`; new secrets `SECRET_PREFIX . base64url(random_bytes(32))` (PHP `rtrim(strtr(base64_encode(...), '+/', '-_'), '=')` = Go `base64.RawURLEncoding`). +- Consent CSRF digest `hash('sha256', $token . '|' . $userId)` [VERIFIED: `classes/ConsentCsrf.php:102`] → Go `sha256Hex(token + "|" + strconv.FormatUint(uint64(userID), 10))`. Imported `consent_token_hash` values stay valid for their 10-minute lifetime. + +## Common Pitfalls + +### Pitfall 1: Treating "one engine" as "one code path" +**What goes wrong:** MCP tests start failing, or OIDC redirects carry `iss`, RFC 3986 encoding, `realm="OAuth"`. +**How to avoid:** flavour orchestration functions; run the full existing wristband and fonoteka suites unchanged as the D-09 gate. +**Warning signs:** a diff to `authorize.go`/`token.go` MCP branches in the OIDC plan. + +### Pitfall 2: Query encoding of redirects +PHP `http_build_query` uses `urlencode` (space `+`, `~` → `%7E`, everything except `[A-Za-z0-9._-]` percent-encoded uppercase); `Location` is compared by tide. Port a `phpURLEncode` and an ordered builder; test `state` values with space, `~`, `+`, `/`, unicode. + +### Pitfall 3: Burn-then-check code redemption +PHP consumes the code before checking client/redirect/PKCE (a mismatched presentation burns it) and revokes tokens on replay. Wristband MCP validates first. A Go OIDC path that validates first passes "happy" tests but fails `test_replayed_code_revokes_the_token_previously_issued_from_it` and changes second-attempt behaviour. Replay revocation must commit even though the response is an error (return, don't roll back). + +### Pitfall 4: Cache-Control rewriting +Symfony changes `no-store` → `no-store, private` and `public, max-age=3600` → `max-age=3600, public`. Write those wire strings, then let tide confirm. + +### Pitfall 5: `aud` as array, header order, map claims +See Anti-Patterns. Use `jwt.MapClaims`; values `exp`/`iat` as `int64`. + +### Pitfall 6: Lazy first key under multiple instances +Two instances generate keys at once → two "active" keys, JWKS shows both, RPs fine, but ring state is inconsistent. Partial unique index + retry. Also generate at plugin migration time (PHP seeds an initial key, `updates/generate_initial_keys.php`) so the first sign-in doesn't pay RSA keygen. + +### Pitfall 7: `app.key` rotation bricks the ring +`lagoon.Encrypted` decrypts only with `app.key` or an `app.previous_keys` entry. Document in the plugin README; `oauthserver:rotate-keys` after an app-key change re-encrypts nothing (old keys stay old) — consider a re-encrypt step in prune or accept. + +### Pitfall 8: JWKS cached by RPs after rotation +New kid is unknown to an RP holding a ≤1 h stale JWKS. go-oidc refetches on unknown kid [ASSUMED from go-oidc RemoteKeySet behaviour]; some RPs don't. PHP had the same window. Optional improvement (out of scope): stage the next key in JWKS before activating it. + +### Pitfall 9: Soft-deleted users and the cascade +Go sm-user-plugin fires `user.Deleting` only from the admin hard-delete path (`FormAfterDelete` → `FireDeletingEvent` → `ForceDeleteCleanup` → unscoped delete) [VERIFIED: `controllers/users_admin_controller.go:513-532`, `classes/events.go:48-54`]. Soft delete (`DeactivateUsers`, "the PHP $user->delete()") fires nothing [VERIFIED: `classes/admin_actions.go:93-113`]. PHP revokes tokens/codes/consents on soft delete too. Needs an additive event in sm-user-plugin or a userinfo/token check that the user is not soft-deleted (Go `First` on a model with `gorm.DeletedAt` excludes soft-deleted rows, which already makes userinfo/token fail closed). Open Question 3. + +### Pitfall 10: sm-user-plugin version drift +The newest sm-user-plugin (with `user.Deleting`, 19-04) exists only in `fonoteka.go/plugins/golem15/user` at `c077029`; that checkout reports `ahead 3, behind 1` against its (possibly stale) `origin/master`; the other four apps pin `24b16b1` (2026-10-07) [VERIFIED: `git log`/`git status` this session]. sm-user-plugin is consumed by **five** apps (fonoteka.go, sm-bm-app, sm-demo-app, sm-grzybyfunkcjonalne-app, sm-summercmsio-app) [VERIFIED: `.gitmodules`], not two as D-05 says. Fetch and reconcile before the new plugin pins a version; every additive change must keep all five building. + +### Pitfall 11: Throttle key reads the form body +`r.FormValue("client_id")` in a Bucket Key parses the body before the handler. Fine for urlencoded bodies (Go caches `r.Form`), but put `body.limit:` on the token route and keep the OIDC token handler tolerant of an already-parsed form. + +### Pitfall 12: Route collisions under `/oauth/` +19.5 adds `/oauth/{provider}` and `/oauth/{provider}/callback` (google|facebook). Go 1.22+ ServeMux prefers the more specific literal pattern, and `/oauth/authorize/switch-account` (POST) vs `/oauth/{provider}/callback` (GET) do not overlap, so no conflict — but port `RouteGuardTest` both directions so a later wildcard can't shadow OIDC paths [ASSUMED: ServeMux precedence reasoning; test proves it]. + +### Pitfall 13: Duplicate query parameters +PHP takes the **last** value of a repeated parameter, Go `Query().Get` the **first**; PHP treats `state[]=` arrays as absent, Go sees a different key (also absent). Decide (match PHP last-wins, or reject duplicates with `invalid_request` per RFC 6749 §3.1) and test it. + +## Code Examples + +### JWK encoding (stdlib) +```go +// Recommendation. RFC 7518 §6.3.1 (n, e: Base64urlUInt) and §6.2.1 (x, y fixed length). +func jwkFor(kid, alg string, pub crypto.PublicKey) (map[string]string, error) { + b64 := base64.RawURLEncoding.EncodeToString + switch k := pub.(type) { + case *rsa.PublicKey: + return map[string]string{"kty": "RSA", "use": "sig", "alg": alg, "kid": kid, + "n": b64(k.N.Bytes()), "e": b64(big.NewInt(int64(k.E)).Bytes())}, nil + case *ecdsa.PublicKey: + raw, err := k.Bytes() // 0x04 || X(32) || Y(32) for P-256 + if err != nil || len(raw) != 65 { + return nil, fmt.Errorf("wristband: unsupported EC key") + } + return map[string]string{"kty": "EC", "use": "sig", "alg": alg, "kid": kid, + "crv": "P-256", "x": b64(raw[1:33]), "y": b64(raw[33:65])}, nil + } + return nil, fmt.Errorf("wristband: unsupported key type") +} +``` +Emit it through an ordered struct (kty, use, alg, kid, n, e / crv, x, y) if you want PHP's member order; tide ignores order either way. + +### id_token signing (golang-jwt v5) +```go +// Recommendation. Claims mirror PHP TokenIssuer::idToken. +func signIDToken(signer crypto.Signer, kid, alg, iss, sub, aud string, ttl time.Duration, + nonce, accessToken string, identity map[string]any, allowed []string, now time.Time) (string, error) { + claims := jwt.MapClaims{"iss": iss, "sub": sub, "aud": aud, // aud is a string, as in PHP + "exp": now.Add(ttl).Unix(), "iat": now.Unix()} + if nonce != "" { + claims["nonce"] = nonce + } + if accessToken != "" { + sum := sha256.Sum256([]byte(accessToken)) // RS256 and ES256 both use SHA-256 + claims["at_hash"] = base64.RawURLEncoding.EncodeToString(sum[:16]) + } + for name, v := range identity { + if slices.Contains(allowed, name) { + claims[name] = v + } + } + claims["sub"] = sub + method := jwt.SigningMethodRS256 + if alg == "ES256" { + method = jwt.SigningMethodES256 + } + tok := jwt.NewWithClaims(method, claims) + tok.Header["kid"] = kid + return tok.SignedString(signer) // *rsa.PrivateKey or *ecdsa.PrivateKey +} +``` +`jwt.NewWithClaims` sets `Header{"typ":"JWT","alg":…}` [VERIFIED: golang-jwt v5.3.1 `token.go:47-51`]. + +### Interop tracer (go-oidc + x/oauth2, test-only) +```go +// Recommendation: MVP tracer in modules/wristband (memory fakes) or the plugin (Postgres). +ctx := oidc.ClientContext(context.Background(), srv.Client()) // httptest server +provider, _ := oidc.NewProvider(ctx, srv.URL) // issuer == srv.URL, discovery fetched +conf := oauth2.Config{ClientID: id, ClientSecret: secret, Endpoint: provider.Endpoint(), + RedirectURL: "https://rp.example.test/callback", Scopes: []string{oidc.ScopeOpenID, "profile", "email"}} +verifier := oauth2.GenerateVerifier() +authURL := conf.AuthCodeURL("st", oauth2.S256ChallengeOption(verifier), oidc.Nonce("n-123")) +// drive authorize -> resume -> decide with a signed-in user cookie (fake resolver), read ?code= +tok, _ := conf.Exchange(ctx, code, oauth2.VerifierOption(verifier)) +raw, _ := tok.Extra("id_token").(string) +idt, err := provider.Verifier(&oidc.Config{ClientID: id}).Verify(ctx, raw) +// assert err == nil, idt.Nonce == "n-123", idt.VerifyAccessToken(tok.AccessToken) == nil, userinfo sub == idt.Subject +``` +`Verify` does not check the nonce; the test must [CITED: pkg.go.dev/github.com/coreos/go-oidc/v3/oidc]. x/oauth2 sends Basic credentials as `url.QueryEscape(id):url.QueryEscape(secret)` [VERIFIED: x/oauth2 v0.37.0 `internal/token.go:202`] — the OIDC token path must `url.QueryUnescape` both halves (PHP does `urldecode`). + +### Folded todo: empty Resource +Authorize compares `if resource != nil && *resource != s.opts.Resource` → `invalid_target` [VERIFIED: `modules/wristband/authorize.go:119-126`]. With `Resource == ""`, any client that sends `resource` gets `invalid_target`; clients that omit it are unaffected; the token-side check only compares against the stored value. So an empty default is safe for the framework and **breaks MCP clients that send `resource`** unless the host sets it. Fonoteka already sets it from merged plugin config: `resource: "https://mcp.plytarium.com/mcp"` under `oauth:` [VERIFIED: `fonoteka.go/plugins/golem15/fonoteka/config/config.yaml:4-12`], read as `golem15.fonoteka.oauth.resource` (`plugin.go:166-168`), and its assembled authorize test runs "against the real (config-driven) resource". Plan: set the default to `""`, reword the comments in `server.go:1-11,61-65,100`, `stores.go`, `client_issue.go` to "the host application", update the wristband README table, and add a fonoteka test asserting the booted server's resource (a Go-side default in fonoteka's `Boot` if config is missing is optional). The OIDC flavour ignores `resource` (PHP does). + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| `ecdsa.PublicKey.X/Y` big.Int handling for JWK | `PublicKey.Bytes()` / `ParseUncompressedPublicKey` | Go 1.25 (fields deprecated) [VERIFIED: `go doc crypto/ecdsa.PublicKey`] | No manual left-padding | +| third-party UUID | stdlib `uuid` (`NewV4`, `NewV7`) | Go 1.27 [VERIFIED: `go doc uuid`] | No new dep | +| `zitadel/oidc` as the decided OAuth stack (STACK.md, 2026-09-16) | hand-written wristband (Phase 8 note) | 2026-09-23 | The 19.4 note extends the supersession to OIDC | +| PKCE plain allowed | S256 only (OAuth 2.1 drafts; PHP and wristband already) | — | Keep | + +**Deprecated/outdated:** `jumbojett/openid-connect-php` is the PHP RP oracle; the Go equivalent is go-oidc + x/oauth2. + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | Recommend Option A (extend wristband) over zitadel/oidc v3 | D-16 | Decision note needs user sign-off; reversing later is costly (D-01) | +| A2 | JWK encoding via stdlib rather than go-jose v4 at runtime | D-16 / Stack | User preference may favour go-jose; low technical risk either way | +| A3 | go-oidc v3.21.0 and x/oauth2 v0.37.0 are legitimate upstream modules (seam can't check Go) | Legitimacy | Low; `checkpoint:human-verify` before `go get` | +| A4 | PHP `.key` files are PKCS#8 (`BEGIN PRIVATE KEY`) | Pattern 3 | Low: import parses PKCS#8 then PKCS#1 | +| A5 | php-jwt v7.1.0 header order `typ, alg, kid` | Contract map | None for RPs (order-insensitive) | +| A6 | Symfony wire values `no-store, private` and `max-age=3600, public` apply to these routes | Contract map | tide replay catches it; cheap fix | +| A7 | go-oidc refetches JWKS on an unknown kid | Pitfall 8 | Only affects rotation-window UX | +| A8 | ServeMux precedence keeps `/oauth/*` literals safe from 19.5 wildcards | Pitfall 12 | Ported RouteGuard test catches it | +| A9 | `communities` claim via read-only SQL on userfriends tables when they exist (no import of sm-userfriends-plugin) | Contract map / OQ 2 | Couples to another plugin's table names; alternative is always `[]` (parity break for WavePath) | +| A10 | Pairwise subs stored, not HMAC-derived; sector from redirect host or admin-set value, no remote sector_identifier_uri fetch | Pattern 5 | Spec-strictness for dynamically registered pairwise clients (none in 19.4: DCR is MCP-only) | +| A11 | OpenID Foundation conformance suite as optional extra oracle | D-16 | None (optional) | + +## Open Questions + +1. **How much MCP wiring does the plugin ship in 19.4 (D-02)?** No consumer exists until 19.7 (fonoteka keeps its adapter). The plugin's stores must implement `wristband.Backend`/`Tx` (needs a refresh-tokens table and an MCP access-token mint — PHP has neither). Recommendation: implement the MCP store methods plus a smoke test, mount `/oauth/mcp/*` + RFC 8414 metadata behind a config switch (off by default), and leave MCP access-token *verification* to 19.7. Confirm with the user. +2. **`communities` claim source.** PHP reads `Golem15\UserFriends\Models\GroupMember` when present. D-05 forbids a claims-provider interface. Options: read-only SQL against `golem15_userfriends_group_members` ⨝ `golem15_userfriends_groups` (`deleted_at IS NULL`) guarded by `Migrator().HasTable` [VERIFIED table names: `sm-userfriends-plugin/models/group_member.go:27`, `group.go:21`]; or a festival fire-and-collect event (like sm-user-plugin's `GetApiArrayEvent`) that sm-userfriends-plugin answers. Recommendation: the guarded SQL read (mirrors PHP's `class_exists` degrade-to-`[]`); confirm. +3. **Soft-delete cascade.** Add an additive `user.Deactivating` (or similar) event to sm-user-plugin's `DeactivateUsers`, or accept that soft-deleted users fail closed at token/userinfo (GORM soft-delete scope) while their consent/token rows linger until hard delete. PHP test `test_soft_delete_removes_consents_codes_and_tokens` expects the former. +4. **OIDC refresh (D-17) on the wire.** When disabled server-wide, behave byte-identically to PHP (`unsupported_grant_type` for `refresh_token`; discovery unchanged). When enabled: advertise `refresh_token`/`offline_access`; refresh response includes a fresh `id_token` without `nonce` (OIDC Core §12.2) [CITED: openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse]; a client without offline_access gets `unauthorized_client`? Recommendation: config flag default off; confirm error code. +5. **Pairwise sector identifier.** Admin-entered sector string vs fetching a `sector_identifier_uri` JSON (OIDC Core §8.1) through `fetchguard` PublicOnly. Recommendation: admin-entered value in 19.4, require it when redirect hosts differ. +6. **Where the interop test lives** (root module go.mod gains go-oidc/x-oauth2 test requires vs plugin repo). Recommendation: wristband keeps a memory-fake tracer using golang-jwt parse + a stdlib JWKS check; the go-oidc interop test lives in the plugin repo against Postgres. Confirm. +7. **Optional user resolution seam** (Pattern 4): sm-user-plugin publishes a resolver vs bouncer `Registry.Guard(name)`. Both additive; pick one. +8. **Branding settings + logos import** are not in D-19's six tables; include `system_settings` item `golem15_oauthserver_settings` and `system_files` attachments in `import-winter`? Recommendation: yes, optional flags. +9. **cabana widgets.** PHP uses `colorpicker`, `codeeditor`, `checkboxlist`; cabana's form types are `text, textarea, number, checkbox, switch, dropdown, relation, relation-manager, widget, partial, fileupload, datepicker, password, permissioneditor, markdown, mltext, mltextarea, mlmarkdown, richeditor, repeater, money, taglist` [VERIFIED: `modules/cabana/form_schema.go:24-30`]. Use `text` (validated hex) for colours, `textarea` for custom CSS, `taglist`/`checkbox` per scope — or add widgets to cabana (scope creep). Recommendation: stock types. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Go toolchain | all code | ✓ | go1.27.2 | — | +| Docker daemon | testcontainers Postgres/Redis tests | ✓ | 29.9.1 (server reachable) | `-short` skips | +| psql / redis-cli | manual checks | ✓ | — | — | +| PHP CLI | running WavePath for tide recording | ✓ | 8.5.11 | — | +| MariaDB client | WavePath DB / `winter-export.sql` | ✓ (`mariadb`, `mysql`) | — | — | +| WavePath app (local, with `.env`) | tide recording of the PHP contract | present on disk; running state unknown | — | Manual `checkpoint:human-action` to start it; PHP feature tests as fallback spec | +| WavePath `storage/app/oauthserver` key_dir | key import test with real files | ✗ (mode 0700, not readable by this user) | — | Generate PHP-shaped fixtures with `openssl` in tests | +| Module proxy (network) | `go get` test deps | ✓ (go list worked) | — | — | + +**Missing dependencies with no fallback:** none blocking code; the tide recording needs a human to start WavePath locally. +**Missing dependencies with fallback:** real key_dir → synthetic fixtures. + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Go `testing` (+ `net/http/httptest`, testcontainers-go v0.44.0 for Postgres) | +| Config file | none; plugin needs `testmain_test.go` (pattern: `sm-pgstripe-plugin/testmain_test.go`, `flag.Parse()` + `testing.Short()`) | +| Quick run command | `go test -short ./modules/wristband/...` and, in the plugin repo, `go test -short ./...` | +| Full suite command | `go test ./...` (root) + `cd ../sm-oauthserver-plugin && go test ./...` + `cd ../fonoteka.go && go test ./...` (D-09 gate) | + +### Phase Requirements → Test Map +| Req | Behavior | Test Type | Automated Command | File Exists? | +|-----|----------|-----------|-------------------|-------------| +| SC-1 | Decision note exists and names deps | gate script | `scripts/check-phase19.4.sh` greps the note | ❌ Wave 0 | +| SC-2 | Discovery fields/values; JWKS RS256+ES256; rotation shows old+new; id_token claims; at_hash; nonce | unit + interop | `go test -short ./modules/wristband -run 'TestOIDC(Discovery|JWKS|IDToken)'`; plugin `go test ./... -run TestInterop` | ❌ | +| SC-2 | go-oidc verifies id_token from both RS256 and ES256 rings | interop | plugin `go test ./... -run TestRelyingPartyInterop` | ❌ | +| SC-3 | start/resume/silent/decide/deny/switch; CSRF one-shot + user-bound; consent merge; authorizations list/revoke; pairwise vs public sub; prune counts | unit + Postgres smoke | `go test ./... -run 'Test(Authorize|Consent|Switch|Authorizations|Subject|Prune)'` | ❌ | +| SC-4 | Import copies six tables + keys with original kids; a pre-cutover access token works at userinfo; pre-cutover id_token verifies against the Go JWKS | Postgres smoke | plugin `go test ./console -run TestImportWinter` | ❌ | +| SC-4 | tide replay of recorded PHP flows | parity | `summer parity:replay …` (fixtures under the plugin `testdata/parity/`) | ❌ (needs recording checkpoint) | +| D-09 | fonoteka compiles and passes unchanged | regression | `cd ../fonoteka.go && go vet ./... && go test -short ./...` | ✅ | +| D-09 | MCP wristband suite unchanged | regression | `go test -short ./modules/wristband/...` | ✅ | +| D-18 | Bucket keys per PHP | unit | `go test -run TestBuckets` | ❌ | +| SC-5 | README/docs identifiers | docs checker | `summer docs:build --check` / `go test ./cmd/summer -run TestDocsTree` | ✅ | + +**PHP test map (port case by case in the last plan):** 13 PHP files, ~150 cases [VERIFIED: grep of `tests/`]: feature/AuthorizeStartTest (11), ClientRegistryTest (12), ConsentOnceTest (6), DiscoveryEndpointTest (4), IncrementalConsentTest (4), JumbojettInteropTest (1 → go-oidc), RelyingPartyFlowTest (5), RevokeAuthorizationTest (9), TokenEndpointTest (10), UnconfiguredSiteTest (3), UserInfoEndpointTest (8); security/ConsentCsrfTest (9), PkceAndReplayTest (6), RedirectUriMatchTest (11), RouteGuardTest (9), UserDeleteCascadeTest (6), ZeroEditAdditivityTest (2 → "no app vocabulary" grep + "sm-user-plugin changes additive" check); unit/AuthCodeLifecycleTest (5), ClaimBuilderTest (12), DiscoveryDocumentTest (3), KeyRingTest (6), PkceVerifierTest (9), PruneExpiredTest (5), SubjectStabilityTest (5 → `test_sub_is_public_not_pairwise` becomes "public by default; pairwise opt-in"), TokenIssuerTest (10). New Go-only cases: ES256 ring, mixed RS256+ES256 JWKS, pairwise subs, kind isolation (DCR client rejected at OIDC authorize/token/userinfo; OIDC client rejected at MCP), refresh opt-in, DB key ring multi-instance race, lagoon decrypt with previous key. + +### Sampling Rate +- **Per task commit:** `go vet ./... && go test -short ./modules/wristband/...` (+ plugin `go test -short ./...`) +- **Per wave merge:** full suites incl. Docker, fonoteka regression +- **Phase gate:** `scripts/check-phase19.4.sh --all` green (fail-closed like 19.3's gate) before `/msd-verify-work` + +### Wave 0 Gaps +- [ ] New repo `sm-oauthserver-plugin` with `go.mod`, `go.work` (replace summercms → `../summercms.go`, sm-user-plugin → the newest checkout), `testmain_test.go` +- [ ] Decision note `.planning/notes/2026-10-XX-oidc-on-wristband-golang-jwt.md` +- [ ] `scripts/check-phase19.4.sh` (named-test detector, like 19.3) +- [ ] `checkpoint:human-verify` before adding go-oidc/x-oauth2 +- [ ] `checkpoint:human-action`: start local WavePath for tide recording + +## Security Domain + +`security_enforcement` is not set in `.planning/config.json` (absent = enabled). Target ASVS L1, with a dedicated security review task. + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes (client auth) | SHA-256 hex + `subtle.ConstantTimeCompare`; dummy compare for unknown clients; prefix gates | +| V3 Session Management | yes | bouncer `token` cookie (19.3 attributes); switch-account blacklists jti + clears cookie | +| V4 Access Control | yes | kind isolation (D-08); authorizations scoped to the JWT user; 404 for foreign rows | +| V5 Input Validation | yes | allowlists (response_type, S256, scopes), regex for challenge/verifier, 255-char caps, exact redirect matching, array/duplicate params | +| V6 Cryptography | yes | golang-jwt RS256/ES256; RSA 2048 / P-256 keygen via `crypto/rand`; AES-256-GCM `lagoon.Encrypted` | +| V7 Errors/Logging | yes | log client_id + IP + fixed reason only; never codes/tokens/secrets/keys | +| V8 Data Protection | yes | `no-store` on token/userinfo/consent; claims allowlist; sub never derived from account data | +| V13 API | yes | RFC 6749/6750 error shapes; userinfo uniform 401 | +| V14 Configuration | yes | consent page headers (XFO DENY, frame-ancestors 'none', no-referrer); issuer required | + +### Known Threat Patterns (seed list for T-19.4-xx) + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Open redirect / code leak via unverified redirect_uri | Spoofing/Info disclosure | Render, never redirect, for unknown client or mismatch; exact match; loopback-port rule only for `is_dev` | +| Code interception | Spoofing | Mandatory S256 PKCE; code bound to client + redirect; 60 s TTL; single use | +| Code replay | Tampering | Atomic consume; replay revokes tokens issued from the code (commit) | +| Consent CSRF / session fixation across login handoff | Spoofing | One-shot digest `sha256(token|userId)`; request has no user column | +| Clickjacking consent | Tampering | XFO DENY + CSP frame-ancestors | +| Client enumeration | Info disclosure | Uniform invalid_client; dummy compare; `unauthorized_client`-free redirects; throttles | +| Secret brute force | Spoofing | 20/min per client_id bucket; 256-bit secrets | +| Token substitution across RPs | Spoofing | `aud` = client_id; nonce echo; at_hash | +| Algorithm confusion / `alg:none` | Tampering | Server signs only; RPs given exact `id_token_signing_alg_values_supported`; JWK carries `alg` | +| Private key exfiltration | Info disclosure | DB-encrypted PEM, never logged, never in cabana fields or JSON (`Encrypted.MarshalJSON` redacts) | +| Key rotation outage | DoS | Grace window, retired keys published until prune | +| Branding CSS injection | Tampering | Port `Settings::css/color/fontFamily/url` sanitisers; `html/template` with `template.CSS` only after sanitising; https-only font URL | +| Identity scope escalation via DCR | Elevation | Kind isolation; DCR MCP-only | +| Cross-user revoke | Elevation | Lock + filter by JWT user; 404 otherwise | +| Credentials at rest in limiter keys | Info disclosure | Hash bearer in userinfo key | + +## Suggested plan shape (for the lean-planning confirmation) + +MVP mode: first deliver a thin tracer — discovery + JWKS + one authorize→resume→decide→token round trip whose `id_token` the RP library verifies — then widen. + +1. **19.4-01 — Decision note + wristband OIDC engine (summercms.go).** Roadmap corrections; SC-1 note; flavour split, new interfaces, discovery/JWKS/id_token/userinfo, resume/decide/switch operations, PHP encoders, pairwise support, kind check, neutral `Resource` default (+ fonoteka assertion); memory-fake tracer test; README + `docs/services/oauth-server.md`. +2. **19.4-02 — sm-oauthserver-plugin core.** New repo, migrations (six tables + keys + refresh tokens + new client columns: kind, subject_type, sector, offline_access, token auth method), GORM stores (MCP + OIDC), DB key ring, routes/buckets/cors, consent + error HTML with branding and CSRF, authorizations JSON, cascade, additive sm-user-plugin exports (resolver, avatar URL, groups, deactivation event), go-oidc interop test against Postgres. +3. **19.4-03 — Admin, commands, import, parity, security review.** cabana Clients + Settings screens, `rotate-keys`, `prune` + schedule, `import-winter` (+ key_dir, + settings/logos), tide record/replay (human checkpoint), security review (ASVS L1 / T-19.4-xx). +4. **19.4-04 — Unit tests (last plan).** PHP test map port + Go-only cases; `check-phase19.4.sh`. + +## Sources + +### Primary (HIGH confidence) +- PHP plugin source read this session: `/media/nvme/dev/jz/wavepath.org/plugins/golem15/oauthserver/` — `routes.php`, `config/oauthserver.php`, `Plugin.php`, `controllers/oidc/*.php`, `classes/*.php`, `models/*.php`, `updates/*.php`, `console/*.php`, `models/settings/fields.yaml`, `models/client/{fields,columns}.yaml`, `tests/**` (case names) +- wristband source: `modules/wristband/{server,stores,crypto,client_issue,authorize,consent,token,redirect_html}.go`, README +- `modules/lagoon/encrypted.go`, `laravel_decrypt.go`; `modules/surf/limiter.go`; `modules/bouncer/{registry,jwt,context,blacklist}.go`, README; `modules/pact/capabilities.go`; `modules/tide/diff.go`, README; `modules/cabana/form_schema.go` +- fonoteka: `classes/auth/oauth_store.go`, `plugin.go`, `config/config.yaml`; sm-user-plugin (fonoteka checkout): `classes/events.go`, `classes/admin_actions.go`, `controllers/users_admin_controller.go`, `models/user.go`, `routes.go`, `plugin.go`; sm-userfriends-plugin models +- Module sources in GOMODCACHE: golang-jwt v5.3.1 (`token.go`, `types.go`, `ecdsa.go`, `rsa.go`), zitadel/oidc v3.51.16 (`pkg/op/storage.go`, `discovery.go`, `client.go`, go.mod), go-oidc v3.21.0 (`oidc/verify.go`, `oidc/oidc.go`, go.mod), x/oauth2 v0.37.0 (`internal/token.go`) +- `go list -m -versions/-json` (proxy.golang.org) for all candidate modules; `go doc uuid`, `go doc crypto/ecdsa.PublicKey` + +### Secondary (MEDIUM confidence) +- pkg.go.dev: github.com/coreos/go-oidc/v3/oidc, golang.org/x/oauth2 (API identifiers) +- `.planning/notes/oauth2-wristband-direct-port.md`, `.planning/research/STACK.md`, STATE.md Phase 08 P09 decisions (Laravel Cache-Control behaviour) + +### Tertiary (LOW confidence) +- Training knowledge: php-jwt header order, OpenSSL PEM export format, go-oidc JWKS refetch on unknown kid, OIDC conformance suite usage + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — versions from the module proxy; golang-jwt already in go.mod and used for ES256 in-tree +- Architecture: MEDIUM-HIGH — seams verified in code; the flavour split is a recommendation +- PHP contract: HIGH — read line by line; wire-level Symfony rewrites MEDIUM until tide recording +- Pitfalls: HIGH for code-verified ones (cascade gap, Resource, aud array, cabana widgets); MEDIUM for RP-cache behaviour + +**Research date:** 2026-10-19 +**Valid until:** 2026-11-18 (30 days; re-check go-oidc/x-oauth2 versions and the sm-user-plugin state before planning if later) diff --git a/.planning/phases/19.4-oidc-provider-on-wristband/19.4-VALIDATION.md b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-VALIDATION.md new file mode 100644 index 00000000..0489592f --- /dev/null +++ b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-VALIDATION.md @@ -0,0 +1,84 @@ +--- +phase: "19.4" +slug: "oidc-provider-on-wristband" +# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6) +# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117) +status: draft +nyquist_compliant: false +wave_0_complete: false +created: "2026-10-19" +--- + +# Phase 19.4 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Go `testing` + `net/http/httptest`; testcontainers-go v0.44.0 (Postgres) | +| **Config file** | none in framework; plugin repo gets `testmain_test.go` (`flag.Parse()` + `testing.Short()`, pattern of `sm-pgstripe-plugin`) | +| **Quick run command** | `go vet ./... && go test -short ./modules/wristband/...` (plugin repo: `go test -short ./...`) | +| **Full suite command** | `go test ./...` + `cd ../sm-oauthserver-plugin && go test ./...` + `cd ../fonoteka.go && go test ./...` (D-09 gate) | +| **Estimated runtime** | ~30 s quick, ~180 s full (Docker) | + +--- + +## Sampling Rate + +- **After every task commit:** Run the quick command for the repo the task touched +- **After every plan wave:** Run the full suite command, including the fonoteka regression +- **Before `/msd-verify-work`:** `scripts/check-phase19.4.sh --all` and the full suite must be green +- **Max feedback latency:** 60 seconds + +--- + +## Per-Task Verification Map + +Filled in by the planner per task; seed rows from RESEARCH.md "Phase Requirements → Test Map": + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 19.4-01-xx | 01 | 1 | SC-1 | — | decision note names deps | gate script | `scripts/check-phase19.4.sh` | ❌ W0 | ⬜ pending | +| 19.4-01-xx | 01 | 1 | SC-2 | T-19.4-xx | id_token signed RS256/ES256, aud=client_id, nonce, at_hash | unit | `go test -short ./modules/wristband -run 'TestOIDC(Discovery\|JWKS\|IDToken)'` | ❌ | ⬜ pending | +| 19.4-01-xx | 01 | 1 | D-09 | — | MCP flow unchanged | regression | `go test -short ./modules/wristband/... && (cd ../fonoteka.go && go vet ./... && go test -short ./...)` | ✅ | ⬜ pending | +| 19.4-02-xx | 02 | 2 | SC-2 | T-19.4-xx | RP library verifies id_token | interop | plugin `go test ./... -run TestRelyingPartyInterop` | ❌ | ⬜ pending | +| 19.4-02-xx | 02 | 2 | SC-3 | T-19.4-xx | CSRF one-shot, kind isolation, cross-user revoke 404 | Postgres smoke | plugin `go test ./... -run 'Test(Authorize\|Consent\|Switch\|Authorizations\|Subject)'` | ❌ | ⬜ pending | +| 19.4-03-xx | 03 | 3 | SC-3/SC-4 | T-19.4-xx | prune, rotate, import keeps kids and tokens | Postgres smoke | plugin `go test ./console -run 'Test(Prune\|RotateKeys\|ImportWinter)'` | ❌ | ⬜ pending | +| 19.4-04-xx | 04 | 4 | SC-5 | — | PHP test map ported | unit | `go test ./...` (both repos) + `go test ./cmd/summer -run TestDocsTree` | ❌ | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] New repo `sm-oauthserver-plugin` with `go.mod`, `go.work`/replace to `../summercms.go`, `testmain_test.go` +- [ ] Decision note `.planning/notes/2026-10-XX-oidc-on-wristband-golang-jwt.md` +- [ ] `scripts/check-phase19.4.sh` (named-test detector, fail-closed like 19.3) +- [ ] Human verification before adding `coreos/go-oidc/v3` and `golang.org/x/oauth2` (test-only) + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| tide recording of the PHP OIDC contract | SC-4 / D-14 | needs the local WavePath app running | start WavePath locally, run the recording, commit fixtures under the plugin `testdata/parity/` | +| Consent/error HTML honours branding | D-03 | visual | open the consent page with branding settings set and compare against PHP | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 60s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending