COMPANION-APP-OAUTH-SERVERSIDE-GATE.md
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 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). |