diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 630dd53..19017e9 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -14,7 +14,7 @@ export const configSchema = z.object({ adminEmail: z.string().email().default('admin@shnt.top'), adminPassword: z.string().default('admin'), adminName: z.string().default('Admin'), - returnToAllowlist: z.string().default('.shnt.top,localhost'), + returnToAllowlist: z.string().default('.shnt.top,localhost,private'), serverPort: z.coerce.number().int().positive().default(8080), staticDir: z.string().optional(), logLevel: z.string().default('info'), @@ -36,7 +36,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { adminEmail: env.ADMIN_EMAIL ?? 'admin@shnt.top', adminPassword: env.ADMIN_PASSWORD ?? 'admin', adminName: env.ADMIN_NAME ?? 'Admin', - returnToAllowlist: env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost', + returnToAllowlist: + env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost,private', serverPort: env.SERVER_PORT ?? 8080, staticDir: env.STATIC_DIR || undefined, logLevel: env.LOG_LEVEL ?? 'info', diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 72f0213..2993c57 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -19,6 +19,12 @@ import { requireAuth, toMe } from '../plugins/auth-guards.js' const REFRESH_COOKIE = 'refresh_token' export async function authRoutes(app: FastifyInstance): Promise { + /** Public — SPA reads allowlist at runtime (Docker-friendly). */ + app.get('/api/v1/auth/config', async () => ({ + return_to_allowlist: app.config.returnToAllowlist, + issuer: app.config.issuer, + })) + app.post('/api/v1/auth/login', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } }, handler: async (request, reply) => { diff --git a/apps/web/src/components/portal-login-form.tsx b/apps/web/src/components/portal-login-form.tsx index 21d3625..b7e77a3 100644 --- a/apps/web/src/components/portal-login-form.tsx +++ b/apps/web/src/components/portal-login-form.tsx @@ -17,16 +17,11 @@ import { AlertDescription, AlertTitle, } from '@/components/reui/alert' -import { setToken } from '@/lib/auth' +import { ensureReturnToAllowlist, setToken } from '@/lib/auth' import { ApiError } from '@/lib/api-client' import { login, meQueryKey } from '@/queries/auth' import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo' -/** Dev default matches auth-portal .env.example; prod should pass via Vite if needed. */ -const RETURN_TO_ALLOWLIST = - import.meta.env.VITE_RETURN_TO_ALLOWLIST ?? - '.shnt.top,localhost,http://localhost:5173' - export function PortalLoginForm() { const navigate = useNavigate() const queryClient = useQueryClient() @@ -48,10 +43,8 @@ export function PortalLoginForm() { setToken(res.access_token) queryClient.setQueryData(meQueryKey, res.user) - if ( - returnTo && - isReturnToAllowed(returnTo, RETURN_TO_ALLOWLIST) - ) { + const allowlist = await ensureReturnToAllowlist() + if (returnTo && isReturnToAllowed(returnTo, allowlist)) { window.location.href = buildSsoRedirectUrl( returnTo, res.access_token, diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 9c728c8..87c2c34 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,5 +1,41 @@ const TOKEN_KEY = 'authportal_token' +/** Fallback if /api/v1/auth/config unavailable (dev). Includes `private` for LAN SSO. */ +export const DEFAULT_RETURN_TO_ALLOWLIST = + '.shnt.top,localhost,private,http://localhost:5173' + +let returnToAllowlist: string | null = null +let returnToAllowlistPromise: Promise | null = null + +export async function ensureReturnToAllowlist(): Promise { + if (returnToAllowlist) return returnToAllowlist + if (returnToAllowlistPromise) return returnToAllowlistPromise + + returnToAllowlistPromise = (async () => { + const fromVite = import.meta.env.VITE_RETURN_TO_ALLOWLIST as + | string + | undefined + try { + const res = await fetch('/api/v1/auth/config') + if (res.ok) { + const data = (await res.json()) as { return_to_allowlist?: string } + if (data.return_to_allowlist) { + returnToAllowlist = data.return_to_allowlist + return returnToAllowlist + } + } + } catch { + /* ignore */ + } + returnToAllowlist = fromVite || DEFAULT_RETURN_TO_ALLOWLIST + return returnToAllowlist + })().finally(() => { + returnToAllowlistPromise = null + }) + + return returnToAllowlistPromise +} + export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY) } diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 926ba93..c4eea9b 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -5,7 +5,7 @@ import { isReturnToAllowed, type MeResponse, } from '@authportal/shared' -import { getToken } from '@/lib/auth' +import { ensureReturnToAllowlist, getToken } from '@/lib/auth' import { PortalLoginForm } from '@/components/portal-login-form' import { api } from '@/lib/api-client' @@ -13,10 +13,6 @@ const searchSchema = z.object({ return_to: z.string().url().optional(), }) -const RETURN_TO_ALLOWLIST = - import.meta.env.VITE_RETURN_TO_ALLOWLIST ?? - '.shnt.top,localhost,http://localhost:5173' - export const Route = createFileRoute('/')({ validateSearch: (search) => searchSchema.parse(search), beforeLoad: async ({ search }) => { @@ -24,9 +20,10 @@ export const Route = createFileRoute('/')({ if (!token) return try { const me = await api.get('/api/v1/auth/me') + const allowlist = await ensureReturnToAllowlist() if ( search.return_to && - isReturnToAllowed(search.return_to, RETURN_TO_ALLOWLIST) + isReturnToAllowed(search.return_to, allowlist) ) { const exp = new Date(Date.now() + 60 * 60 * 1000).toISOString() window.location.href = buildSsoRedirectUrl(