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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
76
apps/api/src/connections/connection-store.test.ts
Normal file
76
apps/api/src/connections/connection-store.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { PoolClient } from "pg";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
import type { SessionPayload } from "../auth/session.js";
|
||||
import { Vault } from "../vault/vault.js";
|
||||
import { PgConnectionStore } from "./connection-store.js";
|
||||
|
||||
const session: SessionPayload = { workspaceId: randomUUID(), userId: randomUUID(), role: "Owner", authzEpoch: 3, issuedAt: 0, expiresAt: Math.floor(Date.now() / 1000) + 600 };
|
||||
const connection: ConnectorConnection = { id: randomUUID(), workspaceId: session.workspaceId, provider: "mastodon", channelId: "remote-account", credentialId: { kind: "credential-ref", id: "opaque" }, fields: { instanceUrl: "https://social.example" }, createdAt: new Date() };
|
||||
|
||||
function fixture(allowed = true, existingCredential = false) {
|
||||
const queries: Array<{ sql: string; params: unknown[] }> = [];
|
||||
const credentialId = randomUUID();
|
||||
const client = {
|
||||
async query(sql: string, params: unknown[] = []) {
|
||||
queries.push({ sql, params });
|
||||
if (sql.includes("FROM memberships")) return { rowCount: allowed ? 1 : 0, rows: [] };
|
||||
if (sql.startsWith("UPDATE scheduled_publications")) return { rowCount: 1, rows: [{ id: connection.id, draft_id: connection.id }] };
|
||||
if (sql.includes("SELECT id FROM credentials")) return { rows: existingCredential ? [{ id: credentialId }] : [] };
|
||||
if (sql.includes("SELECT c.*, cr.ciphertext")) return { rows: [{ id: connection.id, provider: connection.provider, channel_id: connection.channelId, fields: connection.fields, created_at: connection.createdAt, ciphertext: null, key_version: 1 }] };
|
||||
return { rowCount: 1, rows: [{ id: connection.id }] };
|
||||
},
|
||||
} as unknown as PoolClient;
|
||||
const store = new PgConnectionStore(async (workspaceId, callback) => {
|
||||
expect(workspaceId).toBe(session.workspaceId);
|
||||
return callback(client);
|
||||
});
|
||||
return { store, queries, credentialId };
|
||||
}
|
||||
|
||||
describe("Postgres connection writes", () => {
|
||||
it("saves only a vault envelope and identifier-only audit in the same tenant transaction", async () => {
|
||||
const { store, queries } = fixture();
|
||||
const sealed = await new Vault().seal(session.workspaceId, connection.id, Buffer.from('{"access_token":"never-in-db-plaintext"}'));
|
||||
await store.save(session, connection, { provider: connection.provider, remoteId: "remote-account", handle: "@alice" }, sealed, false);
|
||||
const credential = queries.find(({ sql }) => sql.startsWith("INSERT INTO credentials"));
|
||||
expect(credential?.params).toContain(sealed.ciphertext);
|
||||
expect(queries[0]?.sql).toContain("FOR UPDATE");
|
||||
expect(JSON.stringify(queries)).not.toContain("never-in-db-plaintext");
|
||||
const audit = queries.find(({ sql }) => sql.includes("audit_append"));
|
||||
expect(audit?.params.at(-1)).toEqual({ provider: connection.provider });
|
||||
});
|
||||
it("rotates the credential row in place and blocks a revoked authorization before writes", async () => {
|
||||
const { store, queries, credentialId } = fixture(true, true);
|
||||
const sealed = await new Vault().seal(session.workspaceId, connection.id, Buffer.from("rotated-secret"));
|
||||
await store.save(session, connection, { provider: connection.provider, remoteId: "remote-account", handle: "@alice" }, sealed, true);
|
||||
expect(queries.find(({ sql }) => sql.startsWith("UPDATE credentials"))?.params[0]).toBe(credentialId);
|
||||
expect(queries.some(({ sql }) => sql.startsWith("INSERT INTO connections"))).toBe(false);
|
||||
const denied = fixture(false);
|
||||
await expect(denied.store.save(session, connection, { provider: connection.provider, remoteId: "remote-account", handle: "@alice" }, sealed, true)).rejects.toThrow();
|
||||
expect(denied.queries.some(({ sql }) => /^(INSERT|UPDATE)/.test(sql))).toBe(false);
|
||||
});
|
||||
it("locks and revokes locally before provider I/O while preserving publishing history", async () => {
|
||||
const { store, queries } = fixture();
|
||||
await store.remove(session, connection.id, async (stored) => {
|
||||
expect(stored.connection.id).toBe(connection.id);
|
||||
expect(queries.some(({ sql }) => sql.includes("FOR UPDATE"))).toBe(true);
|
||||
expect(queries.some(({ sql }) => sql.startsWith("DELETE"))).toBe(false);
|
||||
});
|
||||
expect(queries.filter(({ sql }) => sql.startsWith("DELETE"))).toHaveLength(0);
|
||||
expect(queries.some(({ sql }) => sql.startsWith("UPDATE credentials SET revoked_at"))).toBe(true);
|
||||
expect(queries.some(({ sql }) => sql.startsWith("UPDATE connections SET disconnected_at"))).toBe(true);
|
||||
expect(queries.some(({ sql }) => sql.includes("needs_reauthorization"))).toBe(true);
|
||||
expect(queries.some(({ sql }) => sql.startsWith("UPDATE publishing_attempts") && sql.includes("abandoned"))).toBe(true);
|
||||
expect(queries.some(({ sql }) => sql.startsWith("UPDATE drafts") && sql.includes("approved_revision = revision"))).toBe(true);
|
||||
expect(queries.at(-1)?.sql).toContain("audit_append");
|
||||
});
|
||||
it("retains revoked local evidence and audits provider revocation failure", async () => {
|
||||
const { store, queries } = fixture();
|
||||
await expect(store.remove(session, connection.id, async () => { throw new Error("provider offline"); })).resolves.toBeUndefined();
|
||||
expect(queries.some(({ sql }) => sql.startsWith("UPDATE credentials SET revoked_at"))).toBe(true);
|
||||
expect(queries.at(-1)?.sql).toContain("connection.revoke_failed");
|
||||
expect(queries.some(({ sql }) => sql.startsWith("DELETE"))).toBe(false);
|
||||
});
|
||||
});
|
||||
117
apps/api/src/connections/connection-store.ts
Normal file
117
apps/api/src/connections/connection-store.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { NotFoundException, ForbiddenException } from "@nestjs/common";
|
||||
import type { ConnectorConnection, NormalizedIdentity } from "@tactical-media-broadcast-hub/core";
|
||||
import type { SessionPayload } from "../auth/session.js";
|
||||
import type { PoolClient } from "pg";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import type { SealedCredential } from "../vault/vault.js";
|
||||
import type { ConnectionSummary } from "./connections.service.js";
|
||||
import { scrub } from "../audit/audit.service.js";
|
||||
|
||||
export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null }
|
||||
export interface ConnectionStore {
|
||||
list(workspaceId: string): Promise<ConnectionSummary[]>;
|
||||
get(workspaceId: string, id: string): Promise<StoredConnection>;
|
||||
assertAuthorized(session: SessionPayload): Promise<void>;
|
||||
save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void>;
|
||||
rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>;
|
||||
remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>;
|
||||
}
|
||||
|
||||
export class PgConnectionStore implements ConnectionStore {
|
||||
constructor(private readonly transaction: typeof withTenant = withTenant) {}
|
||||
async assertAuthorized(session: SessionPayload): Promise<void> {
|
||||
if (session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException("Session expired");
|
||||
const result = await this.transaction(session.workspaceId, (client) => client.query(
|
||||
`SELECT 1 FROM workspaces w JOIN memberships m ON m.workspace_id = w.id
|
||||
JOIN role_permissions rp ON rp.role = m.role
|
||||
WHERE w.id = $1 AND w.authz_epoch = $2 AND m.user_id = $3 AND m.status = 'active' AND rp.permission = 'connection.manage'`,
|
||||
[session.workspaceId, session.authzEpoch, session.userId],
|
||||
));
|
||||
if (result.rowCount !== 1) throw new ForbiddenException("Connection authorization no longer valid");
|
||||
}
|
||||
async list(workspaceId: string): Promise<ConnectionSummary[]> {
|
||||
const res = await this.transaction(workspaceId, (client) => client.query(
|
||||
`SELECT c.*, cr.ciphertext, coalesce(cr.rotated_at, cr.created_at) AS rotated_at,
|
||||
(SELECT array_agg(p.published_at ORDER BY p.published_at DESC) FROM publications p
|
||||
WHERE p.connection_id = c.id AND p.published_at > now() - interval '24 hours') AS recent
|
||||
FROM connections c LEFT JOIN LATERAL (SELECT * FROM credentials WHERE connection_id = c.id AND kind = 'connector-oauth' AND revoked_at IS NULL LIMIT 1) cr ON true WHERE c.disconnected_at IS NULL ORDER BY c.created_at`,
|
||||
));
|
||||
return res.rows.map((row: { id: string; provider: string; channel_id: string; fields: Record<string, unknown>; ciphertext: Buffer | null; created_at: Date; rotated_at: Date | null; recent: Date[] | null }) => ({
|
||||
id: row.id, provider: row.provider, channelId: row.channel_id, fields: scrub(row.fields) ?? {},
|
||||
health: row.ciphertext ? "unavailable" : "needs_credentials", credentialRotatedAt: row.rotated_at?.toISOString() ?? null,
|
||||
recentPublishes: (row.recent ?? []).map((date) => date.toISOString()), createdAt: row.created_at.toISOString(),
|
||||
}));
|
||||
}
|
||||
async get(workspaceId: string, id: string): Promise<StoredConnection> {
|
||||
return this.transaction(workspaceId, (client) => this.read(client, workspaceId, id));
|
||||
}
|
||||
private async read(client: PoolClient, workspaceId: string, id: string): Promise<StoredConnection> {
|
||||
const result = await client.query(
|
||||
`SELECT c.*, cr.ciphertext, cr.key_version FROM connections c LEFT JOIN LATERAL
|
||||
(SELECT * FROM credentials WHERE connection_id = c.id AND kind = 'connector-oauth' AND revoked_at IS NULL LIMIT 1) cr ON true WHERE c.id = $1`, [id],
|
||||
);
|
||||
const row = result.rows[0] as { id: string; provider: string; channel_id: string; fields: Record<string, string>; created_at: Date; ciphertext: Buffer | null; key_version: number } | undefined;
|
||||
if (!row) throw new NotFoundException("Connection not found");
|
||||
return {
|
||||
connection: { id: row.id, workspaceId, provider: row.provider, channelId: row.channel_id, fields: row.fields, createdAt: row.created_at, credentialId: { kind: "credential-ref", id: row.id } },
|
||||
credential: row.ciphertext ? { ciphertext: row.ciphertext, keyVersion: row.key_version } : null,
|
||||
};
|
||||
}
|
||||
async save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void> {
|
||||
await this.transaction(session.workspaceId, async (client) => {
|
||||
// Serialize with membership revocation; re-check at commit, not only before provider I/O.
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
|
||||
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'connection.manage'`, [session.userId, session.authzEpoch]);
|
||||
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
||||
const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId };
|
||||
if (reconnect) {
|
||||
const updated = await client.query("UPDATE connections SET fields = $2, disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id", [connection.id, fields, connection.provider]);
|
||||
if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization");
|
||||
} else {
|
||||
await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]);
|
||||
}
|
||||
const existing = await client.query("SELECT id FROM credentials WHERE connection_id = $1 AND kind = 'connector-oauth' FOR UPDATE", [connection.id]);
|
||||
if (existing.rows[0]) {
|
||||
await client.query("UPDATE credentials SET ciphertext = $2, key_version = $3, rotated_at = now(), revoked_at = NULL WHERE id = $1", [existing.rows[0].id, credential.ciphertext, credential.keyVersion]);
|
||||
} else {
|
||||
await client.query("INSERT INTO credentials (workspace_id, connection_id, kind, ciphertext, key_version) VALUES ($1,$2,'connector-oauth',$3,$4)", [session.workspaceId, connection.id, credential.ciphertext, credential.keyVersion]);
|
||||
}
|
||||
await client.query("SELECT audit_append($1,$2,$3,$4,'connection',$5,$6)", [session.workspaceId, session.userId, session.role, reconnect ? "connection.reconnect" : "connection.connect", connection.id, { provider: connection.provider }]);
|
||||
});
|
||||
}
|
||||
async remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void> {
|
||||
let stored: StoredConnection | undefined;
|
||||
await this.transaction(session.workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
|
||||
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'connection.manage'`, [session.userId, session.authzEpoch]);
|
||||
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
|
||||
const locked = await client.query("SELECT id FROM connections WHERE id = $1 FOR UPDATE", [id]);
|
||||
if (!locked.rowCount) throw new NotFoundException("Connection not found");
|
||||
stored = await this.read(client, session.workspaceId, id);
|
||||
await client.query("UPDATE credentials SET revoked_at = now() WHERE connection_id = $1 AND revoked_at IS NULL", [id]);
|
||||
await client.query("UPDATE connections SET disconnected_at = COALESCE(disconnected_at, now()) WHERE id = $1", [id]);
|
||||
// Undispatched intent pauses. Dispatched or unknown work retains all evidence.
|
||||
const paused = await client.query<{ id: string; draft_id: string }>(`UPDATE scheduled_publications s SET state = 'needs_reauthorization', error_code = 'CREDENTIAL_CHANGED'
|
||||
WHERE connection_id = $1 AND state IN ('pending', 'in_flight') AND NOT EXISTS
|
||||
(SELECT 1 FROM publishing_attempts a WHERE a.schedule_id = s.id AND (a.status = 'unknown' OR (a.dispatch_started_at IS NOT NULL AND a.status NOT IN ('completed', 'failed'))))
|
||||
RETURNING s.id, s.draft_id`, [id]);
|
||||
const scheduleIds = paused.rows.map((row) => row.id);
|
||||
const draftIds = paused.rows.map((row) => row.draft_id);
|
||||
await client.query("UPDATE publishing_attempts SET status = 'abandoned', owner_token = NULL, lease_until = NULL, error_code = 'CREDENTIAL_CHANGED' WHERE schedule_id = ANY($1::uuid[]) AND status IN ('ready', 'in_progress')", [scheduleIds]);
|
||||
await client.query("UPDATE drafts SET state = CASE WHEN approved_revision = revision AND approved_hash IS NOT NULL THEN 'approved' ELSE 'editing' END, updated_at = now() WHERE id = ANY($1::uuid[]) AND state = 'scheduled'", [draftIds]);
|
||||
await client.query("SELECT audit_append($1,$2,$3,'connection.remove','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id]);
|
||||
});
|
||||
if (stored) {
|
||||
try { await revoke(stored); }
|
||||
catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); }
|
||||
}
|
||||
}
|
||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||
await this.transaction(workspaceId, async (client) => {
|
||||
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
|
||||
// A health refresh must never overwrite a concurrent explicit reconnect.
|
||||
const updated = await client.query("UPDATE credentials SET ciphertext = $3, key_version = $4, rotated_at = now() WHERE connection_id = $1 AND ciphertext = $2 AND kind = 'connector-oauth' AND revoked_at IS NULL RETURNING id", [id, previous.ciphertext, credential.ciphertext, credential.keyVersion]);
|
||||
if (updated.rowCount) await client.query("SELECT audit_append($1,NULL,'Service','credential.refresh','connection',$2,NULL)", [workspaceId, id]);
|
||||
});
|
||||
}
|
||||
}
|
||||
84
apps/api/src/connections/connections.controller.ts
Normal file
84
apps/api/src/connections/connections.controller.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Body, Controller, Delete, Get, Inject, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
|
||||
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
|
||||
import { ConnectionsService, type ConnectionSummary } from "./connections.service.js";
|
||||
import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js";
|
||||
|
||||
@Controller("connections")
|
||||
export class ConnectionsController {
|
||||
constructor(@Inject(ConnectionsService) private readonly connections: ConnectionsService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission("connection.view")
|
||||
async list(@Req() request: Request & AuthenticatedRequest): Promise<ConnectionSummary[]> {
|
||||
return this.connections.list(requireSession(request).workspaceId);
|
||||
}
|
||||
|
||||
@Get("providers")
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission("connection.view")
|
||||
providers() { return this.connections.providers(); }
|
||||
|
||||
private redirectResponse(response: Response, authorization: { redirectUrl: string; state: string; binding: string }) {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.cookie(stateCookieName(authorization.state), authorization.binding, {
|
||||
httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax",
|
||||
path: "/connections/callback", maxAge: AUTH_TTL_MS,
|
||||
});
|
||||
return { redirectUrl: authorization.redirectUrl };
|
||||
}
|
||||
|
||||
@Post("authorize")
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission("connection.manage")
|
||||
async authorize(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown, @Res({ passthrough: true }) response: Response) {
|
||||
const input = requireObject(body, "authorization");
|
||||
const authorization = await this.connections.authorize(requireSession(request), requireString(input.provider, "provider"), optionalString(input.instanceUrl, "instanceUrl"));
|
||||
return this.redirectResponse(response, authorization);
|
||||
}
|
||||
|
||||
/** Deliberately outside RbacGuard: the provider's browser redirect carries no bearer header. */
|
||||
@Get("callback")
|
||||
async callback(@Req() request: Request, @Query() query: Record<string, unknown>, @Res() response: Response): Promise<void> {
|
||||
const state = typeof query.state === "string" ? query.state : "";
|
||||
const name = validState(state) ? stateCookieName(state) : "";
|
||||
const cookie = name ? (request.headers.cookie ?? "").split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1) : undefined;
|
||||
let binding = "";
|
||||
try { binding = decodeURIComponent(cookie ?? ""); } catch { /* Malformed cookies fail state validation. */ }
|
||||
const location = await this.connections.callback(state, binding, typeof query.code === "string" ? query.code : undefined, typeof query.error === "string" ? query.error : undefined);
|
||||
if (name) response.clearCookie(name, { path: "/connections/callback", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" });
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("Referrer-Policy", "no-referrer");
|
||||
response.redirect(303, location);
|
||||
}
|
||||
|
||||
// Public endpoint spelling is API routing, not provider behavior branching.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@Post("telegram/bot-token")
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission("connection.manage")
|
||||
async botToken(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<{ connected: true; token: string }> {
|
||||
const input = requireObject(body, "bot token");
|
||||
const id = optionalString(input.connectionId, "connectionId");
|
||||
const channelId = optionalString(input.channelId, "channelId");
|
||||
const token = await this.connections.botToken(requireSession(request), requireString(input.token, "token"), channelId ? { channelId } : {}, id ? parseId(id) : undefined);
|
||||
return { connected: true, token };
|
||||
}
|
||||
|
||||
@Post(":connectionId/reconnect")
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission("connection.manage")
|
||||
async reconnect(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string, @Res({ passthrough: true }) response: Response) {
|
||||
return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id)));
|
||||
}
|
||||
|
||||
@Delete(":connectionId")
|
||||
@UseGuards(RbacGuard)
|
||||
@RequirePermission("connection.manage")
|
||||
async remove(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string): Promise<{ removed: true; token: string }> {
|
||||
const token = await this.connections.remove(requireSession(request), parseId(id));
|
||||
return { removed: true, token };
|
||||
}
|
||||
}
|
||||
345
apps/api/src/connections/connections.integration.test.ts
Normal file
345
apps/api/src/connections/connections.integration.test.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import "reflect-metadata";
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { Module, UnauthorizedException, type ExecutionContext, type INestApplication } from "@nestjs/common";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectorConnection, NormalizedIdentity } from "@tactical-media-broadcast-hub/core";
|
||||
import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js";
|
||||
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
|
||||
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
|
||||
import { Vault, type SealedCredential } from "../vault/vault.js";
|
||||
import type { SessionPayload } from "../auth/session.js";
|
||||
import { RbacGuard } from "../rbac/rbac.guard.js";
|
||||
import { logger } from "../telemetry/logger.js";
|
||||
import { ConnectionsController } from "./connections.controller.js";
|
||||
import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js";
|
||||
import type { ConnectionStore, StoredConnection } from "./connection-store.js";
|
||||
import { RedisPendingStore } from "./pending.js";
|
||||
import { MemoryPendingRedis } from "./testing.js";
|
||||
import { injectHttp } from "./http-test-transport.js";
|
||||
|
||||
class MemoryConnections implements ConnectionStore {
|
||||
readonly rows = new Map<string, StoredConnection>();
|
||||
readonly audit: Array<Record<string, string>> = [];
|
||||
authorized = true;
|
||||
async assertAuthorized(session: SessionPayload): Promise<void> {
|
||||
if (!this.authorized || session.workspaceId !== sessionFixture.workspaceId) throw new Error("membership revoked");
|
||||
}
|
||||
async list(workspaceId: string): Promise<ConnectionSummary[]> {
|
||||
return [...this.rows.values()].filter(({ connection }) => connection.workspaceId === workspaceId).map(({ connection, credential }) => ({
|
||||
id: connection.id, provider: connection.provider, channelId: connection.channelId, fields: { ...connection.fields },
|
||||
health: credential ? "unavailable" : "needs_credentials", credentialRotatedAt: null, recentPublishes: [], createdAt: connection.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
async get(workspaceId: string, id: string): Promise<StoredConnection> {
|
||||
const row = this.rows.get(id);
|
||||
if (!row || row.connection.workspaceId !== workspaceId) throw new Error("connection not found");
|
||||
return row;
|
||||
}
|
||||
async save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void> {
|
||||
await this.assertAuthorized(session);
|
||||
if (reconnect && !this.rows.has(connection.id)) throw new Error("connection removed");
|
||||
this.rows.set(connection.id, { connection: { ...connection, fields: { ...connection.fields, remoteId: identity.remoteId, handle: identity.handle } }, credential });
|
||||
this.audit.push({ action: reconnect ? "connection.reconnect" : "connection.connect", provider: connection.provider });
|
||||
}
|
||||
async remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void> {
|
||||
await this.assertAuthorized(session);
|
||||
await revoke(await this.get(session.workspaceId, id));
|
||||
this.rows.delete(id);
|
||||
this.audit.push({ action: "connection.remove" });
|
||||
}
|
||||
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
|
||||
const row = await this.get(workspaceId, id);
|
||||
if (row.credential?.ciphertext.equals(previous.ciphertext)) {
|
||||
row.credential = credential;
|
||||
this.audit.push({ action: "credential.refresh" });
|
||||
}
|
||||
}
|
||||
}
|
||||
const sessionFixture: SessionPayload = {
|
||||
userId: randomUUID(), workspaceId: randomUUID(), role: "Owner", authzEpoch: 1,
|
||||
issuedAt: Math.floor(Date.now() / 1000), expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
@Module({})
|
||||
class TestModule {}
|
||||
|
||||
async function form(request: IncomingMessage): Promise<URLSearchParams> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array));
|
||||
return new URLSearchParams(Buffer.concat(chunks).toString());
|
||||
}
|
||||
function json(response: ServerResponse, value: unknown, status = 200): void {
|
||||
response.writeHead(status, { "content-type": "application/json" }); response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
describe("HTTP OAuth connect with real connectors and mock authorization server", () => {
|
||||
let mock: Server;
|
||||
let origin: string;
|
||||
let apiOrigin: string;
|
||||
let app: INestApplication;
|
||||
const store = new MemoryConnections();
|
||||
const redis = new MemoryPendingRedis();
|
||||
const encryption = new Vault();
|
||||
const activeTokens = new Set<string>();
|
||||
const grants = new Map<string, { challenge: string; redirectUri: string }>();
|
||||
const revokedTokens: string[] = [];
|
||||
const guard = vi.spyOn(RbacGuard.prototype, "canActivate").mockImplementation(async (context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest<{ headers: Record<string, string>; session?: SessionPayload }>();
|
||||
if (request.headers.authorization !== "Bearer test-session") throw new UnauthorizedException();
|
||||
request.session = sessionFixture; return true;
|
||||
});
|
||||
const warn = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
|
||||
const info = vi.spyOn(logger, "info").mockImplementation(() => undefined);
|
||||
let nextAccount = "account-1";
|
||||
let issued = 0;
|
||||
let failExchange = false;
|
||||
let bridgeConfigured = false;
|
||||
const botToken = "12345:bot_token_secret";
|
||||
|
||||
beforeEach(() => {
|
||||
store.rows.clear(); store.audit.length = 0; store.authorized = true;
|
||||
redis.entries.clear(); activeTokens.clear(); grants.clear(); revokedTokens.length = 0;
|
||||
nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false;
|
||||
warn.mockClear(); info.mockClear();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
mock = createServer((request, response) => { void (async () => {
|
||||
const url = new URL(request.url ?? "/", origin);
|
||||
if (url.pathname === "/api/v1/apps") {
|
||||
const body = await form(request);
|
||||
expect(body.get("redirect_uris")).toBe(`${apiOrigin}/connections/callback`);
|
||||
json(response, { client_id: "client-id", client_secret: "registered-client-secret" });
|
||||
} else if (url.pathname === "/oauth/authorize") {
|
||||
const code = randomUUID();
|
||||
grants.set(code, { challenge: url.searchParams.get("code_challenge") ?? "", redirectUri: url.searchParams.get("redirect_uri") ?? "" });
|
||||
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "");
|
||||
redirect.searchParams.set("state", url.searchParams.get("state") ?? "");
|
||||
redirect.searchParams.set("code", code);
|
||||
response.writeHead(302, { location: redirect.toString() }); response.end();
|
||||
} else if (url.pathname === "/oauth/token") {
|
||||
const body = await form(request);
|
||||
const code = body.get("code") ?? "";
|
||||
const grant = grants.get(code);
|
||||
grants.delete(code);
|
||||
if (failExchange || !grant || grant.redirectUri !== body.get("redirect_uri") || grant.challenge !== createHash("sha256").update(body.get("code_verifier") ?? "").digest("base64url") || body.get("client_secret") !== "registered-client-secret") {
|
||||
json(response, { error: "secret echoed: registered-client-secret token-secret-leak" }, 400); return;
|
||||
}
|
||||
const token = `access-token-secret-${++issued}`;
|
||||
activeTokens.add(token);
|
||||
json(response, { access_token: token, refresh_token: `refresh-token-secret-${issued}`, token_type: "Bearer", scope: "read write:statuses write:media", created_at: Date.now() / 1000 });
|
||||
} else if (url.pathname === "/api/v1/accounts/verify_credentials") {
|
||||
const token = (request.headers.authorization ?? "").slice(7);
|
||||
if (!activeTokens.has(token)) { json(response, { error: `revoked ${token}` }, 401); return; }
|
||||
json(response, { id: nextAccount, username: "alice", acct: "alice", display_name: "Alice", avatar: "", url: `${origin}/@alice` });
|
||||
} else if (url.pathname === "/api/v1/oauth-clients/local") {
|
||||
json(response, { client_id: "client-id", client_secret: "registered-client-secret" });
|
||||
} else if (url.pathname === "/api/v1/users/me") {
|
||||
const token = (request.headers.authorization ?? "").slice(7);
|
||||
if (!activeTokens.has(token)) { json(response, { error: "invalid token" }, 401); return; }
|
||||
json(response, { id: 1, username: "alice", account: { id: 1, name: "alice", host: "127.0.0.1", displayName: "Alice" } });
|
||||
} else if (url.pathname === "/api/v1/users/token") {
|
||||
const body = await form(request);
|
||||
if (body.get("grant_type") !== "refresh_token" || body.get("client_secret") !== "registered-client-secret") { json(response, { error: "invalid refresh" }, 401); return; }
|
||||
const token = `access-token-secret-${++issued}`;
|
||||
activeTokens.add(token);
|
||||
json(response, { access_token: token, refresh_token: `refresh-token-secret-${issued}` });
|
||||
} else if (url.pathname === "/api/v1/users/revoke-token") {
|
||||
const token = (request.headers.authorization ?? "").slice(7);
|
||||
revokedTokens.push(token); activeTokens.delete(token); json(response, {});
|
||||
} else if (url.pathname === "/oauth/revoke") {
|
||||
const body = await form(request);
|
||||
const token = body.get("token") ?? "";
|
||||
revokedTokens.push(token); activeTokens.delete(token); json(response, {});
|
||||
} else if (url.pathname.startsWith("/bot")) {
|
||||
if (!url.pathname.startsWith(`/bot${botToken}/`)) { json(response, { ok: false, error_code: 401, description: `unauthorized ${url.pathname}` }); return; }
|
||||
json(response, { ok: true, result: { id: 12345, is_bot: true, username: "test_bot", first_name: "Test bot" } });
|
||||
} else { json(response, { error: "not found" }, 404); }
|
||||
})().catch(() => json(response, { error: "mock failure" }, 500)); });
|
||||
origin = "http://127.0.0.1:4500";
|
||||
const deps: ConnectionDependencies = {
|
||||
renewSession: async () => "replacement-session",
|
||||
store, pending: new RedisPendingStore(redis, encryption), encryption,
|
||||
appUrl: "http://localhost:3001", callbackUrl: "",
|
||||
registry: (credentials) => {
|
||||
const registry = new ConnectorRegistry();
|
||||
registry.register(createMastodonConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000 }));
|
||||
registry.register(createPeerTubeConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000,
|
||||
env: bridgeConfigured ? { PEERTUBE_AUTHORIZE_PATH: "/oauth/authorize", PEERTUBE_TOKEN_PATH: "/oauth/token" } : {},
|
||||
}));
|
||||
const botFetch: typeof fetch = (input, init) => fetch(String(input).replace("https://api.telegram.org", origin), init);
|
||||
registry.register(createTelegramConnector({ credentials, fetchImpl: botFetch, requestsPerSecond: 100_000 }));
|
||||
return registry;
|
||||
},
|
||||
};
|
||||
app = await NestFactory.create({ module: TestModule, controllers: [ConnectionsController], providers: [
|
||||
ConnectionsService, { provide: CONNECTION_DEPENDENCIES, useValue: deps },
|
||||
|
||||
] }, { logger: false });
|
||||
await app.init();
|
||||
apiOrigin = "http://api.local";
|
||||
deps.callbackUrl = `${apiOrigin}/connections/callback`;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
|
||||
warn.mockRestore(); info.mockRestore(); guard.mockRestore();
|
||||
});
|
||||
const mockFetch: typeof globalThis.fetch = (input, init) => injectHttp((request, response) => { mock.emit("request", request, response); }, input, init);
|
||||
const fetch: typeof globalThis.fetch = (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
if (url.origin === origin) return mockFetch(input, init);
|
||||
if (url.origin !== apiOrigin) throw new Error("Unexpected test destination");
|
||||
const adapter = app.getHttpAdapter();
|
||||
return injectHttp((request, response) => { adapter.getInstance().handle(request, response); }, input, init);
|
||||
};
|
||||
async function api(path: string, method = "GET", body?: unknown): Promise<Response> {
|
||||
return fetch(`${apiOrigin}/connections${path}`, { method, headers: { authorization: "Bearer test-session", "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
}
|
||||
async function begin(id?: string, provider = "mastodon"): Promise<{ redirectUrl: string; cookie: string }> {
|
||||
const response = id ? await api(`/${id}/reconnect`, "POST", {}) : await api("/authorize", "POST", { provider, instanceUrl: origin });
|
||||
expect(response.status).toBe(201);
|
||||
const { redirectUrl } = await response.json() as { redirectUrl: string };
|
||||
expect(new URL(redirectUrl).searchParams.get("code_challenge_method")).toBe("S256");
|
||||
return { redirectUrl, cookie: response.headers.get("set-cookie")?.split(";")[0] ?? "" };
|
||||
}
|
||||
async function finish(authorization: { redirectUrl: string; cookie: string }): Promise<Response> {
|
||||
const consent = await fetch(authorization.redirectUrl, { redirect: "manual" });
|
||||
expect(consent.status).toBe(302);
|
||||
return fetch(consent.headers.get("location") ?? "", { redirect: "manual", headers: { cookie: authorization.cookie } });
|
||||
}
|
||||
async function listed(): Promise<ConnectionSummary[]> { return (await (await api("")).json()) as ConnectionSummary[]; }
|
||||
|
||||
it("authorize → provider → unauthenticated callback → healthy list; reconnect rotates; remove revokes and clears row + vault envelope", async () => {
|
||||
const auth = await begin();
|
||||
expect(auth.cookie).toMatch(/^ap_connect_/);
|
||||
expect(JSON.stringify([...redis.entries.values()])).not.toContain("registered-client-secret");
|
||||
const callback = await finish(auth);
|
||||
expect(callback.status).toBe(303);
|
||||
expect(callback.headers.get("location")).toBe("http://localhost:3001/connections?connect=success");
|
||||
expect(callback.headers.get("set-cookie")).toContain("Expires=");
|
||||
const rows = await listed();
|
||||
expect(rows).toHaveLength(1); expect(rows[0]?.health).toBe("healthy");
|
||||
expect(rows[0]?.capabilities).toContain("publishText");
|
||||
const id = rows[0]?.id ?? "";
|
||||
const before = store.rows.get(id)?.credential?.ciphertext;
|
||||
expect(before).toBeDefined();
|
||||
const stored = store.rows.get(id)?.credential;
|
||||
if (!stored) throw new Error("credential missing");
|
||||
expect((await new Vault().open(sessionFixture.workspaceId, id, stored)).toString()).toContain("access-token-secret-1");
|
||||
expect(JSON.stringify(rows)).not.toContain("token-secret");
|
||||
const second = await finish(await begin(id));
|
||||
expect(second.headers.get("location")).toContain("connect=success");
|
||||
expect((await listed())[0]?.id).toBe(id);
|
||||
expect(store.rows.get(id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(false);
|
||||
expect(store.audit.at(-1)?.action).toBe("connection.reconnect");
|
||||
const replay = await finish(auth);
|
||||
expect(replay.headers.get("location")).toContain("connect=failure");
|
||||
expect((await api(`/${id}`, "DELETE")).status).toBe(200);
|
||||
expect(revokedTokens).toContain("access-token-secret-2");
|
||||
expect(await listed()).toHaveLength(0); expect(store.rows.size).toBe(0); expect(redis.entries.size).toBe(0);
|
||||
const observable = JSON.stringify({ audit: store.audit, warn: warn.mock.calls, info: info.mock.calls });
|
||||
for (const secret of ["access-token-secret", "refresh-token-secret", "registered-client-secret"]) expect(observable).not.toContain(secret);
|
||||
});
|
||||
|
||||
it("rejects missing auth on authorize, missing/wrong browser cookie and preserves a valid pending state", async () => {
|
||||
expect((await fetch(`${apiOrigin}/connections/authorize`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "mastodon", instanceUrl: origin }) })).status).toBe(401);
|
||||
const auth = await begin();
|
||||
const consent = await fetch(auth.redirectUrl, { redirect: "manual" });
|
||||
const callbackUrl = consent.headers.get("location") ?? "";
|
||||
const missing = await fetch(callbackUrl, { redirect: "manual" });
|
||||
expect(missing.headers.get("location")).toContain("connect=failure");
|
||||
const wrong = await fetch(callbackUrl, { redirect: "manual", headers: { cookie: auth.cookie.replace(sessionFixture.workspaceId, randomUUID()) } });
|
||||
expect(wrong.headers.get("location")).toContain("connect=failure");
|
||||
const good = await fetch(callbackUrl, { redirect: "manual", headers: { cookie: auth.cookie } });
|
||||
expect(good.headers.get("location")).toContain("connect=success");
|
||||
});
|
||||
|
||||
it("failed exchange and different-account reconnect retain the previous encrypted credential; health detects revoked tokens", async () => {
|
||||
await finish(await begin());
|
||||
const row = (await listed())[0];
|
||||
if (!row) throw new Error("connection missing");
|
||||
const before = store.rows.get(row.id)?.credential?.ciphertext;
|
||||
failExchange = true;
|
||||
const failure = await finish(await begin(row.id));
|
||||
failExchange = false;
|
||||
expect(failure.headers.get("location")).toContain("connect=failure");
|
||||
expect(store.rows.get(row.id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(true);
|
||||
nextAccount = "other-account";
|
||||
const wrongAccount = await finish(await begin(row.id));
|
||||
nextAccount = "account-1";
|
||||
expect(wrongAccount.headers.get("location")).toContain("connect=failure");
|
||||
expect(store.rows.get(row.id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(true);
|
||||
activeTokens.clear();
|
||||
expect((await listed())[0]?.health).toBe("needs_credentials");
|
||||
expect((await finish(await begin(row.id))).headers.get("location")).toContain("connect=success");
|
||||
expect((await listed())[0]?.health).toBe("healthy");
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain("token-secret-leak");
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain("registered-client-secret");
|
||||
});
|
||||
|
||||
it("rechecks workspace membership during callback, handles denial, and does not expose provider errors", async () => {
|
||||
const revoked = await begin();
|
||||
store.authorized = false;
|
||||
expect((await finish(revoked)).headers.get("location")).toContain("connect=failure");
|
||||
store.authorized = true;
|
||||
const denied = await begin();
|
||||
const url = new URL(`${apiOrigin}/connections/callback`);
|
||||
url.searchParams.set("state", new URL(denied.redirectUrl).searchParams.get("state") ?? "");
|
||||
url.searchParams.set("error", "access_denied token-secret-leak");
|
||||
const response = await fetch(url, { redirect: "manual", headers: { cookie: denied.cookie } });
|
||||
expect(response.headers.get("location")).toContain("connect=failure");
|
||||
expect(JSON.stringify(warn.mock.calls)).not.toContain("token-secret-leak");
|
||||
});
|
||||
|
||||
it("validates Telegram getMe, encrypts tokens, rotates in place and removes locally; PeerTube is capability-honest", async () => {
|
||||
const bad = await api("/telegram/bot-token", "POST", { token: "12345:bad_secret" });
|
||||
expect(bad.status).toBe(400); expect(await bad.text()).not.toContain("bad_secret");
|
||||
const response = await api("/telegram/bot-token", "POST", { token: botToken, channelId: "@target" });
|
||||
expect(response.status).toBe(201);
|
||||
const bot = (await listed()).find((row) => row.provider === "telegram");
|
||||
expect(bot?.health).toBe("healthy"); expect(bot?.channelId).toBe("@target");
|
||||
const id = bot?.id ?? "";
|
||||
const before = store.rows.get(id)?.credential?.ciphertext;
|
||||
expect((await api("/telegram/bot-token", "POST", { token: botToken, connectionId: id })).status).toBe(201);
|
||||
expect(store.rows.get(id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(false);
|
||||
expect((await listed()).filter((row) => row.provider === "telegram")).toHaveLength(1);
|
||||
expect((await api(`/${id}`, "DELETE")).status).toBe(200);
|
||||
expect(store.rows.has(id)).toBe(false);
|
||||
const providers = await (await api("/providers")).json() as Array<{ id: string; enabled: boolean; note?: string }>;
|
||||
expect(providers.find((provider) => provider.id === "peertube")?.enabled).toBe(false);
|
||||
expect((await api("/authorize", "POST", { provider: "peertube", instanceUrl: origin })).status).toBe(400);
|
||||
expect(JSON.stringify({ audit: store.audit, warn: warn.mock.calls, info: info.mock.calls })).not.toContain(botToken);
|
||||
});
|
||||
|
||||
it("runs PeerTube code flow only with an explicitly configured instance bridge", async () => {
|
||||
bridgeConfigured = true;
|
||||
expect((await finish(await begin(undefined, "peertube"))).headers.get("location")).toContain("connect=success");
|
||||
const row = (await listed()).find((connection) => connection.provider === "peertube");
|
||||
expect(row?.health).toBe("healthy");
|
||||
const id = row?.id ?? "";
|
||||
expect((await finish(await begin(id))).headers.get("location")).toContain("connect=success");
|
||||
expect((await listed()).filter((connection) => connection.id === id)).toHaveLength(1);
|
||||
const stored = store.rows.get(id)?.credential;
|
||||
if (!stored) throw new Error("PeerTube credential missing");
|
||||
const secrets = JSON.parse((await encryption.open(sessionFixture.workspaceId, id, stored)).toString()) as Record<string, string>;
|
||||
activeTokens.delete(secrets.access_token ?? "");
|
||||
expect((await listed()).find((connection) => connection.id === id)?.health).toBe("healthy");
|
||||
expect(store.audit.at(-1)?.action).toBe("credential.refresh");
|
||||
expect(store.rows.get(id)?.credential?.ciphertext.equals(stored.ciphertext)).toBe(false);
|
||||
expect((await api(`/${id}`, "DELETE")).status).toBe(200);
|
||||
bridgeConfigured = false;
|
||||
});
|
||||
|
||||
it("does not resurrect a connection removed while reconnect consent is pending", async () => {
|
||||
await finish(await begin());
|
||||
const row = (await listed())[0];
|
||||
if (!row) throw new Error("connection missing");
|
||||
const pending = await begin(row.id);
|
||||
expect((await api(`/${row.id}`, "DELETE")).status).toBe(200);
|
||||
expect((await finish(pending)).headers.get("location")).toContain("connect=failure");
|
||||
expect(store.rows.has(row.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
231
apps/api/src/connections/connections.service.ts
Normal file
231
apps/api/src/connections/connections.service.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { BadRequestException, Inject, Injectable, Optional } from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ConnectorConnection, NormalizedIdentity } from "@tactical-media-broadcast-hub/core";
|
||||
import { AuthError, type AuthorizationRequest, type AuthorizationResult, type CredentialAccessor, type ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import type { SessionPayload } from "../auth/session.js";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import { issueSessionInTenant } from "../auth/auth.service.js";
|
||||
import { vault, type Vault } from "../vault/vault.js";
|
||||
import { logger } from "../telemetry/logger.js";
|
||||
import { connectionRegistry } from "./connector-registry.js";
|
||||
import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js";
|
||||
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js";
|
||||
import { oauthFetch } from "./oauth-fetch.js";
|
||||
|
||||
export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable";
|
||||
export interface ConnectionSummary {
|
||||
id: string; provider: string; channelId: string; fields: Record<string, unknown>;
|
||||
health: ConnectionHealth; credentialRotatedAt: string | null; recentPublishes: string[]; createdAt: string;
|
||||
capabilities?: string[];
|
||||
}
|
||||
export interface ConnectionDependencies {
|
||||
store: ConnectionStore;
|
||||
pending: PendingStore;
|
||||
encryption: Vault;
|
||||
registry: (credentials: CredentialAccessor) => ConnectorRegistry;
|
||||
appUrl: string;
|
||||
callbackUrl: string;
|
||||
renewSession(session: SessionPayload): Promise<string>;
|
||||
}
|
||||
export const CONNECTION_DEPENDENCIES = Symbol("CONNECTION_DEPENDENCIES");
|
||||
|
||||
/** Each exchange has an isolated, tenant-bound working set; never writes plaintext to DB. */
|
||||
class StagedCredentials implements CredentialAccessor {
|
||||
dirty = false;
|
||||
constructor(private readonly workspaceId: string, private readonly connectionId: string, readonly secrets: Record<string, string> = {}) {}
|
||||
private assert(connection: ConnectorConnection): void {
|
||||
if (connection.workspaceId !== this.workspaceId || connection.id !== this.connectionId || connection.credentialId.id !== this.connectionId) throw new Error("Credential binding mismatch");
|
||||
}
|
||||
async getAccessToken(connection: ConnectorConnection): Promise<string> { return this.getSecret(connection, "access_token"); }
|
||||
async getSecret(connection: ConnectorConnection, key: string): Promise<string> {
|
||||
this.assert(connection);
|
||||
const value = this.secrets[key];
|
||||
if (!value) throw new Error("Credential missing");
|
||||
return value;
|
||||
}
|
||||
async put(connection: ConnectorConnection, key: string, value: string): Promise<void> {
|
||||
this.assert(connection);
|
||||
if (typeof value !== "string" || !value) throw new Error("Provider returned invalid credential");
|
||||
this.secrets[key] = value;
|
||||
this.dirty = true;
|
||||
}
|
||||
}
|
||||
function defaults(): ConnectionDependencies {
|
||||
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
return {
|
||||
renewSession: (session) => withTenant(session.workspaceId, (client) => issueSessionInTenant(client, session.workspaceId, session.userId, session.expiresAt)),
|
||||
store: new PgConnectionStore(), pending: new RedisPendingStore(), encryption: vault,
|
||||
registry: (credentials) => connectionRegistry(credentials, oauthFetch), appUrl,
|
||||
callbackUrl: process.env.CONNECTIONS_CALLBACK_URL ?? `${process.env.API_URL ?? "http://localhost:3000"}/connections/callback`,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ConnectionsService {
|
||||
private readonly deps: ConnectionDependencies;
|
||||
constructor(@Optional() @Inject(CONNECTION_DEPENDENCIES) dependencies?: ConnectionDependencies) { this.deps = dependencies ?? defaults(); }
|
||||
|
||||
providers() {
|
||||
const registry = this.deps.registry(new StagedCredentials("", ""));
|
||||
return registry.list().map((connector) => {
|
||||
const manifest = connector.manifest();
|
||||
return {
|
||||
id: manifest.id, displayName: manifest.displayName, kind: manifest.oauth?.kind,
|
||||
enabled: manifest.oauth?.connectEnabled !== false && Boolean(connector.validateToken || (connector.buildAuthorizationUrl && connector.exchangeCode)),
|
||||
note: manifest.oauth?.connectNote, targetField: manifest.oauth?.targetField,
|
||||
instanceRequired: manifest.oauth?.kind === "per-instance-oauth2",
|
||||
capabilities: Object.entries(manifest.capabilities).filter(([, enabled]) => enabled === true).map(([key]) => key),
|
||||
};
|
||||
});
|
||||
}
|
||||
landing(status: "success" | "failure"): string {
|
||||
const url = new URL("/connections", this.deps.appUrl);
|
||||
url.searchParams.set("connect", status);
|
||||
return url.toString();
|
||||
}
|
||||
async authorize(session: SessionPayload, provider: string, instanceUrl?: string, connectionId?: string) {
|
||||
await this.deps.store.assertAuthorized(session);
|
||||
const previous = connectionId ? await this.deps.store.get(session.workspaceId, connectionId) : undefined;
|
||||
if (previous && previous.connection.provider !== provider) throw new BadRequestException("Provider mismatch");
|
||||
const id = connectionId ?? randomUUID();
|
||||
const appSecrets: Record<string, string> = {};
|
||||
if (previous?.credential) {
|
||||
const bytes = await this.deps.encryption.open(session.workspaceId, id, previous.credential);
|
||||
const secrets = JSON.parse(bytes.toString()) as Record<string, string>;
|
||||
// Reuse the registered app, but stage no previous account token.
|
||||
for (const key of ["client_id", "client_secret"]) if (secrets[key]) appSecrets[key] = secrets[key];
|
||||
}
|
||||
const staging = new StagedCredentials(session.workspaceId, id, appSecrets);
|
||||
const registry = this.deps.registry(staging);
|
||||
if (!registry.has(provider)) throw new BadRequestException("Provider is not available");
|
||||
const connector = registry.get(provider);
|
||||
if (!connector.buildAuthorizationUrl || !connector.exchangeCode || connector.manifest().oauth?.connectEnabled === false) throw new BadRequestException(connector.manifest().oauth?.connectNote ?? "Redirect linking is unavailable");
|
||||
const state = randomState();
|
||||
const binding = `${session.workspaceId}.${randomState()}`;
|
||||
const instance = previous?.connection.fields.instanceUrl ?? instanceUrl;
|
||||
const request: AuthorizationRequest = {
|
||||
state, workspaceId: session.workspaceId, connectionId: id, redirectUri: this.deps.callbackUrl,
|
||||
fields: instance ? { instanceUrl: instance } : {},
|
||||
...(connector.manifest().oauth?.pkce ? { codeVerifier: randomState() } : {}),
|
||||
};
|
||||
try {
|
||||
const redirect = await connector.buildAuthorizationUrl(request);
|
||||
request.fields = redirect.connection.fields;
|
||||
await this.deps.pending.put(state, { session, provider, request, secrets: staging.secrets, reconnect: Boolean(previous), expiresAt: Date.now() + AUTH_TTL_MS, browserHash: browserHash(binding) });
|
||||
return { redirectUrl: redirect.authorizationUrl, state, binding };
|
||||
} catch {
|
||||
logger.warn({ event: "connection.authorize_failed", provider }, "Connection authorization failed");
|
||||
throw new BadRequestException("Could not start authorization. Check the instance URL and provider configuration.");
|
||||
}
|
||||
}
|
||||
async callback(state: string, binding: string, code?: string, providerError?: string): Promise<string> {
|
||||
try {
|
||||
const pending = await this.deps.pending.consume(state, binding);
|
||||
await this.deps.store.assertAuthorized(pending.session);
|
||||
if (providerError || !code || code.length > 4096) throw new Error("Provider authorization denied");
|
||||
const id = pending.request.connectionId;
|
||||
if (!id) throw new Error("Missing connection binding");
|
||||
const staging = new StagedCredentials(pending.session.workspaceId, id, pending.secrets);
|
||||
const connector = this.deps.registry(staging).get(pending.provider);
|
||||
if (!connector.exchangeCode) throw new Error("Code exchange unavailable");
|
||||
const result = await connector.exchangeCode({ ...pending.request, code });
|
||||
try {
|
||||
await this.persist(pending.session, result, staging, pending.reconnect);
|
||||
} catch (error) {
|
||||
// A failed DB write/account check must not leave an orphan authorization.
|
||||
await connector.revokeCredentials?.(result.connection).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return this.landing("success");
|
||||
} catch {
|
||||
// Provider errors can echo secrets. Only a fixed event reaches logs/UI.
|
||||
logger.warn({ event: "connection.callback_failed" }, "Connection callback failed");
|
||||
return this.landing("failure");
|
||||
}
|
||||
}
|
||||
async botToken(session: SessionPayload, token: string, fields: Record<string, string>, connectionId?: string): Promise<string> {
|
||||
await this.deps.store.assertAuthorized(session);
|
||||
const previous = connectionId ? await this.deps.store.get(session.workspaceId, connectionId) : undefined;
|
||||
const id = connectionId ?? randomUUID();
|
||||
const staging = new StagedCredentials(session.workspaceId, id);
|
||||
const connector = this.deps.registry(staging).list().find((item) => item.manifest().oauth?.kind === "token" && item.validateToken);
|
||||
if (!connector?.validateToken || (previous && previous.connection.provider !== connector.manifest().id)) throw new BadRequestException("Token linking is unavailable");
|
||||
try {
|
||||
const result = await connector.validateToken({ workspaceId: session.workspaceId, connectionId: id, token, fields: { ...previous?.connection.fields, ...fields } });
|
||||
await this.persist(session, result, staging, Boolean(previous));
|
||||
return await this.deps.renewSession(session);
|
||||
} catch {
|
||||
logger.warn({ event: "connection.token_failed" }, "Bot validation failed");
|
||||
throw new BadRequestException("Bot validation failed. Check the token and try again.");
|
||||
}
|
||||
}
|
||||
async reconnect(session: SessionPayload, id: string) {
|
||||
const stored = await this.deps.store.get(session.workspaceId, id);
|
||||
return this.authorize(session, stored.connection.provider, undefined, id);
|
||||
}
|
||||
private async persist(session: SessionPayload, result: AuthorizationResult, staging: StagedCredentials, reconnect: boolean): Promise<void> {
|
||||
const { connection, identity } = result;
|
||||
if (connection.workspaceId !== session.workspaceId || identity.provider !== connection.provider || !identity.remoteId || !identity.handle) throw new Error("Provider identity invalid");
|
||||
if (reconnect) {
|
||||
const previous = await this.deps.store.get(session.workspaceId, connection.id);
|
||||
if (previous.connection.provider !== connection.provider || (previous.connection.fields.remoteId && previous.connection.fields.remoteId !== identity.remoteId)) throw new Error("Reconnect must use the same account");
|
||||
connection.channelId = previous.connection.channelId;
|
||||
} else if (!connection.fields.channelId) {
|
||||
connection.channelId = identity.remoteId;
|
||||
}
|
||||
const encrypted = await this.deps.encryption.seal(session.workspaceId, connection.id, Buffer.from(JSON.stringify(staging.secrets)));
|
||||
await this.deps.store.save(session, connection, identity, encrypted, reconnect);
|
||||
}
|
||||
private async credentials(workspaceId: string, id: string) {
|
||||
const stored = await this.deps.store.get(workspaceId, id);
|
||||
return this.storedCredentials(stored);
|
||||
}
|
||||
private async storedCredentials(stored: StoredConnection) {
|
||||
const { workspaceId, id } = stored.connection;
|
||||
if (!stored.credential) throw new AuthError("Credential missing", stored.connection.provider, false);
|
||||
const bytes = await this.deps.encryption.open(workspaceId, id, stored.credential);
|
||||
const secrets = JSON.parse(bytes.toString()) as Record<string, string>;
|
||||
const staging = new StagedCredentials(workspaceId, id, secrets);
|
||||
return { connection: stored.connection, connector: this.deps.registry(staging).get(stored.connection.provider), staging, stored };
|
||||
}
|
||||
async list(workspaceId: string): Promise<ConnectionSummary[]> {
|
||||
const rows = await this.deps.store.list(workspaceId);
|
||||
// Probe actual identity, rather than assuming a credential row means a valid token.
|
||||
for (const row of rows) {
|
||||
if (row.health === "needs_credentials") continue;
|
||||
try {
|
||||
const { connection, connector, staging, stored } = await this.credentials(workspaceId, row.id);
|
||||
let identity: NormalizedIdentity;
|
||||
try {
|
||||
identity = await connector.getIdentity(connection);
|
||||
} finally {
|
||||
// Some connectors refresh during identity reads; keep the new refresh token durable.
|
||||
if (staging.dirty && stored.credential) {
|
||||
const sealed = await this.deps.encryption.seal(workspaceId, row.id, Buffer.from(JSON.stringify(staging.secrets)));
|
||||
await this.deps.store.rotateCredential(workspaceId, row.id, stored.credential, sealed);
|
||||
}
|
||||
}
|
||||
row.health = "healthy";
|
||||
row.fields.handle = identity.handle;
|
||||
row.capabilities = Object.entries(connector.manifest().capabilities).filter(([, enabled]) => enabled === true).map(([key]) => key);
|
||||
} catch (error) {
|
||||
row.health = error instanceof AuthError ? "needs_credentials" : "unavailable";
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
async remove(session: SessionPayload, id: string): Promise<string> {
|
||||
await this.deps.store.assertAuthorized(session);
|
||||
await this.deps.store.remove(session, id, async (stored) => {
|
||||
if (!stored.credential) return;
|
||||
try {
|
||||
const { connection, connector } = await this.storedCredentials(stored);
|
||||
await connector.revokeCredentials?.(connection);
|
||||
} catch (error) {
|
||||
// Already invalid/revoked credentials are safe to remove locally.
|
||||
if (!(error instanceof AuthError)) throw new BadRequestException("Provider revocation failed. Try removing again.");
|
||||
}
|
||||
});
|
||||
return this.deps.renewSession(session);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/connections/connector-registry.ts
Normal file
17
apps/api/src/connections/connector-registry.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { ConnectorRegistry, type CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
|
||||
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
|
||||
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
|
||||
|
||||
/** Composition boundary only: services resolve manifests rather than branch on providers. */
|
||||
export function connectionRegistry(credentials: CredentialAccessor, fetchImpl: typeof fetch = fetch): ConnectorRegistry {
|
||||
const registry = new ConnectorRegistry();
|
||||
const options = { credentials, fetchImpl, env: process.env };
|
||||
registry.register(createMastodonConnector(options));
|
||||
registry.register(createPeerTubeConnector(options));
|
||||
registry.register(createTelegramConnector(options));
|
||||
return registry;
|
||||
}
|
||||
46
apps/api/src/connections/http-test-transport.ts
Normal file
46
apps/api/src/connections/http-test-transport.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Duplex } from "node:stream";
|
||||
import type { Socket } from "node:net";
|
||||
|
||||
/** Runs real Node/Express HTTP handlers without binding a port (restricted CI). */
|
||||
export async function injectHttp(
|
||||
handler: (request: IncomingMessage, response: ServerResponse) => void,
|
||||
input: string | URL | Request,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
const output: Buffer[] = [];
|
||||
const socket = new Duplex({
|
||||
read() {},
|
||||
write(chunk: Buffer, _encoding, callback) { output.push(Buffer.from(chunk)); callback(); },
|
||||
});
|
||||
// ServerResponse uses only Duplex operations; no network or unsafe dynamic values.
|
||||
const httpSocket = socket as unknown as Socket;
|
||||
const request = new IncomingMessage(httpSocket);
|
||||
request.method = init.method ?? "GET";
|
||||
request.complete = true;
|
||||
request.url = `${url.pathname}${url.search}`;
|
||||
request.headers = Object.fromEntries(new Headers(init.headers).entries());
|
||||
request.headers.host = url.host;
|
||||
const body = typeof init.body === "string" ? init.body : "";
|
||||
if (body) request.headers["content-length"] = String(Buffer.byteLength(body));
|
||||
request.push(body || null);
|
||||
if (body) request.push(null);
|
||||
const response = new ServerResponse(request);
|
||||
response.assignSocket(httpSocket);
|
||||
const completed = new Promise<Response>((resolve, reject) => {
|
||||
response.once("error", reject);
|
||||
response.once("finish", () => {
|
||||
const bytes = Buffer.concat(output);
|
||||
const bodyOffset = bytes.indexOf("\r\n\r\n") + 4;
|
||||
const headers = new Headers();
|
||||
for (const line of bytes.subarray(0, bodyOffset - 4).toString().split("\r\n").slice(1)) {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator > 0) headers.append(line.slice(0, separator), line.slice(separator + 1).trim());
|
||||
}
|
||||
resolve(new Response([204, 304].includes(response.statusCode) ? null : bytes.subarray(bodyOffset), { status: response.statusCode, headers }));
|
||||
});
|
||||
});
|
||||
handler(request, response);
|
||||
return completed;
|
||||
}
|
||||
11
apps/api/src/connections/oauth-fetch.test.ts
Normal file
11
apps/api/src/connections/oauth-fetch.test.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { publicAddress } from "./oauth-fetch.js";
|
||||
|
||||
describe("authorization egress policy", () => {
|
||||
it("rejects private, mapped, metadata, multicast and documentation addresses", () => {
|
||||
for (const address of ["127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.1.1", "0.0.0.0", "224.0.0.1", "::1", "::ffff:127.0.0.1", "fc00::1", "fe80::1", "2001:db8::1", "2002:7f00:1::"]) expect(publicAddress(address), address).toBe(false);
|
||||
expect(publicAddress("8.8.8.8")).toBe(true);
|
||||
expect(publicAddress("2606:4700:4700::1111")).toBe(true);
|
||||
expect(publicAddress("2001:4860:4860::8888")).toBe(true);
|
||||
});
|
||||
});
|
||||
1
apps/api/src/connections/oauth-fetch.ts
Normal file
1
apps/api/src/connections/oauth-fetch.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { publicAddress, providerFetch as oauthFetch } from "@tactical-media-broadcast-hub/connector-runtime";
|
||||
53
apps/api/src/connections/pending.test.ts
Normal file
53
apps/api/src/connections/pending.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { Vault } from "../vault/vault.js";
|
||||
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingAuthorization } from "./pending.js";
|
||||
import { MemoryPendingRedis } from "./testing.js";
|
||||
|
||||
function fixture() {
|
||||
const redis = new MemoryPendingRedis();
|
||||
const store = new RedisPendingStore(redis, new Vault());
|
||||
const state = randomState();
|
||||
const binding = `workspace-a.${randomState()}`;
|
||||
const pending: PendingAuthorization = {
|
||||
provider: "mastodon", request: { redirectUri: "http://localhost/callback", workspaceId: "workspace-a", state },
|
||||
session: { userId: "user-a", workspaceId: "workspace-a", role: "Owner", authzEpoch: 1, issuedAt: 0, expiresAt: Date.now() / 1000 + 600 },
|
||||
secrets: { client_secret: "registration-secret" }, reconnect: false, expiresAt: Date.now() + AUTH_TTL_MS, browserHash: browserHash(binding),
|
||||
};
|
||||
return { redis, store, state, binding, pending };
|
||||
}
|
||||
afterEach(() => vi.useRealTimers());
|
||||
describe("pending authorization binding", () => {
|
||||
it("encrypts provider secrets and consumes state exactly once", async () => {
|
||||
const { redis, store, state, binding, pending } = fixture();
|
||||
await store.put(state, pending);
|
||||
expect(JSON.stringify([...redis.entries.values()])).not.toContain("registration-secret");
|
||||
expect(await store.consume(state, binding)).toEqual(pending);
|
||||
await expect(store.consume(state, binding)).rejects.toThrow(/already used/);
|
||||
});
|
||||
it("rejects CSRF, unknown states and a different workspace without burning the valid state", async () => {
|
||||
const { store, state, binding, pending } = fixture();
|
||||
await store.put(state, pending);
|
||||
await expect(store.consume(state, "")).rejects.toThrow(/mismatch/);
|
||||
await expect(store.consume(state, binding.replace("workspace-a", "workspace-b"))).rejects.toThrow(/mismatch/);
|
||||
await expect(store.consume(randomState(), binding)).rejects.toThrow();
|
||||
await expect(store.consume("malformed", binding)).rejects.toThrow();
|
||||
expect(await store.consume(state, binding)).toEqual(pending);
|
||||
});
|
||||
it("rejects a workspace mismatch inside the encrypted request", async () => {
|
||||
const { store, state, binding, pending } = fixture();
|
||||
pending.request.workspaceId = "workspace-b";
|
||||
await store.put(state, pending);
|
||||
await expect(store.consume(state, binding)).rejects.toThrow(/workspace mismatch/);
|
||||
});
|
||||
it("expires state after ten minutes and atomically fences simultaneous callbacks", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { store, state, binding, pending } = fixture();
|
||||
await store.put(state, pending);
|
||||
const results = await Promise.allSettled([store.consume(state, binding), store.consume(state, binding)]);
|
||||
expect(results.filter((item) => item.status === "fulfilled")).toHaveLength(1);
|
||||
const next = fixture();
|
||||
await next.store.put(next.state, next.pending);
|
||||
vi.advanceTimersByTime(AUTH_TTL_MS);
|
||||
await expect(next.store.consume(next.state, next.binding)).rejects.toThrow(/expired/);
|
||||
});
|
||||
});
|
||||
57
apps/api/src/connections/pending.ts
Normal file
57
apps/api/src/connections/pending.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
23
apps/api/src/connections/testing.ts
Normal file
23
apps/api/src/connections/testing.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { ControlRedis } from "../media-control/redis.js";
|
||||
|
||||
/** TTL-faithful Redis substitute; compare-and-delete runs synchronously like Redis Lua. */
|
||||
export class MemoryPendingRedis implements Pick<ControlRedis, "get" | "set" | "eval"> {
|
||||
readonly entries = new Map<string, { value: string; expiresAt: number }>();
|
||||
async get(key: string): Promise<string | null> {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry || entry.expiresAt <= Date.now()) { this.entries.delete(key); return null; }
|
||||
return entry.value;
|
||||
}
|
||||
async set(key: string, value: string, options: { nx?: boolean; pxMs?: number } = {}): Promise<boolean> {
|
||||
if (options.nx && await this.get(key) !== null) return false;
|
||||
this.entries.set(key, { value, expiresAt: Date.now() + (options.pxMs ?? 1000) });
|
||||
return true;
|
||||
}
|
||||
async eval(_script: string, keys: string[], args: string[]): Promise<number> {
|
||||
const key = keys[0] ?? "";
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry || entry.value !== args[0] || entry.expiresAt <= Date.now()) return 0;
|
||||
this.entries.delete(key);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue