From 49e772afb8503401ecdd05731e92be52fb233dd1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 19 Oct 2026 18:35:37 +0200 Subject: [PATCH] docs(19.4-01): complete OIDC provider on wristband plan --- .../19.4-01-SUMMARY.md | 288 ++++++++++++++++++ .../deferred-items.md | 6 + 2 files changed, 294 insertions(+) create mode 100644 .planning/phases/19.4-oidc-provider-on-wristband/19.4-01-SUMMARY.md create mode 100644 .planning/phases/19.4-oidc-provider-on-wristband/deferred-items.md diff --git a/.planning/phases/19.4-oidc-provider-on-wristband/19.4-01-SUMMARY.md b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-01-SUMMARY.md new file mode 100644 index 00000000..a8d00ce0 --- /dev/null +++ b/.planning/phases/19.4-oidc-provider-on-wristband/19.4-01-SUMMARY.md @@ -0,0 +1,288 @@ +--- +phase: 19.4-oidc-provider-on-wristband +plan: 01 +subsystem: auth +tags: [oidc, oauth, wristband, golang-jwt, jwks, rs256, es256, pkce, consent, pairwise] + +requires: + - phase: 08 + provides: hand-written wristband MCP authorization server (PKCE, hashed secrets, redirect writer, refresh lineage) + - phase: 19.3 + provides: bouncer cookie transport (used by plan 02's OIDCHost) +provides: + - wristband OIDC flavour (NewOIDCServer) next to the unchanged MCP flavour + - OIDCBackend/OIDCTx, KeyStore and OIDCHost seams for plan 02's sm-oauthserver-plugin + - Discovery, JWKS, Authorize (start + decide), Resume, SwitchAccount, Token with id_token, UserInfo + - ConsentRequest/DecideConsent JSON-mode operations + - ClientRecord.Kind with ClientKindMCP/ClientKindOIDC isolation + - RS256 + ES256 keys, pairwise subjects, opt-in OIDC refresh + - neutral wristband DefaultOptions().Resource (folded todo) + - SC-1 decision note (golang-jwt v5, zitadel rejected) +affects: [19.4-02, 19.4-03, 19.4-04, 19.7, fonoteka] + +actuals: + tokens: 48000 + tasks: 4 + commits: 6 +plan_head_before: 95051d26cef7e9fd9e06312501df1a55f2865927 +plan_head_after: 811124259c1b1e9c0867e58601d7d6cc41aabcc1 + +tech-stack: + added: [] + patterns: + - "One engine, two flavours: Server.flavour dispatch at the top of Authorize and Token; flavour-only handlers answer 404 on the other flavour" + - "Protocol errors commit: the OIDC transaction closure returns nil with an outcome so burns and revocations persist" + - "Host render seam: engine sets security headers, host writes status and body" + +key-files: + created: + - .planning/notes/2026-10-19-oidc-on-wristband-golang-jwt.md + - modules/wristband/oidc_options.go + - modules/wristband/oidc_stores.go + - modules/wristband/oidc_keys.go + - modules/wristband/oidc_discovery.go + - modules/wristband/oidc_authorize.go + - modules/wristband/oidc_consent.go + - modules/wristband/oidc_token.go + - modules/wristband/oidc_userinfo.go + - modules/wristband/oidc_redirect.go + - modules/wristband/oidc_subject.go + - modules/wristband/oidc_fakes_test.go + - modules/wristband/oidc_tracer_test.go + - modules/wristband/oidc_authorize_smoke_test.go + - modules/wristband/oidc_token_smoke_test.go + - ../fonoteka.go/plugins/golem15/fonoteka/oauth_resource_test.go + modified: + - modules/wristband/server.go + - modules/wristband/stores.go + - modules/wristband/authorize.go + - modules/wristband/token.go + - modules/wristband/register.go + - modules/wristband/client_issue.go + - modules/wristband/example_test.go + - modules/wristband/README.md + - README.md + - docs/services/oauth-server.md + - .planning/research/STACK.md + - ../fonoteka.go/plugins/golem15/fonoteka/plugin.go + +key-decisions: + - "D-16: extend the hand-written wristband; golang-jwt/jwt/v5 v5.3.1 signs id_tokens via MapClaims (aud stays a string); stdlib JWK encoding; zitadel/oidc v3 rejected; go-oidc v3.21.0 and x/oauth2 v0.37.0 test-only in sm-oauthserver-plugin" + - "OIDC redirects send Cache-Control: no-store, private as the plan specifies; the PHP provider sends Laravel's default no-cache, private on these redirects, so plan 03's tide replay must accept or settle that diff" + - "ConsentView carries RedirectHost and Incremental besides the planned fields, because the PHP consent page shows both" + - "SwitchAccount without a signed-in user redirects to the resume path (PHP behaviour) instead of rendering expired_request" + - "Discovery and JWKS 500 bodies are {\"message\": ...} (Laravel abort shape) with the PHP message texts" + - "Pairwise sector errors (ErrSectorRequired) at resume, token or userinfo answer 500; plan 03 adds the admin save-time check" + +patterns-established: + - "OIDC flavour code lives in oidc_*.go; MCP files change only at the flavour dispatch and the Kind checks" + - "PHP query semantics: lastValue (parse_str last wins), inputValue (body over query)" + +requirements-completed: [SC-1, SC-2, SC-3, SC-5, D-01, D-04, D-06, D-07, D-08, D-09, D-12, D-14, D-15, D-16, D-17] + +coverage: + - id: D1 + description: "SC-1 decision note names golang-jwt v5, the test-only RP modules and rejects zitadel/oidc, committed before the first OIDC code; STACK.md row superseded" + requirement: SC-1 + verification: + - kind: other + ref: "git merge-base --is-ancestor ; grep checks on the note and STACK.md" + status: pass + human_judgment: false + - id: D2 + description: "Relying party flow end to end over memory fakes: discovery, JWKS, authorize, resume, consent, code exchange, id_token verified against the served JWKS, userinfo" + requirement: SC-2 + verification: + - kind: integration + ref: "modules/wristband/oidc_tracer_test.go#TestOIDCTracerSmoke" + status: pass + human_judgment: false + - id: D3 + description: "Authorize start, resume, silent and incremental consent, one-shot consent token, deny, switch account, PHP redirect encoding, is_dev loopback ports" + requirement: SC-3 + verification: + - kind: unit + ref: "modules/wristband/oidc_authorize_smoke_test.go#TestOIDCAuthorizeStartSmoke" + status: pass + - kind: unit + ref: "modules/wristband/oidc_authorize_smoke_test.go#TestOIDCResumeDecideSwitchSmoke" + status: pass + - kind: unit + ref: "modules/wristband/oidc_authorize_smoke_test.go#TestPHPRedirectEncodingSmoke" + status: pass + human_judgment: false + - id: D4 + description: "Kind isolation in both flavours; MCP still refuses openid; flavour-only handlers 404" + requirement: D-08 + verification: + - kind: unit + ref: "modules/wristband/oidc_authorize_smoke_test.go#TestKindIsolationSmoke" + status: pass + human_judgment: false + - id: D5 + description: "Token errors, burn-then-check replay revocation, uniform userinfo 401, mixed RS256/ES256 ring, pairwise subjects, opt-in refresh" + requirement: SC-2 + verification: + - kind: unit + ref: "modules/wristband/oidc_token_smoke_test.go#TestOIDCTokenErrorsSmoke" + status: pass + - kind: unit + ref: "modules/wristband/oidc_token_smoke_test.go#TestOIDCReplayRevokesSmoke" + status: pass + - kind: unit + ref: "modules/wristband/oidc_token_smoke_test.go#TestOIDCUserInfoSmoke" + status: pass + - kind: unit + ref: "modules/wristband/oidc_token_smoke_test.go#TestMixedKeyRingSmoke" + status: pass + - kind: unit + ref: "modules/wristband/oidc_token_smoke_test.go#TestPairwiseSubjectSmoke" + status: pass + - kind: unit + ref: "modules/wristband/oidc_token_smoke_test.go#TestOIDCRefreshOptInSmoke" + status: pass + human_judgment: false + - id: D6 + description: "Neutral DefaultOptions().Resource; fonoteka sets its resource explicitly and keeps its MCP behaviour (D-09)" + requirement: D-09 + verification: + - kind: unit + ref: "modules/wristband/server_test.go#TestDefaultOptionsResourceEmpty" + status: pass + - kind: integration + ref: "../fonoteka.go/plugins/golem15/fonoteka/oauth_resource_test.go#TestOAuthResourceExplicit (Docker, real Postgres)" + status: pass + human_judgment: false + - id: D7 + description: "wristband README and docs/services/oauth-server.md describe the OIDC flavour; every identifier exists" + requirement: SC-5 + verification: + - kind: other + ref: "go test ./cmd/summer -run TestDocsTree; go run ./cmd/summer docs:build --check" + status: pass + human_judgment: true + rationale: "The docs checker proves identifiers and links; whether the OIDC section reads well for a host author is a reader judgment" + +duration: 28min +completed: 2026-10-19 +status: complete +--- + +# Phase 19.4 Plan 01: OIDC provider on wristband Summary + +**wristband gains an OpenID Connect flavour on its existing engine: PHP-compatible discovery, JWKS (RS256 and ES256), resumable authorize with one-shot consent and account switching, token with an id_token signed through golang-jwt map claims, userinfo, pairwise subjects and opt-in refresh, behind new OIDCBackend, KeyStore and OIDCHost seams; the MCP flavour is unchanged and its default resource is now neutral.** + +## Performance + +- **Duration:** 28 min +- **Started:** 2026-10-19T16:05:54Z +- **Completed:** 2026-10-19T16:34:15Z +- **Tasks:** 4 +- **Files modified:** 34 (32 in summercms.go, 2 in fonoteka.go) + +## Accomplishments + +- SC-1 decision note on record before the code: extend wristband, golang-jwt v5.3.1 signs, stdlib JWKs, go-oidc and x/oauth2 test-only in the plugin repo, zitadel/oidc v3 rejected (D-06, D-09, D-14). +- `wristband.NewOIDCServer` builds a second `*Server` on the same engine; `Authorize` and `Token` dispatch by flavour before any MCP line runs; MCP-only handlers 404 on OIDC and the reverse. +- PHP contract ported: validation order and messages, rendered local errors with the four security headers, login handoff with only `redirect=`, silent and incremental consent, deny, switch account, PHP `http_build_query` redirects without `iss`, Basic-or-form client auth with the lowercase realm, burn-then-check codes with replay revocation, uniform userinfo 401. +- Keys: RS256 and ES256 generation and parsing (PKCS#8, PKCS#1, SEC1), stdlib JWK encoding, mixed rings with retired keys inside the grace window, discovery advertising what the ring holds. +- Subjects: lazy random UUIDv4 per (user, sector), public by default, pairwise by sector identifier or single redirect host. +- Opt-in OIDC refresh (`OIDCOptions.RefreshEnabled`), only for `OfflineAccess` clients granted `offline_access`; rotation and lineage revocation. +- Folded todo closed: `DefaultOptions().Resource` is empty, framework comments name no application, fonoteka sets its resource explicitly. + +## Task Commits + +1. **Task 1 (tracer), planning doc:** `756a97c` docs(19.4): decide OIDC on wristband with golang-jwt (roadmap corrections were already in the plan-creation commit, so no separate roadmap commit) +2. **Task 1 (tracer), code:** `659b42a` feat(19.4-01): OIDC flavour of wristband with an end-to-end tracer +3. **Task 2:** `dfa3c3e` feat(19.4-01): OIDC authorize, consent and switch account match the PHP provider +4. **Task 3:** `e25e733` feat(19.4-01): OIDC token, keys, subjects and opt-in refresh +5. **Task 4, framework:** `ddda59a` feat(19.4-01): neutral wristband resource default and OIDC provider docs +6. **Task 4, fonoteka.go:** `687e25a` fix(fonoteka): set the OAuth resource explicitly (in `../fonoteka.go`, only `plugin.go` and `oauth_resource_test.go` staged) +7. **Task 4, todo:** `8111242` docs(19.4): close neutral wristband resource todo + +**Plan metadata:** this SUMMARY commit (docs(19.4-01)). + +## Exported identifiers (for plan 02) + +Constructors and handlers: `NewOIDCServer(opts Options, oidc OIDCOptions) *Server`, `(*Server).SetOIDCBackend(b OIDCBackend, keys KeyStore, host OIDCHost) error`, `(*Server).Discovery`, `(*Server).JWKS`, `(*Server).Authorize` (OIDC: GET start, POST decide), `(*Server).Resume` (reads `r.PathValue("request")`), `(*Server).SwitchAccount`, `(*Server).Token`, `(*Server).UserInfo`, `(*Server).ConsentRequest(ctx, requestID string, userID uint) (ConsentView, error)`, `(*Server).DecideConsent(ctx, requestID string, userID uint, consentToken string, allow bool) (string, error)`. + +Options: `OIDCOptions{LoginURL, IDTokenTTL, AccessTokenTTL, CodeTTL, AuthRequestTTL, RefreshEnabled, RefreshTokenTTL, ScopesSupported, ClaimsSupported, IdentityClaims, SubjectTypesSupported, ClientIDPrefix, ClientSecretPrefix, CodePrefix, AccessTokenPrefix, RefreshTokenPrefix}`, `DefaultOIDCOptions()`. + +Host seam: `OIDCHost{SignedInUser(r) (uint, bool, error); RenderAuthorizeError(w, r, code string); RenderConsent(w, r, view ConsentView); EndSession(w, r) error}`, `ConsentView{RequestID, ConsentToken, ClientID, ClientName, Scopes, UserID, RedirectHost, Incremental}`, `AuthorizeErrorUnknownClient`, `AuthorizeErrorRedirectMismatch`, `AuthorizeErrorInactiveClient`, `AuthorizeErrorExpiredRequest`, `ErrAuthRequestExpired`. + +Storage seam: `OIDCBackend{WithinOIDCTx(ctx, fn func(OIDCTx) error) error}`; `OIDCTx` = `OIDCClientStore{OIDCClientByClientID}` + `AuthRequestStore{CreateAuthRequest, AuthRequestByRequestID, AuthRequestByRequestIDForUpdate, ConsumeAuthRequest(ctx, id, now) (bool, error), SetConsentTokenHash(ctx, id, *string)}` + `OIDCCodeStore{CreateCode, ConsumeCode(ctx, hash, now) (*OIDCCodeRecord, error), CodeByHash}` + `OIDCAccessTokenStore{CreateAccessToken, AccessTokenByHash, TouchAccessToken(ctx, id, now), RevokeAccessTokensForCode(ctx, codeID, now)}` + `ConsentStore{ConsentFor, SaveConsent(ctx, userID, clientID, scopes, now), TouchConsent(ctx, id, now)}` + `SubjectStore{SubjectFor(ctx, userID, sector, candidate)}` + `IdentityStore{IdentityClaims(ctx, userID, scopes) (map[string]any, bool, error)}` + `OIDCRefreshTokenStore{CreateRefreshToken, RefreshTokenByHashForUpdate, MarkRefreshRotated(ctx, id, successorID), RevokeRefreshLineage(ctx, startID, now)}`. Records: `OIDCClientRecord`, `AuthRequestRecord`, `OIDCCodeRecord`, `OIDCAccessTokenRecord`, `OIDCRefreshTokenRecord`, `ConsentRecord` (fields as planned). Create methods fill `rec.ID`; not found is `(nil, nil)`; `SaveConsent` stamps `LastUsedAt`; `RevokeAccessTokensForCode` also revokes refresh tokens linked to those access tokens. + +Keys: `KeyStore{ActiveSigningKey(ctx) (SigningKey, error); PublishedKeys(ctx, now) ([]PublicKey, error)}`, `SigningKey{Kid, Alg, Signer}` (Signer must be `*rsa.PrivateKey` or P-256 `*ecdsa.PrivateKey`), `PublicKey{Kid, Alg, Key}`, `AlgRS256`, `AlgES256`, `GenerateSigningKey(alg)`, `ParsePrivateKeyPEM`, `ParsePublicKeyPEM`. + +Kinds and subjects: `ClientRecord.Kind`, `ClientKindMCP`, `ClientKindOIDC`, `SubjectTypePublic`, `SubjectTypePairwise`, `SubjectSector(c *OIDCClientRecord) (string, error)`, `ErrSectorRequired`, `IssueOIDCClientCredentials(o OIDCOptions) (clientID, secret, secretHash string, err error)`. + +## Files Created/Modified + +- `modules/wristband/oidc_options.go` - OIDCOptions, NewOIDCServer, SetOIDCBackend, OIDCHost, ConsentView, error codes, IssueOIDCClientCredentials +- `modules/wristband/oidc_stores.go` - OIDCBackend, OIDCTx and the eight component stores, record types +- `modules/wristband/oidc_keys.go` - KeyStore, key generation and parsing, JWK encoding, id_token signing, at_hash +- `modules/wristband/oidc_discovery.go` - Discovery and JWKS handlers +- `modules/wristband/oidc_authorize.go` - start, Resume, decide handler, validation, PHP query helpers +- `modules/wristband/oidc_consent.go` - consent token, prepareConsent, decideConsent, ConsentRequest, DecideConsent, SwitchAccount +- `modules/wristband/oidc_token.go` - OIDC token endpoint, client auth, code redemption, refresh grant +- `modules/wristband/oidc_userinfo.go` - UserInfo, bearer parsing, ordered claims JSON +- `modules/wristband/oidc_redirect.go` - phpURLEncode, appendPHPQuery, phpURLDecode, matchOIDCRedirectURI +- `modules/wristband/oidc_subject.go` - SubjectSector, ErrSectorRequired, subject types +- `modules/wristband/{server,authorize,token,register,stores,client_issue}.go` - flavour field and dispatch, 404s, Kind, neutral Resource and comments +- `modules/wristband/oidc_*_test.go` - memory fakes, tracer, ten smoke tests +- `modules/wristband/{authorize,token,server,consent,registration,example}_test.go` - neutral literals, explicit resource, TestDefaultOptionsResourceEmpty, ExampleNewOIDCServer +- `modules/wristband/README.md`, `README.md`, `docs/services/oauth-server.md` - OIDC documentation +- `../fonoteka.go/plugins/golem15/fonoteka/plugin.go`, `oauth_resource_test.go` - explicit resource and its test + +## Decisions Made + +See `key-decisions` in the frontmatter. The main judgement calls beyond the plan text: +- Redirect Cache-Control follows the plan (`no-store, private`), not PHP's Laravel default (`no-cache, private`). This is open for plan 03's tide replay. +- `ConsentView` gained `RedirectHost` and `Incremental` (additive), which the PHP consent page shows. +- `SubjectTypePublic` and `SubjectTypePairwise` constants were added, so the documented values of `OIDCClientRecord.SubjectType` exist in code. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Task boundaries moved for code that one task could not leave half-written** +- **Found during:** Task 1 +- **Issue:** The tracer's code redirect needs the PHP encoder (Task 2's `oidc_redirect.go`). The token path already needed burn-then-check redemption and the full client-auth rules for a correct happy path. Resume needed the silent-consent branch to be complete. +- **Fix:** Task 1 shipped `phpURLEncode`/`appendPHPQuery`, PHP client auth, burn-then-check redemption, the uniform userinfo 401 and the silent resume path. Task 2 added the loopback matcher, kinds, the remaining validation steps, switch account and the exported JSON operations; Task 3 added ES256, pairwise, refresh and client credentials. All planned smoke tests landed in their planned tasks. +- **Verification:** each task's verify command passed at its commit. +- **Committed in:** 659b42a, dfa3c3e, e25e733 + +**2. [Rule 1 - Bug] The plan's own acceptance grep would have matched a doc comment** +- **Found during:** Task 3 +- **Issue:** A comment in `oidc_keys.go` named the golang-jwt package global, which breaks the "MarshalSingle... appears 0 times" criterion even though the code never touches it. +- **Fix:** Reworded the comment. +- **Committed in:** e25e733 + +**3. [Rule 2 - Missing critical] Non-OIDC clients refused at userinfo and token as well as authorize** +- **Found during:** Task 2 +- **Fix:** Kind checks at all three OIDC entry points and both MCP entry points (T-19.4-05), as the threat register requires. +- **Committed in:** dfa3c3e + +--- + +**Total deviations:** 3 auto-fixed (1 blocking/sequencing, 1 bug, 1 missing critical). **Impact:** no scope change. Every name in the plan's Artifacts exists as planned. Additive extras: `ConsentView.RedirectHost`, `ConsentView.Incremental`, `SubjectTypePublic`, `SubjectTypePairwise`. + +## Issues Encountered + +- `../fonoteka.go` full Docker-backed run: `TestPhase09SecurityRoutes` fails on cabana admin routes that other phases added (account preferences, reorder, singleton). It has nothing to do with this plan; it is logged in `deferred-items.md`. Every fonoteka OAuth test passes (Docker-backed subset `OAuth|Phase8|Authorize|Token|Consent|Connected|Register` green, `go test -short ./plugins/golem15/fonoteka/...` green). +- **Docker-backed tests run:** `TestOAuthResourceExplicit` PASS (real Postgres via testcontainers, no sudo needed); the fonoteka OAuth subset PASS; the full fonoteka plugin tree ran (one unrelated failure above). The framework's own Docker tests (`go test ./...` without `-short`) were not run; `go test -short ./...` is green. +- **g15office:** not updated. The g15office MCP tools are not available in this executor session (no ToolSearch or MCP tools in the toolset). The orchestrator should move task `iarcg5dk` (folded todo) to Live with the comment "Closed in 19.4-01: DefaultOptions().Resource is empty, fonoteka sets its resource explicitly", and post a plan-executed comment on the phase 19.4 task `l7t8dbyl`. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Plan 02 (sm-oauthserver-plugin) can implement `OIDCBackend`, `KeyStore` and `OIDCHost` against the identifiers above. Before installing go-oidc v3.21.0 and x/oauth2 v0.37.0 it needs the blocking human package check. +- Open for plan 03's tide replay: redirect `Cache-Control` (`no-store, private` here vs PHP `no-cache, private`), id_token header member order (golang-jwt map order), the 500 body shape of discovery/JWKS. +- Nothing is pushed or tagged. fonoteka must pick up a framework version with these changes together with commit 687e25a. + +--- +*Phase: 19.4-oidc-provider-on-wristband* +*Completed: 2026-10-19* diff --git a/.planning/phases/19.4-oidc-provider-on-wristband/deferred-items.md b/.planning/phases/19.4-oidc-provider-on-wristband/deferred-items.md new file mode 100644 index 00000000..952561a0 --- /dev/null +++ b/.planning/phases/19.4-oidc-provider-on-wristband/deferred-items.md @@ -0,0 +1,6 @@ +## Deferred Items + +- fonoteka `TestPhase09SecurityRoutes` fails against the current framework (found by 19.4-01, outside its scope) + status: open + **What:** `go test ./plugins/golem15/fonoteka/...` in `../fonoteka.go` (Docker-backed, not `-short`) reports "unexpected admin routes" for cabana routes other phases added: `GET`/`PUT /plytadmin/api/v1/account/preferences`, `GET`/`POST .../{controller}/reorder` and `GET .../{controller}/singleton`. The test's admin route allowlist predates them. Nothing in wristband or the OAuth wiring is involved; every OAuth test in that package passes. + **Where:** `../fonoteka.go/plugins/golem15/fonoteka/admin_phase09_security_test.go:52`.