Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion packages/mcp-server/src/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
createMcpProtectedResourceMetadata,
exchangeMcpOAuthAuthorizationCode,
normalizeMcpPublicUrl,
type OAuthAccessTokenStore,
type OAuthAuthorizationCodeStore,
registerMcpOAuthClient,
revokeMcpOAuthAccessToken,
} from "./oauth.js";
Expand Down Expand Up @@ -443,7 +445,7 @@ describe("MCP OAuth metadata and dynamic client registration", () => {
const codeStore = createInMemoryOAuthAuthorizationCodeStore();
const exhaustedTokenStore = {
issueToken: () => undefined,
verifyToken: () => undefined,
verifyToken: () => ({ ok: false, reason: "unknown_token" }) as const,
revokeToken: () => false,
};
const verifier = "j".repeat(43);
Expand Down Expand Up @@ -491,6 +493,128 @@ describe("MCP OAuth metadata and dynamic client registration", () => {
).toMatchObject({ status: 400, body: { error: "invalid_grant" } });
});

it("binds exchanged access tokens to the authorized resource", () => {
const clientsStore = createInMemoryOAuthClientsStore();
const codeStore = createInMemoryOAuthAuthorizationCodeStore();
const tokenStore = createInMemoryOAuthAccessTokenStore();
const verifier = "r".repeat(43);
const registered = registerMcpOAuthClient(
{
redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
token_endpoint_auth_method: "none",
},
clientsStore,
);
if (registered.status !== 201) throw new Error("expected successful registration");
const authorizeParams = new URLSearchParams({
client_id: registered.body.client_id,
redirect_uri: "https://claude.ai/api/mcp/auth_callback",
response_type: "code",
code_challenge_method: "S256",
code_challenge: pkceS256(verifier),
resource: "https://codemem.example.test/mcp",
});

const wrongResourceAuthorize = authorizeMcpOAuthClient(
authorizeParams,
clientsStore,
codeStore,
);
if (wrongResourceAuthorize.status !== 302) throw new Error("expected authorization redirect");
expect(
exchangeMcpOAuthAuthorizationCode(
new URLSearchParams({
grant_type: "authorization_code",
client_id: registered.body.client_id,
redirect_uri: "https://claude.ai/api/mcp/auth_callback",
code: new URL(wrongResourceAuthorize.location).searchParams.get("code") ?? "",
code_verifier: verifier,
resource: "https://other.example.test/mcp",
}),
clientsStore,
codeStore,
tokenStore,
),
).toMatchObject({ status: 400, body: { error: "invalid_grant" } });

const authorize = authorizeMcpOAuthClient(authorizeParams, clientsStore, codeStore);
if (authorize.status !== 302) throw new Error("expected authorization redirect");
const token = exchangeMcpOAuthAuthorizationCode(
new URLSearchParams({
grant_type: "authorization_code",
client_id: registered.body.client_id,
redirect_uri: "https://claude.ai/api/mcp/auth_callback",
code: new URL(authorize.location).searchParams.get("code") ?? "",
code_verifier: verifier,
resource: "https://codemem.example.test/mcp",
}),
clientsStore,
codeStore,
tokenStore,
);

expect(token.status).toBe(200);
if (token.status !== 200) throw new Error("expected token response");
expect(tokenStore.verifyToken(token.body.access_token)).toMatchObject({
ok: true,
record: { resource: "https://codemem.example.test/mcp" },
});
});

it("revokes tokens issued during an authorization-code consume race", () => {
const clientsStore = createInMemoryOAuthClientsStore();
const realTokenStore = createInMemoryOAuthAccessTokenStore();
const issuedTokens: string[] = [];
const tokenStore: OAuthAccessTokenStore = {
issueToken: (clientId, now, resource) => {
const issued = realTokenStore.issueToken(clientId, now, resource);
if (issued) issuedTokens.push(issued.token);
return issued;
},
verifyToken: (token, now) => realTokenStore.verifyToken(token, now),
revokeToken: (token, now) => realTokenStore.revokeToken(token, now),
};
const registered = registerMcpOAuthClient(
{
redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
token_endpoint_auth_method: "none",
},
clientsStore,
);
if (registered.status !== 201) throw new Error("expected successful registration");
const codeStore: OAuthAuthorizationCodeStore = {
issueCode: () => "race-code",
peekCode: () => ({
clientId: registered.body.client_id,
redirectUri: "https://claude.ai/api/mcp/auth_callback",
codeChallenge: pkceS256("s".repeat(43)),
expiresAt: Date.now() + 5 * 60 * 1000,
used: false,
}),
consumeCode: () => undefined,
};

expect(
exchangeMcpOAuthAuthorizationCode(
new URLSearchParams({
grant_type: "authorization_code",
client_id: registered.body.client_id,
redirect_uri: "https://claude.ai/api/mcp/auth_callback",
code: "race-code",
code_verifier: "s".repeat(43),
}),
clientsStore,
codeStore,
tokenStore,
),
).toMatchObject({ status: 400, body: { error: "invalid_grant" } });
expect(issuedTokens).toHaveLength(1);
expect(realTokenStore.verifyToken(issuedTokens[0] ?? "")).toMatchObject({
ok: false,
reason: "revoked_token",
});
});

it("consumes the authorization code on permanent grant failures", () => {
const clientsStore = createInMemoryOAuthClientsStore();
const codeStore = createInMemoryOAuthAuthorizationCodeStore();
Expand Down
30 changes: 25 additions & 5 deletions packages/mcp-server/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface AuthorizationCodeRecord {
clientId: string;
redirectUri: string;
codeChallenge: string;
resource?: string;
expiresAt: number;
used: boolean;
}
Expand All @@ -62,6 +63,7 @@ export interface OAuthAuthorizationCodeStore {

export interface AccessTokenRecord {
clientId: string;
resource?: string;
tokenHash: string;
issuedAt: number;
expiresAt: number;
Expand All @@ -74,7 +76,11 @@ export type AccessTokenVerificationResult =
| { ok: false; reason: "unknown_token" | "expired_token" | "revoked_token" };

export interface OAuthAccessTokenStore {
issueToken(clientId: string, now?: number): { token: string; expiresIn: number } | undefined;
issueToken(
clientId: string,
now?: number,
resource?: string,
): { token: string; expiresIn: number } | undefined;
verifyToken(token: string, now?: number): AccessTokenVerificationResult;
revokeToken(token: string, now?: number): boolean;
}
Expand All @@ -98,6 +104,7 @@ export interface PreparedMcpOAuthAuthorizationRequest {
clientId: string;
redirectUri: string;
codeChallenge: string;
resource: string | null;
state: string | null;
}

Expand Down Expand Up @@ -160,13 +167,18 @@ export class InMemoryOAuthAccessTokenStore implements OAuthAccessTokenStore {
readonly #tokensByHash = new Map<string, AccessTokenRecord>();
readonly #tokenHashKey = randomBytes(32);

issueToken(clientId: string, now = Date.now()): { token: string; expiresIn: number } | undefined {
issueToken(
clientId: string,
now = Date.now(),
resource?: string,
): { token: string; expiresIn: number } | undefined {
this.#deleteInactiveTokens(now);
if (this.#tokensByHash.size >= MAX_ACCESS_TOKENS) return undefined;
const tokenBytes = randomBytes(ACCESS_TOKEN_BYTES);
const tokenHash = signOAuthAccessTokenBytes(tokenBytes, this.#tokenHashKey);
this.#tokensByHash.set(tokenHash, {
clientId,
resource,
tokenHash,
issuedAt: now,
expiresAt: now + ACCESS_TOKEN_TTL_SECONDS * 1000,
Expand Down Expand Up @@ -322,6 +334,7 @@ export function authorizeMcpOAuthClient(
clientId: prepared.clientId,
redirectUri: prepared.redirectUri,
codeChallenge: prepared.codeChallenge,
...(prepared.resource ? { resource: prepared.resource } : {}),
expiresAt: now + AUTHORIZATION_CODE_TTL_MS,
},
now,
Expand All @@ -343,6 +356,7 @@ export function prepareMcpOAuthAuthorizationRequest(
const responseType = params.get("response_type") ?? "";
const codeChallenge = params.get("code_challenge") ?? "";
const codeChallengeMethod = params.get("code_challenge_method") ?? "";
const resource = params.get("resource");
const state = params.get("state");

if (responseType !== "code") return invalidOAuthRequest("unsupported_response_type");
Expand All @@ -359,7 +373,7 @@ export function prepareMcpOAuthAuthorizationRequest(
return invalidOAuthRequest("invalid_request", "redirect_uri is not registered for this client");
}

return { clientId, redirectUri, codeChallenge, state };
return { clientId, redirectUri, codeChallenge, resource, state };
}

export function exchangeMcpOAuthAuthorizationCode(
Expand All @@ -377,6 +391,7 @@ export function exchangeMcpOAuthAuthorizationCode(
const code = params.get("code") ?? "";
const redirectUri = params.get("redirect_uri") ?? "";
const codeVerifier = params.get("code_verifier") ?? "";
const resource = params.get("resource");
const client = getRegisteredClient(clientsStore, clientId);
if (!client) return invalidOAuthRequest("invalid_client", "Unknown OAuth client_id");
if (!PKCE_VERIFIER.test(codeVerifier)) {
Expand All @@ -401,8 +416,12 @@ export function exchangeMcpOAuthAuthorizationCode(
codeStore.consumeCode(code);
return invalidOAuthRequest("invalid_grant", "PKCE verification failed");
}
if ((record.resource ?? null) !== (resource || null)) {
codeStore.consumeCode(code);
return invalidOAuthRequest("invalid_grant", "Code does not match resource");
}

const issued = tokenStore.issueToken(clientId, now);
const issued = tokenStore.issueToken(clientId, now, record.resource);
if (!issued) {
// Token-store overload is transient: leave the auth code unused so the
// client can retry token exchange without restarting the OAuth flow.
Expand All @@ -411,9 +430,10 @@ export function exchangeMcpOAuthAuthorizationCode(

const consumed = codeStore.consumeCode(code);
if (!consumed) {
tokenStore.revokeToken(issued.token, now);
// Lost a race with another request that consumed the code in the gap
// between peek and consume. Treat as invalid_grant rather than issuing
// the token, since the code is no longer single-use atomic.
// a duplicate usable token, since the code is no longer single-use atomic.
return invalidOAuthRequest("invalid_grant", "Authorization code already used");
}

Expand Down
Loading