diff --git a/apps/server/.env b/apps/server/.env index 2895eb0a0..2eff879ef 100644 --- a/apps/server/.env +++ b/apps/server/.env @@ -15,6 +15,10 @@ STRIPE_WEBHOOK_SECRET="" CLIENT_URL="" API_SERVER_URL="" +# Comma-separated browser origins for CORS (/api/*) and Stripe return URLs. +# Required when the Capacitor dev server uses a LAN IP (see ios/App/App/capacitor.config.json), +# e.g. ADDITIONAL_TRUSTED_ORIGINS="https://10.0.0.129:5273,https://198.18.0.1:5273" + # OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" GATEWAY_BASE_URL="http://localhost:18080" diff --git a/apps/server/README.md b/apps/server/README.md index 72e0de163..7e1b8e1ca 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -25,3 +25,11 @@ For local observability infrastructure, use: ```sh docker compose -f apps/server/docker-compose.otel.yml up -d ``` + +## `ADDITIONAL_TRUSTED_ORIGINS` (LAN / Capacitor dev) + +When the mobile dev server uses a non-localhost origin (for example `https://10.x.x.x:5273` from `cap copy ios` / `capacitor.config.json`), set **`ADDITIONAL_TRUSTED_ORIGINS`** in `apps/server/.env.local` to a comma-separated list of exact origins (parsed and normalized at startup). Example: + +`ADDITIONAL_TRUSTED_ORIGINS=https://10.0.0.129:5273,https://198.18.0.1:5273` + +Restart the API server after changing this variable. diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index ac6c1da57..0c6ad1412 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -104,7 +104,7 @@ export async function buildApp(deps: AppDeps) { .use( '/api/*', cors({ - origin: origin => getTrustedOrigin(origin), + origin: origin => getTrustedOrigin(origin, deps.env.ADDITIONAL_TRUSTED_ORIGINS), credentials: true, }), ) diff --git a/apps/server/src/libs/auth.test.ts b/apps/server/src/libs/auth.test.ts index e6ca7a125..24d6f613b 100644 --- a/apps/server/src/libs/auth.test.ts +++ b/apps/server/src/libs/auth.test.ts @@ -139,6 +139,7 @@ describe('ensureDynamicFirstPartyRedirectUri', () => { await ensureDynamicFirstPartyRedirectUri( db as any, new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&redirect_uri=https%3A%2F%2Fpreview.kwaa.workers.dev%2Fauth%2Fcallback'), + [], ) expect(setCalls).toHaveLength(1) @@ -173,6 +174,7 @@ describe('ensureDynamicFirstPartyRedirectUri', () => { await ensureDynamicFirstPartyRedirectUri( db as any, new Request('https://airi-server-dev.up.railway.app/api/auth/oauth2/authorize?client_id=airi-stage-electron&redirect_uri=https%3A%2F%2Fairi-server-dev.up.railway.app%2Fapi%2Fauth%2Foidc%2Felectron-callback'), + [], ) expect(setCalls).toHaveLength(1) @@ -192,16 +194,19 @@ describe('ensureDynamicFirstPartyRedirectUri', () => { await ensureDynamicFirstPartyRedirectUri( db as any, new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&redirect_uri=https%3A%2F%2Fevil.example%2Fauth%2Fcallback'), + [], ) await ensureDynamicFirstPartyRedirectUri( db as any, new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-web&redirect_uri=https%3A%2F%2Fairi.moeru.ai%2Fother-path'), + [], ) await ensureDynamicFirstPartyRedirectUri( db as any, new Request('https://api.airi.build/api/auth/oauth2/authorize?client_id=airi-stage-electron&redirect_uri=https%3A%2F%2Fother.example%2Fapi%2Fauth%2Foidc%2Felectron-callback'), + [], ) expect(db.select).not.toHaveBeenCalled() diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 85ef5a986..ab7d26ef4 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -89,13 +89,13 @@ function buildWebRedirectUris(env: Env): string[] { return [...uris] } -function buildTrustedWebRedirectUri(redirectUri: string): string | null { +function buildTrustedWebRedirectUri(redirectUri: string, additionalTrustedOrigins: readonly string[]): string | null { try { const parsed = new URL(redirectUri) if (parsed.pathname !== '/auth/callback') return null - const trustedOrigin = getTrustedOrigin(parsed.origin) + const trustedOrigin = getTrustedOrigin(parsed.origin, additionalTrustedOrigins) if (!trustedOrigin) return null @@ -202,6 +202,7 @@ export function getTrustedOIDCClientIds(): string[] { export async function ensureDynamicFirstPartyRedirectUri( db: Database, request: Request, + additionalTrustedOrigins: readonly string[], ): Promise { const url = new URL(request.url) const clientId = url.searchParams.get('client_id') @@ -214,7 +215,7 @@ export async function ensureDynamicFirstPartyRedirectUri( switch (clientId) { case OIDC_CLIENT_ID_WEB: - normalizedRedirectUri = buildTrustedWebRedirectUri(redirectUri) + normalizedRedirectUri = buildTrustedWebRedirectUri(redirectUri, additionalTrustedOrigins) break case OIDC_CLIENT_ID_ELECTRON: normalizedRedirectUri = buildTrustedElectronRedirectUri(request, redirectUri) diff --git a/apps/server/src/libs/env.test.ts b/apps/server/src/libs/env.test.ts index c73406b31..5a16b2d39 100644 --- a/apps/server/src/libs/env.test.ts +++ b/apps/server/src/libs/env.test.ts @@ -1,23 +1,57 @@ import { describe, expect, it } from 'vitest' -import { parseEnv } from './env' +import { parseAdditionalTrustedOriginsEnv, parseEnv } from './env' + +function baseEnv(): Record { + return { + DATABASE_URL: 'postgres://example', + REDIS_URL: 'redis://example', + BETTER_AUTH_SECRET: 'test-secret-at-least-32-characters-long', + AUTH_GOOGLE_CLIENT_ID: 'google-client', + AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', + AUTH_GITHUB_CLIENT_ID: 'github-client', + AUTH_GITHUB_CLIENT_SECRET: 'github-secret', + GATEWAY_BASE_URL: 'http://localhost:18080', + DEFAULT_CHAT_MODEL: 'openai/gpt-5-mini', + DEFAULT_TTS_MODEL: 'microsoft/v1', + } +} + +describe('parseAdditionalTrustedOriginsEnv', () => { + it('normalizes comma-separated origins and dedupes', () => { + expect(parseAdditionalTrustedOriginsEnv('')).toEqual([]) + expect(parseAdditionalTrustedOriginsEnv(' https://10.0.0.129:5273/ , https://198.18.0.1:5273 ')).toEqual([ + 'https://10.0.0.129:5273', + 'https://198.18.0.1:5273', + ]) + expect(parseAdditionalTrustedOriginsEnv('https://x.test:5273/,https://x.test:5273')).toEqual([ + 'https://x.test:5273', + ]) + }) + + it('throws on invalid segments', () => { + expect(() => parseAdditionalTrustedOriginsEnv('not-a-url')).toThrow(/invalid URL origin segment/) + }) +}) describe('parseEnv', () => { it('parses the required auth and infrastructure environment variables', () => { - const env = parseEnv({ - DATABASE_URL: 'postgres://example', - REDIS_URL: 'redis://example', - BETTER_AUTH_SECRET: 'test-secret-at-least-32-characters-long', - AUTH_GOOGLE_CLIENT_ID: 'google-client', - AUTH_GOOGLE_CLIENT_SECRET: 'google-secret', - AUTH_GITHUB_CLIENT_ID: 'github-client', - AUTH_GITHUB_CLIENT_SECRET: 'github-secret', - GATEWAY_BASE_URL: 'https://gateway.example', - DEFAULT_CHAT_MODEL: 'openai/gpt-4o-mini', - DEFAULT_TTS_MODEL: 'openai/gpt-4o-mini-tts', - }) + const env = parseEnv(baseEnv()) expect(env.DATABASE_URL).toBe('postgres://example') expect(env.REDIS_URL).toBe('redis://example') + expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([]) + }) + + it('parses ADDITIONAL_TRUSTED_ORIGINS into a normalized origin list', () => { + const env = parseEnv({ + ...baseEnv(), + ADDITIONAL_TRUSTED_ORIGINS: 'https://10.0.0.129:5273/, https://198.18.0.1:5273', + }) + + expect(env.ADDITIONAL_TRUSTED_ORIGINS).toEqual([ + 'https://10.0.0.129:5273', + 'https://198.18.0.1:5273', + ]) }) }) diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index 65ed84740..872930a5b 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -6,6 +6,49 @@ import { useLogger } from '@guiiai/logg' import { injeca } from 'injeca' import { integer, maxValue, minValue, nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot' +import { DEFAULT_BILLING_EVENTS_STREAM } from '../utils/redis-keys' + +/** + * Parses `ADDITIONAL_TRUSTED_ORIGINS`: comma-separated absolute origins used for + * CORS (`/api/*`) and request-derived trusted bases (e.g. Stripe return URLs). + * Each segment is normalized via `URL.origin` so trailing slashes are stripped. + * + * Before: + * - `" https://10.0.0.129:5273/ , https://198.18.0.1:5273 "` + * + * After: + * - `["https://10.0.0.129:5273", "https://198.18.0.1:5273"]` + */ +export function parseAdditionalTrustedOriginsEnv(raw: string): string[] { + const trimmed = raw.trim() + if (!trimmed) + return [] + + const seen = new Set() + const out: string[] = [] + + for (const part of trimmed.split(',')) { + const entry = part.trim() + if (!entry) + continue + + let normalized: string + try { + normalized = new URL(entry).origin + } + catch { + throw new TypeError(`ADDITIONAL_TRUSTED_ORIGINS: invalid URL origin segment "${entry}"`) + } + + if (!seen.has(normalized)) { + seen.add(normalized) + out.push(normalized) + } + } + + return out +} + function optionalIntegerFromString(defaultValue: number, envKey: string, minimum: number) { return optional( pipe( @@ -38,6 +81,16 @@ const EnvSchema = object({ API_SERVER_URL: optional(string(), 'http://localhost:3000'), + // Comma-separated exact origins (e.g. Capacitor dev server `https://10.x:5273`). + // Prefer this over broad private-IP regex heuristics in production-like configs. + ADDITIONAL_TRUSTED_ORIGINS: optional( + pipe( + string(), + transform(raw => parseAdditionalTrustedOriginsEnv(raw)), + ), + '', + ), + DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')), REDIS_URL: pipe(string(), nonEmpty('REDIS_URL is required')), diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index f80a3f555..b70ecba0f 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -135,7 +135,7 @@ export async function createAuthRoutes(deps: AuthRoutesDeps) { routeLabel: 'auth.api', })) .use('/api/auth/oauth2/authorize', async (c, next) => { - await ensureDynamicFirstPartyRedirectUri(deps.db, c.req.raw) + await ensureDynamicFirstPartyRedirectUri(deps.db, c.req.raw, deps.env.ADDITIONAL_TRUSTED_ORIGINS) await next() }) .route('/api/auth', createOIDCTokenAuthRoute(deps)) diff --git a/apps/server/src/routes/stripe/index.ts b/apps/server/src/routes/stripe/index.ts index 3fddc08f3..4c2325d20 100644 --- a/apps/server/src/routes/stripe/index.ts +++ b/apps/server/src/routes/stripe/index.ts @@ -174,7 +174,7 @@ export function createStripeRoutes( const customer = await stripeService.getCustomerByUserId(user.id) const stripeCustomerId = customer?.stripeCustomerId - const redirectBase = resolveTrustedRequestOrigin(c.req.raw) + const redirectBase = resolveTrustedRequestOrigin(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS) if (!redirectBase) { throw createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN') } @@ -249,7 +249,7 @@ export function createStripeRoutes( if (!customer) throw createBadRequestError('No billing account found', 'NO_CUSTOMER') - const portalReturnBase = resolveTrustedRequestOrigin(c.req.raw) + const portalReturnBase = resolveTrustedRequestOrigin(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS) if (!portalReturnBase) { throw createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN') } diff --git a/apps/server/src/utils/origin.ts b/apps/server/src/utils/origin.ts index 14c86b693..7bf31195f 100644 --- a/apps/server/src/utils/origin.ts +++ b/apps/server/src/utils/origin.ts @@ -15,6 +15,9 @@ const TRUSTED_EXACT_ORIGINS = [ 'https://airi.moeru.ai', // Production ] +// 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). const TRUSTED_ORIGIN_PATTERNS = [ // Localhost dev (any port) /^http:\/\/localhost(:\d+)?$/, @@ -27,30 +30,54 @@ const TRUSTED_ORIGIN_PATTERNS = [ /^https:\/\/.*\.kwaa\.workers\.dev$/, ] -export function getTrustedOrigin(origin: string): string { +/** + * Returns `origin` when it matches built-in trust rules or `additionalTrustedOrigins`. + * + * Use when: + * - CORS allowlists (`/api/*`) or Stripe redirect base resolution need the same rules as Better Auth. + * + * Expects: + * - `origin` is the raw `Origin` header value or `new URL(referer).origin`. + * - `additionalTrustedOrigins` entries are normalized origins (see {@link parseAdditionalTrustedOriginsEnv}). + * + * Returns: + * - The same origin string when trusted, or `''` when not trusted. + */ +export function getTrustedOrigin(origin: string, additionalTrustedOrigins: readonly string[] = []): string { if (!origin) return origin - if (TRUSTED_EXACT_ORIGINS.includes(origin)) return origin - + if (additionalTrustedOrigins.includes(origin)) + return origin if (TRUSTED_ORIGIN_PATTERNS.some(pattern => pattern.test(origin))) return origin - return '' } -export function resolveTrustedRequestOrigin(request: Request): string | undefined { +/** + * Resolves a trusted browser origin from `Referer` (preferred) or `Origin`. + * + * Expects: + * - Same trust inputs as {@link getTrustedOrigin}. + * + * Returns: + * - The trusted origin string, or `undefined` when neither header yields a trusted origin. + */ +export function resolveTrustedRequestOrigin( + request: Request, + additionalTrustedOrigins: readonly string[] = [], +): string | undefined { const refererOrigin = getOriginFromUrl(request.headers.get('referer') ?? '') if (refererOrigin) { - const trustedRefererOrigin = getTrustedOrigin(refererOrigin) + const trustedRefererOrigin = getTrustedOrigin(refererOrigin, additionalTrustedOrigins) if (trustedRefererOrigin) { return trustedRefererOrigin } } const requestOrigin = request.headers.get('origin') ?? '' - const trustedRequestOrigin = getTrustedOrigin(requestOrigin) + const trustedRequestOrigin = getTrustedOrigin(requestOrigin, additionalTrustedOrigins) if (trustedRequestOrigin) { return trustedRequestOrigin } @@ -75,19 +102,36 @@ const ALWAYS_TRUSTED_AUTH_ORIGINS = [ 'http://127.0.0.1:*', ] -export function getAuthTrustedOrigins(env: Pick, request?: Request): string[] { +/** + * Builds the origin list passed to Better Auth `trustedOrigins` (and related flows). + * + * Expects: + * - `env.API_SERVER_URL` and parsed `env.ADDITIONAL_TRUSTED_ORIGINS`. + * - Optional `request` so the caller's Origin/Referer can be merged when known. + * + * Returns: + * - De-duplicated origins in insertion order (API URL, env extras, localhost wildcards, then request-derived). + */ +export function getAuthTrustedOrigins( + env: Pick, + request?: Request, +): string[] { const origins = new Set() const apiServerOrigin = getOriginFromUrl(env.API_SERVER_URL) if (apiServerOrigin) { origins.add(apiServerOrigin) } + for (const origin of env.ADDITIONAL_TRUSTED_ORIGINS) { + origins.add(origin) + } + for (const origin of ALWAYS_TRUSTED_AUTH_ORIGINS) { origins.add(origin) } if (request) { - const requestOrigin = resolveTrustedRequestOrigin(request) + const requestOrigin = resolveTrustedRequestOrigin(request, env.ADDITIONAL_TRUSTED_ORIGINS) if (requestOrigin) { origins.add(requestOrigin) } diff --git a/apps/server/src/utils/tests/origin.test.ts b/apps/server/src/utils/tests/origin.test.ts index 358972ea2..ef8faecc6 100644 --- a/apps/server/src/utils/tests/origin.test.ts +++ b/apps/server/src/utils/tests/origin.test.ts @@ -12,6 +12,17 @@ describe('origin utils', () => { expect(getTrustedOrigin('https://127.0.0.1:5273')).toBe('https://127.0.0.1:5273') }) + it('rejects private LAN Vite dev origins unless listed in ADDITIONAL_TRUSTED_ORIGINS', () => { + expect(getTrustedOrigin('https://10.0.0.129:5273')).toBe('') + expect(getTrustedOrigin('https://198.18.0.1:5273')).toBe('') + expect(getTrustedOrigin('https://192.168.1.5:5273')).toBe('') + + const extra = ['https://10.0.0.129:5273', 'https://198.18.0.1:5273', 'https://192.168.1.5:5273'] + expect(getTrustedOrigin('https://10.0.0.129:5273', extra)).toBe('https://10.0.0.129:5273') + expect(getTrustedOrigin('https://198.18.0.1:5273', extra)).toBe('https://198.18.0.1:5273') + expect(getTrustedOrigin('https://192.168.1.5:5273', extra)).toBe('https://192.168.1.5:5273') + }) + it('rejects untrusted origins', () => { expect(getTrustedOrigin('https://example.com')).toBe('') }) @@ -44,11 +55,26 @@ describe('origin utils', () => { }, }) - expect(getAuthTrustedOrigins({ API_SERVER_URL: 'https://api.airi.moeru.ai' } as any, request)).toEqual([ + expect(getAuthTrustedOrigins({ + API_SERVER_URL: 'https://api.airi.moeru.ai', + ADDITIONAL_TRUSTED_ORIGINS: [], + }, request)).toEqual([ 'https://api.airi.moeru.ai', 'http://localhost:*', 'http://127.0.0.1:*', 'http://localhost:5173', ]) }) + + it('includes ADDITIONAL_TRUSTED_ORIGINS in Better Auth trustedOrigins list', () => { + expect(getAuthTrustedOrigins({ + API_SERVER_URL: 'https://api.airi.moeru.ai', + ADDITIONAL_TRUSTED_ORIGINS: ['https://10.0.0.129:5273'], + })).toEqual([ + 'https://api.airi.moeru.ai', + 'https://10.0.0.129:5273', + 'http://localhost:*', + 'http://127.0.0.1:*', + ]) + }) })