T-HUB/apps/api/src/connections/pending.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

57 lines
3.4 KiB
TypeScript

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<string, string>;
reconnect: boolean;
expiresAt: number;
browserHash: string;
}
export interface PendingStore {
put(state: string, pending: PendingAuthorization): Promise<void>;
consume(state: string, browserBinding: string): Promise<PendingAuthorization>;
}
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<ControlRedis, "get" | "set" | "eval"> = new RespControlRedis(process.env.REDIS_URL ?? "redis://localhost:6379"),
private readonly encryption: Vault = vault,
) {}
async put(state: string, pending: PendingAuthorization): Promise<void> {
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<PendingAuthorization> {
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;
}
}