160 lines
8.7 KiB
TypeScript
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();
|