Files
summercms/.planning/phases/08-oauth2-1-authorization-server/08-01-PLAN.md
2026-09-23 13:38:58 +02:00

17 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
08-oauth2-1-authorization-server 01 execute 1
wristband/server.go
wristband/stores.go
wristband/crypto.go
wristband/register.go
wristband/registration_test.go
../fonoteka.go/plugins/golem15/fonoteka/models/oauth_client.go
../fonoteka.go/plugins/golem15/fonoteka/models/oauth_auth_code.go
../fonoteka.go/plugins/golem15/fonoteka/updates/12_oauth_schema_correction.go
../fonoteka.go/plugins/golem15/fonoteka/updates/oauth_schema_correction_test.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go
../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml
../fonoteka.go/config/app.yaml
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go
true
AUTH-05
AUTH-06
AUTH-07
truths artifacts key_links
An unauthenticated connector can fetch the exact RFC 8414 metadata document and dynamically register a public or confidential client.
Registration is JSON-only, rate-limited, bounded to 64 KiB, and emits bare PHP-compatible success and error bodies.
Public clients, multiple pending requests, and the required OAuth lookup indexes persist correctly in Postgres.
path provides exports
wristband/server.go App-agnostic OAuth options and RFC 8414 metadata handler
Options
Server
New
path provides
wristband/register.go RFC 7591 validation, client issuance, cap, sweep, and bounded JSON handler
path provides
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go GORM-backed transaction-scoped wristband store
path provides
../fonoteka.go/plugins/golem15/fonoteka/updates/12_oauth_schema_correction.go Additive nullability and index correction
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/plugin.go wristband.New Boot constructs the server from app config and the GORM backend wristband.New
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/routes.go wristband.Server raw metadata and register handlers, with register throttle GroupRaw|fonoteka-oauth-register
Deliver the first real OAuth vertical slice: an unchanged connector can discover this authorization server and register a client against persistent Postgres state.

Purpose: Establish the exact raw wire contract and correct data representation that every later grant flow uses, while honoring D-01's direct standard-library implementation instead of zitadel/oidc. Output: The wristband metadata/DCR surface, corrected OAuth schema and models, GORM store adapter, app configuration, boot wiring, and raw routes.

Phase Goal

As a fonoteka-mcp or ChatGPT connector, I want to discover and register with SummerCMS using the same OAuth contract as the PHP backend, so that I can begin the unchanged PKCE connection flow.

<execution_context> @/home/jin/.codex/get-shit-done/workflows/execute-plan.md @/home/jin/.codex/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md @.planning/phases/08-oauth2-1-authorization-server/08-RESEARCH.md @.planning/phases/08-oauth2-1-authorization-server/08-PATTERNS.md @.planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md Existing routing contract from `pact/capabilities.go` and `surf/router.go`: - `Router.GroupRaw(prefix string, middleware []string, fn func(Router))` - `Router.Get/Post/Delete(path string, handler http.HandlerFunc, middleware ...string)`

Existing application records:

  • models.OAuthClient owns client id, optional secret hash, redirect/grant/auth-method JSON, registration IP, consent/revocation, and scope ceiling.
  • models.OAuthAuthCode must represent a pre-consent row with nullable request/code/user fields.
  • Plugin.Buckets() already exposes fonoteka-oauth-register at 30 requests/minute keyed by trusted client IP.

New framework contract established by this plan:

  • wristband.Options carries issuer, endpoint paths, scopes/auth methods, resource, consent URL builder, TTLs, DCR cap/stale age, and RegisterMaxBytes: 65536.
  • wristband.Backend.WithinTx(context.Context, func(wristband.Tx) error) error is the only mutation boundary; Tx composes client/code/refresh/token-issuer operations without importing GORM.
Task 1: Specify the discovery and registration path with failing tests wristband/registration_test.go, ../fonoteka.go/plugins/golem15/fonoteka/updates/oauth_schema_correction_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go .planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md .planning/phases/08-oauth2-1-authorization-server/08-RESEARCH.md .planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/postgres_test.go ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go ../fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthMetadataController.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthRegisterController.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthMetadataTest.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthRegisterTest.php - Metadata returns the exact unwrapped 11-field document, field order, content type, and recorded cache header. - JSON DCR creates public `none` and confidential `client_secret_post`/`client_secret_basic` clients; a secret is returned once and only its SHA-256 hex is stored. - Non-JSON, invalid URI/auth/grant/response metadata, more than five URIs, cap overflow, and a body larger than 65,536 bytes return exact endpoint-native errors. - Concurrent registrations cannot cross the configured client cap; artisan clients with null registration IP are not swept. - The additive migration permits null public-client/pending fields, creates named operational indexes, and refuses rollback when null lifecycle rows would be lost. Per D-18 and the MVP test-first rule, add failing framework, real-Postgres, and assembled-route tests before production code. Use deterministic clock/random readers in wristband tests. Assert bytes and headers before decoding JSON. Include named failures for T-08-DCR-FLOOD, T-08-SECRET-TIMING, T-08-SURFACE, and T-08-REQUEST-LEAK. Prove the raw route table has no JWT, `inv_token`, `inv.scope`, body-limit, or house middleware, while `/register` carries only `throttle:fonoteka-oauth-register`. Commit the RED tests separately; do not weaken assertions to make current code pass. test -f wristband/registration_test.go && cd ../fonoteka.go && test -f plugins/golem15/fonoteka/oauth_registration_test.go - `rg -n 'Test(Metadata|Register|RegistrationBodyLimit|RegistrationCap|OAuthSchema)' wristband/registration_test.go ../fonoteka.go/plugins/golem15/fonoteka/{oauth_registration_test.go,updates/oauth_schema_correction_test.go,classes/auth/oauth_store_test.go}` finds named tests for every behavior above. - At least one focused test fails because the wristband production package or raw routes do not yet exist; the failure is implementation-related, not a syntax error. - Test source contains literal assertions for `65536`, `Basic realm=`, `Cache-Control`, and the absence of house/auth middleware. The executable happy-path and adversarial discovery/DCR contract exists in RED state, including schema, concurrency, body-bound, header, and surface-isolation coverage. Task 2: Implement wristband discovery, DCR, cryptography, and persistent storage wristband/server.go, wristband/stores.go, wristband/crypto.go, wristband/register.go, ../fonoteka.go/plugins/golem15/fonoteka/models/oauth_client.go, ../fonoteka.go/plugins/golem15/fonoteka/models/oauth_auth_code.go, ../fonoteka.go/plugins/golem15/fonoteka/updates/12_oauth_schema_correction.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go wristband/registration_test.go ../fonoteka.go/plugins/golem15/fonoteka/updates/oauth_schema_correction_test.go ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go ../fonoteka.go/plugins/golem15/fonoteka/models/oauth_client.go ../fonoteka.go/plugins/golem15/fonoteka/models/oauth_auth_code.go ../fonoteka.go/plugins/golem15/fonoteka/models/oauth_refresh_token.go ../fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go .planning/phases/08-oauth2-1-authorization-server/08-PATTERNS.md - Fixed-length SHA-256 transforms use `crypto/subtle.ConstantTimeCompare`; raw client secrets are never persisted or logged. - DCR validates the exact PHP allow-list and URI rules, serializes cap/sweep/create atomically, and strips control characters from a maximum-120-character client name. - The GORM adapter applies `FOR UPDATE` only in app code and every transaction-scoped method uses the callback's `*gorm.DB`. Implement the app-agnostic `wristband` package per D-01, D-03, D-05, D-06, D-07, D-17, and D-21. Use `crypto/rand`, `sha256`, `subtle.ConstantTimeCompare`, `base64.RawURLEncoding`, `encoding/json`, and `net/http`; add no dependency. Define typed records and a transaction-scoped backend, a local no-newline JSON writer, exact metadata, and RFC 7591 registration. Enforce the 64 KiB bound with `http.MaxBytesReader` before decoding and map overflow to `invalid_client_metadata`. Create client IDs from 16 random bytes and secrets from 32; store only SHA-256 hex. Correct the two model files to pointer fields and add a new gormigrate step that drops the four `NOT NULL` constraints, adds the PHP-equivalent operational indexes idempotently, and refuses down migration when null rows exist. Implement atomic client cap/sweep/create in the GORM adapter; do not import app/GORM code from wristband. go test ./wristband -run 'Test(Metadata|Register|Registration)' -count=1 && cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/classes/auth ./plugins/golem15/fonoteka/updates -run 'TestOAuth' -count=1 - `go list -deps ./wristband | rg 'fonoteka|gorm.io'` returns no matches. - `rg -n 'subtle\.ConstantTimeCompare' wristband/crypto.go` finds the fixed-transform comparison and `rg -n 'client_secret_hash.*\*string|request_id.*\*string|code_hash.*\*string|user_id.*\*uint' ../fonoteka.go/plugins/golem15/fonoteka/models/oauth_{client,auth_code}.go` finds all nullable model corrections. - Focused framework and real-Postgres tests pass, including concurrent cap enforcement and rollback refusal. - No raw request id, code, verifier, client secret, access token, or refresh token is written through a logging call in `wristband/`. The framework protocol/storage contracts and corrected Postgres schema make public/confidential registration safe, durable, bounded, and byte-compatible. Task 3: Mount the configured metadata and DCR slice in the real app ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/config/app.yaml, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go ../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go ../fonoteka.go/plugins/golem15/fonoteka/plugin.go ../fonoteka.go/plugins/golem15/fonoteka/routes.go ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml ../fonoteka.go/config/app.yaml surf/router.go .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-03-SUMMARY.md - `GET /.well-known/oauth-authorization-server` and `POST /oauth/mcp/register` run through the assembled app, not a hand-built router. - Defaults are pending/code 600 seconds, access 3600 seconds, refresh 30 days, DCR cap 200, stale-client age 24 hours, resource `https://mcp.plytarium.com/mcp`, and registration maximum 65,536 bytes. - `/register` is throttled per trusted client IP and no `oauth` guard is registered. Per D-03, D-09, D-10, and D-12, add plugin config keys under `plugins.golem15.fonoteka.oauth.*`, keep `app.url` as the issuer source with its trailing slash trimmed once, construct the GORM backend and wristband server in `Plugin.Boot`, and retain it for route/command factories. Mount metadata and register inside the existing raw group; pass `throttle:fonoteka-oauth-register` only to register. Do not attach the Phase 6 body limiter or add an `oauth` guard. Preserve the backend token-surface 401 contract and do not add RFC 9728 metadata/challenges owned by fonoteka-mcp. cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestOAuth(Metadata|Register|RawRoute|Config)' -count=1 - Assembled-route tests return 200 metadata and 201 DCR with the exact unwrapped bodies and expected headers. - Route-table assertions show `/oauth/mcp/register` has `throttle:fonoteka-oauth-register` and neither RFC route has JWT, `inv_token`, `inv.scope`, or house middleware. - `rg -n 'Register\(.*oauth|"oauth"' ../fonoteka.go/plugins/golem15/fonoteka` finds no new guard registration. - `go vet ./... && go test ./...` passes in both `summercms.go` and `../fonoteka.go`. A real connector can discover and register against the assembled Go application with exact PHP-compatible routing, configuration, persistence, limits, and security boundaries.

<threat_model>

Trust Boundaries

Boundary Description
Internet connector → raw OAuth routes Unauthenticated metadata and attacker-controlled JSON cross into the authorization server.
wristband → app Backend App-agnostic protocol state crosses into transaction-scoped Postgres persistence.
app config → public metadata Deployment-controlled issuer/resource/endpoints become client trust anchors.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-08-DCR-FLOOD Denial of Service register.go, client store mitigate 64 KiB pre-decode cap, existing per-IP limiter, atomic cap 200, and 24-hour unconsented sweep with concurrency tests.
T-08-SECRET-TIMING Information Disclosure crypto.go, client authentication helper mitigate Compare fixed SHA-256 hex transforms only through crypto/subtle.ConstantTimeCompare; source and behavior tests reject direct equality.
T-08-REQUEST-LEAK Information Disclosure handlers, logs, fixtures mitigate No secret/handle logging, hash-only persistence, one-time secret response, log-capture/source scans.
T-08-SURFACE Elevation of Privilege raw route registration mitigate Route-table test proves raw routes have only their named throttle and no auth/house middleware.
T-08-SC Tampering dependencies mitigate No package install occurs; fail if a new module dependency appears without a package-legitimacy audit.
</threat_model>
- `go test ./wristband -count=1` - `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestOAuth(Metadata|Register|Schema|Store|RawRoute)' -count=1` - `go vet ./... && go test ./...` passes in each repository.

<success_criteria>

  • The exact metadata document and RFC 7591 responses are reachable on the assembled app without an envelope.
  • Public and confidential clients persist with hash-only secrets; concurrent cap enforcement cannot create client 201.
  • The corrected schema represents public clients and multiple pending requests and includes the required indexes.
  • Registration rejects over-64-KiB and non-JSON requests through endpoint-native errors and the named limiter is present. </success_criteria>
Create `.planning/phases/08-oauth2-1-authorization-server/08-01-SUMMARY.md` when done.