@emdash-cms/auth 0.0.1
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/dist/adapters/kysely.d.mts +62 -0
- package/dist/adapters/kysely.d.mts.map +1 -0
- package/dist/adapters/kysely.mjs +379 -0
- package/dist/adapters/kysely.mjs.map +1 -0
- package/dist/authenticate-D5UgaoTH.d.mts +124 -0
- package/dist/authenticate-D5UgaoTH.d.mts.map +1 -0
- package/dist/authenticate-j5GayLXB.mjs +373 -0
- package/dist/authenticate-j5GayLXB.mjs.map +1 -0
- package/dist/index.d.mts +444 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +728 -0
- package/dist/index.mjs.map +1 -0
- package/dist/oauth/providers/github.d.mts +12 -0
- package/dist/oauth/providers/github.d.mts.map +1 -0
- package/dist/oauth/providers/github.mjs +55 -0
- package/dist/oauth/providers/github.mjs.map +1 -0
- package/dist/oauth/providers/google.d.mts +7 -0
- package/dist/oauth/providers/google.d.mts.map +1 -0
- package/dist/oauth/providers/google.mjs +38 -0
- package/dist/oauth/providers/google.mjs.map +1 -0
- package/dist/passkey/index.d.mts +2 -0
- package/dist/passkey/index.mjs +3 -0
- package/dist/types-Bu4irX9A.d.mts +35 -0
- package/dist/types-Bu4irX9A.d.mts.map +1 -0
- package/dist/types-CiSNpRI9.mjs +60 -0
- package/dist/types-CiSNpRI9.mjs.map +1 -0
- package/dist/types-HtRc90Wi.d.mts +208 -0
- package/dist/types-HtRc90Wi.d.mts.map +1 -0
- package/package.json +72 -0
- package/src/adapters/kysely.ts +715 -0
- package/src/config.ts +214 -0
- package/src/index.ts +135 -0
- package/src/invite.ts +205 -0
- package/src/magic-link/index.ts +150 -0
- package/src/oauth/consumer.ts +324 -0
- package/src/oauth/providers/github.ts +68 -0
- package/src/oauth/providers/google.ts +34 -0
- package/src/oauth/types.ts +36 -0
- package/src/passkey/authenticate.ts +183 -0
- package/src/passkey/index.ts +27 -0
- package/src/passkey/register.ts +232 -0
- package/src/passkey/types.ts +120 -0
- package/src/rbac.test.ts +141 -0
- package/src/rbac.ts +205 -0
- package/src/signup.ts +210 -0
- package/src/tokens.test.ts +141 -0
- package/src/tokens.ts +238 -0
- package/src/types.ts +352 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kysely database adapter for @emdash-cms/auth
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Kysely, Insertable, Selectable, Updateable } from "kysely";
|
|
6
|
+
import { ulid } from "ulidx";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
Role,
|
|
10
|
+
toRoleLevel,
|
|
11
|
+
toDeviceType,
|
|
12
|
+
toTokenType,
|
|
13
|
+
type AuthAdapter,
|
|
14
|
+
type User,
|
|
15
|
+
type NewUser,
|
|
16
|
+
type UpdateUser,
|
|
17
|
+
type Credential,
|
|
18
|
+
type NewCredential,
|
|
19
|
+
type AuthToken,
|
|
20
|
+
type NewAuthToken,
|
|
21
|
+
type TokenType,
|
|
22
|
+
type OAuthAccount,
|
|
23
|
+
type NewOAuthAccount,
|
|
24
|
+
type AllowedDomain,
|
|
25
|
+
type RoleLevel,
|
|
26
|
+
} from "../types.js";
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Database schema types
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export interface AuthTables {
|
|
33
|
+
users: UserTable;
|
|
34
|
+
credentials: CredentialTable;
|
|
35
|
+
auth_tokens: AuthTokenTable;
|
|
36
|
+
oauth_accounts: OAuthAccountTable;
|
|
37
|
+
allowed_domains: AllowedDomainTable;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface UserTable {
|
|
41
|
+
id: string;
|
|
42
|
+
email: string;
|
|
43
|
+
name: string | null;
|
|
44
|
+
avatar_url: string | null;
|
|
45
|
+
role: number;
|
|
46
|
+
email_verified: number;
|
|
47
|
+
disabled: number;
|
|
48
|
+
data: string | null;
|
|
49
|
+
created_at: string;
|
|
50
|
+
updated_at: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface CredentialTable {
|
|
54
|
+
id: string;
|
|
55
|
+
user_id: string;
|
|
56
|
+
public_key: Uint8Array;
|
|
57
|
+
counter: number;
|
|
58
|
+
device_type: string;
|
|
59
|
+
backed_up: number;
|
|
60
|
+
transports: string | null;
|
|
61
|
+
name: string | null;
|
|
62
|
+
created_at: string;
|
|
63
|
+
last_used_at: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface AuthTokenTable {
|
|
67
|
+
hash: string;
|
|
68
|
+
user_id: string | null;
|
|
69
|
+
email: string | null;
|
|
70
|
+
type: string;
|
|
71
|
+
role: number | null;
|
|
72
|
+
invited_by: string | null;
|
|
73
|
+
expires_at: string;
|
|
74
|
+
created_at: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface OAuthAccountTable {
|
|
78
|
+
provider: string;
|
|
79
|
+
provider_account_id: string;
|
|
80
|
+
user_id: string;
|
|
81
|
+
created_at: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface AllowedDomainTable {
|
|
85
|
+
domain: string;
|
|
86
|
+
default_role: number;
|
|
87
|
+
enabled: number;
|
|
88
|
+
created_at: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Adapter implementation
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
export function createKyselyAdapter<T extends AuthTables>(db: Kysely<T>): AuthAdapter {
|
|
96
|
+
// Type cast to work with generic Kysely instance
|
|
97
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- generic Kysely<T extends AuthTables> narrowed to concrete AuthTables for internal queries
|
|
98
|
+
const kdb = db as unknown as Kysely<AuthTables>;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
// ========================================================================
|
|
102
|
+
// Users
|
|
103
|
+
// ========================================================================
|
|
104
|
+
|
|
105
|
+
async getUserById(id: string): Promise<User | null> {
|
|
106
|
+
const row = await kdb.selectFrom("users").selectAll().where("id", "=", id).executeTakeFirst();
|
|
107
|
+
|
|
108
|
+
return row ? rowToUser(row) : null;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
async getUserByEmail(email: string): Promise<User | null> {
|
|
112
|
+
const row = await kdb
|
|
113
|
+
.selectFrom("users")
|
|
114
|
+
.selectAll()
|
|
115
|
+
.where("email", "=", email.toLowerCase())
|
|
116
|
+
.executeTakeFirst();
|
|
117
|
+
|
|
118
|
+
return row ? rowToUser(row) : null;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async createUser(user: NewUser): Promise<User> {
|
|
122
|
+
const now = new Date().toISOString();
|
|
123
|
+
const id = ulid();
|
|
124
|
+
|
|
125
|
+
const row: Insertable<UserTable> = {
|
|
126
|
+
id,
|
|
127
|
+
email: user.email.toLowerCase(),
|
|
128
|
+
name: user.name ?? null,
|
|
129
|
+
avatar_url: user.avatarUrl ?? null,
|
|
130
|
+
role: user.role ?? Role.SUBSCRIBER,
|
|
131
|
+
email_verified: user.emailVerified ? 1 : 0,
|
|
132
|
+
disabled: 0,
|
|
133
|
+
data: user.data ? JSON.stringify(user.data) : null,
|
|
134
|
+
created_at: now,
|
|
135
|
+
updated_at: now,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
await kdb.insertInto("users").values(row).execute();
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
id,
|
|
142
|
+
email: row.email,
|
|
143
|
+
name: user.name ?? null,
|
|
144
|
+
avatarUrl: user.avatarUrl ?? null,
|
|
145
|
+
role: toRoleLevel(row.role),
|
|
146
|
+
emailVerified: row.email_verified === 1,
|
|
147
|
+
disabled: false,
|
|
148
|
+
data: user.data ?? null,
|
|
149
|
+
createdAt: new Date(now),
|
|
150
|
+
updatedAt: new Date(now),
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async updateUser(id: string, data: UpdateUser): Promise<void> {
|
|
155
|
+
const update: Updateable<UserTable> = {
|
|
156
|
+
updated_at: new Date().toISOString(),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (data.email !== undefined) update.email = data.email.toLowerCase();
|
|
160
|
+
if (data.name !== undefined) update.name = data.name;
|
|
161
|
+
if (data.avatarUrl !== undefined) update.avatar_url = data.avatarUrl;
|
|
162
|
+
if (data.role !== undefined) update.role = data.role;
|
|
163
|
+
if (data.emailVerified !== undefined) update.email_verified = data.emailVerified ? 1 : 0;
|
|
164
|
+
if (data.disabled !== undefined) update.disabled = data.disabled ? 1 : 0;
|
|
165
|
+
if (data.data !== undefined) update.data = data.data ? JSON.stringify(data.data) : null;
|
|
166
|
+
|
|
167
|
+
await kdb.updateTable("users").set(update).where("id", "=", id).execute();
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async deleteUser(id: string): Promise<void> {
|
|
171
|
+
await kdb.deleteFrom("users").where("id", "=", id).execute();
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async countUsers(): Promise<number> {
|
|
175
|
+
const result = await kdb
|
|
176
|
+
.selectFrom("users")
|
|
177
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
178
|
+
.executeTakeFirstOrThrow();
|
|
179
|
+
|
|
180
|
+
return result.count;
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async getUsers(options?: {
|
|
184
|
+
search?: string;
|
|
185
|
+
role?: number;
|
|
186
|
+
cursor?: string;
|
|
187
|
+
limit?: number;
|
|
188
|
+
}): Promise<{
|
|
189
|
+
items: Array<
|
|
190
|
+
User & {
|
|
191
|
+
lastLogin: Date | null;
|
|
192
|
+
credentialCount: number;
|
|
193
|
+
oauthProviders: string[];
|
|
194
|
+
}
|
|
195
|
+
>;
|
|
196
|
+
nextCursor?: string;
|
|
197
|
+
}> {
|
|
198
|
+
const limit = Math.min(options?.limit ?? 20, 100);
|
|
199
|
+
|
|
200
|
+
let query = kdb
|
|
201
|
+
.selectFrom("users")
|
|
202
|
+
.leftJoin("credentials", "users.id", "credentials.user_id")
|
|
203
|
+
.selectAll("users")
|
|
204
|
+
.select((eb) => [
|
|
205
|
+
eb.fn.count<number>("credentials.id").as("credential_count"),
|
|
206
|
+
eb.fn.max("credentials.last_used_at").as("last_login"),
|
|
207
|
+
])
|
|
208
|
+
.groupBy("users.id")
|
|
209
|
+
.orderBy("users.created_at", "desc")
|
|
210
|
+
.limit(limit + 1);
|
|
211
|
+
|
|
212
|
+
// Apply filters
|
|
213
|
+
if (options?.search) {
|
|
214
|
+
const searchPattern = `%${options.search}%`;
|
|
215
|
+
query = query.where((eb) =>
|
|
216
|
+
eb.or([
|
|
217
|
+
eb("users.email", "like", searchPattern),
|
|
218
|
+
eb("users.name", "like", searchPattern),
|
|
219
|
+
]),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (options?.role !== undefined) {
|
|
224
|
+
query = query.where("users.role", "=", options.role);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (options?.cursor) {
|
|
228
|
+
// Get the cursor user's created_at for pagination
|
|
229
|
+
const cursorUser = await kdb
|
|
230
|
+
.selectFrom("users")
|
|
231
|
+
.select("created_at")
|
|
232
|
+
.where("id", "=", options.cursor)
|
|
233
|
+
.executeTakeFirst();
|
|
234
|
+
|
|
235
|
+
if (cursorUser) {
|
|
236
|
+
query = query.where("users.created_at", "<", cursorUser.created_at);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const rows = await query.execute();
|
|
241
|
+
|
|
242
|
+
// Get OAuth providers for all users in this batch
|
|
243
|
+
const userIds = rows.slice(0, limit).map((r) => r.id);
|
|
244
|
+
const oauthAccounts =
|
|
245
|
+
userIds.length > 0
|
|
246
|
+
? await kdb
|
|
247
|
+
.selectFrom("oauth_accounts")
|
|
248
|
+
.select(["user_id", "provider"])
|
|
249
|
+
.where("user_id", "in", userIds)
|
|
250
|
+
.execute()
|
|
251
|
+
: [];
|
|
252
|
+
|
|
253
|
+
// Group OAuth providers by user
|
|
254
|
+
const oauthByUser = new Map<string, string[]>();
|
|
255
|
+
for (const account of oauthAccounts) {
|
|
256
|
+
const providers = oauthByUser.get(account.user_id) ?? [];
|
|
257
|
+
providers.push(account.provider);
|
|
258
|
+
oauthByUser.set(account.user_id, providers);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const hasMore = rows.length > limit;
|
|
262
|
+
const items = rows.slice(0, limit).map((row) => ({
|
|
263
|
+
id: row.id,
|
|
264
|
+
email: row.email,
|
|
265
|
+
name: row.name,
|
|
266
|
+
avatarUrl: row.avatar_url,
|
|
267
|
+
role: toRoleLevel(row.role),
|
|
268
|
+
emailVerified: row.email_verified === 1,
|
|
269
|
+
disabled: row.disabled === 1,
|
|
270
|
+
data: row.data ? JSON.parse(row.data) : null,
|
|
271
|
+
createdAt: new Date(row.created_at),
|
|
272
|
+
updatedAt: new Date(row.updated_at),
|
|
273
|
+
lastLogin: row.last_login ? new Date(row.last_login) : null,
|
|
274
|
+
credentialCount: row.credential_count ?? 0,
|
|
275
|
+
oauthProviders: oauthByUser.get(row.id) ?? [],
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
items,
|
|
280
|
+
nextCursor: hasMore ? items.at(-1)?.id : undefined,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
|
|
284
|
+
async getUserWithDetails(id: string): Promise<{
|
|
285
|
+
user: User;
|
|
286
|
+
credentials: Credential[];
|
|
287
|
+
oauthAccounts: OAuthAccount[];
|
|
288
|
+
lastLogin: Date | null;
|
|
289
|
+
} | null> {
|
|
290
|
+
const user = await kdb
|
|
291
|
+
.selectFrom("users")
|
|
292
|
+
.selectAll()
|
|
293
|
+
.where("id", "=", id)
|
|
294
|
+
.executeTakeFirst();
|
|
295
|
+
|
|
296
|
+
if (!user) return null;
|
|
297
|
+
|
|
298
|
+
const [credentials, oauthAccounts] = await Promise.all([
|
|
299
|
+
kdb
|
|
300
|
+
.selectFrom("credentials")
|
|
301
|
+
.selectAll()
|
|
302
|
+
.where("user_id", "=", id)
|
|
303
|
+
.orderBy("created_at", "desc")
|
|
304
|
+
.execute(),
|
|
305
|
+
kdb.selectFrom("oauth_accounts").selectAll().where("user_id", "=", id).execute(),
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
// Find last login from most recent credential use
|
|
309
|
+
const lastLogin = credentials.reduce<Date | null>((latest, cred) => {
|
|
310
|
+
const lastUsed = new Date(cred.last_used_at);
|
|
311
|
+
return !latest || lastUsed > latest ? lastUsed : latest;
|
|
312
|
+
}, null);
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
user: rowToUser(user),
|
|
316
|
+
credentials: credentials.map(rowToCredential),
|
|
317
|
+
oauthAccounts: oauthAccounts.map(rowToOAuthAccount),
|
|
318
|
+
lastLogin,
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
async countAdmins(): Promise<number> {
|
|
323
|
+
const result = await kdb
|
|
324
|
+
.selectFrom("users")
|
|
325
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
326
|
+
.where("role", "=", Role.ADMIN)
|
|
327
|
+
.where("disabled", "=", 0)
|
|
328
|
+
.executeTakeFirstOrThrow();
|
|
329
|
+
|
|
330
|
+
return result.count;
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
// ========================================================================
|
|
334
|
+
// Credentials
|
|
335
|
+
// ========================================================================
|
|
336
|
+
|
|
337
|
+
async getCredentialById(id: string): Promise<Credential | null> {
|
|
338
|
+
const row = await kdb
|
|
339
|
+
.selectFrom("credentials")
|
|
340
|
+
.selectAll()
|
|
341
|
+
.where("id", "=", id)
|
|
342
|
+
.executeTakeFirst();
|
|
343
|
+
|
|
344
|
+
return row ? rowToCredential(row) : null;
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
async getCredentialsByUserId(userId: string): Promise<Credential[]> {
|
|
348
|
+
const rows = await kdb
|
|
349
|
+
.selectFrom("credentials")
|
|
350
|
+
.selectAll()
|
|
351
|
+
.where("user_id", "=", userId)
|
|
352
|
+
.execute();
|
|
353
|
+
|
|
354
|
+
return rows.map(rowToCredential);
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async createCredential(credential: NewCredential): Promise<Credential> {
|
|
358
|
+
const now = new Date().toISOString();
|
|
359
|
+
|
|
360
|
+
const row: Insertable<CredentialTable> = {
|
|
361
|
+
id: credential.id,
|
|
362
|
+
user_id: credential.userId,
|
|
363
|
+
public_key: credential.publicKey,
|
|
364
|
+
counter: credential.counter,
|
|
365
|
+
device_type: credential.deviceType,
|
|
366
|
+
backed_up: credential.backedUp ? 1 : 0,
|
|
367
|
+
transports: credential.transports.length > 0 ? JSON.stringify(credential.transports) : null,
|
|
368
|
+
name: credential.name ?? null,
|
|
369
|
+
created_at: now,
|
|
370
|
+
last_used_at: now,
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
await kdb.insertInto("credentials").values(row).execute();
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
id: credential.id,
|
|
377
|
+
userId: credential.userId,
|
|
378
|
+
publicKey: credential.publicKey,
|
|
379
|
+
counter: credential.counter,
|
|
380
|
+
deviceType: credential.deviceType,
|
|
381
|
+
backedUp: credential.backedUp,
|
|
382
|
+
transports: credential.transports,
|
|
383
|
+
name: credential.name ?? null,
|
|
384
|
+
createdAt: new Date(now),
|
|
385
|
+
lastUsedAt: new Date(now),
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
async updateCredentialCounter(id: string, counter: number): Promise<void> {
|
|
390
|
+
await kdb
|
|
391
|
+
.updateTable("credentials")
|
|
392
|
+
.set({
|
|
393
|
+
counter,
|
|
394
|
+
last_used_at: new Date().toISOString(),
|
|
395
|
+
})
|
|
396
|
+
.where("id", "=", id)
|
|
397
|
+
.execute();
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
async updateCredentialName(id: string, name: string | null): Promise<void> {
|
|
401
|
+
await kdb.updateTable("credentials").set({ name }).where("id", "=", id).execute();
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
async deleteCredential(id: string): Promise<void> {
|
|
405
|
+
await kdb.deleteFrom("credentials").where("id", "=", id).execute();
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
async countCredentialsByUserId(userId: string): Promise<number> {
|
|
409
|
+
const result = await kdb
|
|
410
|
+
.selectFrom("credentials")
|
|
411
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
412
|
+
.where("user_id", "=", userId)
|
|
413
|
+
.executeTakeFirstOrThrow();
|
|
414
|
+
|
|
415
|
+
return result.count;
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
// ========================================================================
|
|
419
|
+
// Auth Tokens
|
|
420
|
+
// ========================================================================
|
|
421
|
+
|
|
422
|
+
async createToken(token: NewAuthToken): Promise<void> {
|
|
423
|
+
const row: Insertable<AuthTokenTable> = {
|
|
424
|
+
hash: token.hash,
|
|
425
|
+
user_id: token.userId ?? null,
|
|
426
|
+
email: token.email ?? null,
|
|
427
|
+
type: token.type,
|
|
428
|
+
role: token.role ?? null,
|
|
429
|
+
invited_by: token.invitedBy ?? null,
|
|
430
|
+
expires_at: token.expiresAt.toISOString(),
|
|
431
|
+
created_at: new Date().toISOString(),
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
await kdb.insertInto("auth_tokens").values(row).execute();
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
async getToken(hash: string, type: TokenType): Promise<AuthToken | null> {
|
|
438
|
+
const row = await kdb
|
|
439
|
+
.selectFrom("auth_tokens")
|
|
440
|
+
.selectAll()
|
|
441
|
+
.where("hash", "=", hash)
|
|
442
|
+
.where("type", "=", type)
|
|
443
|
+
.executeTakeFirst();
|
|
444
|
+
|
|
445
|
+
return row ? rowToAuthToken(row) : null;
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
async deleteToken(hash: string): Promise<void> {
|
|
449
|
+
await kdb.deleteFrom("auth_tokens").where("hash", "=", hash).execute();
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
async deleteExpiredTokens(): Promise<void> {
|
|
453
|
+
await kdb
|
|
454
|
+
.deleteFrom("auth_tokens")
|
|
455
|
+
.where("expires_at", "<", new Date().toISOString())
|
|
456
|
+
.execute();
|
|
457
|
+
},
|
|
458
|
+
|
|
459
|
+
// ========================================================================
|
|
460
|
+
// OAuth Accounts
|
|
461
|
+
// ========================================================================
|
|
462
|
+
|
|
463
|
+
async getOAuthAccount(
|
|
464
|
+
provider: string,
|
|
465
|
+
providerAccountId: string,
|
|
466
|
+
): Promise<OAuthAccount | null> {
|
|
467
|
+
const row = await kdb
|
|
468
|
+
.selectFrom("oauth_accounts")
|
|
469
|
+
.selectAll()
|
|
470
|
+
.where("provider", "=", provider)
|
|
471
|
+
.where("provider_account_id", "=", providerAccountId)
|
|
472
|
+
.executeTakeFirst();
|
|
473
|
+
|
|
474
|
+
return row ? rowToOAuthAccount(row) : null;
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
async getOAuthAccountsByUserId(userId: string): Promise<OAuthAccount[]> {
|
|
478
|
+
const rows = await kdb
|
|
479
|
+
.selectFrom("oauth_accounts")
|
|
480
|
+
.selectAll()
|
|
481
|
+
.where("user_id", "=", userId)
|
|
482
|
+
.execute();
|
|
483
|
+
|
|
484
|
+
return rows.map(rowToOAuthAccount);
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
async createOAuthAccount(account: NewOAuthAccount): Promise<OAuthAccount> {
|
|
488
|
+
const now = new Date().toISOString();
|
|
489
|
+
|
|
490
|
+
const row: Insertable<OAuthAccountTable> = {
|
|
491
|
+
provider: account.provider,
|
|
492
|
+
provider_account_id: account.providerAccountId,
|
|
493
|
+
user_id: account.userId,
|
|
494
|
+
created_at: now,
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
await kdb.insertInto("oauth_accounts").values(row).execute();
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
provider: account.provider,
|
|
501
|
+
providerAccountId: account.providerAccountId,
|
|
502
|
+
userId: account.userId,
|
|
503
|
+
createdAt: new Date(now),
|
|
504
|
+
};
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
async deleteOAuthAccount(provider: string, providerAccountId: string): Promise<void> {
|
|
508
|
+
await kdb
|
|
509
|
+
.deleteFrom("oauth_accounts")
|
|
510
|
+
.where("provider", "=", provider)
|
|
511
|
+
.where("provider_account_id", "=", providerAccountId)
|
|
512
|
+
.execute();
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
// ========================================================================
|
|
516
|
+
// Allowed Domains
|
|
517
|
+
// ========================================================================
|
|
518
|
+
|
|
519
|
+
async getAllowedDomain(domain: string): Promise<AllowedDomain | null> {
|
|
520
|
+
const row = await kdb
|
|
521
|
+
.selectFrom("allowed_domains")
|
|
522
|
+
.selectAll()
|
|
523
|
+
.where("domain", "=", domain.toLowerCase())
|
|
524
|
+
.executeTakeFirst();
|
|
525
|
+
|
|
526
|
+
return row ? rowToAllowedDomain(row) : null;
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
async getAllowedDomains(): Promise<AllowedDomain[]> {
|
|
530
|
+
const rows = await kdb.selectFrom("allowed_domains").selectAll().execute();
|
|
531
|
+
|
|
532
|
+
return rows.map(rowToAllowedDomain);
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
async createAllowedDomain(domain: string, defaultRole: RoleLevel): Promise<AllowedDomain> {
|
|
536
|
+
const now = new Date().toISOString();
|
|
537
|
+
|
|
538
|
+
const row: Insertable<AllowedDomainTable> = {
|
|
539
|
+
domain: domain.toLowerCase(),
|
|
540
|
+
default_role: defaultRole,
|
|
541
|
+
enabled: 1,
|
|
542
|
+
created_at: now,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
await kdb.insertInto("allowed_domains").values(row).execute();
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
domain: row.domain,
|
|
549
|
+
defaultRole,
|
|
550
|
+
enabled: true,
|
|
551
|
+
createdAt: new Date(now),
|
|
552
|
+
};
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
async updateAllowedDomain(
|
|
556
|
+
domain: string,
|
|
557
|
+
enabled: boolean,
|
|
558
|
+
defaultRole?: RoleLevel,
|
|
559
|
+
): Promise<void> {
|
|
560
|
+
const update: Updateable<AllowedDomainTable> = {
|
|
561
|
+
enabled: enabled ? 1 : 0,
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
if (defaultRole !== undefined) {
|
|
565
|
+
update.default_role = defaultRole;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
await kdb
|
|
569
|
+
.updateTable("allowed_domains")
|
|
570
|
+
.set(update)
|
|
571
|
+
.where("domain", "=", domain.toLowerCase())
|
|
572
|
+
.execute();
|
|
573
|
+
},
|
|
574
|
+
|
|
575
|
+
async deleteAllowedDomain(domain: string): Promise<void> {
|
|
576
|
+
await kdb.deleteFrom("allowed_domains").where("domain", "=", domain.toLowerCase()).execute();
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ============================================================================
|
|
582
|
+
// Row converters
|
|
583
|
+
// ============================================================================
|
|
584
|
+
|
|
585
|
+
function rowToUser(row: Selectable<UserTable>): User {
|
|
586
|
+
return {
|
|
587
|
+
id: row.id,
|
|
588
|
+
email: row.email,
|
|
589
|
+
name: row.name,
|
|
590
|
+
avatarUrl: row.avatar_url,
|
|
591
|
+
role: toRoleLevel(row.role),
|
|
592
|
+
emailVerified: row.email_verified === 1,
|
|
593
|
+
disabled: row.disabled === 1,
|
|
594
|
+
data: row.data ? JSON.parse(row.data) : null,
|
|
595
|
+
createdAt: new Date(row.created_at),
|
|
596
|
+
updatedAt: new Date(row.updated_at),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function rowToCredential(row: Selectable<CredentialTable>): Credential {
|
|
601
|
+
return {
|
|
602
|
+
id: row.id,
|
|
603
|
+
userId: row.user_id,
|
|
604
|
+
publicKey: row.public_key,
|
|
605
|
+
counter: row.counter,
|
|
606
|
+
deviceType: toDeviceType(row.device_type),
|
|
607
|
+
backedUp: row.backed_up === 1,
|
|
608
|
+
transports: row.transports ? JSON.parse(row.transports) : [],
|
|
609
|
+
name: row.name,
|
|
610
|
+
createdAt: new Date(row.created_at),
|
|
611
|
+
lastUsedAt: new Date(row.last_used_at),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function rowToAuthToken(row: Selectable<AuthTokenTable>): AuthToken {
|
|
616
|
+
return {
|
|
617
|
+
hash: row.hash,
|
|
618
|
+
userId: row.user_id,
|
|
619
|
+
email: row.email,
|
|
620
|
+
type: toTokenType(row.type),
|
|
621
|
+
role: row.role != null ? toRoleLevel(row.role) : null,
|
|
622
|
+
invitedBy: row.invited_by,
|
|
623
|
+
expiresAt: new Date(row.expires_at),
|
|
624
|
+
createdAt: new Date(row.created_at),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function rowToOAuthAccount(row: Selectable<OAuthAccountTable>): OAuthAccount {
|
|
629
|
+
return {
|
|
630
|
+
provider: row.provider,
|
|
631
|
+
providerAccountId: row.provider_account_id,
|
|
632
|
+
userId: row.user_id,
|
|
633
|
+
createdAt: new Date(row.created_at),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function rowToAllowedDomain(row: Selectable<AllowedDomainTable>): AllowedDomain {
|
|
638
|
+
return {
|
|
639
|
+
domain: row.domain,
|
|
640
|
+
defaultRole: toRoleLevel(row.default_role),
|
|
641
|
+
enabled: row.enabled === 1,
|
|
642
|
+
createdAt: new Date(row.created_at),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ============================================================================
|
|
647
|
+
// Migration SQL
|
|
648
|
+
// ============================================================================
|
|
649
|
+
|
|
650
|
+
export const AUTH_TABLES_SQL = `
|
|
651
|
+
-- Users (no password_hash)
|
|
652
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
653
|
+
id TEXT PRIMARY KEY,
|
|
654
|
+
email TEXT UNIQUE NOT NULL,
|
|
655
|
+
name TEXT,
|
|
656
|
+
avatar_url TEXT,
|
|
657
|
+
role INTEGER NOT NULL DEFAULT 10,
|
|
658
|
+
email_verified INTEGER NOT NULL DEFAULT 0,
|
|
659
|
+
disabled INTEGER NOT NULL DEFAULT 0,
|
|
660
|
+
data TEXT,
|
|
661
|
+
created_at TEXT NOT NULL,
|
|
662
|
+
updated_at TEXT NOT NULL
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
666
|
+
|
|
667
|
+
-- Passkey credentials
|
|
668
|
+
CREATE TABLE IF NOT EXISTS credentials (
|
|
669
|
+
id TEXT PRIMARY KEY,
|
|
670
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
671
|
+
public_key BLOB NOT NULL,
|
|
672
|
+
counter INTEGER NOT NULL DEFAULT 0,
|
|
673
|
+
device_type TEXT NOT NULL,
|
|
674
|
+
backed_up INTEGER NOT NULL DEFAULT 0,
|
|
675
|
+
transports TEXT,
|
|
676
|
+
name TEXT,
|
|
677
|
+
created_at TEXT NOT NULL,
|
|
678
|
+
last_used_at TEXT NOT NULL
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
CREATE INDEX IF NOT EXISTS idx_credentials_user ON credentials(user_id);
|
|
682
|
+
|
|
683
|
+
-- Auth tokens (magic links, email verification, invites)
|
|
684
|
+
CREATE TABLE IF NOT EXISTS auth_tokens (
|
|
685
|
+
hash TEXT PRIMARY KEY,
|
|
686
|
+
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
|
687
|
+
email TEXT,
|
|
688
|
+
type TEXT NOT NULL,
|
|
689
|
+
role INTEGER,
|
|
690
|
+
invited_by TEXT REFERENCES users(id),
|
|
691
|
+
expires_at TEXT NOT NULL,
|
|
692
|
+
created_at TEXT NOT NULL
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
CREATE INDEX IF NOT EXISTS idx_auth_tokens_email ON auth_tokens(email);
|
|
696
|
+
|
|
697
|
+
-- OAuth accounts (external provider links)
|
|
698
|
+
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
|
699
|
+
provider TEXT NOT NULL,
|
|
700
|
+
provider_account_id TEXT NOT NULL,
|
|
701
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
702
|
+
created_at TEXT NOT NULL,
|
|
703
|
+
PRIMARY KEY (provider, provider_account_id)
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id);
|
|
707
|
+
|
|
708
|
+
-- Allowed domains for self-signup
|
|
709
|
+
CREATE TABLE IF NOT EXISTS allowed_domains (
|
|
710
|
+
domain TEXT PRIMARY KEY,
|
|
711
|
+
default_role INTEGER NOT NULL DEFAULT 20,
|
|
712
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
713
|
+
created_at TEXT NOT NULL
|
|
714
|
+
);
|
|
715
|
+
`;
|