docs(19.4-01): complete OIDC provider on wristband plan

This commit is contained in:
2026-10-19 18:35:37 +02:00
parent 811124259c
commit 49e772afb8
2 changed files with 294 additions and 0 deletions

View File

@@ -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 <note commit> <first oidc_keys.go commit>; 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*

View File

@@ -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`.