diff --git a/apps/ui-admin/src/modules/server-admin-context.test.ts b/apps/ui-admin/src/modules/server-admin-context.test.ts index 5d7fe8c06..8aa896cbf 100644 --- a/apps/ui-admin/src/modules/server-admin-context.test.ts +++ b/apps/ui-admin/src/modules/server-admin-context.test.ts @@ -32,7 +32,12 @@ describe('ui-admin bootstrap context', () => { }) it('keeps non-local standalone origins unchanged without redirect context', () => { - expect(defaultStandaloneApiServerUrl('https://admin.airi.build')).toBe('https://admin.airi.build') + expect(defaultStandaloneApiServerUrl('https://admin-preview.example')).toBe('https://admin-preview.example') + }) + + it('defaults known standalone admin deployments to their API origins', () => { + expect(defaultStandaloneApiServerUrl('https://admin.airi.build')).toBe('https://api.airi.build') + expect(defaultStandaloneApiServerUrl('https://server-dev.airi-server-admin.pages.dev')).toBe('https://airi-server-dev.up.railway.app') }) it('falls back to the standalone query context when the static placeholder script is still present', () => { diff --git a/apps/ui-admin/src/modules/server-admin-context.ts b/apps/ui-admin/src/modules/server-admin-context.ts index 294bd711b..fdc19781b 100644 --- a/apps/ui-admin/src/modules/server-admin-context.ts +++ b/apps/ui-admin/src/modules/server-admin-context.ts @@ -11,6 +11,11 @@ const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [ 'https://airi-server-dev.up.railway.app', ] +const DEFAULT_API_SERVER_ORIGINS_BY_ADMIN_UI_ORIGIN = new Map([ + ['https://admin.airi.build', 'https://api.airi.build'], + ['https://server-dev.airi-server-admin.pages.dev', 'https://airi-server-dev.up.railway.app'], +]) + const TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS = [ /^http:\/\/localhost(:\d+)?$/, /^http:\/\/127\.0\.0\.1(:\d+)?$/, @@ -59,6 +64,9 @@ export function defaultApiServerUrl(): string { */ export function defaultStandaloneApiServerUrl(currentOrigin: string): string { const origin = new URL(currentOrigin).origin + const knownApiServerOrigin = DEFAULT_API_SERVER_ORIGINS_BY_ADMIN_UI_ORIGIN.get(origin) + if (knownApiServerOrigin) + return knownApiServerOrigin if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin))) { const url = new URL(origin) diff --git a/apps/ui-server-auth/src/modules/sign-in.test.ts b/apps/ui-server-auth/src/modules/sign-in.test.ts index 91d2b9e58..4aceb9b84 100644 --- a/apps/ui-server-auth/src/modules/sign-in.test.ts +++ b/apps/ui-server-auth/src/modules/sign-in.test.ts @@ -76,6 +76,34 @@ describe('ui-server-auth sign-in flow helpers', () => { }) }) + it('keeps trusted standalone admin redirects on the admin origin', () => { + expect(createServerSignInContext( + 'https://accounts.airi.build/ui/sign-in?redirect=https%3A%2F%2Fadmin.airi.build%2Fllm-router%3Fapi_server_url%3Dhttps%253A%252F%252Fairi-server-dev.up.railway.app', + 'https://api.airi.test', + )).toEqual({ + callbackURL: 'https://admin.airi.build/llm-router?api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app', + requestedProvider: null, + }) + + expect(createServerSignInContext( + 'https://accounts.airi.build/ui/sign-in?redirect=http%3A%2F%2F127.0.0.1%3A5178%2Fllm-router%3Fapi_server_url%3Dhttp%253A%252F%252F127.0.0.1%253A3000', + 'https://api.airi.test', + )).toEqual({ + callbackURL: 'http://127.0.0.1:5178/llm-router?api_server_url=http%3A%2F%2F127.0.0.1%3A3000', + requestedProvider: null, + }) + }) + + it('rejects absolute redirects to untrusted origins', () => { + expect(createServerSignInContext( + 'https://accounts.airi.build/ui/sign-in?redirect=https%3A%2F%2Fevil.example%2Fllm-router', + 'https://api.airi.test', + )).toEqual({ + callbackURL: '/', + requestedProvider: null, + }) + }) + it('posts the selected provider and callback URL to the social sign-in endpoint', async () => { const fetchImpl = vi.fn(async () => { return new Response(JSON.stringify({ url: 'https://accounts.example.test/oauth/google' }), { diff --git a/apps/ui-server-auth/src/modules/sign-in.ts b/apps/ui-server-auth/src/modules/sign-in.ts index 5771f70b2..b8810ca4b 100644 --- a/apps/ui-server-auth/src/modules/sign-in.ts +++ b/apps/ui-server-auth/src/modules/sign-in.ts @@ -3,6 +3,18 @@ import type { OAuthProvider } from '@proj-airi/stage-ui/libs/auth' import { extractAuthError } from './auth-fetch' import { buildAuthUiPath } from './auth-ui-base' +const TRUSTED_ADMIN_REDIRECT_ORIGINS = [ + 'https://admin.airi.build', + 'https://server-dev.airi-server-admin.pages.dev', +] + +const TRUSTED_LOCAL_ADMIN_REDIRECT_ORIGIN_PATTERNS = [ + /^http:\/\/localhost(:\d+)?$/, + /^http:\/\/127\.0\.0\.1(:\d+)?$/, + /^https:\/\/localhost(:\d+)?$/, + /^https:\/\/127\.0\.0\.1(:\d+)?$/, +] + export interface ServerSignInContext { callbackURL: string requestedProvider: string | null @@ -55,7 +67,14 @@ export function createServerSignInContext(currentUrl: string, apiServerUrl: stri } function normalizeStandaloneRedirect(currentUrl: URL, redirect: string | null): string | null { - if (!redirect || !redirect.startsWith('/') || redirect.startsWith('//')) + if (!redirect) + return null + + const trustedAdminRedirect = normalizeTrustedAdminRedirect(redirect) + if (trustedAdminRedirect) + return trustedAdminRedirect + + if (!redirect.startsWith('/') || redirect.startsWith('//')) return null if (redirect.startsWith('/admin')) @@ -64,6 +83,22 @@ function normalizeStandaloneRedirect(currentUrl: URL, redirect: string | null): return `${currentUrl.origin}${buildAuthUiPath(redirect)}` } +function normalizeTrustedAdminRedirect(redirect: string): string | null { + try { + const url = new URL(redirect) + if (TRUSTED_ADMIN_REDIRECT_ORIGINS.includes(url.origin)) + return url.toString() + + if (TRUSTED_LOCAL_ADMIN_REDIRECT_ORIGIN_PATTERNS.some(pattern => pattern.test(url.origin))) + return url.toString() + + return null + } + catch { + return null + } +} + export async function requestSocialSignInRedirect(params: SocialSignInRedirectParams): Promise { const fetchImpl = params.fetchImpl ?? fetch const endpoint = new URL('/api/auth/sign-in/social', params.apiServerUrl)