fix(server): auth UI callback trust (#1976)
This commit is contained in:
@@ -10,15 +10,24 @@ function getOriginFromUrl(url: string): string | undefined {
|
||||
}
|
||||
|
||||
const TRUSTED_EXACT_ORIGINS = [
|
||||
'https://airi.moeru.ai', // Production web app
|
||||
'capacitor://localhost', // Capacitor mobile (iOS)
|
||||
'ai.moeru.airi-pocket://links', // Android deep link
|
||||
'https://airi.moeru.ai', // Production
|
||||
'https://accounts.airi.build', // Standalone auth UI
|
||||
'https://server-dev.airi-server-auth.pages.dev', // Server-dev standalone auth UI
|
||||
'https://admin.airi.build', // Standalone admin UI
|
||||
'https://server-dev.airi-server-admin.pages.dev', // Server-dev standalone admin UI
|
||||
]
|
||||
|
||||
// NOTICE:
|
||||
// Better Auth accepts non-http(s) origins by prefix (`url.startsWith(pattern)`),
|
||||
// so native deep-link schemes must not be copied from TRUSTED_EXACT_ORIGINS
|
||||
// into auth callback validation. Browser auth callbacks only need web origins.
|
||||
const TRUSTED_AUTH_CALLBACK_ORIGINS = TRUSTED_EXACT_ORIGINS.filter((origin) => {
|
||||
const protocol = new URL(origin).protocol
|
||||
return protocol === 'http:' || protocol === 'https:'
|
||||
})
|
||||
|
||||
// NOTICE:
|
||||
// Private LAN / CGNAT-style dev hosts (e.g. https://10.x:5273 from cap-vite) are NOT matched
|
||||
// by regex here — list them explicitly via env `ADDITIONAL_TRUSTED_ORIGINS` (see env.ts).
|
||||
@@ -149,6 +158,10 @@ export function getAuthTrustedOrigins(
|
||||
origins.add(apiServerOrigin)
|
||||
}
|
||||
|
||||
for (const origin of TRUSTED_AUTH_CALLBACK_ORIGINS) {
|
||||
origins.add(origin)
|
||||
}
|
||||
|
||||
for (const origin of env.ADDITIONAL_TRUSTED_ORIGINS) {
|
||||
origins.add(origin)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@ describe('origin utils', () => {
|
||||
ADDITIONAL_TRUSTED_ORIGINS: [],
|
||||
}, request)).toEqual([
|
||||
'https://api.airi.moeru.ai',
|
||||
'https://airi.moeru.ai',
|
||||
'https://accounts.airi.build',
|
||||
'https://server-dev.airi-server-auth.pages.dev',
|
||||
'https://admin.airi.build',
|
||||
'https://server-dev.airi-server-admin.pages.dev',
|
||||
'http://localhost:*',
|
||||
'http://127.0.0.1:*',
|
||||
'http://localhost:5173',
|
||||
@@ -122,9 +127,47 @@ describe('origin utils', () => {
|
||||
ADDITIONAL_TRUSTED_ORIGINS: ['https://10.0.0.129:5273'],
|
||||
})).toEqual([
|
||||
'https://api.airi.moeru.ai',
|
||||
'https://airi.moeru.ai',
|
||||
'https://accounts.airi.build',
|
||||
'https://server-dev.airi-server-auth.pages.dev',
|
||||
'https://admin.airi.build',
|
||||
'https://server-dev.airi-server-admin.pages.dev',
|
||||
'https://10.0.0.129:5273',
|
||||
'http://localhost:*',
|
||||
'http://127.0.0.1:*',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not include native deep-link schemes in Better Auth trustedOrigins', () => {
|
||||
expect(getTrustedOrigin('capacitor://localhost')).toBe('capacitor://localhost')
|
||||
expect(getTrustedOrigin('ai.moeru.airi-pocket://links')).toBe('ai.moeru.airi-pocket://links')
|
||||
|
||||
const authOrigins = getAuthTrustedOrigins({
|
||||
API_SERVER_URL: 'https://api.airi.build',
|
||||
ADDITIONAL_TRUSTED_ORIGINS: [],
|
||||
})
|
||||
|
||||
expect(authOrigins).not.toContain('capacitor://localhost')
|
||||
expect(authOrigins).not.toContain('ai.moeru.airi-pocket://links')
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Email verification links carry a callbackURL query parameter that Better
|
||||
// Auth validates only against its trustedOrigins list. The standalone auth
|
||||
// UI sends callbackURL=https://accounts.airi.build/ui/verify-email?verified=true,
|
||||
// but getAuthTrustedOrigins previously listed only API_SERVER_URL,
|
||||
// additional env origins, and localhost wildcards. Clicking the email from
|
||||
// a normal inbox has no usable Origin/Referer header, so request-derived
|
||||
// trust could not add the auth UI origin and Better Auth returned
|
||||
// INVALID_CALLBACK_URL.
|
||||
//
|
||||
// Before patch: auth UI callback -> not in trustedOrigins -> 403.
|
||||
// After patch: built-in first-party exact origins are always present.
|
||||
it('includes built-in first-party origins for email verification callbacks without request headers', () => {
|
||||
expect(getAuthTrustedOrigins({
|
||||
API_SERVER_URL: 'https://api.airi.build',
|
||||
ADDITIONAL_TRUSTED_ORIGINS: [],
|
||||
})).toContain('https://accounts.airi.build')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user