mirror of
https://github.com/better-auth/better-auth.git
synced 2026-08-25 17:11:27 -05:00
feat(oauth-provider): add iss parameter to authorization responses (RFC 9207) (#7669)
This commit is contained in:
committed by
Alex Yang
parent
d660f4dd6b
commit
766fcc8a2d
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user