diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 3ed7037f5..c92bcc742 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -14,6 +14,7 @@ import { OAUTH2_REFRESH_SKEW_MS, OAuth2Error, buildAuthorizationUrl, + providerAudienceParam, providerAuthorizeExtras, createPkceCodeChallenge, createPkceCodeVerifier, @@ -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 // --------------------------------------------------------------------------- @@ -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", @@ -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({ @@ -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({ @@ -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", () => { @@ -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({ diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 077eb6ec4..92a4e1d28 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -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); @@ -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 (`.auth0.com`, including regional + * `..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). * @@ -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, @@ -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, @@ -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(