fix(sso)!: run the DNS SSRF guard on OIDC discovery for public issuers

The discovery URL, and every endpoint taken from the discovery document,
had to be a trusted origin, while assertEndpointResolvesPublic skips
trusted origins. The DNS guard on the discovery path could therefore
never fire, and multi-tenant deployments were pushed to allowlist the
very issuers their tenants control.

Align the discovery path with the manual-config path: publicly routable
hosts are allowed without a trustedOrigins entry, so the resolution guard
runs before the fetch. Non-public hosts still need trustedOrigins and now
fail with discovery_private_host. discovery_untrusted_origin stays in the
error code union, deprecated, but is no longer emitted.

Fixes #10830
Fixes #10831
This commit is contained in:
Bereket Engida
2026-09-03 22:39:04 -07:00
parent 5fe5bc21d1
commit cb8db5e67f
5 changed files with 207 additions and 222 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@better-auth/sso": patch
---
Allow OIDC discovery against publicly routable issuers without a `trustedOrigins` entry, so the DNS-resolution SSRF guard actually runs for them. The discovery URL and every endpoint taken from the discovery document now follow the same public-or-allowlisted host policy as manually configured endpoints. Non-public hosts still require a `trustedOrigins` entry and now fail with `discovery_private_host`; `discovery_untrusted_origin` is no longer emitted.
+22 -50
View File
@@ -244,50 +244,22 @@ Example of relative endpoint and issuer with base path:
This is useful when you need to override the IdP's advertised metadata or when using incomplete mock servers.
</Callout>
#### Trusted origins
#### Private hosts and trusted origins
Both the discovery endpoint as well as any URL resolved through the discovery process are subject to your app's [`trustedOrigins`](/docs/reference/security#trusted-origins) configuration.
Discovery will fail with the `discovery_untrusted_origin` code unless you explicitly update your `trustedOrigins` configuration:
Every URL fetched server-side during discovery (the discovery document itself, then the token, userinfo, and JWKS endpoints it advertises) is checked for SSRF before the request is made:
1. The hostname must be publicly routable. Loopback, RFC 1918, link-local, cloud-metadata hosts such as `169.254.169.254`, and similar ranges are rejected.
2. Right before each fetch, the hostname is resolved and every resolved address is checked the same way, so a public-looking hostname whose DNS record points at an internal address is rejected too.
Public IdPs such as Okta, Entra ID, Google, or Auth0 therefore work without any `trustedOrigins` change. This also holds for multi-tenant products where tenants register their own issuers: you do not need to (and should not) widen `trustedOrigins` to make tenant IdPs work.
If your IdP runs on a private network or behind a corporate VPN, add its origin to [`trustedOrigins`](/docs/reference/security#trusted-origins). A trusted origin is allowed even when it is not publicly routable, and the DNS check is skipped for it:
```ts
trustedOrigins: ["https://your-org.okta.com"],
trustedOrigins: ["https://idp.corp.internal"],
```
If your use-case requires to support multiple arbitrary but known IDPs (e.g Okta), we recommend to:
1. Register a list of well known IDPs ahead of time
```ts
trustedOrigins: [
"https://your-org.okta.com",
"https://accounts.google.com",
"https://login.microsoftonline.com",
"https://auth0.com",
"https://idp.example.com"
],
```
2. Or dynamically compute the `trustedOrigins` by specifying a callback function:
```ts
trustedOrigins: async (request) => {
// request is undefined during initialization and auth.api calls
if (!request) {
return ["https://my-frontend.com"];
}
// SSO trusted origin list
if (request.url.endsWith("/sso/register")) {
const trustedOrigins = await fetchOriginList();
return trustedOrigins;
}
// Your normal origin list for everything else
return [];
}
```
See the [`trustedOrigins`](/docs/reference/security#trusted-origins) docs for more information.
Discovery fails with the `discovery_private_host` code when a host is neither publicly routable nor a trusted origin.
#### Redirecting OIDC endpoints
@@ -308,17 +280,17 @@ Better Auth validates that the IdP's metadata is correct and complete **before**
If the Identity Provider is misconfigured or unreachable, registration will fail with a structured error.
| Error Code | Meaning |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `issuer_mismatch` | The IdP's discovery document reports a different `issuer` than the one you configured |
| `discovery_incomplete` | Required fields (`authorization_endpoint`, `token_endpoint`, `jwks_uri`) are missing |
| `discovery_not_found` | The discovery document endpoint returned 404 |
| `discovery_timeout` | The IdP did not respond within the timeout window (default: 10 seconds) |
| `discovery_invalid_url` | The discovery URL is malformed or uses an unsupported protocol |
| `discovery_untrusted_origin` | The discovery URL or one of the URLs discovered as part of this process was not trusted by your app's trusted origins configuration |
| `discovery_invalid_json` | The discovery response is empty or not valid JSON |
| `oidc_endpoint_redirect` | The discovery, token, userinfo, or JWKS endpoint returned a redirect; configure the final endpoint URL instead |
| `unsupported_token_auth_method` | The IdP only supports token auth methods that Better Auth doesn't support |
| Error Code | Meaning |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `issuer_mismatch` | The IdP's discovery document reports a different `issuer` than the one you configured |
| `discovery_incomplete` | Required fields (`authorization_endpoint`, `token_endpoint`, `jwks_uri`) are missing |
| `discovery_not_found` | The discovery document endpoint returned 404 |
| `discovery_timeout` | The IdP did not respond within the timeout window (default: 10 seconds) |
| `discovery_invalid_url` | The discovery URL is malformed or uses an unsupported protocol |
| `discovery_private_host` | The discovery URL, or one of the URLs discovered as part of this process, points at (or resolves to) a host that is not publicly routable and is not in your app's `trustedOrigins` |
| `discovery_invalid_json` | The discovery response is empty or not valid JSON |
| `oidc_endpoint_redirect` | The discovery, token, userinfo, or JWKS endpoint returned a redirect; configure the final endpoint URL instead |
| `unsupported_token_auth_method` | The IdP only supports token auth methods that Better Auth doesn't support |
**Supported token auth methods:**
+149 -147
View File
@@ -154,7 +154,7 @@ describe("OIDC Discovery", () => {
);
});
it("should throw DiscoveryError with discovery_untrusted_origin code for untrusted origins", () => {
it("should allow a publicly routable discovery URL that is not a trusted origin", () => {
isTrustedOrigin.mockReturnValue(false);
expect(() =>
@@ -162,13 +162,38 @@ describe("OIDC Discovery", () => {
"https://untrusted.com/.well-known/openid-configuration",
isTrustedOrigin,
),
).not.toThrow();
});
it("should throw DiscoveryError with discovery_private_host code for a non-public host that is not a trusted origin", () => {
isTrustedOrigin.mockReturnValue(false);
expect(() =>
validateDiscoveryUrl(
"https://10.0.0.5/.well-known/openid-configuration",
isTrustedOrigin,
),
).toThrow(
expect.objectContaining({
code: "discovery_untrusted_origin",
message: `The main discovery endpoint "https://untrusted.com/.well-known/openid-configuration" is not trusted by your trusted origins configuration.`,
code: "discovery_private_host",
details: expect.objectContaining({
endpoint: "discoveryEndpoint",
hostname: "10.0.0.5",
}),
}),
);
});
it("should allow a non-public host when it is a trusted origin (internal IdP escape hatch)", () => {
isTrustedOrigin.mockReturnValue(true);
expect(() =>
validateDiscoveryUrl(
"https://10.0.0.5/.well-known/openid-configuration",
isTrustedOrigin,
),
).not.toThrow();
});
});
describe("validateDiscoveryDocument", () => {
@@ -445,142 +470,67 @@ describe("OIDC Discovery", () => {
).toThrowError('The url "authorization_endpoint" must be valid');
});
it("should reject with discovery_untrusted_origin code on untrusted discovery urls", () => {
it("should allow discovered urls on public hosts that are not trusted origins", () => {
const doc = createMockDiscoveryDocument({
authorization_endpoint: "/oauth2/authorize",
token_endpoint: "/oauth2/token",
jwks_uri: "/.well-known/jwks.json",
userinfo_endpoint: "/userinfo",
revocation_endpoint: "/revoke",
end_session_endpoint: "/endsession",
introspection_endpoint: "/introspection",
});
const result = normalizeDiscoveryUrls(
doc,
"https://idp.example.com",
() => false,
);
expect(result.token_endpoint).toBe(
"https://idp.example.com/oauth2/token",
);
expect(result.jwks_uri).toBe(
"https://idp.example.com/.well-known/jwks.json",
);
});
it.each([
["token_endpoint", "/oauth2/token"],
["authorization_endpoint", "/oauth2/authorize"],
["jwks_uri", "/.well-known/jwks.json"],
["userinfo_endpoint", "/userinfo"],
["revocation_endpoint", "/revoke"],
["end_session_endpoint", "/endsession"],
["introspection_endpoint", "/introspection"],
] as const)("should reject with discovery_private_host code when %s points at a non-public host that is not a trusted origin", (field, path) => {
const doc = createMockDiscoveryDocument({
[field]: `https://10.0.0.5${path}`,
});
expect(() =>
normalizeDiscoveryUrls(
doc,
"https://idp.example.com",
(url) => !url.endsWith("/oauth2/token"),
),
normalizeDiscoveryUrls(doc, "https://idp.example.com", () => false),
).toThrowError(
expect.objectContaining({
code: "discovery_untrusted_origin",
message:
'The token_endpoint "https://idp.example.com/oauth2/token" is not trusted by your trusted origins configuration.',
details: {
endpoint: "token_endpoint",
url: "https://idp.example.com/oauth2/token",
},
code: "discovery_private_host",
details: expect.objectContaining({
endpoint: field,
url: `https://10.0.0.5${path}`,
hostname: "10.0.0.5",
}),
}),
);
});
it("should allow discovered urls on a non-public host when it is a trusted origin", () => {
const doc = createMockDiscoveryDocument({
issuer: "https://10.0.0.5",
authorization_endpoint: "/oauth2/authorize",
token_endpoint: "/oauth2/token",
jwks_uri: "/.well-known/jwks.json",
userinfo_endpoint: "/userinfo",
});
expect(() =>
normalizeDiscoveryUrls(
doc,
"https://idp.example.com",
(url) => !url.endsWith("/oauth2/authorize"),
),
).toThrowError(
expect.objectContaining({
code: "discovery_untrusted_origin",
message:
'The authorization_endpoint "https://idp.example.com/oauth2/authorize" is not trusted by your trusted origins configuration.',
details: {
endpoint: "authorization_endpoint",
url: "https://idp.example.com/oauth2/authorize",
},
}),
);
expect(() =>
normalizeDiscoveryUrls(
doc,
"https://idp.example.com",
(url) => !url.endsWith("/.well-known/jwks.json"),
),
).toThrowError(
expect.objectContaining({
code: "discovery_untrusted_origin",
message:
'The jwks_uri "https://idp.example.com/.well-known/jwks.json" is not trusted by your trusted origins configuration.',
details: {
endpoint: "jwks_uri",
url: "https://idp.example.com/.well-known/jwks.json",
},
}),
);
expect(() =>
normalizeDiscoveryUrls(
doc,
"https://idp.example.com",
(url) => !url.endsWith("/userinfo"),
),
).toThrowError(
expect.objectContaining({
code: "discovery_untrusted_origin",
message:
'The userinfo_endpoint "https://idp.example.com/userinfo" is not trusted by your trusted origins configuration.',
details: {
endpoint: "userinfo_endpoint",
url: "https://idp.example.com/userinfo",
},
}),
);
expect(() =>
normalizeDiscoveryUrls(
doc,
"https://idp.example.com",
(url) => !url.endsWith("/revoke"),
),
).toThrowError(
expect.objectContaining({
code: "discovery_untrusted_origin",
message:
'The revocation_endpoint "https://idp.example.com/revoke" is not trusted by your trusted origins configuration.',
details: {
endpoint: "revocation_endpoint",
url: "https://idp.example.com/revoke",
},
}),
);
expect(() =>
normalizeDiscoveryUrls(
doc,
"https://idp.example.com",
(url) => !url.endsWith("/endsession"),
),
).toThrowError(
expect.objectContaining({
code: "discovery_untrusted_origin",
message:
'The end_session_endpoint "https://idp.example.com/endsession" is not trusted by your trusted origins configuration.',
details: {
endpoint: "end_session_endpoint",
url: "https://idp.example.com/endsession",
},
}),
);
expect(() =>
normalizeDiscoveryUrls(
doc,
"https://idp.example.com",
(url) => !url.endsWith("/introspection"),
),
).toThrowError(
expect.objectContaining({
code: "discovery_untrusted_origin",
message:
'The introspection_endpoint "https://idp.example.com/introspection" is not trusted by your trusted origins configuration.',
details: {
endpoint: "introspection_endpoint",
url: "https://idp.example.com/introspection",
},
}),
);
normalizeDiscoveryUrls(doc, "https://10.0.0.5", () => true),
).not.toThrow();
});
});
@@ -1163,33 +1113,31 @@ describe("OIDC Discovery", () => {
expect(result.tokenEndpointAuthentication).toBe("client_secret_basic");
});
it("should throw an error with discovery_untrusted_origin code when the main discovery url is untrusted", async () => {
it("should throw an error with discovery_private_host code when the main discovery url is on a non-public host that is not a trusted origin", async () => {
isTrustedOrigin.mockReturnValue(false);
await expect(
discoverOIDCConfig({ issuer, isTrustedOrigin }),
discoverOIDCConfig({ issuer: "https://10.0.0.5", isTrustedOrigin }),
).rejects.toThrow(
expect.objectContaining({
name: "DiscoveryError",
message:
'The main discovery endpoint "https://idp.example.com/.well-known/openid-configuration" is not trusted by your trusted origins configuration.',
code: "discovery_untrusted_origin",
details: {
url: "https://idp.example.com/.well-known/openid-configuration",
},
code: "discovery_private_host",
details: expect.objectContaining({
endpoint: "discoveryEndpoint",
url: "https://10.0.0.5/.well-known/openid-configuration",
}),
}),
);
expect(mockBetterFetch).not.toHaveBeenCalled();
});
it("should throw an error with discovery_untrusted_origin code when discovered urls are untrusted", async () => {
isTrustedOrigin.mockImplementation((url: string) => {
return url.endsWith(".well-known/openid-configuration");
});
it("should throw an error with discovery_private_host code when a discovered url is on a non-public host that is not a trusted origin", async () => {
isTrustedOrigin.mockReturnValue(false);
const discoveryDoc = createMockDiscoveryDocument({
issuer,
authorization_endpoint: `${issuer}/oauth2/authorize`,
token_endpoint: `${issuer}/oauth2/token`,
token_endpoint: "https://10.0.0.5/oauth2/token",
jwks_uri: `${issuer}/.well-known/jwks.json`,
userinfo_endpoint: `${issuer}/userinfo`,
});
@@ -1203,16 +1151,60 @@ describe("OIDC Discovery", () => {
).rejects.toThrow(
expect.objectContaining({
name: "DiscoveryError",
message:
'The token_endpoint "https://idp.example.com/oauth2/token" is not trusted by your trusted origins configuration.',
code: "discovery_untrusted_origin",
details: {
code: "discovery_private_host",
details: expect.objectContaining({
endpoint: "token_endpoint",
url: "https://idp.example.com/oauth2/token",
},
url: "https://10.0.0.5/oauth2/token",
}),
}),
);
});
/**
* @see https://github.com/better-auth/better-auth/issues/10830
* @see https://github.com/better-auth/better-auth/issues/10831
*/
describe("public issuers outside trustedOrigins", () => {
beforeEach(() => {
isTrustedOrigin.mockReturnValue(false);
});
it("should discover a public issuer without a trustedOrigins entry", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
mockBetterFetch.mockResolvedValueOnce({
data: createMockDiscoveryDocument({ issuer }),
error: null,
});
const result = await discoverOIDCConfig({ issuer, isTrustedOrigin });
expect(result.tokenEndpoint).toBe(`${issuer}/oauth2/token`);
expect(lookupMock).toHaveBeenCalledWith("idp.example.com", {
all: true,
});
expect(mockBetterFetch).toHaveBeenCalledTimes(1);
});
it("should resolve the discovery host before fetching and reject internal addresses", async () => {
lookupMock.mockResolvedValue([
{ address: "169.254.169.254", family: 4 },
]);
await expect(
discoverOIDCConfig({ issuer, isTrustedOrigin }),
).rejects.toThrow(
expect.objectContaining({
code: "discovery_private_host",
details: expect.objectContaining({
endpoint: "discoveryEndpoint",
hostname: "idp.example.com",
resolved: "169.254.169.254",
}),
}),
);
expect(mockBetterFetch).not.toHaveBeenCalled();
});
});
});
});
@@ -1288,12 +1280,22 @@ describe("ensureRuntimeDiscovery", () => {
).rejects.toThrow(DiscoveryError);
});
it("throws DiscoveryError for untrusted origin", async () => {
it("throws DiscoveryError for a non-public issuer that is not a trusted origin", async () => {
const privateIssuer = "https://10.0.0.5";
await expect(
ensureRuntimeDiscovery(baseConfig, issuer, () => false),
ensureRuntimeDiscovery(
{
...baseConfig,
issuer: privateIssuer,
discoveryEndpoint: `${privateIssuer}/.well-known/openid-configuration`,
},
privateIssuer,
() => false,
),
).rejects.toThrow(
expect.objectContaining({ code: "discovery_untrusted_origin" }),
expect.objectContaining({ code: "discovery_private_host" }),
);
expect(betterFetch).not.toHaveBeenCalled();
});
it("rejects a stored endpoint whose DNS resolves to an internal address", async () => {
+24 -24
View File
@@ -128,23 +128,26 @@ export function computeDiscoveryUrl(issuer: string): string {
/**
* Validate a discovery URL before fetching.
*
* Applies the same policy as every other user-supplied OIDC endpoint (see
* {@link validateOIDCEndpointUrl}): publicly routable hosts are allowed, and
* non-public hosts require the operator to allowlist the origin through
* `trustedOrigins`.
*
* Public hosts are deliberately *not* required to be trusted origins. A trusted
* origin skips {@link assertEndpointResolvesPublic}, so demanding trust for
* every public issuer would push multi-tenant deployments to allowlist the very
* URLs their tenants control, which disables the DNS SSRF guard for them.
*
* @param url - The discovery URL to validate
* @param isTrustedOrigin - Origin verification tester function
* @throws DiscoveryError if URL is invalid
* @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
* @throws DiscoveryError(discovery_invalid_url) — malformed URL or non-http(s) scheme
* @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
*/
export function validateDiscoveryUrl(
url: string,
isTrustedOrigin: DiscoverOIDCConfigParams["isTrustedOrigin"],
): void {
const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
if (!isTrustedOrigin(discoveryEndpoint)) {
throw new DiscoveryError(
"discovery_untrusted_origin",
`The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`,
{ url: discoveryEndpoint },
);
}
validateOIDCEndpointUrl("discoveryEndpoint", url, isTrustedOrigin);
}
/**
@@ -172,7 +175,7 @@ export function validateDiscoveryUrl(
* @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
*/
function validateOIDCEndpointUrl(
name: OIDCConfigEndpointName,
name: string,
endpoint: string,
isTrustedOrigin: (url: string) => boolean,
): void {
@@ -674,12 +677,17 @@ export function normalizeDiscoveryUrls(
}
/**
* Normalizes and validates a single URL endpoint
* Normalizes and validates a single URL endpoint.
*
* Applies the same public-or-allowlisted host policy as
* {@link validateOIDCEndpointUrl}, so that discovered endpoints on public hosts
* still reach {@link assertEndpointResolvesPublic} at fetch time.
*
* @param name The url name
* @param endpoint The url to validate
* @param issuer The issuer base url
* @param isTrustedOrigin - Origin verification tester function
* @returns
* @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
* @throws DiscoveryError(discovery_invalid_url | discovery_private_host)
*/
function normalizeAndValidateUrl(
name: string,
@@ -688,15 +696,7 @@ function normalizeAndValidateUrl(
isTrustedOrigin: DiscoverOIDCConfigParams["isTrustedOrigin"],
): string {
const url = normalizeUrl(name, endpoint, issuer);
if (!isTrustedOrigin(url)) {
throw new DiscoveryError(
"discovery_untrusted_origin",
`The ${name} "${url}" is not trusted by your trusted origins configuration.`,
{ endpoint: name, url },
);
}
validateOIDCEndpointUrl(name, url, isTrustedOrigin);
return url;
}
+7 -1
View File
@@ -103,7 +103,13 @@ export type DiscoveryErrorCode =
| "discovery_invalid_json"
/** OIDC endpoint URL (discovery or per-endpoint: authorization, token, userinfo, jwks) is invalid, malformed, or uses a non-`http(s)` scheme */
| "discovery_invalid_url"
/** OIDC endpoint URL is not trusted by the trusted origins configuration */
/**
* OIDC endpoint URL is not trusted by the trusted origins configuration.
*
* @deprecated No longer emitted. Public hosts are allowed without a
* `trustedOrigins` entry, and non-public hosts fail with
* `discovery_private_host`. Kept so existing error handling keeps compiling.
*/
| "discovery_untrusted_origin"
/** OIDC endpoint URL (discovery or per-endpoint) points to a host that is not publicly routable (loopback, RFC 1918, link-local, cloud metadata FQDN, etc.) */
| "discovery_private_host"