inspector/specification/v2_auth_mid_session.md
cliffhall c20fce9ba2 docs(spec): align mid-session auth spec with the token_expired predicate
Copilot review on #2105: specification/v2_auth_mid_session.md still said
checkAuthChallengeSatisfied is satisfied for token_expired / unauthorized by
any valid non-expired token. Only token_expired can short-circuit, and only
for a provably unexpired JWT; invalid_token and unauthorized are always false
(which the code already did before this branch).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 07:35:40 -04:00

566 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Inspector V2 Auth — Mid-session authorization
### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md)
#### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | Mid-session | [Smoke testing](v2_auth_smoke_testing.md) | [SDK consolidation](v2_auth_sdk_consolidation.md)
Design and implementation reference for **mid-session authorization** in Inspector: detecting when MCP traffic needs new or elevated credentials, running the correct OAuth or EMA recovery flow, and restoring the session — across **web**, **TUI**, and **CLI**.
For hands-on verification, see [v2_auth_smoke_testing.md §5](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation).
---
## Summary
Inspector already supports **connect-time** OAuth and EMA: the first `connect()` that hits **401** triggers `authenticate()` / browser redirect / loopback callback, and tokens land in per-client storage.
**Mid-session authorization** covers everything **after** that: a token expires, is revoked, or lacks scopes for a specific operation while the user is working. The server signals this with HTTP **401** (bad/missing token) or **403** + `insufficient_scope` (valid token, not enough scope — **step-up**). Inspector normalizes those signals into an **`AuthChallenge`**, runs **`handleAuthChallenge()`**, and either refreshes silently or starts interactive OAuth.
| Client | Transport model | Auth runs where | Recovery highlights |
| ------------- | ---------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------- |
| **Web** | Browser → Hono **remote** backend → MCP server | Browser (`OAuthManager`) | `POST /api/mcp/auth-state` hot-swap; full-page OAuth + **resume snapshot** |
| **TUI / CLI** | **Direct** SDK transport in-process | Node (`OAuthManager`) | Loopback callback on `127.0.0.1:6276`; user retries action after interactive auth |
All three clients share **`core/auth/challenge.ts`** and **`OAuthManager.handleAuthChallenge()`**. UX and wire protocol differ by client.
---
## Background
### Connect-time vs mid-session
| | Connect-time | Mid-session |
| ------------------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| **When** | First connect with no valid tokens, or reconnect before a remote session exists | Any MCP RPC **after** credentials were supplied |
| **Typical signal** | `connect()` throws **401** | MCP HTTP **401/403** on an in-flight request |
| **Web handler** | `App.tsx` → `authenticate()` → redirect | `handleAuthChallenge()` → `auth-state` push and/or redirect |
| **Core handler** | Same OAuth primitives | `OAuthManager.handleAuthChallenge()` |
Both paths use the same token storage, refresh, and authorization-code exchange; only **detection** and **session update** mechanics differ.
### Step-up authorization (SEP-2350)
**Step-up** is OAuth for “I have a token, but not permission for _this_ operation.” MCP servers usually express it as:
```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="weather:read"
```
By contrast, **401** means the token is missing, invalid, or expired — fix by refresh or full re-login.
**[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** (in the MCP 2026-07-28 authorization hardening) requires clients to **accumulate scopes** on step-up:
| Situation | Client scope behavior |
| ---------------- | ---------------------------------------------------------------------------------------------------------------- |
| **403** step-up | **Union** previously requested scopes with scopes from the challenge — do not drop scopes needed for other tools |
| **401** re-login | **Replace** scope set (user may down-scope at the AS) |
Inspector persists granted scopes in `OAuthStorage.scope` (`saveScope()`): when the authorization server returns a `scope` parameter on the token response, that value is **authoritative** (RFC 6749 §5.1 — including when the grant is a subset of what was requested). When `scope` is **omitted** on a successful response, granted scope is assumed to equal what was requested for that exchange. Storage must never claim scopes the access token was not granted.
For step-up, `handleAuthChallenge()` computes `authorizationScopes` as the union of stored grant + challenge scopes for the **authorization request**; `saveScope()` runs only after a successful grant, using the rules above — not the pre-redirect requested union when the AS returned an explicit smaller `scope`.
**UX consequence:** standard-OAuth and **web EMA** step-up need **user-visible consent** before proceeding (web modal, TUI Auth tab confirm, CLI **y/N**). On the web client, EMA `insufficient_scope` shows the same **`StepUpAuthModal`** pattern as standard OAuth, with organization/IdP copy; only after **Authorize** does Inspector run silent re-mint or start an IdP redirect. TUI/CLI may still re-mint silently after their own confirm prompt — see [EMA step-up (web)](v2_auth_ema.md#ema-step-up-web-confirmation).
**Rationale (Inspector as a testing tool):** EMA can often satisfy scope upgrades without a visible IdP prompt when the organization session is still valid. That is convenient in production clients, but Inspector deliberately surfaces the requested scopes first so operators can see _what_ permission elevation is happening while exploring MCP servers — the same visibility standard OAuth step-up already provides on web.
Normative background: [MCP authorization — Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow), [Runtime Insufficient Scope Errors](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors), [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1).
---
## Terminology
| Term | Meaning |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **App tab** | An Inspector **view** inside one browser window: Tools, Resources, Prompts, etc. (`activeTab` in `App.tsx`). Not a browser tab. |
| **Browser tab** | A top-level browsing context (window/tab) running the Inspector SPA. Each has its own `InspectorClient`, remote session, and OAuth context. |
| **Auth challenge** | Normalized `{ reason, requiredScopes?, … }` — not raw HTTP. Built by `parseAuthChallengeFromResponse()` or received on the remote wire. |
| **`handleAuthChallenge()`** | Core orchestrator: silent refresh / EMA re-mint, or start interactive OAuth. Returns `satisfied`, `step_up_confirm` (EMA/web deferral), `interactive`, or `failed`. |
| **`RemoteAuthState`** | Payload for `POST /api/mcp/auth-state`: fresh `oauthTokens` (+ optional `oauthClient`) pushed to the node backend without tearing down the MCP transport. |
| **Command-scoped recovery** | Failure during an active user action (tool call, connect button). May **retry the same JSON-RPC** once after silent recovery. |
| **Ambient recovery** | Failure with no correlated in-flight command (idle SSE). Prepares the session for the **next** user action — no auto-retry of a specific RPC. |
| **OAuth resume snapshot** | `sessionStorage` blob written before a **full-page** OAuth redirect so the web app can restore server, app tab, and form state after callback. |
---
## Architecture
### Two transport models
Inspector implements mid-session auth twice, by design — not as a shared transport wrapper.
```text
Web remote (browser proxy):
Browser RemoteClientTransport
→ POST /api/mcp/send
→ Hono RemoteSession + fetch intercept (frozen stub OAuth provider on node)
→ upstream MCP server
Recovery: handleAuthChallenge() in browser → POST /api/mcp/auth-state → optional send retry
TUI / CLI direct:
InspectorClient + live OAuthClientProvider on SDK StreamableHTTPClientTransport
Recovery: handleAuthChallenge() in-process → token swap / reconnect
Interactive: runRunnerInteractiveOAuth() + loopback callback (127.0.0.1:6276)
```
**Why not unify?** The web node backend uses a **stub** provider (no redirects on the server). TUI/CLI hold a **live** provider. The v2 TypeScript SDK will own **silent** 401/403 retry on direct streamable HTTP ([#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265)); Inspector keeps **`handleAuthChallenge()`**, EMA paths, and **interactive deferral** (modal / confirm / snapshot). See [v2_auth_hardening.md](v2_auth_hardening.md).
**Explicit non-goals for TUI/CLI:** client-side fetch intercept on the SDK transport, `AuthRecoveryTransport`, or mirroring `RemoteClientTransport` on direct paths.
### Recovery shapes
| Shape | When | MCP retry | Page unload |
| -------------------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------- |
| **Silent in-process** | Refresh, EMA re-mint after user confirmed step-up (web modal **Authorize**), TUI/CLI confirm | **Yes** (command-scoped, once) | No |
| **In-app step-up confirm** | Web EMA `insufficient_scope` before re-mint / IdP | **No** — modal only; retry after success toast | No |
| **Interactive full-page** | Standard-OAuth step-up, 401 re-login, EMA IdP leg 1 (after web confirm when applicable) | **No** — user retries action after callback | Yes (web) |
```text
Browser InspectorClient Hono RemoteSession MCP server
───────────────────────── ────────────────── ──────────
RemoteClientTransport StreamableHTTPClientTransport
OAuthClientProvider (live) createRemoteAuthProvider (mutable)
│ │
POST /api/mcp/send ───────────────────────►│ same transport ───────────────►│
│◄─ { ok: false, auth_challenge }──────│ │
handleAuthChallenge() │
POST /api/mcp/auth-state { authState } ───►│ setAuthState() → new Bearer │
POST /api/mcp/send (retry) ─────────────────►│ same mcp-session-id ────────►│
```
**Why auth-state push?** An earlier approach disconnected and reconnected the remote session on every recovery, which broke upstream MCP session continuity. Hot-swapping credentials on the existing transport matches direct streamable-http behavior.
### Code map
| Area | Primary files |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Challenge types & parsing | `core/auth/challenge.ts`, `core/auth/oauthUx.ts` |
| Handler & storage | `core/mcp/oauthManager.ts`, `core/auth/mcpAuth.ts`, `core/auth/scopes.ts` |
| EMA | `core/auth/ema/emaFlow.ts`, `core/auth/ema/resourceContext.ts` — see [v2_auth_ema.md](v2_auth_ema.md) |
| Web remote backend | `core/mcp/remote/node/server.ts`, `remote-session.ts`, `core/mcp/node/authChallengeFetch.ts` |
| Web remote client | `core/mcp/remote/remoteClientTransport.ts`, `core/mcp/inspectorClient.ts` |
| Web app | `clients/web/src/App.tsx`, `lib/oauthResume.ts`, `utils/pendingReauth.ts`, `lib/browserTabVisibility.ts`, `components/groups/StepUpAuthModal/` |
| TUI | `clients/tui/src/App.tsx`, `utils/tuiOAuth.ts` |
| CLI | `clients/cli/src/cliOAuth.ts` |
| Runner OAuth (TUI/CLI) | `core/auth/node/runner-interactive-oauth.ts`, `oauth-callback-server.ts` |
| Step-up test fixture | `test-servers/configs/oauth-step-up-demo.json`, `test-servers/src/test-server-oauth.ts` |
---
## Auth challenge model
### `AuthChallenge` (`core/auth/challenge.ts`)
```typescript
export type AuthChallengeReason =
| "unauthorized"
| "token_expired"
| "insufficient_scope"
| "invalid_token";
export interface AuthChallenge {
reason: AuthChallengeReason;
requiredScopes?: string[]; // From WWW-Authenticate scope= (this operation)
authorizationScopes?: string[]; // SEP-2350 union — set before re-auth, not on wire
resource?: string;
audience?: string;
message?: string;
context?: { method?: string; toolName?: string };
raw?: { httpStatus?: number; wwwAuthenticate?: string };
}
```
### Parsing
Parsing is **layered** at the point of failure — not by guessing from error messages:
1. HTTP **401/403** on the MCP response (fetch intercept or transport error).
2. **`WWW-Authenticate: Bearer`** — `error`, `scope`, `error_description` (quoted and unquoted RFC 6750 forms).
3. Embedded `authChallenge` on SDK/transport errors.
Mapping highlights:
- **403** + `insufficient_scope` → `reason: "insufficient_scope"`.
- **401** + `invalid_token` → `invalid_token` or `token_expired` as appropriate.
- **401** without Bearer error → `token_expired` (coarse default for silent refresh / reauth UX).
- Parse failure → `unauthorized` (still allows interactive re-auth).
`isAuthChallengeError()` treats mid-session failures only when auth markers are present (challenge object, `WWW-Authenticate`, or `AuthChallengeError`) — not bare HTTP status alone.
Connect-time **401** before tokens exist still uses `isUnauthorizedError()` and `authenticate()` — separate from mid-session detection.
---
## Core API — `handleAuthChallenge()`
**Location:** `OAuthManager.handleAuthChallenge()`; `InspectorClient.handleAuthChallenge()` delegates.
```typescript
export type AuthChallengeOutcome =
| { kind: "satisfied" }
| { kind: "interactive"; authorizationUrl: URL; challenge: AuthChallenge }
| { kind: "failed"; error: Error };
```
### `checkAuthChallengeSatisfied(challenge)`
Read-only check against **current storage** (and token expiry helpers). Used before starting visible OAuth — especially when a background browser tab regains focus and another tab may have already re-authenticated. Does **not** call the authorization server.
- **`token_expired`:** a **provably** unexpired access token in storage — a JWT whose `exp` is still in the future (60s skew). Storage records no absolute expiry for an opaque token (`expires_in` is relative to an issuance instant that is never persisted), so "expiry unknown" returns **false**: the resource server has said the credential is dead, and no local evidence contradicts it. Otherwise an opaque token would be replayed forever ([#2051](https://github.com/modelcontextprotocol/inspector/issues/2051)).
- **`invalid_token` / `unauthorized`:** always **false** — the resource server explicitly rejected the credential.
- **`insufficient_scope`:** stored/token scope is a superset of required / union scopes. Empty scope on an `insufficient_scope` challenge returns **false** (do not short-circuit).
### Strategy by protocol
#### Standard OAuth
| Reason | Silent | Interactive |
| ------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `token_expired`, `invalid_token`, `unauthorized` | Refresh via `refresh_token` when supported | Authorization code flow (`authenticate()`) |
| `insufficient_scope` | N/A | Authorize with **`authorizationScopes`** = union(previous, challenge) via `mcpAuth({ forceReauthorization: true })`; navigation **deferred** until UI confirms (web modal / TUI Auth / CLI prompt) |
Union scope is held in `pendingAuthorizationScope` until `completeOAuthFlow()` succeeds; cleared on failure. On success, `saveScope()` stores the AS-granted scope (explicit `scope` on the token response, or the requested union when `scope` is omitted per RFC 6749 §5.1).
#### EMA
Per [v2_auth_ema.md](v2_auth_ema.md): **no** fallback to standard resource-OAuth redirect.
| Reason | Silent | Interactive |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------- |
| Resource token expired / unauthorized | Legs 2–3 re-mint | — |
| IdP session missing | — | Leg 1 IdP OIDC redirect, then legs 2–3 |
| `insufficient_scope` | After user confirms (web modal / TUI / CLI), re-mint legs 2–3 with union scopes when IdP session valid | IdP redirect if IdP session invalid |
On **web**, `handleAuthChallenge()` returns **`step_up_confirm`** for EMA `insufficient_scope` until the user clicks **Authorize** in `StepUpAuthModal`; it does **not** call `trySilentEmaAuth()` before that. After confirm, silent re-mint runs in-process when possible; otherwise IdP redirect + callback (same as before).
### Outcomes — what callers do
| Outcome | Web remote | TUI / CLI direct |
| --------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| **`satisfied`** | `pushAuthState()`; command-scoped: **retry send once** | Reconnect or SDK retry (v2); command wrapper may retry RPC |
| **`step_up_confirm`** | Throw `AuthRecoveryRequiredError` (`emaStepUpConfirm`) → `StepUpAuthModal` (EMA copy) | N/A (web-only deferral) |
| **`interactive`** | Throw `AuthRecoveryRequiredError` (enriched challenge) → App shows modal or redirect + snapshot | `AuthRecoveryRequiredError` or `authChallengeInteractive` → callback server flow |
**InspectorClient events (direct transport):** `authChallengeAmbient` (idle SSE / ambient recovery), `authChallengeCommand` (command-scoped direct recovery — no ambient toast), `authChallengeInteractive`, `authChallengeRecovered`.
| **`failed`** | Toast / banner; stay connected (degraded) | Error message; stay connected (TUI) or exit non-zero (CLI one-shot) |
---
## Web implementation
### Detection and delivery
**Backend** (`core/mcp/remote/node/`):
- **Auth-challenge intercept fetch** on the MCP HTTP transport: on **401/403**, parse headers, throw `AuthChallengeError` **before** the stub provider invokes SDK `auth()`.
- **`createRemoteAuthProvider`**: mutable credentials; `RemoteSession.setAuthState()` updates the upstream Bearer without new `connect()`.
**Dual delivery — one channel per incident:**
| Path | Trigger | Wire | Client behavior |
| ------------------ | --------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| **Command-scoped** | Active `POST /api/mcp/send` | HTTP **200** `{ ok: false, kind: "auth_challenge", authChallenge }` | `handleAuthChallenge()` → `pushAuthState()` → **retry same JSON-RPC once** |
| **Ambient** | Transport error while idle | SSE `auth_challenge` | `handleAmbientAuthChallenge()` → push auth state; **no RPC retry** |
`authReturnedViaHttp` prevents duplicating a command-scoped challenge on SSE.
Inspector API **4xx** are reserved for malformed requests / missing session — not for upstream MCP token expiry.
### `POST /api/mcp/auth-state`
```typescript
interface RemoteSetAuthStateRequest {
sessionId: string;
authState: RemoteAuthState;
}
interface RemoteAuthState {
oauthTokens?: { access_token; token_type; refresh_token?; scope?; … };
oauthClient?: { client_id; client_secret? }; // reserved for future server-side refresh
}
```
Called by `RemoteClientTransport.pushAuthState()` after browser-side recovery. Seeding uses the same shape on `POST /api/mcp/connect`.
### App orchestration (`clients/web/src/App.tsx`)
**Command-scoped paths** (tool, prompt, resource, app) share `handleCommandScopedAuthRecovery()`:
- Standard-OAuth **or EMA** step-up → `StepUpAuthModal` (defer redirect / re-mint until **Authorize**).
- 401 / EMA IdP (non-step-up) → `prepareOAuthRedirect()` (auto-redirect + snapshot).
- Background browser tab hidden → defer to `pendingReauth`; resume on `visibilitychange` with `checkAuthChallengeSatisfied` first.
**Ambient path:** listens for `authChallengeInteractive` on `InspectorClient` (from SSE when silent recovery cannot complete).
**Disconnect** clears `pendingStepUp`, `pendingReauth`, and `reAuthBanner`.
### Step-up confirmation modal
Shown for **`insufficient_scope`** on **standard OAuth** and **EMA (web)** when recovery will **redirect** to an AS or **re-mint** organization permissions.
- Copy from `core/auth/oauthUx.ts` (`stepUpConfirmMessage`, `stepUpFollowUpMessage`, `stepUpModalTitle`). EMA uses organization / IdP language; standard OAuth uses resource-AS redirect language.
- Lists **`requiredScopes`** (additional scopes only — not the full SEP-2350 union).
- **Authorize (standard OAuth):** write [OAuth resume snapshot](#oauth-resume-snapshot), pre-redirect toast, `beginInteractiveAuthorization()`.
- **Authorize (EMA):** in-progress toast → `handleAuthChallenge(..., { confirmedStepUp: true })` → on `satisfied`, push auth state + success toast (retry hint when command-scoped); on `interactive`, same snapshot + IdP redirect as other EMA flows; on `failed`, error toast.
- **Cancel:** scoped by `StepUpSource` (tool / prompt / resource / app / ambient) — only the triggering panel shows error; session stays connected.
Not shown for: token refresh, 401 re-login, connect-time OAuth.
**Rationale:** Inspector is primarily a **testing and exploration** client. Surfacing step-up scopes before silent EMA re-mint makes permission elevation visible during manual validation (same UX bar as standard OAuth step-up on web). Production MCP clients may skip this confirm when silent re-mint is acceptable.
### OAuth resume snapshot
Full-page redirect (`window.location.href`) destroys in-memory React state. Before navigate, `prepareOAuthRedirect()` writes:
```typescript
interface OAuthResumeSnapshot {
version: 1;
serverId: string;
activeTab: string; // App tab id ("Tools", …)
authKind: "step_up" | "reauth";
tabUi: Partial<Record<InspectorTabId, unknown>>; // lifted *UiState shells
remoteSessionId?: string;
authChallenge?: AuthChallenge; // step-up: verify scopes after callback
}
```
Key: `mcp-inspector:oauth-resume` in `sessionStorage` (`clients/web/src/lib/oauthResume.ts`).
**Callback flow** (`InspectorClient.resumeAfterOAuth()`):
1. `completeOAuthFlow(code)`.
2. **Consume** snapshot from `sessionStorage` (read + clear — one-shot).
3. `setupClientForServer(serverId)`.
4. If `remoteSessionId` still valid: `attachToSession()` + `pushAuthState()`; else `connect()` (skipped if already connected).
5. Restore `tabUi` and `activeTab` **immediately after consume** (before async token work finishes); clear in-flight result panels. Never re-applied on later reconnect.
6. Step-up: `checkAuthChallengeSatisfied(authChallenge)` — warning toast if scopes still insufficient.
7. Success toast: step-up vs reauth copy; **user manually retries** the action (no auto-replay).
Snapshots **per app tab UI**, not message logs, tool results, or network bodies. Each snapshot is **one-shot**: written immediately before a full-page OAuth redirect, **consumed** (read + cleared from `sessionStorage`) when the `/oauth/callback` handler runs, and UI restored once at that moment. **Explicit user disconnect** clears any pending snapshot and resets `activeTab` to Servers so a later manual reconnect does not pop back to the OAuth-restored tab; transport/client teardown during connect setup does **not** clear the snapshot (so connect-time OAuth can still match the server on callback). A later manual reconnect does not read or apply a consumed snapshot.
### Multiple browser tabs
Each browser tab has its own `RemoteSession`. OAuth runtime state is shared file-backed (`RemoteOAuthStorage` → `oauth.json`) across tabs on the same backend; SSE `auth_challenge` is scoped to that tab's session.
When a tab is **hidden**, **interactive** OAuth must not steal focus:
1. Set **`pendingReauth`** (in-memory) instead of modal/redirect.
2. On **`visibilitychange` → visible`:** `checkAuthChallengeSatisfied()` first; if still needed, run visible flow.
Command-scoped recovery (user clicked Run in the foreground tab) is **not** deferred.
Future: shared `RemoteOAuthStorage` → `oauth.json` may use optional `navigator.locks` around silent refresh only — see [v2_auth_ema.md §Shared storage](v2_auth_ema.md).
### Web UX reference
| Situation | Behavior |
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Silent refresh / EMA re-mint (after confirm) | Toast “Refreshing authorization…” or EMA in-progress toast — no modal |
| **401** interactive | Toast “Session expired…” → auto-redirect; no confirm modal |
| **403** standard-OAuth step-up | Modal → optional pre-redirect toast → redirect → callback restore |
| **403** EMA step-up (web) | Modal (organization copy) → in-progress toast → silent re-mint or IdP redirect → success toast |
| EMA IdP leg 1 (401 / expired IdP) | Toast “Re-authenticating…” → auto-redirect |
| Step-up **Cancel** | Connected; failed action shows error |
| OAuth abort / callback failure | **ReAuthBanner**; Re-authenticate uses in-session `authenticate()` when already connected (no disconnect cycle) |
| Step-up callback, scopes still insufficient | Warning toast — not green success |
| `insufficient_scope` recovery failure (non-banner reasons) | Yellow toast — not ReAuthBanner |
| Concurrent step-up while modal open | Yellow toast — complete or cancel current step-up first |
---
## TUI and CLI implementation
### Principles
- **Live provider** on the SDK transport — same process as MCP.
- **`InspectorClient.withDirectAuthRecovery()`** wraps RPCs: silent `handleAuthChallenge()` + reconnect; **`AuthRecoveryRequiredError`** for interactive.
- **`directAuthRecovery: true`** enables `interceptAuthChallenges` on `createTransportNode` until v2 SDK transport owns silent retry.
- **Interactive OAuth** uses shared **`runRunnerInteractiveOAuth()`** (`core/auth/node/runner-interactive-oauth.ts`): loopback server, browser redirect, `completeOAuthFlow()`, optional post-step-up scope check, **15-minute callback timeout** (configurable).
Default callback: `http://127.0.0.1:6276/oauth/callback` (`--callback-url` / `MCP_OAUTH_CALLBACK_URL`). Only one TUI/CLI listener on that port at a time.
CLI never spawns TUI/web for auth — completes locally or fails.
### TUI UX (`clients/tui/src/App.tsx`)
| Situation | Behavior |
| ------------------------------ | ---------------------------------------------------------------------- |
| Silent recovery | Auth tab: “Refreshing authorization…” |
| **401** | Auto `runOAuthAuthentication()` (same as connect-time) |
| **403** standard-OAuth step-up | Switch to Auth tab; **A** authorize / **C** cancel; browser → callback |
| **403** EMA | Silent re-mint |
| Step-up on connected server | `presentStepUpForServer()` selects server + opens Auth tab |
| Reauth for affected server | `handleAuthRecoveryRequired()` switches server when needed |
| Clear OAuth | Auth tab **S** |
### CLI UX (`clients/cli/src/cliOAuth.ts`)
| Situation | Behavior |
| ------------------------------ | -------------------------------------------------------------------------------- |
| Connect-time 401 | `connectInspectorWithOAuth()` → authorize URL on stdout → callback |
| Mid-session interactive | `handleCliAuthRecoveryRequired()` / `withCliAuthRecoveryRetry()` (**one retry**) |
| **403** standard-OAuth step-up | stderr: scope message + `Proceed with step-up authorization? [y/N]` |
| Decline step-up | Exit non-zero |
| Insufficient scope after OAuth | stderr message from `stepUpInsufficientScopeMessage()` |
### SSE limitation
Legacy **SSE** transport: **401 only** on mid-session `send()` (no 403 step-up). Step-up testing targets **streamable HTTP**.
---
## Client matrix
| Concern | Web | TUI | CLI |
| --------------------- | -------------------------------------------------------------- | -------------------------------- | ------------------------- |
| Challenge detection | Inline send + SSE ambient | SDK transport + intercept | Same as TUI |
| Auth execution | Browser `OAuthManager` | Node `OAuthManager` | Node `OAuthManager` |
| OAuth storage | `RemoteOAuthStorage` → `oauth.json` (via `/api/storage/oauth`) | `NodeOAuthStorage` (file) | Same file as TUI |
| Silent recovery | `auth-state` push + send retry | Reconnect / SDK retry (v2) | One-shot RPC retry |
| Interactive recovery | Modal + full-page redirect + snapshot | Auth tab + callback | stderr **y/N** + callback |
| Step-up confirm | `StepUpAuthModal` | Auth tab **A** / **C** | **y/N** |
| EMA step-up | Web modal + confirm (then silent or IdP) | Confirm on Auth tab, then silent | **y/N**, then silent |
| Multiple browser tabs | Independent sessions; background defer | N/A | N/A |
---
## Test infrastructure
Step-up integration tests and manual smokes use the **composable OAuth server** (`test-servers/build/server-composable.js`) with per-capability scope requirements enforced in HTTP middleware (`test-server-oauth.ts`).
### How scopes work at two levels
| Level | Config field | Role |
| ------------------------ | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Authorization server** | `oauth.scopesSupported` | Advertises every scope the AS **may grant** (PRM / AS metadata). Inspector uses this for connect-time consent and step-up union. Must include all scopes referenced by `requiredScopes` below. |
| **Per capability** | `requiredScopes` on a tool, resource, or prompt preset ref | Scopes the **access token must already include** before that RPC succeeds. Missing scope → **403** + `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. Omitted → only global bearer validity (401 if no/invalid token). |
**Connect-time vs step-up:** Inspector catalog OAuth scopes control what the user grants on **first connect**. The composable server may accept that token for some operations but reject others until step-up adds more scopes.
Example flow with the sample below:
1. Catalog entry: `"oauth": { "scopes": "mcp tools:read" }` — user connects successfully.
2. `tools/call echo` — no `requiredScopes` → succeeds.
3. `tools/call get_temp` — needs `weather:read` → **403 insufficient_scope** → Inspector step-up with SEP-2350 union (`mcp`, `tools:read`, `weather:read`).
4. After step-up, `get_temp` succeeds; `resources/read` on `file:///secret.txt` still needs another step-up for `secrets:read`.
Step-up is enforced on **use** (`tools/call`, `resources/read`, `prompts/get`), not on discovery (`tools/list`, `resources/list`, `prompts/list`).
### Sample composable config
Illustrates server-level `scopesSupported` plus tool-, resource-, and prompt-level `requiredScopes`. The checked-in smoke fixture [`test-servers/configs/oauth-step-up-demo.json`](../test-servers/configs/oauth-step-up-demo.json) is a **minimal** subset (tools only: `echo` + `get_temp`).
```json
{
"serverInfo": { "name": "step-up-demo", "version": "1.0.0" },
"transport": { "type": "streamable-http", "port": 8081 },
"oauth": {
"enabled": true,
"mode": "combined",
"requireAuth": true,
"scopesSupported": [
"mcp",
"tools:read",
"weather:read",
"secrets:read",
"admin:write"
],
"supportDCR": true,
"supportRefreshTokens": true
},
"tools": [
{ "preset": "echo" },
{ "preset": "get_temp", "requiredScopes": ["weather:read"] },
{ "preset": "add", "requiredScopes": ["admin:write"] }
],
"resources": [
{
"preset": "static_text",
"params": { "uri": "file:///secret.txt", "name": "secret" },
"requiredScopes": ["secrets:read"]
}
],
"prompts": [{ "preset": "simple_prompt", "requiredScopes": ["weather:read"] }]
}
```
**Matching Inspector catalog entry** (connect with subset scopes so step-up is exercisable):
```json
"oauth-step-up-demo": {
"type": "streamable-http",
"url": "http://127.0.0.1:8081/mcp",
"oauth": { "scopes": "mcp tools:read" }
}
```
**Run the server:**
```bash
cd clients/web && npm run test-servers:build
node ../../test-servers/build/server-composable.js \
--config ../../test-servers/configs/oauth-step-up-demo.json
```
### Enforcement (HTTP middleware)
On each MCP request, after bearer validation:
1. Parse JSON-RPC `method` and `params`.
2. Look up `requiredScopes` from the registry built at startup:
| MCP method | Registry key |
| -------------------------- | ---------------------------------- |
| `tools/call` | tool `name` (`params.name`) |
| `resources/read` | resource `uri` (`params.uri`) |
| `prompts/get` | prompt `name` (`params.name`) |
| `resources/templates/read` | template name or URI from `params` |
3. Compare granted scopes on the access token (stored at token issue time in combined mode) against `requiredScopes`.
4. If any required scope is missing, respond **403** with:
```http
WWW-Authenticate: Bearer error="insufficient_scope", scope="weather:read"
```
(`scope=` lists the missing scope(s); body is a JSON-RPC error envelope.)
`requiredScopes` on preset refs is merged in `resolve-config.ts`; no application-code routes — config drives behavior.
### Verification
**Automated (high level):** challenge parsing, scope union, `checkAuthChallengeSatisfied`, `oauthResume`, `runRunnerInteractiveOAuth`; integration suites `inspectorClient-oauth-remote-mid-session-e2e.test.ts`, `inspectorClient-oauth-direct-mid-session-e2e.test.ts`, CLI `oauth-interactive.test.ts` / `cliOAuth.test.ts`.
**Manual:** [v2_auth_smoke_testing.md §5](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — required gate **W1 + W5–W7**, **T1–T2 + T4**, **C1–C2**.
---
## Related specifications
| Document | Relationship |
| ---------------------------------------------------- | --------------------------------------------------------------------------- |
| [v2_auth_hardening.md](v2_auth_hardening.md) | Connect-time SEPs; v2 SDK upgrade; direct transport silent retry delegation |
| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 2–3 re-mint; scope resolution; no resource-OAuth fallback |
| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual OAuth and mid-session validation procedures |
| [v2_storage.md](v2_storage.md) | Shared `oauth.json` via `RemoteOAuthStorage` on web (implemented) |
---
## Future work
- **Optional `navigator.locks`** on shared `RemoteOAuthStorage` — single-flight silent refresh across browser tabs (see [EMA §Shared storage](v2_auth_ema.md)).
- **v2 SDK transport upgrade** — delegate direct streamable HTTP silent 401/403 + SEP-2350 union to SDK; remove `mcpAuth` / client intercept shims where redundant.
- **Server-side token refresh** on the node using `RemoteAuthState.oauthClient` + `refresh_token` (browser owns refresh today).
- **Connection Info** — display effective vs pending scopes.
- **Composable `oauth.operations`** map for method-wide scope defaults in test fixtures.
- **Popup OAuth window** as an alternative to full-page resume (not implemented).
---
## Normative references
- [MCP authorization (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
- [MCP authorization (draft — 2026-07-28 RC)](https://modelcontextprotocol.io/specification/draft/basic/authorization)
- [SEP-2350 — client-side scope accumulation in step-up](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)
- [EMA extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)
- [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) — Bearer `insufficient_scope`