Merge pull request #584 from nwparker/cloud/w6-org-mismatch

fix(cloud): ORG-TOKEN-STALE Worker backstop (x-orca-org 409 org_mismatch; stale_token on create/secrets/GitHub)
This commit is contained in:
Neil
2026-11-12 16:57:52 -08:00
committed by GitHub
4 changed files with 173 additions and 4 deletions
+32 -2
View File
@@ -70,6 +70,28 @@ export function readBearer(value: string | null): string | null {
return /^Bearer ([^\s]+)$/.exec(value ?? '')?.[1] ?? null
}
/**
* ORG-TOKEN-STALE: the desktop says which team it is acting for (`x-orca-org: <org id>`, or `user` for a
* personal account). A token minted for another team (stale after a team or role switch) is refused
* rather than acting on the wrong team. No header: no check (older desktops).
*/
export const ORG_HEADER = 'x-orca-org'
export class OrgMismatchError extends Error {
constructor() {
super('org_mismatch')
this.name = 'OrgMismatchError'
}
}
export function assertExpectedOrg(request: Request, tokenOrgId: string | undefined): void {
const expected = request.headers.get(ORG_HEADER)?.trim()
if (!expected) {
return
}
if (expected !== (tokenOrgId ?? 'user')) {
throw new OrgMismatchError()
}
}
/** Same account rule as billing: an org pays for its members, otherwise the user is the account. */
export function accountIdFor(claims: { sub: string; org?: string }): string {
return claims.org ? `org:${claims.org}` : `user:${claims.sub}`
@@ -144,7 +166,11 @@ export async function authenticateDesktop(
(await verify(token, keysFor(env), env, 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') {
return caller && (await tokenPredatesRemoval(env.SANDBOX_DB, caller)) ? null : caller
if (!caller || (await tokenPredatesRemoval(env.SANDBOX_DB, caller))) {
return null
}
assertExpectedOrg(request, caller.orgId)
return caller
}
if (devKeys?.source !== env.SANDBOX_DEV_JWKS) {
devKeys = {
@@ -159,5 +185,9 @@ export async function authenticateDesktop(
}
// Org-less dev tokens keep their historical `dev:<sub>` account; org dev tokens get `dev:org:<id>`.
const devCaller: SandboxCaller = { ...dev, accountId: dev.orgId ? `dev:org:${dev.orgId}` : `dev:${dev.userId}`, dev: true }
return (await tokenPredatesRemoval(env.SANDBOX_DB, devCaller)) ? null : devCaller
if (await tokenPredatesRemoval(env.SANDBOX_DB, devCaller)) {
return null
}
assertExpectedOrg(request, devCaller.orgId)
return devCaller
}
+44 -2
View File
@@ -47,8 +47,8 @@ import { WorkerEntrypoint } from 'cloudflare:workers'
import type { RunnerBuild } from './build-runner'
import { maintainPool, poolConfigFromEnv } from './pool'
import { cachedCreateTiming } from './create-timing'
import { creatorLeftAccount } from './member-changes'
import { authenticateDesktop } from './auth'
import { creatorLeftAccount, tokenMintedBeforeMemberChange } from './member-changes'
import { authenticateDesktop, OrgMismatchError } from './auth'
import { authenticateApi } from './api/caller'
import { handleApi, handleApiAs, isApiPath, type ApiContext, type ApiExtension } from './api/router'
import { handleMcpRoutes, isMcpPath, oauthApprovalsApi, oauthGrantsApi } from './mcp'
@@ -1160,6 +1160,29 @@ async function shareGateway(request: Request, url: URL, env: Env, deps: Deps): P
}
export async function handleRequest(request: Request, env: Env, deps: Deps = {}): Promise<Response> {
try {
return await routeRequest(request, env, deps)
} catch (error) {
// A stale-org token anywhere in routing (ORG-TOKEN-STALE): the desktop re-mints once and retries.
if (error instanceof OrgMismatchError) {
return orgMismatchResponse()
}
throw error
}
}
function orgMismatchResponse(): Response {
return json(
{
error: 'org_mismatch',
reason: 'org_mismatch',
message: 'Orca Cloud signed you in for a different team. Try again.'
},
409
)
}
async function routeRequest(request: Request, env: Env, deps: Deps): Promise<Response> {
const url = new URL(request.url)
try {
const shared = await shareGateway(request, url, env, deps)
@@ -1394,6 +1417,10 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
if (caller.deviceSessionId) {
return fail('forbidden', 403, 'device_session')
}
if (await tokenMintedBeforeMemberChange(env.SANDBOX_DB, caller)) {
// ORG-TOKEN-STALE: minted before this member's role changed; the desktop re-mints and retries.
return fail('unauthorized', 401, 'stale_token')
}
if (request.method !== 'GET') {
return fail('not_found', 404)
}
@@ -1419,6 +1446,10 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
if (caller.deviceSessionId) {
return fail('forbidden', 403, 'device_session')
}
if (await tokenMintedBeforeMemberChange(env.SANDBOX_DB, caller)) {
// ORG-TOKEN-STALE: minted before this member's role changed; the desktop re-mints and retries.
return fail('unauthorized', 401, 'stale_token')
}
if (url.pathname === '/v1/github/installations' && request.method === 'GET') {
const rows = await listAccountInstallations(env.SANDBOX_DB, caller.accountId)
return json({
@@ -1493,6 +1524,10 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
if (caller.deviceSessionId) {
return fail('forbidden', 403, 'device_session')
}
if (await tokenMintedBeforeMemberChange(env.SANDBOX_DB, caller)) {
// ORG-TOKEN-STALE: minted before this member's role changed; the desktop re-mints and retries.
return fail('unauthorized', 401, 'stale_token')
}
const secrets = await secretsDeps(env)
if (!secrets) {
return fail('not_configured', 503)
@@ -1639,6 +1674,10 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
if (request.method !== 'POST') {
return fail('not_found', 404)
}
if (await tokenMintedBeforeMemberChange(life.db, caller)) {
// ORG-TOKEN-STALE: never create (and bill) with a token minted before a role change.
return fail('unauthorized', 401, 'stale_token')
}
await consumeCreateToken(life.db, life.config.guardrails!, caller.accountId, life.now())
const raw = await readJson(request)
const body = CreateRequestSchema.safeParse(raw)
@@ -2257,6 +2296,9 @@ function errorResponse(error: unknown): Response {
if (error instanceof SecretError) {
return fail(error.code, error.status, error.reason)
}
if (error instanceof OrgMismatchError) {
return orgMismatchResponse()
}
if (error instanceof GuardrailError) {
if (error.code === 'concurrency_limit') {
return fail('entitlement_denied', 403, 'concurrency_limit')
@@ -34,6 +34,20 @@ export async function recordMemberChange(
* (`iat` has second precision, so a token from the same second counts as stale). Device tokens
* and personal accounts never predate a change: their identity is not pushed by auth.
*/
/** Orca auth issues sandbox tokens with `iat` 30 s in the past (clock-skew allowance). */
export const AUTH_IAT_BACKDATE_MS = 30_000
/**
* ORG-TOKEN-STALE routes (create, secrets, GitHub): same rule, allowing for auth's 30 s iat backdate, so a
* token the desktop re-mints just after the change counts as fresh and its one retry succeeds.
*/
export async function tokenMintedBeforeMemberChange(db: D1Database, caller: SandboxCaller): Promise<boolean> {
if (caller.tokenIssuedAt === undefined) {
return false
}
return tokenPredatesMemberChange(db, { ...caller, tokenIssuedAt: caller.tokenIssuedAt + AUTH_IAT_BACKDATE_MS })
}
export async function tokenPredatesMemberChange(db: D1Database, caller: SandboxCaller): Promise<boolean> {
if (caller.tokenIssuedAt === undefined || caller.deviceSessionId || !caller.orgId) {
return false
@@ -0,0 +1,83 @@
// ORG-TOKEN-STALE (Worker backstop): the desktop names the team it is acting for (`x-orca-org`), and a token
// minted for another team is refused with 409 org_mismatch instead of acting on the wrong team. A token minted
// before a role change is refused (401 stale_token) where it would create, read secrets, or reach GitHub.
import { describe, expect, it } from 'vitest'
import { AUTH_IAT_BACKDATE_MS, recordMemberChange } from '../src/member-changes'
import { createHarness } from './harness'
async function setup() {
const h = await createHarness({ SECRETS_KEK: Buffer.alloc(32, 7).toString('base64') })
const teamToken = await h.token('alice', 'acme', 'sandbox', { org_role: 'admin' })
const personalToken = await h.token('alice')
return { h, teamToken, personalToken }
}
describe('x-orca-org: a token for another team is refused', () => {
it('no header: no check (older desktops)', async () => {
const { h, teamToken } = await setup()
const res = await h.call('GET', '/v1/sandboxes', { bearer: teamToken })
expect(res.status).toBe(200)
})
it('header matching the token’s org passes', async () => {
const { h, teamToken } = await setup()
const res = await h.call('GET', '/v1/sandboxes', { bearer: teamToken, headers: { 'x-orca-org': 'acme' } })
expect(res.status).toBe(200)
})
it('header naming another team: 409 org_mismatch, nothing acted on', async () => {
const { h, teamToken } = await setup()
const list = await h.call('GET', '/v1/sandboxes', { bearer: teamToken, headers: { 'x-orca-org': 'other-team' } })
expect(list.status).toBe(409)
expect(list.body).toMatchObject({ error: 'org_mismatch', reason: 'org_mismatch' })
const create = await h.call('POST', '/v1/sandboxes', {
bearer: teamToken,
headers: { 'x-orca-org': 'other-team' },
body: { workspaceId: 'ws-1', machineSize: 'standard' }
})
expect(create.status).toBe(409)
const after = await h.call('GET', '/v1/sandboxes', { bearer: teamToken, headers: { 'x-orca-org': 'acme' } })
expect(after.body.sandboxes).toHaveLength(0)
})
it('`user` (personal) with a team token, or a team header with a personal token: 409', async () => {
const { h, teamToken, personalToken } = await setup()
expect((await h.call('GET', '/v1/sandboxes', { bearer: teamToken, headers: { 'x-orca-org': 'user' } })).status).toBe(409)
expect((await h.call('GET', '/v1/sandboxes', { bearer: personalToken, headers: { 'x-orca-org': 'acme' } })).status).toBe(409)
expect((await h.call('GET', '/v1/sandboxes', { bearer: personalToken, headers: { 'x-orca-org': 'user' } })).status).toBe(200)
})
it('secrets and GitHub routes are covered too', async () => {
const { h, teamToken } = await setup()
const headers = { 'x-orca-org': 'other-team' }
expect((await h.call('GET', '/v1/secrets', { bearer: teamToken, headers })).status).toBe(409)
expect((await h.call('GET', '/v1/github/repositories', { bearer: teamToken, headers })).status).toBe(409)
})
})
describe('a token minted before a role change: 401 stale_token where it matters', () => {
it('create, secrets and GitHub refuse it; a fresh token works', async () => {
const { h, teamToken } = await setup()
// Minted well before the change, even allowing for auth's 30 s iat backdate.
h.advance(AUTH_IAT_BACKDATE_MS + 2_000)
await recordMemberChange(h.env.SANDBOX_DB, 'org:acme', 'alice', 'role', h.now())
const create = await h.call('POST', '/v1/sandboxes', { bearer: teamToken, body: { workspaceId: 'ws-2', machineSize: 'standard' } })
expect(create.status).toBe(401)
expect(create.body).toMatchObject({ reason: 'stale_token' })
expect((await h.call('GET', '/v1/secrets', { bearer: teamToken })).body).toMatchObject({ reason: 'stale_token' })
expect((await h.call('GET', '/v1/github/repositories', { bearer: teamToken })).body).toMatchObject({ reason: 'stale_token' })
h.advance(2_000)
const fresh = await h.token('alice', 'acme', 'sandbox', { org_role: 'member' })
expect((await h.call('GET', '/v1/secrets', { bearer: fresh })).body.reason).not.toBe('stale_token')
})
it('a token re-minted just after the change (iat backdated 30 s by auth) is fresh, so the retry works', async () => {
const { h } = await setup()
// Auth stamps iat = mint time - 30 s: a token minted 5 s after the change carries iat 25 s before it.
const reminted = await h.token('alice', 'acme', 'sandbox', { org_role: 'member' })
h.advance(25_000)
await recordMemberChange(h.env.SANDBOX_DB, 'org:acme', 'alice', 'role', h.now())
const res = await h.call('GET', '/v1/secrets', { bearer: reminted })
expect(res.body?.reason).not.toBe('stale_token')
})
})