mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user