mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
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:
@@ -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.
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user