ai-auth.ts201 lines · main
1/**
2 * AI auth extras (SuperTokens-class “AI authentication” surface — first cut).
3 *
4 * Machine identity for AI agents / tools: scoped bearer tokens that prove
5 * “this agent is allowed to act for project X” without a human session.
6 *
7 * Distinct from M2M (developer server credentials): AI tokens are shorter-lived
8 * by default and carry agent_name + scopes for audit.
9 */
10
11import { createHash, randomBytes } from 'node:crypto';
12
13import { getEnginePool } from './db.js';
14import { mapProjectToAuthCore } from './project-map.js';
15import { recordBrivenEngineAudit } from './audit.js';
16
17export type AiAgentTokenRow = {
18 id: string;
19 projectId: string;
20 agentName: string;
21 scopes: string[];
22 hint: string;
23 expiresAt: string | null;
24 revokedAt: string | null;
25 createdAt: string;
26 lastUsedAt: string | null;
27};
28
29function hashToken(raw: string): string {
30 return createHash('sha256').update(`briven-ai-agent:${raw}`).digest('hex');
31}
32
33export async function createAiAgentToken(input: {
34 projectId: string;
35 agentName: string;
36 scopes?: string[];
37 /** Hours until expiry; default 24. Max 30 days. */
38 ttlHours?: number;
39 createdBy?: string | null;
40}): Promise<{ token: AiAgentTokenRow; plaintext: string }> {
41 const name = input.agentName.trim().slice(0, 80);
42 if (!name) throw new Error('agentName required');
43 const scopes =
44 input.scopes?.length && input.scopes.every((s) => typeof s === 'string')
45 ? input.scopes.map((s) => s.trim()).filter(Boolean)
46 : ['ai.invoke'];
47 const hours = Math.min(Math.max(input.ttlHours ?? 24, 1), 24 * 30);
48 const map = mapProjectToAuthCore(input.projectId);
49 const id = `aia_${randomBytes(10).toString('hex')}`;
50 const plaintext = `brai_${randomBytes(24).toString('base64url')}`;
51 const suffix = plaintext.slice(-4);
52 const expiresAt = new Date(Date.now() + hours * 3600 * 1000);
53
54 const pool = getEnginePool();
55 await pool.query(
56 `INSERT INTO be_ai_agent_tokens
57 (id, project_id, tenant_id, agent_name, scopes_json, token_hash, token_suffix,
58 expires_at, created_by, created_at)
59 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW())`,
60 [
61 id,
62 input.projectId,
63 map.tenantId,
64 name,
65 JSON.stringify(scopes),
66 hashToken(plaintext),
67 suffix,
68 expiresAt.toISOString(),
69 input.createdBy ?? null,
70 ],
71 );
72
73 void recordBrivenEngineAudit({
74 action: 'ai.agent_token.created',
75 projectId: input.projectId,
76 tenantId: map.tenantId,
77 userId: input.createdBy ?? null,
78 metadata: { agentName: name, scopes, hours },
79 });
80
81 return {
82 plaintext,
83 token: {
84 id,
85 projectId: input.projectId,
86 agentName: name,
87 scopes,
88 hint: `…${suffix}`,
89 expiresAt: expiresAt.toISOString(),
90 revokedAt: null,
91 createdAt: new Date().toISOString(),
92 lastUsedAt: null,
93 },
94 };
95}
96
97export async function listAiAgentTokens(
98 projectId: string,
99): Promise<AiAgentTokenRow[]> {
100 const pool = getEnginePool();
101 const res = await pool.query(
102 `SELECT id, project_id, agent_name, scopes_json, token_suffix, expires_at,
103 revoked_at, created_at, last_used_at
104 FROM be_ai_agent_tokens
105 WHERE project_id = $1
106 ORDER BY created_at DESC
107 LIMIT 100`,
108 [projectId],
109 );
110 return (res.rows as Array<Record<string, unknown>>).map((r) => {
111 let scopes: string[] = [];
112 try {
113 scopes = JSON.parse(String(r.scopes_json ?? '[]')) as string[];
114 } catch {
115 scopes = [];
116 }
117 return {
118 id: String(r.id),
119 projectId: String(r.project_id),
120 agentName: String(r.agent_name),
121 scopes,
122 hint: `…${String(r.token_suffix ?? '')}`,
123 expiresAt: r.expires_at
124 ? r.expires_at instanceof Date
125 ? r.expires_at.toISOString()
126 : String(r.expires_at)
127 : null,
128 revokedAt: r.revoked_at
129 ? r.revoked_at instanceof Date
130 ? r.revoked_at.toISOString()
131 : String(r.revoked_at)
132 : null,
133 createdAt:
134 r.created_at instanceof Date
135 ? r.created_at.toISOString()
136 : String(r.created_at),
137 lastUsedAt: r.last_used_at
138 ? r.last_used_at instanceof Date
139 ? r.last_used_at.toISOString()
140 : String(r.last_used_at)
141 : null,
142 };
143 });
144}
145
146export async function revokeAiAgentToken(
147 projectId: string,
148 tokenId: string,
149): Promise<void> {
150 const pool = getEnginePool();
151 const res = await pool.query(
152 `UPDATE be_ai_agent_tokens SET revoked_at = NOW()
153 WHERE project_id = $1 AND id = $2 AND revoked_at IS NULL
154 RETURNING id`,
155 [projectId, tokenId],
156 );
157 if (!res.rowCount) throw new Error('token not found or already revoked');
158 void recordBrivenEngineAudit({
159 action: 'ai.agent_token.revoked',
160 projectId,
161 metadata: { tokenId },
162 });
163}
164
165export async function verifyAiAgentToken(
166 plaintext: string,
167): Promise<{
168 projectId: string;
169 agentName: string;
170 scopes: string[];
171 tokenId: string;
172} | null> {
173 if (!plaintext.startsWith('brai_')) return null;
174 const pool = getEnginePool();
175 const res = await pool.query(
176 `SELECT id, project_id, agent_name, scopes_json, expires_at, revoked_at
177 FROM be_ai_agent_tokens WHERE token_hash = $1 LIMIT 1`,
178 [hashToken(plaintext)],
179 );
180 const row = res.rows[0] as Record<string, unknown> | undefined;
181 if (!row || row.revoked_at) return null;
182 if (row.expires_at && new Date(row.expires_at as string).getTime() < Date.now()) {
183 return null;
184 }
185 let scopes: string[] = [];
186 try {
187 scopes = JSON.parse(String(row.scopes_json ?? '[]')) as string[];
188 } catch {
189 scopes = [];
190 }
191 await pool.query(
192 `UPDATE be_ai_agent_tokens SET last_used_at = NOW() WHERE id = $1`,
193 [row.id],
194 );
195 return {
196 tokenId: String(row.id),
197 projectId: String(row.project_id),
198 agentName: String(row.agent_name),
199 scopes,
200 };
201}