Skip to content
Closed
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
208 changes: 208 additions & 0 deletions packages/core/sdk/src/oauth-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
OAUTH2_REFRESH_SKEW_MS,
OAuth2Error,
buildAuthorizationUrl,
providerAudienceParam,
providerAuthorizeExtras,
createPkceCodeChallenge,
createPkceCodeVerifier,
Expand Down Expand Up @@ -98,6 +99,24 @@ const tokenResponse =
() =>
json(200, body);

// Auth0 token URLs can't point at the loopback fixture (the audience mapping
// keys off the `*.auth0.com` host), so capture the wire request through the
// injected fetch and answer with a canned token response instead.
const capturingFetch = (body: unknown) => {
const bodies: URLSearchParams[] = [];
const fetchImpl: typeof globalThis.fetch = (async (input, init) => {
const text = input instanceof Request ? await input.clone().text() : String(init?.body ?? "");
bodies.push(new URLSearchParams(text));
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof globalThis.fetch;
return { fetchImpl, bodies } as const;
};

const auth0TokenUrl = "https://tenant.us.auth0.com/oauth/token";

// ---------------------------------------------------------------------------
// PKCE
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -143,6 +162,45 @@ describe("providerAuthorizeExtras (Google offline/consent quirk)", () => {
});
});

describe("providerAudienceParam (Auth0 audience quirk)", () => {
it("mirrors the resource into audience for Auth0 tenant hosts", () => {
expect(
providerAudienceParam("https://tenant.auth0.com/oauth/token", "https://api.example.com"),
).toBe("https://api.example.com");
expect(
providerAudienceParam("https://tenant.us.auth0.com/authorize", "https://api.example.com"),
).toBe("https://api.example.com");
});

it("matches the Auth0 host case-insensitively", () => {
expect(
providerAudienceParam("https://Tenant.AUTH0.com/oauth/token", "https://api.example.com"),
).toBe("https://api.example.com");
});

it("returns undefined for non-Auth0 hosts, lookalike hosts, and unparseable URLs", () => {
expect(
providerAudienceParam("https://oauth2.googleapis.com/token", "https://api.example.com"),
).toBeUndefined();
// Suffix check is on the hostname label boundary: `evilauth0.com` is not Auth0.
expect(
providerAudienceParam("https://evilauth0.com/oauth/token", "https://api.example.com"),
).toBeUndefined();
expect(
providerAudienceParam("https://auth0.com.evil.com/token", "https://api.example.com"),
).toBeUndefined();
expect(providerAudienceParam("not a url", "https://api.example.com")).toBeUndefined();
});

it("returns undefined when no resource is known", () => {
expect(
providerAudienceParam("https://tenant.auth0.com/oauth/token", undefined),
).toBeUndefined();
expect(providerAudienceParam("https://tenant.auth0.com/oauth/token", null)).toBeUndefined();
expect(providerAudienceParam("https://tenant.auth0.com/oauth/token", "")).toBeUndefined();
});
});

describe("buildAuthorizationUrl", () => {
const baseInput = {
authorizationUrl: "https://example.com/authorize",
Expand Down Expand Up @@ -219,6 +277,44 @@ describe("buildAuthorizationUrl", () => {
expect(url.searchParams.has("resource")).toBe(false);
});

it("mirrors resource into Auth0's audience param for Auth0 authorize hosts", () => {
const url = new URL(
buildAuthorizationUrl({
...baseInput,
authorizationUrl: "https://tenant.us.auth0.com/authorize",
resource: "https://api.example.com/v1/mcp",
}),
);
expect(url.searchParams.get("resource")).toBe("https://api.example.com/v1/mcp");
expect(url.searchParams.get("audience")).toBe("https://api.example.com/v1/mcp");
});

it("does not add audience for non-Auth0 hosts or when no resource is set", () => {
const nonAuth0 = new URL(
buildAuthorizationUrl({ ...baseInput, resource: "https://api.example.com/v1/mcp" }),
);
expect(nonAuth0.searchParams.has("audience")).toBe(false);
const noResource = new URL(
buildAuthorizationUrl({
...baseInput,
authorizationUrl: "https://tenant.us.auth0.com/authorize",
}),
);
expect(noResource.searchParams.has("audience")).toBe(false);
});

it("lets an explicit extraParams audience override the Auth0 auto-mapping", () => {
const url = new URL(
buildAuthorizationUrl({
...baseInput,
authorizationUrl: "https://tenant.us.auth0.com/authorize",
resource: "https://api.example.com/v1/mcp",
extraParams: { audience: "https://other-api.example.com" },
}),
);
expect(url.searchParams.get("audience")).toBe("https://other-api.example.com");
});

it("rejects unsupported authorization URL schemes", () => {
expect(() =>
buildAuthorizationUrl({
Expand Down Expand Up @@ -338,6 +434,41 @@ describe("exchangeAuthorizationCode", () => {
),
);

it.effect("sends Auth0's audience param alongside resource for *.auth0.com token URLs", () =>
Effect.gen(function* () {
const { fetchImpl, bodies } = capturingFetch(validCodeBody);
yield* exchangeAuthorizationCode({
tokenUrl: auth0TokenUrl,
clientId: "cid",
redirectUrl: "https://app.example.com/cb",
codeVerifier: "verifier",
code: "abc",
resource: "https://api.example.com/v1/mcp",
fetch: fetchImpl,
});
const body = bodies[0]!;
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("resource")).toBe("https://api.example.com/v1/mcp");
expect(body.get("audience")).toBe("https://api.example.com/v1/mcp");
}),
);

it.effect("does not send audience to non-Auth0 token endpoints", () =>
withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
Effect.gen(function* () {
yield* exchangeAuthorizationCode({
tokenUrl,
clientId: "cid",
redirectUrl: "https://app.example.com/cb",
codeVerifier: "verifier",
code: "abc",
resource: "https://api.example.com/v1/mcp",
});
expect((yield* calls)[0]!.body.has("audience")).toBe(false);
}),
),
);

it.effect("strips id_tokens whose iss does not match AS metadata", () =>
withTokenEndpoint(
tokenResponse({
Expand Down Expand Up @@ -665,6 +796,52 @@ describe("exchangeClientCredentials", () => {
expect(JSON.stringify(exit.cause)).toContain("Token URL must use https: or loopback http:");
}),
);

it.effect("sends Auth0's audience param alongside resource for *.auth0.com token URLs", () =>
Effect.gen(function* () {
const { fetchImpl, bodies } = capturingFetch(validRefreshBody);
yield* exchangeClientCredentials({
tokenUrl: auth0TokenUrl,
clientId: "cid",
clientSecret: "secret",
resource: "https://api.example.com/v1/mcp",
fetch: fetchImpl,
});
const body = bodies[0]!;
expect(body.get("grant_type")).toBe("client_credentials");
expect(body.get("resource")).toBe("https://api.example.com/v1/mcp");
expect(body.get("audience")).toBe("https://api.example.com/v1/mcp");
}),
);

it.effect("omits audience for Auth0 token URLs when no resource is known", () =>
Effect.gen(function* () {
const { fetchImpl, bodies } = capturingFetch(validRefreshBody);
yield* exchangeClientCredentials({
tokenUrl: auth0TokenUrl,
clientId: "cid",
clientSecret: "secret",
fetch: fetchImpl,
});
expect(bodies[0]!.has("audience")).toBe(false);
}),
);

it.effect("does not send audience to non-Auth0 token endpoints", () =>
withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
Effect.gen(function* () {
yield* exchangeClientCredentials({
tokenUrl,
clientId: "cid",
clientSecret: "secret",
resource: "https://api.example.com/v1/mcp",
});
const body = (yield* calls)[0]!.body;
expect(body.get("resource")).toBe("https://api.example.com/v1/mcp");
expect(body.has("audience")).toBe(false);
}),
),
);
});

describe("refreshAccessToken", () => {
Expand Down Expand Up @@ -728,6 +905,37 @@ describe("refreshAccessToken", () => {
),
);

it.effect("sends Auth0's audience param alongside resource for *.auth0.com token URLs", () =>
Effect.gen(function* () {
const { fetchImpl, bodies } = capturingFetch(validRefreshBody);
yield* refreshAccessToken({
tokenUrl: auth0TokenUrl,
clientId: "cid",
refreshToken: "old",
resource: "https://api.example.com/v1/mcp",
fetch: fetchImpl,
});
const body = bodies[0]!;
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("resource")).toBe("https://api.example.com/v1/mcp");
expect(body.get("audience")).toBe("https://api.example.com/v1/mcp");
}),
);

it.effect("does not send audience on refresh to non-Auth0 token endpoints", () =>
withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
Effect.gen(function* () {
yield* refreshAccessToken({
tokenUrl,
clientId: "cid",
refreshToken: "old",
resource: "https://api.example.com/v1/mcp",
});
expect((yield* calls)[0]!.body.has("audience")).toBe(false);
}),
),
);

it.effect("strips refreshed id_tokens whose iss does not match AS metadata", () =>
withTokenEndpoint(
tokenResponse({
Expand Down
55 changes: 55 additions & 0 deletions packages/core/sdk/src/oauth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string
if (input.resource) {
url.searchParams.set("resource", input.resource);
}
// Auth0: mirror the resource indicator into the proprietary `audience`
// param (see `providerAudienceParam`). Set before `extraParams` so an
// explicit caller-provided `audience` still wins.
const audience = providerAudienceParam(input.authorizationUrl, input.resource);
if (audience) {
url.searchParams.set("audience", audience);
}
if (input.extraParams) {
for (const [k, v] of Object.entries(input.extraParams)) {
url.searchParams.set(k, v);
Expand All @@ -168,6 +175,34 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string
return url.toString();
};

/** Auth0 quirk: Auth0 does not implement RFC 8707 Resource Indicators. Its
* authorization and token endpoints select the target API (resource server)
* via a proprietary `audience` parameter instead, and silently ignore
* `resource`. Without `audience`, a client_credentials exchange against an
* Auth0 tenant fails outright with 403 `access_denied` ("No audience
* parameter was provided, and no default audience has been configured"), and
* authorization-code grants fall back to an opaque token that resource
* servers cannot validate. Auth0 documents `audience` as its equivalent of
* the resource indicator and takes the same value: the API identifier URI.
*
* Mirror the RFC 8707 `resource` value into `audience` when the endpoint
* host is an Auth0 tenant domain (`<tenant>.auth0.com`, including regional
* `<tenant>.<region>.auth0.com`), keyed off the host exactly like
* `providerAuthorizeExtras`. Custom Auth0 domains cannot be auto-detected
* from the URL; those tenants should configure a default audience on the
* Auth0 side. Returns the `audience` value to send, or `undefined` when the
* endpoint is not Auth0 or no resource is known. */
export const providerAudienceParam = (
endpointUrl: string,
resource: string | null | undefined,
): string | undefined => {
if (!resource) return undefined;
if (!URL.canParse(endpointUrl)) return undefined;
const hostname = new URL(endpointUrl).hostname.toLowerCase();
if (hostname === "auth0.com" || hostname.endsWith(".auth0.com")) return resource;
return undefined;
};

/** Provider-specific authorize-URL extras that are NOT RFC 6749 params, so the
* generic flow must add them per-provider (keyed off the authorization host).
*
Expand Down Expand Up @@ -544,6 +579,12 @@ export const exchangeAuthorizationCode = (
if (input.resource) {
params.set("resource", input.resource);
}
// Auth0: mirror the resource indicator into the proprietary `audience`
// param (see `providerAudienceParam`).
const audience = providerAudienceParam(input.tokenUrl, input.resource);
if (audience) {
params.set("audience", audience);
}
const response = await oauth.genericTokenEndpointRequest(
as,
client,
Expand Down Expand Up @@ -599,6 +640,14 @@ export const exchangeClientCredentials = (
if (input.resource) {
params.set("resource", input.resource);
}
// Auth0: mirror the resource indicator into the proprietary `audience`
// param. Auth0 hard-fails client_credentials without it (403
// access_denied, "No audience parameter was provided, and no default
// audience has been configured").
const audience = providerAudienceParam(input.tokenUrl, input.resource);
if (audience) {
params.set("audience", audience);
}
const response = await oauth.clientCredentialsGrantRequest(
as,
client,
Expand Down Expand Up @@ -661,6 +710,12 @@ export const refreshAccessToken = (
if (input.resource) {
extraParams.set("resource", input.resource);
}
// Auth0: mirror the resource indicator into the proprietary `audience`
// param (see `providerAudienceParam`).
const audience = providerAudienceParam(input.tokenUrl, input.resource);
if (audience) {
extraParams.set("audience", audience);
}
const additionalParameters =
Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
const response = await oauth.refreshTokenGrantRequest(
Expand Down