Skip to content

Commit 9c355ab

Browse files
committed
feat: implement OIDC endpoint origin prefetching for trusted origins resolution
1 parent 3c24417 commit 9c355ab

4 files changed

Lines changed: 163 additions & 20 deletions

File tree

server/api/sso/providers.post.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod'
22
import { eq, and, ne } from 'drizzle-orm'
33
import { ssoProvider } from '~~/server/database/schema'
4+
import { prefetchOidcEndpointOrigins } from '~~/server/utils/auth'
45

56
const registerSsoSchema = z.object({
67
providerId: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/, 'Only lowercase alphanumeric and hyphens'),
@@ -56,6 +57,11 @@ export default defineEventHandler(async (event) => {
5657
}
5758

5859
try {
60+
// Pre-discover OIDC endpoint origins so better-auth trusts them during
61+
// registration. IdPs like Google use separate domains for token/userinfo
62+
// endpoints (oauth2.googleapis.com) vs their issuer (accounts.google.com).
63+
await prefetchOidcEndpointOrigins(body.issuer)
64+
5965
const result = await (auth.api as any).registerSSOProvider({
6066
headers: event.headers,
6167
body: {

server/routes/ingest/[...path].ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Reverse-proxy: /ingest/** → eu.i.posthog.com/**
3+
*
4+
* Proxies PostHog API calls (event capture, decide, feature flags) through
5+
* our domain to bypass ad-blockers. Uses an explicit server route instead
6+
* of Nitro routeRules for better error handling and compression compatibility.
7+
*/
8+
export default defineEventHandler(async (event) => {
9+
const path = getRouterParam(event, 'path') || ''
10+
const query = getQuery(event)
11+
const qs = new URLSearchParams(query as Record<string, string>).toString()
12+
const target = `https://eu.i.posthog.com/${path}${qs ? `?${qs}` : ''}`
13+
14+
return proxyRequest(event, target, {
15+
headers: {
16+
'X-Forwarded-For': getRequestIP(event) || '',
17+
},
18+
})
19+
})

server/utils/auth.ts

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,60 @@ import * as schema from "../database/schema";
1010
type Auth = ReturnType<typeof betterAuth>;
1111
let _auth: Auth | undefined;
1212

13+
/**
14+
* Runtime cache of OIDC endpoint origins discovered from IdP discovery documents.
15+
*
16+
* Populated by `prefetchOidcEndpointOrigins()` before provider registration so
17+
* that better-auth's trusted-origins check passes for IdPs whose token/userinfo
18+
* endpoints live on a different domain than the issuer (e.g. Google).
19+
*/
20+
const discoveredIdpOrigins = new Set<string>();
21+
22+
/**
23+
* Fetch an OIDC discovery document and cache every endpoint origin so
24+
* better-auth trusts them during provider registration.
25+
*
26+
* Must be called **before** `auth.api.registerSSOProvider()` so the
27+
* origins are available when better-auth validates discovery endpoints.
28+
*/
29+
export async function prefetchOidcEndpointOrigins(issuerUrl: string): Promise<void> {
30+
const discoveryUrl = issuerUrl.replace(/\/+$/, "") + "/.well-known/openid-configuration";
31+
const res = await $fetch<Record<string, unknown>>(discoveryUrl, {
32+
timeout: 10_000,
33+
});
34+
35+
// Extract origins from all *_endpoint fields in the discovery document
36+
const endpointKeys = [
37+
"authorization_endpoint",
38+
"token_endpoint",
39+
"userinfo_endpoint",
40+
"revocation_endpoint",
41+
"introspection_endpoint",
42+
"end_session_endpoint",
43+
"jwks_uri",
44+
];
45+
for (const key of endpointKeys) {
46+
const value = res[key];
47+
if (typeof value === "string") {
48+
try {
49+
discoveredIdpOrigins.add(new URL(value).origin);
50+
} catch {
51+
// Skip malformed URLs
52+
}
53+
}
54+
}
55+
}
56+
1357
/**
1458
* Resolve trusted origins for CSRF checks and OIDC discovery.
1559
*
16-
* better-auth resolves trustedOrigins ONCE at init (without a request),
17-
* so origins must be available statically. We include:
60+
* Combines:
1861
* 1. App origins (base URL, configured origins, dev defaults)
19-
* 2. Well-known OIDC identity-provider origins (for SSO discovery)
62+
* 2. Origins auto-discovered from OIDC discovery documents
2063
* 3. Already-registered SSO provider issuers from the database
2164
*
22-
* For custom/self-hosted IdPs not in the well-known list, add their
23-
* origin to the BETTER_AUTH_TRUSTED_ORIGINS environment variable.
65+
* For IdPs that cannot be auto-discovered, add their origin to the
66+
* BETTER_AUTH_TRUSTED_ORIGINS environment variable.
2467
*/
2568
function resolveTrustedOrigins(baseUrl: string): (request?: Request) => Promise<string[]> {
2669
const configuredOrigins = env.BETTER_AUTH_TRUSTED_ORIGINS;
@@ -45,21 +88,8 @@ function resolveTrustedOrigins(baseUrl: string): (request?: Request) => Promise<
4588
new Set([baseOrigin.origin, ...configuredOrigins, ...defaultDevOrigins]),
4689
);
4790

48-
// Well-known OIDC identity-provider origins trusted for SSO discovery.
49-
// These are reputable IdPs whose origins are safe to allow; an attacker
50-
// cannot serve pages from these domains, so CSRF risk is negligible.
51-
const wellKnownIdpOrigins = [
52-
"https://accounts.google.com",
53-
"https://login.microsoftonline.com",
54-
"https://*.okta.com",
55-
"https://*.auth0.com",
56-
"https://*.onelogin.com",
57-
"https://*.duendesoftware.com",
58-
"https://*.pingidentity.com",
59-
];
60-
6191
return async () => {
62-
const allOrigins = [...staticOrigins, ...wellKnownIdpOrigins];
92+
const allOrigins = [...staticOrigins, ...discoveredIdpOrigins];
6393

6494
// Load already-registered SSO provider issuers from the database
6595
try {

tests/unit/sso-trusted-origins.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,97 @@ import { describe, it, expect } from 'vitest'
55
*
66
* The resolveTrustedOrigins function in auth.ts dynamically adds IdP origins
77
* for SSO callback flows. These tests verify the URL parsing and filtering
8-
* logic that would be applied to registered SSO provider issuers.
8+
* logic that would be applied to registered SSO provider issuers and
9+
* OIDC discovery documents.
910
*/
1011

12+
describe('SSO trusted origins — OIDC discovery endpoint extraction', () => {
13+
/**
14+
* Simulates the endpoint origin extraction from prefetchOidcEndpointOrigins.
15+
* This mirrors the logic in server/utils/auth.ts without network calls.
16+
*/
17+
function extractEndpointOrigins(discoveryDoc: Record<string, unknown>): string[] {
18+
const endpointKeys = [
19+
'authorization_endpoint',
20+
'token_endpoint',
21+
'userinfo_endpoint',
22+
'revocation_endpoint',
23+
'introspection_endpoint',
24+
'end_session_endpoint',
25+
'jwks_uri',
26+
]
27+
const origins = new Set<string>()
28+
for (const key of endpointKeys) {
29+
const value = discoveryDoc[key]
30+
if (typeof value === 'string') {
31+
try { origins.add(new URL(value).origin) } catch {}
32+
}
33+
}
34+
return [...origins]
35+
}
36+
37+
it('extracts origins from Google discovery doc (multi-domain)', () => {
38+
// Google uses different domains for issuer vs endpoints
39+
const googleDiscovery = {
40+
issuer: 'https://accounts.google.com',
41+
authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
42+
token_endpoint: 'https://oauth2.googleapis.com/token',
43+
userinfo_endpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
44+
jwks_uri: 'https://www.googleapis.com/oauth2/v3/certs',
45+
revocation_endpoint: 'https://oauth2.googleapis.com/revoke',
46+
}
47+
const origins = extractEndpointOrigins(googleDiscovery)
48+
expect(origins).toContain('https://accounts.google.com')
49+
expect(origins).toContain('https://oauth2.googleapis.com')
50+
expect(origins).toContain('https://openidconnect.googleapis.com')
51+
expect(origins).toContain('https://www.googleapis.com')
52+
})
53+
54+
it('extracts origins from Azure AD discovery doc', () => {
55+
const azureDiscovery = {
56+
authorization_endpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize',
57+
token_endpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token',
58+
userinfo_endpoint: 'https://graph.microsoft.com/oidc/userinfo',
59+
jwks_uri: 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys',
60+
end_session_endpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/logout',
61+
}
62+
const origins = extractEndpointOrigins(azureDiscovery)
63+
expect(origins).toContain('https://login.microsoftonline.com')
64+
expect(origins).toContain('https://graph.microsoft.com')
65+
})
66+
67+
it('deduplicates origins from same-domain endpoints', () => {
68+
const keycloakDiscovery = {
69+
authorization_endpoint: 'https://kc.corp.com/realms/prod/protocol/openid-connect/auth',
70+
token_endpoint: 'https://kc.corp.com/realms/prod/protocol/openid-connect/token',
71+
userinfo_endpoint: 'https://kc.corp.com/realms/prod/protocol/openid-connect/userinfo',
72+
jwks_uri: 'https://kc.corp.com/realms/prod/protocol/openid-connect/certs',
73+
}
74+
const origins = extractEndpointOrigins(keycloakDiscovery)
75+
expect(origins).toEqual(['https://kc.corp.com'])
76+
})
77+
78+
it('skips non-string and missing endpoint values', () => {
79+
const partialDoc = {
80+
authorization_endpoint: 'https://auth.example.com/authorize',
81+
token_endpoint: null,
82+
userinfo_endpoint: 12345,
83+
jwks_uri: undefined,
84+
}
85+
const origins = extractEndpointOrigins(partialDoc)
86+
expect(origins).toEqual(['https://auth.example.com'])
87+
})
88+
89+
it('skips malformed URLs gracefully', () => {
90+
const badDoc = {
91+
authorization_endpoint: 'not-a-url',
92+
token_endpoint: 'https://valid.example.com/token',
93+
}
94+
const origins = extractEndpointOrigins(badDoc)
95+
expect(origins).toEqual(['https://valid.example.com'])
96+
})
97+
})
98+
1199
describe('SSO trusted origins — issuer URL parsing', () => {
12100
/**
13101
* Simulates the issuer → origin extraction from resolveTrustedOrigins.

0 commit comments

Comments
 (0)