From 766fcc8a2d2c49d7cf7f9eb8b33947b548b5e2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paola=20Estefan=C3=ADa=20de=20Campos?= <84341268+Paola3stefania@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:46:08 -0300 Subject: [PATCH] feat(oauth-provider): add iss parameter to authorization responses (RFC 9207) (#7669) --- docs/content/docs/plugins/generic-oauth.mdx | 69 ++++ docs/content/docs/plugins/oauth-provider.mdx | 2 + .../src/plugins/generic-oauth/error-codes.ts | 4 + .../generic-oauth/generic-oauth.test.ts | 381 ++++++++++++++++++ .../src/plugins/generic-oauth/routes.ts | 30 ++ .../src/plugins/generic-oauth/types.ts | 14 + packages/oauth-provider/src/authorize.test.ts | 136 ++++++- packages/oauth-provider/src/authorize.ts | 59 ++- packages/oauth-provider/src/consent.ts | 3 +- packages/oauth-provider/src/metadata.ts | 4 +- packages/oauth-provider/src/types/oauth.ts | 8 + 11 files changed, 706 insertions(+), 4 deletions(-) diff --git a/docs/content/docs/plugins/generic-oauth.mdx b/docs/content/docs/plugins/generic-oauth.mdx index d078d3fc2e..7aeae6051f 100644 --- a/docs/content/docs/plugins/generic-oauth.mdx +++ b/docs/content/docs/plugins/generic-oauth.mdx @@ -248,6 +248,8 @@ Each provider configuration object supports the following options: interface GenericOAuthConfig { providerId: string; discoveryUrl?: string; + issuer?: string; + requireIssuerValidation?: boolean; authorizationUrl?: string; tokenUrl?: string; userInfoUrl?: string; @@ -269,6 +271,10 @@ interface GenericOAuthConfig { **discoveryUrl**: (Optional) URL to fetch the provider's OAuth 2.0/OIDC configuration. If provided, endpoints like `authorizationUrl`, `tokenUrl`, and `userInfoUrl` can be auto-discovered. +**issuer**: (Optional) The expected issuer identifier for validation. If not provided but `discoveryUrl` is set, it will be fetched from the discovery document. When set, the callback validates that the `iss` parameter matches this value. + +**requireIssuerValidation**: (Optional) When `true`, requires the `iss` parameter in callbacks if an issuer is configured. This provides stricter security but may break with older OAuth servers. Defaults to `false`. + **authorizationUrl**: (Optional) The OAuth provider's authorization endpoint. Not required if using `discoveryUrl`. **tokenUrl**: (Optional) The OAuth provider's token endpoint. Not required if using `discoveryUrl`. @@ -316,6 +322,69 @@ interface GenericOAuthConfig { **overrideUserInfo**: (Optional) If true, the user's info in your database will be updated with the provider's info every time they sign in. Defaults to `false`. +## Security: Issuer Validation + +Better Auth validates the OAuth provider's issuer to protect against mix-up attacks ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)). A mix-up attack occurs when a malicious authorization server tricks your application into sending an authorization code to the wrong token endpoint. + +### How It Works + +When an OAuth provider supports RFC 9207, it includes an `iss` (issuer) parameter in the authorization response. Better Auth validates this parameter against the expected issuer to ensure the response came from the intended provider. + +### Configuration Examples + +**Auto-discovery (recommended for OIDC providers):** +```ts +genericOAuth({ + config: [{ + providerId: "my-provider", + discoveryUrl: "https://auth.example.com/.well-known/openid-configuration", + clientId: "...", + clientSecret: "...", + // issuer is automatically fetched from discovery document + }] +}) +``` + +**Manual issuer configuration:** +```ts +genericOAuth({ + config: [{ + providerId: "custom-oauth", + authorizationUrl: "https://auth.example.com/authorize", + tokenUrl: "https://auth.example.com/token", + issuer: "https://auth.example.com", // manually specify expected issuer + clientId: "...", + clientSecret: "...", + }] +}) +``` + +**Strict mode (recommended for modern providers):** +```ts +genericOAuth({ + config: [{ + providerId: "secure-provider", + discoveryUrl: "https://auth.example.com/.well-known/openid-configuration", + clientId: "...", + clientSecret: "...", + requireIssuerValidation: true, // reject if iss parameter is missing + }] +}) +``` + +### Validation Behavior + +| Scenario | `requireIssuerValidation` | Result | +|----------|---------------------------|--------| +| `iss` matches expected | - | Success | +| `iss` doesn't match | - | `issuer_mismatch` error | +| `iss` missing | `false` (default) | Success (backward compatible) | +| `iss` missing | `true` | `issuer_missing` error | + + +For maximum security with modern OAuth/OIDC providers (Google, Auth0, Okta, etc.), we recommend enabling `requireIssuerValidation: true`. + + ## Advanced Usage ### Custom Token Exchange diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 23544e366f..a2afe195f8 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -10,6 +10,7 @@ The plugin has a secured configuration by default providing ease to users unfami **Key Features**: - **OAuth 2.1**: Restricted security practices to [OAuth 2.1](https://oauth.net/2.1/) +- **Issuer Validation**: Authorization responses include `iss` parameter to prevent [mix-up attacks](https://datatracker.ietf.org/doc/html/rfc9207) - **MCP Enabled**: Support with [MCP authentication](#mcp) - **OIDC compatibility**: [OIDC](https://openid.net/specs/openid-connect-core-1_0.html)-compliant with the `openid` scope - **UserInfo**: Endpoint providing current user details @@ -425,6 +426,7 @@ The Authorization Endpoint is the entry point for initiating an OAuth 2.1 author Important notes: - In OAuth 2.1, only `response_type: "code"` is supported. - `code_challenge_method: "plain"` will not be supported since this is a security vulnerability. +- All authorization responses (success and error) include the `iss` parameter for issuer validation ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)). **State** diff --git a/packages/better-auth/src/plugins/generic-oauth/error-codes.ts b/packages/better-auth/src/plugins/generic-oauth/error-codes.ts index b0bf720a30..91614cfff2 100644 --- a/packages/better-auth/src/plugins/generic-oauth/error-codes.ts +++ b/packages/better-auth/src/plugins/generic-oauth/error-codes.ts @@ -7,4 +7,8 @@ export const GENERIC_OAUTH_ERROR_CODES = defineErrorCodes({ PROVIDER_ID_REQUIRED: "Provider ID is required", INVALID_OAUTH_CONFIG: "Invalid OAuth configuration.", SESSION_REQUIRED: "Session is required", + ISSUER_MISMATCH: + "OAuth issuer mismatch. The authorization server issuer does not match the expected value (RFC 9207).", + ISSUER_MISSING: + "OAuth issuer parameter missing. The authorization server did not include the required iss parameter (RFC 9207).", }); diff --git a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts index 680b49a0fc..5420d7c442 100644 --- a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts +++ b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts @@ -1667,4 +1667,385 @@ describe("oauth2", async () => { expect(session.data?.user.name).toBe(customUserInfo.display_name); expect(session.data?.user.image).toBe(customUserInfo.avatar_url); }); + + describe("RFC 9207 Issuer Validation", () => { + it("should allow callback when iss parameter matches configured issuer", async () => { + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "iss-match@test.com", + name: "Issuer Match User", + sub: "iss-match", + picture: "https://test.com/picture.png", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + const expectedIssuer = server.issuer.url; + const { customFetchImpl, cookieSetter } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "iss-test", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId: clientId, + clientSecret: clientSecret, + pkce: true, + issuer: expectedIssuer, + }, + ], + }), + ], + }); + + const authClient = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + const headers = new Headers(); + const res = await authClient.signIn.oauth2({ + providerId: "iss-test", + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + let location: string | null = null; + await betterFetch(res.data?.url || "", { + method: "GET", + redirect: "manual", + onError(context) { + location = context.response.headers.get("location"); + }, + }); + + const callbackWithIss = new URL(location!); + callbackWithIss.searchParams.set("iss", expectedIssuer!); + + let finalCallbackURL = ""; + await betterFetch(callbackWithIss.toString(), { + method: "GET", + customFetchImpl, + headers, + onError(context) { + finalCallbackURL = context.response.headers.get("location") || ""; + cookieSetter(headers)(context); + }, + }); + + expect(finalCallbackURL).toBe("http://localhost:3000/new_user"); + expect(finalCallbackURL).not.toContain("error="); + }); + + it("should reject callback when iss parameter does not match configured issuer", async () => { + const expectedIssuer = server.issuer.url; + const wrongIssuer = "https://evil-server.com"; + + const { customFetchImpl, cookieSetter } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "iss-mismatch-test", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId: clientId, + clientSecret: clientSecret, + pkce: true, + issuer: expectedIssuer, + }, + ], + }), + ], + }); + + const authClient = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + const headers = new Headers(); + const res = await authClient.signIn.oauth2({ + providerId: "iss-mismatch-test", + callbackURL: "http://localhost:3000/dashboard", + errorCallbackURL: "http://localhost:3000/error", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + let location: string | null = null; + await betterFetch(res.data?.url || "", { + method: "GET", + redirect: "manual", + onError(context) { + location = context.response.headers.get("location"); + }, + }); + + const callbackWithWrongIss = new URL(location!); + callbackWithWrongIss.searchParams.set("iss", wrongIssuer); + + let finalCallbackURL = ""; + await betterFetch(callbackWithWrongIss.toString(), { + method: "GET", + customFetchImpl, + headers, + onError(context) { + finalCallbackURL = context.response.headers.get("location") || ""; + }, + }); + + expect(finalCallbackURL).toContain("http://localhost:3000/error"); + expect(finalCallbackURL).toContain("error=issuer_mismatch"); + }); + + it("should use issuer from discovery document when not explicitly configured", async () => { + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "iss-discovery@test.com", + name: "Issuer Discovery User", + sub: "iss-discovery", + picture: "https://test.com/picture.png", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + const expectedIssuer = server.issuer.url; + + const { customFetchImpl, cookieSetter } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "iss-discovery-test", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId: clientId, + clientSecret: clientSecret, + pkce: true, + }, + ], + }), + ], + }); + + const authClient = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + const headers = new Headers(); + const res = await authClient.signIn.oauth2({ + providerId: "iss-discovery-test", + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + let location: string | null = null; + await betterFetch(res.data?.url || "", { + method: "GET", + redirect: "manual", + onError(context) { + location = context.response.headers.get("location"); + }, + }); + + const callbackWithIss = new URL(location!); + callbackWithIss.searchParams.set("iss", expectedIssuer!); + + let finalCallbackURL = ""; + await betterFetch(callbackWithIss.toString(), { + method: "GET", + customFetchImpl, + headers, + onError(context) { + finalCallbackURL = context.response.headers.get("location") || ""; + cookieSetter(headers)(context); + }, + }); + + expect(finalCallbackURL).toBe("http://localhost:3000/new_user"); + }); + + it("should not validate iss when not configured and not in discovery", async () => { + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "no-iss-check@test.com", + name: "No Issuer Check User", + sub: "no-iss-check", + picture: "https://test.com/picture.png", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + const { customFetchImpl, cookieSetter } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "no-iss-test", + authorizationUrl: `http://localhost:${port}/authorize`, + tokenUrl: `http://localhost:${port}/token`, + userInfoUrl: `http://localhost:${port}/userinfo`, + clientId: clientId, + clientSecret: clientSecret, + pkce: true, + }, + ], + }), + ], + }); + + const authClient = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + const headers = new Headers(); + const res = await authClient.signIn.oauth2({ + providerId: "no-iss-test", + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + const { callbackURL } = await simulateOAuthFlow( + res.data?.url || "", + headers, + customFetchImpl, + ); + + expect(callbackURL).toBe("http://localhost:3000/new_user"); + }); + + it("should reject callback when requireIssuerValidation is true and iss is missing", async () => { + const expectedIssuer = server.issuer.url; + + const { customFetchImpl, cookieSetter } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "strict-iss-test", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId: clientId, + clientSecret: clientSecret, + pkce: true, + issuer: expectedIssuer, + requireIssuerValidation: true, + }, + ], + }), + ], + }); + + const authClient = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + const headers = new Headers(); + const res = await authClient.signIn.oauth2({ + providerId: "strict-iss-test", + callbackURL: "http://localhost:3000/dashboard", + errorCallbackURL: "http://localhost:3000/error", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + const { callbackURL } = await simulateOAuthFlow( + res.data?.url || "", + headers, + customFetchImpl, + ); + + expect(callbackURL).toContain("http://localhost:3000/error"); + expect(callbackURL).toContain("error=issuer_missing"); + }); + + it("should allow callback without iss when requireIssuerValidation is false", async () => { + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "lenient-iss@test.com", + name: "Lenient Issuer User", + sub: "lenient-iss", + picture: "https://test.com/picture.png", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + const expectedIssuer = server.issuer.url; + + const { customFetchImpl, cookieSetter } = await getTestInstance({ + plugins: [ + genericOAuth({ + config: [ + { + providerId: "lenient-iss-test", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId: clientId, + clientSecret: clientSecret, + pkce: true, + issuer: expectedIssuer, + requireIssuerValidation: false, + }, + ], + }), + ], + }); + + const authClient = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + const headers = new Headers(); + const res = await authClient.signIn.oauth2({ + providerId: "lenient-iss-test", + callbackURL: "http://localhost:3000/dashboard", + newUserCallbackURL: "http://localhost:3000/new_user", + fetchOptions: { + onSuccess: cookieSetter(headers), + }, + }); + + const { callbackURL } = await simulateOAuthFlow( + res.data?.url || "", + headers, + customFetchImpl, + ); + + expect(callbackURL).toBe("http://localhost:3000/new_user"); + }); + }); }); diff --git a/packages/better-auth/src/plugins/generic-oauth/routes.ts b/packages/better-auth/src/plugins/generic-oauth/routes.ts index e0c51e431a..f0f7651482 100644 --- a/packages/better-auth/src/plugins/generic-oauth/routes.ts +++ b/packages/better-auth/src/plugins/generic-oauth/routes.ts @@ -234,6 +234,12 @@ const OAuth2CallbackQuerySchema = z.object({ description: "The state parameter from the OAuth2 request", }) .optional(), + iss: z + .string() + .meta({ + description: "The issuer identifier", + }) + .optional(), }); export const oAuth2Callback = (options: GenericOAuthOptions) => @@ -324,10 +330,13 @@ export const oAuth2Callback = (options: GenericOAuthOptions) => let finalTokenUrl = providerConfig.tokenUrl; let finalUserInfoUrl = providerConfig.userInfoUrl; + let expectedIssuer = providerConfig.issuer; + if (providerConfig.discoveryUrl) { const discovery = await betterFetch<{ token_endpoint: string; userinfo_endpoint: string; + issuer: string; }>(providerConfig.discoveryUrl, { method: "GET", headers: providerConfig.discoveryHeaders, @@ -335,8 +344,29 @@ export const oAuth2Callback = (options: GenericOAuthOptions) => if (discovery.data) { finalTokenUrl = discovery.data.token_endpoint; finalUserInfoUrl = discovery.data.userinfo_endpoint; + if (!expectedIssuer && discovery.data.issuer) { + expectedIssuer = discovery.data.issuer; + } } } + + if (expectedIssuer) { + if (ctx.query.iss) { + if (ctx.query.iss !== expectedIssuer) { + ctx.context.logger.error("OAuth issuer mismatch", { + expected: expectedIssuer, + received: ctx.query.iss, + }); + return redirectOnError("issuer_mismatch"); + } + } else if (providerConfig.requireIssuerValidation) { + ctx.context.logger.error("OAuth issuer parameter missing", { + expected: expectedIssuer, + }); + return redirectOnError("issuer_missing"); + } + } + try { // Use custom getToken if provided if (providerConfig.getToken) { diff --git a/packages/better-auth/src/plugins/generic-oauth/types.ts b/packages/better-auth/src/plugins/generic-oauth/types.ts index b4a27ac06d..40bb1540b9 100644 --- a/packages/better-auth/src/plugins/generic-oauth/types.ts +++ b/packages/better-auth/src/plugins/generic-oauth/types.ts @@ -20,6 +20,20 @@ export interface GenericOAuthConfig { * If provided, the authorization and token endpoints will be fetched from this URL. */ discoveryUrl?: string | undefined; + /** + * The expected issuer identifier for validation. + * If not provided but discoveryUrl is set, it will be fetched from the discovery document. + * When set, the callback validates that the `iss` parameter matches this value. + * @see https://datatracker.ietf.org/doc/html/rfc9207 + */ + issuer?: string | undefined; + /** + * When true, requires the `iss` parameter in callbacks if an issuer is configured. + * This provides stricter security but may break with older OAuth servers + * that don't support issuer identification. + * @default false + */ + requireIssuerValidation?: boolean | undefined; /** * URL for the authorization endpoint. * Optional if using discoveryUrl. diff --git a/packages/oauth-provider/src/authorize.test.ts b/packages/oauth-provider/src/authorize.test.ts index 8a120af7d8..2729052abf 100644 --- a/packages/oauth-provider/src/authorize.test.ts +++ b/packages/oauth-provider/src/authorize.test.ts @@ -4,10 +4,73 @@ import { createAuthorizationURL } from "better-auth/oauth2"; import { jwt } from "better-auth/plugins/jwt"; import { getTestInstance } from "better-auth/test"; import { beforeAll, describe, expect, it } from "vitest"; +import { validateIssuerUrl } from "./authorize"; import { oauthProviderClient } from "./client"; import { oauthProvider } from "./oauth"; import type { OAuthClient } from "./types/oauth"; +describe("validateIssuerUrl (RFC 9207)", () => { + it("should allow HTTPS URLs unchanged", () => { + expect(validateIssuerUrl("https://auth.example.com")).toBe( + "https://auth.example.com", + ); + }); + + it("should convert HTTP to HTTPS for non-localhost", () => { + expect(validateIssuerUrl("http://auth.example.com")).toBe( + "https://auth.example.com", + ); + }); + + it("should allow HTTP for localhost", () => { + expect(validateIssuerUrl("http://localhost:3000")).toBe( + "http://localhost:3000", + ); + }); + + it("should allow HTTP for 127.0.0.1", () => { + expect(validateIssuerUrl("http://127.0.0.1:3000")).toBe( + "http://127.0.0.1:3000", + ); + }); + + it("should strip query parameters", () => { + expect(validateIssuerUrl("https://auth.example.com?foo=bar")).toBe( + "https://auth.example.com", + ); + }); + + it("should strip fragment", () => { + expect(validateIssuerUrl("https://auth.example.com#section")).toBe( + "https://auth.example.com", + ); + }); + + it("should strip both query and fragment", () => { + expect(validateIssuerUrl("https://auth.example.com?foo=bar#section")).toBe( + "https://auth.example.com", + ); + }); + + it("should remove trailing slash", () => { + expect(validateIssuerUrl("https://auth.example.com/")).toBe( + "https://auth.example.com", + ); + }); + + it("should preserve path", () => { + expect(validateIssuerUrl("https://auth.example.com/api/auth")).toBe( + "https://auth.example.com/api/auth", + ); + }); + + it("should handle complex invalid URL and sanitize", () => { + expect( + validateIssuerUrl("http://auth.example.com:8080/path?query=1#hash"), + ).toBe("https://auth.example.com:8080/path"); + }); +}); + describe("oauth authorize - unauthenticated", async () => { const authServerBaseUrl = "http://localhost:3000"; const rpBaseUrl = "http://localhost:5000"; @@ -134,7 +197,7 @@ describe("oauth authorize - authenticated", async () => { oauthClient = response; }); - it("should authorize - prompt undefined, response code, state set, with codeVerifier", async () => { + it("should authorize and include iss parameter", async () => { if (!oauthClient?.client_id || !oauthClient?.client_secret) { throw Error("beforeAll not run properly"); } @@ -162,5 +225,76 @@ describe("oauth authorize - authenticated", async () => { expect(callbackRedirectUrl).toContain(redirectUri); expect(callbackRedirectUrl).toContain(`code=`); expect(callbackRedirectUrl).toContain(`state=123`); + expect(callbackRedirectUrl).toContain( + `iss=${encodeURIComponent(authServerBaseUrl)}`, + ); + }); + + it("should include iss parameter in error responses", async () => { + if (!oauthClient?.client_id || !oauthClient?.client_secret) { + throw Error("beforeAll not run properly"); + } + const authUrl = new URL(`${authServerBaseUrl}/api/auth/oauth2/authorize`); + authUrl.searchParams.set("client_id", oauthClient.client_id); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("scope", "openid"); + authUrl.searchParams.set("state", "error-test-state"); + + let errorRedirectUrl = ""; + await client.$fetch(authUrl.toString(), { + onError(context) { + errorRedirectUrl = context.response.headers.get("Location") || ""; + }, + }); + + expect(errorRedirectUrl).toContain(redirectUri); + expect(errorRedirectUrl).toContain("error=invalid_request"); + expect(errorRedirectUrl).toContain("pkce"); + expect(errorRedirectUrl).toContain(`iss=`); + expect(errorRedirectUrl).toContain( + `iss=${encodeURIComponent(authServerBaseUrl)}`, + ); + }); + + it("should advertise authorization_response_iss_parameter_supported in metadata", async () => { + const metadata = await auth.api.getOpenIdConfig(); + expect(metadata.authorization_response_iss_parameter_supported).toBe(true); + }); + + it("should have metadata issuer match iss parameter (RFC 9207)", async () => { + if (!oauthClient?.client_id || !oauthClient?.client_secret) { + throw Error("beforeAll not run properly"); + } + + const metadata = await auth.api.getOpenIdConfig(); + const metadataIssuer = metadata.issuer; + + const codeVerifier = generateRandomString(64); + const authUrl = await createAuthorizationURL({ + id: providerId, + options: { + clientId: oauthClient.client_id, + clientSecret: oauthClient.client_secret, + }, + redirectURI: redirectUri, + state: "issuer-match-test", + scopes: ["openid"], + responseType: "code", + authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`, + codeVerifier, + }); + + let callbackRedirectUrl = ""; + await client.$fetch(authUrl.toString(), { + onError(context) { + callbackRedirectUrl = context.response.headers.get("Location") || ""; + }, + }); + + const redirectUrl = new URL(callbackRedirectUrl); + const issParam = redirectUrl.searchParams.get("iss"); + + expect(issParam).toBe(metadataIssuer); }); }); diff --git a/packages/oauth-provider/src/authorize.ts b/packages/oauth-provider/src/authorize.ts index 7ce853ce0a..bd6bd11523 100644 --- a/packages/oauth-provider/src/authorize.ts +++ b/packages/oauth-provider/src/authorize.ts @@ -10,7 +10,7 @@ import type { Scope, VerificationValue, } from "./types"; -import { getClient, parsePrompt, storeToken } from "./utils"; +import { getClient, getJwtPlugin, parsePrompt, storeToken } from "./utils"; /** * Formats an error url @@ -20,12 +20,14 @@ export function formatErrorURL( error: string, description: string, state?: string, + iss?: string, ) { const searchParams = new URLSearchParams({ error, error_description: description, }); state && searchParams.append("state", state); + iss && searchParams.append("iss", iss); return `${url}${url.includes("?") ? "&" : "?"}${searchParams.toString()}`; } @@ -41,6 +43,57 @@ export const handleRedirect = (ctx: GenericEndpointContext, uri: string) => { } }; +/** + * Validates that the issuer URL + * - MUST use HTTPS scheme (HTTP allowed for localhost in dev) + * - MUST NOT contain query components + * - MUST NOT contain fragment components + * + * @returns The validated issuer URL, or a sanitized version if invalid + */ +export function validateIssuerUrl(issuer: string): string { + try { + const url = new URL(issuer); + + const isLocalhost = + url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (url.protocol !== "https:" && !isLocalhost) { + url.protocol = "https:"; + } + + url.search = ""; + url.hash = ""; + + return url.toString().replace(/\/$/, ""); + } catch { + // If URL parsing fails, return as-is + return issuer; + } +} + +/** + * Gets the issuer identifier + */ +export function getIssuer( + ctx: GenericEndpointContext, + opts: OAuthOptions, +): string { + let issuer: string; + + if (opts.disableJwtPlugin) { + issuer = ctx.context.baseURL; + } else { + try { + const jwtPluginOptions = getJwtPlugin(ctx.context).options; + issuer = jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL; + } catch { + issuer = ctx.context.baseURL; + } + } + + return validateIssuerUrl(issuer); +} + /** * Error page url if redirect_uri has not been verified yet * Generates Url for custom error page @@ -154,6 +207,7 @@ export async function authorizeEndpoint( "invalid_scope", `The following scopes are invalid: ${invalidScopes.join(", ")}`, query.state, + getIssuer(ctx, opts), ), ); } @@ -171,6 +225,7 @@ export async function authorizeEndpoint( "invalid_request", "pkce is required", query.state, + getIssuer(ctx, opts), ), ); } @@ -184,6 +239,7 @@ export async function authorizeEndpoint( "invalid_request", "invalid code_challenge method", query.state, + getIssuer(ctx, opts), ), ); } @@ -353,6 +409,7 @@ async function redirectWithAuthorizationCode( verificationValue.query.state, ); } + redirectUriWithCode.searchParams.set("iss", getIssuer(ctx, opts)); return handleRedirect(ctx, redirectUriWithCode.toString()); } diff --git a/packages/oauth-provider/src/consent.ts b/packages/oauth-provider/src/consent.ts index fbac5441c2..8adecb01ed 100644 --- a/packages/oauth-provider/src/consent.ts +++ b/packages/oauth-provider/src/consent.ts @@ -1,6 +1,6 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { APIError, getSessionFromCtx } from "better-auth/api"; -import { authorizeEndpoint, formatErrorURL } from "./authorize"; +import { authorizeEndpoint, formatErrorURL, getIssuer } from "./authorize"; import { oAuthState } from "./oauth"; import type { OAuthConsent, OAuthOptions, Scope } from "./types"; import { deleteFromPrompt } from "./utils"; @@ -48,6 +48,7 @@ export async function consentEndpoint( "access_denied", "User denied access", query.get("state") ?? undefined, + getIssuer(ctx, opts), ), }; } diff --git a/packages/oauth-provider/src/metadata.ts b/packages/oauth-provider/src/metadata.ts index 71b8e89cb4..50d7ab4886 100644 --- a/packages/oauth-provider/src/metadata.ts +++ b/packages/oauth-provider/src/metadata.ts @@ -1,5 +1,6 @@ import type { GenericEndpointContext } from "@better-auth/core"; import type { JWSAlgorithms, JwtOptions } from "better-auth/plugins"; +import { validateIssuerUrl } from "./authorize"; import type { OAuthOptions, Scope } from "./types"; import type { AuthServerMetadata, @@ -22,7 +23,7 @@ export function authServerMetadata( const baseURL = ctx.context.baseURL; const metadata: AuthServerMetadata = { scopes_supported: overrides?.scopes_supported, - issuer: opts?.jwt?.issuer ?? baseURL, + issuer: validateIssuerUrl(opts?.jwt?.issuer ?? baseURL), authorization_endpoint: `${baseURL}/oauth2/authorize`, token_endpoint: `${baseURL}/oauth2/token`, jwks_uri: overrides?.jwt_disabled @@ -59,6 +60,7 @@ export function authServerMetadata( "client_secret_post", ], code_challenge_methods_supported: ["S256"], + authorization_response_iss_parameter_supported: true, }; return metadata; } diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts index 075ddd96d3..d16482df0f 100644 --- a/packages/oauth-provider/src/types/oauth.ts +++ b/packages/oauth-provider/src/types/oauth.ts @@ -170,6 +170,14 @@ export interface AuthServerMetadata { * @default ["S256"] */ code_challenge_methods_supported: "S256"[]; + /** + * Boolean value specifying whether the authorization server provides + * the iss parameter in the authorization response (RFC 9207) + * + * @see https://datatracker.ietf.org/doc/html/rfc9207 + * @default true + */ + authorization_response_iss_parameter_supported?: boolean; } /**