---
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"
---
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.
## 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.
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
@.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
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.
Task 1: Specify the complete PKCE consent and code-exchange path
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
.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
- 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.
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.
test -f wristband/authorize_test.go && test -f wristband/token_test.go && cd ../fonoteka.go && test -f plugins/golem15/fonoteka/oauth_connect_test.go
- 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.
The RED suite proves the exact end-to-end connection contract and every high-severity boundary before implementation.
Task 2: Implement authorize validation and atomic authorization-code exchange
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
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
- Authorize reads query only and validates client then redirect before any redirect-capable error.
- Ordered redirect encoder retains existing query, appends with `&`, 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.
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.
go test ./wristband -run 'Test(Authorize|Token|PKCE|Code)' -count=1 && cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/classes/auth -run 'TestOAuth(Code|Issuer)' -count=1
- 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.
The framework can safely create pending authorization state and atomically exchange one PKCE-bound code for the existing personal-token model.
Task 3: Wire JWT consent and the raw authorize/token routes
../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
../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
- 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.
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.
cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/... -run 'TestOAuth(Authorize|Consent|Deny|CodeExchange|Surface)' -count=1
- 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.
The unchanged Nuxt consent page and a registered PKCE client complete one secure authorization-code flow through the assembled Go app.
## 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. |
- `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.
- 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.