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;
}
/**