--- 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. Create `.planning/phases/08-oauth2-1-authorization-server/08-02-SUMMARY.md` when done.