@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.
- package/README.md +226 -0
- package/index.d.ts +19 -0
- package/package.json +76 -0
- package/src/__tests__/auth.test.js +1171 -0
- package/src/__tests__/impersonation-test-setup.js +208 -0
- package/src/__tests__/impersonation.test.js +473 -0
- package/src/__tests__/oauth-test-setup.js +136 -0
- package/src/__tests__/oauth.test.js +400 -0
- package/src/__tests__/prsm.test.js +215 -0
- package/src/__tests__/test-setup.js +385 -0
- package/src/__tests__/totp.test.js +158 -0
- package/src/__tests__/two-factor-test-setup.js +331 -0
- package/src/__tests__/two-factor.test.js +396 -0
- package/src/activity-logger.js +228 -0
- package/src/auth-context.js +120 -0
- package/src/auth-functions.js +520 -0
- package/src/auth-manager.js +1371 -0
- package/src/errors.js +173 -0
- package/src/hooks.js +41 -0
- package/src/index.js +23 -0
- package/src/invalidation.js +166 -0
- package/src/middleware.js +33 -0
- package/src/providers/azure-provider.js +114 -0
- package/src/providers/base-provider.js +152 -0
- package/src/providers/github-provider.js +86 -0
- package/src/providers/google-provider.js +76 -0
- package/src/providers/index.js +4 -0
- package/src/queries.js +543 -0
- package/src/schema.js +261 -0
- package/src/totp.js +221 -0
- package/src/two-factor/index.js +3 -0
- package/src/two-factor/otp-provider.js +128 -0
- package/src/two-factor/totp-provider.js +98 -0
- package/src/two-factor/two-factor-manager.js +676 -0
- package/src/types.js +399 -0
- package/src/user-roles.js +128 -0
- package/src/util.js +32 -0
- package/types/activity-logger.d.ts +73 -0
- package/types/auth-context.d.ts +88 -0
- package/types/auth-functions.d.ts +151 -0
- package/types/auth-manager.d.ts +365 -0
- package/types/errors.d.ts +108 -0
- package/types/hooks.d.ts +30 -0
- package/types/index.d.ts +13 -0
- package/types/invalidation.d.ts +40 -0
- package/types/middleware.d.ts +11 -0
- package/types/providers/azure-provider.d.ts +35 -0
- package/types/providers/base-provider.d.ts +52 -0
- package/types/providers/github-provider.d.ts +29 -0
- package/types/providers/google-provider.d.ts +29 -0
- package/types/providers/index.d.ts +4 -0
- package/types/queries.d.ts +287 -0
- package/types/schema.d.ts +37 -0
- package/types/totp.d.ts +72 -0
- package/types/two-factor/index.d.ts +3 -0
- package/types/two-factor/otp-provider.d.ts +57 -0
- package/types/two-factor/totp-provider.d.ts +58 -0
- package/types/two-factor/two-factor-manager.d.ts +191 -0
- package/types/types.d.ts +688 -0
- package/types/user-roles.d.ts +47 -0
- 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,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
|
+
}
|