lock, consume code, mint access token, and create refresh token atomically
WithinTx
from
to
via
pattern
oauth_consent_controller.go
wristband.Server
issue-code and deny operations rather than direct OAuth row mutation
IssueCode|Deny
from
to
via
pattern
oauth_token_issuer.go
api_token_manager.go
configured-prefix personal-token mint with oauth_client_id stamp
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.
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.
<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>
- `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.
<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>
Create `.planning/phases/08-oauth2-1-authorization-server/08-02-SUMMARY.md` when done.