Skip to content

Commit eae98b3

Browse files
os-zhuangclaude
andcommitted
fix(auth): trust public-routable external-IdP origins at SSO registration (ADR-0024 / cloud#551)
@better-auth/sso's discovery validation requires every IdP endpoint origin to be in trustedOrigins — even for a publicly-routable IdP — so registering any external IdP returned 400 discovery_untrusted_origin unless pre-listed in boot config, breaking ADR-0024's runtime self-service promise. When the SSO RP is enabled, expose trustedOrigins as a per-request function that, for POST /sso/register|/sso/update-provider, additionally trusts the PUBLIC-ROUTABLE issuer/oidcConfig endpoint origins from the request body (@better-auth/core isPublicRoutableHost). Private/internal/loopback hosts are never auto-trusted; better-auth's DNS-resolution checks still apply. Verified E2E: a same-origin public IdP (GitLab.com) now registers at runtime with no boot config (was 400); the admin gate still fires before discovery. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cb5b393 commit eae98b3

2 files changed

Lines changed: 81 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
Auth: trust public-routable external-IdP origins at SSO registration (ADR-0024 / cloud#551)
6+
7+
`@better-auth/sso`'s discovery validation requires every IdP endpoint origin to be in `trustedOrigins` — even for a publicly-routable IdP. That broke ADR-0024's "register your OIDC IdP at runtime, no boot config" promise: registering any external IdP returned `400 discovery_untrusted_origin` unless the operator had pre-listed it.
8+
9+
When the external-SSO RP is enabled, `trustedOrigins` is now exposed as a per-request function that, for a `POST /sso/register` | `/sso/update-provider`, additionally trusts the **public-routable** issuer / `oidcConfig` endpoint origins declared in the request body (via `@better-auth/core`'s own `isPublicRoutableHost`). Private / internal / loopback hosts are never auto-trusted — they still require explicit `trustedOrigins` config (the documented SSRF escape hatch), and better-auth's own DNS-resolution checks still apply.
10+
11+
Verified: a same-origin public IdP (GitLab.com — issuer and all discovered endpoints on one origin, like Okta / Entra / Auth0 / Keycloak) now registers at runtime with no boot config (was a hard 400). The admin gate still fires first (a non-admin is rejected before discovery runs). Note: IdPs that split endpoints across multiple domains (e.g. Google's `accounts.google.com` + `oauth2.googleapis.com`) still need those extra origins in `trustedOrigins`.

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,32 @@ export class AuthManager {
802802
origins.push('http://*.localhost:*');
803803
origins.push('https://*.localhost:*');
804804
}
805+
// ── ADR-0024: runtime self-service external-IdP registration ───────
806+
// `@better-auth/sso`'s `validateDiscoveryUrl` requires the IdP's
807+
// *discovery* origin to be in `trustedOrigins` — even for a publicly-
808+
// routable IdP (stricter than its own sub-endpoint check, which allows
809+
// any public host). Without help that breaks ADR-0024's "register your
810+
// IdP at runtime, no boot config" promise for every real IdP
811+
// (Okta/Entra/Google). When the SSO RP is enabled, expose
812+
// `trustedOrigins` as a per-request FUNCTION that, for a
813+
// `/sso/register` | `/sso/update-provider` POST, additionally trusts the
814+
// PUBLIC-ROUTABLE issuer / discovery origins declared in the request
815+
// body. Private / internal hosts are never auto-trusted — they still
816+
// require explicit `trustedOrigins` config (the documented SSRF escape
817+
// hatch), and better-auth's own DNS-resolution checks still apply.
818+
if (this.isSsoWired()) {
819+
return {
820+
trustedOrigins: async (request?: Request) => {
821+
const base = [...origins];
822+
try {
823+
for (const o of await this.ssoDiscoveryTrustedOrigins(request)) {
824+
if (!base.includes(o)) base.push(o);
825+
}
826+
} catch { /* never let trust resolution throw */ }
827+
return base;
828+
},
829+
};
830+
}
805831
return origins.length ? { trustedOrigins: origins } : {};
806832
})(),
807833

@@ -1861,6 +1887,50 @@ export class AuthManager {
18611887
}
18621888
}
18631889

1890+
/**
1891+
* Extra `trustedOrigins` entries derived from an external-SSO registration
1892+
* request. For a `POST /sso/register` | `/sso/update-provider`, parse the
1893+
* (cloned) body and return the PUBLIC-ROUTABLE origins of the declared
1894+
* `issuer` / `oidcConfig` endpoints so `@better-auth/sso`'s discovery
1895+
* validation accepts a customer IdP registered at runtime (ADR-0024) without
1896+
* the operator pre-listing it in boot config. Only public-routable hosts are
1897+
* returned — private / internal / loopback hosts are never auto-trusted
1898+
* (better-auth's `isPublicRoutableHost`, the same predicate its own
1899+
* sub-endpoint check uses). Best-effort: any parse error yields `[]`.
1900+
*/
1901+
private async ssoDiscoveryTrustedOrigins(request: unknown): Promise<string[]> {
1902+
try {
1903+
const req = request as { url?: string; method?: string; clone?: () => Request } | undefined;
1904+
if (!req || typeof req.clone !== 'function' || !req.url) return [];
1905+
if ((req.method ?? 'GET').toUpperCase() !== 'POST') return [];
1906+
const path = new URL(req.url).pathname;
1907+
if (!/\/sso\/(register|update-provider)$/.test(path)) return [];
1908+
const body = await req.clone().json().catch(() => null);
1909+
if (!body || typeof body !== 'object') return [];
1910+
const oidc = (body as any).oidcConfig ?? {};
1911+
const candidates = [
1912+
(body as any).issuer,
1913+
oidc.discoveryEndpoint,
1914+
oidc.authorizationEndpoint,
1915+
oidc.tokenEndpoint,
1916+
oidc.jwksEndpoint,
1917+
oidc.userInfoEndpoint,
1918+
].filter((v): v is string => typeof v === 'string' && v.length > 0);
1919+
if (!candidates.length) return [];
1920+
const { isPublicRoutableHost } = await import('@better-auth/core/utils/host');
1921+
const out: string[] = [];
1922+
for (const c of candidates) {
1923+
try {
1924+
const u = new URL(c);
1925+
if (isPublicRoutableHost(u.hostname) && !out.includes(u.origin)) out.push(u.origin);
1926+
} catch { /* skip malformed URL */ }
1927+
}
1928+
return out;
1929+
} catch {
1930+
return [];
1931+
}
1932+
}
1933+
18641934
/**
18651935
* Resolve the acting user (+ their active org) for a before-hook gate,
18661936
* hook-order-independent. Tries the standard cookie session first, then falls

0 commit comments

Comments
 (0)