m2m.ts355 lines · main
1/**
2 * briven-engine M2M (machine-to-machine) client credentials.
3 *
4 * Operators create a client id + secret for a project. Machines exchange that
5 * for a short-lived JWT (OAuth2 client_credentials). The JWT is accepted by
6 * project data-plane auth (requireProjectAuth) for that project only.
7 *
8 * Secrets: shown once at create; only SHA-256 hash stored.
9 */
10
11import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
12
13import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
14
15import { env } from '../../env.js';
16import { getEnginePool } from './db.js';
17import { mapProjectToAuthCore } from './project-map.js';
18import { recordBrivenEngineAudit } from './audit.js';
19
20export type M2mRole = 'viewer' | 'developer' | 'admin';
21
22export type M2mClientRow = {
23 id: string;
24 clientId: string;
25 projectId: string;
26 tenantId: string;
27 name: string;
28 secretSuffix: string;
29 role: M2mRole;
30 revokedAt: string | null;
31 lastUsedAt: string | null;
32 createdBy: string | null;
33 createdAt: string;
34};
35
36export type CreatedM2mClient = {
37 client: M2mClientRow;
38 /** Returned only once at create. */
39 clientSecret: string;
40};
41
42const CLIENT_PREFIX = 'm2m_';
43const SECRET_PREFIX = 'm2ms_';
44const JWT_ISSUER = 'briven-api';
45const JWT_AUDIENCE = 'briven-m2m';
46/** Access token lifetime (seconds). */
47export const M2M_TOKEN_TTL_SECONDS = 60 * 60; // 1 hour
48
49const ROLES: readonly M2mRole[] = ['viewer', 'developer', 'admin'];
50
51export function isM2mRole(v: string): v is M2mRole {
52 return (ROLES as readonly string[]).includes(v);
53}
54
55function hashSecret(secret: string): string {
56 return createHash('sha256').update(`briven-m2m:${secret}`).digest('hex');
57}
58
59function secretBytes(): Uint8Array {
60 const secret = env.BRIVEN_BETTER_AUTH_SECRET;
61 if (!secret) {
62 throw new Error(
63 'BRIVEN_BETTER_AUTH_SECRET is not set — refusing to sign/verify M2M tokens',
64 );
65 }
66 return new TextEncoder().encode(secret);
67}
68
69function iso(v: unknown): string | null {
70 if (v == null) return null;
71 if (v instanceof Date) return v.toISOString();
72 return String(v);
73}
74
75function mapRow(r: Record<string, unknown>): M2mClientRow {
76 return {
77 id: String(r.id),
78 clientId: String(r.client_id),
79 projectId: String(r.project_id),
80 tenantId: String(r.tenant_id),
81 name: String(r.name),
82 secretSuffix: String(r.secret_suffix),
83 role: (isM2mRole(String(r.role)) ? String(r.role) : 'developer') as M2mRole,
84 revokedAt: iso(r.revoked_at),
85 lastUsedAt: iso(r.last_used_at),
86 createdBy: r.created_by ? String(r.created_by) : null,
87 createdAt: iso(r.created_at) ?? new Date().toISOString(),
88 };
89}
90
91export async function createM2mClient(input: {
92 projectId: string;
93 name: string;
94 role?: M2mRole;
95 createdBy?: string | null;
96}): Promise<CreatedM2mClient> {
97 const name = input.name.trim();
98 if (!name || name.length > 80) {
99 throw new Error('name must be 1–80 characters');
100 }
101 const role: M2mRole = input.role && isM2mRole(input.role) ? input.role : 'developer';
102 const map = mapProjectToAuthCore(input.projectId);
103 const id = `bmc_${randomBytes(10).toString('hex')}`;
104 const clientId = `${CLIENT_PREFIX}${randomBytes(12).toString('base64url')}`;
105 const clientSecret = `${SECRET_PREFIX}${randomBytes(24).toString('base64url')}`;
106 const secretHash = hashSecret(clientSecret);
107 const secretSuffix = clientSecret.slice(-4);
108
109 const pool = getEnginePool();
110 await pool.query(
111 `INSERT INTO be_m2m_clients
112 (id, client_id, project_id, tenant_id, name, secret_hash, secret_suffix, role, created_by, created_at)
113 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())`,
114 [
115 id,
116 clientId,
117 input.projectId,
118 map.tenantId,
119 name,
120 secretHash,
121 secretSuffix,
122 role,
123 input.createdBy ?? null,
124 ],
125 );
126
127 void recordBrivenEngineAudit({
128 action: 'm2m.client.created',
129 projectId: input.projectId,
130 tenantId: map.tenantId,
131 userId: input.createdBy ?? null,
132 metadata: { clientId, role, name },
133 });
134
135 const client: M2mClientRow = {
136 id,
137 clientId,
138 projectId: input.projectId,
139 tenantId: map.tenantId,
140 name,
141 secretSuffix,
142 role,
143 revokedAt: null,
144 lastUsedAt: null,
145 createdBy: input.createdBy ?? null,
146 createdAt: new Date().toISOString(),
147 };
148
149 return { client, clientSecret };
150}
151
152export async function listM2mClients(
153 projectId: string,
154): Promise<M2mClientRow[]> {
155 const pool = getEnginePool();
156 const res = await pool.query(
157 `SELECT id, client_id, project_id, tenant_id, name, secret_suffix, role,
158 revoked_at, last_used_at, created_by, created_at
159 FROM be_m2m_clients
160 WHERE project_id = $1
161 ORDER BY created_at DESC`,
162 [projectId],
163 );
164 return (res.rows as Array<Record<string, unknown>>).map(mapRow);
165}
166
167export async function revokeM2mClient(
168 projectId: string,
169 clientId: string,
170): Promise<{ ok: true }> {
171 const pool = getEnginePool();
172 const res = await pool.query(
173 `UPDATE be_m2m_clients
174 SET revoked_at = NOW()
175 WHERE project_id = $1 AND client_id = $2 AND revoked_at IS NULL
176 RETURNING id, client_id`,
177 [projectId, clientId],
178 );
179 if (res.rowCount === 0) {
180 throw new Error('client not found or already revoked');
181 }
182 void recordBrivenEngineAudit({
183 action: 'm2m.client.revoked',
184 projectId,
185 metadata: { clientId },
186 });
187 return { ok: true };
188}
189
190export type M2mTokenPayload = JWTPayload & {
191 scope: 'm2m';
192 project_id: string;
193 role: M2mRole;
194 client_id: string;
195 sub: string;
196};
197
198export async function signM2mAccessToken(input: {
199 clientId: string;
200 projectId: string;
201 role: M2mRole;
202}): Promise<{ accessToken: string; expiresIn: number }> {
203 const accessToken = await new SignJWT({
204 scope: 'm2m',
205 project_id: input.projectId,
206 role: input.role,
207 client_id: input.clientId,
208 })
209 .setProtectedHeader({ alg: 'HS256' })
210 .setSubject(input.clientId)
211 .setIssuer(JWT_ISSUER)
212 .setAudience(JWT_AUDIENCE)
213 .setIssuedAt()
214 .setExpirationTime(`${M2M_TOKEN_TTL_SECONDS}s`)
215 .sign(secretBytes());
216
217 return { accessToken, expiresIn: M2M_TOKEN_TTL_SECONDS };
218}
219
220export async function verifyM2mAccessToken(
221 token: string,
222): Promise<M2mTokenPayload> {
223 const { payload } = await jwtVerify(token, secretBytes(), {
224 issuer: JWT_ISSUER,
225 audience: JWT_AUDIENCE,
226 });
227 if (payload.scope !== 'm2m') {
228 throw new Error('m2m-jwt: wrong scope');
229 }
230 if (typeof payload.project_id !== 'string' || !payload.project_id) {
231 throw new Error('m2m-jwt: missing project_id');
232 }
233 if (typeof payload.client_id !== 'string' || !payload.client_id) {
234 throw new Error('m2m-jwt: missing client_id');
235 }
236 const role = String(payload.role ?? '');
237 if (!isM2mRole(role)) {
238 throw new Error('m2m-jwt: invalid role');
239 }
240 if (typeof payload.sub !== 'string') {
241 throw new Error('m2m-jwt: missing sub');
242 }
243 return payload as M2mTokenPayload;
244}
245
246/**
247 * OAuth2 client_credentials: validate secret, mint access token.
248 */
249export async function issueM2mToken(input: {
250 clientId: string;
251 clientSecret: string;
252}): Promise<
253 | {
254 ok: true;
255 accessToken: string;
256 tokenType: 'Bearer';
257 expiresIn: number;
258 projectId: string;
259 clientId: string;
260 role: M2mRole;
261 }
262 | { ok: false; code: string; message: string }
263> {
264 const clientId = input.clientId.trim();
265 const clientSecret = input.clientSecret.trim();
266 if (!clientId || !clientSecret) {
267 return {
268 ok: false,
269 code: 'invalid_client',
270 message: 'client_id and client_secret required',
271 };
272 }
273
274 const pool = getEnginePool();
275 const res = await pool.query(
276 `SELECT id, client_id, project_id, tenant_id, name, secret_hash, role, revoked_at
277 FROM be_m2m_clients
278 WHERE client_id = $1
279 LIMIT 1`,
280 [clientId],
281 );
282 const row = res.rows[0] as Record<string, unknown> | undefined;
283 if (!row) {
284 void recordBrivenEngineAudit({
285 action: 'm2m.token.fail',
286 metadata: { clientId, reason: 'unknown_client' },
287 });
288 return {
289 ok: false,
290 code: 'invalid_client',
291 message: 'unknown client',
292 };
293 }
294 if (row.revoked_at) {
295 void recordBrivenEngineAudit({
296 action: 'm2m.token.fail',
297 projectId: String(row.project_id),
298 metadata: { clientId, reason: 'revoked' },
299 });
300 return {
301 ok: false,
302 code: 'invalid_client',
303 message: 'client revoked',
304 };
305 }
306
307 const expected = String(row.secret_hash);
308 const got = hashSecret(clientSecret);
309 const expectedBuf = Buffer.from(expected, 'utf8');
310 const gotBuf = Buffer.from(got, 'utf8');
311 const secretOk =
312 expectedBuf.length === gotBuf.length &&
313 timingSafeEqual(expectedBuf, gotBuf);
314 if (!secretOk) {
315 void recordBrivenEngineAudit({
316 action: 'm2m.token.fail',
317 projectId: String(row.project_id),
318 metadata: { clientId, reason: 'bad_secret' },
319 });
320 return {
321 ok: false,
322 code: 'invalid_client',
323 message: 'invalid client credentials',
324 };
325 }
326
327 const role = (isM2mRole(String(row.role)) ? String(row.role) : 'developer') as M2mRole;
328 const projectId = String(row.project_id);
329 const { accessToken, expiresIn } = await signM2mAccessToken({
330 clientId,
331 projectId,
332 role,
333 });
334
335 await pool.query(
336 `UPDATE be_m2m_clients SET last_used_at = NOW() WHERE client_id = $1`,
337 [clientId],
338 );
339
340 void recordBrivenEngineAudit({
341 action: 'm2m.token.issued',
342 projectId,
343 metadata: { clientId, role, expiresIn },
344 });
345
346 return {
347 ok: true,
348 accessToken,
349 tokenType: 'Bearer',
350 expiresIn,
351 projectId,
352 clientId,
353 role,
354 };
355}