/** * Workspace-scoped auth. * * Two principal types are accepted on `/api/...` routes: * 1. NextAuth session (browser users) — `authSession()` * 2. Per-workspace bearer API key (`Authorization: Bearer vibn_sk_...`) * * Either way we resolve a `WorkspacePrincipal` that is scoped to one * workspace. Routes that touch Coolify/Gitea/Theia must call * `requireWorkspacePrincipal()` and use `principal.workspace` to fetch * the right Coolify Project / Gitea org. */ import { createHash, randomBytes } from 'crypto'; import { NextResponse } from 'next/server'; import { authSession } from '@/lib/auth/session-server'; import { query, queryOne } from '@/lib/db-postgres'; import { type VibnWorkspace, getWorkspaceById, getWorkspaceBySlug, getWorkspaceByOwner, userHasWorkspaceAccess, } from '@/lib/workspaces'; const KEY_PREFIX = 'vibn_sk_'; const KEY_RANDOM_BYTES = 32; // 256-bit secret export interface WorkspacePrincipal { /** "session" = browser user; "api_key" = automated/AI client */ source: 'session' | 'api_key'; workspace: VibnWorkspace; /** fs_users.id of the human ultimately responsible for this request */ userId: string; /** When source = "api_key", which key id was used */ apiKeyId?: string; } /** * Resolve the workspace principal from either a NextAuth session or a * `Bearer vibn_sk_...` token. Optional `targetSlug` enforces that the * principal is for the requested workspace. * * Returns: * - principal on success * - NextResponse on failure (401 / 403) — return it directly from the route */ export async function requireWorkspacePrincipal( request: Request, opts: { targetSlug?: string; targetId?: string } = {}, ): Promise { const apiKey = extractApiKey(request); if (apiKey) { const principal = await resolveApiKey(apiKey); if (!principal) { return NextResponse.json({ error: 'Invalid or revoked API key' }, { status: 401 }); } if (!matchesTarget(principal.workspace, opts)) { return NextResponse.json({ error: 'API key not authorized for this workspace' }, { status: 403 }); } return principal; } // Fall through to NextAuth session const session = await authSession(); if (!session?.user?.email) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const userRow = await queryOne<{ id: string }>( `SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`, [session.user.email] ); if (!userRow) { return NextResponse.json({ error: 'No fs_users row for session' }, { status: 401 }); } let workspace: VibnWorkspace | null = null; if (opts.targetSlug) workspace = await getWorkspaceBySlug(opts.targetSlug); else if (opts.targetId) workspace = await getWorkspaceById(opts.targetId); else workspace = await getWorkspaceByOwner(userRow.id); if (!workspace) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }); } const ok = await userHasWorkspaceAccess(userRow.id, workspace.id); if (!ok) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } return { source: 'session', workspace, userId: userRow.id }; } function matchesTarget( workspace: VibnWorkspace, opts: { targetSlug?: string; targetId?: string } ): boolean { if (opts.targetSlug && workspace.slug !== opts.targetSlug) return false; if (opts.targetId && workspace.id !== opts.targetId) return false; return true; } function extractApiKey(request: Request): string | null { const auth = request.headers.get('authorization'); if (!auth) return null; const m = /^Bearer\s+(.+)$/i.exec(auth.trim()); if (!m) return null; const token = m[1].trim(); if (!token.startsWith(KEY_PREFIX)) return null; return token; } async function resolveApiKey(token: string): Promise { const hash = hashKey(token); const row = await queryOne<{ id: string; workspace_id: string; created_by: string; revoked_at: Date | null; }>( `SELECT id, workspace_id, created_by, revoked_at FROM vibn_workspace_api_keys WHERE key_hash = $1 LIMIT 1`, [hash] ); if (!row || row.revoked_at) return null; const workspace = await getWorkspaceById(row.workspace_id); if (!workspace) return null; // Touch last_used_at without blocking void query( `UPDATE vibn_workspace_api_keys SET last_used_at = now() WHERE id = $1`, [row.id] ).catch(() => undefined); return { source: 'api_key', workspace, userId: row.created_by, apiKeyId: row.id, }; } // ────────────────────────────────────────────────── // Key minting + hashing // ────────────────────────────────────────────────── export interface MintedApiKey { id: string; /** Full plaintext key — shown once at creation, never stored. */ token: string; prefix: string; workspace_id: string; name: string; created_at: Date; } export async function mintWorkspaceApiKey(opts: { workspaceId: string; name: string; createdBy: string; scopes?: string[]; }): Promise { const random = randomBytes(KEY_RANDOM_BYTES).toString('base64url'); const token = `${KEY_PREFIX}${random}`; const hash = hashKey(token); const prefix = token.slice(0, 12); // e.g. "vibn_sk_AbCd" const inserted = await query<{ id: string; created_at: Date }>( `INSERT INTO vibn_workspace_api_keys (workspace_id, name, key_prefix, key_hash, scopes, created_by) VALUES ($1, $2, $3, $4, $5::jsonb, $6) RETURNING id, created_at`, [ opts.workspaceId, opts.name, prefix, hash, JSON.stringify(opts.scopes ?? ['workspace:*']), opts.createdBy, ] ); return { id: inserted[0].id, token, prefix, workspace_id: opts.workspaceId, name: opts.name, created_at: inserted[0].created_at, }; } export async function listWorkspaceApiKeys(workspaceId: string): Promise> { const rows = await query<{ id: string; name: string; key_prefix: string; scopes: string[]; created_by: string; last_used_at: Date | null; revoked_at: Date | null; created_at: Date; }>( `SELECT id, name, key_prefix, scopes, created_by, last_used_at, revoked_at, created_at FROM vibn_workspace_api_keys WHERE workspace_id = $1 ORDER BY created_at DESC`, [workspaceId] ); return rows.map(r => ({ id: r.id, name: r.name, prefix: r.key_prefix, scopes: r.scopes, created_by: r.created_by, last_used_at: r.last_used_at, revoked_at: r.revoked_at, created_at: r.created_at, })); } export async function revokeWorkspaceApiKey(workspaceId: string, keyId: string): Promise { const updated = await query<{ id: string }>( `UPDATE vibn_workspace_api_keys SET revoked_at = now() WHERE id = $1 AND workspace_id = $2 AND revoked_at IS NULL RETURNING id`, [keyId, workspaceId] ); return updated.length > 0; } function hashKey(token: string): string { return createHash('sha256').update(token).digest('hex'); }