COMPANION-APP-PHASE-3-OAUTH-PKCE.md file-level

at sha256:0 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 fix(security): pin patched transitive deps to clear Dependabot moderate… · aaronrene · Jun 11, 2026
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).