feat(server): env-based trusted origins for Capacitor dev (#1763)
## Description Adds optional env **`ADDITIONAL_TRUSTED_ORIGINS`**: comma-separated browser origins that are trusted for **CORS (`/api/*`)**, **Stripe return URLs**, **Better Auth `trustedOrigins`**, and **dynamic web OIDC redirect URIs**. LAN / non-localhost Capacitor dev (e.g. Pocket + Vite on `https://10.x:5273`) no longer relies on broad private-IP regex; operators list exact origins in `.env.local` and restart the API server after changes. ## Linked Issues <!-- N/A --> ## Additional Context Pocket iOS dev workflow: `cap`/`capacitor.config` often points at a LAN HTTPS origin; without this allowlist the API rejects those `Origin`/`Referer`/`redirect_uri` bases. Review can stay focused on **`apps/server/src/libs/env.ts`**, **`apps/server/src/utils/origin.ts`**, and wiring in **`app.ts`**, **Stripe**, **auth routes**.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void> {
|
||||
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)
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseEnv } from './env'
|
||||
import { parseAdditionalTrustedOriginsEnv, parseEnv } from './env'
|
||||
|
||||
function baseEnv(): Record<string, string> {
|
||||
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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string>()
|
||||
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')),
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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<Env, 'API_SERVER_URL'>, 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<Env, 'API_SERVER_URL' | 'ADDITIONAL_TRUSTED_ORIGINS'>,
|
||||
request?: Request,
|
||||
): string[] {
|
||||
const origins = new Set<string>()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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:*',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user