import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { BadRequestException } from "@nestjs/common"; import type { AuthorizationRequest } from "@tactical-media-broadcast-hub/connector-sdk"; import type { SessionPayload } from "../auth/session.js"; import { RespControlRedis, type ControlRedis } from "../media-control/redis.js"; import { vault, type Vault } from "../vault/vault.js"; export const AUTH_TTL_MS = 10 * 60_000; export const stateCookieName = (state: string): string => `ap_connect_${state}`; export const validState = (state: string): boolean => /^[A-Za-z0-9_-]{43}$/.test(state); export interface PendingAuthorization { session: SessionPayload; provider: string; request: AuthorizationRequest; secrets: Record; reconnect: boolean; expiresAt: number; browserHash: string; } export interface PendingStore { put(state: string, pending: PendingAuthorization): Promise; consume(state: string, browserBinding: string): Promise; } export function browserHash(binding: string): string { return createHash("sha256").update(binding).digest("hex"); } export const randomState = (): string => randomBytes(32).toString("base64url"); /** GET + compare-and-delete makes callbacks one-use across API replicas. */ export class RedisPendingStore implements PendingStore { constructor( private readonly redis: Pick = new RespControlRedis(process.env.REDIS_URL ?? "redis://localhost:6379"), private readonly encryption: Vault = vault, ) {} async put(state: string, pending: PendingAuthorization): Promise { const sealed = await this.encryption.seal(pending.session.workspaceId, state, Buffer.from(JSON.stringify(pending))); const value = JSON.stringify({ workspaceId: pending.session.workspaceId, ciphertext: sealed.ciphertext.toString("base64") }); if (!await this.redis.set(`oauth:pending:${state}`, value, { nx: true, pxMs: AUTH_TTL_MS })) throw new Error("state collision"); } async consume(state: string, binding: string): Promise { if (!validState(state) || binding.length > 200) throw new BadRequestException("Invalid authorization state"); const key = `oauth:pending:${state}`; const raw = await this.redis.get(key); if (!raw) throw new BadRequestException("Authorization expired or already used"); const envelope = JSON.parse(raw) as { workspaceId: string; ciphertext: string }; const opened = await this.encryption.open(envelope.workspaceId, state, { keyVersion: 1, ciphertext: Buffer.from(envelope.ciphertext, "base64") }); const pending = JSON.parse(opened.toString()) as PendingAuthorization; const actual = Buffer.from(browserHash(binding)); const expected = Buffer.from(pending.browserHash); if (pending.expiresAt <= Date.now() || pending.session.workspaceId !== envelope.workspaceId || pending.request.workspaceId !== envelope.workspaceId || !binding.startsWith(`${envelope.workspaceId}.`) || actual.length !== expected.length || !timingSafeEqual(actual, expected)) { throw new BadRequestException("Authorization browser or workspace mismatch"); } const consumed = await this.redis.eval("if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('DEL', KEYS[1]); return 1 end; return 0", [key], [raw]); if (consumed !== 1) throw new BadRequestException("Authorization already used"); return pending; } }