docs(08): create OAuth authorization server plans

This commit is contained in:
Jakub Zych
2026-09-23 13:38:58 +02:00
parent 716d0ea40d
commit 241af16ba7
11 changed files with 1451 additions and 11 deletions

View File

@@ -66,7 +66,7 @@ Requirements for v1 (the Płytarium port). Each maps to roadmap phases. "User" b
- [x] **AUTH-02**: Organizations with roles; organization fields appear on the user payload through a fire-and-collect event so the fonoteka plugin extends the user plugin without editing it
- [x] **AUTH-03**: Personal API tokens with a read|write|ai scope ceiling, token CRUD endpoints, and a scope-checking middleware
- [x] **AUTH-04**: The must-change-password flag locks the authenticated surface with 423 except the locale and password-change routes
- [ ] **AUTH-05**: OAuth2.1 authorization server on zitadel/oidc: RFC 8414 metadata, authorize with PKCE and consent screen, token endpoint for authorization_code and refresh_token, RFC 7591 dynamic client registration, RFC 8707 resource parameter tolerance, exact WWW-Authenticate and protected-resource-metadata headers
- [ ] **AUTH-05**: Direct standard-library OAuth2.1-style authorization server (`wristband`): RFC 8414 metadata, authorize with S256 PKCE and consent screen, authorization_code and rotating refresh_token grants, RFC 7591 dynamic registration, RFC 8707 resource handling, exact backend Basic invalid-client challenge, unchanged backend personal-token 401, and unchanged fonoteka-mcp-owned RFC 9728 protected-resource metadata/Bearer challenge
- [ ] **AUTH-06**: OAuth routes are form-urlencoded, CSRF-free, rate limited, and return unwrapped RFC 6749 bodies with the PHP cache headers
- [ ] **AUTH-07**: Connected apps can be listed and revoked; OAuthClient, OAuthAuthCode and OAuthRefreshToken models are ported; fonoteka-mcp completes its install and auth flow unchanged
- [ ] **AUTH-08**: Backend admin users with roles and a permissions registry are separate from frontend users, and gate both navigation and admin controller access

View File

@@ -306,7 +306,7 @@ Plans:
### Phase 8: OAuth2.1 authorization server
**Goal**: An RFC 8414/6749/7591-compliant OAuth2.1 server on zitadel/oidc serves fonoteka-mcp and the ChatGPT connector unchanged, including exact `WWW-Authenticate` and protected-resource-metadata headers. Security-load-bearing — bearer tokens, PKCE and constant-time secret comparison all live here; apply the security-review agent.
**Goal**: A direct standard-library OAuth2.1-style authorization server (`wristband`) implements the RFC 8414/6749/7591/8707 contract needed by fonoteka-mcp and the ChatGPT connector unchanged. The backend preserves its exact Basic invalid-client challenge and existing personal-token 401, while fonoteka-mcp retains ownership of RFC 9728 protected-resource metadata and its rich Bearer challenge. Security-load-bearing — bearer tokens, PKCE, replay-family revocation, and constant-time secret comparison all live here; apply the security-review agent.
**Mode:** mvp
**Depends on**: Phase 6, Phase 7
**Repos:** summercms.go, fonoteka.go
@@ -315,12 +315,38 @@ Plans:
1. RFC 8414 metadata is served unenveloped at its well-known path; authorize with PKCE and a consent screen works, and the token endpoint issues/refreshes authorization_code and refresh_token flows, all as unwrapped RFC 6749 bodies with the PHP cache headers (`Cache-Control: no-store`, `Pragma: no-cache`).
2. RFC 7591 dynamic client registration and RFC 8707 resource parameter tolerance both work against a real client registration call.
3. A 401 on a protected route carries the exact `WWW-Authenticate` and protected-resource-metadata header contract, verified against fonoteka-mcp's actual discovery flow, not just a Go unit test.
3. The token endpoint returns exactly `WWW-Authenticate: Basic realm="OAuth"` on `invalid_client`, the backend personal-token 401 remains unchanged with no added challenge, and fonoteka-mcp's own rich Bearer challenge plus protected-resource metadata are verified through its actual discovery flow, not just a Go unit test.
4. Connected apps can be listed and revoked; `OAuthClient`/`OAuthAuthCode`/`OAuthRefreshToken` models persist correctly; fonoteka-mcp completes its install and auth flow unchanged; client-secret comparison uses `crypto/subtle.ConstantTimeCompare`.
**Plans**: TBD
**Plans**: 6 plans
**Research flag:** yes
Plans:
**Wave 1**
- [ ] 08-01-PLAN.md — Discover and dynamically register clients through the real app and corrected OAuth schema
**Wave 2** *(blocked on 08-01)*
- [ ] 08-02-PLAN.md — Complete S256 authorize, JWT consent, and atomic authorization-code exchange
**Wave 3** *(blocked on 08-02)*
- [ ] 08-03-PLAN.md — Rotate refresh grants, kill replayed lineages, and manage connected apps
**Wave 4** *(blocked on 08-03)*
- [ ] 08-04-PLAN.md — Provision confidential clients and serve the MCP personal-token bootstrap
**Wave 5** *(blocked on 08-04)*
- [ ] 08-05-PLAN.md — Replay PHP OAuth flows and run the unchanged real MCP lifecycle
**Wave 6** *(blocked on 08-05; blocking security checkpoint)*
- [ ] 08-06-PLAN.md — Close 103-method coverage, independent security review, and final phase gate
### Phase 9: Backend admin authentication and schema pipeline
**Goal**: Backend admin users with roles are separate from frontend users and gate navigation and controller access; `fields.yaml`/`columns.yaml` drive a JSON form/list schema, including a first-class relation-manager schema replacing the one `partial` field. Security-load-bearing — separate admin authentication and permissions registry live here; apply the security-review agent. This is also the least-precedented design surface in the research (one real relation-manager usage in the PHP source, no direct library equivalent).

View File

@@ -0,0 +1,28 @@
# Decision: Direct OAuth2.1-style `wristband` Port
**Date:** 2026-09-23
**Status:** Accepted for Phase 8
**Supersedes:** Phase 8 assumptions that named `zitadel/oidc` as the server engine
## Decision
Phase 8 implements the Płytarium authorization server as an app-agnostic framework package named `wristband` using Go's standard library (`crypto/rand`, `crypto/sha256`, `crypto/subtle`, `encoding/base64`, `encoding/json`, `net/http`, and `net/url`). It does not add `zitadel/oidc`.
`wristband` owns the byte-specific RFC metadata, authorization, token, dynamic-registration, PKCE, scope, redirect, refresh-rotation, replay-revocation, and expiry-sweep behavior. `fonoteka.go` owns GORM persistence, users/collections, ordinary `inv_` access-token issuance, consent and connected-app controllers, configuration, routes, and the operator command.
## Rationale
Płytarium's unchanged clients depend on the existing PHP response bytes, metadata shape, error bodies, `inv_` access-token model, ordered redirects, and app-specific consent state. `zitadel/oidc` is an OIDC provider with different defaults and cannot directly reproduce that token model without replacing the behavior that justified selecting it. The direct port remains small and uses standard cryptographic/HTTP primitives while preserving framework/app separation.
## Header Ownership
- The Go authorization server emits exactly `WWW-Authenticate: Basic realm="OAuth"` for token-endpoint `invalid_client`.
- The existing backend personal-token 401 remains `{"error":"Invalid token"}` with no added challenge.
- fonoteka-mcp, as the resource server, continues to emit RFC 9728 protected-resource metadata and its rich Bearer `resource_metadata` challenge.
## Consequences
- No external Go package is installed in Phase 8.
- Client-secret and PKCE comparisons use `crypto/subtle.ConstantTimeCompare` on fixed transforms.
- OAuth access tokens remain ordinary configured-prefix `inv_` personal tokens verified by the existing `inv_token` guard.
- Historical STACK/ARCHITECTURE notes that describe the earlier ecosystem assumption remain historical; the Phase 8 context, roadmap, requirements, plans, and this note are authoritative for implementation.

View File

@@ -0,0 +1,227 @@
---
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 &amp;&amp; cd ../fonoteka.go &amp;&amp; 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 &amp;&amp; cd ../fonoteka.go &amp;&amp; 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 &amp;&amp; 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 ./... &amp;&amp; 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>

View File

@@ -0,0 +1,232 @@
---
phase: 08-oauth2-1-authorization-server
plan: 02
type: execute
wave: 2
depends_on: [08-01]
files_modified:
- wristband/authorize.go
- wristband/token.go
- wristband/authorize_test.go
- wristband/token_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_token_issuer.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
- ../fonoteka.go/plugins/golem15/fonoteka/oauth_connect_test.go
autonomous: true
requirements: [AUTH-05, AUTH-06, AUTH-07]
must_haves:
truths:
- "A registered client can start an authorization request only with an exact redirect URI, S256 PKCE, valid resource, and ceiling-bounded scopes."
- "A JWT-authenticated user can inspect, allow, or deny the pending request using the unchanged Nuxt consent screen contract."
- "An allowed request yields a single-use authorization code that exchanges atomically for an ordinary `inv_` access token and refresh token."
artifacts:
- path: "wristband/authorize.go"
provides: "Authorize validation, ordered redirects, pending-state creation, scope/resource/PKCE policy"
- path: "wristband/token.go"
provides: "Authorization-code token exchange and exact RFC 6749 responses"
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go"
provides: "JWT consent show/allow/deny payloads for unchanged Nuxt"
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_token_issuer.go"
provides: "Transaction-bound `inv_` access-token mint/revoke adapter"
key_links:
- from: "wristband/token.go"
to: "wristband.Backend.WithinTx"
via: "lock, consume code, mint access token, and create refresh token atomically"
pattern: "WithinTx"
- from: "oauth_consent_controller.go"
to: "wristband.Server"
via: "issue-code and deny operations rather than direct OAuth row mutation"
pattern: "IssueCode|Deny"
- from: "oauth_token_issuer.go"
to: "api_token_manager.go"
via: "configured-prefix personal-token mint with oauth_client_id stamp"
pattern: "MintPersonalToken"
---
<objective>
Deliver the interactive authorization-code vertical slice from a connector's PKCE authorize request through Nuxt consent to one atomic token exchange.
Purpose: Make the unchanged browser and connector complete the primary OAuth flow with exact redirect, body, header, scope, tenant, and token semantics.
Output: Authorize/token framework handlers, consent controllers, transaction-bound token issuer, store transitions, app routes, and end-to-end tests.
</objective>
## Phase Goal
**As a** Płytarium user connecting a chat application, **I want to** review its requested permissions and approve or deny them, **so that** only an exact PKCE-bound grant can access my active collection.
<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-UI-SPEC.md
@.planning/phases/08-oauth2-1-authorization-server/08-01-SUMMARY.md
<interfaces>
From Plan 08-01:
- `wristband.Server` owns configured RFC handlers and app-agnostic operations.
- `wristband.Backend.WithinTx(ctx, func(Tx) error) error` provides one atomic store/token boundary.
Existing app interfaces:
- `auth.MintPersonalToken(userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.ApiToken, error)`.
- `classes.ResolveActiveCollection(ctx, db, userID)` returns the trusted collection and persists fallback context.
- `bouncer.User(ctx)` supplies the JWT principal; app controllers write exact JSON through `wire.WriteJSON`.
Locked consent payload:
- GET returns `data{client_name,redirect_host,scopes_requested,collection_name,expires_at}`.
- POST consent/deny returns `data{redirect_to}` with ordered RFC3986 query parameters.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Specify the complete PKCE consent and code-exchange path</name>
<files>wristband/authorize_test.go, wristband/token_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller_test.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_connect_test.go</files>
<read_first>
.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md
.planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md
wristband/server.go
wristband/stores.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthAuthorizeController.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthTokenController.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthConsentController.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/OAuthCodeManager.php
</read_first>
<behavior>
- Unknown client and unregistered redirect return local plain-text 400 with no `Location`; later failures redirect only to the exact registered URI in PHP parameter order.
- S256 is mandatory; challenge/verifier syntax and lengths are enforced; wrong/missing/plain PKCE never yields a token.
- Scope ceiling truncates data scopes while keeping `offline_access` protocol-only; consent cannot add scopes or collection IDs.
- Pending requests are JWT owner-bound and single-use; stale, consumed, or foreign handles share the exact 404.
- Code exchange locks and consumes the code once, compares S256 in constant time, mints `inv_`, stamps `oauth_client_id`, and returns exact no-store/no-cache token bytes.
</behavior>
<action>Per D-02, D-04, D-08, D-12, and D-18, add RED unit and assembled real-Postgres tests for the full public-client happy path plus T-08-PKCE, T-08-CODE-REPLAY, T-08-OPEN-REDIRECT, T-08-SCOPE-CEILING, T-08-CROSS-USER, T-08-REQUEST-LEAK, and T-08-SURFACE. Assert exact query parameter order and RFC 3986 `%20`, body bytes, content types, `Cache-Control`, `Pragma`, and `WWW-Authenticate: Basic realm="OAuth"` on invalid confidential-client authentication. Prove JSON `/token` is `invalid_request`, form body values override query values, and Basic credentials override form credentials.</action>
<verify>
<automated>test -f wristband/authorize_test.go &amp;&amp; test -f wristband/token_test.go &amp;&amp; cd ../fonoteka.go &amp;&amp; test -f plugins/golem15/fonoteka/oauth_connect_test.go</automated>
</verify>
<acceptance_criteria>
- Named tests cover authorize validation order, ordered redirects, consent show/allow/deny, PKCE syntax/mismatch, sequential and concurrent code replay, token parser asymmetry, Basic precedence, exact headers, and cross-user denial.
- The assembled happy-path test begins at `GET /oauth/mcp/authorize`, uses JWT consent endpoints, and finishes at `POST /oauth/mcp/token` with an `inv_` access token.
- Focused tests fail on absent implementation while compiling against the interfaces produced by 08-01.
</acceptance_criteria>
<done>The RED suite proves the exact end-to-end connection contract and every high-severity boundary before implementation.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement authorize validation and atomic authorization-code exchange</name>
<files>wristband/authorize.go, wristband/token.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_token_issuer.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go</files>
<read_first>
wristband/authorize_test.go
wristband/token_test.go
wristband/server.go
wristband/stores.go
wristband/crypto.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go
../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go
../fonoteka.go/plugins/golem15/fonoteka/models/oauth_auth_code.go
</read_first>
<behavior>
- Authorize reads query only and validates client then redirect before any redirect-capable error.
- Ordered redirect encoder retains existing query, appends with `&amp;`, preserves field order, and encodes spaces as `%20`.
- Code exchange executes code lock/consume, token mint/persist, oauth-client stamp, and refresh-row creation in one transaction.
</behavior>
<action>Implement D-02, D-03, D-04, D-05, D-06, D-07, D-11, and D-17. Add authorize validation in locked order: usable client, exact redirect, response type, S256 method/challenge, requested/ceiling scopes, optional exact resource, then pending creation. Build redirects from ordered pairs rather than `url.Values.Encode`. Add token parsing with JSON rejection then `ParseForm`; authenticate `none`, post, or Basic using fixed SHA-256 transforms and constant-time comparison. Exchange codes under a store row lock; compare S256 through `subtle.ConstantTimeCompare`, mark the code used, mint/persist an access token, set `oauth_client_id`, and create the refresh record atomically. Make the personal-token prefix config-backed at the app adapter while preserving `inv_` as Płytarium's configured value. Run the expiry sweep on token entry but delete only expired rows.</action>
<verify>
<automated>go test ./wristband -run 'Test(Authorize|Token|PKCE|Code)' -count=1 &amp;&amp; cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/classes/auth -run 'TestOAuth(Code|Issuer)' -count=1</automated>
</verify>
<acceptance_criteria>
- Framework tests prove local 400s have no `Location`, trusted errors have ordered `error,error_description,iss,state`, and successful consent redirects use `code,iss,state`.
- Sequential and synchronized concurrent code exchanges produce exactly one usable token grant.
- `rg -n 'subtle\.ConstantTimeCompare' wristband` finds both client-secret and PKCE comparisons; no direct equality compares presented secrets/verifiers.
- Issued access tokens start with configured `inv_`, store only SHA-256 hash, carry exact scopes/collection IDs/expiry, and have `oauth_client_id` set.
</acceptance_criteria>
<done>The framework can safely create pending authorization state and atomically exchange one PKCE-bound code for the existing personal-token model.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Wire JWT consent and the raw authorize/token routes</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller_test.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_connect_test.go</files>
<read_first>
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_connect_test.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go
../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/pages/connect.vue
/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts
</read_first>
<behavior>
- Consent show returns sanitized client name, host-only redirect, ordered mintable scopes, server-resolved collection name, and ISO expiry.
- Allow grants only submitted ∩ requested ∩ ceiling ∩ mintable scopes and server-derived collection ids; deny consumes the request and returns ordered `access_denied,iss,state`.
- Raw authorize/token endpoints remain unenveloped and token alone receives `throttle:fonoteka-oauth-token`; JWT management routes never appear under personal-token auth.
</behavior>
<action>Per D-08, D-09, D-10, D-12 and the UI-SPEC, implement GET request, POST consent, and POST deny inside the established JWT+locale+must-change-password group. Use `bouncer.User`, `ResolveActiveCollection`, `lagoon.Validate`, Vue-safe plain strings, and wristband operations; never accept collection IDs or extra scopes from the request. Return the exact UI payload/status shapes and collapse absent, stale, consumed, and foreign handles to `{"error":"Request not found"}` 404. Mount authorize and token on the raw group; token gets only `throttle:fonoteka-oauth-token`. Preserve no house middleware, no `oauth` guard, and no backend RFC 9728 challenge.</action>
<verify>
<automated>cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/... -run 'TestOAuth(Authorize|Consent|Deny|CodeExchange|Surface)' -count=1</automated>
</verify>
<acceptance_criteria>
- The assembled PKCE happy-path test passes from authorize through JWT consent and token exchange against real Postgres.
- Consent tests prove empty/no-longer-grantable scope intersection is exact 422 and foreign/stale/used handles are indistinguishable exact 404.
- Route-table tests prove four raw RFC routes and three JWT consent routes occupy only their locked auth surfaces.
- `git -C /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app status --short` shows no Phase 8 changes.
</acceptance_criteria>
<done>The unchanged Nuxt consent page and a registered PKCE client complete one secure authorization-code flow through the assembled Go app.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Connector → authorize/token | Untrusted query/form/Basic credentials attempt to create or redeem grants. |
| Browser JWT principal → consent API | Authenticated but untrusted scope selection crosses tenant/ownership boundaries. |
| wristband transaction → personal-token store | A one-time authorization code becomes durable access/refresh credentials. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-PKCE | Spoofing/Elevation | authorize and code exchange | mitigate | Mandatory S256 syntax plus constant-time computed challenge comparison; missing/plain/wrong tests. |
| T-08-CODE-REPLAY | Spoofing | code exchange/store | mitigate | `FOR UPDATE`, one transaction, used stamp, sequential and concurrent single-winner tests. |
| T-08-OPEN-REDIRECT | Spoofing/Disclosure | authorize/consent redirects | mitigate | Validate usable client and exact redirect before constructing any `Location`; ordered trusted-URI tests. |
| T-08-SECRET-TIMING | Information Disclosure | token client auth and PKCE | mitigate | Constant-time fixed-transform comparisons only. |
| T-08-SCOPE-CEILING | Elevation | authorize and consent | mitigate | Requested ∩ ceiling before display; submitted ∩ pending ∩ mintable at consent; collection IDs server-derived. |
| T-08-CROSS-USER | Elevation | consent endpoints | mitigate | Principal-scoped pending lookup/consume and indistinguishable 404s. |
| T-08-REQUEST-LEAK | Information Disclosure | browser/logging | mitigate | Opaque short-lived handle, no value logging, host-only UI payload, no-referrer client behavior retained unchanged. |
| T-08-SURFACE | Elevation | routes | mitigate | Assembled route-table isolation across raw, JWT, and personal-token groups. |
| T-08-SC | Tampering | dependencies | mitigate | No new dependency; standard-library implementation enforced by import tests. |
</threat_model>
<verification>
- `go test ./wristband -run 'Test(Authorize|Token|PKCE|Code)' -count=1`
- `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestOAuth(Authorize|Consent|Deny|CodeExchange|Surface)' -count=1`
- `go vet ./... && go test ./...` passes in each repository.
</verification>
<success_criteria>
- A registered client completes authorize → JWT consent → code exchange and receives an `inv_` access token plus refresh token.
- PKCE, exact redirect, scope ceiling, ownership, parser precedence, single use, and exact raw response headers are all failing-when-broken tests.
- The existing consent UI receives every locked field and status without any Nuxt change.
</success_criteria>
<output>
Create `.planning/phases/08-oauth2-1-authorization-server/08-02-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,215 @@
---
phase: 08-oauth2-1-authorization-server
plan: 03
type: execute
wave: 3
depends_on: [08-02]
files_modified:
- wristband/token.go
- wristband/token_test.go
- wristband/stores.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/controllers/api/connected_app_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
- ../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go
autonomous: true
requirements: [AUTH-05, AUTH-06, AUTH-07]
must_haves:
truths:
- "A valid refresh token rotates to a new access/refresh pair while revoking the prior access token."
- "Replaying a spent refresh token commits revocation of the whole lineage, leaving no usable branch."
- "A user sees only their live connected OAuth apps and can revoke one access token plus its refresh lineage atomically."
artifacts:
- path: "wristband/token.go"
provides: "Refresh grant rotation, replay detection, and committed lineage kill"
- path: "../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go"
provides: "Row-locked refresh traversal, revoke, and expiry sweep storage"
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go"
provides: "Owner-scoped connected-app list and revoke handlers"
key_links:
- from: "wristband/token.go"
to: "oauth_store.go"
via: "transaction outcome commits lineage kill before returning invalid_grant"
pattern: "WithinTx"
- from: "connected_app_controller.go"
to: "wristband.Server"
via: "cascade revoke operation rather than direct refresh-row deletion"
pattern: "Revoke"
- from: "connected_app_controller.go"
to: "token_api_controller.go"
via: "reuse of positive allow-list token serializer"
pattern: "serializeToken"
---
<objective>
Deliver the durable OAuth lifecycle slice: safe refresh rotation/replay handling and user-visible connected-app listing/revocation.
Purpose: Ensure stolen or replayed refresh tokens cannot create surviving branches and the unchanged Settings UI controls the same grant lineage.
Output: Refresh-grant state machine, row-locked store operations, connected-app controllers/routes, and concurrency-backed lifecycle tests.
</objective>
## Phase Goal
**As a** connected-app user, **I want to** refresh access safely and revoke applications from Settings, **so that** replayed or revoked credentials immediately lose access.
<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-UI-SPEC.md
@.planning/phases/08-oauth2-1-authorization-server/08-02-SUMMARY.md
<interfaces>
From Plan 08-02:
- Token handler already dispatches `authorization_code` and recognizes the configured refresh grant.
- `wristband.Tx` provides row-lock-capable refresh/code stores and the transaction-bound access-token issuer.
- OAuth access tokens are `models.ApiToken` rows distinguished by non-null `OAuthClientID`.
Existing serializer:
- `serializeToken(gdb, *models.ApiToken) map[string]any` is the required positive allow-list for connected-app responses.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Specify rotation, replay, list, and revoke as one lifecycle</name>
<files>wristband/token_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go</files>
<read_first>
.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md
.planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md
wristband/token.go
wristband/stores.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth/OAuthCodeManager.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/ConnectedAppController.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/OAuthRefreshRotationTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/OAuthRevocationTest.php
</read_first>
<behavior>
- Normal refresh revokes the old access token, marks predecessor `rotated_to_id`, and returns a new same-scope/same-collection pair.
- Sequential or concurrent spent-token replay returns `invalid_grant` only after the complete lineage and current access token are durably revoked.
- Expiry sweep removes only expired pending/code/refresh rows and retains unexpired rotated/revoked refresh rows as replay evidence.
- Connected-app list is newest-first, owner-only, live OAuth tokens only, with manual count separate and no secret/client-id fields.
- Revoke of an owned OAuth token kills its refresh lineage; foreign, missing, and manual token IDs share the exact 404.
</behavior>
<action>Per D-04, D-08, D-16, D-17, and D-18, extend the RED suite with deterministic in-memory tests and synchronized real-Postgres contention tests for T-08-REFRESH-REPLAY, T-08-CROSS-USER, T-08-SCOPE-CEILING, T-08-REQUEST-LEAK, and T-08-SURFACE. Include an assembled lifecycle that starts with a grant from 08-02, refreshes, replays the spent predecessor, verifies the new branch and access token are dead, creates another grant, lists it, revokes it, and proves refresh afterward fails. Assert exact UI response allow-lists and 404 bytes.</action>
<verify>
<automated>rg -n 'TestOAuth(Refresh|Replay|Connected|Revoke|Sweep)' wristband/token_test.go ../fonoteka.go/plugins/golem15/fonoteka/{oauth_lifecycle_test.go,classes/auth/oauth_store_test.go,controllers/api/connected_app_controller_test.go}</automated>
</verify>
<acceptance_criteria>
- Tests include sequential replay, a barrier-synchronized double refresh, committed lineage kill, expiry retention, owner isolation, manual-token exclusion, list ordering, and post-revoke refresh failure.
- The replay test explicitly reloads database rows after the `invalid_grant` response and asserts revoked lineage/access state, preventing rollback-hidden false positives.
- Tests fail on missing refresh/connected-app implementation while all 08-02 happy-path tests remain green.
</acceptance_criteria>
<done>The RED lifecycle suite detects branch survival, rollback of replay revocation, ownership leaks, serialization leaks, and route misplacement.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement refresh rotation, committed replay kill, and exact sweeps</name>
<files>wristband/token.go, wristband/stores.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go</files>
<read_first>
wristband/token_test.go
wristband/token.go
wristband/stores.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
../fonoteka.go/plugins/golem15/fonoteka/models/oauth_refresh_token.go
../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go
</read_first>
<behavior>
- Presented refresh lookup uses SHA-256 hash and a row lock inside the single transaction boundary.
- Replay revocation returns success from the transaction callback, then maps the recorded outcome to `invalid_grant` outside it.
- Rotation keeps predecessor/successor relationships and unexpired evidence rows.
</behavior>
<action>Implement D-04, D-05, D-07, and D-17's refresh branch in wristband and the GORM adapter. Authenticate the client using the same Basic-over-form rule, hash the presented refresh secret, lock its row, reject expired/revoked/wrong-client grants, and rotate atomically by revoking the old access token, minting/persisting its successor, creating the next refresh secret/hash, and linking `rotated_to_id`. If a spent token is presented, traverse and revoke the whole lineage and associated access tokens, return nil from the transaction so the kill commits, then return `invalid_grant` from the handler. Keep the old scopes, collection IDs, offline flag, and client binding. Sweep only rows whose `expires_at` is past; do not delete unexpired replay evidence.</action>
<verify>
<automated>go test ./wristband -run 'Test(Refresh|Replay|Sweep)' -count=1 &amp;&amp; cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/classes/auth -run 'TestOAuth(Refresh|Replay|Sweep)' -count=1</automated>
</verify>
<acceptance_criteria>
- Normal refresh and sequential/concurrent replay tests pass under real Postgres.
- A spent-token replay leaves every lineage refresh row and its live access token revoked after the response transaction commits.
- `go test -race ./wristband` passes and the app contention test produces a single usable branch.
- Sweep tests prove expired rows are removed and unexpired rotated/revoked rows remain.
</acceptance_criteria>
<done>Refresh rotation is atomic, preserves replay evidence, and commits whole-lineage revocation before emitting the protocol error.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Expose connected-app list and atomic revoke to the unchanged Settings UI</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go</files>
<read_first>
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/token_api_controller.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/components/settings/ConnectedAppsManager.vue
/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/fonoteka.ts
</read_first>
<behavior>
- List returns `data` and numeric `manual_tokens_count`, filters to owner/live/OAuth tokens, and orders created_at descending.
- Every row is `serializeToken` plus sanitized client name and contains no raw credential, hash, OAuth client id, redirect URI, or other-user data.
- Revoke completes access-token and refresh-lineage revocation before returning `{"data":{"revoked":true}}`.
</behavior>
<action>Per D-08 and the UI-SPEC, add GET and DELETE connected-app controllers in the JWT group. Reuse `serializeToken`; append only the sanitized/truncated client name, initialize collection/scope arrays as arrays, count live manual tokens separately, and order OAuth tokens newest first. Scope every query by `bouncer.User`. For DELETE, require an owned OAuth token, invoke the wristband lineage-revoke operation in the same committed transaction, and collapse missing/foreign/manual IDs to exact `{"error":"Token not found"}` 404. Mount only under `/_fonoteka/api/v1/oauth`; do not expose these routes on the personal-token or raw groups.</action>
<verify>
<automated>cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/... -run 'TestOAuth(ConnectedApps|Revoke|Lifecycle|Surface)' -count=1</automated>
</verify>
<acceptance_criteria>
- Empty, populated, manual-count, newest-first, foreign/manual 404, and successful atomic revoke tests pass with exact bytes.
- The lifecycle test proves the revoked app disappears on the next list and its refresh token returns `invalid_grant`.
- JSON assertions reject `token`, `token_hash`, `oauth_client_id`, `client_secret`, `refresh_token`, `request_id`, and `redirect_uris` anywhere in list output.
- The Nuxt repository remains unchanged.
</acceptance_criteria>
<done>The existing Settings → Integrations UI can list and revoke only the current user's connected applications, and revoke kills the entire grant lineage.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Refresh credential → token endpoint | A bearer-like long-lived credential requests a new grant branch. |
| JWT principal → connected-app API | User-controlled ids request listing/revocation of durable credentials. |
| Transaction outcome → OAuth error | Security revocation must commit even though the protocol response is an error. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-REFRESH-REPLAY | Spoofing/Elevation | refresh state machine | mitigate | Row lock, rotation chain, single-winner concurrency, commit lineage kill before `invalid_grant`. |
| T-08-CROSS-USER | Elevation | connected-app controllers | mitigate | Owner-scoped reads/deletes and indistinguishable missing/foreign/manual 404. |
| T-08-SCOPE-CEILING | Elevation | refresh rotation | mitigate | Copy only stored granted scopes/collection ids; refresh cannot add request-provided authority. |
| T-08-REQUEST-LEAK | Information Disclosure | list response/logs | mitigate | Positive allow-list serializer and explicit forbidden-field tests. |
| T-08-SURFACE | Elevation | route groups | mitigate | JWT-only management route inspection; token endpoint remains raw. |
| T-08-SC | Tampering | dependencies | mitigate | No install; standard library and existing GORM only. |
</threat_model>
<verification>
- `go test ./wristband -run 'Test(Refresh|Replay|Sweep)' -count=1`
- `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestOAuth(Refresh|Replay|ConnectedApps|Revoke|Lifecycle|Surface)' -count=1`
- `cd ../fonoteka.go && go test -race ./plugins/golem15/fonoteka/classes/auth ./plugins/golem15/fonoteka`
</verification>
<success_criteria>
- Refresh rotation has exactly one usable successor and replay durably kills the entire lineage.
- Connected-app output matches the UI contract and cannot disclose secrets or other users.
- Revocation removes the app from the list and invalidates both access and refresh credentials before success is returned.
</success_criteria>
<output>
Create `.planning/phases/08-oauth2-1-authorization-server/08-03-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,211 @@
---
phase: 08-oauth2-1-authorization-server
plan: 04
type: execute
wave: 4
depends_on: [08-03]
files_modified:
- bonfire/command.go
- bonfire/root.go
- bonfire/output_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
- ../fonoteka.go/plugins/golem15/fonoteka/oauth_tools_test.go
autonomous: true
requirements: [AUTH-05, AUTH-07]
must_haves:
truths:
- "An operator can issue, amend, and list confidential OAuth clients with repeated redirect/scope flags, while a client secret is printed only at creation."
- "The unchanged fonoteka-mcp process can authenticate its issued `inv_` token at `GET /api/v1/fonoteka/me` and receive the exact bootstrap payload."
- "Invalid personal tokens keep the existing exact `Invalid token` body and gain no backend Bearer/resource-metadata challenge."
artifacts:
- path: "../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go"
provides: "Exact `fonoteka:oauth-client` operator command"
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go"
provides: "Personal-token MCP bootstrap endpoint"
- path: "bonfire/command.go"
provides: "Typed repeatable string-slice flag contract"
key_links:
- from: "oauth_client.go"
to: "wristband client issuance helper"
via: "same validation/hash/one-time-secret path as DCR"
pattern: "wristband"
- from: "me_token_controller.go"
to: "bouncer.Credential"
via: "reuse matched `*models.ApiToken` without re-parsing bearer input"
pattern: "Credential"
---
<objective>
Deliver the operator and MCP bootstrap slice required for confidential-client coverage and unchanged real-MCP startup.
Purpose: Preserve the PHP management command and satisfy the actual MCP client's pre-tool-call `/me` dependency without broadening the profile API.
Output: Repeatable bonfire flags, app OAuth client command, personal-token `/me` endpoint, route wiring, and integration tests.
</objective>
## Phase Goal
**As an** operator and unchanged fonoteka-mcp client, **I want to** provision a confidential client and bootstrap an issued personal token, **so that** managed connectors and MCP tools can use the same secure OAuth server.
<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-PATTERNS.md
@.planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md
@.planning/phases/08-oauth2-1-authorization-server/08-03-SUMMARY.md
<interfaces>
Existing bonfire contract:
- `Command{ Name, Description, Flags []Flag, Args []Arg, Run func(context.Context, Input, Output) error }`.
- `Input.Flag(name) (string, bool)` handles scalar flags; this plan adds an explicit string-slice shape without changing scalar callers.
Existing auth context:
- `TokenGuard.AuthenticateCredential` places the matched `*models.ApiToken` in `bouncer.Credential` and its owner in `bouncer.User`.
- Personal-token route middleware order is `inv_token`, `throttle:fonoteka-api-token`, `inv.scope:read`.
Locked `/me` response:
- `data{scopes,collection_ids,user_id,name}` with arrays, not null, and no other profile/token fields.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Specify operator provisioning and real MCP bootstrap contracts</name>
<files>bonfire/output_test.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_tools_test.go</files>
<read_first>
.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md
.planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md
bonfire/command.go
bonfire/root.go
bonfire/output_test.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/console/IssueOAuthClient.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/MeTokenController.php
/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts
</read_first>
<behavior>
- Repeated `--redirect-uri` and `--scope` values arrive as ordered slices without breaking existing scalar/bare flags.
- Create prints `client_id=`, `client_secret=`, and the non-recoverable warning once; `--list` never prints a secret or hash.
- Explicit `--client-id` updates the intended client through validated store operations; scope ceiling is enforced by later authorization.
- A valid read-scoped `inv_` token gets exact `/me` data; absent/invalid/wrong-scope tokens keep existing 401/403 bytes and headers.
</behavior>
<action>Per D-11, D-12, D-18, D-19, and D-20, add RED tests for repeatable bonfire flags, every command mode/output line, secret non-recovery, and assembled `/me` behavior. Use the real command root and assembled surf router. Assert the MCP-required fields exactly and reject any added user/profile/credential fields. Add T-08-SECRET-TIMING, T-08-SCOPE-CEILING, T-08-REQUEST-LEAK, and T-08-SURFACE regressions: command issuance uses hash-only storage; list output never leaks; `/me` is personal-token-only; invalid token response is exactly `{"error":"Invalid token"}` with no `WWW-Authenticate` or protected-resource metadata header.</action>
<verify>
<automated>rg -n 'Test(Repeatable|OAuthClientCommand|MeToken|TokenSurface)' bonfire/output_test.go ../fonoteka.go/plugins/golem15/fonoteka/{console/oauth_client_test.go,controllers/api/me_token_controller_test.go,oauth_tools_test.go}</automated>
</verify>
<acceptance_criteria>
- RED tests cover repeated flags, create/update/list, one-time secret, scope ceiling, exact `/me`, invalid token, missing scope, and route isolation.
- Command tests assert the exact warning and prove `--list` output contains neither `client_secret=` nor the stored secret hash.
- `/me` tests use the existing `inv_token` guard and `bouncer.Credential`, not direct controller context injection.
</acceptance_criteria>
<done>The operator and MCP bootstrap contracts are executable and fail only on the absent production behavior.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add repeatable flags and the exact OAuth client command</name>
<files>bonfire/command.go, bonfire/root.go, bonfire/output_test.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go, ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go</files>
<read_first>
bonfire/output_test.go
bonfire/command.go
bonfire/root.go
../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
../fonoteka.go/plugins/golem15/user/plugin.go
wristband/register.go
wristband/stores.go
</read_first>
<behavior>
- `Flag` explicitly distinguishes scalar and repeated string values; `Input` exposes both without parsing `os.Args`.
- Command create/update/list shares wristband validation and issuance, including constant-time-secret-ready hash storage and one-time raw secret.
</behavior>
<action>Extend bonfire with an explicit string-slice flag kind and `Input.Flags(name) ([]string, bool)` while preserving every existing `Flag` call and bare/scalar behavior; wire Cobra `StringSlice`/`StringSliceP` only for that kind. Implement `fonoteka:oauth-client` per D-19 with optional name, repeated `--redirect-uri`/`--scope`, scalar `--auth-method`, `--client-id`, and bare `--list`. Keep it thin over wristband's client validation/issuing helper and the app ClientStore; artisan clients have null `registration_ip`. Print the exact creation lines and warning; never recover or print secrets in list/update. Register through the plugin command capability and include sanitized names, redirects, ceiling, auth method, and revocation state in list output.</action>
<verify>
<automated>go test ./bonfire -run 'Test.*Flag' -count=1 &amp;&amp; cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/... -run TestOAuthClientCommand -count=1</automated>
</verify>
<acceptance_criteria>
- Existing bonfire test suite stays green and a repeated flag preserves both ordered values.
- The command accepts the locked signature, validates through wristband, and passes create/update/list real-store tests.
- Create output contains one client id, one client secret, and one warning; list/update output contains no raw secret or hash.
- No `os.Args` access exists in the app command.
</acceptance_criteria>
<done>Operators can safely provision confidential or ceiling-bounded clients with the exact PHP command contract.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Serve the MCP token bootstrap payload on the existing personal-token surface</name>
<files>../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_tools_test.go</files>
<read_first>
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_tools_test.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_locale_controller.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts
</read_first>
<behavior>
- `/api/v1/fonoteka/me` reads the matched credential and principal and returns exactly scopes, collection_ids, user_id, and name.
- It inherits the personal-token middleware order and exact deny responses; it performs no second bearer parse or DB token lookup.
</behavior>
<action>Implement D-20's exact token bootstrap handler using `bouncer.Credential` asserted as `*models.ApiToken` and `bouncer.User`. Initialize scopes and collection_ids to arrays, preserve nullable token name as PHP does, and emit only the four locked fields through `wire.WriteJSON`. Mount GET `/me` in the existing `/api/v1/fonoteka` group after `inv_token`, `throttle:fonoteka-api-token`, and `inv.scope:read`. Do not add profile fields or move the route into the JWT/raw groups. Per D-12, leave the backend invalid-token response and headers untouched.</action>
<verify>
<automated>cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/... -run 'Test(MeToken|TokenSurface|OAuthTools)' -count=1</automated>
</verify>
<acceptance_criteria>
- Valid access token returns exact `data.scopes`, `data.collection_ids`, `data.user_id`, and `data.name`, with arrays never null.
- Missing/invalid token is exact 401 `{"error":"Invalid token"}` without `WWW-Authenticate`; missing read scope is the existing exact 403.
- Route-table test finds `/api/v1/fonoteka/me` only under the personal-token group with all three existing middleware in order.
- `go vet ./... &amp;&amp; go test ./...` passes in both repositories.
</acceptance_criteria>
<done>The real MCP process can bootstrap from its OAuth-issued `inv_` token without any client change or backend header drift.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Operator CLI → OAuth client store | Trusted operator input can create recoverable-once confidential credentials. |
| Bearer header → personal-token `/me` | Untrusted bearer input crosses the established token guard and scope ceiling. |
| Stored credential → command/API output | Secret-bearing records must be reduced to positive allow-list output. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-SECRET-TIMING | Information Disclosure | issued confidential client | mitigate | Command reuses wristband hash/validation path; raw secret returned once, hash only stored. |
| T-08-SCOPE-CEILING | Elevation | command and `/me` | mitigate | Validated command ceiling and existing `inv.scope:read`; output reflects stored granted scopes only. |
| T-08-REQUEST-LEAK | Information Disclosure | CLI/list/API output | mitigate | List never prints secret/hash; `/me` positive allow-list; output-source tests. |
| T-08-SURFACE | Elevation | `/me` route | mitigate | Existing personal-token group and route-table proof; backend challenge remains unchanged. |
| T-08-SC | Tampering | Cobra dependency use | mitigate | Use already-pinned Cobra; no new package install. |
</threat_model>
<verification>
- `go test ./bonfire -count=1`
- `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'Test(OAuthClientCommand|MeToken|TokenSurface|OAuthTools)' -count=1`
- `go vet ./... && go test ./...` passes in each repository.
</verification>
<success_criteria>
- The command exactly supports create/update/list and repeated flags without leaking an existing secret.
- An OAuth-issued `inv_` token receives the exact MCP `/me` bootstrap payload.
- Invalid-token and route-surface behavior remains byte-identical to Phase 6/7.
</success_criteria>
<output>
Create `.planning/phases/08-oauth2-1-authorization-server/08-04-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,212 @@
---
phase: 08-oauth2-1-authorization-server
plan: 05
type: execute
wave: 5
depends_on: [08-04]
files_modified:
- ../fonoteka.go/parity/oauth_flow_test.go
- ../fonoteka.go/parity/capture_clients.mjs
- ../fonoteka.go/parity/capture-rules.yaml
- ../fonoteka.go/parity/fixtures/mcp/mcp-lifecycle.yaml
- ../fonoteka.go/parity/manifest.yaml
- ../fonoteka.go/parity/check_corpus.go
- scripts/check-phase8.sh
autonomous: true
requirements: [AUTH-05, AUTH-06, AUTH-07]
must_haves:
truths:
- "All four raw OAuth routes and five JWT OAuth management routes replay their recorded PHP contracts against Go and count as ported only after passing."
- "A clean recorded lifecycle proves DCR, authorize, consent, token, refresh, replay kill, list, revoke, post-revoke failure, deny, confidential Basic auth, and scope ceiling."
- "The unchanged real fonoteka-mcp process completes discovery, DCR, PKCE, JWT consent, token bootstrap, an MCP tool call, and refresh against the Go backend."
artifacts:
- path: "../fonoteka.go/parity/oauth_flow_test.go"
provides: "Projected existing flows and full lifecycle replay"
- path: "../fonoteka.go/parity/fixtures/mcp/mcp-lifecycle.yaml"
provides: "Secret-scrubbed PHP lifecycle source-of-truth fixture"
- path: "scripts/check-phase8.sh"
provides: "Two-repository, parity, secret, Postgres, real-MCP phase gate"
key_links:
- from: "oauth_flow_test.go"
to: "newConfiguredTarget"
via: "replay through the assembled Go app and real Postgres"
pattern: "newConfiguredTarget"
- from: "scripts/check-phase8.sh"
to: "/media/nvme/dev/golem15/fonoteka/fonoteka-mcp"
via: "three configured URLs and scripted MCP SDK lifecycle"
pattern: "FONOTEKA_(API_URL|MCP_PUBLIC_URL|MCP_AUTH_SERVER)"
---
<objective>
Prove the completed server through recorded PHP parity and the unchanged real MCP client rather than only implementation-local tests.
Purpose: Turn exact route bytes, lifecycle security semantics, protected-resource discovery ownership, and actual SDK compatibility into one repeatable acceptance gate.
Output: Lifecycle fixture/capture policy, projected replay tests, nine ported manifest entries, and `scripts/check-phase8.sh`.
</objective>
## Phase Goal
**As an** unchanged MCP client, **I want to** complete the full OAuth lifecycle against Go exactly as I did against PHP, **so that** discovery, tool use, refresh, replay defense, and revocation are proven together.
<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-VALIDATION.md
@.planning/phases/08-oauth2-1-authorization-server/08-04-SUMMARY.md
<interfaces>
Existing parity seams:
- `newConfiguredTarget(t, db)` boots the same assembled handler used by the app.
- `tide.LoadFlow`, `tide.OpenStore`, and `tide.ReplayFlow` execute captured request/response sequences with private variables.
- `parity/manifest.yaml` status becomes `ported` only when the selected replay subtest passes.
Unchanged MCP inputs:
- `FONOTEKA_API_URL` points to the Go app personal-token API.
- `FONOTEKA_MCP_PUBLIC_URL` points to the MCP resource server.
- `FONOTEKA_MCP_AUTH_SERVER` points to the Go authorization server.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Specify recorded and real-client OAuth acceptance before changing fixtures</name>
<files>../fonoteka.go/parity/oauth_flow_test.go, scripts/check-phase8.sh</files>
<read_first>
.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md
.planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md
../fonoteka.go/parity/nuxt_flow_test.go
../fonoteka.go/parity/parity_test.go
../fonoteka.go/parity/fixtures/mcp/mcp-oauth.yaml
../fonoteka.go/parity/fixtures/mcp/mcp-tools.yaml
../fonoteka.go/parity/manifest.yaml
../fonoteka.go/parity/capture_clients.mjs
scripts/check-phase3.sh
/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts
/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/config.ts
</read_first>
<behavior>
- Existing broad flows are projected to named OAuth/MCP prerequisite steps and fail if an expected step disappears; unrelated later-phase calls are not replayed.
- The clean lifecycle flow is required and every terminal security action is asserted before routes can be marked ported.
- The gate starts real Postgres, Go app, and unchanged Node MCP; it verifies MCP-owned protected-resource metadata and Bearer hint separately from backend-owned metadata and Basic invalid-client challenge.
</behavior>
<action>Per D-12, D-13, D-14, D-15, D-16, D-18, and the MVP test-first rule, create failing parity tests and the fail-closed gate skeleton before recording/changing status. Project `mcp-oauth` and `mcp-tools` by stable named step IDs and require the exact OAuth prerequisites plus `/me` and one tool call. Require full `mcp-lifecycle`. In the gate, declare stages for both repo vet/test/race, real-Postgres app boot, real MCP startup with all three environment variables, resource-server discovery/401 challenge, backend metadata/DCR/PKCE/login/consent/token, `/me`, MCP tool call, refresh/replay/revoke, and corpus/secret checks. Plan 08-06 adds the security-review validation stage after its review artifact exists. Do not edit or patch the MCP checkout.</action>
<verify>
<automated>test -f ../fonoteka.go/parity/oauth_flow_test.go &amp;&amp; test -x scripts/check-phase8.sh</automated>
</verify>
<acceptance_criteria>
- `oauth_flow_test.go` names projections for `mcp-oauth`, `mcp-tools`, and the complete `mcp-lifecycle`, and missing expected steps fail.
- `scripts/check-phase8.sh` uses `set -euo pipefail`, fail-closed dependency/Docker checks, cleanup traps, and all three MCP environment variables.
- The gate distinguishes MCP RFC 9728 metadata/rich Bearer challenge from backend exact Basic invalid-client challenge and unchanged token-surface 401.
- Tests/gate fail because the lifecycle fixture/status/evidence is not yet complete, not because of shell or Go syntax errors.
</acceptance_criteria>
<done>The acceptance harness demands the exact recorded and real-client lifecycle before any route can be claimed ported.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Record, scrub, replay, and promote the complete OAuth lifecycle</name>
<files>../fonoteka.go/parity/capture_clients.mjs, ../fonoteka.go/parity/capture-rules.yaml, ../fonoteka.go/parity/fixtures/mcp/mcp-lifecycle.yaml, ../fonoteka.go/parity/oauth_flow_test.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/check_corpus.go</files>
<read_first>
../fonoteka.go/parity/oauth_flow_test.go
../fonoteka.go/parity/capture_clients.mjs
../fonoteka.go/parity/capture-rules.yaml
../fonoteka.go/parity/php_parity.sh
../fonoteka.go/parity/fixtures/mcp/mcp-oauth.yaml
../fonoteka.go/parity/fixtures/mcp/mcp-tools.yaml
../fonoteka.go/parity/manifest.yaml
tide/flow.go
tide/replay.go
</read_first>
<behavior>
- Lifecycle records DCR → authorize → consent → token → refresh → spent-token replay → list → revoke → refresh failure → deny, plus confidential Basic and ceiling/invalid-scope cases.
- Every request id, code, verifier, client secret, access token, and refresh token is represented only by a typed placeholder in committed fixtures; private vars are mode 0600.
- Nine manifest entries become ported only after their exact route replay passes; pending never increments passing.
</behavior>
<action>Extend the existing capture script/rules and use the Phase 2 isolated-PHP process to record D-16's lifecycle. Issue the confidential client through `fonoteka:oauth-client`; exercise scope ceiling truncation and invalid-scope redirect with `client_secret_basic`. Capture all secret-bearing values with explicit pkce/token/credential categories into the private store, confirm both vars files are 0600, and commit only symbolic variable references. Add full and projected replays through `newConfiguredTarget` with real Postgres. After each of the four raw and five JWT route subtests passes, change only those manifest entries to `status: ported`; keep honest corpus accounting.</action>
<verify>
<automated>cd ../fonoteka.go &amp;&amp; go test ./parity -run 'TestOAuthFlows|TestParityCorpus|TestParityContract' -count=1</automated>
</verify>
<acceptance_criteria>
- `mcp-lifecycle.yaml` contains the locked sequence and placeholder references, not recoverable credential values.
- `go run ./parity/check_corpus.go --manifest parity/manifest.yaml --fixtures parity/fixtures --check-secrets` exits 0.
- All nine OAuth manifest entries are `ported`; replay reports them passing with zero failing and does not count any pending route as passing.
- Existing `mcp-oauth`/`mcp-tools` projections fail if a required named step is removed and ignore only explicitly enumerated later-phase steps.
</acceptance_criteria>
<done>The Go app passes the PHP-recorded OAuth route and lifecycle contracts without committing live secrets.</done>
</task>
<task type="auto">
<name>Task 3: Complete the real unchanged-MCP phase gate</name>
<files>scripts/check-phase8.sh</files>
<read_first>
scripts/check-phase8.sh
scripts/check-phase3.sh
../fonoteka.go/parity/oauth_flow_test.go
/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/package.json
/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts
/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/config.ts
/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/install.ts
</read_first>
<action>Finish D-14's executable gate using the existing gate family and installed Node MCP dependencies. Allocate loopback ports, start disposable Postgres and the assembled Go app, start the unchanged MCP with its three URLs pointed at the test services, and drive the actual SDK discovery/DCR/PKCE flow. Obtain a JWT only through the app's real login route, consent through the JWT API, exchange, call an MCP tool after `/me` bootstrap, refresh, replay/revoke, and assert failures. Verify MCP emits the rich Bearer `resource_metadata` challenge and protected-resource document; verify the backend emits only exact Basic on token invalid-client and unchanged no-challenge token-surface 401. Run both modules' vet/test/race, parity/corpus/secret checks, and require a verified security-review artifact stage to be satisfiable by 08-06. Preserve cleanup on success, error, and interruption; never print secrets.</action>
<verify>
<automated>scripts/check-phase8.sh</automated>
</verify>
<acceptance_criteria>
- The gate starts the real unchanged MCP checkout and completes metadata, DCR, PKCE, JWT consent, token, `/me`, one MCP tool call, and refresh against the Go backend.
- The gate proves spent refresh replay and connected-app revoke kill the lineage and later access/refresh attempts fail.
- Both repositories pass `go vet ./...`, `go test ./...`, and `go test -race ./...`; corpus and secret scans exit 0.
- `git -C /media/nvme/dev/golem15/fonoteka/fonoteka-mcp status --short` and the Nuxt equivalent show no Phase 8 diff.
</acceptance_criteria>
<done>The actual connector stack, including RFC 9728 resource-server behavior, runs unchanged through the complete Go authorization lifecycle.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| PHP capture → committed fixtures | Live credentials must become typed placeholders before entering git. |
| Scripted SDK → Go backend/MCP | External client parsing and redirects exercise public network-facing contracts. |
| Gate process → child services | Secrets and cleanup state cross shell/Node/Go process boundaries. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-PKCE | Spoofing/Elevation | real SDK flow | mitigate | Actual SDK S256 authorize/exchange plus wrong-verifier rejection in gate. |
| T-08-CODE-REPLAY | Spoofing | lifecycle replay | mitigate | Recorded and real repeated code/spent state fail. |
| T-08-REFRESH-REPLAY | Spoofing/Elevation | lifecycle replay/gate | mitigate | Spent replay kills lineage; post-replay DB/API evidence required. |
| T-08-OPEN-REDIRECT | Spoofing/Disclosure | recorded authorize cases | mitigate | PHP fixture and Go replay assert local errors versus trusted ordered redirects. |
| T-08-SECRET-TIMING | Information Disclosure | confidential Basic flow | mitigate | Actual confidential flow uses the constant-time implementation; final source audit in 08-06. |
| T-08-SCOPE-CEILING | Elevation | confidential lifecycle | mitigate | Recorded ceiling truncation and invalid-scope redirect. |
| T-08-CROSS-USER | Elevation | consent/list/revoke replay | mitigate | JWT ownership cases and indistinguishable 404 replay. |
| T-08-REQUEST-LEAK | Information Disclosure | fixtures/logs | mitigate | Capture categories, 0600 stores, placeholder-only fixtures, check-secrets and quiet gate. |
| T-08-DCR-FLOOD | Denial of Service | register route | mitigate | Recorded native errors plus focused rate/body/cap tests run by gate. |
| T-08-SURFACE | Elevation | MCP/backend boundary | mitigate | Gate asserts correct RFC 9728 ownership and exact backend challenges. |
| T-08-SC | Tampering | reused Node dependencies | mitigate | No install; use checked-in lockfile/node_modules and dependency preflight. |
</threat_model>
<verification>
- `cd ../fonoteka.go && go test ./parity -run 'TestOAuthFlows|TestParityCorpus|TestParityContract' -count=1`
- `scripts/check-phase8.sh`
</verification>
<success_criteria>
- Nine OAuth routes and the full lifecycle replay pass against Go with no leaked fixture secret.
- The real unchanged MCP discovers, authorizes, initializes, executes a tool, refreshes, and observes replay/revoke failure.
- Resource-server and authorization-server header ownership is proven exactly, not conflated.
</success_criteria>
<output>
Create `.planning/phases/08-oauth2-1-authorization-server/08-05-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,228 @@
---
phase: 08-oauth2-1-authorization-server
plan: 06
type: execute
wave: 6
depends_on: [08-05]
files_modified:
- wristband/phase08_coverage_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go
- ../fonoteka.go/parity/oauth_audit_test.go
- scripts/check-phase8.sh
- .planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md
- .planning/phases/08-oauth2-1-authorization-server/08-SECURITY-REVIEW.md
- .planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md
autonomous: false
requirements: [AUTH-05, AUTH-06, AUTH-07]
must_haves:
truths:
- "Every PHP OAuth functional/security test method maps to a named passing Go test or subtest, and both repositories pass vet/test/race."
- "Every T-08 threat maps to a failing-when-broken test with zero open high-severity findings."
- "The phase gate refuses missing tests, route parity, secret scans, unchanged-client evidence, or an unverified security review."
artifacts:
- path: ".planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md"
provides: "Auditable one-to-one map of all 103 PHP methods to Go evidence"
- path: ".planning/phases/08-oauth2-1-authorization-server/08-SECURITY-REVIEW.md"
provides: "ASVS L1 threat disposition and executed evidence"
- path: ".planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md"
provides: "Nyquist-complete task/status and gate sign-off"
key_links:
- from: "08-SECURITY-REVIEW.md"
to: "named Go tests"
via: "file:TestName evidence for every mitigated threat"
pattern: "T-08-"
- from: "scripts/check-phase8.sh"
to: "08-SECURITY-REVIEW.md"
via: "fail-closed verified/zero-open audit check"
pattern: "08-SECURITY-REVIEW"
---
<objective>
Close Phase 8 with complete unit/security coverage, an independent security-review agent pass, and one fail-closed verification gate.
Purpose: Demonstrate that the exact OAuth implementation is not merely functional but resistant to every identified high-severity replay, redirect, timing, scope, ownership, leakage, flooding, and surface threat.
Output: Coverage tests, 103-method audit map, verified security review, signed validation strategy, and final gate.
</objective>
## Phase Goal
**As a** Płytarium operator, **I want to** rely on independently reviewed OAuth behavior and complete regression evidence, **so that** unchanged connectors can be enabled without accepting an unproven high-severity security risk.
<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-VALIDATION.md
@.planning/phases/08-oauth2-1-authorization-server/08-05-SUMMARY.md
@.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md
<interfaces>
Security-review evidence contract:
- One register row per `T-08-*` threat with category, component, disposition, mitigation, and exact `file:TestName` evidence.
- Frontmatter reports total/closed/open and status; completion requires `status: verified`, `threats_open: 0`, and no unmitigated HIGH.
PHP test inventory contract:
- 103 methods: authorize 11, client-command 8, metadata 2, migration 7, register 10, token 10, consent/scope 7, refresh rotation 10, revocation 8, surface isolation 30.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Close the 103-method PHP audit and Phase 8 coverage gaps</name>
<files>wristband/phase08_coverage_test.go, ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go, ../fonoteka.go/parity/oauth_audit_test.go, .planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md</files>
<read_first>
.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md
.planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md
wristband/registration_test.go
wristband/authorize_test.go
wristband/token_test.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_connect_test.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_tools_test.go
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthAuthorizeTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthClientCommandTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthMetadataTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthMigrationTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthRegisterTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthTokenTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/OAuthConsentScopeCeilingTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/OAuthRefreshRotationTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/OAuthRevocationTest.php
/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/TokenSurfaceIsolationTest.php
</read_first>
<behavior>
- Every PHP method is listed once with its source class, behavior, and a named Go test/subtest that actually runs.
- Coverage tests exercise error branches, encoding failures, nil/misconfigured dependencies, clock/entropy errors, parser edges, and route/config drift not already covered.
- Audit fails if a mapped Go test is renamed/missing or if any PHP method is unmapped/duplicated.
</behavior>
<action>Per D-18 and the repository rule that unit coverage is the last plan, enumerate all 103 PHP methods into `08-PHP-TEST-MAP.md`, map each to existing Phase 8 tests, and add focused coverage tests only where no named evidence exists. Add an executable audit that parses the inventory/map and Go test list so counts alone cannot hide missing or duplicate mappings. Close framework handler/store branches, app boot/config/route/controller/command branches, parity projections, and every exact response/header path. Do not replace behavior assertions with coverage-only calls or map one broad test to methods whose distinct assertions are absent.</action>
<verify>
<automated>go test ./wristband -count=1 &amp;&amp; cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/... ./parity -run 'Test(OAuth|Phase08|PHPTestMap|TokenSurface|MeToken)' -count=1</automated>
</verify>
<acceptance_criteria>
- The map contains exactly 103 unique PHP method rows distributed 11/8/2/7/10/10/7/10/8/30 by source suite.
- The executable audit confirms every mapped Go `TestName[/subtest]` exists and executes; missing or duplicate rows make it fail.
- Both repositories pass full `go vet ./...`, `go test ./...`, and `go test -race ./...`, including nested plugin modules.
- Coverage additions retain exact byte/header/concurrency assertions for security branches.
</acceptance_criteria>
<done>All PHP OAuth behavior has one-to-one named Go evidence and the phase's code paths are covered by meaningful regression tests.</done>
</task>
<task type="auto">
<name>Task 2: Run the mandated security-review agent and close every high-severity finding</name>
<files>.planning/phases/08-oauth2-1-authorization-server/08-SECURITY-REVIEW.md, .planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md, scripts/check-phase8.sh</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-PHP-TEST-MAP.md
.planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md
.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-SECURITY-REVIEW.md
scripts/check-phase8.sh
wristband/server.go
wristband/authorize.go
wristband/token.go
wristband/register.go
wristband/crypto.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
</read_first>
<action>Invoke the `gsd-security-auditor` security-review agent against all Phase 8 production/test changes and the locked threat map, explicitly requiring OWASP ASVS L1 review of T-08-PKCE, CODE-REPLAY, REFRESH-REPLAY, OPEN-REDIRECT, SECRET-TIMING, SCOPE-CEILING, CROSS-USER, REQUEST-LEAK, DCR-FLOOD, SURFACE, and supply-chain status. Write `08-SECURITY-REVIEW.md` in the Phase 6 format with trust boundaries, complete STRIDE register, severity, disposition, mitigation, and executed `file:TestName` evidence. If the agent finds any HIGH issue, stop sign-off, implement the narrow fix and failing regression in the owning Phase 8 file, rerun the focused and full gates, and re-run the auditor until no HIGH remains. Then update VALIDATION task IDs/statuses, set `nyquist_compliant: true` and `wave_0_complete: true`, and add a fail-closed verified/zero-open security-review check to `check-phase8.sh`.</action>
<verify>
<automated>scripts/check-phase8.sh</automated>
</verify>
<acceptance_criteria>
- `08-SECURITY-REVIEW.md` has `status: verified`, `threats_open: 0`, and one evidence-backed disposition for every named T-08 threat plus T-08-SC.
- Every HIGH finding is mitigated by a named failing-when-broken test; no HIGH is accepted, deferred, or omitted.
- Static evidence finds `crypto/subtle.ConstantTimeCompare` for client secret and PKCE, row locks for code/refresh, committed replay kill, 64 KiB register cap, raw/JWT/personal route isolation, and no sensitive-value logging.
- `08-VALIDATION.md` maps final plan/task IDs, all required test/gate files exist, all statuses are green, and both Nyquist flags are true.
- `scripts/check-phase8.sh` exits nonzero if the review is missing, unverified, has a nonzero open count, or lacks any required T-08 row.
</acceptance_criteria>
<done>An independent security agent has reviewed the implemented phase, all high-severity findings are closed with executable evidence, and the final gate enforces the review.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 3: Approve the OAuth security and unchanged-client evidence</name>
<files>.planning/phases/08-oauth2-1-authorization-server/08-SECURITY-REVIEW.md, .planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md</files>
<read_first>
.planning/phases/08-oauth2-1-authorization-server/08-SECURITY-REVIEW.md
.planning/phases/08-oauth2-1-authorization-server/08-PHP-TEST-MAP.md
.planning/phases/08-oauth2-1-authorization-server/08-VALIDATION.md
.planning/phases/08-oauth2-1-authorization-server/08-05-SUMMARY.md
</read_first>
<action>Present the completed automated evidence after the security-review agent has produced zero open high-severity findings. Do not ask the user to rerun automation; show the exact gate result, threat totals, 103-method audit result, nine-route parity result, real-MCP lifecycle result, and unchanged Nuxt/MCP worktree checks. Block completion if any displayed result is missing or non-green.</action>
<verify>
<automated>scripts/check-phase8.sh</automated>
</verify>
<what-built>Direct standard-library OAuth authorization server with DCR, S256 PKCE, JWT consent, authorization-code and rotating-refresh grants, connected-app revocation, operator client command, MCP bootstrap endpoint, PHP parity, and unchanged real-MCP proof.</what-built>
<how-to-verify>
1. Review `08-SECURITY-REVIEW.md`; expect `status: verified`, zero open threats, and named test evidence for every T-08 row.
2. Review the recorded gate transcript; expect both repositories' vet/test/race, 103/103 PHP method map, nine OAuth route replays, secret scan, and real MCP lifecycle to be green.
3. Confirm the Nuxt and fonoteka-mcp repositories have no Phase 8 source diff.
</how-to-verify>
<acceptance_criteria>
- Human approval occurs only after zero open HIGH findings and a passing `scripts/check-phase8.sh` result are shown.
- The evidence explicitly includes exact Basic `WWW-Authenticate` at backend invalid-client, unchanged no-challenge token 401, and MCP-owned rich Bearer/resource-metadata behavior.
- Rejection includes the failing threat/test/gate identifier so remediation is deterministic.
</acceptance_criteria>
<resume-signal>Type "approved" to close Phase 8, or provide the failed threat/test/gate identifier.</resume-signal>
<done>The user has accepted the complete automated OAuth compatibility and security evidence.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Phase implementation → independent auditor | Claims must be supported by executable evidence, not implementation intent. |
| Test inventory → completion status | Missing/renamed tests or unmapped PHP methods must fail closed. |
| Security report → phase gate | Stale, missing, or open findings must prevent sign-off. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-08-PKCE | Spoofing/Elevation | authorize/exchange | mitigate | Independent audit plus missing/plain/wrong/syntax/constant-time tests. |
| T-08-CODE-REPLAY | Spoofing | code transaction | mitigate | Sequential/concurrent single-winner tests and row-lock source evidence. |
| T-08-REFRESH-REPLAY | Spoofing/Elevation | refresh transaction | mitigate | Branch-concurrency and post-error persisted lineage-kill evidence. |
| T-08-OPEN-REDIRECT | Spoofing/Disclosure | redirect construction | mitigate | Exact allow-list/validation-order and no-Location tests. |
| T-08-SECRET-TIMING | Information Disclosure | crypto/client auth | mitigate | `subtle.ConstantTimeCompare` source gate and invalid-secret behavior tests. |
| T-08-SCOPE-CEILING | Elevation | authorize/consent/refresh | mitigate | End-to-end requested/submitted/ceiling/mintable and server-derived tenant proofs. |
| T-08-CROSS-USER | Elevation | consent/connected apps | mitigate | Foreign ownership tests with indistinguishable 404s. |
| T-08-REQUEST-LEAK | Information Disclosure | logs/fixtures/output | mitigate | Log capture, source scan, fixture secret scan, positive output allow-lists. |
| T-08-DCR-FLOOD | Denial of Service | register | mitigate | 64 KiB cap, limiter, atomic client cap, sweep, concurrency evidence. |
| T-08-SURFACE | Elevation | route/MCP boundary | mitigate | Assembled route table and real client header-ownership gate. |
| T-08-SC | Tampering | package supply chain | mitigate | No added package; module-diff and package-audit checks. |
</threat_model>
<verification>
- `go vet ./... && go test ./... && go test -race ./...`
- `cd ../fonoteka.go && go vet ./... && go test ./... && go test -race ./...`
- `scripts/check-phase8.sh`
- Human approval after independent security review reports zero open HIGH findings.
</verification>
<success_criteria>
- All 103 PHP methods map uniquely to named passing Go tests/subtests.
- All T-08 threats have explicit dispositions and executable evidence; zero high-severity findings remain open.
- Nyquist validation, parity, secret scan, and unchanged real-MCP lifecycle are green in the final gate.
- The blocking human security checkpoint is approved.
</success_criteria>
<output>
Create `.planning/phases/08-oauth2-1-authorization-server/08-06-SUMMARY.md` when done.
</output>

View File

@@ -0,0 +1,61 @@
# Phase 08 Source Coverage Audit
All required GOAL, REQ, RESEARCH, CONTEXT, VALIDATION, and UI-SPEC items are planned. Deferred ideas remain excluded.
| Source | ID | Feature / requirement | Plan | Status | Notes |
|--------|----|-----------------------|------|--------|-------|
| GOAL | — | PHP-compatible authorization server serves unchanged MCP/connector with exact header ownership | 01-06 | COVERED | Direct standard-library `wristband` per locked D-01; real-client proof in 05. |
| REQ | AUTH-05 | Metadata, DCR, S256 authorize/consent, code/refresh grants, resource handling, exact discovery/challenges | 01-05 | COVERED | Backend Basic challenge and MCP RFC 9728 ownership are tested separately. |
| REQ | AUTH-06 | Form/query/JSON source rules, CSRF-free raw routes, rate limits, unwrapped responses, cache headers | 01-03, 05-06 | COVERED | Route-table, byte, header, parity, and final audit coverage. |
| REQ | AUTH-07 | Persistent OAuth models, connected-app list/revoke, unchanged MCP install/auth/tool flow | 01, 03-06 | COVERED | Includes schema correction, `/me`, lifecycle, and real MCP. |
| RESEARCH | R-01 | Additive nullability/index migration and pointer models | 01 | COVERED | Safe rollback refusal is explicit. |
| RESEARCH | R-02 | App-agnostic transaction-scoped store bundle with GORM row locks in app tier | 01-03 | COVERED | Framework never imports GORM/fonoteka. |
| RESEARCH | R-03 | Commit refresh replay lineage kill before returning `invalid_grant` | 03, 06 | COVERED | Persisted post-error evidence and concurrency tests. |
| RESEARCH | R-04 | Ordered RFC3986 redirects and endpoint-specific parsers | 02, 05-06 | COVERED | Exact bytes/parity. |
| RESEARCH | R-05 | No new package; standard-library crypto/HTTP and package-legitimacy audit not applicable | 01-06 | COVERED | T-08-SC included in every threat model. |
| RESEARCH | R-06 | 103 PHP-method audit and complete validation architecture | 06 | COVERED | Exact distribution and executable missing-name gate. |
| RESEARCH | R-07 | Real MCP `/me` prerequisite and 64 KiB DCR bound | 01, 04-06 | COVERED | Both resolved questions are locked as D-20/D-21. |
| CONTEXT | D-01 | Direct stdlib port; no zitadel/oidc; correct roadmap/requirement wording | 01, planning update | COVERED | No dependency install. |
| CONTEXT | D-02 | Query/form/JSON parameter sources | 01-02, 05-06 | COVERED | JSON token rejection, ParseForm precedence, JSON-only register. |
| CONTEXT | D-03 | TTLs, caps, issuer/resource/consent configuration | 01-03 | COVERED | Exact PHP defaults in plan 01. |
| CONTEXT | D-04 | Full T-08 security treatment and constant-time comparisons | 01-06 | COVERED | Independent security agent and blocking approval in 06. |
| CONTEXT | D-05 | `wristband` owns RFC surface/state machine | 01-03 | COVERED | Framework structure and import boundary explicit. |
| CONTEXT | D-06 | PHP-minimal response shapes are defaults; no hooks | 01-03, 05 | COVERED | Exact response/header tests and parity. |
| CONTEXT | D-07 | App stores, issuer, transaction boundary, row locks | 01-03 | COVERED | Real Postgres concurrency tests. |
| CONTEXT | D-08 | App owns consent and connected apps | 02-03 | COVERED | Exact UI payloads and ownership. |
| CONTEXT | D-09 | Raw routes and per-route token/register throttles | 01-02, 06 | COVERED | Route-table inspection. |
| CONTEXT | D-10 | Retire reserved oauth guard; access stays `inv_token` | 01-04, 06 | COVERED | Negative guard/source tests. |
| CONTEXT | D-11 | Preserve configured `inv_` prefix | 02, 04-05 | COVERED | Actual MCP install/HTTP consumption. |
| CONTEXT | D-12 | Backend Basic challenge; MCP owns rich Bearer/resource metadata | 01-06 | COVERED | Unit, route, and real-process evidence. |
| CONTEXT | D-13 | Replay projected MCP flows in Go tests | 05 | COVERED | Stable named-step projections fail on disappearance. |
| CONTEXT | D-14 | Full real Node MCP lifecycle gate | 05-06 | COVERED | Includes discovery, DCR, PKCE, login/consent, `/me`, tool, refresh. |
| CONTEXT | D-15 | No live vendor connection in Phase 8 | — | EXCLUDED | Deferred to cutover by explicit decision. |
| CONTEXT | D-16 | Record clean `mcp-lifecycle`; nine routes ported honestly | 05 | COVERED | Secret-scrubbed fixture and corpus audit. |
| CONTEXT | D-17 | On-request expiry sweep, expired rows only | 01, 03 | COVERED | No timer/goroutine; replay evidence retained. |
| CONTEXT | D-18 | Port every named PHP OAuth test | 01-06 | COVERED | Final one-to-one 103-method map. |
| CONTEXT | D-19 | Exact app-side `fonoteka:oauth-client` | 04 | COVERED | Repeatable bonfire flags and one-time secret. |
| CONTEXT | D-20 | Exact personal-token `/me` MCP prerequisite | 04-05 | COVERED | Existing guard/middleware and positive allow-list. |
| CONTEXT | D-21 | Register body bounded at 64 KiB with native error | 01, 06 | COVERED | Bound precedes JSON decode. |
| VALIDATION | W0-01 | Framework metadata/authorize/token/register/PKCE/refresh tests | 01-03, 06 | COVERED | Fast in-memory tests plus audit. |
| VALIDATION | W0-02 | Real-Postgres migration/store locking/replay/sweep tests | 01-03, 06 | COVERED | Existing auth TestMain harness. |
| VALIDATION | W0-03 | Raw routing/parser/rate/body/header isolation | 01-02, 06 | COVERED | Assembled route tests. |
| VALIDATION | W0-04 | Consent/collection/connected-app ownership | 02-03, 06 | COVERED | Real-Postgres controllers. |
| VALIDATION | W0-05 | Nine routes, lifecycle replay, 103-method map | 05-06 | COVERED | Corpus/fixture/map gates. |
| VALIDATION | W0-06 | Personal-token `/me` | 04-06 | COVERED | MCP startup prerequisite. |
| VALIDATION | W0-07 | Full unchanged MCP and security gate | 05-06 | COVERED | Final script plus review checkpoint. |
| UI-SPEC | UI-01 | Nuxt remains unchanged | 02-06 | COVERED | Git status checks and backend-only files. |
| UI-SPEC | UI-02 | Consent read/allow/deny states and exact payload/status/redirect semantics | 02, 05-06 | COVERED | Includes stale/foreign/used 404 and empty-scope 422. |
| UI-SPEC | UI-03 | Connected-app empty/populated/error/list/revoke contracts | 03, 05-06 | COVERED | Positive allow-list, manual count, identical 404. |
| UI-SPEC | UI-04 | Untrusted names/hosts, scope order, server-derived collection, no secrets | 02-03, 06 | COVERED | Sanitization, host-only, intersection, output audits. |
| UI-SPEC | UI-05 | Existing accessibility/responsive/i18n behavior is preserved | 02-03, 05 | COVERED | No frontend changes; exact data/state selectors exercised. |
## Deferred and Out-of-Scope Audit
- Summer-themed token prefix: excluded; `inv_` remains locked.
- River expiry job: excluded; request-time sweep ships here and River remains Phase 11.
- Generic framework client command: excluded; app command ships in Plan 04.
- Live Claude/ChatGPT/Grok connection: excluded; scripted SDK plus unchanged MCP is the Phase 8 acceptance gate.
- Social login and oauth-identities: excluded; no plan creates those routes.
- Frontend redesign/new UI: excluded; Nuxt must remain unchanged.
No required source item is missing.

View File

@@ -39,13 +39,13 @@ created: 2026-09-23
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 08-W0-01 | TBD | 0 | AUTH-05 | T-08-PKCE / T-08-CODE-REPLAY | Metadata, authorize, PKCE S256, code exchange, refresh, DCR, and ordered redirects have deterministic framework tests | unit | `go test ./wristband -run 'Test(Metadata|Authorize|Token|Register|PKCE|Refresh)' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-02 | TBD | 0 | AUTH-05, AUTH-07 | T-08-CODE-REPLAY / T-08-REFRESH-REPLAY | Nullability, row locks, single-use codes, committed lineage kill, sweeps, and indexes work on real Postgres | integration | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/classes/... -run 'TestOAuth' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-03 | TBD | 0 | AUTH-06 | T-08-DCR-FLOOD / T-08-SURFACE | Raw routing, parser rules, rate limits, 64 KiB DCR bound, exact bare bodies, and headers remain isolated from house middleware | route/integration | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestOAuth' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-04 | TBD | 0 | AUTH-07 | T-08-SCOPE-CEILING / T-08-CROSS-USER | Consent, active-collection binding, connected-app ownership, list, and revoke semantics match PHP | Postgres integration | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/controllers/... -run 'TestOAuth' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-05 | TBD | 0 | AUTH-05, AUTH-06, AUTH-07 | T-08-REQUEST-LEAK / T-08-SURFACE | Nine manifest routes plus `mcp-lifecycle` replay exactly and every one of 103 PHP OAuth/security methods maps to a named Go test | parity/corpus | `cd ../fonoteka.go && go test ./parity -run 'TestOAuthFlows|TestParityCorpus' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-06 | TBD | 0 | AUTH-07 | T-08-SURFACE | Minimal authenticated `/api/v1/fonoteka/me` lets the unchanged MCP process initialize without expanding the profile API surface | integration/e2e | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestMe|TestTokenSurface' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-07 | TBD | 0 | AUTH-05, AUTH-07 | All T-08 threats | Real SDK discovery, DCR, PKCE, JWT consent, token, MCP tool call, refresh/replay, connected-app revoke, and post-revoke failure complete unchanged | e2e | `scripts/check-phase8.sh` | ❌ W0 | ⬜ pending |
| 08-W0-01 | 08-01, 08-02, 08-03, 08-06 | 1-3, 6 | AUTH-05 | T-08-PKCE / T-08-CODE-REPLAY | Metadata, authorize, PKCE S256, code exchange, refresh, DCR, and ordered redirects have deterministic framework tests | unit | `go test ./wristband -run 'Test(Metadata|Authorize|Token|Register|PKCE|Refresh)' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-02 | 08-01, 08-03, 08-06 | 1, 3, 6 | AUTH-05, AUTH-07 | T-08-CODE-REPLAY / T-08-REFRESH-REPLAY | Nullability, row locks, single-use codes, committed lineage kill, sweeps, and indexes work on real Postgres | integration | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/classes/... -run 'TestOAuth' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-03 | 08-01, 08-02, 08-06 | 1, 2, 6 | AUTH-06 | T-08-DCR-FLOOD / T-08-SURFACE | Raw routing, parser rules, rate limits, 64 KiB DCR bound, exact bare bodies, and headers remain isolated from house middleware | route/integration | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestOAuth' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-04 | 08-02, 08-03, 08-06 | 2, 3, 6 | AUTH-07 | T-08-SCOPE-CEILING / T-08-CROSS-USER | Consent, active-collection binding, connected-app ownership, list, and revoke semantics match PHP | Postgres integration | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/controllers/... -run 'TestOAuth' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-05 | 08-05, 08-06 | 5, 6 | AUTH-05, AUTH-06, AUTH-07 | T-08-REQUEST-LEAK / T-08-SURFACE | Nine manifest routes plus `mcp-lifecycle` replay exactly and every one of 103 PHP OAuth/security methods maps to a named Go test | parity/corpus | `cd ../fonoteka.go && go test ./parity -run 'TestOAuthFlows|TestParityCorpus' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-06 | 08-04, 08-05 | 4, 5 | AUTH-07 | T-08-SURFACE | Exact authenticated `/api/v1/fonoteka/me` lets the unchanged MCP process initialize without expanding the profile API surface | integration/e2e | `cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestMe|TestTokenSurface' -count=1` | ❌ W0 | ⬜ pending |
| 08-W0-07 | 08-05, 08-06 | 5, 6 | AUTH-05, AUTH-07 | All T-08 threats | Real SDK discovery, DCR, PKCE, JWT consent, token, MCP tool call, refresh/replay, connected-app revoke, and post-revoke failure complete unchanged | e2e | `scripts/check-phase8.sh` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*