@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
package/src/schema.js ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * @typedef {import("./types.js").AuthConfig} AuthConfig
3
+ */
4
+
5
+ /**
6
+ * Create all auth tables and indexes. Must run before the first request uses auth.
7
+ * @param {AuthConfig} config
8
+ * @returns {Promise<void>}
9
+ */
10
+ export async function createAuthTables(config) {
11
+ const prefix = config.tablePrefix || "user_"
12
+ const { db } = config
13
+
14
+ const accountsTable = `${prefix}accounts`
15
+ await db.query(`
16
+ CREATE TABLE IF NOT EXISTS ${accountsTable} (
17
+ id SERIAL PRIMARY KEY,
18
+ user_id VARCHAR(255) NOT NULL,
19
+ email VARCHAR(255) NOT NULL UNIQUE,
20
+ password VARCHAR(255),
21
+ verified BOOLEAN DEFAULT FALSE,
22
+ status INTEGER DEFAULT 0,
23
+ rolemask INTEGER DEFAULT 0,
24
+ last_login TIMESTAMPTZ,
25
+ force_logout INTEGER DEFAULT 0,
26
+ resettable BOOLEAN DEFAULT TRUE,
27
+ registered TIMESTAMPTZ DEFAULT NOW(),
28
+ CONSTRAINT ${prefix}unique_user_id_per_account UNIQUE(user_id)
29
+ )
30
+ `)
31
+
32
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}accounts_user_id ON ${accountsTable}(user_id)`)
33
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}accounts_email ON ${accountsTable}(email)`)
34
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}accounts_status ON ${accountsTable}(status)`)
35
+
36
+ const confirmationsTable = `${prefix}confirmations`
37
+ await db.query(`
38
+ CREATE TABLE IF NOT EXISTS ${confirmationsTable} (
39
+ id SERIAL PRIMARY KEY,
40
+ account_id INTEGER NOT NULL,
41
+ token VARCHAR(255) NOT NULL,
42
+ email VARCHAR(255) NOT NULL,
43
+ expires TIMESTAMPTZ NOT NULL,
44
+ CONSTRAINT fk_${prefix}confirmations_account
45
+ FOREIGN KEY (account_id) REFERENCES ${accountsTable}(id) ON DELETE CASCADE
46
+ )
47
+ `)
48
+
49
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}confirmations_token ON ${confirmationsTable}(token)`)
50
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}confirmations_email ON ${confirmationsTable}(email)`)
51
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}confirmations_account_id ON ${confirmationsTable}(account_id)`)
52
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}confirmations_expires ON ${confirmationsTable}(expires)`)
53
+
54
+ const remembersTable = `${prefix}remembers`
55
+ await db.query(`
56
+ CREATE TABLE IF NOT EXISTS ${remembersTable} (
57
+ id SERIAL PRIMARY KEY,
58
+ account_id INTEGER NOT NULL,
59
+ token VARCHAR(255) NOT NULL,
60
+ expires TIMESTAMPTZ NOT NULL,
61
+ CONSTRAINT fk_${prefix}remembers_account
62
+ FOREIGN KEY (account_id) REFERENCES ${accountsTable}(id) ON DELETE CASCADE
63
+ )
64
+ `)
65
+
66
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}remembers_token ON ${remembersTable}(token)`)
67
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}remembers_account_id ON ${remembersTable}(account_id)`)
68
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}remembers_expires ON ${remembersTable}(expires)`)
69
+
70
+ const resetsTable = `${prefix}resets`
71
+ await db.query(`
72
+ CREATE TABLE IF NOT EXISTS ${resetsTable} (
73
+ id SERIAL PRIMARY KEY,
74
+ account_id INTEGER NOT NULL,
75
+ token VARCHAR(255) NOT NULL,
76
+ expires TIMESTAMPTZ NOT NULL,
77
+ CONSTRAINT fk_${prefix}resets_account
78
+ FOREIGN KEY (account_id) REFERENCES ${accountsTable}(id) ON DELETE CASCADE
79
+ )
80
+ `)
81
+
82
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}resets_token ON ${resetsTable}(token)`)
83
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}resets_account_id ON ${resetsTable}(account_id)`)
84
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}resets_expires ON ${resetsTable}(expires)`)
85
+
86
+ const providersTable = `${prefix}providers`
87
+ await db.query(`
88
+ CREATE TABLE IF NOT EXISTS ${providersTable} (
89
+ id SERIAL PRIMARY KEY,
90
+ account_id INTEGER NOT NULL,
91
+ provider VARCHAR(50) NOT NULL,
92
+ provider_id VARCHAR(255) NOT NULL,
93
+ provider_email VARCHAR(255),
94
+ provider_username VARCHAR(255),
95
+ provider_name VARCHAR(255),
96
+ provider_avatar VARCHAR(500),
97
+ created_at TIMESTAMPTZ DEFAULT NOW(),
98
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
99
+ CONSTRAINT fk_${prefix}providers_account
100
+ FOREIGN KEY (account_id) REFERENCES ${accountsTable}(id) ON DELETE CASCADE,
101
+ CONSTRAINT ${prefix}unique_provider_identity
102
+ UNIQUE(provider, provider_id)
103
+ )
104
+ `)
105
+
106
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}providers_account_id ON ${providersTable}(account_id)`)
107
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}providers_provider ON ${providersTable}(provider)`)
108
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}providers_provider_id ON ${providersTable}(provider_id)`)
109
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}providers_email ON ${providersTable}(provider_email)`)
110
+
111
+ const activityTable = `${prefix}activity_log`
112
+ await db.query(`
113
+ CREATE TABLE IF NOT EXISTS ${activityTable} (
114
+ id SERIAL PRIMARY KEY,
115
+ account_id INTEGER,
116
+ actor_account_id INTEGER,
117
+ action VARCHAR(255) NOT NULL,
118
+ ip_address INET,
119
+ user_agent TEXT,
120
+ browser VARCHAR(255),
121
+ os VARCHAR(255),
122
+ device VARCHAR(255),
123
+ success BOOLEAN DEFAULT TRUE,
124
+ metadata JSONB,
125
+ created_at TIMESTAMPTZ DEFAULT NOW(),
126
+ CONSTRAINT fk_${prefix}activity_log_account
127
+ FOREIGN KEY (account_id) REFERENCES ${accountsTable}(id) ON DELETE CASCADE,
128
+ CONSTRAINT fk_${prefix}activity_log_actor
129
+ FOREIGN KEY (actor_account_id) REFERENCES ${accountsTable}(id) ON DELETE SET NULL
130
+ )
131
+ `)
132
+
133
+ // migration for pre-existing deployments that have activity_log without actor_account_id
134
+ await db.query(`ALTER TABLE ${activityTable} ADD COLUMN IF NOT EXISTS actor_account_id INTEGER`)
135
+ await db.query(`
136
+ DO $$
137
+ BEGIN
138
+ IF NOT EXISTS (
139
+ SELECT 1 FROM pg_constraint WHERE conname = 'fk_${prefix}activity_log_actor'
140
+ ) THEN
141
+ ALTER TABLE ${activityTable}
142
+ ADD CONSTRAINT fk_${prefix}activity_log_actor
143
+ FOREIGN KEY (actor_account_id) REFERENCES ${accountsTable}(id) ON DELETE SET NULL;
144
+ END IF;
145
+ END$$;
146
+ `)
147
+
148
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}activity_log_created_at ON ${activityTable}(created_at DESC)`)
149
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}activity_log_account_id ON ${activityTable}(account_id)`)
150
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}activity_log_actor_account_id ON ${activityTable}(actor_account_id)`)
151
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}activity_log_action ON ${activityTable}(action)`)
152
+
153
+ const twoFactorMethodsTable = `${prefix}2fa_methods`
154
+ await db.query(`
155
+ CREATE TABLE IF NOT EXISTS ${twoFactorMethodsTable} (
156
+ id SERIAL PRIMARY KEY,
157
+ account_id INTEGER NOT NULL,
158
+ mechanism INTEGER NOT NULL,
159
+ secret VARCHAR(255),
160
+ backup_codes TEXT[],
161
+ verified BOOLEAN DEFAULT FALSE,
162
+ created_at TIMESTAMPTZ DEFAULT NOW(),
163
+ last_used_at TIMESTAMPTZ,
164
+ CONSTRAINT fk_${prefix}2fa_methods_account
165
+ FOREIGN KEY (account_id) REFERENCES ${accountsTable}(id) ON DELETE CASCADE,
166
+ CONSTRAINT ${prefix}unique_account_mechanism
167
+ UNIQUE(account_id, mechanism)
168
+ )
169
+ `)
170
+
171
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}2fa_methods_account_id ON ${twoFactorMethodsTable}(account_id)`)
172
+
173
+ const twoFactorTokensTable = `${prefix}2fa_tokens`
174
+ await db.query(`
175
+ CREATE TABLE IF NOT EXISTS ${twoFactorTokensTable} (
176
+ id SERIAL PRIMARY KEY,
177
+ account_id INTEGER NOT NULL,
178
+ mechanism INTEGER NOT NULL,
179
+ selector VARCHAR(32) NOT NULL,
180
+ token_hash VARCHAR(255) NOT NULL,
181
+ expires_at TIMESTAMPTZ NOT NULL,
182
+ created_at TIMESTAMPTZ DEFAULT NOW(),
183
+ CONSTRAINT fk_${prefix}2fa_tokens_account
184
+ FOREIGN KEY (account_id) REFERENCES ${accountsTable}(id) ON DELETE CASCADE
185
+ )
186
+ `)
187
+
188
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}2fa_tokens_selector ON ${twoFactorTokensTable}(selector)`)
189
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}2fa_tokens_account_id ON ${twoFactorTokensTable}(account_id)`)
190
+ await db.query(`CREATE INDEX IF NOT EXISTS idx_${prefix}2fa_tokens_expires ON ${twoFactorTokensTable}(expires_at)`)
191
+ }
192
+
193
+ /**
194
+ * @param {AuthConfig} config
195
+ * @returns {Promise<void>}
196
+ */
197
+ export async function dropAuthTables(config) {
198
+ const prefix = config.tablePrefix || "user_"
199
+ const { db } = config
200
+
201
+ await db.query(`DROP TABLE IF EXISTS ${prefix}2fa_tokens CASCADE`)
202
+ await db.query(`DROP TABLE IF EXISTS ${prefix}2fa_methods CASCADE`)
203
+ await db.query(`DROP TABLE IF EXISTS ${prefix}activity_log CASCADE`)
204
+ await db.query(`DROP TABLE IF EXISTS ${prefix}providers CASCADE`)
205
+ await db.query(`DROP TABLE IF EXISTS ${prefix}resets CASCADE`)
206
+ await db.query(`DROP TABLE IF EXISTS ${prefix}remembers CASCADE`)
207
+ await db.query(`DROP TABLE IF EXISTS ${prefix}confirmations CASCADE`)
208
+ await db.query(`DROP TABLE IF EXISTS ${prefix}accounts CASCADE`)
209
+ }
210
+
211
+ /**
212
+ * @param {AuthConfig} config
213
+ * @returns {Promise<void>}
214
+ */
215
+ export async function cleanupExpiredTokens(config) {
216
+ const prefix = config.tablePrefix || "user_"
217
+ const { db } = config
218
+
219
+ await db.query(`DELETE FROM ${prefix}confirmations WHERE expires < NOW()`)
220
+ await db.query(`DELETE FROM ${prefix}remembers WHERE expires < NOW()`)
221
+ await db.query(`DELETE FROM ${prefix}resets WHERE expires < NOW()`)
222
+ await db.query(`DELETE FROM ${prefix}2fa_tokens WHERE expires_at < NOW()`)
223
+ }
224
+
225
+ /**
226
+ * @param {AuthConfig} config
227
+ * @returns {Promise<{ accounts: number, providers: number, confirmations: number, remembers: number, resets: number, twoFactorMethods: number, twoFactorTokens: number, expiredConfirmations: number, expiredRemembers: number, expiredResets: number, expiredTwoFactorTokens: number }>}
228
+ */
229
+ export async function getAuthTableStats(config) {
230
+ const prefix = config.tablePrefix || "user_"
231
+ const { db } = config
232
+
233
+ const [accountsResult, providersResult, confirmationsResult, remembersResult, resetsResult, twoFactorMethodsResult, twoFactorTokensResult, expiredConfirmationsResult, expiredRemembersResult, expiredResetsResult, expiredTwoFactorTokensResult] =
234
+ await Promise.all([
235
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}accounts`),
236
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}providers`),
237
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}confirmations`),
238
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}remembers`),
239
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}resets`),
240
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}2fa_methods`),
241
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}2fa_tokens`),
242
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}confirmations WHERE expires < NOW()`),
243
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}remembers WHERE expires < NOW()`),
244
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}resets WHERE expires < NOW()`),
245
+ db.query(`SELECT COUNT(*) as count FROM ${prefix}2fa_tokens WHERE expires_at < NOW()`),
246
+ ])
247
+
248
+ return {
249
+ accounts: parseInt(accountsResult.rows[0]?.count || "0"),
250
+ providers: parseInt(providersResult.rows[0]?.count || "0"),
251
+ confirmations: parseInt(confirmationsResult.rows[0]?.count || "0"),
252
+ remembers: parseInt(remembersResult.rows[0]?.count || "0"),
253
+ resets: parseInt(resetsResult.rows[0]?.count || "0"),
254
+ twoFactorMethods: parseInt(twoFactorMethodsResult.rows[0]?.count || "0"),
255
+ twoFactorTokens: parseInt(twoFactorTokensResult.rows[0]?.count || "0"),
256
+ expiredConfirmations: parseInt(expiredConfirmationsResult.rows[0]?.count || "0"),
257
+ expiredRemembers: parseInt(expiredRemembersResult.rows[0]?.count || "0"),
258
+ expiredResets: parseInt(expiredResetsResult.rows[0]?.count || "0"),
259
+ expiredTwoFactorTokens: parseInt(expiredTwoFactorTokensResult.rows[0]?.count || "0"),
260
+ }
261
+ }
package/src/totp.js ADDED
@@ -0,0 +1,221 @@
1
+ import * as crypto from "node:crypto"
2
+
3
+ // inlined RFC 6238 TOTP, ported from @eaccess/totp. internal to @prsm/auth -
4
+ // not re-exported, since a standalone totp primitive has no use outside 2fa.
5
+ // base32 (RFC 4648, no padding) is implemented here to avoid a dependency
6
+
7
+ const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
8
+
9
+ /**
10
+ * @param {Buffer | Uint8Array} bytes
11
+ * @returns {string}
12
+ */
13
+ function base32Encode(bytes) {
14
+ let bits = 0
15
+ let value = 0
16
+ let output = ""
17
+ for (const byte of bytes) {
18
+ value = (value << 8) | byte
19
+ bits += 8
20
+ while (bits >= 5) {
21
+ output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]
22
+ bits -= 5
23
+ }
24
+ value &= (1 << bits) - 1
25
+ }
26
+ if (bits > 0) {
27
+ output += BASE32_ALPHABET[(value << (5 - bits)) & 31]
28
+ }
29
+ return output
30
+ }
31
+
32
+ /**
33
+ * decodes a base32 string, ignoring any non-alphabet characters (padding, separators)
34
+ * @param {string} str
35
+ * @returns {Uint8Array}
36
+ */
37
+ function base32Decode(str) {
38
+ let bits = 0
39
+ let value = 0
40
+ /** @type {number[]} */
41
+ const output = []
42
+ for (const char of str) {
43
+ const idx = BASE32_ALPHABET.indexOf(char)
44
+ if (idx === -1) continue
45
+ value = (value << 5) | idx
46
+ bits += 5
47
+ if (bits >= 8) {
48
+ output.push((value >>> (bits - 8)) & 0xff)
49
+ bits -= 8
50
+ }
51
+ value &= (1 << bits) - 1
52
+ }
53
+ return Uint8Array.from(output)
54
+ }
55
+
56
+ export class InvalidOtpLengthError extends Error {}
57
+ export class InvalidSecretError extends Error {}
58
+ export class InvalidHashFunctionError extends Error {}
59
+ export class InvalidSecretStrengthError extends Error {}
60
+ export class InvalidIntervalError extends Error {}
61
+
62
+ class Otp {
63
+ static OTP_LENGTH_MIN = 6
64
+ static OTP_LENGTH_MAX = 8
65
+ static OTP_LENGTH_DEFAULT = 6
66
+ static INTERVAL_LENGTH_DEFAULT = 30
67
+ static EPOCH_DEFAULT = 0
68
+ static HASH_FUNCTION_SHA_1 = 1
69
+ static HASH_FUNCTION_SHA_256 = 2
70
+ static HASH_FUNCTION_SHA_512 = 3
71
+ static HASH_FUNCTION_DEFAULT = Otp.HASH_FUNCTION_SHA_1
72
+ static SHARED_SECRET_STRENGTH_LOW = 1
73
+ static SHARED_SECRET_STRENGTH_MODERATE = 2
74
+ static SHARED_SECRET_STRENGTH_HIGH = 3
75
+
76
+ /**
77
+ * Generate a random Base32 shared secret (without padding).
78
+ * @param {number} [strength] one of the SHARED_SECRET_STRENGTH_* constants, defaults to high (160 bits)
79
+ * @returns {string}
80
+ * @throws {InvalidSecretStrengthError}
81
+ */
82
+ static createSecret(strength = Otp.SHARED_SECRET_STRENGTH_HIGH) {
83
+ const bits = this.determineBitsForSharedSecretStrength(strength)
84
+ const bytes = Math.ceil(bits / 8)
85
+ const buffer = crypto.randomBytes(bytes)
86
+ return base32Encode(buffer).replace(/=+$/, "")
87
+ }
88
+
89
+ /**
90
+ * Build an otpauth:// URI for QR-code provisioning in authenticator apps.
91
+ * @param {string} issuer
92
+ * @param {string} accountName
93
+ * @param {string} secret
94
+ * @returns {string}
95
+ */
96
+ static createTotpKeyUriForQrCode(issuer, accountName, secret) {
97
+ return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`
98
+ }
99
+
100
+ /**
101
+ * Generate a TOTP value.
102
+ * @param {string} secret base32 shared secret, at least 16 chars after sanitization
103
+ * @param {number} [t] unix time in seconds, defaults to now
104
+ * @param {number} [otpLength] number of digits (6-8), defaults to 6
105
+ * @param {number} [t_x] time step in seconds, defaults to 30
106
+ * @param {number} [t_0] epoch start in seconds, defaults to 0
107
+ * @param {number} [hashFunction] one of the HASH_FUNCTION_* constants, defaults to SHA-1
108
+ * @returns {string}
109
+ * @throws {InvalidOtpLengthError|InvalidIntervalError|InvalidSecretError|InvalidHashFunctionError}
110
+ */
111
+ static generateTotp(secret, t = Math.floor(Date.now() / 1000), otpLength = Otp.OTP_LENGTH_DEFAULT, t_x = Otp.INTERVAL_LENGTH_DEFAULT, t_0 = Otp.EPOCH_DEFAULT, hashFunction = Otp.HASH_FUNCTION_DEFAULT) {
112
+ if (otpLength < Otp.OTP_LENGTH_MIN || otpLength > Otp.OTP_LENGTH_MAX) {
113
+ throw new InvalidOtpLengthError()
114
+ }
115
+
116
+ if (t_x <= 0) {
117
+ throw new InvalidIntervalError()
118
+ }
119
+
120
+ secret = secret ?? ""
121
+ t = t ?? Math.floor(Date.now() / 1000)
122
+
123
+ const c_t = Math.max(0, Math.floor((t - t_0) / t_x))
124
+
125
+ secret = secret.replace(/[^A-Za-z2-7]/g, "").toUpperCase()
126
+
127
+ if (secret.length < 16) {
128
+ throw new InvalidSecretError()
129
+ }
130
+
131
+ const k = base32Decode(secret)
132
+
133
+ const counter64BitBigEndian = Buffer.alloc(8)
134
+ counter64BitBigEndian.writeUInt32BE(Math.floor(c_t / Math.pow(2, 32)), 0)
135
+ counter64BitBigEndian.writeUInt32BE(c_t % Math.pow(2, 32), 4)
136
+
137
+ let hashFunctionNameForHmac
138
+ switch (hashFunction) {
139
+ case Otp.HASH_FUNCTION_SHA_1:
140
+ hashFunctionNameForHmac = "sha1"
141
+ break
142
+ case Otp.HASH_FUNCTION_SHA_256:
143
+ hashFunctionNameForHmac = "sha256"
144
+ break
145
+ case Otp.HASH_FUNCTION_SHA_512:
146
+ hashFunctionNameForHmac = "sha512"
147
+ break
148
+ default:
149
+ throw new InvalidHashFunctionError()
150
+ }
151
+
152
+ const hmac = crypto.createHmac(hashFunctionNameForHmac, Buffer.from(k))
153
+ hmac.update(counter64BitBigEndian)
154
+ const mac = hmac.digest()
155
+
156
+ const offset = mac[mac.length - 1] & 0x0f
157
+ const macSubstring4Bytes = mac.slice(offset, offset + 4)
158
+
159
+ const integer32Bit = macSubstring4Bytes.readUInt32BE(0) & 0x7fffffff
160
+
161
+ const hotp = integer32Bit % Math.pow(10, otpLength)
162
+
163
+ return hotp.toString().padStart(otpLength, "0")
164
+ }
165
+
166
+ /**
167
+ * Verify a user-supplied TOTP across a drift window using a constant-time compare.
168
+ * @param {string} secret
169
+ * @param {string} otpValue
170
+ * @param {number} [lookBehindSteps] defaults to 2
171
+ * @param {number} [lookAheadSteps] defaults to lookBehindSteps (symmetric)
172
+ * @param {number} [t]
173
+ * @param {number} [otpLength]
174
+ * @param {number} [t_x]
175
+ * @param {number} [t_0]
176
+ * @param {number} [hashFunction]
177
+ * @returns {boolean}
178
+ */
179
+ static verifyTotp(secret, otpValue, lookBehindSteps = 2, lookAheadSteps, t = Math.floor(Date.now() / 1000), otpLength = Otp.OTP_LENGTH_DEFAULT, t_x = Otp.INTERVAL_LENGTH_DEFAULT, t_0 = Otp.EPOCH_DEFAULT, hashFunction = Otp.HASH_FUNCTION_DEFAULT) {
180
+ const ahead = lookAheadSteps ?? lookBehindSteps
181
+
182
+ otpValue = otpValue.replace(/[^0-9]/g, "")
183
+
184
+ if (otpValue.length < Otp.OTP_LENGTH_MIN || otpValue.length > Otp.OTP_LENGTH_MAX) {
185
+ return false
186
+ }
187
+
188
+ if (otpValue.length !== otpLength) {
189
+ return false
190
+ }
191
+
192
+ for (let s = -lookBehindSteps; s <= ahead; s++) {
193
+ const expectedOtpValue = this.generateTotp(secret, t + t_x * s, otpLength, t_x, t_0, hashFunction)
194
+ if (crypto.timingSafeEqual(Buffer.from(expectedOtpValue), Buffer.from(otpValue))) {
195
+ return true
196
+ }
197
+ }
198
+
199
+ return false
200
+ }
201
+
202
+ /**
203
+ * @param {number} strength
204
+ * @returns {number}
205
+ * @throws {InvalidSecretStrengthError}
206
+ */
207
+ static determineBitsForSharedSecretStrength(strength) {
208
+ switch (strength) {
209
+ case 1:
210
+ return 80
211
+ case 2:
212
+ return 128
213
+ case 3:
214
+ return 160
215
+ default:
216
+ throw new InvalidSecretStrengthError()
217
+ }
218
+ }
219
+ }
220
+
221
+ export default Otp
@@ -0,0 +1,3 @@
1
+ export { TwoFactorManager } from "./two-factor-manager.js"
2
+ export { TotpProvider } from "./totp-provider.js"
3
+ export { OtpProvider } from "./otp-provider.js"
@@ -0,0 +1,128 @@
1
+ import ms from "@prsm/ms"
2
+ import hash from "@prsm/hash"
3
+ import { AuthQueries } from "../queries.js"
4
+
5
+ /**
6
+ * @typedef {import("../types.js").AuthConfig} AuthConfig
7
+ * @typedef {import("../types.js").TwoFactorToken} TwoFactorToken
8
+ */
9
+
10
+ export class OtpProvider {
11
+ /**
12
+ * @param {AuthConfig} config
13
+ */
14
+ constructor(config) {
15
+ this.config = config
16
+ this.queries = new AuthQueries(config)
17
+ }
18
+
19
+ /**
20
+ * Generate a numeric one-time password.
21
+ * @returns {string}
22
+ */
23
+ generateOTP() {
24
+ const length = this.config.twoFactor?.codeLength || 6
25
+ const bytes = crypto.getRandomValues(new Uint8Array(length))
26
+ return Array.from(bytes, (b) => (b % 10).toString()).join("")
27
+ }
28
+
29
+ /**
30
+ * Generate a random opaque selector for an OTP token.
31
+ * @returns {string}
32
+ */
33
+ generateSelector() {
34
+ return crypto.randomUUID().replace(/-/g, "")
35
+ }
36
+
37
+ /**
38
+ * Create, hash, and store a new OTP for an account and mechanism.
39
+ * @param {number} accountId
40
+ * @param {number} mechanism EMAIL or SMS
41
+ * @returns {Promise<{ otp: string, selector: string }>}
42
+ */
43
+ async createAndStoreOTP(accountId, mechanism) {
44
+ const otp = this.generateOTP()
45
+ const selector = this.generateSelector()
46
+ const tokenHash = await hash.encode(otp)
47
+
48
+ const expiryDuration = this.config.twoFactor?.tokenExpiry || "5m"
49
+ const expiresAt = new Date(Date.now() + ms(expiryDuration))
50
+
51
+ // delete any existing tokens for this account and mechanism
52
+ await this.queries.deleteTwoFactorTokensByAccountAndMechanism(accountId, mechanism)
53
+
54
+ // store the new token
55
+ await this.queries.createTwoFactorToken({
56
+ accountId,
57
+ mechanism,
58
+ selector,
59
+ tokenHash,
60
+ expiresAt,
61
+ })
62
+
63
+ return { otp, selector }
64
+ }
65
+
66
+ /**
67
+ * Verify an OTP by selector, consuming the token on success.
68
+ * @param {string} selector
69
+ * @param {string} inputCode
70
+ * @returns {Promise<{ isValid: boolean, token?: TwoFactorToken }>}
71
+ */
72
+ async verifyOTP(selector, inputCode) {
73
+ const token = await this.queries.findTwoFactorTokenBySelector(selector)
74
+
75
+ if (!token) {
76
+ return { isValid: false }
77
+ }
78
+
79
+ // check if token has expired (extra check, even though query filters expired tokens)
80
+ if (token.expires_at <= new Date()) {
81
+ // clean up expired token
82
+ await this.queries.deleteTwoFactorToken(token.id)
83
+ return { isValid: false }
84
+ }
85
+
86
+ const isValid = await hash.verify(token.token_hash, inputCode)
87
+
88
+ if (isValid) {
89
+ // clean up used token
90
+ await this.queries.deleteTwoFactorToken(token.id)
91
+ return { isValid: true, token }
92
+ }
93
+
94
+ return { isValid: false }
95
+ }
96
+
97
+ /**
98
+ * Mask a phone number for display.
99
+ * @param {string} phone
100
+ * @returns {string}
101
+ */
102
+ maskPhone(phone) {
103
+ if (phone.length < 4) {
104
+ return phone.replace(/./g, "*")
105
+ }
106
+
107
+ // show first digit and last 2 digits: +1234567890 -> +1*****90
108
+ if (phone.startsWith("+")) {
109
+ return phone[0] + phone[1] + "*".repeat(phone.length - 3) + phone.slice(-2)
110
+ }
111
+
112
+ // for regular numbers: 1234567890 -> 1*****90
113
+ return phone[0] + "*".repeat(phone.length - 3) + phone.slice(-2)
114
+ }
115
+
116
+ /**
117
+ * Mask an email address for display.
118
+ * @param {string} email
119
+ * @returns {string}
120
+ */
121
+ maskEmail(email) {
122
+ const [username, domain] = email.split("@")
123
+ if (username.length <= 2) {
124
+ return `${username[0]}***@${domain}`
125
+ }
126
+ return `${username[0]}${"*".repeat(username.length - 2)}${username[username.length - 1]}@${domain}`
127
+ }
128
+ }