From eae98b3daffd65efca9622fe37e8ba51aa70d744 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:36:06 +0800 Subject: [PATCH] fix(auth): trust public-routable external-IdP origins at SSO registration (ADR-0024 / cloud#551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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 --- .changeset/adr-0024-sso-public-idp-trust.md | 11 +++ .../plugins/plugin-auth/src/auth-manager.ts | 70 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 .changeset/adr-0024-sso-public-idp-trust.md diff --git a/.changeset/adr-0024-sso-public-idp-trust.md b/.changeset/adr-0024-sso-public-idp-trust.md new file mode 100644 index 0000000000..576642690e --- /dev/null +++ b/.changeset/adr-0024-sso-public-idp-trust.md @@ -0,0 +1,11 @@ +--- +'@objectstack/plugin-auth': patch +--- + +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. 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. + +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. + +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`. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 66b7700c2c..c76be587de 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -802,6 +802,32 @@ export class AuthManager { origins.push('http://*.localhost:*'); origins.push('https://*.localhost:*'); } + // ── ADR-0024: runtime self-service external-IdP registration ─────── + // `@better-auth/sso`'s `validateDiscoveryUrl` requires the IdP's + // *discovery* origin to be in `trustedOrigins` — even for a publicly- + // routable IdP (stricter than its own sub-endpoint check, which allows + // any public host). Without help that breaks ADR-0024's "register your + // IdP at runtime, no boot config" promise for every real IdP + // (Okta/Entra/Google). When the SSO RP is enabled, expose + // `trustedOrigins` as a per-request FUNCTION that, for a + // `/sso/register` | `/sso/update-provider` POST, additionally trusts the + // PUBLIC-ROUTABLE issuer / discovery origins declared in the request + // body. Private / internal 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. + if (this.isSsoWired()) { + return { + trustedOrigins: async (request?: Request) => { + const base = [...origins]; + try { + for (const o of await this.ssoDiscoveryTrustedOrigins(request)) { + if (!base.includes(o)) base.push(o); + } + } catch { /* never let trust resolution throw */ } + return base; + }, + }; + } return origins.length ? { trustedOrigins: origins } : {}; })(), @@ -1861,6 +1887,50 @@ export class AuthManager { } } + /** + * Extra `trustedOrigins` entries derived from an external-SSO registration + * request. For a `POST /sso/register` | `/sso/update-provider`, parse the + * (cloned) body and return the PUBLIC-ROUTABLE origins of the declared + * `issuer` / `oidcConfig` endpoints so `@better-auth/sso`'s discovery + * validation accepts a customer IdP registered at runtime (ADR-0024) without + * the operator pre-listing it in boot config. Only public-routable hosts are + * returned — private / internal / loopback hosts are never auto-trusted + * (better-auth's `isPublicRoutableHost`, the same predicate its own + * sub-endpoint check uses). Best-effort: any parse error yields `[]`. + */ + private async ssoDiscoveryTrustedOrigins(request: unknown): Promise { + try { + const req = request as { url?: string; method?: string; clone?: () => Request } | undefined; + if (!req || typeof req.clone !== 'function' || !req.url) return []; + if ((req.method ?? 'GET').toUpperCase() !== 'POST') return []; + const path = new URL(req.url).pathname; + if (!/\/sso\/(register|update-provider)$/.test(path)) return []; + const body = await req.clone().json().catch(() => null); + if (!body || typeof body !== 'object') return []; + const oidc = (body as any).oidcConfig ?? {}; + const candidates = [ + (body as any).issuer, + oidc.discoveryEndpoint, + oidc.authorizationEndpoint, + oidc.tokenEndpoint, + oidc.jwksEndpoint, + oidc.userInfoEndpoint, + ].filter((v): v is string => typeof v === 'string' && v.length > 0); + if (!candidates.length) return []; + const { isPublicRoutableHost } = await import('@better-auth/core/utils/host'); + const out: string[] = []; + for (const c of candidates) { + try { + const u = new URL(c); + if (isPublicRoutableHost(u.hostname) && !out.includes(u.origin)) out.push(u.origin); + } catch { /* skip malformed URL */ } + } + return out; + } catch { + return []; + } + } + /** * Resolve the acting user (+ their active org) for a before-hook gate, * hook-order-independent. Tries the standard cookie session first, then falls