From 5870930dadfb3fd5a6365cbda159488fbd3c792f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 13 Nov 2026 03:32:59 -0800 Subject: [PATCH] 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. --- cloud/apps/billing/src/auth.ts | 20 ++++-- cloud/apps/billing/src/cloud-setup-urls.ts | 12 ++++ cloud/apps/billing/src/env.ts | 1 + cloud/apps/billing/src/index.ts | 8 ++- cloud/apps/billing/test/auth-issuers.test.ts | 63 +++++++++++++++++++ .../billing/test/cloud-setup-urls.test.ts | 41 ++++++++++++ cloud/apps/sandbox-control/src/auth.ts | 20 ++++-- cloud/apps/sandbox-control/src/env.ts | 3 + cloud/apps/sandbox-control/src/index.ts | 2 + cloud/apps/sandbox-control/src/lifecycle.ts | 4 +- .../sandbox-control/src/network-policy.ts | 23 +++++-- .../sandbox-control/test/auth-issuers.test.ts | 53 ++++++++++++++++ .../test/network-policy.test.ts | 31 ++++++++- 13 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 cloud/apps/billing/test/auth-issuers.test.ts create mode 100644 cloud/apps/billing/test/cloud-setup-urls.test.ts create mode 100644 cloud/apps/sandbox-control/test/auth-issuers.test.ts diff --git a/cloud/apps/billing/src/auth.ts b/cloud/apps/billing/src/auth.ts index 944208b4248..b905157a37e 100644 --- a/cloud/apps/billing/src/auth.ts +++ b/cloud/apps/billing/src/auth.ts @@ -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> = [ - () => verify(keysFor(env), env.BILLING_AUTH_ISSUER) + () => verify(keysFor(env), issuerList(env.BILLING_AUTH_ISSUER)) ] const staging = stagingKeysFor(env) if (staging) { diff --git a/cloud/apps/billing/src/cloud-setup-urls.ts b/cloud/apps/billing/src/cloud-setup-urls.ts index 4ccc17b8461..552b14659fd 100644 --- a/cloud/apps/billing/src/cloud-setup-urls.ts +++ b/cloud/apps/billing/src/cloud-setup-urls.ts @@ -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(/\/$/, '') } diff --git a/cloud/apps/billing/src/env.ts b/cloud/apps/billing/src/env.ts index 1e798845dde..06c3f11d482 100644 --- a/cloud/apps/billing/src/env.ts +++ b/cloud/apps/billing/src/env.ts @@ -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 diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index ebc71c6162b..f7d22eea512 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -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) { diff --git a/cloud/apps/billing/test/auth-issuers.test.ts b/cloud/apps/billing/test/auth-issuers.test.ts new file mode 100644 index 00000000000..a1aa466a006 --- /dev/null +++ b/cloud/apps/billing/test/auth-issuers.test.ts @@ -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 => { + 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) + }) +}) diff --git a/cloud/apps/billing/test/cloud-setup-urls.test.ts b/cloud/apps/billing/test/cloud-setup-urls.test.ts new file mode 100644 index 00000000000..1e00346d13b --- /dev/null +++ b/cloud/apps/billing/test/cloud-setup-urls.test.ts @@ -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' } }) + }) +}) diff --git a/cloud/apps/sandbox-control/src/auth.ts b/cloud/apps/sandbox-control/src/auth.ts index a0fed6fa3ae..cd653b5b2ed 100644 --- a/cloud/apps/sandbox-control/src/auth.ts +++ b/cloud/apps/sandbox-control/src/auth.ts @@ -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 { 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))) { diff --git a/cloud/apps/sandbox-control/src/env.ts b/cloud/apps/sandbox-control/src/env.ts index d4586f574ab..2249033af37 100644 --- a/cloud/apps/sandbox-control/src/env.ts +++ b/cloud/apps/sandbox-control/src/env.ts @@ -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 /** diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index ed5aaf12047..bcbd013aabd 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -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 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). */ diff --git a/cloud/apps/sandbox-control/test/auth-issuers.test.ts b/cloud/apps/sandbox-control/test/auth-issuers.test.ts new file mode 100644 index 00000000000..dc4d9b5ae7c --- /dev/null +++ b/cloud/apps/sandbox-control/test/auth-issuers.test.ts @@ -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 => { + 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) + }) +}) diff --git a/cloud/apps/sandbox-control/test/network-policy.test.ts b/cloud/apps/sandbox-control/test/network-policy.test.ts index 454b579335a..447a1a66e29 100644 --- a/cloud/apps/sandbox-control/test/network-policy.test.ts +++ b/cloud/apps/sandbox-control/test/network-policy.test.ts @@ -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([]) + }) +})