feat(cloud): issuer lists, platform egress suffixes and a prod-required setup pages URL (DOMAIN-1 D2) (#668)

- SANDBOX_/BILLING_(STAGING_)AUTH_ISSUER accept a comma-separated issuer list; a single value behaves as before.
- ORCA_PLATFORM_HOST_SUFFIXES supplies the platform egress hosts (default *.onorca.dev, unchanged allowlist).
- Billing: CLOUD_SETUP_PAGES_URL unset with BILLING_ENV=prod answers 503 billing_misconfigured; dev/staging keep the fallback.

No wrangler config or current value changes.
This commit is contained in:
Neil
2026-11-13 03:32:59 -08:00
committed by GitHub
parent e65f777984
commit 5870930dad
13 changed files with 265 additions and 16 deletions
+16 -4
View File
@@ -63,10 +63,22 @@ function devKeysFor(env: Env): JWTVerifyGetKey | null {
return devKeys.keys
}
/**
* A comma-separated issuer var as jwtVerify's `issuer` option: the token's `iss` must equal one entry.
* Unset or empty: no issuer check (as before lists). Blank entries are ignored; a value with no
* entries left accepts no issuer.
*/
export function issuerList(value: string | undefined): string[] | undefined {
if (!value) {
return undefined
}
return value.split(',').map((issuer) => issuer.trim()).filter(Boolean)
}
let stagingKeys: { source: string; keys: JWTVerifyGetKey } | null = null
/** Staging auth's keys and issuer; never honored when BILLING_ENV is 'prod'. */
function stagingKeysFor(env: Env): { keys: JWTVerifyGetKey; issuer: string } | null {
function stagingKeysFor(env: Env): { keys: JWTVerifyGetKey; issuer: string[] | undefined } | null {
if (env.BILLING_ENV === 'prod' || !env.BILLING_STAGING_JWKS_URL || !env.BILLING_STAGING_AUTH_ISSUER) {
return null
}
@@ -76,7 +88,7 @@ function stagingKeysFor(env: Env): { keys: JWTVerifyGetKey; issuer: string } | n
keys: createRemoteJWKSet(new URL(env.BILLING_STAGING_JWKS_URL))
}
}
return { keys: stagingKeys.keys, issuer: env.BILLING_STAGING_AUTH_ISSUER }
return { keys: stagingKeys.keys, issuer: issuerList(env.BILLING_STAGING_AUTH_ISSUER) }
}
export function readBearer(value: string | null): string | null {
@@ -102,7 +114,7 @@ export async function authenticateDesktop(
if (!token) {
return null
}
const verify = (keys: JWTVerifyGetKey, issuer: string | undefined) =>
const verify = (keys: JWTVerifyGetKey, issuer: string | string[] | undefined) =>
jwtVerify(token, keys, {
audience: env.BILLING_JWT_AUDIENCE,
algorithms: ['ES256'],
@@ -114,7 +126,7 @@ export async function authenticateDesktop(
try {
// Production auth first; in dev only, then staging auth, then the operator key set.
const attempts: Array<() => ReturnType<typeof verify>> = [
() => verify(keysFor(env), env.BILLING_AUTH_ISSUER)
() => verify(keysFor(env), issuerList(env.BILLING_AUTH_ISSUER))
]
const staging = stagingKeysFor(env)
if (staging) {
@@ -1,7 +1,19 @@
// URLs of the cloud setup pages on Orca Cloud auth (auth.onorca.dev/cloud/setup/...).
import type { Env } from './env'
/** Thrown when CLOUD_SETUP_PAGES_URL is unset in prod; the index answers 503 billing_misconfigured. */
export class SetupPagesConfigError extends Error {
constructor() {
super('cloud_setup_pages_url_missing')
this.name = 'SetupPagesConfigError'
}
}
function pagesOrigin(env: Env): string {
if (!env.CLOUD_SETUP_PAGES_URL && env.BILLING_ENV === 'prod') {
// Prod must name its pages origin; the fallback below is only a dev/staging convenience.
throw new SetupPagesConfigError()
}
return (env.CLOUD_SETUP_PAGES_URL ?? 'https://auth.onorca.dev').replace(/\/$/, '')
}
+1
View File
@@ -51,6 +51,7 @@ export type Env = {
// BILLING_JWKS (inline set) is for local dev and tests only.
BILLING_JWKS_URL?: string
BILLING_JWKS?: string
// Accepted `iss` values, comma-separated (a single issuer is a one-item list); so is the staging one.
BILLING_AUTH_ISSUER?: string
BILLING_JWT_AUDIENCE: string
// 'dev' | 'prod'. Dev may also accept BILLING_DEV_JWKS (issuer 'orca-billing-dev-test', a secret
+7 -1
View File
@@ -55,6 +55,7 @@ import { entitlementsView, offersFor, plansView } from './plans-api'
import { reconcile } from './reconcile'
import type { Env } from './env'
import { MACHINE_SIZES, PlanConfigError, planCatalog, publicOffers } from './plans'
import { SetupPagesConfigError } from './cloud-setup-urls'
import { OverageRequestSchema, updateOverage } from './overage'
import { LiveModeRefusedError, StripeError, createStripeClient, type StripeClient } from './stripe'
import { UsageReportSchema, flushMeterReports, recordUsage, validateSegment } from './usage'
@@ -566,7 +567,12 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo
if (error instanceof SyntaxError) {
return json({ error: 'invalid_json' }, 400)
}
if (error instanceof LiveModeRefusedError || error instanceof PlanConfigError || error instanceof CatalogUnavailableError) {
if (
error instanceof LiveModeRefusedError ||
error instanceof PlanConfigError ||
error instanceof CatalogUnavailableError ||
error instanceof SetupPagesConfigError
) {
return json({ error: 'billing_misconfigured' }, 503)
}
if (error instanceof MembershipUnavailableError) {
@@ -0,0 +1,63 @@
import { SignJWT, exportJWK, generateKeyPair } from 'jose'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { authenticateDesktop, issuerList } from '../src/auth'
import { createHarness } from './harness'
const PROD = 'https://login.onorca.dev'
const NEXT = 'https://login.example.dev'
const NOW = Date.UTC(2026, 0, 15)
async function issuerHarness(issuers: string) {
const { privateKey, publicKey } = await generateKeyPair('ES256')
const jwk = { ...(await exportJWK(publicKey)), kid: 'iss1', alg: 'ES256', use: 'sig' }
const h = await createHarness({ BILLING_JWKS: JSON.stringify({ keys: [jwk] }), BILLING_AUTH_ISSUER: issuers })
const accepts = async (issuer: string): Promise<boolean> => {
const iat = Math.floor(NOW / 1000)
const token = await new SignJWT({ sub: 'user_iss', purpose: 'billing' })
.setProtectedHeader({ alg: 'ES256', kid: 'iss1' })
.setIssuer(issuer)
.setAudience('orca-billing')
.setIssuedAt(iat)
.setExpirationTime(iat + 300)
.sign(privateKey)
const request = new Request('https://billing.test/v1/billing/entitlements', { headers: { authorization: `Bearer ${token}` } })
return (await authenticateDesktop(h.env, request)) !== null
}
return { accepts }
}
describe('BILLING_AUTH_ISSUER list', () => {
// jose checks exp/iat against Date; pin it so no wall clock is involved.
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('parses a comma list, ignoring whitespace and empty entries', () => {
expect(issuerList(undefined)).toBeUndefined()
expect(issuerList('')).toBeUndefined()
expect(issuerList(PROD)).toEqual([PROD])
expect(issuerList(` ${PROD} ,, ${NEXT} , `)).toEqual([PROD, NEXT])
})
it('a single issuer behaves as before', async () => {
const h = await issuerHarness(PROD)
expect(await h.accepts(PROD)).toBe(true)
expect(await h.accepts(NEXT)).toBe(false)
})
it('a list accepts each listed issuer and rejects others', async () => {
const h = await issuerHarness(` ${PROD} , ,${NEXT},`)
expect(await h.accepts(PROD)).toBe(true)
expect(await h.accepts(NEXT)).toBe(true)
expect(await h.accepts('https://login.evil.dev')).toBe(false)
})
it('a value with no entries accepts no issuer', async () => {
const h = await issuerHarness(' , ')
expect(await h.accepts(PROD)).toBe(false)
})
})
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SetupPagesConfigError, setupPageUrl } from '../src/cloud-setup-urls'
import { createHarness } from './harness'
const NOW = Date.UTC(2026, 0, 15)
describe('CLOUD_SETUP_PAGES_URL', () => {
// The harness and jose read Date; pin it so no wall clock is involved.
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
it('falls back to the current auth origin outside prod when unset', async () => {
for (const BILLING_ENV of [undefined, 'dev', 'staging']) {
const { env } = await createHarness({ BILLING_ENV })
expect(setupPageUrl(env, '/start', { session: 's1' })).toBe('https://auth.onorca.dev/cloud/setup/start?session=s1')
}
})
it('uses the configured origin in every environment', async () => {
for (const BILLING_ENV of ['dev', 'prod']) {
const { env } = await createHarness({ BILLING_ENV, CLOUD_SETUP_PAGES_URL: 'https://pages.test/' })
expect(setupPageUrl(env, '/plan', { session: 's1' })).toBe('https://pages.test/cloud/setup/plan?session=s1')
}
})
it('is a configuration error in prod when unset, answered as billing_misconfigured', async () => {
const h = await createHarness({ BILLING_ENV: 'prod' })
expect(() => setupPageUrl(h.env, '/start', { session: 's1' })).toThrow(SetupPagesConfigError)
const token = await h.desktopToken({ sub: 'u1', email: 'neil@acme.dev' })
const created = await h.call('POST', '/v1/cloud-setup/sessions', {
body: { repo: 'https://github.com/acme/web', needs: ['plan'], device: 'MacBook Pro' },
headers: { authorization: `Bearer ${token}` }
})
expect(created).toEqual({ status: 503, body: { error: 'billing_misconfigured' } })
})
})
+16 -4
View File
@@ -97,10 +97,22 @@ export function accountIdFor(claims: { sub: string; org?: string }): string {
return claims.org ? `org:${claims.org}` : `user:${claims.sub}`
}
/**
* A comma-separated issuer var as jwtVerify's `issuer` option: the token's `iss` must equal one entry.
* Unset or empty: no issuer check (as before lists). Blank entries are ignored; a value with no
* entries left accepts no issuer.
*/
export function issuerList(value: string | undefined): string[] | undefined {
if (!value) {
return undefined
}
return value.split(',').map((issuer) => issuer.trim()).filter(Boolean)
}
let stagingKeys: { source: string; keys: JWTVerifyGetKey } | null = null
/** Staging auth's keys and issuer; never honored in prod. Same accounts as billing dev (it trusts staging too). */
function stagingKeysFor(env: Env): { keys: JWTVerifyGetKey; issuer: string } | null {
function stagingKeysFor(env: Env): { keys: JWTVerifyGetKey; issuer: string[] | undefined } | null {
const source = env.SANDBOX_STAGING_JWKS_URL ?? (env.SANDBOX_STAGING_JWKS ? `inline:${env.SANDBOX_STAGING_JWKS}` : null)
if (env.SANDBOX_ENV === 'prod' || !source || !env.SANDBOX_STAGING_AUTH_ISSUER) {
return null
@@ -113,7 +125,7 @@ function stagingKeysFor(env: Env): { keys: JWTVerifyGetKey; issuer: string } | n
: createLocalJWKSet(JSON.parse(env.SANDBOX_STAGING_JWKS ?? '{"keys":[]}') as JSONWebKeySet)
}
}
return { keys: stagingKeys.keys, issuer: env.SANDBOX_STAGING_AUTH_ISSUER }
return { keys: stagingKeys.keys, issuer: issuerList(env.SANDBOX_STAGING_AUTH_ISSUER) }
}
/** Dev-only operator keys (issuer DEV_TEST_ISSUER) so live tests run before Orca auth mints sandbox tokens. */
@@ -124,7 +136,7 @@ async function verify(
token: string,
keys: JWTVerifyGetKey,
env: Env,
issuer: string | undefined,
issuer: string | string[] | undefined,
now: number
): Promise<SandboxCaller | null> {
try {
@@ -163,7 +175,7 @@ export async function authenticateDesktop(
}
const staging = stagingKeysFor(env)
const caller =
(await verify(token, keysFor(env), env, env.SANDBOX_AUTH_ISSUER, now)) ??
(await verify(token, keysFor(env), env, issuerList(env.SANDBOX_AUTH_ISSUER), now)) ??
(staging ? await verify(token, staging.keys, env, staging.issuer, now) : null)
if (caller || !env.SANDBOX_DEV_JWKS || env.SANDBOX_ENV === 'prod') {
if (!caller || (await tokenPredatesRemoval(env.SANDBOX_DB, caller))) {
+3
View File
@@ -46,6 +46,7 @@ export type Env = {
// SANDBOX_JWKS (inline set) is for local dev and tests only.
SANDBOX_JWKS_URL?: string
SANDBOX_JWKS?: string
/** Accepted `iss` values, comma-separated (a single issuer is a one-item list). */
SANDBOX_AUTH_ISSUER?: string
SANDBOX_JWT_AUDIENCE: string
/** Dev-only operator key set (issuer `orca-sandbox-dev-test`); ignored when SANDBOX_ENV=prod. */
@@ -194,6 +195,8 @@ export type Env = {
PUBLIC_API_URL?: string
/** Comma list of extra egress hosts every VM may reach (dev: this Worker's workers.dev host). */
EGRESS_EXTRA_HOSTS?: string
/** Comma list of Orca platform egress hosts every VM may reach (default `*.onorca.dev`). */
ORCA_PLATFORM_HOST_SUFFIXES?: string
/** GitHub App webhook secret (secret): verifies X-Hub-Signature-256 on /v1/github/webhook. */
GITHUB_WEBHOOK_SECRET?: string
/**
+2
View File
@@ -200,6 +200,7 @@ import {
userAuthorizeUrl
} from './github-user-tokens'
import { MEMBER_GIT_WINDOW_MS, memberGitRoutes, memberSentWithin, recordMemberSend } from './member-git'
import { egressAllowlistFor } from './network-policy'
import { getWorkspacePrefs, putWorkspacePrefs } from './workspace-prefs'
import { getOrgSlug, putOrgSlug } from './org-slugs'
import {
@@ -937,6 +938,7 @@ export async function lifecycleDeps(env: Env, deps: Deps): Promise<LifecycleDeps
config: {
pool: poolConfigFromEnv(env),
shareLinkSecret: shareSecret(env),
egressAllowlist: egressAllowlistFor(env.ORCA_PLATFORM_HOST_SUFFIXES),
extraEgressHosts: extraEgressHostsFor(env),
guardrails: guardrailsFromEnv(env),
snapshotExpirationMs:
+3 -1
View File
@@ -149,6 +149,8 @@ export type LifecycleDeps = {
snapshotExpirationMs?: number
/** Warm pool (pool.ts); absent or size 0 means every create boots its own VM. */
pool?: PoolConfig
/** Base egress allowlist (egressAllowlistFor ORCA_PLATFORM_HOST_SUFFIXES); default DEFAULT_EGRESS_ALLOWLIST. */
egressAllowlist?: readonly string[]
/** Hosts allowed on top of the default egress allowlist (e.g. this control plane for the in-VM CLI). */
extraEgressHosts?: readonly string[]
/** SHARE_LINK_SECRET: each session's share-proxy key derives from it; unset = no share links. */
@@ -314,7 +316,7 @@ export function egressPolicy(deps: LifecycleDeps, row: SandboxRow, gitToken?: st
: []
// Custom model-provider hosts (ANTHROPIC_BASE_URL / OPENAI_BASE_URL secrets) recorded at boot,
// plus operator-configured extra hosts.
const allowlist = [...new Set([...DEFAULT_EGRESS_ALLOWLIST, ...(deps.config.extraEgressHosts ?? []), ...egressAllowHosts(row)])]
const allowlist = [...new Set([...(deps.config.egressAllowlist ?? DEFAULT_EGRESS_ALLOWLIST), ...(deps.config.extraEgressHosts ?? []), ...egressAllowHosts(row)])]
return compileNetworkPolicy({ mode: 'allowlist', allowlist, credentials: [...git, ...credentials] })
}
@@ -41,8 +41,11 @@ export const EDITOR_REMOTE_SERVER_HOSTS: readonly string[] = [
'openvsxorg.blob.core.windows.net'
]
/** Registries, git hosting and agent model/sign-in hosts a coding VM needs. */
export const DEFAULT_EGRESS_ALLOWLIST: readonly string[] = [
/** Orca's own hosts when ORCA_PLATFORM_HOST_SUFFIXES is unset. */
export const DEFAULT_PLATFORM_HOST_SUFFIXES: readonly string[] = ['*.onorca.dev']
/** Registries, git hosting and agent model/sign-in hosts a coding VM needs (platform hosts excluded). */
const BASE_EGRESS_HOSTS: readonly string[] = [
'registry.npmjs.org',
'registry.yarnpkg.com',
'pypi.org',
@@ -94,11 +97,21 @@ export const DEFAULT_EGRESS_ALLOWLIST: readonly string[] = [
'models.dev',
// GitHub Copilot CLI: Copilot API (per plan: api.individual., api.business., ...).
'api.githubcopilot.com',
'*.githubcopilot.com',
'*.onorca.dev',
...EDITOR_REMOTE_SERVER_HOSTS
'*.githubcopilot.com'
]
/**
* The default allowlist with Orca's platform hosts from ORCA_PLATFORM_HOST_SUFFIXES (comma list, e.g.
* `*.onorca.dev`). Unset, blank or only commas: DEFAULT_PLATFORM_HOST_SUFFIXES.
*/
export function egressAllowlistFor(platformHostSuffixes?: string): readonly string[] {
const configured = (platformHostSuffixes ?? '').split(',').map((host) => host.trim()).filter(Boolean)
const platform = configured.length > 0 ? configured : DEFAULT_PLATFORM_HOST_SUFFIXES
return [...new Set([...BASE_EGRESS_HOSTS, ...platform, ...EDITOR_REMOTE_SERVER_HOSTS])]
}
export const DEFAULT_EGRESS_ALLOWLIST: readonly string[] = egressAllowlistFor()
export type CredentialRoute = {
host: string
/** Header the VM sends with a placeholder value, matched exactly (one claim rule per value). */
@@ -0,0 +1,53 @@
import { SignJWT, exportJWK, generateKeyPair } from 'jose'
import { describe, expect, it } from 'vitest'
import { authenticateDesktop, issuerList } from '../src/auth'
import { createHarness } from './harness'
const PROD = 'https://login.onorca.dev'
const NEXT = 'https://login.example.dev'
async function issuerHarness(issuers: string) {
const { privateKey, publicKey } = await generateKeyPair('ES256')
const jwk = { ...(await exportJWK(publicKey)), kid: 'iss1', alg: 'ES256', use: 'sig' }
const h = await createHarness({ SANDBOX_JWKS: JSON.stringify({ keys: [jwk] }), SANDBOX_AUTH_ISSUER: issuers })
const accepts = async (issuer: string): Promise<boolean> => {
const iat = Math.floor(h.now() / 1000)
const token = await new SignJWT({ sub: 'user_iss', purpose: 'sandbox' })
.setProtectedHeader({ alg: 'ES256', kid: 'iss1' })
.setIssuer(issuer)
.setAudience('orca-sandbox')
.setIssuedAt(iat)
.setExpirationTime(iat + 300)
.sign(privateKey)
const request = new Request('https://sandbox.test/v1/config', { headers: { authorization: `Bearer ${token}` } })
return (await authenticateDesktop(h.env, request, h.now())) !== null
}
return { accepts }
}
describe('SANDBOX_AUTH_ISSUER list', () => {
it('parses a comma list, ignoring whitespace and empty entries', () => {
expect(issuerList(undefined)).toBeUndefined()
expect(issuerList('')).toBeUndefined()
expect(issuerList(PROD)).toEqual([PROD])
expect(issuerList(` ${PROD} ,, ${NEXT} , `)).toEqual([PROD, NEXT])
})
it('a single issuer behaves as before', async () => {
const h = await issuerHarness(PROD)
expect(await h.accepts(PROD)).toBe(true)
expect(await h.accepts(NEXT)).toBe(false)
})
it('a list accepts each listed issuer and rejects others', async () => {
const h = await issuerHarness(` ${PROD} , ,${NEXT},`)
expect(await h.accepts(PROD)).toBe(true)
expect(await h.accepts(NEXT)).toBe(true)
expect(await h.accepts('https://login.evil.dev')).toBe(false)
})
it('a value with no entries accepts no issuer', async () => {
const h = await issuerHarness(' , ')
expect(await h.accepts(PROD)).toBe(false)
})
})
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import { EDITOR_REMOTE_SERVER_HOSTS, compileNetworkPolicy, gitBasicAuthorization } from '../src/network-policy'
import {
DEFAULT_EGRESS_ALLOWLIST,
EDITOR_REMOTE_SERVER_HOSTS,
compileNetworkPolicy,
egressAllowlistFor,
gitBasicAuthorization
} from '../src/network-policy'
const deny = { response: { statusCode: 403, body: 'orca-egress-denied', contentType: 'text/plain' } }
@@ -78,3 +84,26 @@ describe('compileNetworkPolicy', () => {
expect(allow['*']).toBeUndefined()
})
})
describe('ORCA_PLATFORM_HOST_SUFFIXES', () => {
it('unset (or blank) keeps the default allowlist exactly as before', () => {
const editorAt = DEFAULT_EGRESS_ALLOWLIST.length - EDITOR_REMOTE_SERVER_HOSTS.length
expect(DEFAULT_EGRESS_ALLOWLIST).toHaveLength(56)
expect(DEFAULT_EGRESS_ALLOWLIST.slice(editorAt - 2, editorAt)).toEqual(['*.githubcopilot.com', '*.onorca.dev'])
expect(DEFAULT_EGRESS_ALLOWLIST.slice(editorAt)).toEqual(EDITOR_REMOTE_SERVER_HOSTS)
for (const unset of [undefined, '', ' , ']) {
expect(egressAllowlistFor(unset)).toEqual(DEFAULT_EGRESS_ALLOWLIST)
}
})
it('replaces the platform hosts with the configured suffixes, once each', () => {
const allowlist = egressAllowlistFor(' *.onorca.dev , ,*.example.dev,')
expect(allowlist.filter((host) => host === '*.onorca.dev')).toHaveLength(1)
expect(allowlist).toContain('*.example.dev')
expect(allowlist).toHaveLength(DEFAULT_EGRESS_ALLOWLIST.length + 1)
expect(egressAllowlistFor('*.example.dev')).not.toContain('*.onorca.dev')
const { allow } = compileNetworkPolicy({ mode: 'allowlist', allowlist })
expect(allow['*.onorca.dev']).toEqual([])
expect(allow['*.example.dev']).toEqual([])
})
})