@checkstack/auth-backend 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -0
- package/drizzle/0000_minor_virginia_dare.sql +90 -0
- package/drizzle/0001_certain_madame_hydra.sql +20 -0
- package/drizzle/meta/0000_snapshot.json +580 -0
- package/drizzle/meta/0001_snapshot.json +717 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +7 -0
- package/package.json +34 -0
- package/src/hooks.ts +14 -0
- package/src/index.ts +878 -0
- package/src/meta-config.ts +13 -0
- package/src/platform-registration-config.ts +25 -0
- package/src/router.test.ts +440 -0
- package/src/router.ts +1051 -0
- package/src/schema.ts +173 -0
- package/src/utils/auth-error-redirect.ts +42 -0
- package/src/utils/user.test.ts +99 -0
- package/src/utils/user.ts +62 -0
- package/src/utils/validate-schema.test.ts +85 -0
- package/src/utils/validate-schema.ts +45 -0
- package/tsconfig.json +6 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth";
|
|
2
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
3
|
+
import { APIError } from "better-auth/api";
|
|
4
|
+
import {
|
|
5
|
+
createBackendPlugin,
|
|
6
|
+
coreServices,
|
|
7
|
+
coreHooks,
|
|
8
|
+
authenticationStrategyServiceRef,
|
|
9
|
+
type AuthStrategy,
|
|
10
|
+
} from "@checkstack/backend-api";
|
|
11
|
+
import {
|
|
12
|
+
pluginMetadata,
|
|
13
|
+
permissionList,
|
|
14
|
+
authContract,
|
|
15
|
+
authRoutes,
|
|
16
|
+
permissions,
|
|
17
|
+
} from "@checkstack/auth-common";
|
|
18
|
+
import { NotificationApi } from "@checkstack/notification-common";
|
|
19
|
+
import * as schema from "./schema";
|
|
20
|
+
import { eq, inArray, or } from "drizzle-orm";
|
|
21
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
22
|
+
import { User } from "better-auth/types";
|
|
23
|
+
import { hashPassword, verifyPassword } from "better-auth/crypto";
|
|
24
|
+
import { createExtensionPoint } from "@checkstack/backend-api";
|
|
25
|
+
import { enrichUser } from "./utils/user";
|
|
26
|
+
import { createAuthRouter } from "./router";
|
|
27
|
+
import { validateStrategySchema } from "./utils/validate-schema";
|
|
28
|
+
import {
|
|
29
|
+
strategyMetaConfigV1,
|
|
30
|
+
STRATEGY_META_CONFIG_VERSION,
|
|
31
|
+
} from "./meta-config";
|
|
32
|
+
import {
|
|
33
|
+
platformRegistrationConfigV1,
|
|
34
|
+
PLATFORM_REGISTRATION_CONFIG_VERSION,
|
|
35
|
+
PLATFORM_REGISTRATION_CONFIG_ID,
|
|
36
|
+
} from "./platform-registration-config";
|
|
37
|
+
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
38
|
+
import { resolveRoute } from "@checkstack/common";
|
|
39
|
+
|
|
40
|
+
export interface BetterAuthExtensionPoint {
|
|
41
|
+
addStrategy(strategy: AuthStrategy<unknown>): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const betterAuthExtensionPoint =
|
|
45
|
+
createExtensionPoint<BetterAuthExtensionPoint>(
|
|
46
|
+
"auth.betterAuthExtensionPoint"
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sync permissions to database and assign to admin role.
|
|
51
|
+
* @param fullSync - If true, also performs orphan cleanup and default role sync.
|
|
52
|
+
* Should only be true when syncing ALL permissions (not per-plugin hooks).
|
|
53
|
+
*/
|
|
54
|
+
async function syncPermissionsToDb({
|
|
55
|
+
database,
|
|
56
|
+
logger,
|
|
57
|
+
permissions,
|
|
58
|
+
fullSync = false,
|
|
59
|
+
}: {
|
|
60
|
+
database: NodePgDatabase<typeof schema>;
|
|
61
|
+
logger: { debug: (msg: string) => void };
|
|
62
|
+
permissions: {
|
|
63
|
+
id: string;
|
|
64
|
+
description?: string;
|
|
65
|
+
isAuthenticatedDefault?: boolean;
|
|
66
|
+
isPublicDefault?: boolean;
|
|
67
|
+
}[];
|
|
68
|
+
fullSync?: boolean;
|
|
69
|
+
}) {
|
|
70
|
+
logger.debug(`🔑 Syncing ${permissions.length} permissions to database...`);
|
|
71
|
+
|
|
72
|
+
for (const perm of permissions) {
|
|
73
|
+
const existing = await database
|
|
74
|
+
.select()
|
|
75
|
+
.from(schema.permission)
|
|
76
|
+
.where(eq(schema.permission.id, perm.id));
|
|
77
|
+
|
|
78
|
+
if (existing.length === 0) {
|
|
79
|
+
await database.insert(schema.permission).values(perm);
|
|
80
|
+
logger.debug(` -> Created permission: ${perm.id}`);
|
|
81
|
+
} else {
|
|
82
|
+
await database
|
|
83
|
+
.update(schema.permission)
|
|
84
|
+
.set({ description: perm.description })
|
|
85
|
+
.where(eq(schema.permission.id, perm.id));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Assign all permissions to admin role
|
|
90
|
+
const adminRolePermissions = await database
|
|
91
|
+
.select()
|
|
92
|
+
.from(schema.rolePermission)
|
|
93
|
+
.where(eq(schema.rolePermission.roleId, "admin"));
|
|
94
|
+
|
|
95
|
+
for (const perm of permissions) {
|
|
96
|
+
const hasPermission = adminRolePermissions.some(
|
|
97
|
+
(rp) => rp.permissionId === perm.id
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (!hasPermission) {
|
|
101
|
+
await database
|
|
102
|
+
.insert(schema.rolePermission)
|
|
103
|
+
.values({
|
|
104
|
+
roleId: "admin",
|
|
105
|
+
permissionId: perm.id,
|
|
106
|
+
})
|
|
107
|
+
.onConflictDoNothing();
|
|
108
|
+
logger.debug(` -> Assigned permission ${perm.id} to admin role`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Only perform orphan cleanup and default sync when doing a full sync
|
|
113
|
+
// (i.e., when we have ALL permissions, not just one plugin's permissions from a hook)
|
|
114
|
+
if (!fullSync) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Cleanup orphan permissions (no longer registered by any plugin)
|
|
119
|
+
const registeredIds = new Set(permissions.map((p) => p.id));
|
|
120
|
+
const allDbPermissions = await database.select().from(schema.permission);
|
|
121
|
+
const orphanPermissions = allDbPermissions.filter(
|
|
122
|
+
(p) => !registeredIds.has(p.id)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (orphanPermissions.length > 0) {
|
|
126
|
+
logger.debug(
|
|
127
|
+
`🧹 Removing ${orphanPermissions.length} orphan permission(s)...`
|
|
128
|
+
);
|
|
129
|
+
for (const orphan of orphanPermissions) {
|
|
130
|
+
// Delete role_permission entries first (FK doesn't cascade)
|
|
131
|
+
await database
|
|
132
|
+
.delete(schema.rolePermission)
|
|
133
|
+
.where(eq(schema.rolePermission.permissionId, orphan.id));
|
|
134
|
+
// Then delete the permission itself
|
|
135
|
+
await database
|
|
136
|
+
.delete(schema.permission)
|
|
137
|
+
.where(eq(schema.permission.id, orphan.id));
|
|
138
|
+
logger.debug(` -> Removed orphan permission: ${orphan.id}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Sync authenticated default permissions to users role
|
|
143
|
+
await syncAuthenticatedDefaultPermissionsToUsersRole({
|
|
144
|
+
database,
|
|
145
|
+
logger,
|
|
146
|
+
permissions,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Sync public default permissions to anonymous role
|
|
150
|
+
await syncPublicDefaultPermissionsToAnonymousRole({
|
|
151
|
+
database,
|
|
152
|
+
logger,
|
|
153
|
+
permissions,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Sync authenticated default permissions (isAuthenticatedDefault=true) to the "users" role.
|
|
159
|
+
* Respects admin-disabled defaults stored in disabled_default_permission table.
|
|
160
|
+
*/
|
|
161
|
+
async function syncAuthenticatedDefaultPermissionsToUsersRole({
|
|
162
|
+
database,
|
|
163
|
+
logger,
|
|
164
|
+
permissions,
|
|
165
|
+
}: {
|
|
166
|
+
database: NodePgDatabase<typeof schema>;
|
|
167
|
+
logger: { debug: (msg: string) => void };
|
|
168
|
+
permissions: { id: string; isAuthenticatedDefault?: boolean }[];
|
|
169
|
+
}) {
|
|
170
|
+
// Debug: log all permissions with their isAuthenticatedDefault status
|
|
171
|
+
logger.debug(
|
|
172
|
+
`[DEBUG] All permissions received (${permissions.length} total):`
|
|
173
|
+
);
|
|
174
|
+
for (const p of permissions) {
|
|
175
|
+
logger.debug(
|
|
176
|
+
` -> ${p.id}: isAuthenticatedDefault=${p.isAuthenticatedDefault}`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const defaultPermissions = permissions.filter(
|
|
181
|
+
(p) => p.isAuthenticatedDefault
|
|
182
|
+
);
|
|
183
|
+
logger.debug(
|
|
184
|
+
`👥 Found ${defaultPermissions.length} authenticated default permissions to sync to users role`
|
|
185
|
+
);
|
|
186
|
+
if (defaultPermissions.length === 0) {
|
|
187
|
+
logger.debug(
|
|
188
|
+
` -> No authenticated default permissions found, skipping sync`
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Get already disabled defaults (admin has removed them)
|
|
194
|
+
const disabledDefaults = await database
|
|
195
|
+
.select()
|
|
196
|
+
.from(schema.disabledDefaultPermission);
|
|
197
|
+
const disabledIds = new Set(disabledDefaults.map((d) => d.permissionId));
|
|
198
|
+
|
|
199
|
+
// Get current users role permissions
|
|
200
|
+
const usersRolePermissions = await database
|
|
201
|
+
.select()
|
|
202
|
+
.from(schema.rolePermission)
|
|
203
|
+
.where(eq(schema.rolePermission.roleId, "users"));
|
|
204
|
+
|
|
205
|
+
for (const perm of defaultPermissions) {
|
|
206
|
+
// Skip if admin has disabled this default
|
|
207
|
+
if (disabledIds.has(perm.id)) {
|
|
208
|
+
logger.debug(` -> Skipping disabled authenticated default: ${perm.id}`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const hasPermission = usersRolePermissions.some(
|
|
213
|
+
(rp) => rp.permissionId === perm.id
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (!hasPermission) {
|
|
217
|
+
await database.insert(schema.rolePermission).values({
|
|
218
|
+
roleId: "users",
|
|
219
|
+
permissionId: perm.id,
|
|
220
|
+
});
|
|
221
|
+
logger.debug(
|
|
222
|
+
` -> Assigned authenticated default permission ${perm.id} to users role`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Sync public default permissions (isPublicDefault=true) to the "anonymous" role.
|
|
230
|
+
* Respects admin-disabled defaults stored in disabled_public_default_permission table.
|
|
231
|
+
*/
|
|
232
|
+
async function syncPublicDefaultPermissionsToAnonymousRole({
|
|
233
|
+
database,
|
|
234
|
+
logger,
|
|
235
|
+
permissions,
|
|
236
|
+
}: {
|
|
237
|
+
database: NodePgDatabase<typeof schema>;
|
|
238
|
+
logger: { debug: (msg: string) => void };
|
|
239
|
+
permissions: { id: string; isPublicDefault?: boolean }[];
|
|
240
|
+
}) {
|
|
241
|
+
const publicDefaults = permissions.filter((p) => p.isPublicDefault);
|
|
242
|
+
logger.debug(
|
|
243
|
+
`🌐 Found ${publicDefaults.length} public default permissions to sync to anonymous role`
|
|
244
|
+
);
|
|
245
|
+
if (publicDefaults.length === 0) {
|
|
246
|
+
logger.debug(` -> No public default permissions found, skipping sync`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Get already disabled public defaults (admin has removed them)
|
|
251
|
+
const disabledDefaults = await database
|
|
252
|
+
.select()
|
|
253
|
+
.from(schema.disabledPublicDefaultPermission);
|
|
254
|
+
const disabledIds = new Set(disabledDefaults.map((d) => d.permissionId));
|
|
255
|
+
|
|
256
|
+
// Get current anonymous role permissions
|
|
257
|
+
const anonymousRolePermissions = await database
|
|
258
|
+
.select()
|
|
259
|
+
.from(schema.rolePermission)
|
|
260
|
+
.where(eq(schema.rolePermission.roleId, "anonymous"));
|
|
261
|
+
|
|
262
|
+
for (const perm of publicDefaults) {
|
|
263
|
+
// Skip if admin has disabled this public default
|
|
264
|
+
if (disabledIds.has(perm.id)) {
|
|
265
|
+
logger.debug(` -> Skipping disabled public default: ${perm.id}`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const hasPermission = anonymousRolePermissions.some(
|
|
270
|
+
(rp) => rp.permissionId === perm.id
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (!hasPermission) {
|
|
274
|
+
await database.insert(schema.rolePermission).values({
|
|
275
|
+
roleId: "anonymous",
|
|
276
|
+
permissionId: perm.id,
|
|
277
|
+
});
|
|
278
|
+
logger.debug(
|
|
279
|
+
` -> Assigned public default permission ${perm.id} to anonymous role`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export default createBackendPlugin({
|
|
286
|
+
metadata: pluginMetadata,
|
|
287
|
+
register(env) {
|
|
288
|
+
let auth: ReturnType<typeof betterAuth> | undefined;
|
|
289
|
+
let db: NodePgDatabase<typeof schema> | undefined;
|
|
290
|
+
|
|
291
|
+
const strategies: AuthStrategy<unknown>[] = [];
|
|
292
|
+
|
|
293
|
+
// Strategy registry
|
|
294
|
+
const strategyRegistry = {
|
|
295
|
+
getStrategies: () => strategies,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// Permission registry - gets all permissions from PluginManager
|
|
299
|
+
const permissionRegistry = {
|
|
300
|
+
getPermissions: () => {
|
|
301
|
+
// Get all permissions from the central PluginManager registry
|
|
302
|
+
return env.pluginManager.getAllPermissions();
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
env.registerPermissions(permissionList);
|
|
307
|
+
|
|
308
|
+
env.registerExtensionPoint(betterAuthExtensionPoint, {
|
|
309
|
+
addStrategy: (s) => {
|
|
310
|
+
// Validate that the strategy schema doesn't have required fields without defaults
|
|
311
|
+
try {
|
|
312
|
+
validateStrategySchema(s.configSchema, s.id);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
const message =
|
|
315
|
+
error instanceof Error ? error.message : String(error);
|
|
316
|
+
throw new Error(
|
|
317
|
+
`Failed to register authentication strategy "${s.id}": ${message}`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
strategies.push(s);
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Helper to fetch permissions
|
|
325
|
+
const enrichUserLocal = async (user: User) => {
|
|
326
|
+
if (!db) return user;
|
|
327
|
+
return enrichUser(user, db);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// 2. Register Authentication Strategy (used by Core AuthService)
|
|
331
|
+
env.registerService(authenticationStrategyServiceRef, {
|
|
332
|
+
validate: async (request: Request) => {
|
|
333
|
+
if (!db) {
|
|
334
|
+
return; // Not initialized yet
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Check for API key authentication (Bearer ck_<appId>_<secret>)
|
|
338
|
+
const authHeader = request.headers.get("authorization");
|
|
339
|
+
if (authHeader?.startsWith("Bearer ck_")) {
|
|
340
|
+
const token = authHeader.slice(7); // Remove "Bearer "
|
|
341
|
+
const parts = token.split("_");
|
|
342
|
+
// Token format: ck_<uuid>_<secret>
|
|
343
|
+
// Split: ["ck", "uuid-with-dashes", "secret"]
|
|
344
|
+
// UUID has dashes, so we need to handle properly
|
|
345
|
+
if (parts.length >= 3 && parts[0] === "ck") {
|
|
346
|
+
// The UUID is parts[1] and potentially includes more parts if UUID has dashes
|
|
347
|
+
// For a UUID like "abc-def-ghi", after "ck_", we get the rest split by _
|
|
348
|
+
// Safer approach: find the application ID by parsing
|
|
349
|
+
const tokenWithoutPrefix = token.slice(3); // Remove "ck_"
|
|
350
|
+
// UUID is 36 chars, secret is 32 chars
|
|
351
|
+
const applicationId = tokenWithoutPrefix.slice(0, 36);
|
|
352
|
+
const secret = tokenWithoutPrefix.slice(37); // Skip the _ separator
|
|
353
|
+
|
|
354
|
+
if (applicationId && secret) {
|
|
355
|
+
// Look up application
|
|
356
|
+
const apps = await db
|
|
357
|
+
.select()
|
|
358
|
+
.from(schema.application)
|
|
359
|
+
.where(eq(schema.application.id, applicationId))
|
|
360
|
+
.limit(1);
|
|
361
|
+
|
|
362
|
+
if (apps.length > 0) {
|
|
363
|
+
const app = apps[0];
|
|
364
|
+
// Verify secret using bcrypt
|
|
365
|
+
const isValid = await verifyPassword({
|
|
366
|
+
hash: app.secretHash,
|
|
367
|
+
password: secret,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (isValid) {
|
|
371
|
+
// Update lastUsedAt timestamp (fire-and-forget)
|
|
372
|
+
db.update(schema.application)
|
|
373
|
+
.set({ lastUsedAt: new Date() })
|
|
374
|
+
.where(eq(schema.application.id, applicationId))
|
|
375
|
+
.execute()
|
|
376
|
+
.catch(() => {
|
|
377
|
+
// Ignore errors from lastUsedAt update
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Fetch roles and compute permissions for the application
|
|
381
|
+
const appRoles = await db
|
|
382
|
+
.select({ roleId: schema.applicationRole.roleId })
|
|
383
|
+
.from(schema.applicationRole)
|
|
384
|
+
.where(
|
|
385
|
+
eq(schema.applicationRole.applicationId, applicationId)
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const roleIds = appRoles.map((r) => r.roleId);
|
|
389
|
+
|
|
390
|
+
// Get permissions for these roles
|
|
391
|
+
let permissions: string[] = [];
|
|
392
|
+
if (roleIds.length > 0) {
|
|
393
|
+
const rolePerms = await db
|
|
394
|
+
.select({
|
|
395
|
+
permissionId: schema.rolePermission.permissionId,
|
|
396
|
+
})
|
|
397
|
+
.from(schema.rolePermission)
|
|
398
|
+
.where(inArray(schema.rolePermission.roleId, roleIds));
|
|
399
|
+
|
|
400
|
+
permissions = [
|
|
401
|
+
...new Set(rolePerms.map((rp) => rp.permissionId)),
|
|
402
|
+
];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Return ApplicationUser
|
|
406
|
+
return {
|
|
407
|
+
type: "application" as const,
|
|
408
|
+
id: app.id,
|
|
409
|
+
name: app.name,
|
|
410
|
+
roles: roleIds,
|
|
411
|
+
permissions,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return; // Invalid API key
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Fall back to session-based authentication (better-auth)
|
|
421
|
+
if (!auth) {
|
|
422
|
+
return; // Not initialized yet
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const session = await auth.api.getSession({
|
|
426
|
+
headers: request.headers,
|
|
427
|
+
});
|
|
428
|
+
if (!session?.user) return;
|
|
429
|
+
return enrichUserLocal(session.user);
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// 3. Register Init logic
|
|
434
|
+
env.registerInit({
|
|
435
|
+
schema,
|
|
436
|
+
deps: {
|
|
437
|
+
database: coreServices.database,
|
|
438
|
+
rpc: coreServices.rpc,
|
|
439
|
+
rpcClient: coreServices.rpcClient,
|
|
440
|
+
logger: coreServices.logger,
|
|
441
|
+
auth: coreServices.auth,
|
|
442
|
+
config: coreServices.config,
|
|
443
|
+
},
|
|
444
|
+
init: async ({
|
|
445
|
+
database,
|
|
446
|
+
rpc,
|
|
447
|
+
rpcClient,
|
|
448
|
+
logger,
|
|
449
|
+
auth: _auth,
|
|
450
|
+
config,
|
|
451
|
+
}) => {
|
|
452
|
+
logger.debug("[auth-backend] Initializing Auth Backend...");
|
|
453
|
+
|
|
454
|
+
db = database;
|
|
455
|
+
|
|
456
|
+
// Function to initialize/reinitialize better-auth
|
|
457
|
+
const initializeBetterAuth = async () => {
|
|
458
|
+
const socialProviders: Record<string, unknown> = {};
|
|
459
|
+
logger.debug(
|
|
460
|
+
`[auth-backend] Processing ${strategies.length} strategies...`
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
for (const strategy of strategies) {
|
|
464
|
+
logger.debug(
|
|
465
|
+
`[auth-backend] -> Processing auth strategy: ${strategy.id}`
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
// Skip credential strategy - it's built into better-auth
|
|
469
|
+
if (strategy.id === "credential") continue;
|
|
470
|
+
|
|
471
|
+
// Load config from ConfigService
|
|
472
|
+
const strategyConfig = await config.get(
|
|
473
|
+
strategy.id,
|
|
474
|
+
strategy.configSchema,
|
|
475
|
+
strategy.configVersion,
|
|
476
|
+
strategy.migrations
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
// Check if strategy is enabled from meta config
|
|
480
|
+
const metaConfig = await config.get(
|
|
481
|
+
`${strategy.id}.meta`,
|
|
482
|
+
strategyMetaConfigV1,
|
|
483
|
+
STRATEGY_META_CONFIG_VERSION
|
|
484
|
+
);
|
|
485
|
+
const enabled = metaConfig?.enabled ?? false;
|
|
486
|
+
|
|
487
|
+
if (!enabled) {
|
|
488
|
+
logger.debug(
|
|
489
|
+
`[auth-backend] -> Strategy ${strategy.id} is disabled, skipping`
|
|
490
|
+
);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Add to socialProviders (secrets are already decrypted by ConfigService)
|
|
495
|
+
logger.debug(
|
|
496
|
+
`[auth-backend] -> Config keys for ${
|
|
497
|
+
strategy.id
|
|
498
|
+
}: ${Object.keys(strategyConfig || {}).join(", ")}`
|
|
499
|
+
);
|
|
500
|
+
socialProviders[strategy.id] = strategyConfig;
|
|
501
|
+
logger.debug(
|
|
502
|
+
`[auth-backend] -> ✅ Added ${strategy.id} to socialProviders`
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Check if credential strategy is enabled from meta config
|
|
507
|
+
const credentialStrategy = strategies.find(
|
|
508
|
+
(s) => s.id === "credential"
|
|
509
|
+
);
|
|
510
|
+
const credentialMetaConfig = credentialStrategy
|
|
511
|
+
? await config.get(
|
|
512
|
+
"credential.meta",
|
|
513
|
+
strategyMetaConfigV1,
|
|
514
|
+
STRATEGY_META_CONFIG_VERSION
|
|
515
|
+
)
|
|
516
|
+
: undefined;
|
|
517
|
+
// Default to true on fresh installs (no meta config)
|
|
518
|
+
const credentialEnabled = credentialMetaConfig?.enabled ?? true;
|
|
519
|
+
|
|
520
|
+
// Check platform registration setting
|
|
521
|
+
const platformRegistrationConfig = await config.get(
|
|
522
|
+
PLATFORM_REGISTRATION_CONFIG_ID,
|
|
523
|
+
platformRegistrationConfigV1,
|
|
524
|
+
PLATFORM_REGISTRATION_CONFIG_VERSION
|
|
525
|
+
);
|
|
526
|
+
const registrationAllowed =
|
|
527
|
+
platformRegistrationConfig?.allowRegistration ?? true;
|
|
528
|
+
|
|
529
|
+
logger.debug(
|
|
530
|
+
`[auth-backend] Initializing Better Auth with ${
|
|
531
|
+
Object.keys(socialProviders).length
|
|
532
|
+
} social providers: ${Object.keys(socialProviders).join(", ")}`
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
return betterAuth({
|
|
536
|
+
database: drizzleAdapter(database, {
|
|
537
|
+
provider: "pg",
|
|
538
|
+
schema: { ...schema },
|
|
539
|
+
}),
|
|
540
|
+
emailAndPassword: {
|
|
541
|
+
enabled: credentialEnabled,
|
|
542
|
+
disableSignUp: !registrationAllowed,
|
|
543
|
+
minPasswordLength: 8,
|
|
544
|
+
maxPasswordLength: 128,
|
|
545
|
+
sendResetPassword: async ({ user, url }) => {
|
|
546
|
+
// Send password reset notification via all enabled strategies
|
|
547
|
+
// Using void to prevent timing attacks revealing email existence
|
|
548
|
+
const notificationClient = rpcClient.forPlugin(NotificationApi);
|
|
549
|
+
const frontendUrl =
|
|
550
|
+
process.env.VITE_FRONTEND_URL || "http://localhost:5173";
|
|
551
|
+
const resetUrl = `${frontendUrl}/auth/reset-password?token=${
|
|
552
|
+
url.split("token=")[1] ?? ""
|
|
553
|
+
}`;
|
|
554
|
+
|
|
555
|
+
void notificationClient.sendTransactional({
|
|
556
|
+
userId: user.id,
|
|
557
|
+
notification: {
|
|
558
|
+
title: "Password Reset Request",
|
|
559
|
+
body: `You requested to reset your password. Click the button below to set a new password. This link will expire in 1 hour.\n\nIf you didn't request this, please ignore this message or contact support if you're concerned.`,
|
|
560
|
+
action: {
|
|
561
|
+
label: "Reset Password",
|
|
562
|
+
url: resetUrl,
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
logger.debug(
|
|
568
|
+
`[auth-backend] Password reset email sent to user: ${user.id}`
|
|
569
|
+
);
|
|
570
|
+
},
|
|
571
|
+
resetPasswordTokenExpiresIn: 60 * 60, // 1 hour
|
|
572
|
+
},
|
|
573
|
+
socialProviders,
|
|
574
|
+
basePath: "/api/auth",
|
|
575
|
+
baseURL: process.env.VITE_API_BASE_URL || "http://localhost:3000",
|
|
576
|
+
trustedOrigins: [
|
|
577
|
+
process.env.VITE_FRONTEND_URL || "http://localhost:5173",
|
|
578
|
+
],
|
|
579
|
+
databaseHooks: {
|
|
580
|
+
user: {
|
|
581
|
+
create: {
|
|
582
|
+
before: async (user) => {
|
|
583
|
+
// Block new user creation when registration is disabled
|
|
584
|
+
// Credential registration is already blocked by disableSignUp,
|
|
585
|
+
// so any user.create here must be from social providers
|
|
586
|
+
if (!registrationAllowed) {
|
|
587
|
+
throw new APIError("FORBIDDEN", {
|
|
588
|
+
message:
|
|
589
|
+
"Registration is currently disabled. Please contact an administrator.",
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return { data: user };
|
|
593
|
+
},
|
|
594
|
+
after: async (user) => {
|
|
595
|
+
// Auto-assign "users" role to new users
|
|
596
|
+
try {
|
|
597
|
+
await database.insert(schema.userRole).values({
|
|
598
|
+
userId: user.id,
|
|
599
|
+
roleId: "users",
|
|
600
|
+
});
|
|
601
|
+
logger.debug(
|
|
602
|
+
`[auth-backend] Assigned 'users' role to new user: ${user.id}`
|
|
603
|
+
);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
// Role might not exist yet on first boot, that's okay
|
|
606
|
+
logger.debug(
|
|
607
|
+
`[auth-backend] Could not assign 'users' role to ${user.id}: ${error}`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// Initialize better-auth
|
|
618
|
+
auth = await initializeBetterAuth();
|
|
619
|
+
|
|
620
|
+
// Reload function for dynamic auth config changes
|
|
621
|
+
const reloadAuth = async () => {
|
|
622
|
+
logger.info(
|
|
623
|
+
"[auth-backend] Reloading authentication configuration..."
|
|
624
|
+
);
|
|
625
|
+
auth = await initializeBetterAuth();
|
|
626
|
+
logger.info("[auth-backend] ✅ Authentication reloaded successfully");
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// IMPORTANT: Seed roles BEFORE syncing permissions so default perms can be assigned
|
|
630
|
+
logger.debug("🌱 Checking for initial roles...");
|
|
631
|
+
const adminRole = await database
|
|
632
|
+
.select()
|
|
633
|
+
.from(schema.role)
|
|
634
|
+
.where(eq(schema.role.id, "admin"));
|
|
635
|
+
if (adminRole.length === 0) {
|
|
636
|
+
await database.insert(schema.role).values({
|
|
637
|
+
id: "admin",
|
|
638
|
+
name: "Administrators",
|
|
639
|
+
isSystem: true,
|
|
640
|
+
});
|
|
641
|
+
logger.info(" -> Created 'admin' role.");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Seed "users" role for default permissions
|
|
645
|
+
const usersRole = await database
|
|
646
|
+
.select()
|
|
647
|
+
.from(schema.role)
|
|
648
|
+
.where(eq(schema.role.id, "users"));
|
|
649
|
+
if (usersRole.length === 0) {
|
|
650
|
+
await database.insert(schema.role).values({
|
|
651
|
+
id: "users",
|
|
652
|
+
name: "Users",
|
|
653
|
+
description: "Default role for all authenticated users",
|
|
654
|
+
isSystem: true,
|
|
655
|
+
});
|
|
656
|
+
logger.info(" -> Created 'users' role.");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Seed "anonymous" role for public access
|
|
660
|
+
const anonymousRole = await database
|
|
661
|
+
.select()
|
|
662
|
+
.from(schema.role)
|
|
663
|
+
.where(eq(schema.role.id, "anonymous"));
|
|
664
|
+
if (anonymousRole.length === 0) {
|
|
665
|
+
await database.insert(schema.role).values({
|
|
666
|
+
id: "anonymous",
|
|
667
|
+
name: "Anonymous Users",
|
|
668
|
+
description: "Permissions for unauthenticated (anonymous) users",
|
|
669
|
+
isSystem: true,
|
|
670
|
+
});
|
|
671
|
+
logger.info(" -> Created 'anonymous' role.");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Seed "applications" role for external API applications
|
|
675
|
+
const applicationsRole = await database
|
|
676
|
+
.select()
|
|
677
|
+
.from(schema.role)
|
|
678
|
+
.where(eq(schema.role.id, "applications"));
|
|
679
|
+
if (applicationsRole.length === 0) {
|
|
680
|
+
await database.insert(schema.role).values({
|
|
681
|
+
id: "applications",
|
|
682
|
+
name: "Applications",
|
|
683
|
+
description: "Default role for external API applications",
|
|
684
|
+
isSystem: true,
|
|
685
|
+
});
|
|
686
|
+
logger.info(" -> Created 'applications' role.");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Note: Permission sync happens in afterPluginsReady (when all plugins have registered)
|
|
690
|
+
|
|
691
|
+
// 4. Register oRPC router
|
|
692
|
+
const authRouter = createAuthRouter(
|
|
693
|
+
database as NodePgDatabase<typeof schema>,
|
|
694
|
+
strategyRegistry,
|
|
695
|
+
reloadAuth,
|
|
696
|
+
config,
|
|
697
|
+
permissionRegistry
|
|
698
|
+
);
|
|
699
|
+
rpc.registerRouter(authRouter, authContract);
|
|
700
|
+
|
|
701
|
+
// 5. Register Better Auth native handler
|
|
702
|
+
rpc.registerHttpHandler((req: Request) => auth!.handler(req));
|
|
703
|
+
|
|
704
|
+
// All auth management endpoints are now via oRPC (see ./router.ts)
|
|
705
|
+
|
|
706
|
+
// 5. Idempotent Admin User Seeding (roles already seeded above)
|
|
707
|
+
const adminId = "initial-admin-id";
|
|
708
|
+
const existingAdmin = await database
|
|
709
|
+
.select()
|
|
710
|
+
.from(schema.user)
|
|
711
|
+
.where(
|
|
712
|
+
or(
|
|
713
|
+
eq(schema.user.email, "admin@checkstack.dev"),
|
|
714
|
+
eq(schema.user.id, adminId)
|
|
715
|
+
)
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
// Skip seeding if user exists by either email or ID
|
|
719
|
+
if (existingAdmin.length === 0) {
|
|
720
|
+
await database.insert(schema.user).values({
|
|
721
|
+
id: adminId,
|
|
722
|
+
name: "Admin",
|
|
723
|
+
email: "admin@checkstack.dev",
|
|
724
|
+
emailVerified: true,
|
|
725
|
+
createdAt: new Date(),
|
|
726
|
+
updatedAt: new Date(),
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const hashedAdminPassword = await hashPassword("admin");
|
|
730
|
+
await database.insert(schema.account).values({
|
|
731
|
+
id: "initial-admin-account-id",
|
|
732
|
+
accountId: "admin@checkstack.dev",
|
|
733
|
+
providerId: "credential",
|
|
734
|
+
userId: adminId,
|
|
735
|
+
password: hashedAdminPassword,
|
|
736
|
+
createdAt: new Date(),
|
|
737
|
+
updatedAt: new Date(),
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
await database.insert(schema.userRole).values({
|
|
741
|
+
userId: adminId,
|
|
742
|
+
roleId: "admin",
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
logger.info(
|
|
746
|
+
" -> Created initial admin user (admin@checkstack.dev : admin)"
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Register command palette commands
|
|
751
|
+
registerSearchProvider({
|
|
752
|
+
pluginMetadata,
|
|
753
|
+
commands: [
|
|
754
|
+
{
|
|
755
|
+
id: "users",
|
|
756
|
+
title: "Manage Users",
|
|
757
|
+
subtitle: "View and manage platform users",
|
|
758
|
+
iconName: "Users",
|
|
759
|
+
shortcuts: ["meta+shift+u", "ctrl+shift+u"],
|
|
760
|
+
route: resolveRoute(authRoutes.routes.settings) + "?tab=users",
|
|
761
|
+
requiredPermissions: [permissions.usersRead],
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
id: "createUser",
|
|
765
|
+
title: "Create User",
|
|
766
|
+
subtitle: "Create a new user account",
|
|
767
|
+
iconName: "UserPlus",
|
|
768
|
+
route:
|
|
769
|
+
resolveRoute(authRoutes.routes.settings) +
|
|
770
|
+
"?tab=users&action=create",
|
|
771
|
+
requiredPermissions: [permissions.usersCreate],
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
id: "roles",
|
|
775
|
+
title: "Manage Roles",
|
|
776
|
+
subtitle: "Manage roles and permissions",
|
|
777
|
+
iconName: "Shield",
|
|
778
|
+
route: resolveRoute(authRoutes.routes.settings) + "?tab=roles",
|
|
779
|
+
requiredPermissions: [permissions.rolesRead],
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
id: "applications",
|
|
783
|
+
title: "Manage Applications",
|
|
784
|
+
subtitle: "Manage external API applications",
|
|
785
|
+
iconName: "Key",
|
|
786
|
+
route:
|
|
787
|
+
resolveRoute(authRoutes.routes.settings) + "?tab=applications",
|
|
788
|
+
requiredPermissions: [permissions.applicationsManage],
|
|
789
|
+
},
|
|
790
|
+
],
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
logger.debug("✅ Auth Backend initialized.");
|
|
794
|
+
},
|
|
795
|
+
// Phase 3: After all plugins are ready - sync all permissions including defaults
|
|
796
|
+
afterPluginsReady: async ({ database, logger, onHook }) => {
|
|
797
|
+
// Now that all plugins are ready, sync permissions including defaults
|
|
798
|
+
// This is critical because during init, other plugins haven't registered yet
|
|
799
|
+
const allPermissions = permissionRegistry.getPermissions();
|
|
800
|
+
logger.debug(
|
|
801
|
+
`[auth-backend] afterPluginsReady: syncing ${allPermissions.length} permissions from all plugins`
|
|
802
|
+
);
|
|
803
|
+
await syncPermissionsToDb({
|
|
804
|
+
database: database as NodePgDatabase<typeof schema>,
|
|
805
|
+
logger,
|
|
806
|
+
permissions: allPermissions,
|
|
807
|
+
fullSync: true,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// Subscribe to permission registration hook for future registrations
|
|
811
|
+
// This syncs new permissions when other plugins register them dynamically
|
|
812
|
+
onHook(
|
|
813
|
+
coreHooks.permissionsRegistered,
|
|
814
|
+
async ({ permissions }) => {
|
|
815
|
+
await syncPermissionsToDb({
|
|
816
|
+
database: database as NodePgDatabase<typeof schema>,
|
|
817
|
+
logger,
|
|
818
|
+
permissions,
|
|
819
|
+
});
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
mode: "work-queue",
|
|
823
|
+
workerGroup: "permission-db-sync",
|
|
824
|
+
maxRetries: 5,
|
|
825
|
+
}
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
// Subscribe to plugin deregistered hook for permission cleanup
|
|
829
|
+
// When a plugin is removed at runtime, delete its permissions from DB
|
|
830
|
+
onHook(
|
|
831
|
+
coreHooks.pluginDeregistered,
|
|
832
|
+
async ({ pluginId }) => {
|
|
833
|
+
logger.debug(
|
|
834
|
+
`[auth-backend] Cleaning up permissions for deregistered plugin: ${pluginId}`
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
// Delete all permissions with this plugin's prefix
|
|
838
|
+
const allDbPermissions = await database
|
|
839
|
+
.select()
|
|
840
|
+
.from(schema.permission);
|
|
841
|
+
const pluginPermissions = allDbPermissions.filter((p) =>
|
|
842
|
+
p.id.startsWith(`${pluginId}.`)
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
for (const perm of pluginPermissions) {
|
|
846
|
+
// Delete role_permission entries first
|
|
847
|
+
await database
|
|
848
|
+
.delete(schema.rolePermission)
|
|
849
|
+
.where(eq(schema.rolePermission.permissionId, perm.id));
|
|
850
|
+
// Then delete the permission itself
|
|
851
|
+
await database
|
|
852
|
+
.delete(schema.permission)
|
|
853
|
+
.where(eq(schema.permission.id, perm.id));
|
|
854
|
+
logger.debug(` -> Removed permission: ${perm.id}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
logger.debug(
|
|
858
|
+
`[auth-backend] Cleaned up ${pluginPermissions.length} permissions for ${pluginId}`
|
|
859
|
+
);
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
mode: "work-queue",
|
|
863
|
+
workerGroup: "permission-cleanup",
|
|
864
|
+
maxRetries: 3,
|
|
865
|
+
}
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
logger.debug("✅ Auth Backend afterPluginsReady complete.");
|
|
869
|
+
},
|
|
870
|
+
});
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
// Re-export utility functions for use by custom auth strategies
|
|
875
|
+
export * from "./utils/auth-error-redirect";
|
|
876
|
+
|
|
877
|
+
// Re-export hooks for cross-plugin communication
|
|
878
|
+
export { authHooks } from "./hooks";
|