@zero-server/auth 0.9.1 → 0.9.3
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/LICENSE +21 -21
- package/index.d.ts +1 -1
- package/index.js +26 -26
- package/lib/auth/authorize.js +399 -0
- package/lib/auth/enrollment.js +367 -0
- package/lib/auth/index.js +57 -0
- package/lib/auth/jwt.js +731 -0
- package/lib/auth/oauth.js +362 -0
- package/lib/auth/session.js +588 -0
- package/lib/auth/trustedDevice.js +409 -0
- package/lib/auth/twoFactor.js +1150 -0
- package/lib/auth/webauthn.js +946 -0
- package/lib/debug.js +372 -0
- package/package.json +12 -2
- package/types/app.d.ts +223 -0
- package/types/auth.d.ts +520 -0
- package/types/body.d.ts +14 -0
- package/types/cli.d.ts +2 -0
- package/types/cluster.d.ts +75 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +316 -0
- package/types/fetch.d.ts +43 -0
- package/types/grpc.d.ts +432 -0
- package/types/index.d.ts +384 -0
- package/types/lifecycle.d.ts +60 -0
- package/types/middleware.d.ts +320 -0
- package/types/observe.d.ts +304 -0
- package/types/orm.d.ts +1887 -0
- package/types/request.d.ts +109 -0
- package/types/response.d.ts +157 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +126 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module auth/enrollment
|
|
3
|
+
* @description 2FA Enrollment Flow Helper.
|
|
4
|
+
* Provides a session-scoped, multi-step enrollment workflow
|
|
5
|
+
* for TOTP-based two-factor authentication.
|
|
6
|
+
*
|
|
7
|
+
* Steps:
|
|
8
|
+
* 1. `start()` — Generate secret + backup codes, store in session
|
|
9
|
+
* 2. `verify()` — Confirm user can produce a valid TOTP code
|
|
10
|
+
* 3. `complete()` — Persist the verified secret to the database
|
|
11
|
+
* 4. `disable()` — Remove 2FA from the account
|
|
12
|
+
*
|
|
13
|
+
* @example | Full enrollment flow
|
|
14
|
+
* const { enrollment } = require('@zero-server/sdk');
|
|
15
|
+
* const flow = enrollment({
|
|
16
|
+
* saveSecret: async (req, secret, backupHashes) => {
|
|
17
|
+
* await db.users.update(req.user.id, { totpSecret: secret, backupHashes });
|
|
18
|
+
* },
|
|
19
|
+
* removeSecret: async (req) => {
|
|
20
|
+
* await db.users.update(req.user.id, { totpSecret: null, backupHashes: [] });
|
|
21
|
+
* },
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* app.post('/2fa/start', json(), flow.start());
|
|
25
|
+
* app.post('/2fa/verify', json(), flow.verify());
|
|
26
|
+
* app.post('/2fa/complete', json(), flow.complete());
|
|
27
|
+
* app.post('/2fa/disable', json(), flow.disable());
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const log = require('../debug')('zero:enrollment');
|
|
31
|
+
|
|
32
|
+
// Lazy-load twoFactor to avoid circular deps at module level
|
|
33
|
+
let _twoFactor = null;
|
|
34
|
+
function _getTwoFactor()
|
|
35
|
+
{
|
|
36
|
+
if (!_twoFactor) _twoFactor = require('./twoFactor');
|
|
37
|
+
return _twoFactor;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// -- Constants ---------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const DEFAULT_SESSION_KEY = '_2faEnrollment';
|
|
43
|
+
const DEFAULT_ENROLLMENT_TTL = 10 * 60 * 1000; // 10 minutes
|
|
44
|
+
|
|
45
|
+
// -- Enrollment Factory ------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a 2FA enrollment flow bound to your persistence callbacks.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} opts - Options.
|
|
51
|
+
* @param {Function} opts.saveSecret - `(req, base32Secret, backupHashes) => Promise<void>`.
|
|
52
|
+
* Persist the verified TOTP secret and backup hashes.
|
|
53
|
+
* @param {Function} opts.removeSecret - `(req) => Promise<void>`.
|
|
54
|
+
* Remove TOTP secret on disable.
|
|
55
|
+
* @param {string} [opts.issuer='App'] - Issuer name for the otpauth URI.
|
|
56
|
+
* @param {Function} [opts.getAccount] - `(req) => string`. User label for QR code.
|
|
57
|
+
* Defaults to `req.user.email || req.user.id`.
|
|
58
|
+
* @param {string} [opts.sessionKey='_2faEnrollment'] - Session key for pending enrollment.
|
|
59
|
+
* @param {number} [opts.ttl=600000] - Enrollment session TTL in ms (default 10 min).
|
|
60
|
+
* @param {number} [opts.backupCount=10] - Number of backup codes to generate.
|
|
61
|
+
* @param {number} [opts.window=1] - TOTP verification window.
|
|
62
|
+
* @param {number} [opts.period=30] - TOTP period in seconds.
|
|
63
|
+
* @param {string} [opts.algorithm='sha1'] - HMAC algorithm.
|
|
64
|
+
* @param {number} [opts.digits=6] - Code length.
|
|
65
|
+
* @param {Function} [opts.isEnabled] - `(req) => boolean|Promise<boolean>`.
|
|
66
|
+
* Check if 2FA is already enabled (for guarding start/disable).
|
|
67
|
+
* @returns {{ start: Function, verify: Function, complete: Function, disable: Function }}
|
|
68
|
+
*/
|
|
69
|
+
function enrollment(opts = {})
|
|
70
|
+
{
|
|
71
|
+
if (typeof opts.saveSecret !== 'function')
|
|
72
|
+
throw new Error('enrollment() requires a saveSecret(req, secret, backupHashes) function');
|
|
73
|
+
if (typeof opts.removeSecret !== 'function')
|
|
74
|
+
throw new Error('enrollment() requires a removeSecret(req) function');
|
|
75
|
+
|
|
76
|
+
const issuer = opts.issuer || 'App';
|
|
77
|
+
const getAccount = opts.getAccount || _defaultGetAccount;
|
|
78
|
+
const sessionKey = opts.sessionKey || DEFAULT_SESSION_KEY;
|
|
79
|
+
const ttl = opts.ttl || DEFAULT_ENROLLMENT_TTL;
|
|
80
|
+
const backupCount = opts.backupCount || 10;
|
|
81
|
+
const window = opts.window != null ? opts.window : 1;
|
|
82
|
+
const period = opts.period || 30;
|
|
83
|
+
const algorithm = opts.algorithm || 'sha1';
|
|
84
|
+
const digits = opts.digits || 6;
|
|
85
|
+
const isEnabled = opts.isEnabled || null;
|
|
86
|
+
|
|
87
|
+
function _sendJson(res, status, body)
|
|
88
|
+
{
|
|
89
|
+
const raw = res.raw || res;
|
|
90
|
+
if (raw.headersSent) return;
|
|
91
|
+
raw.statusCode = status;
|
|
92
|
+
raw.setHeader('Content-Type', 'application/json');
|
|
93
|
+
raw.end(JSON.stringify(body));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _requireSession(req, res)
|
|
97
|
+
{
|
|
98
|
+
if (!req.session || typeof req.session.set !== 'function')
|
|
99
|
+
{
|
|
100
|
+
_sendJson(res, 500, { error: 'Session middleware required for 2FA enrollment' });
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- start() ----
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Start the enrollment process.
|
|
110
|
+
* Generates a TOTP secret and backup codes, stores them in the session,
|
|
111
|
+
* and returns the otpauth URI (for QR code) plus backup codes to the client.
|
|
112
|
+
*
|
|
113
|
+
* @returns {Function} Middleware `(req, res) => void`.
|
|
114
|
+
*/
|
|
115
|
+
function start()
|
|
116
|
+
{
|
|
117
|
+
return async function _enrollmentStart(req, res)
|
|
118
|
+
{
|
|
119
|
+
if (!_requireSession(req, res)) return;
|
|
120
|
+
|
|
121
|
+
// Guard: if 2FA is already enabled
|
|
122
|
+
if (typeof isEnabled === 'function')
|
|
123
|
+
{
|
|
124
|
+
try
|
|
125
|
+
{
|
|
126
|
+
const enabled = await isEnabled(req);
|
|
127
|
+
if (enabled)
|
|
128
|
+
{
|
|
129
|
+
_sendJson(res, 409, { error: '2FA is already enabled. Disable it first.' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (err)
|
|
134
|
+
{
|
|
135
|
+
log.error('isEnabled check error: %s', err.message);
|
|
136
|
+
_sendJson(res, 500, { error: 'Internal server error' });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const tf = _getTwoFactor();
|
|
142
|
+
|
|
143
|
+
const secret = tf.generateSecret();
|
|
144
|
+
let account;
|
|
145
|
+
try
|
|
146
|
+
{
|
|
147
|
+
account = await getAccount(req);
|
|
148
|
+
}
|
|
149
|
+
catch (err)
|
|
150
|
+
{
|
|
151
|
+
log.error('getAccount error: %s', err.message);
|
|
152
|
+
_sendJson(res, 500, { error: 'Internal server error' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const uri = tf.otpauthURI({ secret: secret.base32, issuer, account });
|
|
157
|
+
const { codes, hashes } = tf.generateBackupCodes(backupCount);
|
|
158
|
+
|
|
159
|
+
// Store pending enrollment in session
|
|
160
|
+
req.session.set(sessionKey, {
|
|
161
|
+
secret: secret.base32,
|
|
162
|
+
backupHashes: hashes,
|
|
163
|
+
createdAt: Date.now(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
log.info('enrollment started for %s', account);
|
|
167
|
+
|
|
168
|
+
_sendJson(res, 200, {
|
|
169
|
+
secret: secret.base32,
|
|
170
|
+
uri,
|
|
171
|
+
backupCodes: codes,
|
|
172
|
+
expiresIn: Math.floor(ttl / 1000),
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- verify() ----
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Verify that the user can produce a valid TOTP code with the pending secret.
|
|
181
|
+
* This confirms their authenticator app is configured correctly.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} [verifyOpts] - Options.
|
|
184
|
+
* @param {string} [verifyOpts.codeField='code'] - Body field for the TOTP code.
|
|
185
|
+
* @returns {Function} Middleware `(req, res) => void`.
|
|
186
|
+
*/
|
|
187
|
+
function verify(verifyOpts = {})
|
|
188
|
+
{
|
|
189
|
+
const codeField = verifyOpts.codeField || 'code';
|
|
190
|
+
|
|
191
|
+
return async function _enrollmentVerify(req, res)
|
|
192
|
+
{
|
|
193
|
+
if (!_requireSession(req, res)) return;
|
|
194
|
+
|
|
195
|
+
const pending = req.session.get(sessionKey);
|
|
196
|
+
if (!pending || !pending.secret)
|
|
197
|
+
{
|
|
198
|
+
_sendJson(res, 400, { error: 'No pending enrollment. Call start() first.' });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Check TTL
|
|
203
|
+
if (Date.now() - pending.createdAt > ttl)
|
|
204
|
+
{
|
|
205
|
+
req.session.set(sessionKey, null);
|
|
206
|
+
_sendJson(res, 410, { error: 'Enrollment expired. Please start again.' });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const code = req.body?.[codeField];
|
|
211
|
+
if (!code || typeof code !== 'string')
|
|
212
|
+
{
|
|
213
|
+
_sendJson(res, 400, { error: `Missing ${codeField} field` });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const tf = _getTwoFactor();
|
|
218
|
+
const result = tf.verifyTOTP(code, pending.secret, { window, period, algorithm, digits });
|
|
219
|
+
|
|
220
|
+
if (!result.valid)
|
|
221
|
+
{
|
|
222
|
+
_sendJson(res, 401, { error: 'Invalid code. Check your authenticator app and try again.' });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Mark as verified in session
|
|
227
|
+
pending.verified = true;
|
|
228
|
+
req.session.set(sessionKey, pending);
|
|
229
|
+
|
|
230
|
+
log.info('enrollment code verified (delta=%d)', result.delta);
|
|
231
|
+
|
|
232
|
+
_sendJson(res, 200, { verified: true });
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- complete() ----
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Complete the enrollment by persisting the verified secret.
|
|
240
|
+
* Only succeeds if `verify()` was called first.
|
|
241
|
+
*
|
|
242
|
+
* @returns {Function} Middleware `(req, res) => void`.
|
|
243
|
+
*/
|
|
244
|
+
function complete()
|
|
245
|
+
{
|
|
246
|
+
return async function _enrollmentComplete(req, res)
|
|
247
|
+
{
|
|
248
|
+
if (!_requireSession(req, res)) return;
|
|
249
|
+
|
|
250
|
+
const pending = req.session.get(sessionKey);
|
|
251
|
+
if (!pending || !pending.secret)
|
|
252
|
+
{
|
|
253
|
+
_sendJson(res, 400, { error: 'No pending enrollment.' });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!pending.verified)
|
|
258
|
+
{
|
|
259
|
+
_sendJson(res, 400, { error: 'Enrollment not yet verified. Call verify() first.' });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Check TTL
|
|
264
|
+
if (Date.now() - pending.createdAt > ttl)
|
|
265
|
+
{
|
|
266
|
+
req.session.set(sessionKey, null);
|
|
267
|
+
_sendJson(res, 410, { error: 'Enrollment expired. Please start again.' });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try
|
|
272
|
+
{
|
|
273
|
+
await opts.saveSecret(req, pending.secret, pending.backupHashes);
|
|
274
|
+
}
|
|
275
|
+
catch (err)
|
|
276
|
+
{
|
|
277
|
+
log.error('saveSecret error: %s', err.message);
|
|
278
|
+
_sendJson(res, 500, { error: 'Failed to save 2FA configuration' });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Clear pending enrollment
|
|
283
|
+
req.session.set(sessionKey, null);
|
|
284
|
+
|
|
285
|
+
// Mark 2FA as verified in session so require2FA() passes
|
|
286
|
+
req.session.set('twoFactorVerified', true);
|
|
287
|
+
|
|
288
|
+
log.info('enrollment completed successfully');
|
|
289
|
+
|
|
290
|
+
_sendJson(res, 200, { enabled: true });
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- disable() ----
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Disable 2FA for the user.
|
|
298
|
+
* Optionally requires current TOTP code or password confirmation.
|
|
299
|
+
*
|
|
300
|
+
* @param {object} [disableOpts] - Options.
|
|
301
|
+
* @param {Function} [disableOpts.confirm] - `(req) => boolean|Promise<boolean>`.
|
|
302
|
+
* If provided, must return `true` to allow disable (e.g. validate password).
|
|
303
|
+
* @returns {Function} Middleware `(req, res) => void`.
|
|
304
|
+
*/
|
|
305
|
+
function disable(disableOpts = {})
|
|
306
|
+
{
|
|
307
|
+
return async function _enrollmentDisable(req, res)
|
|
308
|
+
{
|
|
309
|
+
if (!_requireSession(req, res)) return;
|
|
310
|
+
|
|
311
|
+
// Confirmation check
|
|
312
|
+
if (typeof disableOpts.confirm === 'function')
|
|
313
|
+
{
|
|
314
|
+
try
|
|
315
|
+
{
|
|
316
|
+
const ok = await disableOpts.confirm(req);
|
|
317
|
+
if (!ok)
|
|
318
|
+
{
|
|
319
|
+
_sendJson(res, 403, { error: 'Confirmation failed' });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (err)
|
|
324
|
+
{
|
|
325
|
+
log.error('disable confirm error: %s', err.message);
|
|
326
|
+
_sendJson(res, 500, { error: 'Internal server error' });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try
|
|
332
|
+
{
|
|
333
|
+
await opts.removeSecret(req);
|
|
334
|
+
}
|
|
335
|
+
catch (err)
|
|
336
|
+
{
|
|
337
|
+
log.error('removeSecret error: %s', err.message);
|
|
338
|
+
_sendJson(res, 500, { error: 'Failed to remove 2FA configuration' });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Clear enrollment and 2FA session state
|
|
343
|
+
req.session.set(sessionKey, null);
|
|
344
|
+
req.session.set('twoFactorVerified', false);
|
|
345
|
+
|
|
346
|
+
log.info('2FA disabled');
|
|
347
|
+
|
|
348
|
+
_sendJson(res, 200, { disabled: true });
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { start, verify, complete, disable };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// -- Helpers -------------------------------------------------
|
|
356
|
+
|
|
357
|
+
function _defaultGetAccount(req)
|
|
358
|
+
{
|
|
359
|
+
if (!req.user) throw new Error('No user on request — authentication middleware required');
|
|
360
|
+
return req.user.email || req.user.id || req.user.sub;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// -- Exports -------------------------------------------------
|
|
364
|
+
|
|
365
|
+
module.exports = {
|
|
366
|
+
enrollment,
|
|
367
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module auth
|
|
3
|
+
* @description Authentication & authorization barrel export.
|
|
4
|
+
* Re-exports JWT, Session, OAuth2, and Authorization helpers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { jwt, sign, verify, decode, jwks, tokenPair, createRefreshToken, SUPPORTED_ALGORITHMS } = require('./jwt');
|
|
8
|
+
const { session, Session, MemoryStore } = require('./session');
|
|
9
|
+
const { oauth, generatePKCE, generateState, PROVIDERS } = require('./oauth');
|
|
10
|
+
const { authorize, can, canAny, Policy, gate, attachUserHelpers } = require('./authorize');
|
|
11
|
+
const twoFactor = require('./twoFactor');
|
|
12
|
+
const { webauthn } = require('./webauthn');
|
|
13
|
+
const { trustedDevice } = require('./trustedDevice');
|
|
14
|
+
const { enrollment } = require('./enrollment');
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
// JWT
|
|
18
|
+
jwt,
|
|
19
|
+
sign,
|
|
20
|
+
verify,
|
|
21
|
+
decode,
|
|
22
|
+
jwks,
|
|
23
|
+
tokenPair,
|
|
24
|
+
createRefreshToken,
|
|
25
|
+
SUPPORTED_ALGORITHMS,
|
|
26
|
+
|
|
27
|
+
// Session
|
|
28
|
+
session,
|
|
29
|
+
Session,
|
|
30
|
+
MemoryStore,
|
|
31
|
+
|
|
32
|
+
// OAuth2
|
|
33
|
+
oauth,
|
|
34
|
+
generatePKCE,
|
|
35
|
+
generateState,
|
|
36
|
+
PROVIDERS,
|
|
37
|
+
|
|
38
|
+
// Authorization
|
|
39
|
+
authorize,
|
|
40
|
+
can,
|
|
41
|
+
canAny,
|
|
42
|
+
Policy,
|
|
43
|
+
gate,
|
|
44
|
+
attachUserHelpers,
|
|
45
|
+
|
|
46
|
+
// Two-Factor Authentication
|
|
47
|
+
twoFactor,
|
|
48
|
+
|
|
49
|
+
// WebAuthn / Passkeys
|
|
50
|
+
webauthn,
|
|
51
|
+
|
|
52
|
+
// Trusted Device / Remember Me
|
|
53
|
+
trustedDevice,
|
|
54
|
+
|
|
55
|
+
// 2FA Enrollment Flow
|
|
56
|
+
enrollment,
|
|
57
|
+
};
|