auth-sdk-keys.ts189 lines · main
1import { createHash, randomBytes } from 'node:crypto';
2
3import { brivenError, newId, NotFoundError, ValidationError } from '@briven/shared';
4import { and, desc, eq } from 'drizzle-orm';
5
6import { getDb } from '../db/client.js';
7import {
8 brivenAuthSdkKeys,
9 brivenAuthSdkKeyScope,
10 type BrivenAuthSdkKey,
11 type BrivenAuthSdkKeyScope,
12} from '../db/schema.js';
13import { decryptValue, encryptValue } from './project-env.js';
14
15/**
16 * SDK keys for briven auth — issued from the dashboard's Auth → API Keys
17 * panel. Embedded in customer apps via `@briven/auth`'s `createBrivenAuth({
18 * publicKey })`. Different from the CLI deploy keys in `services/api-keys.ts`:
19 *
20 * - prefix `pk_briven_auth_` (recognisable in logs + grep + leaked-cred scans)
21 * - scope vocabulary `read | read-write | admin` instead of MemberRole
22 * - separate table (`briven_auth_sdk_keys`) so the deploy-key admin path
23 * and the SDK-key admin path don't share authorisation logic
24 *
25 * Plaintext is returned on creation and can be revealed later via the audited
26 * copy-again path (AES-256-GCM `encrypted_key`, migration 0039). Auth
27 * verification always uses the sha-256 `hash` only — leaking the dump without
28 * the KEK still leaks zero usable keys for sign-in.
29 */
30
31const KEY_PREFIX = 'pk_briven_auth_';
32const KEY_ENTROPY_BYTES = 32; // 256 bits
33const NAME_MIN = 1;
34const NAME_MAX = 64;
35
36export function isAssignableSdkKeyScope(scope: string): scope is BrivenAuthSdkKeyScope {
37 return (brivenAuthSdkKeyScope as readonly string[]).includes(scope);
38}
39
40export interface CreatedSdkKey {
41 record: BrivenAuthSdkKey;
42 /** The only place the plaintext ever exists outside the customer's clipboard. */
43 plaintext: string;
44}
45
46export async function createAuthSdkKey(input: {
47 projectId: string;
48 createdBy: string;
49 name: string;
50 scope?: BrivenAuthSdkKeyScope;
51 expiresAt?: Date;
52}): Promise<CreatedSdkKey> {
53 const name = input.name.trim();
54 if (name.length < NAME_MIN || name.length > NAME_MAX) {
55 throw new ValidationError(`name must be ${NAME_MIN}-${NAME_MAX} chars`, { name });
56 }
57 const scope = input.scope ?? 'read';
58 if (!isAssignableSdkKeyScope(scope)) {
59 throw new ValidationError(
60 `scope must be one of ${brivenAuthSdkKeyScope.join(' | ')}`,
61 { scope },
62 );
63 }
64
65 const random = randomBytes(KEY_ENTROPY_BYTES).toString('base64url');
66 const plaintext = `${KEY_PREFIX}${random}`;
67 const hash = createHash('sha256').update(plaintext).digest('hex');
68 const suffix = plaintext.slice(-4);
69 // Encrypt-at-rest for the audited "copy again" path. Hash remains the sole
70 // verification mechanism; ciphertext is never used for auth.
71 const encryptedKey = encryptValue(plaintext);
72
73 const db = getDb();
74 const [record] = await db
75 .insert(brivenAuthSdkKeys)
76 .values({
77 id: newId('auk'),
78 projectId: input.projectId,
79 createdBy: input.createdBy,
80 name,
81 hash,
82 encryptedKey,
83 prefix: KEY_PREFIX,
84 suffix,
85 scope,
86 expiresAt: input.expiresAt ?? null,
87 })
88 .returning();
89 if (!record) throw new Error('briven_auth_sdk_keys insert returned no row');
90
91 return { record, plaintext };
92}
93
94export interface MaskedSdkKey {
95 id: string;
96 name: string;
97 prefix: string;
98 suffix: string;
99 scope: BrivenAuthSdkKeyScope;
100 createdAt: Date;
101 lastUsedAt: Date | null;
102 expiresAt: Date | null;
103 revokedAt: Date | null;
104}
105
106export async function listAuthSdkKeysForProject(projectId: string): Promise<MaskedSdkKey[]> {
107 const db = getDb();
108 const rows = await db
109 .select({
110 id: brivenAuthSdkKeys.id,
111 name: brivenAuthSdkKeys.name,
112 prefix: brivenAuthSdkKeys.prefix,
113 suffix: brivenAuthSdkKeys.suffix,
114 scope: brivenAuthSdkKeys.scope,
115 createdAt: brivenAuthSdkKeys.createdAt,
116 lastUsedAt: brivenAuthSdkKeys.lastUsedAt,
117 expiresAt: brivenAuthSdkKeys.expiresAt,
118 revokedAt: brivenAuthSdkKeys.revokedAt,
119 })
120 .from(brivenAuthSdkKeys)
121 .where(eq(brivenAuthSdkKeys.projectId, projectId))
122 .orderBy(desc(brivenAuthSdkKeys.createdAt));
123 return rows;
124}
125
126export async function revokeAuthSdkKey(projectId: string, keyId: string): Promise<void> {
127 const db = getDb();
128 const [row] = await db
129 .select()
130 .from(brivenAuthSdkKeys)
131 .where(and(eq(brivenAuthSdkKeys.id, keyId), eq(brivenAuthSdkKeys.projectId, projectId)))
132 .limit(1);
133 if (!row) throw new NotFoundError('briven_auth_sdk_key', keyId);
134 if (row.revokedAt) return; // already revoked — idempotent
135 await db
136 .update(brivenAuthSdkKeys)
137 .set({ revokedAt: new Date() })
138 .where(eq(brivenAuthSdkKeys.id, keyId));
139}
140
141/**
142 * Decrypt a stored SDK key for the audited "copy again" dashboard action.
143 * Refuses revoked keys and pre-0039 rows with no ciphertext.
144 */
145export async function revealAuthSdkKey(
146 projectId: string,
147 keyId: string,
148): Promise<{ plaintext: string }> {
149 const db = getDb();
150 const [row] = await db
151 .select()
152 .from(brivenAuthSdkKeys)
153 .where(and(eq(brivenAuthSdkKeys.id, keyId), eq(brivenAuthSdkKeys.projectId, projectId)))
154 .limit(1);
155 if (!row) throw new NotFoundError('briven_auth_sdk_key', keyId);
156 if (row.revokedAt || !row.encryptedKey) {
157 throw new brivenError('key_not_revealable', 'key cannot be revealed', { status: 404 });
158 }
159 return { plaintext: decryptValue(row.encryptedKey) };
160}
161
162/**
163 * Resolve a plaintext SDK key to the project it belongs to. Used by the
164 * runtime middleware that authenticates incoming SDK requests. Returns
165 * null for unknown, revoked, or expired keys. Bumps `last_used_at` on hit.
166 */
167export async function resolveAuthSdkKey(
168 plaintext: string,
169): Promise<{ projectId: string; keyId: string; scope: BrivenAuthSdkKeyScope } | null> {
170 if (!plaintext.startsWith(KEY_PREFIX)) return null;
171 const hash = createHash('sha256').update(plaintext).digest('hex');
172 const db = getDb();
173 const [row] = await db
174 .select()
175 .from(brivenAuthSdkKeys)
176 .where(eq(brivenAuthSdkKeys.hash, hash))
177 .limit(1);
178 if (!row) return null;
179 if (row.revokedAt) return null;
180 if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null;
181
182 await db
183 .update(brivenAuthSdkKeys)
184 .set({ lastUsedAt: new Date() })
185 .where(eq(brivenAuthSdkKeys.id, row.id));
186 return { projectId: row.projectId, keyId: row.id, scope: row.scope };
187}
188
189export { KEY_PREFIX as AUTH_SDK_KEY_PREFIX };