--- phase: 08-oauth2-1-authorization-server plan: 03 subsystem: auth tags: [oauth2, rfc6749, pkce, wristband, raw-routes, rfc3986, standard-library] # Dependency graph requires: - phase: 08-oauth2-1-authorization-server plan: 02 provides: "wristband.Backend/Tx transaction-scoped store bundle (ClientStore/AuthCodeStore/RefreshTokenStore/AccessTokenIssuer), the fonoteka OAuthStore GORM adapter, and the corrected OAuth schema" provides: - "wristband.Server.Authorize: exact port of OAuthAuthorizeController::authorize's validation order (usable client, exact redirect, response_type, S256 syntax, scope/ceiling, resource) and its local-400-vs-trusted-redirect open-redirect defense" - "An RFC 3986 ordered-pair query encoder (buildOrderedQuery/appendOrderedQuery/rfc3986Escape) used for every authorize error redirect, distinct from net/url.Values.Encode's key-sorting and '+'-for-space behavior" - "GET /oauth/mcp/authorize mounted raw with zero middleware on the assembled fonoteka.go app; golem15.fonoteka.oauth.resource and .pending_request_ttl_seconds wired into wristband.Options" affects: [08-04-token-exchange, 08-05-consent-and-connected-apps, 08-06-lifecycle-and-sweeps, 08-09-parity-and-real-mcp-gate, 08-10-unit-tests-and-security-review] # Tech tracking tech-stack: added: [] patterns: - "Two-phase client/redirect validation: an unknown or unusable client, or a redirect_uri that is not an exact member of the client's registered list, is a local text/plain 400 with no Location; only after both pass does any later failure construct a redirect to the now-trusted URI (T-08-OPEN-REDIRECT)" - "Ordered RFC 3986 query encoding for OAuth error redirects: percent-encode only non-unreserved bytes (space becomes %20, not '+'), never sort keys — implemented as three small package-level functions (rfc3986Escape/buildOrderedQuery/appendOrderedQuery) rather than reusing net/url.Values.Encode" - "Scope-ceiling truncation happens before the offline_access peel: a client's ScopeCeiling keeps offline_access unconditionally (never counted as a 'data' scope for the empty-intersection check) and truncates everything else; the offline_access boolean is only split out of the scope list at pending-row-creation time, matching PHP's createPendingRequest" key-files: created: [] modified: - wristband/authorize.go - wristband/authorize_test.go - wristband/server.go - ../fonoteka.go/plugins/golem15/fonoteka/plugin.go - ../fonoteka.go/plugins/golem15/fonoteka/routes.go - ../fonoteka.go/plugins/golem15/fonoteka/oauth_authorize_test.go key-decisions: - "Options gained Resource and PendingRequestTTL fields (not listed in the plan's files_modified for server.go) to carry the RFC 8707 expected-resource value and the 600s pending-request TTL as deployment-configurable values, following the same Options-extension pattern 08-02 established for DCRClientCap/DCRUnconsentedSweepAge/RegisterMaxBodyBytes" - "authorize's allowed-scope set is authorizeAllowedScopes, a package-level constant matching PHP's private ALLOWED_SCOPES, not wristband's Options.ScopesSupported (which is the RFC 8414 metadata field) — the two happen to share the same PHP-default values but are conceptually distinct config surfaces, matching the PHP source's own separation" - "Client lookup and pending-row creation each open their own wristband.Backend.WithinTx call (not one transaction spanning the whole request), matching PHP's own lack of a wrapping DB transaction around authorize; only DCR's sweep+cap+create (08-02) and later code-exchange/refresh-rotation (08-04) need single-transaction atomicity" patterns-established: - "RFC 3986 ordered-pair redirect encoding is now the shared pattern 08-04's token-endpoint success/error redirects and 08-05's consent/deny redirects will reuse via the same buildOrderedQuery/appendOrderedQuery helpers in wristband/authorize.go" requirements-completed: [] # Metrics duration: ~20min completed: 2026-09-23 --- # Phase 08 Plan 03: Authorize Summary **`wristband.Server.Authorize` ports the exact PHP authorize-request validation order and open-redirect defense, persists an opaque PKCE-bound pending row through the transaction-scoped store bundle, and is now connector-visible raw on the assembled Go app.** ## Performance - **Duration:** ~20 min - **Started:** 2026-09-23T19:55:00Z (approx.) - **Completed:** 2026-09-23T20:08:45+02:00 - **Tasks:** 2 completed (4 commits: RED/GREEN pairs across both repos) - **Files modified:** 6 (3 in summercms.go's wristband package, 3 in fonoteka.go) ## Accomplishments - `wristband.Server.Authorize` is a byte-for-byte port of `OAuthAuthorizeController::authorize`'s validation order: usable client, exact redirect-URI membership, `response_type=code`, `code_challenge_method=S256`, challenge length (43-128), scope parsing with a `["read"]` default, client scope-ceiling truncation (preserving `offline_access` and never rejecting except on an empty data-scope intersection), and the RFC 8707 resource check — only after all of these does it persist a pending row and redirect - An unknown/unusable client or an unregistered `redirect_uri` returns a local `text/plain; charset=UTF-8` 400 with no `Location` header (T-08-OPEN-REDIRECT); every later validation failure redirects to the now-trusted `redirect_uri` with an ordered `error`, `error_description`, `iss`, optional `state` query built by a dedicated RFC 3986 encoder (`rfc3986Escape`/`buildOrderedQuery`/`appendOrderedQuery`) that never uses `net/url.Values.Encode` (which sorts keys and encodes spaces as `+`) - A valid S256 request persists exactly one durable, hash-only-free pending `AuthCodeRecord` (nil `CodeHash`, nil `UserID`, 600s expiry from `Options.PendingRequestTTL`) and redirects to `/connect?request=` with `Cache-Control: no-store` and no `code`/`state`/`client_secret` leaked onto the app's own redirect - `GET /oauth/mcp/authorize` is now mounted on the assembled fonoteka.go raw route group with zero middleware (D-09); `golem15.fonoteka.oauth.resource` and `.pending_request_ttl_seconds` are wired from config into `wristband.Options` in `Plugin.Boot` - 08-01 metadata and 08-02 DCR exact-byte tests, plus the full `plugins/golem15/fonoteka` module test suite (including `-race`), remain green ## Task Commits Each task was committed atomically (TDD RED then GREEN, split per repo since both repos changed): 1. **Task 1: authorize RED anchor** — `787e612` (test, summercms.go): `Server.Authorize` 501 stub, `TestPhase8RedAuthorize` fails the exact S256 success contract against it; `57049f8` (test, fonoteka.go): `TestPhase8RedAuthorizeApp` fails 404 against the unmounted route. Both verified fail-closed via `scripts/check-phase8-red.sh`. 2. **Task 2: implement and mount authorize** — `90752be` (feat, summercms.go): the real `Authorize` handler, the RFC 3986 ordered-query encoder, `Options.Resource`/`Options.PendingRequestTTL`, and the full framework-level test matrix (`TestAuthorize*`, `TestPKCE*`, `TestOrderedRedirect*`); `804da83` (feat, fonoteka.go): mounts the raw route, wires config into `Options`, and adds `TestOAuthAuthorizeAssembled`, `TestOAuthAuthorizeInvalidRequestsCreateNoPendingRows`, `TestOAuthAuthorizeRawRouteSurface`. **Plan metadata:** committed as part of this summary/state-update commit. _Note: both tasks carry `tdd="true"`; RED/GREEN pairs land as separate commits, and Task 2 splits its GREEN across the two repositories it touches._ ## Files Created/Modified - `wristband/authorize.go` — `Server.Authorize`, `writeAuthorizeLocalError`, `authorizeErrorRedirect`, `parseAuthorizeScopes`, `stringSliceContains`, `rfc3986Escape`/`isRFC3986Unreserved`, `buildOrderedQuery`, `appendOrderedQuery` - `wristband/authorize_test.go` — `TestPhase8RedAuthorize` (RED anchor) plus the full unit matrix: unknown/revoked client, unregistered/trailing-slash redirect, response-type/PKCE-method/PKCE-length failures, valid success + pending-row assertions, `iss`-on-every-error, scope default/invalid/ceiling truncation/ceiling-rejection (4 cases mirroring the PHP test file), resource mismatch/omission, RFC3986 encoding fixtures, backend-unavailable 500 - `wristband/server.go` — `Options` gains `Resource` and `PendingRequestTTL` with PHP-parity defaults - `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` — wires `golem15.fonoteka.oauth.resource`/`.pending_request_ttl_seconds` into `wristband.Options` - `../fonoteka.go/plugins/golem15/fonoteka/routes.go` — mounts `GET /oauth/mcp/authorize` on the raw group with no middleware - `../fonoteka.go/plugins/golem15/fonoteka/oauth_authorize_test.go` — `TestPhase8RedAuthorizeApp` (RED anchor), `TestOAuthAuthorizeAssembled`, `TestOAuthAuthorizeInvalidRequestsCreateNoPendingRows`, `TestOAuthAuthorizeRawRouteSurface` ## Decisions Made See frontmatter `key-decisions`. Most notable: `Options` was extended with `Resource`/`PendingRequestTTL` even though `wristband/server.go` was not in the plan's `` list for either task — this follows the exact precedent 08-02 set when it extended the same struct for DCR's cap/sweep/body-limit options, and is necessary to make the resource check and 600s TTL configurable rather than hardcoded (Rule 2 — auto-add missing critical functionality implied by the task's own action text: "600-second expiry using the configured server/backend from 08-02"). ## Deviations from Plan **1. [Rule 2 - Missing functionality] Extended `wristband.Options` with `Resource` and `PendingRequestTTL`** - **Found during:** Task 2 (implementing the resource check and pending-request expiry) - **Issue:** The plan's action text requires the resource check and 600-second expiry to be "configured" through the server, but neither value existed on `Options` yet (only DCR-related fields were added in 08-02) - **Fix:** Added `Options.Resource` (default `"https://mcp.plytarium.com/mcp"`) and `Options.PendingRequestTTL` (default `600 * time.Second"`) to `DefaultOptions()`, and wired both from `golem15.fonoteka.oauth.resource`/`.pending_request_ttl_seconds` config in `plugin.go` - **Files modified:** `wristband/server.go`, `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` - **Verification:** `TestAuthorizeResourceMismatchRedirectsInvalidTarget`, `TestAuthorizeResourceOmittedIsAccepted`, and the pending-row `ExpiresAt` assertion in `TestAuthorizeValidRequestRedirectsToConnectWithOpaqueHandleOnly` all pass - **Committed in:** `90752be` (Task 2 GREEN commit, summercms.go) --- **Total deviations:** 1 auto-fixed (1 missing functionality) **Impact on plan:** No scope change; a struct-field addition following an established precedent, required by the task's own stated action. ## Issues Encountered Full-suite verification (`go vet`/`go test ./...` in both repos, per CLAUDE.md) surfaced two pre-existing, out-of-scope failures unrelated to this plan's changed files — logged to `deferred-items.md` rather than fixed here (scope-boundary rule): - `fonoteka.go`'s root-module `parity` package: `TestRemainingMigrationsUpDown` and `TestRollbackIsolatesFonotekaFullSchema` fail because 08-02's `202609230019_oauth_schema_correction` migration is now the last registered migration, which these Phase-5-era tests' hardcoded "last migration" assertions don't know about. Neither test file nor any migration/model file is in this plan's `files_modified`. - `summercms.go`'s `fetchguard` package: `TestFetchTooLargeIsStreaming` is intermittently flaky under the full `go test ./...` run but passes reliably in isolation; `fetchguard` is untouched by this plan. The module this plan actually modifies (`fonoteka.go/plugins/golem15/fonoteka`) is fully green, including `go vet` and `go test -race ./...`. ## User Setup Required None — no external service configuration required. ## Next Phase Readiness - The RFC 3986 ordered-query encoder (`buildOrderedQuery`/`appendOrderedQuery`) is ready for 08-04's token-endpoint redirects and 08-05's consent/deny redirects to reuse directly. - The pending `AuthCodeRecord` created by `Authorize` (hash-only-free, `RequestID` set, `CodeHash`/`UserID` nil) is exactly the shape 08-05's consent flow needs to read via `AuthCodeStore.ByRequestID` and turn into an issued code via `MarkIssued`. - `Options.Resource`/`Options.PendingRequestTTL` establish the pattern for 08-04 to add `Options.CodeTTL`/`AccessTokenTTL`/`RefreshTokenTTL` the same way. - AUTH-05/AUTH-06/AUTH-07 remain Pending in REQUIREMENTS.md, continuing 08-01/08-02's decision: this plan ships authorize only; consent, token exchange, and refresh remain for 08-04/08-05. - No blockers. ## Self-Check: PASSED - FOUND: wristband/authorize.go, wristband/authorize_test.go, wristband/server.go, .planning/phases/08-oauth2-1-authorization-server/08-03-SUMMARY.md - FOUND: ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, routes.go, oauth_authorize_test.go - FOUND commits (summercms.go): 787e612, 90752be - FOUND commits (fonoteka.go): 57049f8, 804da83