228 lines
17 KiB
Markdown
228 lines
17 KiB
Markdown
---
|
|
phase: 08-oauth2-1-authorization-server
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- 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
|
|
autonomous: true
|
|
requirements: [AUTH-05, AUTH-06, AUTH-07]
|
|
must_haves:
|
|
truths:
|
|
- "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."
|
|
artifacts:
|
|
- path: "wristband/server.go"
|
|
provides: "App-agnostic OAuth options and RFC 8414 metadata handler"
|
|
exports: ["Options", "Server", "New"]
|
|
- path: "wristband/register.go"
|
|
provides: "RFC 7591 validation, client issuance, cap, sweep, and bounded JSON handler"
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go"
|
|
provides: "GORM-backed transaction-scoped wristband store"
|
|
- path: "../fonoteka.go/plugins/golem15/fonoteka/updates/12_oauth_schema_correction.go"
|
|
provides: "Additive nullability and index correction"
|
|
key_links:
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/plugin.go"
|
|
to: "wristband.New"
|
|
via: "Boot constructs the server from app config and the GORM backend"
|
|
pattern: "wristband\\.New"
|
|
- from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go"
|
|
to: "wristband.Server"
|
|
via: "raw metadata and register handlers, with register throttle"
|
|
pattern: "GroupRaw|fonoteka-oauth-register"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
## 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>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
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.
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Specify the discovery and registration path with failing tests</name>
|
|
<files>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</files>
|
|
<read_first>
|
|
.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
|
|
</read_first>
|
|
<behavior>
|
|
- 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.
|
|
</behavior>
|
|
<action>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.</action>
|
|
<verify>
|
|
<automated>test -f wristband/registration_test.go && cd ../fonoteka.go && test -f plugins/golem15/fonoteka/oauth_registration_test.go</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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.
|
|
</acceptance_criteria>
|
|
<done>The executable happy-path and adversarial discovery/DCR contract exists in RED state, including schema, concurrency, body-bound, header, and surface-isolation coverage.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Implement wristband discovery, DCR, cryptography, and persistent storage</name>
|
|
<files>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</files>
|
|
<read_first>
|
|
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
|
|
</read_first>
|
|
<behavior>
|
|
- 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`.
|
|
</behavior>
|
|
<action>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.</action>
|
|
<verify>
|
|
<automated>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</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `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/`.
|
|
</acceptance_criteria>
|
|
<done>The framework protocol/storage contracts and corrected Postgres schema make public/confidential registration safe, durable, bounded, and byte-compatible.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 3: Mount the configured metadata and DCR slice in the real app</name>
|
|
<files>../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</files>
|
|
<read_first>
|
|
../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
|
|
</read_first>
|
|
<behavior>
|
|
- `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.
|
|
</behavior>
|
|
<action>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.</action>
|
|
<verify>
|
|
<automated>cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestOAuth(Metadata|Register|RawRoute|Config)' -count=1</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- 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`.
|
|
</acceptance_criteria>
|
|
<done>A real connector can discover and register against the assembled Go application with exact PHP-compatible routing, configuration, persistence, limits, and security boundaries.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
- `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.
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/phases/08-oauth2-1-authorization-server/08-01-SUMMARY.md` when done.
|
|
</output>
|