toiljs 0.0.100 → 0.0.102
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/CHANGELOG.md +10 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +35 -1
- package/build/client/auth.js +75 -0
- package/build/client/index.d.ts +2 -2
- package/build/client/index.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +23 -12
- package/build/compiler/toil-docs.generated.js +5 -4
- package/docs/README.md +2 -1
- package/docs/auth/configuration.md +11 -0
- package/docs/concepts/ai-guide.md +132 -0
- package/docs/frontend/rendering.md +5 -5
- package/docs/llms.txt +1 -0
- package/docs/services/cookies.md +2 -2
- package/package.json +1 -1
- package/server/auth/AuthController.ts +65 -17
- package/src/client/auth.ts +125 -0
- package/src/client/index.ts +9 -1
- package/src/compiler/docs.ts +23 -12
- package/src/compiler/toil-docs.generated.ts +5 -4
- package/test/pqauth-e2e.test.ts +5 -5
- package/test/pqauth-email.test.ts +116 -44
package/test/pqauth-e2e.test.ts
CHANGED
|
@@ -97,10 +97,10 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
|
|
|
97
97
|
it(
|
|
98
98
|
'registers then logs in (full OPRF + ML-DSA + ML-KEM mutual-auth chain)',
|
|
99
99
|
async () => {
|
|
100
|
-
await Auth.register('ada', 'correct horse battery
|
|
100
|
+
await Auth.register('ada', 'correct horse battery stapleA1', 'ada@example.com');
|
|
101
101
|
// login resolves ONLY if the server's mutual-auth confirmation tag
|
|
102
102
|
// verified against the client's own shared secret.
|
|
103
|
-
const session = await Auth.login('ada', 'correct horse battery
|
|
103
|
+
const session = await Auth.login('ada', 'correct horse battery stapleA1');
|
|
104
104
|
expect(session.length).toBeGreaterThan(0);
|
|
105
105
|
|
|
106
106
|
// Regression guard: the freshly minted session cookie (captured by the
|
|
@@ -129,7 +129,7 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
|
|
|
129
129
|
it(
|
|
130
130
|
'rejects a wrong password at login',
|
|
131
131
|
async () => {
|
|
132
|
-
await Auth.register('bob', 'hunter2-
|
|
132
|
+
await Auth.register('bob', 'hunter2-correctA1', 'bob@example.com');
|
|
133
133
|
await expect(Auth.login('bob', 'hunter2-WRONG')).rejects.toThrow(/login failed|request failed/);
|
|
134
134
|
},
|
|
135
135
|
60_000,
|
|
@@ -146,12 +146,12 @@ describe.skipIf(!haveWasm)('post-quantum auth end-to-end (client <-> example was
|
|
|
146
146
|
it(
|
|
147
147
|
'rejects a duplicate registration with a clear (not generic) message',
|
|
148
148
|
async () => {
|
|
149
|
-
await Auth.register('dupe', 'correct horse battery
|
|
149
|
+
await Auth.register('dupe', 'correct horse battery stapleA1', 'dupe@example.com');
|
|
150
150
|
// Second registration of the same username must say so explicitly,
|
|
151
151
|
// not return the generic "request failed". (The username-taken check fires
|
|
152
152
|
// before the email check, so it stays `already registered` even with the same email.)
|
|
153
153
|
await expect(
|
|
154
|
-
Auth.register('dupe', 'correct horse battery
|
|
154
|
+
Auth.register('dupe', 'correct horse battery stapleA1', 'dupe@example.com'),
|
|
155
155
|
).rejects.toThrow(/already registered/);
|
|
156
156
|
},
|
|
157
157
|
60_000,
|
|
@@ -27,9 +27,14 @@ import { __sentEmails, __clearSentEmails } from '../src/devserver/email/index.js
|
|
|
27
27
|
import {
|
|
28
28
|
Auth,
|
|
29
29
|
AuthErrorCode,
|
|
30
|
+
checkPassword,
|
|
30
31
|
EmailInUseError,
|
|
31
32
|
EmailNotConfirmedError,
|
|
33
|
+
InvalidEmailError,
|
|
34
|
+
InvalidUsernameError,
|
|
35
|
+
isValidEmail,
|
|
32
36
|
TwoFactorRequiredError,
|
|
37
|
+
WeakPasswordError,
|
|
33
38
|
} from '../src/client/auth.js';
|
|
34
39
|
import { DataReader, DataWriter } from '../src/io/codec.js';
|
|
35
40
|
|
|
@@ -106,6 +111,15 @@ function setConfirmation(on: boolean): void {
|
|
|
106
111
|
clearEnvCache();
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
const EMAIL_CONFIRM_ENV = 'AUTH_EMAIL_CONFIRMATION';
|
|
115
|
+
|
|
116
|
+
/** Toggle "send a verify email at register but DO NOT block login" (the decoupled flag). */
|
|
117
|
+
function setEmailConfirmation(on: boolean): void {
|
|
118
|
+
if (on) process.env[EMAIL_CONFIRM_ENV] = 'true';
|
|
119
|
+
else delete process.env[EMAIL_CONFIRM_ENV];
|
|
120
|
+
clearEnvCache();
|
|
121
|
+
}
|
|
122
|
+
|
|
109
123
|
function hexToBytes(hex: string): Uint8Array {
|
|
110
124
|
const out = new Uint8Array(hex.length / 2);
|
|
111
125
|
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
@@ -157,6 +171,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
157
171
|
__resetRatelimitForTests();
|
|
158
172
|
__clearSentEmails();
|
|
159
173
|
setConfirmation(false); // default: confirmation off unless a case opts in
|
|
174
|
+
setEmailConfirmation(false);
|
|
160
175
|
mod = loadModule();
|
|
161
176
|
const shim = installFetchShim(mod);
|
|
162
177
|
restoreFetch = shim.restore;
|
|
@@ -166,13 +181,14 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
166
181
|
afterEach(() => {
|
|
167
182
|
restoreFetch();
|
|
168
183
|
setConfirmation(false);
|
|
184
|
+
setEmailConfirmation(false);
|
|
169
185
|
});
|
|
170
186
|
|
|
171
187
|
it(
|
|
172
188
|
'confirmation OFF (default): register auto-confirms, login succeeds, /auth/me returns the user',
|
|
173
189
|
async () => {
|
|
174
|
-
await Auth.register('ada', 'correct horse battery
|
|
175
|
-
const session = await Auth.login('ada', 'correct horse battery
|
|
190
|
+
await Auth.register('ada', 'correct horse battery stapleA1', 'ada@example.com');
|
|
191
|
+
const session = await Auth.login('ada', 'correct horse battery stapleA1');
|
|
176
192
|
expect(session.length).toBeGreaterThan(0);
|
|
177
193
|
|
|
178
194
|
// The controller's `@auth`-gated /auth/me returns bytes(toilUserId) str(username).
|
|
@@ -195,7 +211,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
195
211
|
async () => {
|
|
196
212
|
// grace lives on the victim tenant (realm = normalized Host "victim.com").
|
|
197
213
|
setHost('victim.com');
|
|
198
|
-
await Auth.register('grace', 'pw-grace-
|
|
214
|
+
await Auth.register('grace', 'pw-grace-correctA1', 'grace@x.com');
|
|
199
215
|
__clearSentEmails();
|
|
200
216
|
// Attacker triggers reset/request with a Host that routes to the victim
|
|
201
217
|
// (the edge strip_port -> "victim.com", which is grace's realm, so the
|
|
@@ -224,8 +240,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
224
240
|
it(
|
|
225
241
|
'a session minted for one tenant does NOT verify for another (#2 cross-tenant session)',
|
|
226
242
|
async () => {
|
|
227
|
-
await Auth.register('heidi', 'pw-heidi-
|
|
228
|
-
await Auth.login('heidi', 'pw-heidi-
|
|
243
|
+
await Auth.register('heidi', 'pw-heidi-correctA1', 'heidi@x.com');
|
|
244
|
+
await Auth.login('heidi', 'pw-heidi-correctA1'); // minted under the shim Host localhost:3000
|
|
229
245
|
const sess = jar.get('toil_sess'); // dev is plain HTTP -> unprefixed cookie
|
|
230
246
|
expect(sess).toBeTruthy();
|
|
231
247
|
const meHeaders = (host: string): [string, string][] => [
|
|
@@ -256,7 +272,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
256
272
|
it(
|
|
257
273
|
'token bound: a new reset request invalidates the previous token (#5c growth bound)',
|
|
258
274
|
async () => {
|
|
259
|
-
await Auth.register('ivan', 'pw-ivan-
|
|
275
|
+
await Auth.register('ivan', 'pw-ivan-correctA1', 'ivan@x.com');
|
|
260
276
|
await Auth.requestPasswordReset('ivan@x.com');
|
|
261
277
|
const token1 = tokenFromEmail('reset', 'ivan@x.com');
|
|
262
278
|
__clearSentEmails();
|
|
@@ -264,10 +280,10 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
264
280
|
const token2 = tokenFromEmail('reset', 'ivan@x.com');
|
|
265
281
|
expect(token2).not.toBe(token1);
|
|
266
282
|
// The superseded token no longer works...
|
|
267
|
-
await expect(Auth.resetPassword(token1, 'new-pw-
|
|
283
|
+
await expect(Auth.resetPassword(token1, 'new-pw-unusedA1')).rejects.toThrow();
|
|
268
284
|
// ...only the latest does.
|
|
269
|
-
await Auth.resetPassword(token2, 'new-pw-
|
|
270
|
-
const session = await Auth.login('ivan', 'new-pw-
|
|
285
|
+
await Auth.resetPassword(token2, 'new-pw-ivanA1');
|
|
286
|
+
const session = await Auth.login('ivan', 'new-pw-ivanA1');
|
|
271
287
|
expect(session.length).toBeGreaterThan(0);
|
|
272
288
|
},
|
|
273
289
|
60_000,
|
|
@@ -277,17 +293,17 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
277
293
|
'confirmation ON: login is refused until the emailed token confirms the account',
|
|
278
294
|
async () => {
|
|
279
295
|
setConfirmation(true);
|
|
280
|
-
await Auth.register('bob', 'hunter2-
|
|
296
|
+
await Auth.register('bob', 'hunter2-correctA1', 'bob@example.com');
|
|
281
297
|
|
|
282
298
|
// A valid credential is not enough: the domain requires a confirmed email.
|
|
283
|
-
await expect(Auth.login('bob', 'hunter2-
|
|
299
|
+
await expect(Auth.login('bob', 'hunter2-correctA1')).rejects.toThrow(EmailNotConfirmedError);
|
|
284
300
|
|
|
285
301
|
// Read the confirm link out of the captured email and confirm.
|
|
286
302
|
const token = tokenFromEmail('confirm', 'bob@example.com');
|
|
287
303
|
await Auth.confirmEmail(token);
|
|
288
304
|
|
|
289
305
|
// Now login succeeds.
|
|
290
|
-
const session = await Auth.login('bob', 'hunter2-
|
|
306
|
+
const session = await Auth.login('bob', 'hunter2-correctA1');
|
|
291
307
|
expect(session.length).toBeGreaterThan(0);
|
|
292
308
|
},
|
|
293
309
|
60_000,
|
|
@@ -296,10 +312,10 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
296
312
|
it(
|
|
297
313
|
'duplicate email is rejected at registration (distinct "email already in use")',
|
|
298
314
|
async () => {
|
|
299
|
-
await Auth.register('carol', 'carol-pw-
|
|
315
|
+
await Auth.register('carol', 'carol-pw-strongA1', 'dupe@x.com');
|
|
300
316
|
// Typed error: a distinct EmailInUseError carrying the stable code, so a UI can
|
|
301
317
|
// branch reliably (never on the message/name string).
|
|
302
|
-
const err = await Auth.register('dave', 'dave-pw-
|
|
318
|
+
const err = await Auth.register('dave', 'dave-pw-strongA1', 'dupe@x.com').catch(
|
|
303
319
|
(e: unknown) => e,
|
|
304
320
|
);
|
|
305
321
|
expect(err).toBeInstanceOf(EmailInUseError);
|
|
@@ -311,18 +327,18 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
311
327
|
it(
|
|
312
328
|
'password reset round trip: old password stops working, the new one logs in',
|
|
313
329
|
async () => {
|
|
314
|
-
await Auth.register('erin', 'oldpw-erin-
|
|
330
|
+
await Auth.register('erin', 'oldpw-erin-strongA1', 'erin@x.com');
|
|
315
331
|
// Sanity: confirmation is off, so she can log in before the reset.
|
|
316
|
-
expect((await Auth.login('erin', 'oldpw-erin-
|
|
332
|
+
expect((await Auth.login('erin', 'oldpw-erin-strongA1')).length).toBeGreaterThan(0);
|
|
317
333
|
|
|
318
334
|
await Auth.requestPasswordReset('erin@x.com');
|
|
319
335
|
const token = tokenFromEmail('reset', 'erin@x.com');
|
|
320
|
-
await Auth.resetPassword(token, 'newpw-erin-
|
|
336
|
+
await Auth.resetPassword(token, 'newpw-erin-strongA1');
|
|
321
337
|
|
|
322
|
-
await expect(Auth.login('erin', 'oldpw-erin-
|
|
338
|
+
await expect(Auth.login('erin', 'oldpw-erin-strongA1')).rejects.toThrow(
|
|
323
339
|
/login failed|request failed/,
|
|
324
340
|
);
|
|
325
|
-
expect((await Auth.login('erin', 'newpw-erin-
|
|
341
|
+
expect((await Auth.login('erin', 'newpw-erin-strongA1')).length).toBeGreaterThan(0);
|
|
326
342
|
},
|
|
327
343
|
60_000,
|
|
328
344
|
);
|
|
@@ -341,10 +357,10 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
341
357
|
it(
|
|
342
358
|
'a reset token is one-time: replaying /auth/reset/finish with the consumed token fails',
|
|
343
359
|
async () => {
|
|
344
|
-
await Auth.register('frank', 'oldpw-frank-
|
|
360
|
+
await Auth.register('frank', 'oldpw-frank-strongA1', 'frank@x.com');
|
|
345
361
|
await Auth.requestPasswordReset('frank@x.com');
|
|
346
362
|
const tokenHex = tokenFromEmail('reset', 'frank@x.com');
|
|
347
|
-
await Auth.resetPassword(tokenHex, 'newpw-frank-
|
|
363
|
+
await Auth.resetPassword(tokenHex, 'newpw-frank-strongA1'); // consumes the token
|
|
348
364
|
|
|
349
365
|
// Replay reset/finish with a valid-length pk/proof so the controller reaches the
|
|
350
366
|
// token-consume step, which now finds the token already deleted -> generic fail.
|
|
@@ -372,8 +388,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
372
388
|
it(
|
|
373
389
|
'(a) full email-2FA login round-trip: enable, login requires a code, verify mints the session',
|
|
374
390
|
async () => {
|
|
375
|
-
await Auth.register('dave', 'dave-pw-
|
|
376
|
-
await Auth.login('dave', 'dave-pw-
|
|
391
|
+
await Auth.register('dave', 'dave-pw-strongA1', 'dave@x.com');
|
|
392
|
+
await Auth.login('dave', 'dave-pw-strongA1'); // 2FA off -> session
|
|
377
393
|
|
|
378
394
|
// Enable email 2FA: setup delivers a code, confirm switches the method on.
|
|
379
395
|
await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
|
|
@@ -387,7 +403,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
387
403
|
// verifies serverConfirm before throwing), but NO session is minted.
|
|
388
404
|
let err: unknown;
|
|
389
405
|
try {
|
|
390
|
-
await Auth.login('dave', 'dave-pw-
|
|
406
|
+
await Auth.login('dave', 'dave-pw-strongA1');
|
|
391
407
|
} catch (e) {
|
|
392
408
|
err = e;
|
|
393
409
|
}
|
|
@@ -413,8 +429,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
413
429
|
it(
|
|
414
430
|
'(b) 2FA code security: wrong code rejected + attempt-limited to death, correct code single-use',
|
|
415
431
|
async () => {
|
|
416
|
-
await Auth.register('eve', 'eve-pw-
|
|
417
|
-
await Auth.login('eve', 'eve-pw-
|
|
432
|
+
await Auth.register('eve', 'eve-pw-strongA1', 'eve@x.com');
|
|
433
|
+
await Auth.login('eve', 'eve-pw-strongA1');
|
|
418
434
|
await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
|
|
419
435
|
await Auth.confirmTwoFactorSetup(codeFromEmail('eve@x.com'));
|
|
420
436
|
|
|
@@ -423,7 +439,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
423
439
|
jar.clear();
|
|
424
440
|
let err: unknown;
|
|
425
441
|
try {
|
|
426
|
-
await Auth.login('eve', 'eve-pw-
|
|
442
|
+
await Auth.login('eve', 'eve-pw-strongA1');
|
|
427
443
|
} catch (e) {
|
|
428
444
|
err = e;
|
|
429
445
|
}
|
|
@@ -447,7 +463,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
447
463
|
__resetRatelimitForTests();
|
|
448
464
|
let err2: unknown;
|
|
449
465
|
try {
|
|
450
|
-
await Auth.login('eve', 'eve-pw-
|
|
466
|
+
await Auth.login('eve', 'eve-pw-strongA1');
|
|
451
467
|
} catch (e) {
|
|
452
468
|
err2 = e;
|
|
453
469
|
}
|
|
@@ -464,7 +480,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
464
480
|
it(
|
|
465
481
|
'(c) cross-flow: reset tokens and 2FA codes are NOT interchangeable (namespace separation)',
|
|
466
482
|
async () => {
|
|
467
|
-
await Auth.register('frank', 'frank-pw-
|
|
483
|
+
await Auth.register('frank', 'frank-pw-strongA1', 'frank@x.com');
|
|
468
484
|
|
|
469
485
|
// ---- direction 1: a LIVE reset token is useless at /auth/2fa/verify ----
|
|
470
486
|
await Auth.requestPasswordReset('frank@x.com');
|
|
@@ -475,7 +491,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
475
491
|
await expect(Auth.verifyTwoFactor(resetToken, resetToken)).rejects.toThrow();
|
|
476
492
|
|
|
477
493
|
// ---- direction 2: a LIVE 2FA login challenge id is useless at /auth/reset/finish ----
|
|
478
|
-
await Auth.login('frank', 'frank-pw-
|
|
494
|
+
await Auth.login('frank', 'frank-pw-strongA1'); // session (2FA still off here)
|
|
479
495
|
__clearSentEmails();
|
|
480
496
|
await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
|
|
481
497
|
await Auth.confirmTwoFactorSetup(codeFromEmail('frank@x.com'));
|
|
@@ -484,7 +500,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
484
500
|
__resetRatelimitForTests();
|
|
485
501
|
let err: unknown;
|
|
486
502
|
try {
|
|
487
|
-
await Auth.login('frank', 'frank-pw-
|
|
503
|
+
await Auth.login('frank', 'frank-pw-strongA1');
|
|
488
504
|
} catch (e) {
|
|
489
505
|
err = e;
|
|
490
506
|
}
|
|
@@ -527,8 +543,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
527
543
|
it(
|
|
528
544
|
'(d) NO session cookie is set on the ST_TWOFA_REQUIRED login response',
|
|
529
545
|
async () => {
|
|
530
|
-
await Auth.register('gwen', 'gwen-pw-
|
|
531
|
-
await Auth.login('gwen', 'gwen-pw-
|
|
546
|
+
await Auth.register('gwen', 'gwen-pw-strongA1', 'gwen@x.com');
|
|
547
|
+
await Auth.login('gwen', 'gwen-pw-strongA1');
|
|
532
548
|
await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
|
|
533
549
|
await Auth.confirmTwoFactorSetup(codeFromEmail('gwen@x.com'));
|
|
534
550
|
|
|
@@ -538,7 +554,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
538
554
|
|
|
539
555
|
let err: unknown;
|
|
540
556
|
try {
|
|
541
|
-
await Auth.login('gwen', 'gwen-pw-
|
|
557
|
+
await Auth.login('gwen', 'gwen-pw-strongA1');
|
|
542
558
|
} catch (e) {
|
|
543
559
|
err = e;
|
|
544
560
|
}
|
|
@@ -556,8 +572,8 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
556
572
|
it(
|
|
557
573
|
'(e) login<->setup 2FA challenges are separate: a setup code cannot mint a login session, and setup/verify never silently disables 2FA',
|
|
558
574
|
async () => {
|
|
559
|
-
await Auth.register('ivy', 'ivy-pw-
|
|
560
|
-
await Auth.login('ivy', 'ivy-pw-
|
|
575
|
+
await Auth.register('ivy', 'ivy-pw-strongA1', 'ivy@x.com');
|
|
576
|
+
await Auth.login('ivy', 'ivy-pw-strongA1'); // session; 2FA still off
|
|
561
577
|
|
|
562
578
|
// Begin a 2FA SETUP: writes a challenge into `twoFaSetup` (keyed by
|
|
563
579
|
// username), NOT a login challenge into `twoFaLogins`.
|
|
@@ -593,7 +609,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
593
609
|
'(f) cross-flow: confirm tokens and 2FA codes are NOT interchangeable (namespace separation)',
|
|
594
610
|
async () => {
|
|
595
611
|
setConfirmation(true);
|
|
596
|
-
await Auth.register('jade', 'jade-pw-
|
|
612
|
+
await Auth.register('jade', 'jade-pw-strongA1', 'jade@x.com'); // unconfirmed -> confirm token emailed
|
|
597
613
|
const confirmToken = tokenFromEmail('confirm', 'jade@x.com'); // live confirmTokens entry
|
|
598
614
|
|
|
599
615
|
// ---- direction 1: a LIVE confirm token is useless at /auth/2fa/verify ----
|
|
@@ -607,7 +623,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
607
623
|
__resetRatelimitForTests();
|
|
608
624
|
await Auth.confirmEmail(confirmToken);
|
|
609
625
|
__resetRatelimitForTests();
|
|
610
|
-
expect((await Auth.login('jade', 'jade-pw-
|
|
626
|
+
expect((await Auth.login('jade', 'jade-pw-strongA1')).length).toBeGreaterThan(0);
|
|
611
627
|
|
|
612
628
|
// ---- direction 2: a LIVE 2FA login id/code is useless at /auth/confirm ----
|
|
613
629
|
// Enable 2FA (jade is confirmed + has a session from the login above).
|
|
@@ -622,7 +638,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
622
638
|
__resetRatelimitForTests();
|
|
623
639
|
let err: unknown;
|
|
624
640
|
try {
|
|
625
|
-
await Auth.login('jade', 'jade-pw-
|
|
641
|
+
await Auth.login('jade', 'jade-pw-strongA1');
|
|
626
642
|
} catch (e) {
|
|
627
643
|
err = e;
|
|
628
644
|
}
|
|
@@ -665,7 +681,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
665
681
|
// ---- confirm token: minted on A, unredeemable on B ----
|
|
666
682
|
setConfirmation(true);
|
|
667
683
|
setHost(A);
|
|
668
|
-
await Auth.register('mia', 'mia-pw-
|
|
684
|
+
await Auth.register('mia', 'mia-pw-strongA1', 'mia@x.com'); // realm A + confirm token emailed
|
|
669
685
|
const confirmTok = tokenFromEmail('confirm', 'mia@x.com');
|
|
670
686
|
|
|
671
687
|
setHost(B);
|
|
@@ -687,16 +703,16 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
687
703
|
|
|
688
704
|
setHost(B);
|
|
689
705
|
__resetRatelimitForTests();
|
|
690
|
-
await expect(Auth.resetPassword(resetTok, 'mia-pw-
|
|
706
|
+
await expect(Auth.resetPassword(resetTok, 'mia-pw-newA1')).rejects.toThrow(); // realm B miss
|
|
691
707
|
|
|
692
708
|
setHost(A);
|
|
693
709
|
__resetRatelimitForTests();
|
|
694
|
-
await Auth.resetPassword(resetTok, 'mia-pw-
|
|
710
|
+
await Auth.resetPassword(resetTok, 'mia-pw-newA1'); // realm A -> resets
|
|
695
711
|
|
|
696
712
|
// ---- 2FA login code: minted on A, unusable on B ----
|
|
697
713
|
setHost(A);
|
|
698
714
|
__resetRatelimitForTests();
|
|
699
|
-
await Auth.login('mia', 'mia-pw-
|
|
715
|
+
await Auth.login('mia', 'mia-pw-newA1'); // session on A (2FA still off)
|
|
700
716
|
__clearSentEmails();
|
|
701
717
|
await Auth.setupTwoFactor(Auth.TwoFactorMethod.Email);
|
|
702
718
|
await Auth.confirmTwoFactorSetup(codeFromEmail('mia@x.com')); // 2FA enabled on A
|
|
@@ -706,7 +722,7 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
706
722
|
__resetRatelimitForTests();
|
|
707
723
|
let err: unknown;
|
|
708
724
|
try {
|
|
709
|
-
await Auth.login('mia', 'mia-pw-
|
|
725
|
+
await Auth.login('mia', 'mia-pw-newA1'); // -> 2FA challenge (twoFaId + code, realm A)
|
|
710
726
|
} catch (e) {
|
|
711
727
|
err = e;
|
|
712
728
|
}
|
|
@@ -726,4 +742,60 @@ describe.skipIf(!haveWasm)('built-in auth: email verification + password reset (
|
|
|
726
742
|
},
|
|
727
743
|
180_000,
|
|
728
744
|
);
|
|
745
|
+
|
|
746
|
+
// ---- input validation (client-side, before any crypto / network) ----
|
|
747
|
+
|
|
748
|
+
it('checkPassword + isValidEmail: the pure validators behave', () => {
|
|
749
|
+
expect(checkPassword('Str0ng-pw!')).toEqual([]); // meets every rule
|
|
750
|
+
expect(checkPassword('short1A').sort()).toEqual(['minLength', 'special'].sort()); // 7 chars, no special
|
|
751
|
+
expect(checkPassword('alllowercase1!')).toEqual(['uppercase']);
|
|
752
|
+
expect(checkPassword('NoDigits!!')).toEqual(['digit']);
|
|
753
|
+
expect(isValidEmail('bob@example.com')).toBe(true);
|
|
754
|
+
expect(isValidEmail('bobfwehfuewhfwf.com')).toBe(false); // no @
|
|
755
|
+
expect(isValidEmail('a@b')).toBe(false); // no dotted domain
|
|
756
|
+
expect(isValidEmail('a b@x.com')).toBe(false); // space
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('register rejects a weak password with WeakPasswordError (before any network)', async () => {
|
|
760
|
+
const err = await Auth.register('newbie', 'weak', 'newbie@x.com').catch((e: unknown) => e);
|
|
761
|
+
expect(err).toBeInstanceOf(WeakPasswordError);
|
|
762
|
+
expect((err as WeakPasswordError).code).toBe(AuthErrorCode.WeakPassword);
|
|
763
|
+
// the unmet rules are exposed so a UI can show a checklist
|
|
764
|
+
expect((err as WeakPasswordError).unmet).toContain('minLength');
|
|
765
|
+
expect((err as WeakPasswordError).unmet).toContain('uppercase');
|
|
766
|
+
// no confirm email was sent (it threw before the request)
|
|
767
|
+
__clearSentEmails();
|
|
768
|
+
expect(__sentEmails.length).toBe(0);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('register rejects an invalid email + a bad username with typed errors', async () => {
|
|
772
|
+
await expect(Auth.register('newbie', 'Str0ng-pw!', 'not-an-email')).rejects.toBeInstanceOf(
|
|
773
|
+
InvalidEmailError,
|
|
774
|
+
);
|
|
775
|
+
await expect(Auth.register('', 'Str0ng-pw!', 'ok@x.com')).rejects.toBeInstanceOf(
|
|
776
|
+
InvalidUsernameError,
|
|
777
|
+
);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it(
|
|
781
|
+
'AUTH_EMAIL_CONFIRMATION sends the verify email at register but does NOT block login',
|
|
782
|
+
async () => {
|
|
783
|
+
// The decoupled flag: verify-at-register, login NOT gated (unlike the
|
|
784
|
+
// stricter AUTH_REQUIRE_EMAIL_CONFIRMATION, which also blocks login).
|
|
785
|
+
setEmailConfirmation(true);
|
|
786
|
+
__clearSentEmails();
|
|
787
|
+
await Auth.register('kev', 'Kev-pw-strong1!', 'kev@x.com');
|
|
788
|
+
// a confirm link was emailed (the account is stored unconfirmed)
|
|
789
|
+
const tok = tokenFromEmail('confirm', 'kev@x.com');
|
|
790
|
+
expect(tok.length).toBeGreaterThan(0);
|
|
791
|
+
// login SUCCEEDS despite the unconfirmed email (no login gate)
|
|
792
|
+
__resetRatelimitForTests();
|
|
793
|
+
const session = await Auth.login('kev', 'Kev-pw-strong1!');
|
|
794
|
+
expect(session.length).toBeGreaterThan(0);
|
|
795
|
+
// and the emailed confirm link still works
|
|
796
|
+
__resetRatelimitForTests();
|
|
797
|
+
await Auth.confirmEmail(tok);
|
|
798
|
+
},
|
|
799
|
+
90_000,
|
|
800
|
+
);
|
|
729
801
|
});
|