inspector/clients/web/src/utils/oauthNetworkPhase.ts
Cliff Hall 7fecebed5e
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1694)
* OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1625)

Re-key the persisted OAuth store to (server, issuer) and add the
SEP-837/2350/2352 authorization-hardening UX. The SDK v2 auth() drives the
SEP-2352 flow off the provider's ctx.issuer; the Inspector's job is to honor it
and round-trip the issuer stamp.

SEP-2352 — issuer-bound credentials
- ServerOAuthState nests issuer-bound credentials under
  byIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }, with
  an activeIssuer pointer answering the transport's ctx-less per-request bearer
  read.
- Fix a latent bug: OAuthStorageBase re-parsed tokens/client-info through
  OAuthTokensSchema/OAuthClientInformationSchema, which strip the non-RFC
  `issuer` field and silently defeat the SDK's discardIfIssuerMismatch. The
  store re-attaches the stamp from the byIssuer key.
- BaseOAuthClientProvider threads ctx.issuer and implements
  discoveryState()/saveDiscoveryState() (activates the SDK's callback-leg
  AuthorizationServerMismatchError binding check) and invalidateCredentials().
- Lazy, migration-free upgrade: a legacy pre-1625 blob's top-level
  clientInformation/tokens are returned as an unkeyed fallback until the first
  issuer-stamped save promotes them into byIssuer.

SEP-837 — application_type
- DCR declares application_type: "native" (localhost Inspector).
- ClientSettingsForm renders RegistrationRejectedError detail and reflects DCR's
  deprecated-in-favor-of-CIMD (SEP-991) status.

SEP-2350 — step-up scope union
- Per-server onInsufficientScope (reauthorize | throw) setting wired
  ServerSettingsForm -> StoredMCPServer.oauth -> StreamableHTTPClientTransport.
- StepUpAuthModal visualizes the prior-union-challenged scope set, tagging each
  scope `new` vs `already granted`.

mcpAuth simplification
- Delete the hand-rolled authorizeWithoutRefresh (mishandled issuer stamping);
  forward forceReauthorization/skipIssuerMetadataValidation to native SDK auth()
  so both the initial and step-up paths get SEP-2352 stamping.

Vocabulary + docs
- OAuthStep gains cimd_fetch / issuer_comparison / scope_step_up (recorded on a
  step-up redirect); the Network tab labels auth requests by OAuth phase.
- v2_auth_hardening.md gains an as-built status section.

Deferred to the era-aware card: the SdkError(EraNegotiationFailed) ->
UnauthorizedError unwrap and finishAuth(URLSearchParams) form (only fire under
non-legacy negotiation).

Closes #1625

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address @claude review: issuer-stamp source gating + clear/activeIssuer + phase over-match

- SEP-2352: stamp the issuer only when the credential actually came from the
  byIssuer slot (`fromSlot`), not merely when a slot exists. A partially-populated
  slot alongside a surviving legacy field would otherwise stamp a legacy (possibly
  cross-AS) credential with the current issuer, defeating discardIfIssuerMismatch.
- Issuer-scoped clears no longer promote the cleared issuer to `activeIssuer`
  (updateIssuerSlot gains a `setActive` flag; save paths keep the promotion).
- oauthNetworkPhase matches register/token/authorize as the final path segment
  (trailing slash tolerated) so `/token/refresh` or `/api/register/foo` no longer
  over-match.
- Tests for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:52:53 -04:00

62 lines
2.1 KiB
TypeScript

/**
* Classify a captured `auth`-category network request by its OAuth flow phase,
* so the Network tab can label discovery / registration / token / step-up
* traffic. Purely heuristic on the request URL — the SDK owns the flow, so this
* is presentation only and returns `undefined` when nothing matches.
*
* Phases follow the 2026-07-28 authorization flow: RFC 9728/8414 discovery,
* DCR / CIMD registration (SEP-991), the authorization redirect, and the token
* exchange (where a `403 insufficient_scope` step-up re-authorizes, SEP-2350).
*/
export type OAuthNetworkPhase =
| "discovery"
| "registration"
| "authorize"
| "token";
const PHASE_LABELS: Record<OAuthNetworkPhase, string> = {
discovery: "Discovery",
registration: "Registration",
authorize: "Authorize",
token: "Token",
};
export function oauthNetworkPhase(
rawUrl: string,
): OAuthNetworkPhase | undefined {
// Match on the path only; query strings and fragments are irrelevant and can
// contain misleading substrings (e.g. a `redirect_uri` pointing at `/token`).
let path: string;
try {
path = new URL(rawUrl).pathname.toLowerCase();
} catch {
// Not an absolute URL — fall back to the raw string, minus any query.
path = rawUrl.toLowerCase().split(/[?#]/)[0] ?? "";
}
if (
path.includes("/.well-known/oauth-protected-resource") ||
path.includes("/.well-known/oauth-authorization-server") ||
path.includes("/.well-known/openid-configuration")
) {
return "discovery";
}
// Match the endpoint as the final path segment (trailing slash tolerated) so a
// nested path like `/token/refresh` or `/api/register/foo` is not misclassified.
const endpoint = path.replace(/\/+$/, "");
if (endpoint.endsWith("/register")) {
return "registration";
}
if (endpoint.endsWith("/token")) {
return "token";
}
if (endpoint.endsWith("/authorize")) {
return "authorize";
}
return undefined;
}
/** Human-readable badge label for a phase. */
export function oauthNetworkPhaseLabel(phase: OAuthNetworkPhase): string {
return PHASE_LABELS[phase];
}