feat(oauth-provider): add iss parameter to authorization responses (RFC 9207) (#7669)

This commit is contained in:
Paola Estefanía de Campos
2026-02-18 22:31:37 +08:00
committed by Alex Yang
parent d660f4dd6b
commit 766fcc8a2d
11 changed files with 706 additions and 4 deletions
@@ -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 |
<Callout>
For maximum security with modern OAuth/OIDC providers (Google, Auth0, Okta, etc.), we recommend enabling `requireIssuerValidation: true`.
</Callout>
## Advanced Usage
### Custom Token Exchange
@@ -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**
@@ -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).",
});
@@ -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");
});
});
});
@@ -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) {
@@ -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.
+135 -1
View File
@@ -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);
});
});
+58 -1
View File
@@ -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<Scope[]>,
): 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());
}
+2 -1
View File
@@ -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),
),
};
}
+3 -1
View File
@@ -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;
}
@@ -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;
}
/**