mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
177 lines
6.3 KiB
TypeScript
177 lines
6.3 KiB
TypeScript
// Caller authentication.
|
|
// - Desktop: a short-lived ES256 JWT minted by Orca Cloud auth for the signed-in user
|
|
// (aud = BILLING_JWT_AUDIENCE, purpose = 'billing'), verified against the auth JWKS — the same
|
|
// pattern the relay uses, so billing never sees the user's refresh or access token.
|
|
// - Control plane: a static bearer secret (rotation: comma-separated), compared in constant time.
|
|
import { createLocalJWKSet, createRemoteJWKSet, jwtVerify, type JSONWebKeySet, type JWTVerifyGetKey } from 'jose'
|
|
import { z } from 'zod'
|
|
import type { Env } from './env'
|
|
import { constantTimeEqual } from './webhook-signature'
|
|
|
|
const ID = z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/)
|
|
|
|
const ClaimsSchema = z.object({
|
|
sub: ID,
|
|
org: ID.optional(),
|
|
// Role in `org`. Only owners and admins may buy or manage the org's plan.
|
|
orgRole: z.enum(['owner', 'admin', 'member']).optional(),
|
|
email: z.string().email().max(320).optional(),
|
|
purpose: z.literal('billing')
|
|
})
|
|
|
|
export type BillingCaller = {
|
|
userId: string
|
|
accountId: string
|
|
canManage: boolean
|
|
email?: string
|
|
}
|
|
|
|
const keySets = new Map<string, JWTVerifyGetKey>()
|
|
|
|
function keysFor(env: Env): JWTVerifyGetKey {
|
|
const source = env.BILLING_JWKS_URL ?? `inline:${env.BILLING_JWKS ?? ''}`
|
|
let keys = keySets.get(source)
|
|
if (!keys) {
|
|
keys = env.BILLING_JWKS_URL
|
|
? createRemoteJWKSet(new URL(env.BILLING_JWKS_URL))
|
|
: createLocalJWKSet(JSON.parse(env.BILLING_JWKS ?? '{"keys":[]}') as JSONWebKeySet)
|
|
keySets.set(source, keys)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
export const DEV_ISSUER = 'orca-billing-dev-test'
|
|
let devKeys: { source: string; keys: JWTVerifyGetKey } | null = null
|
|
|
|
/** Dev operator keys; never honored when BILLING_ENV is 'prod'. */
|
|
function devKeysFor(env: Env): JWTVerifyGetKey | null {
|
|
if (env.BILLING_ENV === 'prod' || !env.BILLING_DEV_JWKS) {
|
|
return null
|
|
}
|
|
if (devKeys?.source !== env.BILLING_DEV_JWKS) {
|
|
devKeys = {
|
|
source: env.BILLING_DEV_JWKS,
|
|
keys: createLocalJWKSet(JSON.parse(env.BILLING_DEV_JWKS) as JSONWebKeySet)
|
|
}
|
|
}
|
|
return devKeys.keys
|
|
}
|
|
|
|
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 {
|
|
if (env.BILLING_ENV === 'prod' || !env.BILLING_STAGING_JWKS_URL || !env.BILLING_STAGING_AUTH_ISSUER) {
|
|
return null
|
|
}
|
|
if (stagingKeys?.source !== env.BILLING_STAGING_JWKS_URL) {
|
|
stagingKeys = {
|
|
source: env.BILLING_STAGING_JWKS_URL,
|
|
keys: createRemoteJWKSet(new URL(env.BILLING_STAGING_JWKS_URL))
|
|
}
|
|
}
|
|
return { keys: stagingKeys.keys, issuer: env.BILLING_STAGING_AUTH_ISSUER }
|
|
}
|
|
|
|
export function readBearer(value: string | null): string | null {
|
|
return /^Bearer ([^\s]+)$/.exec(value ?? '')?.[1] ?? null
|
|
}
|
|
|
|
/** An org pays for its members; a user without an org is their own account. */
|
|
export function accountIdFor(claims: { sub: string; org?: string }): string {
|
|
return claims.org ? `org:${claims.org}` : `user:${claims.sub}`
|
|
}
|
|
|
|
// Billing tokens re-resolve org and role from auth's org_memberships rows when minted and live 5
|
|
// minutes. Actions that spend money or change billing take only tokens minted in the last few
|
|
// minutes, so a demotion or removal stops them within that window (auth backdates iat by 30 s).
|
|
export const ADMIN_ACTION_MAX_TOKEN_AGE = '6m'
|
|
|
|
export async function authenticateDesktop(
|
|
env: Env,
|
|
request: Request,
|
|
options: { maxTokenAge?: string } = {}
|
|
): Promise<BillingCaller | null> {
|
|
const token = readBearer(request.headers.get('authorization'))
|
|
if (!token) {
|
|
return null
|
|
}
|
|
const verify = (keys: JWTVerifyGetKey, issuer: string | undefined) =>
|
|
jwtVerify(token, keys, {
|
|
audience: env.BILLING_JWT_AUDIENCE,
|
|
algorithms: ['ES256'],
|
|
// Billing tokens are minted per call; refuse anything long-lived.
|
|
maxTokenAge: options.maxTokenAge ?? '15m',
|
|
requiredClaims: ['exp', 'iat'],
|
|
...(issuer ? { issuer } : {})
|
|
})
|
|
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)
|
|
]
|
|
const staging = stagingKeysFor(env)
|
|
if (staging) {
|
|
attempts.push(() => verify(staging.keys, staging.issuer))
|
|
}
|
|
const dev = devKeysFor(env)
|
|
if (dev) {
|
|
attempts.push(() => verify(dev, DEV_ISSUER))
|
|
}
|
|
let verified: Awaited<ReturnType<typeof verify>> | undefined
|
|
let lastError: unknown
|
|
for (const attempt of attempts) {
|
|
try {
|
|
verified = await attempt()
|
|
break
|
|
} catch (error) {
|
|
lastError = error
|
|
}
|
|
}
|
|
if (!verified) {
|
|
throw lastError
|
|
}
|
|
const claims = ClaimsSchema.parse(verified.payload)
|
|
return {
|
|
userId: claims.sub,
|
|
accountId: accountIdFor(claims),
|
|
canManage: !claims.org || claims.orgRole === 'owner' || claims.orgRole === 'admin',
|
|
email: claims.email
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function digest(value: string): Promise<string> {
|
|
const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))
|
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
}
|
|
|
|
export function authenticateControlPlane(env: Env, request: Request): Promise<boolean> {
|
|
return authenticateSharedSecret(env.CONTROL_PLANE_TOKEN, request)
|
|
}
|
|
|
|
/** Orca Cloud auth -> billing membership pushes; a separate secret from the control plane's. */
|
|
export function authenticateMembershipSync(env: Env, request: Request): Promise<boolean> {
|
|
return authenticateSharedSecret(env.MEMBERSHIP_SYNC_TOKEN, request)
|
|
}
|
|
|
|
async function authenticateSharedSecret(secret: string | undefined, request: Request): Promise<boolean> {
|
|
const token = readBearer(request.headers.get('authorization'))
|
|
const accepted = (secret ?? '')
|
|
.split(',')
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length >= 32)
|
|
if (!token || accepted.length === 0) {
|
|
return false
|
|
}
|
|
// Hash both sides so the comparison is fixed-length and constant time.
|
|
const presented = await digest(token)
|
|
let matched = false
|
|
for (const candidate of accepted) {
|
|
matched = constantTimeEqual(presented, await digest(candidate)) || matched
|
|
}
|
|
return matched
|
|
}
|