COMPANION-APP-OAUTH-SERVERSIDE-GATE.md file-level

at sha256:3 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:6 feat(calendar): enforce agent context tiers in retrieval API (Phase 1E)… · aaronrene · Jun 18, 2026
1 # Companion App β€” Server-Side OAuth Gate (client registration + scopes)
2
3 **Status:** βœ… **RATIFIED 2026-06-06 + IMPLEMENTED 2026-06-06.** All four decisions (D-SS.1–D-SS.4) accepted. All six changes (C1–C6) implemented and tested (86/86 tests green across 7 tiers).
4 **Branch:** `feat/companion-app` (Muse-canonical; paired with the Phase 3/4 code already on this
5 branch β€” **not** a docs-only PR to `main`).
6 **Resolves:** [`COMPANION-APP-PHASE-3-OAUTH-PKCE.md`](COMPANION-APP-PHASE-3-OAUTH-PKCE.md) Β§7 (the four
7 server-side items Phase 3 explicitly deferred) and Β§1 D-P3.2.
8 **Unblocks:** Phase 5 (companion shell) β€” it cannot obtain a web-session-equivalent identity until
9 this gate is accepted.
10 **Touches the protected list:** this gate decides **OAuth client registration and scopes** β€” the exact
11 items
12 [`COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md`](COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md)
13 Β§"DOES NOT approve" guards. A wrong default here is an over-privilege or account-compromise path, so
14 every decision below is argued against an attacker and defaults **fail-closed**.
15
16 ---
17
18 ## Simple summary
19
20 The companion app (a helper that runs AI on your own computer) needs to sign in the **same way the
21 website does**, and end up with the **same kind of pass** the website gives you β€” no weaker, no
22 stronger. Phase 3 already built the safe sign-in handshake as pure math (PKCE), but it left four
23 questions about the **server** side unanswered, because answering them changes who gets what
24 permissions β€” and getting that wrong could over-grant access or open an account-takeover path. This
25 document answers those four questions, argues each one against an attacker, and lists exactly what
26 the server team must build and test next. It writes **no server code**.
27
28 The four questions: (1) what permissions should the companion's pass carry, and how is it issued?
29 (2) the sign-in server is currently turned off on our hosted (Netlify) deployment β€” where does it
30 run instead? (3) should the sign-in reply include a tamper-proof "who sent this" stamp (RFC 9207)?
31 (4) does the server correctly accept a desktop app's "reply to me on my own computer" address even
32 though its exact door number changes every time?
33
34 ## Technical summary
35
36 Phase 3 shipped a **provider-agnostic, pure PKCE client core** (`lib/companion-oauth-pkce.mjs`) plus
37 pure custody (`lib/companion-token-custody.mjs`) and deferred all server-side OAuth to this gate
38 (Phase 3 Β§1 D-P3.2, Β§7). Verified against source, the existing hosted OAuth surface does **not** yet
39 deliver the gate's "**same JWT / same scopes as the web session**" promise
40 ([design gate Β§3](COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md)):
41
42 | Verified fact | Source |
43 | --- | --- |
44 | Web-session JWT = `{ sub, provider, id, name, role }`; **no `scopes` claim, no `type` claim** | `hub/gateway/server.mjs:177` `issueToken` |
45 | `scopesForRole`: `member β†’ [vault:read, vault:write]`; `admin β†’ [vault:read, vault:write, admin]` | `hub/gateway/server.mjs:225` |
46 | MCP provider mints `type:'mcp_access'`, scopes **default `['vault:read']`** (read-only) | `hub/gateway/mcp-oauth-provider.mjs:170-198` |
47 | MCP provider refresh = **in-memory `randomUUID()` Map, no reuse-detection family-revoke, lost on restart** | `hub/gateway/mcp-oauth-provider.mjs:80-83,183-241` |
48 | `verifyToken`/`getUserId` accept **any** `SESSION_SECRET`-signed JWT with a `sub` β€” **no `type` check, no `scopes` enforcement** | `hub/gateway/server.mjs:194-201,1087-1091` |
49 | Data-plane authority is **re-derived server-side** (role/scope from the bridge), not from the JWT's `scopes` | `hub/gateway/server.mjs:1178-1206` `getHostedAccessContext`; `hub/gateway/mcp-proxy.mjs:160-205` |
50 | `/api/v1/auth/session` (consumed by Scooling, gate Β§8) reads `provider/id/name/role` from the JWT | `hub/gateway/server.mjs:412-432` |
51 | Loopback redirect carries `code` + `state` only β€” **no `iss`** | `hub/gateway/mcp-oauth-provider.mjs:146-149` |
52 | `exchangeAuthorizationCode`'s `redirectUri` argument is **ignored** (`_redirectUri`) | `hub/gateway/mcp-oauth-provider.mjs:158` |
53 | OAuth router mounted only when `SESSION_SECRET && !process.env.NETLIFY` β€” **skipped on Netlify** | `hub/gateway/server.mjs:540,568-570` |
54 | Refresh rotation + reuse detection + family-revoke (the lifecycle Phase 3 custody mirrors) | `hub/lib/refresh-token-core.mjs:259-319`; `hub/auth-session.mjs:104-156` |
55
56 The decisions below resolve the four Β§7 items on top of these facts. The headline finding that
57 reframes Decision 1: **the JWT `scopes` claim is not the gateway's enforcement point today** β€” data
58 authority is governed by the signature + `sub` + server-side role resolution β€” so the meaningful
59 parity question is the **token shape and refresh lifecycle**, not the scope string.
60
61 ---
62
63 ## 1. Decision D-SS.1 β€” Scope / identity parity for the native client
64
65 > Phase 3 Β§7(1): *either (a) the MCP provider issues role-derived web scopes instead of the read-only
66 > `mcp_access` default, or (b) a dedicated native-client path issues the web-session JWT.*
67
68 ### Verified state
69
70 The companion must write `ai_summary` enrichment back to the partition it can already read
71 ([design gate Β§6, D3](COMPANION-APP-DESIGN-AND-AUTHORIZATION-GATE.md); Phase 0 D1.3). That requires
72 **`vault:write`**, which is exactly what a web **member** already holds (`scopesForRole`,
73 `server.mjs:225`). So **web-session-equivalent IS least-privilege for this client** β€” read-only
74 (`mcp_access` default) breaks the function; anything above `[vault:read, vault:write]` (e.g. `admin`)
75 over-grants.
76
77 Critically, on the REST data plane authority is **not** read from the JWT `scopes` claim
78 (`verifyToken` ignores it, `server.mjs:194`); it is re-derived from the bridge hosted-context
79 (`getHostedAccessContext`, `server.mjs:1178`) keyed on `sub`. Therefore the choice between (a) and (b)
80 is decided by **token shape, identity fidelity, and refresh lifecycle**, not by the scope string.
81
82 ### Adversarial argument
83
84 | Threat / property | Option (a): bump `mcp_access` default to role scopes | Option (b): dedicated native path β†’ web-session JWT |
85 | --- | --- | --- |
86 | **Identity fidelity** (Scooling reads `/api/v1/auth/session`, `server.mjs:412`) | ❌ `mcp_access` carries no `provider/id/name/role`; introspection returns **empty identity + default `member`** β€” breaks "same identity as the web session." | βœ… Byte-for-byte the web JWT `{sub,provider,id,name,role}` β†’ introspection + Scooling unaffected. |
87 | **Refresh blast radius** (theft of the refresh token) | ❌ In-memory UUID Map, **no reuse-detection family-revoke**, **lost on every restart** (silent forced re-auth). A replayed rotated token only returns "unknown" β€” it does **not** burn the family. | βœ… `refresh-token-core` rotation + **reuse β†’ family revoke** (`refresh-token-core.mjs:287-290`) β€” the exact lifecycle Phase 3 custody Β§4 was built to mirror (`invalid_grant`/reuse β†’ `clearSession` β†’ fresh browser login). |
88 | **Over-grant of the access token** | The read-only default is a **false floor**: an `mcp_access` JWT is *already* accepted on the REST plane by `verifyToken` regardless of its `['vault:read']` scope. Bumping the default does not add capability it lacked β€” it papers over the identity/lifecycle gap. | The token is the web session β€” a stolen companion JWT is **no worse than a stolen web JWT**, and strictly better on the refresh side. |
89 | **Confused-deputy with real MCP clients** | ❌ Changing the shared `mcp_access` default also widens **every** MCP-tool client (Claude Desktop, etc.) from read-only to read+write β€” collateral over-grant. | βœ… The native path is **distinct**; the `mcp_access` path for MCP-tool clients is left untouched at its read-only default. |
90 | **New attack surface** | None beyond today. | One: a native authorization/token route. Bounded by Phase 3's PKCE + `state` + `iss` + loopback-literal allowlist (threat model a–j) and public-client (no device secret). |
91
92 ### Recommendation β€” **Option (b)** (product + eng call β†’ owner ratification requested, [Β§9](#9-approval-table))
93
94 Issue the **web-session JWT** (`issueToken` shape) to the native/loopback client through a
95 **dedicated native-client authorization path**, with the issued scope **bound to `scopesForRole(role)`
96 β€” never a superset, never `admin` unless the user is already admin** (identical to the web ceiling).
97 Drive its refresh through **`refresh-token-core`** (rotation + reuse-detection family-revoke),
98 delivered in the **token-response body** (the companion is not a browser β€” no `HttpOnly` cookie),
99 stored by Phase 5 in the OS keychain per Phase 3 custody.
100
101 **Implementation note (allowed, not required):** the path MAY reuse the MCP SDK auth-router's PKCE /
102 dynamic-registration / authorization / token **protocol plumbing** as machinery, but the **token it
103 mints must be the web-session JWT** (via the shared `issueToken`) and its **refresh must be backed by
104 `refresh-token-core`** β€” i.e. option (b)'s semantics, regardless of which plumbing is reused. The
105 companion is **not** an MCP-tool client; it is a native app acting as the user against the REST data
106 plane, so it does **not** need the `type:'mcp_access'` token and the existing `mcp_access` path must
107 remain unchanged for actual MCP clients.
108
109 **Fail-closed defaults:** missing/unknown role β†’ treat as `member` ceiling (`[vault:read,
110 vault:write]`), never elevate; never issue a non-rotating or long-lived token.
111
112 ---
113
114 ## 2. Decision D-SS.2 β€” Hosted availability of the authorization/token endpoints
115
116 > Phase 3 Β§7(2): the PKCE provider is skipped on Netlify (`SESSION_SECRET && !NETLIFY`,
117 > `server.mjs:540`). The companion targets the hosted gateway β€” where are the endpoints served?
118
119 ### Verified state
120
121 The OAuth router is gated off on Netlify because the SDK MCP **session** transport needs stateful
122 SSE + shared memory incompatible with serverless (`server.mjs:534-539`), and the provider keeps
123 `_pendingCodes` + `_refreshTokens` in **in-memory Maps** (`mcp-oauth-provider.mjs:80-83`). The web
124 refresh path, by contrast, already runs on Netlify against a **durable blob store** through
125 `refresh-token-core` + `auth-session.mjs` β€” so durable AS state on serverless is not without
126 precedent.
127
128 ### Verified live server inventory (owner-confirmed 2026-06-06)
129
130 > The former AWS `paperclip-prod` (t3.xlarge) has been **decommissioned**; Paperclip migrated to
131 > the iMac. Two servers remain in AWS `us-east-2`:
132
133 | Name | Instance ID | Type | Public IP | Security Group | Role |
134 | --- | --- | --- | --- | --- | --- |
135 | Discord Bot | i-00ffa62e50bd41080 | t3.micro | 3.19.27.252 | launch-wizard-1 | Bot / automation |
136 | **knowtation-mcp-gateway** | **i-025679d93cf47aeab** | **t3.small** | **18.221.120.124** | **knowtation-mcp-sg** | **Persistent MCP/OAuth gateway** |
137
138 The hosted REST API runs on **Netlify** (serverless); the OAuth/MCP router is skipped there
139 (`server.mjs:540,568`). `hub/gateway/README.md:62` documents the intended split β€” **API on Netlify,
140 persistent MCP on a separate host** β€” and `knowtation-mcp-gateway` **is that host**.
141
142 ### Adversarial argument
143
144 - **Discord Bot (t3.micro) β€” REJECTED.** Automation/bot workload; same privilege-separation failure
145 as the old Paperclip box. A compromise yields the ability to mint identity for every Knowtation
146 user if the gateway's `SESSION_SECRET` is co-located there.
147 - **`knowtation-mcp-gateway` (t3.small) β€” ACCEPTED for reuse.** Purpose-built for this exact role
148 (the name and dedicated `knowtation-mcp-sg` security group confirm it), has a public IP, is already
149 the intended co-location for `/mcp` and the OAuth AS per `hub/gateway/README.md`. It runs **no
150 automation/bot workloads** β€” it is the gateway itself. The privilege-separation requirement is
151 satisfied: identity is isolated on a host whose only job is serving the Knowtation persistent
152 gateway. A t3.small (2 vCPU, 2 GB) is correctly sized β€” the gateway is I/O-bound, runs no
153 inference, no Postgres, no agent subprocesses.
154 - **New host β€” not needed.** `knowtation-mcp-gateway` already exists, is already dedicated, and
155 already has the right posture. Provisioning a third server would duplicate it for no security gain.
156 - **(ii) Durable-state-on-Netlify β€” viable fallback, not needed.** Acceptable if the owner ever
157 wants to decommission the persistent host, but adds bespoke AS-state porting work (expired/replayed
158 codes must be atomic across isolated invocations) and this host already exists.
159
160 ### Recommendation β€” **DECIDED: reuse `knowtation-mcp-gateway`** (no new server)
161
162 **The companion's OAuth authorization/token/registration endpoints co-locate on
163 `knowtation-mcp-gateway` (i-025679d93cf47aeab, t3.small, 18.221.120.124)**, alongside the existing
164 `/mcp` endpoint, exactly as `hub/gateway/README.md` planned. No third server is needed.
165
166 **Implementation obligations** for the follow-up phase:
167 - TLS must terminate on the host (Caddy/Let's Encrypt or an ACM-backed ALB) β€” the OAuth endpoints
168 MUST be HTTPS-only; the companion's `buildAuthorizationUrl` enforces HTTPS on the AS endpoint.
169 - `SESSION_SECRET` stored in **AWS SSM Parameter Store / Secrets Manager** under a
170 least-privilege IAM role scoped to this instance only β€” never in the process environment of the
171 Discord bot or any other host.
172 - The `knowtation-mcp-sg` security group must allow inbound 443 (HTTPS) from `0.0.0.0/0` for OAuth
173 redirects (the companion's system browser hits the authorization endpoint) and inbound from the
174 Netlify gateway IP range for the MCP proxy path. No other ports.
175 - Durable AS state (pending codes + native refresh records) must survive process restart β€” use the
176 **same blob/file store** the web refresh path uses, or a small SQLite/Redis local to the host.
177 **No in-memory Maps** for production AS state.
178
179 **Either way:** the endpoints must serve over **HTTPS**, advertise discovery metadata whose `issuer`
180 exactly matches the emitted `iss` (D-SS.3), and never rely on in-memory Maps for code/refresh state
181 (that would silently drop valid sessions and break reuse detection).
182
183 ---
184
185 ## 3. Decision D-SS.3 β€” RFC 9207 `iss` emission on the redirect
186
187 > Phase 3 Β§7(3) and D-P3.3: emit `iss` so clients passing `expectedIssuer` get full mix-up defense.
188
189 ### Verified state
190
191 `completeMcpAuthorization` (`mcp-oauth-provider.mjs:146-149`) builds the loopback redirect with `code`
192 and `state` only β€” **no `iss`**. Phase 3's client validates `iss` **constant-time when present** and
193 **tolerates absence** for back-compat (D-P3.3). So today, even a client that passes `expectedIssuer`
194 gets **no** mix-up protection (threat **c**), because absent-`iss` is tolerated.
195
196 ### Recommendation β€” **CONFIRM (emit `iss`)**
197
198 Add `iss` to the authorization-**response** redirect (the loopback redirect built in
199 `completeMcpAuthorization`), set to the **issuer identifier string** β€” identical to the `issuerUrl`
200 the SDK auth-router advertises in discovery metadata (`server.mjs:557`, `new URL(BASE_URL)`), with no
201 trailing-slash drift. Specification:
202
203 - Value = the AS issuer identifier, URL-encoded, **exactly equal** to the `issuer` in the
204 authorization-server metadata (RFC 9207 Β§2 / RFC 8414).
205 - Emitted on the **authorization response** (the redirect), **not** the token response.
206 - Purely additive: a Phase 3 client passing `expectedIssuer` now gets **constant-time mix-up defense
207 with zero client change** (Phase 3 threat **c**, `ISSUER_MISMATCH`); a client that does not pass
208 `expectedIssuer` is unaffected.
209 - Carries no secret. Absent-`iss` remains tolerated **only** for any pre-existing client; new native
210 clients SHOULD pass `expectedIssuer` and SHOULD treat a mismatch as fatal.
211
212 ---
213
214 ## 4. Decision D-SS.4 β€” Loopback redirect registration with a variable ephemeral port
215
216 > Phase 3 Β§7(4): confirm the provider/SDK auth-router accepts a native client registering a loopback
217 > `redirect_uri` with a **variable ephemeral port** (RFC 8252 Β§7.3), and that `redirect_uri` is
218 > validated against the registration at the token exchange.
219
220 ### Verified state
221
222 - The provider **stores** `params.redirectUri` at `authorize` and redirects to it at
223 `completeMcpAuthorization` (`mcp-oauth-provider.mjs:97-118,146-149`), but **does not re-validate it
224 at the token exchange** β€” `exchangeAuthorizationCode`'s `_redirectUri` argument is **ignored**
225 (`:158`).
226 - Whether a loopback `redirect_uri` with a variable port is accepted at **registration** and
227 **authorization** is governed by the **`@modelcontextprotocol/sdk` auth-router**
228 (`mcpAuthRouter`, mounted `server.mjs:555`), whose source/version this gate has **not** inspected.
229 This is therefore a **CONFIRM-WITH-VERIFICATION**, not an assertion.
230
231 ### Adversarial argument
232
233 If neither the SDK nor the provider validates `redirect_uri` at token exchange, a code intercepted on
234 the loopback could in principle be exchanged from a different redirect. **PKCE still blocks the
235 exchange** (the attacker lacks the `code_verifier`, Phase 3 threat **a**), so this is not a
236 stand-alone compromise β€” but `redirect_uri` validation is **defense-in-depth required by RFC 6749
237 Β§4.1.3** and must not be skipped.
238
239 RFC 8252 Β§7.3 nuance the implementation must respect: the AS **MUST allow variable ports** for
240 loopback redirects, i.e. registration/authorization matching must be **port-agnostic on the loopback
241 literal**. But within a **single** attempt the companion binds one ephemeral port, derives the
242 `redirect_uri` from it, and uses that **same** value for both authorization and token exchange β€” so
243 the Β§4.1.3 **equality** check (same `redirect_uri` for a given code) holds per attempt. "Variable
244 port" is a property **across** attempts/registration, not within one exchange. Both are satisfiable
245 simultaneously.
246
247 ### Recommendation β€” **CONFIRM, with a hard implementation obligation**
248
249 1. **Verify** against the pinned `@modelcontextprotocol/sdk` version that (a) a native client can
250 dynamically register a loopback `redirect_uri`, and (b) the authorization request's loopback
251 `redirect_uri` is accepted with a variable/ephemeral port (port-agnostic loopback match, RFC 8252
252 Β§7.3) β€” `127.0.0.1`/`[::1]` literals only, never `localhost`-wildcard, never a non-loopback host.
253 2. **Enforce** `redirect_uri` validation **at the token exchange**: the `redirect_uri` presented with
254 a code MUST equal the one bound to that code at `authorize` (RFC 6749 Β§4.1.3). If the SDK does not
255 already enforce this upstream, **change the provider** to compare against `pending.redirectUri`
256 (replacing the ignored `_redirectUri`). The comparison is per-code **equality** (the same attempt's
257 value), not a port-agnostic match β€” port-agnosticism applies only to registration/authorization
258 acceptance.
259 3. Reject any registered/presented redirect that is not an RFC 8252 loopback literal (mirrors the
260 client-side `validateRedirectUri`, Phase 3 threat **e**), fail-closed.
261
262 ---
263
264 ## 5. Threat model β†’ control (server side)
265
266 Extends Phase 3 Β§2 (client side) to the server changes this gate authorizes.
267
268 | # | Attacker capability | Control mandated by this gate | RFC |
269 | --- | --- | --- | --- |
270 | S-a | Over-privileged companion token (write where read suffices, or `admin`) | Issued scope **bound to `scopesForRole(role)`**, never a superset; native path distinct from `mcp_access` so MCP clients are not widened (D-SS.1) | RFC 6749 Β§3.3; RFC 9700 |
271 | S-b | Refresh-token theft / replay | `refresh-token-core` rotation + **reuse β†’ family revoke** for the native client (D-SS.1) | RFC 6819 Β§5.2.2.3; RFC 9700 |
272 | S-c | AS / redirect **mix-up** (client juggling >1 AS) | Emit `iss` = issuer identifier on the redirect; Phase 3 client constant-time-matches `expectedIssuer` (D-SS.3) | RFC 9207 |
273 | S-d | Authorization-**code interception** on loopback | PKCE S256 verifier binding (Phase 3) **+** `redirect_uri` equality at token exchange (D-SS.4) | RFC 7636; RFC 6749 Β§4.1.3 |
274 | S-e | **Open-redirect** via registered `redirect_uri` | Loopback-literal-only registration, port-agnostic per RFC 8252 Β§7.3, no wildcard host (D-SS.4) | RFC 8252 Β§7.3, Β§8.3 |
275 | S-f | Degraded/forged identity to Scooling introspection | Web-session JWT shape `{sub,provider,id,name,role}` so `/api/v1/auth/session` is unchanged (D-SS.1) | β€” (internal contract, gate Β§8) |
276 | S-g | AS-state loss / cross-instance drift admitting stale codes or breaking reuse detection | Durable code/refresh state on the chosen host; no in-memory Maps in serverless/multi-instance (D-SS.2) | RFC 6819 Β§5.1.5 |
277 | S-h | Second secret-holder compromise (new host) | Persistent host shares `SESSION_SECRET` over a controlled channel; HTTPS only; minimal surface (D-SS.2) | RFC 9700 |
278
279 ---
280
281 ## 6. Precise server-side change list (for the FOLLOW-UP implementation phase)
282
283 βœ… **Implementation complete 2026-06-06. 86/86 tests green across all 7 tiers.**
284
285 | # | Change | Implementation | Decision |
286 | --- | --- | --- | --- |
287 | βœ… C1 | Native-client authorization path mints the **web-session JWT** (`issueToken` shape), scopes **bound to `scopesForRole(role)`**; `mcp_access` path untouched | `hub/gateway/native-oauth-provider.mjs` β€” `createNativeOAuthRouter()`; mounted in `server.mjs` at `/api/v1/auth/native` | D-SS.1 |
288 | βœ… C2 | Native-client refresh backed by **`refresh-token-core`** (rotation + reuseβ†’family-revoke); token in response body (no cookie); reason codes aligned to `auth-session.mjs` | `hub/gateway/native-oauth-provider.mjs` β€” `grant_type=refresh_token` via `opts.refreshStore.rotate()` (shared `createGatewayRefreshStore()`) | D-SS.1 |
289 | βœ… C3 | **`iss`** = issuer identifier on loopback redirect in both MCP and native paths, equal to discovery `issuer` | `hub/gateway/mcp-oauth-provider.mjs:completeMcpAuthorization` + `hub/gateway/native-oauth-provider.mjs:completeNativeAuthorization` | D-SS.3 |
290 | βœ… C4 | **Durable** pending auth codes (survive restart) + native refresh via durable gateway store | `hub/gateway/native-as-store.mjs` (atomic JSON file); refresh via `createGatewayRefreshStore()` | D-SS.2 |
291 | βœ… C5 | **`redirect_uri` validated at token exchange** (per-code equality, RFC 6749 Β§4.1.3); loopback-only at registration; SDK v1.27.1 variable-port loopback verified | `hub/gateway/native-oauth-provider.mjs` β€” exact equality check; `hub/gateway/mcp-oauth-provider.mjs:exchangeAuthorizationCode` β€” validates when provided | D-SS.4 |
292 | βœ… C6 | Scope ceiling guard in every token-mint path; unknown/missing role β†’ `member` ceiling; applied at code exchange AND on every refresh rotation | `hub/gateway/native-oauth-provider.mjs:applyScopeCeiling()` | D-SS.1 |
293
294 **New files:** `hub/gateway/native-as-store.mjs`, `hub/gateway/native-oauth-provider.mjs`, `test/native-oauth-c1-c6-{unit,integration,e2e,stress,data-integrity,performance,security}.test.mjs`
295
296 **Modified files:** `hub/gateway/mcp-oauth-provider.mjs` (C3, C5), `hub/gateway/server.mjs` (native router mount at `/api/v1/auth/native`, IDP callback `native:` state prefix)
297
298 **Explicitly out of scope (unchanged):** the existing `verifyToken` behavior of not enforcing the
299 JWT `scopes` claim (`server.mjs:194`) is a separately tracked concern. Authority is re-derived
300 server-side by role. Changing data-plane scope enforcement requires its own gate.
301 ---
302
303 ## 7. 7-tier test obligations (per change C1–C6)
304
305 Aaron's Rule #0. Each change above ships all seven tiers before merge to `main`.
306
307 | Tier | Obligation |
308 | --- | --- |
309 | **Unit** | Native path mints exactly `{sub,provider,id,name,role}` with scope == `scopesForRole(role)`; `iss` value == discovery `issuer`; `redirect_uri` equality compare; scope-ceiling guard rejects supersets; unknown role β†’ `member` ceiling. |
310 | **Integration** | Full native authorization β†’ token exchange against the chosen host; refresh rotation via `refresh-token-core`; reuse β†’ family-revoke β†’ `REFRESH_REUSE`; `/api/v1/auth/session` returns full identity for the native JWT (parity with web). |
311 | **End-to-end** | Companion sign-in β†’ web-session JWT β†’ write `ai_summary` back (D3/Β§6) β†’ introspection identity intact; `mcp_access` clients **unchanged** (regression: still read-only by default, still `type:'mcp_access'`). |
312 | **Stress** | Many concurrent native authorizations; refresh-rotation storm with interleaved reuse attempts (zero family-revoke misses); durable-store contention on the chosen host; ephemeral-port variety across attempts. |
313 | **Data-integrity** | Single-use codes never double-spend across instances; refresh family invariants hold under durable store; no scope drift on refresh (subset-only, `mcp-oauth-provider.mjs:211-213` analogue); `iss` byte-stable vs discovery. |
314 | **Performance** | Token-exchange + introspection latency bounds; durable-store read/write within the host budget (and within 26 s if D-SS.2 (ii) is chosen). |
315 | **Security** | **Centerpiece.** No superset/`admin` over-grant; PKCE still required (no `plain`); `redirect_uri` non-loopback rejected; mix-up rejected when `expectedIssuer` set + wrong `iss`; refresh reuse burns the family; no secret (`SESSION_SECRET`, JWT, refresh token, code, verifier) in any log/error/redirect; second-host secret handling reviewed; `mcp_access` clients not widened. |
316
317 ---
318
319 ## 8. Constraints honored
320
321 - **Decisions only β€” no server code.** This document changes no `hub/` runtime; it records what a
322 follow-up phase must build and test.
323 - **Muse-canonical**, on `feat/companion-app`, paired with the Phase 3/4 code already there β€” **not** a
324 docs-only PR to `main` (per the owner's no-docs-only-PR-to-`main` policy).
325 - **Security first; fail-closed defaults.** Every default above denies/least-privileges on ambiguity.
326 - **No assumptions stated as fact.** Every claim is anchored to a verified file:line; the one item
327 this gate could **not** verify (SDK loopback variable-port behavior) is marked
328 CONFIRM-WITH-VERIFICATION (D-SS.4), not asserted.
329
330 ---
331
332 ## 9. Approval table
333
334 | Decision | Recommendation | Owner approval |
335 | --- | --- | --- |
336 | **D-SS.1** β€” native client gets the **web-session JWT** (option b), scope == `scopesForRole(role)`, refresh via `refresh-token-core`; `mcp_access` path untouched | **ACCEPT (option b)** | βœ… approved 2026-06-06 |
337 | **D-SS.2** β€” hosted availability: **reuse `knowtation-mcp-gateway`** (i-025679d93cf47aeab, t3.small, us-east-2c) β€” no new server needed | **DECIDED: reuse `knowtation-mcp-gateway`** | βœ… approved 2026-06-06 |
338 | **D-SS.3** β€” emit `iss` = issuer identifier on the redirect (RFC 9207) | **CONFIRM** | βœ… approved 2026-06-06 |
339 | **D-SS.4** β€” loopback variable-port registration (RFC 8252 Β§7.3) + `redirect_uri` equality at token exchange (RFC 6749 Β§4.1.3) | **CONFIRM (with SDK verification)** | βœ… approved 2026-06-06 |
340
341 D-SS.1–D-SS.4 are ratified. The four Phase 3 Β§7 items are resolved. The **server-side implementation
342 phase** (changes C1–C6, Β§6) is unblocked β€” itself gated on the Β§7 7-tier test obligation before any
343 merge to `main`. That phase in turn unblocks **Phase 5** (companion shell).