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 | |
| 11 | import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; |
| 12 | |
| 13 | import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; |
| 14 | |
| 15 | import { env } from '../../env.js'; |
| 16 | import { getEnginePool } from './db.js'; |
| 17 | import { mapProjectToAuthCore } from './project-map.js'; |
| 18 | import { recordBrivenEngineAudit } from './audit.js'; |
| 19 | |
| 20 | export type M2mRole = 'viewer' | 'developer' | 'admin'; |
| 21 | |
| 22 | export 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 | |
| 36 | export type CreatedM2mClient = { |
| 37 | client: M2mClientRow; |
| 38 | /** Returned only once at create. */ |
| 39 | clientSecret: string; |
| 40 | }; |
| 41 | |
| 42 | const CLIENT_PREFIX = 'm2m_'; |
| 43 | const SECRET_PREFIX = 'm2ms_'; |
| 44 | const JWT_ISSUER = 'briven-api'; |
| 45 | const JWT_AUDIENCE = 'briven-m2m'; |
| 46 | /** Access token lifetime (seconds). */ |
| 47 | export const M2M_TOKEN_TTL_SECONDS = 60 * 60; // 1 hour |
| 48 | |
| 49 | const ROLES: readonly M2mRole[] = ['viewer', 'developer', 'admin']; |
| 50 | |
| 51 | export function isM2mRole(v: string): v is M2mRole { |
| 52 | return (ROLES as readonly string[]).includes(v); |
| 53 | } |
| 54 | |
| 55 | function hashSecret(secret: string): string { |
| 56 | return createHash('sha256').update(`briven-m2m:${secret}`).digest('hex'); |
| 57 | } |
| 58 | |
| 59 | function 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 | |
| 69 | function 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 | |
| 75 | function 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 | |
| 91 | export 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 | |
| 152 | export 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 | |
| 167 | export 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 | |
| 190 | export type M2mTokenPayload = JWTPayload & { |
| 191 | scope: 'm2m'; |
| 192 | project_id: string; |
| 193 | role: M2mRole; |
| 194 | client_id: string; |
| 195 | sub: string; |
| 196 | }; |
| 197 | |
| 198 | export 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 | |
| 220 | export 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 | */ |
| 249 | export 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 | } |