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
114 lines
4.7 KiB
TypeScript
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))
|
|
}
|