COMPANION-APP-PHASE-3-OAUTH-PKCE.md
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | # Companion App — Phase 3: OAuth Native/Public Client (PKCE + Loopback Redirect) |
| 2 | |
| 3 | **Status:** accepted design + implementation (pure protocol core + pure custody; **no socket |
| 4 | bound, no network, no real keychain I/O**). |
| 5 | **Branch:** `feat/companion-app` (Muse-canonical; not a docs-only PR to `main`). |
| 6 | **Phase table ref:** Gate §12, Phase 3 — 🧠 Thinking. "Auth/crypto protocol correctness (PKCE, |
| 7 | redirect handling, keychain). Subtle deviations create real account-compromise paths." |
| 8 | **Depends on:** Phase 0 Decision Record (gate §13, D1–D3), Phase 1 adapter seam |
| 9 | ([`COMPANION-APP-PHASE-1-ADAPTER-SEAM.md`](COMPANION-APP-PHASE-1-ADAPTER-SEAM.md)), Phase 2 |
| 10 | loopback security core ([`COMPANION-APP-PHASE-2-LOOPBACK-SECURITY.md`](COMPANION-APP-PHASE-2-LOOPBACK-SECURITY.md)). |
| 11 | **Upstream:** [`COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md`](COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md) |
| 12 | §3 (OAuth model), §4.1 (per-session bearer token), §12 phase table row 3, the "DOES NOT approve" |
| 13 | list; [`COMPANION-APP-MODEL-ROUTING-AND-ENRICHMENT-ARCHITECTURE.md`](COMPANION-APP-MODEL-ROUTING-AND-ENRICHMENT-ARCHITECTURE.md) |
| 14 | §3 (client-side constraint), §5 (OAuth for the companion). |
| 15 | |
| 16 | --- |
| 17 | |
| 18 | ## Simple summary |
| 19 | |
| 20 | When you sign the companion app into Knowtation, it should log you in **the same way a normal |
| 21 | website login works**, but safely for a program running on your own computer. The safe pattern for |
| 22 | "native" apps (desktop/mobile) is well established and written down in internet standards: |
| 23 | |
| 24 | - It opens your **real web browser** (never a fake in-app browser), so you can see the genuine |
| 25 | Knowtation/Google/GitHub login page and your password manager works. |
| 26 | - It uses a trick called **PKCE** so that even if another program on your machine grabs the |
| 27 | one-time login "code" as it comes back, that code is **useless** without a secret the companion |
| 28 | kept to itself. |
| 29 | - It carries **no password and no app secret baked into the download** — there is nothing to steal |
| 30 | by cracking open the installed app. |
| 31 | - It puts the resulting login token in the **operating system's secure keychain**, not a plain file |
| 32 | and never in a log. |
| 33 | |
| 34 | This phase builds and exhaustively tests the **rules of that handshake as pure math** — generate |
| 35 | the secret, build the login link, check the reply hasn't been tampered with, build the token |
| 36 | request, check the token reply, and decide when to refresh. It deliberately does **not** yet open |
| 37 | any network port, talk to the internet, open the browser, or touch the real keychain. Those |
| 38 | physical actions are the single most dangerous step and are bundled into a later, separately |
| 39 | approved step (Phase 5), exactly as Phase 2 did with its socket. |
| 40 | |
| 41 | ## Technical summary |
| 42 | |
| 43 | Phase 3 delivers two pure modules and their 7-tier suites: |
| 44 | |
| 45 | - **`lib/companion-oauth-pkce.mjs`** — the Authorization-Code-with-PKCE protocol core for a |
| 46 | **native/public client** (RFC 7636, RFC 8252), **S256-only**, with RFC 9207 `iss` support: |
| 47 | `createPkcePair`, `computeCodeChallenge`, `createOAuthState`, `createNonce`, |
| 48 | `buildAuthorizationUrl`, `validateRedirectUri`, `validateAuthorizationResponse`, |
| 49 | `buildTokenRequest`, `buildRefreshRequest`, `validateTokenResponse`, `decideTokenRefresh`, |
| 50 | plus the `constantTimeEqual` primitive. No socket, no `fetch`, no env, no clock, no logging. |
| 51 | - **`lib/companion-token-custody.mjs`** — pure custody logic over an **injected** keychain adapter |
| 52 | (`{ get, set, delete }`): `buildSessionMeta`, `createTokenCustody(...)` → |
| 53 | `storeSession`/`loadSession`/`clearSession`/`updateAccessToken`/`decide` plus the Phase 2 |
| 54 | per-session loopback-token lifecycle (`storeLoopbackToken`/`getLoopbackToken`/ |
| 55 | `rotateLoopbackToken`/`clearLoopbackToken`). It performs **no real keychain I/O** — Phase 5 |
| 56 | supplies the OS-backed adapter (Keychain / DPAPI / libsecret); tests inject an in-memory fake. |
| 57 | |
| 58 | This scope is deliberate and gate-compliant. The gate's "DOES NOT approve" list forbids **"no new |
| 59 | local HTTP listener"** and **"Any change to OAuth client registration or scopes."** Phase 3 binds |
| 60 | no listener (the RFC 8252 loopback **redirect** listener is bound, together with the Phase 2 |
| 61 | inference socket, only at the Phase 5 bind gate), registers no OAuth client, and alters no scopes |
| 62 | (the authorization server, `client_id`, and scope list are all **injected inputs** — the core is |
| 63 | provider-agnostic). Everything is **fail-closed**: any missing/ambiguous/malformed input denies, |
| 64 | and no token / JWT / refresh token / authorization code / `code_verifier` / `state` ever appears in |
| 65 | a `reason`, a log value, or a thrown error. |
| 66 | |
| 67 | --- |
| 68 | |
| 69 | ## 1. Scope decisions (owner-approved 2026-06-05) |
| 70 | |
| 71 | ### D-P3.1 — Pure-then-bind (CONFIRMED) |
| 72 | |
| 73 | Phase 3 builds the OAuth/PKCE **protocol core** and the **custody decision layer** as pure, |
| 74 | fully-tested, I/O-free functions. **No socket bind, no network fetch, no system-browser launch, no |
| 75 | real OS-keychain I/O.** The loopback redirect listener bind, the TLS POST to the token endpoint, |
| 76 | opening the system browser, and the real Keychain/DPAPI/libsecret calls are deferred to **Phase 5** |
| 77 | (the shared bind gate that also opens the Phase 2 inference socket) — see [§6](#6-what-phase-5-must-do-to-bind-safely). |
| 78 | This mirrors how Phase 1 and Phase 2 shipped pure logic and deferred I/O. |
| 79 | |
| 80 | ### D-P3.2 — Provider-agnostic core; client-registration boundary respected (CONFIRMED) |
| 81 | |
| 82 | Verified against source (`hub/gateway/mcp-oauth-provider.mjs`, `hub/gateway/server.mjs`): |
| 83 | |
| 84 | - The existing MCP OAuth provider **is already a public client + PKCE + dynamic client |
| 85 | registration** (it mints a `client_id` and stores **no `client_secret`**), and the MCP SDK token |
| 86 | handler performs the actual PKCE verification (`challengeForAuthorizationCode` → stored challenge, |
| 87 | then `S256(code_verifier)` compare). A native client **can** register a loopback `redirect_uri` |
| 88 | via dynamic registration. |
| 89 | - **However**, two facts mean "the companion gets the **same JWT / same scopes** as the web |
| 90 | session" (gate §3, brief §5) is **not** delivered by that provider as-is: |
| 91 | 1. **Token/scope mismatch.** The web-session JWT (`issueToken`) is `{ sub, provider, id, name, |
| 92 | role }` with **no `scopes` claim** — scopes are role-derived at introspection |
| 93 | (`scopesForRole`: member → `[vault:read, vault:write]`). The MCP path issues a **different** |
| 94 | token, `type:'mcp_access'`, defaulting to **`['vault:read']`** (read-only). |
| 95 | 2. **Deployment.** The MCP OAuth provider is mounted only when `SESSION_SECRET && !NETLIFY` |
| 96 | (persistent server) and is **explicitly skipped on the hosted Netlify gateway**. |
| 97 | |
| 98 | **Decision:** Phase 3 is **provider-agnostic**. `authorizationEndpoint`, `tokenEndpoint`, |
| 99 | `clientId`, and `scopes` are all **injected inputs**; the core hardcodes none of them, registers no |
| 100 | client, and changes no scope. Whether the native/loopback client is issued **web-session-equivalent |
| 101 | scopes** (read **and** write — required for the companion to write `ai_summary` enrichment back per |
| 102 | D3/§6) and whether the PKCE provider runs on the hosted deployment is a **separate server-side OAuth |
| 103 | gate** and a **Phase 5 prerequisite** (see [§7](#7-server-side-oauth-gate-phase-5-prerequisite)). |
| 104 | This keeps Phase 3 strictly inside the gate while making the server-side gap explicit rather than |
| 105 | smuggled in. |
| 106 | |
| 107 | ### D-P3.3 — RFC 9207 `iss`: optional-but-validated (CONFIRMED) |
| 108 | |
| 109 | `validateAuthorizationResponse` supports an `expectedIssuer`. **If** an `expectedIssuer` is supplied |
| 110 | **and** the callback carries `iss`, an exact **constant-time** match is required (a mismatch is |
| 111 | rejected — the property that actually stops a mix-up). If `iss` is absent it is **tolerated** for |
| 112 | back-compat (the current provider does not emit `iss` yet). The day the server emits `iss`, clients |
| 113 | that pass `expectedIssuer` get full mix-up protection with **zero client change**. Emitting `iss` |
| 114 | on the redirect is a documented server-side follow-up (part of the §7 gate). |
| 115 | |
| 116 | --- |
| 117 | |
| 118 | ## 2. Adversarial threat model → exact control |
| 119 | |
| 120 | The native-app OAuth flow's most dangerous moment is the **authorization-code round-trip on the |
| 121 | loopback redirect**: any local process can race for the code, and any page/AS can try to confuse |
| 122 | the exchange. Each attacker capability below is paired with the **exact** control that stops it, |
| 123 | argued against the attacker — not pattern-matched. |
| 124 | |
| 125 | | # | Attacker capability | Exact control | Where | |
| 126 | | --- | --- | --- | --- | |
| 127 | | **a** | **Authorization-code interception** by a malicious local app listening on / racing the loopback redirect. | **PKCE S256**: the code is bound to the `code_verifier`. The attacker captures the code but not the verifier (it never leaves the companion until the TLS token POST), so the token exchange fails (`invalid_grant`). | `createPkcePair` / `computeCodeChallenge` (S256 only); `buildTokenRequest` carries `code_verifier`; proven in `…-e2e` "PKCE interception attack fails". | |
| 128 | | **b** | **CSRF / session-fixation** on the callback (attacker injects their own code/state). | **`state`**: high-entropy CSPRNG value bound to the pending request, compared in **constant time**; mismatch/absence denies. Single-use (caller discards after one callback). | `createOAuthState`; `validateAuthorizationResponse` (`STATE_MISMATCH`/`STATE_MISSING`); `constantTimeEqual`. | |
| 129 | | **c** | **Authorization-server / redirect mix-up** (client juggling >1 AS is fed a response from the wrong one). | **RFC 9207 `iss`** constant-time match when present (D-P3.3); plus exact loopback redirect validation. | `validateAuthorizationResponse` (`ISSUER_MISMATCH`). | |
| 130 | | **d** | **PKCE downgrade to `plain`** (strip S256 so a captured challenge == verifier). | **S256 enforced**: the client never constructs a non-S256 request and there is no `plain` code path at all. | `buildAuthorizationUrl` throws on any non-`S256` method; `computeCodeChallenge` is S256-only. | |
| 131 | | **e** | **Open-redirect / `redirect_uri` manipulation** (point the redirect at an attacker target). | **Strict RFC 8252 loopback-literal allowlist, no wildcard**: only `http://127.0.0.1:<port>` / `[::1]` (or an explicit caller allowlist), explicit numeric port, no userinfo/query/fragment. | `validateRedirectUri`; enforced inside `buildAuthorizationUrl` + `buildTokenRequest`. | |
| 132 | | **f** | **JWT / refresh-token theft at rest** (read a dotfile / env / log). | **OS keychain only**, via the injected adapter — never a plaintext file, never env, never logged. Metadata stored separately holds **no** token. | `companion-token-custody.mjs`; proven in `…-custody-security`. | |
| 133 | | **g** | **Client-secret extraction** from the distributed binary. | **Public client, NO secret on device**: no `client_secret` is ever built into a URL or token request, and `extraParams` cannot inject one. | `buildAuthorizationUrl` / `buildTokenRequest` (no secret; `client_secret` dropped). | |
| 134 | | **h** | **Authorization-response replay** (re-send a captured callback). | **One-time `state`** (caller discards → replay fails closed) + **single-use code** (the AS burns it; a second exchange returns `invalid_grant`). | `validateAuthorizationResponse`; `…-security`/`…-e2e` replay tests. | |
| 135 | | **i** | **Embedded-webview phishing** (a fake login UI harvests credentials). | **System browser only** (RFC 8252 §8.12) — Phase 5 launches the OS default browser; an embedded webview is forbidden. The protocol core emits only a URL for the OS to open. | [§6](#6-what-phase-5-must-do-to-bind-safely) (Phase 5 obligation). | |
| 136 | | **j** | **Secret exfiltration via logs/errors.** | Fixed-constant `reason` codes; thrown errors carry fixed messages; success returns the code only through its legitimate return channel. | both modules; `…-security` "no secret in any output". | |
| 137 | |
| 138 | --- |
| 139 | |
| 140 | ## 3. Module contract — `lib/companion-oauth-pkce.mjs` |
| 141 | |
| 142 | All functions are pure (no I/O, no env, no clock, no logging). `now` is always injected. |
| 143 | |
| 144 | | Function | Purpose | Fail-closed behavior | |
| 145 | | --- | --- | --- | |
| 146 | | `createPkcePair()` | `{ codeVerifier, codeChallenge, method:'S256' }`; verifier = 32 CSPRNG bytes base64url (43 chars, ≥256-bit). | — (generator) | |
| 147 | | `computeCodeChallenge(verifier)` | `base64url(SHA-256(ASCII(verifier)))`, S256 only; validates RFC 7636 §4.1 length+charset. | throws fixed-message (no secret) on invalid verifier. | |
| 148 | | `createOAuthState()` / `createNonce()` | 32 CSPRNG bytes base64url (CSRF / replay). | — (generator) | |
| 149 | | `buildAuthorizationUrl({...})` | Pure auth URL: `response_type=code`, `code_challenge_method=S256`, exact loopback `redirect_uri`, injected `client_id` + space-joined `scope` + `state`; optional `nonce`. HTTPS AS endpoint required. | throws on non-S256, non-https AS, bad redirect, missing field; `extraParams` cannot override security params or inject `client_secret`. | |
| 150 | | `validateRedirectUri(uri,{allowedHosts})` | RFC 8252 loopback rules. | `{ok:false, reason}`; reason never carries the URI. | |
| 151 | | `validateAuthorizationResponse({params, expectedState, expectedIssuer?})` | Constant-time state compare; reject `error`; RFC 9207 `iss`; extract `code`. | `{ok:false, reason[, errorCode]}`; **never** carries code/state; only allowlisted RFC 6749 error codes surface; free-text `error_description` never surfaces. | |
| 152 | | `buildTokenRequest({...})` | Pure `authorization_code` request **descriptor** (`grant_type`, `code`, `code_verifier`, `redirect_uri`, `client_id`). **No `fetch`.** | throws on non-https token endpoint, bad verifier, bad redirect; never a `client_secret`. | |
| 153 | | `buildRefreshRequest({...})` | Pure `refresh_token` request descriptor; optional subset `scope`; no secret. | throws on bad config. | |
| 154 | | `validateTokenResponse(json)` | Shape-validate `{ accessToken, refreshToken?, expiresIn, tokenType:'Bearer', scope? }`; requires `token_type=bearer` + positive-integer `expires_in`; length-bounded. | `{ok:false, reason[, errorCode]}` on anything off (incl. oversized). | |
| 155 | | `decideTokenRefresh({expiresAt, now, skewMs?, refreshExpiresAt?})` | `'valid' \| 'refresh' \| 'reauth'`. | malformed/missing input → `'reauth'` (safest). | |
| 156 | | `constantTimeEqual(a,b)` | SHA-256 + `timingSafeEqual`; no length oracle. | non-string/empty → `false` without compare. | |
| 157 | |
| 158 | **Reason codes** (`OAUTH_PKCE_REASONS`, frozen): `ok`, `malformed_input`, |
| 159 | `authorization_server_error`, `state_missing`, `state_mismatch`, `issuer_mismatch`, `missing_code`, |
| 160 | `invalid_redirect_uri`, `unsupported_pkce_method`, `invalid_token_response`. |
| 161 | |
| 162 | ## 4. Module contract — `lib/companion-token-custody.mjs` |
| 163 | |
| 164 | Pure custody over an **injected** `{ get, set, delete }` adapter (sync or Promise-returning; every |
| 165 | call is awaited). Keychain accounts (`KEYCHAIN_ACCOUNTS`): `accessToken`, `refreshToken`, |
| 166 | `sessionMeta` (non-secret), `loopbackToken`. |
| 167 | |
| 168 | - `buildSessionMeta(tokenResponse, { now, refreshTtlMs?, issuer? })` → pure non-secret metadata |
| 169 | (`expiresAt`, `refreshExpiresAt`, `scope`, `tokenType`, `issuer`, `storedAt`). |
| 170 | - `createTokenCustody(adapter)` → |
| 171 | `storeSession({accessToken, refreshToken?, meta})`, `loadSession()` (fail-closed → `null`), |
| 172 | `updateAccessToken({accessToken, meta, refreshToken?})` (refresh rotation), `clearSession()` |
| 173 | (logout / refresh-reuse; removes **both** tokens + meta; **does not** touch the loopback token), |
| 174 | `decide({now, skewMs?})` (delegates to `decideTokenRefresh`; no session → `'reauth'`), and the |
| 175 | loopback lifecycle `storeLoopbackToken`/`getLoopbackToken`/`rotateLoopbackToken`/ |
| 176 | `clearLoopbackToken`. |
| 177 | |
| 178 | **Custody/rotation rules:** |
| 179 | - **JWT (access token):** short-lived; replaced on every refresh (`updateAccessToken`). |
| 180 | - **Refresh token:** rotated whenever the server returns a new one; on `invalid_grant`/reuse the |
| 181 | caller invokes `clearSession()` → force a fresh browser login (mirrors the server-side |
| 182 | reuse-detection family-revoke in `hub/lib/refresh-token-core.mjs`). |
| 183 | - **Phase 2 loopback token:** per-session; **rotated at each companion start** |
| 184 | (`rotateLoopbackToken`), stored under its **own** account (a compromise of one secret is not a |
| 185 | compromise of the other), and **independent** of OAuth logout (survives `clearSession`). |
| 186 | |
| 187 | --- |
| 188 | |
| 189 | ## 5. RFC conformance |
| 190 | |
| 191 | - **RFC 7636 (PKCE).** Verifier per §4.1 (unreserved charset, 43–128 chars, ≥256-bit CSPRNG); |
| 192 | challenge per §4.2 (`S256 = BASE64URL(SHA-256(ASCII(verifier)))`); **S256 only**, `plain` |
| 193 | rejected (§7.2 downgrade defense). The RFC 7636 Appendix B test vector is asserted directly in |
| 194 | `…-unit`. |
| 195 | - **RFC 8252 (OAuth for Native Apps).** Loopback redirect (§7.3) with literal-IP host (§8.3, |
| 196 | `127.0.0.1`/`[::1]` preferred over `localhost`), plain `http` for the loopback redirect only, |
| 197 | variable ephemeral port (the AS must permit it), system browser (§8.12, Phase 5), public client |
| 198 | (§8.5, no secret). |
| 199 | - **RFC 6749 (OAuth 2.0).** Authorization request §4.1.1, token request §4.1.3, refresh §6, |
| 200 | token/error responses §5.1/§5.2, `state` CSRF §10.12. |
| 201 | - **RFC 9207 (Issuer Identification).** `iss` validated when present (mix-up defense), adopted as |
| 202 | optional-but-validated (D-P3.3). |
| 203 | |
| 204 | Crypto uses **Node `node:crypto`** exclusively (`randomBytes`, `createHash`, `timingSafeEqual`) — |
| 205 | no hand-rolled primitives. |
| 206 | |
| 207 | --- |
| 208 | |
| 209 | ## 6. What Phase 5 must do to bind safely |
| 210 | |
| 211 | The pure core is the protocol; Phase 5 (companion shell) performs the I/O — the single most |
| 212 | security-critical step, behind an explicit gate, binding **both** the Phase 2 inference socket and |
| 213 | this phase's loopback **redirect** listener. When Phase 5 binds, it MUST: |
| 214 | |
| 215 | 1. **Open the SYSTEM browser, never an embedded webview** (RFC 8252 §8.12). Launch the OS default |
| 216 | browser with the string from `buildAuthorizationUrl`. An in-app webview is forbidden (attacker |
| 217 | capability **i**). |
| 218 | 2. **Bind the loopback redirect listener on `127.0.0.1` (or `[::1]`) with an OS-assigned ephemeral |
| 219 | port** (`listen(0, '127.0.0.1')`); never `0.0.0.0`, never a fixed port. Construct the |
| 220 | `redirect_uri` from the actual bound port and pass it through `validateRedirectUri`. This listener |
| 221 | shares the Phase 2 bind gate. |
| 222 | 3. **Generate `state`, `nonce`, and the PKCE pair per attempt** with this module; keep the |
| 223 | `code_verifier` and `state` in memory only; **discard `state` after one callback** (one-time). |
| 224 | 4. **On the callback**, parse query params and call `validateAuthorizationResponse({ params, |
| 225 | expectedState, expectedIssuer })`. On `ok:false`, abort and surface a generic message; never log |
| 226 | the raw callback. |
| 227 | 5. **POST the token request over TLS** using the `buildTokenRequest` descriptor — verify the TLS |
| 228 | certificate (no `rejectUnauthorized:false`), enforce HTTPS, and never attach a `client_secret`. |
| 229 | 6. **Validate the token response** with `validateTokenResponse`, then `buildSessionMeta` and |
| 230 | `storeSession` into the **OS keychain** via the real adapter (macOS Keychain / Windows DPAPI / |
| 231 | Linux libsecret). Never write a token to a file or a log. |
| 232 | 7. **Drive refresh** with `decide`/`decideTokenRefresh`: `'valid'` → use; `'refresh'` → |
| 233 | `buildRefreshRequest` → POST → `updateAccessToken` (rotate); `'reauth'` or any |
| 234 | `invalid_grant`/reuse → `clearSession` → restart the browser flow. |
| 235 | 8. **Manage the Phase 2 loopback token** with `rotateLoopbackToken` at each start and |
| 236 | `clearLoopbackToken` at shutdown; pass it to the Phase 2 guard as `expectedToken`. |
| 237 | 9. **Ship its own 7-tier suite** for the bind/lifecycle layer (listener bind assertion, |
| 238 | ephemeral-port randomness, browser-launch invocation, real keychain read/write, TLS POST) per |
| 239 | gate §10 — the pure cores' suites do not absolve the listener of its own tests. |
| 240 | |
| 241 | Until that explicit Phase 5 gate is approved, **no socket is bound, no network call is made, and no |
| 242 | real keychain is touched.** |
| 243 | |
| 244 | --- |
| 245 | |
| 246 | ## 7. Server-side OAuth gate (Phase 5 prerequisite) |
| 247 | |
| 248 | Phase 3 changes **no** server-side OAuth. Before the companion can obtain a **web-session-equivalent |
| 249 | (read+write) identity** on the **hosted** deployment, a separate server-side OAuth gate must decide: |
| 250 | |
| 251 | 1. **Native/loopback client at web-session scopes.** Either (a) the MCP OAuth provider issues the |
| 252 | companion the role-derived web scopes (`[vault:read, vault:write]`) rather than the read-only |
| 253 | `mcp_access` default, or (b) a dedicated native-client authorization path issues the |
| 254 | web-session JWT (`issueToken`). Today the MCP path defaults to `['vault:read']` (read-only), |
| 255 | which would make the companion unable to write `ai_summary` enrichment back (defeating §6/D3). |
| 256 | 2. **Hosted availability.** The PKCE provider is currently skipped on Netlify |
| 257 | (`SESSION_SECRET && !NETLIFY`). The companion targets the hosted gateway, so the gate must decide |
| 258 | how the PKCE authorization/token endpoints are served on the hosted deployment. |
| 259 | 3. **RFC 9207 `iss` emission** on the redirect (enables required mix-up defense for clients that |
| 260 | pass `expectedIssuer`). |
| 261 | 4. **Loopback redirect_uri acceptance** with a variable port for the native client registration |
| 262 | (RFC 8252 §7.3) — confirm the SDK auth-router/provider permits per-attempt ephemeral ports. |
| 263 | |
| 264 | This gate is itself security-sensitive (it touches client registration + scopes — the very items |
| 265 | the companion gate's "DOES NOT approve" list protects) and warrants its own review. |
| 266 | |
| 267 | --- |
| 268 | |
| 269 | ## 8. Test obligations satisfied (gate §10, 7 tiers × 2 modules) |
| 270 | |
| 271 | `lib/companion-oauth-pkce.mjs` — `test/companion-oauth-pkce-*.test.mjs` (100 cases): |
| 272 | |
| 273 | | Tier | Focus | |
| 274 | | --- | --- | |
| 275 | | Unit | Each function in isolation; RFC 7636 Appendix B vector; RFC 8252 redirect rules; token/refresh decisions. | |
| 276 | | Integration | The functions composed across the flow; state + PKCE bindings survive a URL round-trip. | |
| 277 | | End-to-end | Full client sequence vs a simulated PKCE-enforcing AS + token endpoint: happy path, interception failure, user-deny, single-use code. | |
| 278 | | Stress | 50k PKCE pairs / 100k states+nonces (no collisions); 100k wrong-state callbacks (zero admits); 50k malformed token responses (zero admits). | |
| 279 | | Data-integrity | Determinism; no input mutation; env-independence; stable verdict shapes. | |
| 280 | | Performance | Coarse upper bounds (pair/challenge/build/validate at 10k–200k). | |
| 281 | | **Security** | **Centerpiece:** S256 correctness + verifier entropy; `plain` rejected; state mismatch constant-time; AS error without leak; loopback/wildcard/foreign redirect rejected; no client secret + response_type=code + S256; token request carries `code_verifier`; oversized token response fails closed; replay rejected; no secret in any output/reason/error. | |
| 282 | |
| 283 | `lib/companion-token-custody.mjs` — `test/companion-token-custody-*.test.mjs` (35 cases): unit, |
| 284 | integration, e2e, stress, data-integrity, performance, and **security** (secrets persist only via |
| 285 | the adapter; no log ever contains a secret; thrown errors carry none; `clearSession` truly removes |
| 286 | both tokens; loopback token isolated from the OAuth session; corrupt-store load fails closed). |
| 287 | |
| 288 | --- |
| 289 | |
| 290 | ## 9. Deferred (explicitly not Phase 3) |
| 291 | |
| 292 | - The loopback **redirect** listener bind, ephemeral-port allocation, system-browser launch, the |
| 293 | TLS token POST, and real OS-keychain I/O — **Phase 5** behind the shared bind gate (§6). |
| 294 | - Any **server-side** OAuth change (web-session-equivalent native-client scopes, hosted PKCE |
| 295 | availability, `iss` emission, loopback redirect registration) — the **separate OAuth gate** (§7). |
| 296 | - Any change to OAuth client registration or scopes in this phase (gate "DOES NOT approve" — |
| 297 | unchanged). |