typescript-sdk/examples/oauth/client.ts

160 lines
8.7 KiB
TypeScript

/**
* Self-verifying **authorization-code** OAuth client — the CI-runnable headless
* twin of {@link ./simpleOAuthClient.ts}.
*
* `simpleOAuthClient.ts` is the manual real-user example: it `open()`s the
* authorization URL in a real browser, the user signs in and clicks **Approve**
* on the consent screen, the browser is redirected to a local callback server,
* and the example reads the `code` off that callback. THIS file drives the
* exact same SDK auth machinery but follows the redirect chain itself — which
* only works because the demo Authorization Server is started with
* `OAUTH_DEMO_AUTO_CONSENT=1` so its `/sign-in` page auto-signs-in a fixed
* demo user and its `/authorize` endpoint auto-consents (skips the Approve
* screen and 302s straight back to `redirect_uri?code=...`).
*
* Flow:
* 1. Connect with an {@linkcode InMemoryOAuthClientProvider} → 401 → SDK auth
* driver discovers PRM → AS metadata → registers a client (DCR) → builds
* the authorization URL → calls our `redirectToAuthorization` hook (we
* capture the URL) → throws {@linkcode UnauthorizedError}.
* 2. Follow that URL with `fetch(..., { redirect: 'manual' })`, forwarding
* `Set-Cookie` → `Cookie` across hops, until the AS 302s to our
* `redirect_uri` with `?code=...`. No callback server is bound — the code
* is read straight off the `Location` header.
* 3. `transport.finishAuth(code)` → SDK exchanges the code (+ PKCE verifier)
* for tokens at the AS `/token` endpoint and saves them on the provider.
* 4. Reconnect with a fresh transport (same provider, now holding tokens) →
* Bearer header → 200. Call `whoami` and assert `ctx.authInfo` round-trips.
*
* HTTP-only (the OAuth dance is HTTP redirects + Bearer headers), so the
* canonical stdio branch does not apply.
*/
import { check, parseExampleArgs } from '@mcp-examples/shared';
import type { OAuthClientMetadata } from '@modelcontextprotocol/client';
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider';
// The redirect target the AS will 302 back to with `?code=...`. In the real
// browser flow (`simpleOAuthClient.ts`) a tiny HTTP server listens here so the
// browser has somewhere to land; headlessly we never bind it — we read the
// `code` off the final 302's `Location` header instead.
const CALLBACK_URL = 'http://127.0.0.1:8090/callback';
/**
* Follow an authorization URL through the demo AS's redirect chain
* (authorize → /sign-in → authorize → redirect_uri?code=...) and return the
* `code`. This is the headless stand-in for "the user's browser navigates the
* login + consent pages": cookies are forwarded hop-to-hop the way a browser
* would, and the demo AS's auto-sign-in + `autoConsent` collapse every
* interactive step into a 302.
*/
async function followAuthorizationRedirects(authorizationUrl: URL): Promise<URLSearchParams> {
let next = authorizationUrl.href;
// Crude cookie jar — enough for a single-origin demo AS.
const jar = new Map<string, string>();
for (let hop = 0; hop < 10; hop++) {
const cookie = [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
// In a real client this is `open(authorizationUrl)` — we follow the redirect
// chain headlessly because the demo AS auto-signs-in and auto-approves.
const res = await fetch(next, { redirect: 'manual', headers: cookie ? { cookie } : {} });
for (const sc of res.headers.getSetCookie()) {
const pair = sc.split(';', 1)[0] ?? '';
const eq = pair.indexOf('=');
if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
const location = res.headers.get('location');
if (!location || res.status < 300 || res.status >= 400) {
const body = await res.text().catch(() => '');
throw new Error(`expected a redirect at hop ${hop} (${next}); got ${res.status}\n${body.slice(0, 400)}`);
}
const resolved = new globalThis.URL(location, next);
// In a real deployment, the browser would render the consent page here and
// the user would click Approve; the demo AS's `autoConsent` flag simulates
// that approval, so the chain ends in a 302 straight to `redirect_uri`.
if (resolved.href.startsWith(CALLBACK_URL)) {
const code = resolved.searchParams.get('code');
const error = resolved.searchParams.get('error');
if (error) throw new Error(`AS returned error on callback: ${error} ${resolved.searchParams.get('error_description') ?? ''}`);
if (!code) throw new Error(`callback redirect missing ?code: ${resolved.href}`);
return resolved.searchParams;
}
next = resolved.href;
}
throw new Error('authorization redirect chain did not terminate at the callback within 10 hops');
}
const { url, era } = parseExampleArgs();
const client = new Client(
{ name: 'oauth-headless-client', version: '1.0.0' },
{ versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' } }
);
// ---- 1. Kick off the SDK auth driver --------------------------------------
// The SDK builds the authorization URL and hands it to
// `redirectToAuthorization` — in `simpleOAuthClient.ts` that opens a browser;
// here we just capture it.
let capturedAuthorizationUrl: URL | undefined;
const clientMetadata: OAuthClientMetadata = {
client_name: 'Headless OAuth MCP Client (CI)',
redirect_uris: [CALLBACK_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'native',
token_endpoint_auth_method: 'client_secret_post'
};
const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, authUrl => {
capturedAuthorizationUrl = authUrl;
});
const firstTransport = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider });
let challenged = false;
try {
await client.connect(firstTransport);
} catch (error) {
// Both `--legacy` and `mode: 'auto'` surface the original
// `UnauthorizedError` directly (the negotiation probe propagates it
// unchanged; older releases wrapped it as the `data.cause` of an
// EraNegotiationFailed `SdkError`, which the unwrap below still
// tolerates). Either way the auth driver has already run by the time we
// land here — DCR done, auth URL captured.
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
if (!(root instanceof UnauthorizedError)) throw error;
challenged = true;
}
check.ok(challenged, 'first connect must 401 and throw UnauthorizedError');
check.ok(capturedAuthorizationUrl, 'SDK auth driver should have produced an authorization URL');
check.ok(provider.clientInformation()?.client_id, 'dynamic client registration should have run');
// ---- 2. Follow the authorization URL headlessly ---------------------------
// (the browser-and-user stand-in; see `followAuthorizationRedirects`).
const callbackParams = await followAuthorizationRedirects(capturedAuthorizationUrl!);
// ---- 3. Exchange the code for tokens --------------------------------------
// In the browser flow the local callback server hands the redirect query to
// `transport.finishAuth`; we read it off the final `Location` header instead.
// The SDK reads `code` + `iss` (RFC 9207) from the params, validates `iss`
// against the recorded issuer, then POSTs `grant_type=authorization_code`
// (+ PKCE `code_verifier`) to the AS `/token` endpoint and saves the tokens
// on `provider`.
await firstTransport.finishAuth(callbackParams);
const tokens = provider.tokens();
check.ok(tokens?.access_token, 'token exchange should have yielded an access_token');
check.equal(tokens?.token_type, 'Bearer');
// ---- 4. Reconnect with the now-populated provider -------------------------
// A fresh transport reads the saved Bearer token from `provider` and the
// protected `/mcp` endpoint lets us through.
const transport = new StreamableHTTPClientTransport(new globalThis.URL(url), { authProvider: provider });
await client.connect(transport);
const result = await client.callTool({ name: 'whoami', arguments: {} });
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : '';
const seen = JSON.parse(text) as { clientId?: string; scopes?: string[] };
// `ctx.authInfo` round-trips: the clientId the AS minted at DCR time is the
// one the Resource Server's verifier sees on the Bearer token.
check.equal(seen.clientId, provider.clientInformation()?.client_id, 'ctx.authInfo.clientId round-trips the DCR client_id');
check.ok(seen.scopes?.includes('openid'), 'ctx.authInfo.scopes carries a granted scope');
await client.close();