Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

114 lines
4.7 KiB
TypeScript

import { safeCompare } from '@sim/security/compare'
import { sha256Base64Url, sha256Hex } from '@sim/security/hash'
import { getRedisClient } from '@/lib/core/config/redis'
/**
* Short-lived storage for CLI authorization approvals.
*
* The device-flow rendezvous: the CLI polls by `requestId` while the user
* approves in a browser. The record is created only when a signed-in user
* approves, so an abandoned flow leaves nothing behind — nothing to expire, and
* nothing to poll until the click happens.
*
* Redis rather than Postgres: the records are ephemeral and self-expiring, so a
* table would need a migration plus a sweeper for rows that are garbage two
* minutes after they're written. It holds no credential — the API key is minted
* at poll time, so a Redis dump yields nothing redeemable.
*/
const APPROVAL_TTL_MS = 120_000
// The mint lock must outlive the approval it guards: if a mint succeeds but the
// cleanup delete fails, the still-held lock is what stops a later poll from
// re-minting the now-orphaned key. Matching the approval TTL means the record
// and the lock expire together, so there is never a window where the approval
// is redeemable but the lock is gone.
const MINT_LOCK_TTL_MS = APPROVAL_TTL_MS
interface ApprovalRecord {
/** BASE64URL(SHA256(pollSecret)) — the CLI proves possession of the secret at poll time. */
challenge: string
/** Always taken from the approving user's session, never from a request body. */
userId: string
createdAt: number
}
export type PollResult = { status: 'pending' } | { status: 'approved'; userId: string }
function requireRedis() {
const redis = getRedisClient()
if (!redis) {
throw new Error('CLI authentication requires Redis. Set REDIS_URL to enable it.')
}
return redis
}
/**
* `requestId` is the rendezvous handle the CLI puts in the browser URL, so it is
* semi-public (OAuth's `user_code`, not its `device_code`). Hashing it as the
* key keeps raw ids out of a Redis dump; the actual secret is the pollSecret,
* never stored in the clear.
*/
function approvalKey(requestId: string): string {
return `cli:auth:req:${sha256Hex(requestId)}`
}
/** Guards the mint step so two concurrent valid polls can't both mint a key. */
function mintLockKey(requestId: string): string {
return `cli:auth:mint:${sha256Hex(requestId)}`
}
/** Records a signed-in user's approval. Overwrites any prior approval for the same request. */
export async function createApproval(
userId: string,
requestId: string,
challenge: string
): Promise<void> {
const redis = requireRedis()
const record: ApprovalRecord = { challenge, userId, createdAt: Date.now() }
await redis.set(approvalKey(requestId), JSON.stringify(record), 'PX', APPROVAL_TTL_MS)
}
/**
* Polls for an approval and reserves the mint slot, without consuming the
* approval itself.
*
* `pending` covers every non-terminal state — not yet approved, expired, a wrong
* `pollSecret`, or another poll already minting — so the endpoint is not an
* oracle for which requests exist or whether a secret is close. The approving
* user is returned only to a caller that proves possession of the secret.
*
* Verifies *before* reserving: an attacker who knows the semi-public `requestId`
* but not the secret can never touch the record. The reservation is an atomic
* `SET NX` lock (not a delete) so two concurrent valid polls can't both mint,
* while the approval survives a *failed* mint — the caller releases the lock and
* a later poll retries, instead of forcing a fresh browser approval. Its short
* TTL frees the slot if the minting caller dies. Callers MUST finish with
* {@link completeApproval} on success or {@link releaseMint} on failure.
*/
export async function pollApproval(requestId: string, pollSecret: string): Promise<PollResult> {
const redis = requireRedis()
const raw = await redis.get(approvalKey(requestId))
if (!raw) return { status: 'pending' }
const record = JSON.parse(raw) as ApprovalRecord
if (!safeCompare(sha256Base64Url(pollSecret), record.challenge)) return { status: 'pending' }
const reserved = await redis.set(mintLockKey(requestId), '1', 'PX', MINT_LOCK_TTL_MS, 'NX')
if (reserved !== 'OK') return { status: 'pending' }
return { status: 'approved', userId: record.userId }
}
/** Consumes the approval after a successful mint — single-use from here on. */
export async function completeApproval(requestId: string): Promise<void> {
const redis = requireRedis()
await redis.del(approvalKey(requestId), mintLockKey(requestId))
}
/** Releases the mint reservation after a failed mint so a later poll can retry. */
export async function releaseMint(requestId: string): Promise<void> {
const redis = requireRedis()
await redis.del(mintLockKey(requestId))
}