@prsm/auth 1.0.0

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.
Files changed (61) hide show
  1. package/README.md +226 -0
  2. package/index.d.ts +19 -0
  3. package/package.json +76 -0
  4. package/src/__tests__/auth.test.js +1171 -0
  5. package/src/__tests__/impersonation-test-setup.js +208 -0
  6. package/src/__tests__/impersonation.test.js +473 -0
  7. package/src/__tests__/oauth-test-setup.js +136 -0
  8. package/src/__tests__/oauth.test.js +400 -0
  9. package/src/__tests__/prsm.test.js +215 -0
  10. package/src/__tests__/test-setup.js +385 -0
  11. package/src/__tests__/totp.test.js +158 -0
  12. package/src/__tests__/two-factor-test-setup.js +331 -0
  13. package/src/__tests__/two-factor.test.js +396 -0
  14. package/src/activity-logger.js +228 -0
  15. package/src/auth-context.js +120 -0
  16. package/src/auth-functions.js +520 -0
  17. package/src/auth-manager.js +1371 -0
  18. package/src/errors.js +173 -0
  19. package/src/hooks.js +41 -0
  20. package/src/index.js +23 -0
  21. package/src/invalidation.js +166 -0
  22. package/src/middleware.js +33 -0
  23. package/src/providers/azure-provider.js +114 -0
  24. package/src/providers/base-provider.js +152 -0
  25. package/src/providers/github-provider.js +86 -0
  26. package/src/providers/google-provider.js +76 -0
  27. package/src/providers/index.js +4 -0
  28. package/src/queries.js +543 -0
  29. package/src/schema.js +261 -0
  30. package/src/totp.js +221 -0
  31. package/src/two-factor/index.js +3 -0
  32. package/src/two-factor/otp-provider.js +128 -0
  33. package/src/two-factor/totp-provider.js +98 -0
  34. package/src/two-factor/two-factor-manager.js +676 -0
  35. package/src/types.js +399 -0
  36. package/src/user-roles.js +128 -0
  37. package/src/util.js +32 -0
  38. package/types/activity-logger.d.ts +73 -0
  39. package/types/auth-context.d.ts +88 -0
  40. package/types/auth-functions.d.ts +151 -0
  41. package/types/auth-manager.d.ts +365 -0
  42. package/types/errors.d.ts +108 -0
  43. package/types/hooks.d.ts +30 -0
  44. package/types/index.d.ts +13 -0
  45. package/types/invalidation.d.ts +40 -0
  46. package/types/middleware.d.ts +11 -0
  47. package/types/providers/azure-provider.d.ts +35 -0
  48. package/types/providers/base-provider.d.ts +52 -0
  49. package/types/providers/github-provider.d.ts +29 -0
  50. package/types/providers/google-provider.d.ts +29 -0
  51. package/types/providers/index.d.ts +4 -0
  52. package/types/queries.d.ts +287 -0
  53. package/types/schema.d.ts +37 -0
  54. package/types/totp.d.ts +72 -0
  55. package/types/two-factor/index.d.ts +3 -0
  56. package/types/two-factor/otp-provider.d.ts +57 -0
  57. package/types/two-factor/totp-provider.d.ts +58 -0
  58. package/types/two-factor/two-factor-manager.d.ts +191 -0
  59. package/types/types.d.ts +688 -0
  60. package/types/user-roles.d.ts +47 -0
  61. package/types/util.d.ts +3 -0
@@ -0,0 +1,86 @@
1
+ import { BaseOAuthProvider } from "./base-provider.js"
2
+
3
+ /**
4
+ * @typedef {import("../types.js").AuthConfig} AuthConfig
5
+ * @typedef {import("../types.js").OAuthUserData} OAuthUserData
6
+ * @typedef {import("../types.js").GitHubProviderConfig} GitHubProviderConfig
7
+ */
8
+
9
+ export class GitHubProvider extends BaseOAuthProvider {
10
+ /**
11
+ * @param {GitHubProviderConfig} config
12
+ * @param {AuthConfig} authConfig
13
+ * @param {import("../auth-manager.js").AuthManager} authManager
14
+ */
15
+ constructor(config, authConfig, authManager) {
16
+ super(config, authConfig, authManager)
17
+ }
18
+
19
+ /**
20
+ * Build the GitHub authorization URL.
21
+ * @param {string} [state]
22
+ * @param {string[]} [scopes]
23
+ * @returns {string}
24
+ */
25
+ getAuthUrl(state, scopes) {
26
+ const params = new URLSearchParams({
27
+ client_id: this.config.clientId,
28
+ redirect_uri: this.config.redirectUri,
29
+ scope: scopes?.join(" ") || "user:email",
30
+ state: state || crypto.randomUUID(),
31
+ response_type: "code",
32
+ })
33
+
34
+ return `https://github.com/login/oauth/authorize?${params}`
35
+ }
36
+
37
+ /**
38
+ * Exchange the callback code and resolve the GitHub user profile.
39
+ * @param {import("express").Request} req
40
+ * @returns {Promise<OAuthUserData>}
41
+ * @throws {Error} when no code is provided or no verified email is found
42
+ */
43
+ async getUserData(req) {
44
+ const code = req.query.code
45
+ if (!code) {
46
+ throw new Error("No authorization code provided")
47
+ }
48
+
49
+ // exchange code for access token
50
+ const accessToken = await this.exchangeCodeForToken(code, "https://github.com/login/oauth/access_token")
51
+
52
+ const apiHeaders = {
53
+ Accept: "application/vnd.github+json",
54
+ "User-Agent": this.authConfig.githubUserAgent || "EasyAccess",
55
+ "X-GitHub-Api-Version": "2022-11-28",
56
+ }
57
+
58
+ const [user, emails] = await Promise.all([
59
+ this.fetchUserFromAPI(accessToken, "https://api.github.com/user", apiHeaders),
60
+ this.fetchUserFromAPI(accessToken, "https://api.github.com/user/emails", apiHeaders),
61
+ ])
62
+
63
+ const verifiedEmails = Array.isArray(emails) ? emails.filter((email) => email.verified) : []
64
+ const primaryEmail = verifiedEmails.find((email) => email.primary)?.email
65
+ const fallbackEmail = primaryEmail || verifiedEmails[0]?.email
66
+
67
+ if (!fallbackEmail) {
68
+ throw new Error("No verified email found in GitHub account")
69
+ }
70
+
71
+ return {
72
+ id: user.id.toString(),
73
+ email: fallbackEmail,
74
+ username: user.login,
75
+ name: user.name || user.login,
76
+ avatar: user.avatar_url,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * @returns {string}
82
+ */
83
+ getProviderName() {
84
+ return "github"
85
+ }
86
+ }
@@ -0,0 +1,76 @@
1
+ import { BaseOAuthProvider } from "./base-provider.js"
2
+
3
+ /**
4
+ * @typedef {import("../types.js").AuthConfig} AuthConfig
5
+ * @typedef {import("../types.js").OAuthUserData} OAuthUserData
6
+ * @typedef {import("../types.js").GoogleProviderConfig} GoogleProviderConfig
7
+ */
8
+
9
+ export class GoogleProvider extends BaseOAuthProvider {
10
+ /**
11
+ * @param {GoogleProviderConfig} config
12
+ * @param {AuthConfig} authConfig
13
+ * @param {import("../auth-manager.js").AuthManager} authManager
14
+ */
15
+ constructor(config, authConfig, authManager) {
16
+ super(config, authConfig, authManager)
17
+ }
18
+
19
+ /**
20
+ * Build the Google authorization URL.
21
+ * @param {string} [state]
22
+ * @param {string[]} [scopes]
23
+ * @returns {string}
24
+ */
25
+ getAuthUrl(state, scopes) {
26
+ const params = new URLSearchParams({
27
+ client_id: this.config.clientId,
28
+ redirect_uri: this.config.redirectUri,
29
+ scope: scopes?.join(" ") || "openid profile email",
30
+ state: state || crypto.randomUUID(),
31
+ response_type: "code",
32
+ access_type: "offline",
33
+ prompt: "consent",
34
+ })
35
+
36
+ return `https://accounts.google.com/o/oauth2/v2/auth?${params}`
37
+ }
38
+
39
+ /**
40
+ * Exchange the callback code and resolve the Google user profile.
41
+ * @param {import("express").Request} req
42
+ * @returns {Promise<OAuthUserData>}
43
+ * @throws {Error} when no code is provided or no email is found
44
+ */
45
+ async getUserData(req) {
46
+ const code = req.query.code
47
+ if (!code) {
48
+ throw new Error("No authorization code provided")
49
+ }
50
+
51
+ // exchange code for access token
52
+ const accessToken = await this.exchangeCodeForToken(code, "https://oauth2.googleapis.com/token")
53
+
54
+ // fetch user data
55
+ const user = await this.fetchUserFromAPI(accessToken, "https://www.googleapis.com/oauth2/v2/userinfo")
56
+
57
+ if (!user.email) {
58
+ throw new Error("No email found in Google account")
59
+ }
60
+
61
+ return {
62
+ id: user.id,
63
+ email: user.email,
64
+ username: user.email.split("@")[0], // use email prefix as username
65
+ name: user.name,
66
+ avatar: user.picture,
67
+ }
68
+ }
69
+
70
+ /**
71
+ * @returns {string}
72
+ */
73
+ getProviderName() {
74
+ return "google"
75
+ }
76
+ }
@@ -0,0 +1,4 @@
1
+ export { BaseOAuthProvider } from "./base-provider.js"
2
+ export { GitHubProvider } from "./github-provider.js"
3
+ export { GoogleProvider } from "./google-provider.js"
4
+ export { AzureProvider } from "./azure-provider.js"
package/src/queries.js ADDED
@@ -0,0 +1,543 @@
1
+ import { notifyInvalidation } from "./invalidation.js"
2
+
3
+ /**
4
+ * @typedef {import("./types.js").AuthConfig} AuthConfig
5
+ * @typedef {import("./types.js").AuthAccount} AuthAccount
6
+ * @typedef {import("./types.js").AuthConfirmation} AuthConfirmation
7
+ * @typedef {import("./types.js").AuthRemember} AuthRemember
8
+ * @typedef {import("./types.js").AuthReset} AuthReset
9
+ * @typedef {import("./types.js").AuthProvider} AuthProvider
10
+ * @typedef {import("./types.js").TwoFactorMethod} TwoFactorMethod
11
+ * @typedef {import("./types.js").TwoFactorToken} TwoFactorToken
12
+ * @typedef {import("./types.js").TwoFactorMechanism} TwoFactorMechanism
13
+ */
14
+
15
+ export class AuthQueries {
16
+ /**
17
+ * @param {AuthConfig} config
18
+ */
19
+ constructor(config) {
20
+ this.config = config
21
+ this.db = config.db
22
+ this.tablePrefix = config.tablePrefix || "user_"
23
+ }
24
+
25
+ get accountsTable() {
26
+ return `${this.tablePrefix}accounts`
27
+ }
28
+
29
+ get confirmationsTable() {
30
+ return `${this.tablePrefix}confirmations`
31
+ }
32
+
33
+ get remembersTable() {
34
+ return `${this.tablePrefix}remembers`
35
+ }
36
+
37
+ get resetsTable() {
38
+ return `${this.tablePrefix}resets`
39
+ }
40
+
41
+ get providersTable() {
42
+ return `${this.tablePrefix}providers`
43
+ }
44
+
45
+ get twoFactorMethodsTable() {
46
+ return `${this.tablePrefix}2fa_methods`
47
+ }
48
+
49
+ get twoFactorTokensTable() {
50
+ return `${this.tablePrefix}2fa_tokens`
51
+ }
52
+
53
+ /**
54
+ * @param {number} id
55
+ * @returns {Promise<AuthAccount | null>}
56
+ */
57
+ async findAccountById(id) {
58
+ const sql = `SELECT * FROM ${this.accountsTable} WHERE id = $1`
59
+ const result = await this.db.query(sql, [id])
60
+ return result.rows[0] || null
61
+ }
62
+
63
+ /**
64
+ * @param {string | number} userId
65
+ * @returns {Promise<AuthAccount | null>}
66
+ */
67
+ async findAccountByUserId(userId) {
68
+ const sql = `SELECT * FROM ${this.accountsTable} WHERE user_id = $1`
69
+ const result = await this.db.query(sql, [userId])
70
+ return result.rows[0] || null
71
+ }
72
+
73
+ /**
74
+ * @param {string} email
75
+ * @returns {Promise<AuthAccount | null>}
76
+ */
77
+ async findAccountByEmail(email) {
78
+ const sql = `SELECT * FROM ${this.accountsTable} WHERE email = $1`
79
+ const result = await this.db.query(sql, [email])
80
+ return result.rows[0] || null
81
+ }
82
+
83
+ /**
84
+ * List accounts with optional email search, newest first. Used by the
85
+ * @prsm/devtools admin panel binding.
86
+ * @param {{ limit?: number, offset?: number, search?: string }} [opts]
87
+ * @returns {Promise<AuthAccount[]>}
88
+ */
89
+ async listAccounts({ limit = 50, offset = 0, search } = {}) {
90
+ const params = []
91
+ let where = ""
92
+ if (search) {
93
+ params.push(`%${search}%`)
94
+ where = `WHERE email ILIKE $1`
95
+ }
96
+ const sql = `SELECT * FROM ${this.accountsTable} ${where} ORDER BY id DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
97
+ params.push(limit, offset)
98
+ const result = await this.db.query(sql, params)
99
+ return result.rows
100
+ }
101
+
102
+ /**
103
+ * @param {string} [search]
104
+ * @returns {Promise<number>}
105
+ */
106
+ async countAccounts(search) {
107
+ const params = []
108
+ let where = ""
109
+ if (search) {
110
+ params.push(`%${search}%`)
111
+ where = `WHERE email ILIKE $1`
112
+ }
113
+ const result = await this.db.query(`SELECT COUNT(*) as count FROM ${this.accountsTable} ${where}`, params)
114
+ return parseInt(result.rows[0]?.count || "0")
115
+ }
116
+
117
+ /**
118
+ * @param {{ userId: string | number, email: string, password: string | null, verified: boolean, status: number, rolemask: number }} data
119
+ * @returns {Promise<AuthAccount>}
120
+ */
121
+ async createAccount(data) {
122
+ const sql = `
123
+ INSERT INTO ${this.accountsTable} (
124
+ user_id, email, password, verified, status, rolemask,
125
+ force_logout, resettable, registered
126
+ )
127
+ VALUES ($1, $2, $3, $4, $5, $6, 0, true, NOW())
128
+ RETURNING *
129
+ `
130
+
131
+ const result = await this.db.query(sql, [data.userId, data.email, data.password, data.verified, data.status, data.rolemask])
132
+
133
+ return result.rows[0]
134
+ }
135
+
136
+ /**
137
+ * @param {number} id
138
+ * @param {Partial<AuthAccount>} updates
139
+ * @returns {Promise<void>}
140
+ */
141
+ async updateAccount(id, updates) {
142
+ const fields = []
143
+ const values = []
144
+ let paramIndex = 1
145
+
146
+ for (const [key, value] of Object.entries(updates)) {
147
+ if (key === "id") continue // don't update id
148
+ fields.push(`${key} = $${paramIndex++}`)
149
+ values.push(value)
150
+ }
151
+
152
+ if (fields.length === 0) return
153
+
154
+ values.push(id)
155
+ const sql = `UPDATE ${this.accountsTable} SET ${fields.join(", ")} WHERE id = $${paramIndex}`
156
+ await this.db.query(sql, values)
157
+
158
+ // signal other instances to resync this account when a security-relevant
159
+ // field changed, so role/status/password updates propagate fleet-wide
160
+ if ("status" in updates || "rolemask" in updates || "password" in updates || "verified" in updates || "force_logout" in updates) {
161
+ await notifyInvalidation(this.config, id)
162
+ }
163
+ }
164
+
165
+ /**
166
+ * @param {number} id
167
+ * @returns {Promise<void>}
168
+ */
169
+ async updateAccountLastLogin(id) {
170
+ const sql = `UPDATE ${this.accountsTable} SET last_login = NOW() WHERE id = $1`
171
+ await this.db.query(sql, [id])
172
+ }
173
+
174
+ /**
175
+ * @param {number} id
176
+ * @returns {Promise<void>}
177
+ */
178
+ async incrementForceLogout(id) {
179
+ const sql = `UPDATE ${this.accountsTable} SET force_logout = force_logout + 1 WHERE id = $1`
180
+ await this.db.query(sql, [id])
181
+ await notifyInvalidation(this.config, id)
182
+ }
183
+
184
+ /**
185
+ * @param {number} id
186
+ * @returns {Promise<void>}
187
+ */
188
+ async deleteAccount(id) {
189
+ await this.db.query(`DELETE FROM ${this.twoFactorTokensTable} WHERE account_id = $1`, [id])
190
+ await this.db.query(`DELETE FROM ${this.twoFactorMethodsTable} WHERE account_id = $1`, [id])
191
+ await this.db.query(`DELETE FROM ${this.providersTable} WHERE account_id = $1`, [id])
192
+ await this.db.query(`DELETE FROM ${this.confirmationsTable} WHERE account_id = $1`, [id])
193
+ await this.db.query(`DELETE FROM ${this.remembersTable} WHERE account_id = $1`, [id])
194
+ await this.db.query(`DELETE FROM ${this.resetsTable} WHERE account_id = $1`, [id])
195
+
196
+ await this.db.query(`DELETE FROM ${this.accountsTable} WHERE id = $1`, [id])
197
+ }
198
+
199
+ /**
200
+ * @param {{ accountId: number, token: string, email: string, expires: Date }} data
201
+ * @returns {Promise<void>}
202
+ */
203
+ async createConfirmation(data) {
204
+ await this.db.query(`DELETE FROM ${this.confirmationsTable} WHERE account_id = $1`, [data.accountId])
205
+
206
+ const sql = `
207
+ INSERT INTO ${this.confirmationsTable} (account_id, token, email, expires)
208
+ VALUES ($1, $2, $3, $4)
209
+ `
210
+
211
+ await this.db.query(sql, [data.accountId, data.token, data.email, data.expires])
212
+ }
213
+
214
+ /**
215
+ * @param {string} token
216
+ * @returns {Promise<AuthConfirmation | null>}
217
+ */
218
+ async findConfirmation(token) {
219
+ const sql = `SELECT * FROM ${this.confirmationsTable} WHERE token = $1`
220
+ const result = await this.db.query(sql, [token])
221
+ return result.rows[0] || null
222
+ }
223
+
224
+ /**
225
+ * @param {number} accountId
226
+ * @returns {Promise<AuthConfirmation | null>}
227
+ */
228
+ async findLatestConfirmationForAccount(accountId) {
229
+ const sql = `
230
+ SELECT * FROM ${this.confirmationsTable}
231
+ WHERE account_id = $1
232
+ ORDER BY expires DESC
233
+ LIMIT 1
234
+ `
235
+ const result = await this.db.query(sql, [accountId])
236
+ return result.rows[0] || null
237
+ }
238
+
239
+ /**
240
+ * @param {string} token
241
+ * @returns {Promise<void>}
242
+ */
243
+ async deleteConfirmation(token) {
244
+ await this.db.query(`DELETE FROM ${this.confirmationsTable} WHERE token = $1`, [token])
245
+ }
246
+
247
+ /**
248
+ * @param {{ accountId: number, token: string, expires: Date }} data
249
+ * @returns {Promise<void>}
250
+ */
251
+ async createRememberToken(data) {
252
+ await this.db.query(`DELETE FROM ${this.remembersTable} WHERE account_id = $1`, [data.accountId])
253
+
254
+ const sql = `
255
+ INSERT INTO ${this.remembersTable} (account_id, token, expires)
256
+ VALUES ($1, $2, $3)
257
+ `
258
+
259
+ await this.db.query(sql, [data.accountId, data.token, data.expires])
260
+ }
261
+
262
+ /**
263
+ * @param {string} token
264
+ * @returns {Promise<AuthRemember | null>}
265
+ */
266
+ async findRememberToken(token) {
267
+ const sql = `SELECT * FROM ${this.remembersTable} WHERE token = $1`
268
+ const result = await this.db.query(sql, [token])
269
+ return result.rows[0] || null
270
+ }
271
+
272
+ /**
273
+ * @param {string} token
274
+ * @returns {Promise<void>}
275
+ */
276
+ async deleteRememberToken(token) {
277
+ await this.db.query(`DELETE FROM ${this.remembersTable} WHERE token = $1`, [token])
278
+ }
279
+
280
+ /**
281
+ * @param {number} accountId
282
+ * @returns {Promise<void>}
283
+ */
284
+ async deleteRememberTokensForAccount(accountId) {
285
+ await this.db.query(`DELETE FROM ${this.remembersTable} WHERE account_id = $1`, [accountId])
286
+ }
287
+
288
+ /**
289
+ * @param {number} accountId
290
+ * @returns {Promise<void>}
291
+ */
292
+ async deleteExpiredRememberTokensForAccount(accountId) {
293
+ await this.db.query(`DELETE FROM ${this.remembersTable} WHERE account_id = $1 AND expires <= NOW()`, [accountId])
294
+ }
295
+
296
+ /**
297
+ * @param {{ accountId: number, token: string, expires: Date }} data
298
+ * @returns {Promise<void>}
299
+ */
300
+ async createResetToken(data) {
301
+ const sql = `
302
+ INSERT INTO ${this.resetsTable} (account_id, token, expires)
303
+ VALUES ($1, $2, $3)
304
+ `
305
+
306
+ await this.db.query(sql, [data.accountId, data.token, data.expires])
307
+ }
308
+
309
+ /**
310
+ * @param {string} token
311
+ * @returns {Promise<AuthReset | null>}
312
+ */
313
+ async findResetToken(token) {
314
+ const sql = `
315
+ SELECT * FROM ${this.resetsTable}
316
+ WHERE token = $1
317
+ ORDER BY expires DESC
318
+ LIMIT 1
319
+ `
320
+ const result = await this.db.query(sql, [token])
321
+ return result.rows[0] || null
322
+ }
323
+
324
+ /**
325
+ * @param {number} accountId
326
+ * @returns {Promise<number>}
327
+ */
328
+ async countActiveResetTokensForAccount(accountId) {
329
+ const sql = `
330
+ SELECT COUNT(*) as count FROM ${this.resetsTable}
331
+ WHERE account_id = $1 AND expires >= NOW()
332
+ `
333
+ const result = await this.db.query(sql, [accountId])
334
+ return parseInt(result.rows[0]?.count || "0")
335
+ }
336
+
337
+ /**
338
+ * @param {string} token
339
+ * @returns {Promise<void>}
340
+ */
341
+ async deleteResetToken(token) {
342
+ await this.db.query(`DELETE FROM ${this.resetsTable} WHERE token = $1`, [token])
343
+ }
344
+
345
+ /**
346
+ * @param {number} accountId
347
+ * @returns {Promise<void>}
348
+ */
349
+ async deleteResetTokensForAccount(accountId) {
350
+ await this.db.query(`DELETE FROM ${this.resetsTable} WHERE account_id = $1`, [accountId])
351
+ }
352
+
353
+ /**
354
+ * @param {{ accountId: number, provider: string, providerId: string, providerEmail: string | null, providerUsername: string | null, providerName: string | null, providerAvatar: string | null }} data
355
+ * @returns {Promise<AuthProvider>}
356
+ */
357
+ async createProvider(data) {
358
+ const sql = `
359
+ INSERT INTO ${this.providersTable} (
360
+ account_id, provider, provider_id, provider_email,
361
+ provider_username, provider_name, provider_avatar
362
+ )
363
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
364
+ RETURNING *
365
+ `
366
+
367
+ const result = await this.db.query(sql, [data.accountId, data.provider, data.providerId, data.providerEmail, data.providerUsername, data.providerName, data.providerAvatar])
368
+
369
+ return result.rows[0]
370
+ }
371
+
372
+ /**
373
+ * @param {string} providerId
374
+ * @param {string} provider
375
+ * @returns {Promise<AuthProvider | null>}
376
+ */
377
+ async findProviderByProviderIdAndType(providerId, provider) {
378
+ const sql = `SELECT * FROM ${this.providersTable} WHERE provider_id = $1 AND provider = $2`
379
+ const result = await this.db.query(sql, [providerId, provider])
380
+ return result.rows[0] || null
381
+ }
382
+
383
+ /**
384
+ * @param {number} accountId
385
+ * @returns {Promise<AuthProvider[]>}
386
+ */
387
+ async findProvidersByAccountId(accountId) {
388
+ const sql = `SELECT * FROM ${this.providersTable} WHERE account_id = $1 ORDER BY created_at DESC`
389
+ const result = await this.db.query(sql, [accountId])
390
+ return result.rows
391
+ }
392
+
393
+ /**
394
+ * @param {number} id
395
+ * @returns {Promise<void>}
396
+ */
397
+ async deleteProvider(id) {
398
+ await this.db.query(`DELETE FROM ${this.providersTable} WHERE id = $1`, [id])
399
+ }
400
+
401
+ /**
402
+ * @param {number} accountId
403
+ * @returns {Promise<void>}
404
+ */
405
+ async deleteProvidersByAccountId(accountId) {
406
+ await this.db.query(`DELETE FROM ${this.providersTable} WHERE account_id = $1`, [accountId])
407
+ }
408
+
409
+ // two-factor authentication methods
410
+
411
+ /**
412
+ * @param {number} accountId
413
+ * @returns {Promise<TwoFactorMethod[]>}
414
+ */
415
+ async findTwoFactorMethodsByAccountId(accountId) {
416
+ const sql = `SELECT * FROM ${this.twoFactorMethodsTable} WHERE account_id = $1 ORDER BY created_at DESC`
417
+ const result = await this.db.query(sql, [accountId])
418
+ return result.rows
419
+ }
420
+
421
+ /**
422
+ * @param {number} accountId
423
+ * @param {TwoFactorMechanism} mechanism
424
+ * @returns {Promise<TwoFactorMethod | null>}
425
+ */
426
+ async findTwoFactorMethodByAccountAndMechanism(accountId, mechanism) {
427
+ const sql = `SELECT * FROM ${this.twoFactorMethodsTable} WHERE account_id = $1 AND mechanism = $2`
428
+ const result = await this.db.query(sql, [accountId, mechanism])
429
+ return result.rows[0] || null
430
+ }
431
+
432
+ /**
433
+ * @param {{ accountId: number, mechanism: TwoFactorMechanism, secret?: string, backupCodes?: string[], verified?: boolean }} data
434
+ * @returns {Promise<TwoFactorMethod>}
435
+ */
436
+ async createTwoFactorMethod(data) {
437
+ const sql = `
438
+ INSERT INTO ${this.twoFactorMethodsTable} (
439
+ account_id, mechanism, secret, backup_codes, verified
440
+ )
441
+ VALUES ($1, $2, $3, $4, $5)
442
+ RETURNING *
443
+ `
444
+
445
+ const result = await this.db.query(sql, [data.accountId, data.mechanism, data.secret || null, data.backupCodes || null, data.verified || false])
446
+
447
+ return result.rows[0]
448
+ }
449
+
450
+ /**
451
+ * @param {number} id
452
+ * @param {Partial<Pick<TwoFactorMethod, "secret" | "backup_codes" | "verified" | "last_used_at">>} updates
453
+ * @returns {Promise<void>}
454
+ */
455
+ async updateTwoFactorMethod(id, updates) {
456
+ const fields = []
457
+ const values = []
458
+ let paramIndex = 1
459
+
460
+ for (const [key, value] of Object.entries(updates)) {
461
+ if (key === "id") continue
462
+ fields.push(`${key} = $${paramIndex++}`)
463
+ values.push(value)
464
+ }
465
+
466
+ if (fields.length === 0) return
467
+
468
+ values.push(id)
469
+ const sql = `UPDATE ${this.twoFactorMethodsTable} SET ${fields.join(", ")} WHERE id = $${paramIndex}`
470
+ await this.db.query(sql, values)
471
+ }
472
+
473
+ /**
474
+ * @param {number} id
475
+ * @returns {Promise<void>}
476
+ */
477
+ async deleteTwoFactorMethod(id) {
478
+ await this.db.query(`DELETE FROM ${this.twoFactorMethodsTable} WHERE id = $1`, [id])
479
+ }
480
+
481
+ /**
482
+ * @param {number} accountId
483
+ * @returns {Promise<void>}
484
+ */
485
+ async deleteTwoFactorMethodsByAccountId(accountId) {
486
+ await this.db.query(`DELETE FROM ${this.twoFactorMethodsTable} WHERE account_id = $1`, [accountId])
487
+ }
488
+
489
+ // two-factor authentication tokens
490
+
491
+ /**
492
+ * @param {{ accountId: number, mechanism: TwoFactorMechanism, selector: string, tokenHash: string, expiresAt: Date }} data
493
+ * @returns {Promise<TwoFactorToken>}
494
+ */
495
+ async createTwoFactorToken(data) {
496
+ const sql = `
497
+ INSERT INTO ${this.twoFactorTokensTable} (
498
+ account_id, mechanism, selector, token_hash, expires_at
499
+ )
500
+ VALUES ($1, $2, $3, $4, $5)
501
+ RETURNING *
502
+ `
503
+
504
+ const result = await this.db.query(sql, [data.accountId, data.mechanism, data.selector, data.tokenHash, data.expiresAt])
505
+
506
+ return result.rows[0]
507
+ }
508
+
509
+ /**
510
+ * @param {string} selector
511
+ * @returns {Promise<TwoFactorToken | null>}
512
+ */
513
+ async findTwoFactorTokenBySelector(selector) {
514
+ const sql = `SELECT * FROM ${this.twoFactorTokensTable} WHERE selector = $1 AND expires_at > NOW()`
515
+ const result = await this.db.query(sql, [selector])
516
+ return result.rows[0] || null
517
+ }
518
+
519
+ /**
520
+ * @param {number} id
521
+ * @returns {Promise<void>}
522
+ */
523
+ async deleteTwoFactorToken(id) {
524
+ await this.db.query(`DELETE FROM ${this.twoFactorTokensTable} WHERE id = $1`, [id])
525
+ }
526
+
527
+ /**
528
+ * @param {number} accountId
529
+ * @returns {Promise<void>}
530
+ */
531
+ async deleteTwoFactorTokensByAccountId(accountId) {
532
+ await this.db.query(`DELETE FROM ${this.twoFactorTokensTable} WHERE account_id = $1`, [accountId])
533
+ }
534
+
535
+ /**
536
+ * @param {number} accountId
537
+ * @param {TwoFactorMechanism} mechanism
538
+ * @returns {Promise<void>}
539
+ */
540
+ async deleteTwoFactorTokensByAccountAndMechanism(accountId, mechanism) {
541
+ await this.db.query(`DELETE FROM ${this.twoFactorTokensTable} WHERE account_id = $1 AND mechanism = $2`, [accountId, mechanism])
542
+ }
543
+ }