Skip to content

Commit caa3ef4

Browse files
os-zhuangclaude
andauthored
fix(auth): trust public-routable external-IdP origins at SSO registration (ADR-0024 / cloud#551) (#2370)
@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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 21b3208 commit caa3ef4

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
@@ -832,6 +832,32 @@ export class AuthManager {
832832
origins.push('http://*.localhost:*');
833833
origins.push('https://*.localhost:*');
834834
}
835+
// ── ADR-0024: runtime self-service external-IdP registration ───────
836+
// `@better-auth/sso`'s `validateDiscoveryUrl` requires the IdP's
837+
// *discovery* origin to be in `trustedOrigins` — even for a publicly-
838+
// routable IdP (stricter than its own sub-endpoint check, which allows
839+
// any public host). Without help that breaks ADR-0024's "register your
840+
// IdP at runtime, no boot config" promise for every real IdP
841+
// (Okta/Entra/Google). When the SSO RP is enabled, expose
842+
// `trustedOrigins` as a per-request FUNCTION that, for a
843+
// `/sso/register` | `/sso/update-provider` POST, additionally trusts the
844+
// PUBLIC-ROUTABLE issuer / discovery origins declared in the request
845+
// body. Private / internal hosts are never auto-trusted — they still
846+
// require explicit `trustedOrigins` config (the documented SSRF escape
847+
// hatch), and better-auth's own DNS-resolution checks still apply.
848+
if (this.isSsoWired()) {
849+
return {
850+
trustedOrigins: async (request?: Request) => {
851+
const base = [...origins];
852+
try {
853+
for (const o of await this.ssoDiscoveryTrustedOrigins(request)) {
854+
if (!base.includes(o)) base.push(o);
855+
}
856+
} catch { /* never let trust resolution throw */ }
857+
return base;
858+
},
859+
};
860+
}
835861
return origins.length ? { trustedOrigins: origins } : {};
836862
})(),
837863

@@ -1891,6 +1917,50 @@ export class AuthManager {
18911917
}
18921918
}
18931919

1920+
/**
1921+
* Extra `trustedOrigins` entries derived from an external-SSO registration
1922+
* request. For a `POST /sso/register` | `/sso/update-provider`, parse the
1923+
* (cloned) body and return the PUBLIC-ROUTABLE origins of the declared
1924+
* `issuer` / `oidcConfig` endpoints so `@better-auth/sso`'s discovery
1925+
* validation accepts a customer IdP registered at runtime (ADR-0024) without
1926+
* the operator pre-listing it in boot config. Only public-routable hosts are
1927+
* returned — private / internal / loopback hosts are never auto-trusted
1928+
* (better-auth's `isPublicRoutableHost`, the same predicate its own
1929+
* sub-endpoint check uses). Best-effort: any parse error yields `[]`.
1930+
*/
1931+
private async ssoDiscoveryTrustedOrigins(request: unknown): Promise<string[]> {
1932+
try {
1933+
const req = request as { url?: string; method?: string; clone?: () => Request } | undefined;
1934+
if (!req || typeof req.clone !== 'function' || !req.url) return [];
1935+
if ((req.method ?? 'GET').toUpperCase() !== 'POST') return [];
1936+
const path = new URL(req.url).pathname;
1937+
if (!/\/sso\/(register|update-provider)$/.test(path)) return [];
1938+
const body = await req.clone().json().catch(() => null);
1939+
if (!body || typeof body !== 'object') return [];
1940+
const oidc = (body as any).oidcConfig ?? {};
1941+
const candidates = [
1942+
(body as any).issuer,
1943+
oidc.discoveryEndpoint,
1944+
oidc.authorizationEndpoint,
1945+
oidc.tokenEndpoint,
1946+
oidc.jwksEndpoint,
1947+
oidc.userInfoEndpoint,
1948+
].filter((v): v is string => typeof v === 'string' && v.length > 0);
1949+
if (!candidates.length) return [];
1950+
const { isPublicRoutableHost } = await import('@better-auth/core/utils/host');
1951+
const out: string[] = [];
1952+
for (const c of candidates) {
1953+
try {
1954+
const u = new URL(c);
1955+
if (isPublicRoutableHost(u.hostname) && !out.includes(u.origin)) out.push(u.origin);
1956+
} catch { /* skip malformed URL */ }
1957+
}
1958+
return out;
1959+
} catch {
1960+
return [];
1961+
}
1962+
}
1963+
18941964
/**
18951965
* Resolve the acting user (+ their active org) for a before-hook gate,
18961966
* hook-order-independent. Tries the standard cookie session first, then falls

0 commit comments

Comments
 (0)