OAuth로 사용자 인증하기

OAuth로 사용자 인증하기 (Authenticate a user with OAuth)

이 페이지는 여러분이 만든 클라이언트에서 OAuth authorization-code 흐름으로 최종 사용자(end user)를 로그인시키는 방법을 다룹니다. 실행하는 서버를 보호하는 쪽은 인증 요구하기 를, 사용자가 없는 기계 간 흐름은 사용자 없이 인증하기 를 보세요.

전송에 OAuth 프로바이더 넘기기

전송의 authProviderOAuthClientProvider 를 넘기면 됩니다. 이것과 이 페이지에 나오는 모든 심볼은 @modelcontextprotocol/client 에서 와요.

const provider = new MyOAuthProvider();
const client = new Client({ name: 'my-app', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), {
    authProvider: provider
});

try {
    await client.connect(transport);
} catch (error) {
    if (!(error instanceof UnauthorizedError)) throw error;
    // The transport already called provider.redirectToAuthorization(url):
    // the end user is in the browser, at the authorization server.
}

서버가 인증을 요구하고 프로바이더에 토큰이 없으면, SDK는 서버에 대해 discovery 를 돌리고 여러분의 OAuth 클라이언트를 등록(또는 조회)한 다음 프로바이더의 redirectToAuthorization(url) 을 호출하고 connect()UnauthorizedError 를 던져요. 최종 사용자는 밖에서(band) 로그인을 마치고, 여러분의 콜백 엔드포인트가 아래에서 흐름을 다시 이어 받습니다.

::: info 프로토콜 버전 협상(versionNegotiation: { mode: 'auto' } 또는 pin)이 걸려 있으면, 연결 시점의 UnauthorizedErrorconnect() 에서 바뀌지 않고 그대로 퍼져 나와요. 그래서 같은 instanceof 검사가 모든 모드에서 동작합니다(옛 릴리스는 error.data.cause 에 오류가 담긴 SdkError 로 감쌌어요). 프로토콜 버전 을 보세요. :::

OAuthClientProvider 구현하기

프로바이더는 SDK가 구동하는 저장소이자 리다이렉트 표면이에요. 클라이언트 등록, 토큰, PKCE verifier, discovery 상태, 브라우저 인계가 모두 여기 담겨요. 클라이언트 자격증명은 ctx.issuer 를 키로 삼아, 한 인증 서버에 등록한 client_id 가 다른 서버로 절대 보내지지 않게 하세요.

class MyOAuthProvider implements OAuthClientProvider {
    // Key DCR-obtained credentials by issuer so a client_id registered with one
    // authorization server is never returned for another (SEP-2352).
    private creds = new Map<string, OAuthClientInformationMixed>();
    private storedTokens?: OAuthTokens;
    private verifier?: string;
    private discovery?: OAuthDiscoveryState;
    lastState?: string;

    readonly redirectUrl = 'http://localhost:8090/callback';
    readonly clientMetadata: OAuthClientMetadata = {
        client_name: 'My MCP Client',
        redirect_uris: ['http://localhost:8090/callback'],
        // Loopback redirect → the SDK would default this to 'native'; set
        // explicitly when the heuristic is wrong for your deployment (SEP-837).
        application_type: 'native'
    };

    clientInformation(ctx?: OAuthClientInformationContext) {
        return ctx ? this.creds.get(ctx.issuer) : undefined;
    }
    saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) {
        if (ctx) this.creds.set(ctx.issuer, info);
    }
    tokens() {
        return this.storedTokens;
    }
    saveTokens(tokens: OAuthTokens) {
        // In production, persist to OS keychain / secure storage — never plain files.
        this.storedTokens = tokens;
    }
    // CSRF binding for the redirect — the SDK puts this on the authorize URL;
    // your callback handler compares it before calling `finishAuth`.
    state() {
        this.lastState = crypto.randomUUID();
        return this.lastState;
    }
    // Callback-leg AS-binding (SEP-2352): record what discovery resolved before
    // the redirect so the SDK can verify the code is exchanged at the same AS.
    saveDiscoveryState(state: OAuthDiscoveryState) {
        this.discovery = state;
    }
    discoveryState() {
        return this.discovery;
    }
    redirectToAuthorization(url: URL) {
        onRedirect(url);
    }
    saveCodeVerifier(v: string) {
        this.verifier = v;
    }
    codeVerifier() {
        if (!this.verifier) throw new Error('no code verifier');
        return this.verifier;
    }
}

SDK는 흐름이 값을 만들어 낼 때 save* 메서드를 부르고, tokens(), clientInformation(), codeVerifier(), discoveryState() 로 다시 읽어 옵니다. 다음 connect() 에서는 무엇보다 먼저 tokens() 를 읽으므로, 영속 저장소에 담긴 프로바이더라면 브라우저 왕복을 건너뜁니다.

콜백에서 흐름 마무리하기

인증 서버는 최종 사용자를 codestate 가 쿼리에 담긴 redirectUrl 로 리다이렉트해요. state 를 비교하고 쿼리 전체를 finishAuth 에 넘긴 뒤 다시 연결하면 됩니다.

const callbackUrl = await waitForCallback(); // however your app receives the redirect
const params = new URL(callbackUrl).searchParams;

// The SDK does not validate `state` — compare it to the value your provider generated.
if (params.get('state') !== provider.lastState) throw new Error('state mismatch');

await transport.finishAuth(params);

// Reconnect on a FRESH transport — a started transport cannot be restarted.
// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport.
await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider }));

finishAuth(params)code 를 뽑아내고 RFC 9207 iss 파라미터를 검증하며, 리다이렉트 전에 해결된 인증 서버에서 코드를 교환하고, 여러분의 프로바이더를 통해 토큰을 저장합니다. 두 번째 connect() 는 그 토큰을 찾아 리다이렉트 없이 완료돼요.

::: tip finishAuth 는 위치 기반 형태 finishAuth(code, iss) 도 받습니다. 대신 URLSearchParams 를 넘기세요. SDK가 두 값을 거기서 읽고, 위치 기반 호출이 iss 를 빼먹으면 인증 서버가 RFC 9207 지원을 광고할 때 거부돼요. :::

발급자 불일치 처리하기

finishAuth 는 콜백의 iss 가 흐름을 시작할 때의 발급자와 다르면 IssuerMismatchError 를 던집니다.

try {
    await transport.finishAuth(params);
} catch (error) {
    if (error instanceof IssuerMismatchError) {
        // Mix-up attack: never render params.get('error_description') to the user.
        throw new Error('Authorization failed: issuer mismatch');
    }
    throw error;
}

여기서 오류의 kind'authorization_response' 예요. discovery 동안 인증 서버가 발행한 issuer(RFC 8414 §3.3)에 대해서도 같은 검사가 돌아가고, 이때는 kind: 'metadata' 로 던져집니다.

::: warning 불일치는 콜백이 여러분이 흐름을 시작하지 않은 인증 서버에서 왔다는 뜻이에요 — mix-up 공격이죠. 콜백의 errorerror_description 은 공격자가 조종한 것이니 절대 그대로 렌더링하지 마세요. 전송의 skipIssuerMetadataValidation 옵션은 discovery 쪽 검사를 끄는데, 서버를 직접 제어하지 않으면 끄지 마세요. :::

리소스 표시자 고정하기

SDK는 RFC 8707 resource 파라미터로 토큰을 여러분의 서버에 묶어요. 서버가 보호 리소스 메타데이터(RFC 9728)를 발행하면, SDK는 메타데이터의 resource 를 서버 URL과 대조하고 그것을 인증 리다이렉트와 모든 토큰 요청에 붙입니다. validateResourceURL 을 오버라이드해 값을 강제할 수 있어요 — 보낼 URL을 돌려주거나, 파라미터를 생략하도록 undefined 를 돌려주면 됩니다.

class PinnedResourceProvider extends MyOAuthProvider {
    async validateResourceURL(serverUrl: string | URL, resource?: string): Promise<URL | undefined> {
        const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2)
        if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) {
            throw new Error(`Refusing resource ${resource} for server ${expected.href}`);
        }
        return expected;
    }
}

PinnedResourceProvider 는 흐름의 모든 구간에서 서버 자신의 URL을 resource 로 보내고, 다른 이름을 가리키는 메타데이터는 거부해요. checkResourceAllowedresourceUrlFromServerUrl 은 정확히 이 오버라이드를 위해 내보내진 함수입니다.

요약

  • 이 페이지는 최종 사용자를 로그인시키는 것이고, 기계 간 흐름은 사용자 없이 인증하기 에 있어요.
  • 전송의 authProviderOAuthClientProvider 를 넘기면, connect() 가 사용자를 인증 서버로 보낸 뒤 UnauthorizedError 를 던집니다.
  • 콜백 쿼리 전체로 finishAuth(params) 를 호출하면 iss(RFC 9207)를 검증하고 코드를 교환해요.
  • 새 전송에서 다시 연결하면 되고, OAuth 상태는 전송이 아니라 프로바이더에 있습니다.
  • IssuerMismatchError 가 mix-up 방어예요 — 콜백의 error_description 은 절대 렌더링하지 마세요.
  • validateResourceURL 은 SDK가 보내는 RFC 8707 resource 파라미터를 오버라이드합니다.