diff --git a/.changeset/sso-discovery-public-hosts.md b/.changeset/sso-discovery-public-hosts.md new file mode 100644 index 0000000000..c5f4a88af0 --- /dev/null +++ b/.changeset/sso-discovery-public-hosts.md @@ -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. diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx index c462440f4d..fc8f98ce3d 100644 --- a/docs/content/docs/plugins/sso.mdx +++ b/docs/content/docs/plugins/sso.mdx @@ -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. -#### 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:** diff --git a/packages/sso/src/oidc/discovery.test.ts b/packages/sso/src/oidc/discovery.test.ts index 3ebc348b0c..b95775162d 100644 --- a/packages/sso/src/oidc/discovery.test.ts +++ b/packages/sso/src/oidc/discovery.test.ts @@ -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 () => { diff --git a/packages/sso/src/oidc/discovery.ts b/packages/sso/src/oidc/discovery.ts index ba9a292ebf..4f54b670a4 100644 --- a/packages/sso/src/oidc/discovery.ts +++ b/packages/sso/src/oidc/discovery.ts @@ -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; } diff --git a/packages/sso/src/oidc/types.ts b/packages/sso/src/oidc/types.ts index b4b1131097..029d7534b8 100644 --- a/packages/sso/src/oidc/types.ts +++ b/packages/sso/src/oidc/types.ts @@ -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"