@shipfox/api-auth 2.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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +116 -0
- package/LICENSE +21 -0
- package/README.md +336 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +95 -0
- package/dist/config.js.map +1 -0
- package/dist/core/auth.d.ts +108 -0
- package/dist/core/auth.d.ts.map +1 -0
- package/dist/core/auth.js +476 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/entities/email-verification.d.ts +9 -0
- package/dist/core/entities/email-verification.d.ts.map +1 -0
- package/dist/core/entities/email-verification.js +3 -0
- package/dist/core/entities/email-verification.js.map +1 -0
- package/dist/core/entities/password-reset.d.ts +9 -0
- package/dist/core/entities/password-reset.d.ts.map +1 -0
- package/dist/core/entities/password-reset.js +3 -0
- package/dist/core/entities/password-reset.js.map +1 -0
- package/dist/core/entities/refresh-token.d.ts +12 -0
- package/dist/core/entities/refresh-token.d.ts.map +1 -0
- package/dist/core/entities/refresh-token.js +3 -0
- package/dist/core/entities/refresh-token.js.map +1 -0
- package/dist/core/entities/user.d.ts +12 -0
- package/dist/core/entities/user.d.ts.map +1 -0
- package/dist/core/entities/user.js +3 -0
- package/dist/core/entities/user.js.map +1 -0
- package/dist/core/errors.d.ts +54 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +106 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/job-lease-token.d.ts +12 -0
- package/dist/core/job-lease-token.d.ts.map +1 -0
- package/dist/core/job-lease-token.js +58 -0
- package/dist/core/job-lease-token.js.map +1 -0
- package/dist/core/jwt.d.ts +37 -0
- package/dist/core/jwt.d.ts.map +1 -0
- package/dist/core/jwt.js +46 -0
- package/dist/core/jwt.js.map +1 -0
- package/dist/core/password.d.ts +10 -0
- package/dist/core/password.d.ts.map +1 -0
- package/dist/core/password.js +13 -0
- package/dist/core/password.js.map +1 -0
- package/dist/core/rate-limit.d.ts +34 -0
- package/dist/core/rate-limit.d.ts.map +1 -0
- package/dist/core/rate-limit.js +76 -0
- package/dist/core/rate-limit.js.map +1 -0
- package/dist/core/runner-session-token.d.ts +6 -0
- package/dist/core/runner-session-token.d.ts.map +1 -0
- package/dist/core/runner-session-token.js +37 -0
- package/dist/core/runner-session-token.js.map +1 -0
- package/dist/db/db.d.ts +1723 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +28 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/email-verifications.d.ts +39 -0
- package/dist/db/email-verifications.d.ts.map +1 -0
- package/dist/db/email-verifications.js +95 -0
- package/dist/db/email-verifications.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/password-resets.d.ts +23 -0
- package/dist/db/password-resets.d.ts.map +1 -0
- package/dist/db/password-resets.js +37 -0
- package/dist/db/password-resets.js.map +1 -0
- package/dist/db/rate-limits.d.ts +18 -0
- package/dist/db/rate-limits.d.ts.map +1 -0
- package/dist/db/rate-limits.js +44 -0
- package/dist/db/rate-limits.js.map +1 -0
- package/dist/db/refresh-tokens.d.ts +44 -0
- package/dist/db/refresh-tokens.d.ts.map +1 -0
- package/dist/db/refresh-tokens.js +71 -0
- package/dist/db/refresh-tokens.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/email-verifications.d.ts +114 -0
- package/dist/db/schema/email-verifications.d.ts.map +1 -0
- package/dist/db/schema/email-verifications.js +35 -0
- package/dist/db/schema/email-verifications.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/schema/password-resets.d.ts +114 -0
- package/dist/db/schema/password-resets.d.ts.map +1 -0
- package/dist/db/schema/password-resets.js +35 -0
- package/dist/db/schema/password-resets.js.map +1 -0
- package/dist/db/schema/rate-limits.d.ts +145 -0
- package/dist/db/schema/rate-limits.d.ts.map +1 -0
- package/dist/db/schema/rate-limits.js +25 -0
- package/dist/db/schema/rate-limits.js.map +1 -0
- package/dist/db/schema/refresh-tokens.d.ts +165 -0
- package/dist/db/schema/refresh-tokens.d.ts.map +1 -0
- package/dist/db/schema/refresh-tokens.js +47 -0
- package/dist/db/schema/refresh-tokens.js.map +1 -0
- package/dist/db/schema/users.d.ts +149 -0
- package/dist/db/schema/users.d.ts.map +1 -0
- package/dist/db/schema/users.js +40 -0
- package/dist/db/schema/users.js.map +1 -0
- package/dist/db/users.d.ts +28 -0
- package/dist/db/users.d.ts.map +1 -0
- package/dist/db/users.js +83 -0
- package/dist/db/users.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +16 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +52 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/presentation/auth/bearer-token-auth.d.ts +15 -0
- package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -0
- package/dist/presentation/auth/bearer-token-auth.js +31 -0
- package/dist/presentation/auth/bearer-token-auth.js.map +1 -0
- package/dist/presentation/auth/jwt-auth.d.ts +10 -0
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -0
- package/dist/presentation/auth/jwt-auth.js +31 -0
- package/dist/presentation/auth/jwt-auth.js.map +1 -0
- package/dist/presentation/auth/lease-token-auth.d.ts +15 -0
- package/dist/presentation/auth/lease-token-auth.d.ts.map +1 -0
- package/dist/presentation/auth/lease-token-auth.js +27 -0
- package/dist/presentation/auth/lease-token-auth.js.map +1 -0
- package/dist/presentation/auth/refresh-cookie.d.ts +7 -0
- package/dist/presentation/auth/refresh-cookie.d.ts.map +1 -0
- package/dist/presentation/auth/refresh-cookie.js +29 -0
- package/dist/presentation/auth/refresh-cookie.js.map +1 -0
- package/dist/presentation/auth/runner-session-auth.d.ts +3 -0
- package/dist/presentation/auth/runner-session-auth.d.ts.map +1 -0
- package/dist/presentation/auth/runner-session-auth.js +16 -0
- package/dist/presentation/auth/runner-session-auth.js.map +1 -0
- package/dist/presentation/dto/user.d.ts +8 -0
- package/dist/presentation/dto/user.d.ts.map +1 -0
- package/dist/presentation/dto/user.js +19 -0
- package/dist/presentation/dto/user.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-session.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-session.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-session.js +49 -0
- package/dist/presentation/e2eRoutes/create-session.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-user.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-user.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-user.js +42 -0
- package/dist/presentation/e2eRoutes/create-user.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +3 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +15 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts +2 -0
- package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts.map +1 -0
- package/dist/presentation/routes/email-verification/verify-email-confirm.js +35 -0
- package/dist/presentation/routes/email-verification/verify-email-confirm.js.map +1 -0
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts +2 -0
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -0
- package/dist/presentation/routes/email-verification/verify-email-resend.js +27 -0
- package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +31 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/password/change-password.d.ts +2 -0
- package/dist/presentation/routes/password/change-password.d.ts.map +1 -0
- package/dist/presentation/routes/password/change-password.js +50 -0
- package/dist/presentation/routes/password/change-password.js.map +1 -0
- package/dist/presentation/routes/password/password-reset-confirm.d.ts +2 -0
- package/dist/presentation/routes/password/password-reset-confirm.d.ts.map +1 -0
- package/dist/presentation/routes/password/password-reset-confirm.js +36 -0
- package/dist/presentation/routes/password/password-reset-confirm.js.map +1 -0
- package/dist/presentation/routes/password/password-reset-request.d.ts +2 -0
- package/dist/presentation/routes/password/password-reset-request.d.ts.map +1 -0
- package/dist/presentation/routes/password/password-reset-request.js +26 -0
- package/dist/presentation/routes/password/password-reset-request.js.map +1 -0
- package/dist/presentation/routes/rate-limit.d.ts +10 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
- package/dist/presentation/routes/rate-limit.js +101 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -0
- package/dist/presentation/routes/registration/signup.d.ts +2 -0
- package/dist/presentation/routes/registration/signup.d.ts.map +1 -0
- package/dist/presentation/routes/registration/signup.js +82 -0
- package/dist/presentation/routes/registration/signup.js.map +1 -0
- package/dist/presentation/routes/session/login.d.ts +2 -0
- package/dist/presentation/routes/session/login.d.ts.map +1 -0
- package/dist/presentation/routes/session/login.js +48 -0
- package/dist/presentation/routes/session/login.js.map +1 -0
- package/dist/presentation/routes/session/logout.d.ts +2 -0
- package/dist/presentation/routes/session/logout.d.ts.map +1 -0
- package/dist/presentation/routes/session/logout.js +25 -0
- package/dist/presentation/routes/session/logout.js.map +1 -0
- package/dist/presentation/routes/session/me.d.ts +2 -0
- package/dist/presentation/routes/session/me.d.ts.map +1 -0
- package/dist/presentation/routes/session/me.js +42 -0
- package/dist/presentation/routes/session/me.js.map +1 -0
- package/dist/presentation/routes/session/refresh.d.ts +2 -0
- package/dist/presentation/routes/session/refresh.d.ts.map +1 -0
- package/dist/presentation/routes/session/refresh.js +49 -0
- package/dist/presentation/routes/session/refresh.js.map +1 -0
- package/dist/presentation/subscribers/index.d.ts +3 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -0
- package/dist/presentation/subscribers/index.js +4 -0
- package/dist/presentation/subscribers/index.js.map +1 -0
- package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts +3 -0
- package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-email-verification-send-requested.js +13 -0
- package/dist/presentation/subscribers/on-email-verification-send-requested.js.map +1 -0
- package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts +3 -0
- package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-password-reset-send-requested.js +14 -0
- package/dist/presentation/subscribers/on-password-reset-send-requested.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +81 -0
- package/drizzle/meta/0000_snapshot.json +643 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +88 -0
- package/src/config.ts +88 -0
- package/src/core/auth.test.ts +675 -0
- package/src/core/auth.ts +560 -0
- package/src/core/entities/email-verification.ts +8 -0
- package/src/core/entities/password-reset.ts +8 -0
- package/src/core/entities/refresh-token.ts +11 -0
- package/src/core/entities/user.ts +12 -0
- package/src/core/errors.ts +123 -0
- package/src/core/job-lease-token.test.ts +201 -0
- package/src/core/job-lease-token.ts +83 -0
- package/src/core/jwt.test.ts +180 -0
- package/src/core/jwt.ts +66 -0
- package/src/core/password.test.ts +28 -0
- package/src/core/password.ts +21 -0
- package/src/core/rate-limit.test.ts +462 -0
- package/src/core/rate-limit.ts +130 -0
- package/src/core/runner-session-token.test.ts +126 -0
- package/src/core/runner-session-token.ts +49 -0
- package/src/db/db.ts +28 -0
- package/src/db/email-verifications.test.ts +61 -0
- package/src/db/email-verifications.ts +160 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/password-resets.test.ts +61 -0
- package/src/db/password-resets.ts +69 -0
- package/src/db/rate-limits.ts +69 -0
- package/src/db/refresh-tokens.test.ts +164 -0
- package/src/db/refresh-tokens.ts +149 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/email-verifications.ts +37 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/schema/password-resets.ts +37 -0
- package/src/db/schema/rate-limits.ts +27 -0
- package/src/db/schema/refresh-tokens.ts +43 -0
- package/src/db/schema/users.ts +37 -0
- package/src/db/users.test.ts +75 -0
- package/src/db/users.ts +121 -0
- package/src/index.test.ts +39 -0
- package/src/index.ts +69 -0
- package/src/metrics/index.ts +13 -0
- package/src/metrics/instance.test.ts +69 -0
- package/src/metrics/instance.ts +77 -0
- package/src/presentation/auth/bearer-token-auth.test.ts +99 -0
- package/src/presentation/auth/bearer-token-auth.ts +51 -0
- package/src/presentation/auth/jwt-auth.test.ts +136 -0
- package/src/presentation/auth/jwt-auth.ts +39 -0
- package/src/presentation/auth/lease-token-auth.test.ts +56 -0
- package/src/presentation/auth/lease-token-auth.ts +25 -0
- package/src/presentation/auth/refresh-cookie.test.ts +39 -0
- package/src/presentation/auth/refresh-cookie.ts +34 -0
- package/src/presentation/auth/runner-session-auth.test.ts +54 -0
- package/src/presentation/auth/runner-session-auth.ts +13 -0
- package/src/presentation/dto/user.ts +21 -0
- package/src/presentation/e2eRoutes/create-session.ts +39 -0
- package/src/presentation/e2eRoutes/create-user.ts +38 -0
- package/src/presentation/e2eRoutes/index.test.ts +102 -0
- package/src/presentation/e2eRoutes/index.ts +10 -0
- package/src/presentation/routes/email-verification/verify-email-confirm.test.ts +66 -0
- package/src/presentation/routes/email-verification/verify-email-confirm.ts +37 -0
- package/src/presentation/routes/email-verification/verify-email-resend.test.ts +100 -0
- package/src/presentation/routes/email-verification/verify-email-resend.ts +26 -0
- package/src/presentation/routes/index.ts +29 -0
- package/src/presentation/routes/password/change-password.test.ts +106 -0
- package/src/presentation/routes/password/change-password.ts +45 -0
- package/src/presentation/routes/password/password-reset-confirm.test.ts +63 -0
- package/src/presentation/routes/password/password-reset-confirm.ts +35 -0
- package/src/presentation/routes/password/password-reset-request.test.ts +56 -0
- package/src/presentation/routes/password/password-reset-request.ts +25 -0
- package/src/presentation/routes/rate-limit.test.ts +341 -0
- package/src/presentation/routes/rate-limit.ts +119 -0
- package/src/presentation/routes/registration/signup.test.ts +284 -0
- package/src/presentation/routes/registration/signup.ts +81 -0
- package/src/presentation/routes/session/login.test.ts +77 -0
- package/src/presentation/routes/session/login.ts +50 -0
- package/src/presentation/routes/session/logout.test.ts +55 -0
- package/src/presentation/routes/session/logout.ts +23 -0
- package/src/presentation/routes/session/me.test.ts +56 -0
- package/src/presentation/routes/session/me.ts +35 -0
- package/src/presentation/routes/session/refresh.test.ts +89 -0
- package/src/presentation/routes/session/refresh.ts +54 -0
- package/src/presentation/subscribers/email-send-requested.test.ts +77 -0
- package/src/presentation/subscribers/index.ts +2 -0
- package/src/presentation/subscribers/on-email-verification-send-requested.ts +10 -0
- package/src/presentation/subscribers/on-password-reset-send-requested.ts +13 -0
- package/test/env.ts +10 -0
- package/test/factories/index.ts +1 -0
- package/test/factories/user.ts +36 -0
- package/test/globalSetup.ts +18 -0
- package/test/index.ts +1 -0
- package/test/routes.ts +275 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +4 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTH_EMAIL_VERIFICATION_SEND_REQUESTED,
|
|
3
|
+
AUTH_PASSWORD_RESET_SEND_REQUESTED,
|
|
4
|
+
EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS,
|
|
5
|
+
} from '@shipfox/api-auth-dto';
|
|
6
|
+
import type {Mailer, MailMessage} from '@shipfox/node-mailer';
|
|
7
|
+
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
8
|
+
import {and, desc, eq, sql} from 'drizzle-orm';
|
|
9
|
+
import {
|
|
10
|
+
changePassword,
|
|
11
|
+
confirmEmailVerification,
|
|
12
|
+
confirmPasswordReset,
|
|
13
|
+
createSessionForUser,
|
|
14
|
+
getCurrentUser,
|
|
15
|
+
login,
|
|
16
|
+
logout,
|
|
17
|
+
provisionUser,
|
|
18
|
+
refreshAccessToken,
|
|
19
|
+
requestPasswordReset,
|
|
20
|
+
resendEmailVerification,
|
|
21
|
+
signup,
|
|
22
|
+
} from '#core/auth.js';
|
|
23
|
+
import {
|
|
24
|
+
AuthDependencyUnavailableError,
|
|
25
|
+
EmailNotVerifiedError,
|
|
26
|
+
EmailTakenError,
|
|
27
|
+
InvalidCredentialsError,
|
|
28
|
+
TokenInvalidError,
|
|
29
|
+
UserNotFoundError,
|
|
30
|
+
} from '#core/errors.js';
|
|
31
|
+
import {verifyUserToken} from '#core/jwt.js';
|
|
32
|
+
import {db} from '#db/db.js';
|
|
33
|
+
import * as refreshTokenDb from '#db/refresh-tokens.js';
|
|
34
|
+
import {emailVerifications} from '#db/schema/email-verifications.js';
|
|
35
|
+
import {authOutbox} from '#db/schema/outbox.js';
|
|
36
|
+
import {passwordResets} from '#db/schema/password-resets.js';
|
|
37
|
+
import {refreshTokens} from '#db/schema/refresh-tokens.js';
|
|
38
|
+
import {users} from '#db/schema/users.js';
|
|
39
|
+
import {findUserByEmail, findUserById} from '#db/users.js';
|
|
40
|
+
import {userFactory} from '#test/index.js';
|
|
41
|
+
|
|
42
|
+
const testConfig = vi.hoisted(
|
|
43
|
+
(): {
|
|
44
|
+
captured: MailMessage[];
|
|
45
|
+
mailer: Mailer;
|
|
46
|
+
clientBaseUrl: string;
|
|
47
|
+
} => {
|
|
48
|
+
const captured: MailMessage[] = [];
|
|
49
|
+
const mailer: Mailer = {
|
|
50
|
+
send: (message) => {
|
|
51
|
+
captured.push(message);
|
|
52
|
+
return Promise.resolve();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
captured,
|
|
57
|
+
mailer,
|
|
58
|
+
clientBaseUrl: 'https://app.example.test',
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
vi.mock('#config.js', () => ({
|
|
64
|
+
config: {
|
|
65
|
+
AUTH_JWT_SECRET: 'auth-core-test-secret',
|
|
66
|
+
AUTH_JWT_EXPIRES_IN: '15m',
|
|
67
|
+
AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS: 14,
|
|
68
|
+
AUTH_REFRESH_ROTATION_GRACE_SECONDS: 30,
|
|
69
|
+
AUTH_REFRESH_COOKIE_NAME: 'shipfox_refresh_token',
|
|
70
|
+
CLIENT_BASE_URL: testConfig.clientBaseUrl,
|
|
71
|
+
},
|
|
72
|
+
mailer: testConfig.mailer,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
vi.mock('@shipfox/api-workspaces', () => ({
|
|
76
|
+
listMembershipsByUser: vi.fn(() => Promise.resolve([])),
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
const {listMembershipsByUser} = await import('@shipfox/api-workspaces');
|
|
80
|
+
const listMembershipsByUserMock = vi.mocked(listMembershipsByUser);
|
|
81
|
+
|
|
82
|
+
const TOKEN_RE = /token=([\w\-_=]+)/;
|
|
83
|
+
|
|
84
|
+
function extractToken(link: string | undefined): string {
|
|
85
|
+
const match = link?.match(TOKEN_RE);
|
|
86
|
+
if (!match?.[1]) {
|
|
87
|
+
throw new Error('Expected link to contain a token');
|
|
88
|
+
}
|
|
89
|
+
return match[1];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function outboxEventsTo(email: string, eventType: string) {
|
|
93
|
+
return await db()
|
|
94
|
+
.select()
|
|
95
|
+
.from(authOutbox)
|
|
96
|
+
.where(
|
|
97
|
+
and(eq(authOutbox.eventType, eventType), sql`${authOutbox.payload}->>'email' = ${email}`),
|
|
98
|
+
)
|
|
99
|
+
.orderBy(desc(authOutbox.createdAt));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function latestEmailLinkTo(email: string, eventType: string): Promise<string> {
|
|
103
|
+
const event = (await outboxEventsTo(email, eventType))[0];
|
|
104
|
+
const payload = event?.payload as {verifyLink?: string; resetLink?: string} | undefined;
|
|
105
|
+
const link =
|
|
106
|
+
eventType === AUTH_EMAIL_VERIFICATION_SEND_REQUESTED ? payload?.verifyLink : payload?.resetLink;
|
|
107
|
+
if (!link) throw new Error(`No ${eventType} outbox link for ${email}`);
|
|
108
|
+
return link;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('auth core', () => {
|
|
112
|
+
let captured: MailMessage[];
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
captured = testConfig.captured;
|
|
116
|
+
captured.length = 0;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('signup creates an unverified user and sends a verification email', async () => {
|
|
120
|
+
const email = `signup-${crypto.randomUUID()}@example.com`;
|
|
121
|
+
|
|
122
|
+
const user = await signup({
|
|
123
|
+
email,
|
|
124
|
+
password: 'correct horse battery staple',
|
|
125
|
+
name: 'Sign Up',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(user.email).toBe(email);
|
|
129
|
+
expect(user.name).toBe('Sign Up');
|
|
130
|
+
expect(user.emailVerifiedAt).toBeNull();
|
|
131
|
+
expect(captured).toHaveLength(0);
|
|
132
|
+
expect(await latestEmailLinkTo(email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED)).toContain(
|
|
133
|
+
`${testConfig.clientBaseUrl}/auth/verify-email?token=`,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('signup rejects duplicate email with a business error', async () => {
|
|
138
|
+
const existing = await userFactory.create();
|
|
139
|
+
|
|
140
|
+
const promise = signup({
|
|
141
|
+
email: existing.email,
|
|
142
|
+
password: 'correct horse battery staple',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await expect(promise).rejects.toBeInstanceOf(EmailTakenError);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('provisionUser creates a verified, password-less user with a normalized email', async () => {
|
|
149
|
+
const email = `Provision-${crypto.randomUUID()}@EXAMPLE.COM`;
|
|
150
|
+
|
|
151
|
+
const user = await provisionUser({email, name: 'Provisioned User'});
|
|
152
|
+
|
|
153
|
+
expect(user.email).toBe(email.toLowerCase());
|
|
154
|
+
expect(user.name).toBe('Provisioned User');
|
|
155
|
+
expect(user.hashedPassword).toBeNull();
|
|
156
|
+
expect(user.emailVerifiedAt).toBeInstanceOf(Date);
|
|
157
|
+
expect(user.status).toBe('active');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('provisionUser returns existing unverified and suspended users unchanged', async () => {
|
|
161
|
+
const unverified = await userFactory.create();
|
|
162
|
+
const suspended = await userFactory.create({emailVerifiedAt: new Date()});
|
|
163
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.id));
|
|
164
|
+
const storedUnverified = await findUserById({id: unverified.id});
|
|
165
|
+
const storedSuspended = await findUserById({id: suspended.id});
|
|
166
|
+
|
|
167
|
+
const existingUnverified = await provisionUser({
|
|
168
|
+
email: unverified.email.toUpperCase(),
|
|
169
|
+
name: 'Replacement Name',
|
|
170
|
+
});
|
|
171
|
+
const existingSuspended = await provisionUser({
|
|
172
|
+
email: suspended.email.toUpperCase(),
|
|
173
|
+
name: 'Replacement Name',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(existingUnverified).toEqual(storedUnverified);
|
|
177
|
+
expect(existingSuspended).toEqual(storedSuspended);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('provisionUser returns one unchanged user for concurrent callbacks', async () => {
|
|
181
|
+
const email = `concurrent-provision-${crypto.randomUUID()}@example.com`;
|
|
182
|
+
|
|
183
|
+
const results = await Promise.all(
|
|
184
|
+
Array.from({length: 8}, (_, index) => provisionUser({email, name: `Provider ${index}`})),
|
|
185
|
+
);
|
|
186
|
+
const stored = await findUserByEmail({email});
|
|
187
|
+
|
|
188
|
+
expect(new Set(results.map((user) => user.id)).size).toBe(1);
|
|
189
|
+
expect(stored?.id).toBe(results[0]?.id);
|
|
190
|
+
expect(stored?.hashedPassword).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('login returns a token for verified users and rejects invalid credentials', async () => {
|
|
194
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
195
|
+
|
|
196
|
+
const result = await login({email: user.email, password: user.plainPassword});
|
|
197
|
+
|
|
198
|
+
expect(result.token).toEqual(expect.any(String));
|
|
199
|
+
expect(result.refreshToken).toEqual(expect.any(String));
|
|
200
|
+
expect(result.user.id).toBe(user.id);
|
|
201
|
+
|
|
202
|
+
const wrongPassword = login({email: user.email, password: 'not the right password'});
|
|
203
|
+
await expect(wrongPassword).rejects.toBeInstanceOf(InvalidCredentialsError);
|
|
204
|
+
|
|
205
|
+
const missingUser = login({
|
|
206
|
+
email: `missing-${crypto.randomUUID()}@example.com`,
|
|
207
|
+
password: user.plainPassword,
|
|
208
|
+
});
|
|
209
|
+
await expect(missingUser).rejects.toBeInstanceOf(InvalidCredentialsError);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('login rejects unverified users with a business error', async () => {
|
|
213
|
+
const user = await userFactory.create();
|
|
214
|
+
|
|
215
|
+
const promise = login({email: user.email, password: user.plainPassword});
|
|
216
|
+
|
|
217
|
+
await expect(promise).rejects.toBeInstanceOf(EmailNotVerifiedError);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('password login and reset flows refuse password-less users', async () => {
|
|
221
|
+
const user = await provisionUser({
|
|
222
|
+
email: `password-less-${crypto.randomUUID()}@example.com`,
|
|
223
|
+
});
|
|
224
|
+
const resetToken = `password-less-reset-${crypto.randomUUID()}`;
|
|
225
|
+
await db()
|
|
226
|
+
.insert(passwordResets)
|
|
227
|
+
.values({
|
|
228
|
+
userId: user.id,
|
|
229
|
+
hashedToken: hashOpaqueToken(resetToken),
|
|
230
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const passwordLogin = login({email: user.email, password: 'not a configured password'});
|
|
234
|
+
await expect(passwordLogin).rejects.toBeInstanceOf(InvalidCredentialsError);
|
|
235
|
+
|
|
236
|
+
await requestPasswordReset({email: user.email});
|
|
237
|
+
expect(await outboxEventsTo(user.email, AUTH_PASSWORD_RESET_SEND_REQUESTED)).toHaveLength(0);
|
|
238
|
+
|
|
239
|
+
const passwordReset = confirmPasswordReset({
|
|
240
|
+
token: resetToken,
|
|
241
|
+
newPassword: 'a valid replacement password',
|
|
242
|
+
});
|
|
243
|
+
await expect(passwordReset).rejects.toBeInstanceOf(TokenInvalidError);
|
|
244
|
+
expect((await findUserById({id: user.id}))?.hashedPassword).toBeNull();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('createSessionForUser only creates sessions for active verified users', async () => {
|
|
248
|
+
const unverified = await userFactory.create();
|
|
249
|
+
const suspended = await userFactory.create({emailVerifiedAt: new Date()});
|
|
250
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.id));
|
|
251
|
+
|
|
252
|
+
await expect(createSessionForUser({userId: unverified.id})).rejects.toBeInstanceOf(
|
|
253
|
+
EmailNotVerifiedError,
|
|
254
|
+
);
|
|
255
|
+
await expect(createSessionForUser({userId: suspended.id})).rejects.toBeInstanceOf(
|
|
256
|
+
InvalidCredentialsError,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('refreshAccessToken rotates the refresh token', async () => {
|
|
261
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
262
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
263
|
+
|
|
264
|
+
const refreshed = await refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
265
|
+
|
|
266
|
+
expect(refreshed.token).toEqual(expect.any(String));
|
|
267
|
+
expect(refreshed.refreshToken).toEqual(expect.any(String));
|
|
268
|
+
expect(refreshed.refreshToken).not.toBe(loginResult.refreshToken);
|
|
269
|
+
expect(refreshed.user.id).toBe(user.id);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('refreshAccessToken tolerates a concurrent reuse within the grace window', async () => {
|
|
273
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
274
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
275
|
+
|
|
276
|
+
await refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
277
|
+
const raced = await refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
278
|
+
|
|
279
|
+
expect(raced.token).toEqual(expect.any(String));
|
|
280
|
+
expect(raced.user.id).toBe(user.id);
|
|
281
|
+
// The racing tab keeps the cookie the winning refresh installed.
|
|
282
|
+
expect(raced.refreshToken).toBeUndefined();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('refreshAccessToken rejects a lost rotation claim when the token was revoked', async () => {
|
|
286
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
287
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
288
|
+
listMembershipsByUserMock.mockClear();
|
|
289
|
+
vi.spyOn(refreshTokenDb, 'rotateRefreshToken').mockImplementationOnce(async () => {
|
|
290
|
+
await logout({refreshToken: loginResult.refreshToken});
|
|
291
|
+
return undefined;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const raced = refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
295
|
+
|
|
296
|
+
await expect(raced).rejects.toBeInstanceOf(TokenInvalidError);
|
|
297
|
+
expect(listMembershipsByUserMock).not.toHaveBeenCalled();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('refreshAccessToken rejects reuse past the grace window and revokes the session', async () => {
|
|
301
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
302
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
303
|
+
const refreshed = await refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
304
|
+
// Backdate the rotation so the original token is now past the grace window.
|
|
305
|
+
await db()
|
|
306
|
+
.update(refreshTokens)
|
|
307
|
+
.set({rotatedAt: new Date(Date.now() - 60 * 60 * 1000)})
|
|
308
|
+
.where(eq(refreshTokens.hashedToken, hashOpaqueToken(loginResult.refreshToken)));
|
|
309
|
+
|
|
310
|
+
const reused = refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
311
|
+
|
|
312
|
+
await expect(reused).rejects.toBeInstanceOf(TokenInvalidError);
|
|
313
|
+
// The successor token is revoked too: reuse is treated as a compromise.
|
|
314
|
+
const successor = refreshed.refreshToken
|
|
315
|
+
? await refreshTokenDb.findActiveRefreshTokenByHash({
|
|
316
|
+
hashedToken: hashOpaqueToken(refreshed.refreshToken),
|
|
317
|
+
})
|
|
318
|
+
: undefined;
|
|
319
|
+
expect(successor).toBeUndefined();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('refreshAccessToken rejects refresh tokens for inactive users', async () => {
|
|
323
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
324
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
325
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, user.id));
|
|
326
|
+
|
|
327
|
+
const promise = refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
328
|
+
|
|
329
|
+
await expect(promise).rejects.toBeInstanceOf(TokenInvalidError);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('confirmEmailVerification marks the user verified, creates a session, and rejects reused tokens', async () => {
|
|
333
|
+
const user = await signup({
|
|
334
|
+
email: `verify-${crypto.randomUUID()}@example.com`,
|
|
335
|
+
password: 'correct horse battery staple',
|
|
336
|
+
});
|
|
337
|
+
const token = extractToken(
|
|
338
|
+
await latestEmailLinkTo(user.email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const result = await confirmEmailVerification({token});
|
|
342
|
+
const verified = await findUserById({id: user.id});
|
|
343
|
+
const refreshSession = await refreshTokenDb.findActiveRefreshTokenByHash({
|
|
344
|
+
hashedToken: hashOpaqueToken(result.refreshToken),
|
|
345
|
+
});
|
|
346
|
+
const reused = confirmEmailVerification({token});
|
|
347
|
+
|
|
348
|
+
expect(verified?.emailVerifiedAt).toBeInstanceOf(Date);
|
|
349
|
+
expect(result.token).toEqual(expect.any(String));
|
|
350
|
+
expect(result.user.id).toBe(user.id);
|
|
351
|
+
expect(refreshSession?.userId).toBe(user.id);
|
|
352
|
+
await expect(reused).rejects.toBeInstanceOf(TokenInvalidError);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('resendEmailVerification only sends for active unverified users', async () => {
|
|
356
|
+
const unverified = await userFactory.create();
|
|
357
|
+
const verified = await userFactory.create({emailVerifiedAt: new Date()});
|
|
358
|
+
const inactive = await userFactory.create();
|
|
359
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, inactive.id));
|
|
360
|
+
|
|
361
|
+
const sent = await resendEmailVerification({email: unverified.email});
|
|
362
|
+
const verifiedResult = await resendEmailVerification({email: verified.email});
|
|
363
|
+
const inactiveResult = await resendEmailVerification({email: inactive.email});
|
|
364
|
+
const missingResult = await resendEmailVerification({
|
|
365
|
+
email: `missing-${crypto.randomUUID()}@example.com`,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(captured).toHaveLength(0);
|
|
369
|
+
expect(
|
|
370
|
+
await outboxEventsTo(unverified.email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED),
|
|
371
|
+
).toHaveLength(1);
|
|
372
|
+
expect(
|
|
373
|
+
await outboxEventsTo(verified.email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED),
|
|
374
|
+
).toHaveLength(0);
|
|
375
|
+
expect(
|
|
376
|
+
await outboxEventsTo(inactive.email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED),
|
|
377
|
+
).toHaveLength(0);
|
|
378
|
+
expect(sent.nextResendAvailableAt).toBeInstanceOf(Date);
|
|
379
|
+
expect(verifiedResult.nextResendAvailableAt).toBeInstanceOf(Date);
|
|
380
|
+
expect(inactiveResult.nextResendAvailableAt).toBeInstanceOf(Date);
|
|
381
|
+
expect(missingResult.nextResendAvailableAt).toBeInstanceOf(Date);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('resendEmailVerification respects cooldown without invalidating the current token', async () => {
|
|
385
|
+
const user = await signup({
|
|
386
|
+
email: `resend-cooldown-${crypto.randomUUID()}@example.com`,
|
|
387
|
+
password: 'correct horse battery staple',
|
|
388
|
+
});
|
|
389
|
+
const token = extractToken(
|
|
390
|
+
await latestEmailLinkTo(user.email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED),
|
|
391
|
+
);
|
|
392
|
+
const existingCreatedAt = new Date(
|
|
393
|
+
Date.now() - (EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS / 2) * 1000,
|
|
394
|
+
);
|
|
395
|
+
await db()
|
|
396
|
+
.update(emailVerifications)
|
|
397
|
+
.set({createdAt: existingCreatedAt})
|
|
398
|
+
.where(eq(emailVerifications.userId, user.id));
|
|
399
|
+
|
|
400
|
+
const beforeCall = Date.now();
|
|
401
|
+
const result = await resendEmailVerification({email: user.email});
|
|
402
|
+
const verified = await confirmEmailVerification({token});
|
|
403
|
+
|
|
404
|
+
// The cooldown branch must return the generic `now + cooldown` estimate, not
|
|
405
|
+
// `latest.createdAt + cooldown`, so the response cannot leak prior timing.
|
|
406
|
+
expect(result.nextResendAvailableAt.getTime()).toBeGreaterThanOrEqual(
|
|
407
|
+
beforeCall + EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS * 1000,
|
|
408
|
+
);
|
|
409
|
+
expect(captured).toHaveLength(0);
|
|
410
|
+
expect(await outboxEventsTo(user.email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED)).toHaveLength(
|
|
411
|
+
1,
|
|
412
|
+
);
|
|
413
|
+
expect(verified.user.id).toBe(user.id);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('resendEmailVerification sends again after cooldown', async () => {
|
|
417
|
+
const user = await signup({
|
|
418
|
+
email: `resend-after-cooldown-${crypto.randomUUID()}@example.com`,
|
|
419
|
+
password: 'correct horse battery staple',
|
|
420
|
+
});
|
|
421
|
+
const staleCreatedAt = new Date(
|
|
422
|
+
Date.now() - (EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS + 1) * 1000,
|
|
423
|
+
);
|
|
424
|
+
await db()
|
|
425
|
+
.update(emailVerifications)
|
|
426
|
+
.set({createdAt: staleCreatedAt})
|
|
427
|
+
.where(eq(emailVerifications.userId, user.id));
|
|
428
|
+
|
|
429
|
+
const result = await resendEmailVerification({email: user.email});
|
|
430
|
+
|
|
431
|
+
expect(result.nextResendAvailableAt).toBeInstanceOf(Date);
|
|
432
|
+
expect(captured).toHaveLength(0);
|
|
433
|
+
expect(await outboxEventsTo(user.email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED)).toHaveLength(
|
|
434
|
+
2,
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('resendEmailVerification serializes duplicate requests for one user', async () => {
|
|
439
|
+
const user = await userFactory.create();
|
|
440
|
+
|
|
441
|
+
const results = await Promise.all([
|
|
442
|
+
resendEmailVerification({email: user.email}),
|
|
443
|
+
resendEmailVerification({email: user.email}),
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
expect(results).toHaveLength(2);
|
|
447
|
+
expect(captured).toHaveLength(0);
|
|
448
|
+
expect(await outboxEventsTo(user.email, AUTH_EMAIL_VERIFICATION_SEND_REQUESTED)).toHaveLength(
|
|
449
|
+
1,
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('password reset request and confirm update the password and invalidate the token', async () => {
|
|
454
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
455
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
456
|
+
const newPassword = 'new password is also long';
|
|
457
|
+
|
|
458
|
+
await requestPasswordReset({email: user.email});
|
|
459
|
+
const resetToken = extractToken(
|
|
460
|
+
await latestEmailLinkTo(user.email, AUTH_PASSWORD_RESET_SEND_REQUESTED),
|
|
461
|
+
);
|
|
462
|
+
const resetResult = await confirmPasswordReset({token: resetToken, newPassword});
|
|
463
|
+
|
|
464
|
+
const oldLogin = login({email: user.email, password: user.plainPassword});
|
|
465
|
+
await expect(oldLogin).rejects.toBeInstanceOf(InvalidCredentialsError);
|
|
466
|
+
|
|
467
|
+
const newLogin = await login({email: user.email, password: newPassword});
|
|
468
|
+
expect(resetResult.token).toEqual(expect.any(String));
|
|
469
|
+
expect(resetResult.refreshToken).toEqual(expect.any(String));
|
|
470
|
+
expect(resetResult.user.id).toBe(user.id);
|
|
471
|
+
expect(newLogin.user.id).toBe(user.id);
|
|
472
|
+
|
|
473
|
+
const oldRefresh = refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
474
|
+
await expect(oldRefresh).rejects.toBeInstanceOf(TokenInvalidError);
|
|
475
|
+
|
|
476
|
+
const reused = confirmPasswordReset({token: resetToken, newPassword});
|
|
477
|
+
await expect(reused).rejects.toBeInstanceOf(TokenInvalidError);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('requestPasswordReset does not send email for missing users', async () => {
|
|
481
|
+
const email = `missing-reset-${crypto.randomUUID()}@example.com`;
|
|
482
|
+
|
|
483
|
+
await requestPasswordReset({email});
|
|
484
|
+
|
|
485
|
+
expect(captured).toHaveLength(0);
|
|
486
|
+
expect(await outboxEventsTo(email, AUTH_PASSWORD_RESET_SEND_REQUESTED)).toHaveLength(0);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('changePassword validates the current password and updates the password', async () => {
|
|
490
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
491
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
492
|
+
const otherLoginResult = await login({email: user.email, password: user.plainPassword});
|
|
493
|
+
const newPassword = 'new password is also long';
|
|
494
|
+
|
|
495
|
+
await changePassword({
|
|
496
|
+
userId: user.id,
|
|
497
|
+
currentPassword: user.plainPassword,
|
|
498
|
+
newPassword,
|
|
499
|
+
refreshToken: loginResult.refreshToken,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const oldLogin = login({email: user.email, password: user.plainPassword});
|
|
503
|
+
await expect(oldLogin).rejects.toBeInstanceOf(InvalidCredentialsError);
|
|
504
|
+
|
|
505
|
+
const newLogin = await login({email: user.email, password: newPassword});
|
|
506
|
+
|
|
507
|
+
expect(newLogin.user.id).toBe(user.id);
|
|
508
|
+
|
|
509
|
+
const currentRefresh = await refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
510
|
+
const otherRefresh = refreshAccessToken({refreshToken: otherLoginResult.refreshToken});
|
|
511
|
+
|
|
512
|
+
expect(currentRefresh.user.id).toBe(user.id);
|
|
513
|
+
await expect(otherRefresh).rejects.toBeInstanceOf(TokenInvalidError);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('changePassword rejects unknown users and invalid current passwords', async () => {
|
|
517
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
518
|
+
|
|
519
|
+
const missingUser = changePassword({
|
|
520
|
+
userId: crypto.randomUUID(),
|
|
521
|
+
currentPassword: user.plainPassword,
|
|
522
|
+
newPassword: 'new password is also long',
|
|
523
|
+
});
|
|
524
|
+
await expect(missingUser).rejects.toBeInstanceOf(UserNotFoundError);
|
|
525
|
+
|
|
526
|
+
const badPassword = changePassword({
|
|
527
|
+
userId: user.id,
|
|
528
|
+
currentPassword: 'not the right password',
|
|
529
|
+
newPassword: 'new password is also long',
|
|
530
|
+
});
|
|
531
|
+
await expect(badPassword).rejects.toBeInstanceOf(InvalidCredentialsError);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test('logout revokes the presented refresh token', async () => {
|
|
535
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
536
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
537
|
+
|
|
538
|
+
await logout({refreshToken: loginResult.refreshToken});
|
|
539
|
+
|
|
540
|
+
const active = await refreshTokenDb.findActiveRefreshTokenByHash({
|
|
541
|
+
hashedToken: hashOpaqueToken(loginResult.refreshToken),
|
|
542
|
+
});
|
|
543
|
+
expect(active).toBeUndefined();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('getCurrentUser returns the user', async () => {
|
|
547
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
548
|
+
|
|
549
|
+
const result = await getCurrentUser({userId: user.id});
|
|
550
|
+
|
|
551
|
+
expect(result.user.id).toBe(user.id);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test('getCurrentUser rejects unknown users', async () => {
|
|
555
|
+
const userId = crypto.randomUUID();
|
|
556
|
+
|
|
557
|
+
const promise = getCurrentUser({userId});
|
|
558
|
+
|
|
559
|
+
await expect(promise).rejects.toBeInstanceOf(UserNotFoundError);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('confirmPasswordReset rejects invalid tokens', async () => {
|
|
563
|
+
const token = `missing-${crypto.randomUUID()}`;
|
|
564
|
+
|
|
565
|
+
const promise = confirmPasswordReset({token, newPassword: 'new password is also long'});
|
|
566
|
+
|
|
567
|
+
await expect(promise).rejects.toBeInstanceOf(TokenInvalidError);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('requestPasswordReset sends reset email for active users', async () => {
|
|
571
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
572
|
+
|
|
573
|
+
await requestPasswordReset({email: user.email});
|
|
574
|
+
|
|
575
|
+
expect(captured).toHaveLength(0);
|
|
576
|
+
expect(await latestEmailLinkTo(user.email, AUTH_PASSWORD_RESET_SEND_REQUESTED)).toContain(
|
|
577
|
+
`${testConfig.clientBaseUrl}/auth/reset?token=`,
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test('signup persists a hashed password', async () => {
|
|
582
|
+
const email = `hashed-${crypto.randomUUID()}@example.com`;
|
|
583
|
+
const password = 'correct horse battery staple';
|
|
584
|
+
|
|
585
|
+
await signup({email, password});
|
|
586
|
+
const user = await findUserByEmail({email});
|
|
587
|
+
|
|
588
|
+
expect(user?.hashedPassword).toEqual(expect.any(String));
|
|
589
|
+
expect(user?.hashedPassword).not.toBe(password);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('login embeds the user current memberships into the access token', async () => {
|
|
593
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
594
|
+
const workspaceA = crypto.randomUUID();
|
|
595
|
+
const workspaceB = crypto.randomUUID();
|
|
596
|
+
listMembershipsByUserMock.mockResolvedValueOnce([
|
|
597
|
+
{
|
|
598
|
+
id: crypto.randomUUID(),
|
|
599
|
+
userId: user.id,
|
|
600
|
+
userEmail: user.email,
|
|
601
|
+
userName: null,
|
|
602
|
+
workspaceId: workspaceA,
|
|
603
|
+
workspaceName: 'Workspace A',
|
|
604
|
+
createdAt: new Date(),
|
|
605
|
+
updatedAt: new Date(),
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
id: crypto.randomUUID(),
|
|
609
|
+
userId: user.id,
|
|
610
|
+
userEmail: user.email,
|
|
611
|
+
userName: null,
|
|
612
|
+
workspaceId: workspaceB,
|
|
613
|
+
workspaceName: 'Workspace B',
|
|
614
|
+
createdAt: new Date(),
|
|
615
|
+
updatedAt: new Date(),
|
|
616
|
+
},
|
|
617
|
+
]);
|
|
618
|
+
|
|
619
|
+
const result = await login({email: user.email, password: user.plainPassword});
|
|
620
|
+
|
|
621
|
+
const claims = await verifyUserToken({token: result.token, secret: 'auth-core-test-secret'});
|
|
622
|
+
expect(claims.name).toBe(user.name);
|
|
623
|
+
expect(claims.memberships).toEqual([
|
|
624
|
+
{workspaceId: workspaceA, role: 'admin'},
|
|
625
|
+
{workspaceId: workspaceB, role: 'admin'},
|
|
626
|
+
]);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test('refresh re-fetches memberships so newly-added workspaces appear in the next token', async () => {
|
|
630
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
631
|
+
listMembershipsByUserMock.mockResolvedValueOnce([]);
|
|
632
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
633
|
+
|
|
634
|
+
const newWorkspaceId = crypto.randomUUID();
|
|
635
|
+
listMembershipsByUserMock.mockResolvedValueOnce([
|
|
636
|
+
{
|
|
637
|
+
id: crypto.randomUUID(),
|
|
638
|
+
userId: user.id,
|
|
639
|
+
userEmail: user.email,
|
|
640
|
+
userName: null,
|
|
641
|
+
workspaceId: newWorkspaceId,
|
|
642
|
+
workspaceName: 'New Workspace',
|
|
643
|
+
createdAt: new Date(),
|
|
644
|
+
updatedAt: new Date(),
|
|
645
|
+
},
|
|
646
|
+
]);
|
|
647
|
+
const refreshed = await refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
648
|
+
|
|
649
|
+
const refreshedClaims = await verifyUserToken({
|
|
650
|
+
token: refreshed.token,
|
|
651
|
+
secret: 'auth-core-test-secret',
|
|
652
|
+
});
|
|
653
|
+
expect(refreshedClaims.memberships).toEqual([{workspaceId: newWorkspaceId, role: 'admin'}]);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('login fails closed when listMembershipsByUser throws', async () => {
|
|
657
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
658
|
+
listMembershipsByUserMock.mockRejectedValueOnce(new Error('workspaces DB down'));
|
|
659
|
+
|
|
660
|
+
const promise = login({email: user.email, password: user.plainPassword});
|
|
661
|
+
|
|
662
|
+
await expect(promise).rejects.toBeInstanceOf(AuthDependencyUnavailableError);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('refresh fails closed when listMembershipsByUser throws', async () => {
|
|
666
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
667
|
+
listMembershipsByUserMock.mockResolvedValueOnce([]);
|
|
668
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
669
|
+
|
|
670
|
+
listMembershipsByUserMock.mockRejectedValueOnce(new Error('workspaces DB down'));
|
|
671
|
+
const promise = refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
672
|
+
|
|
673
|
+
await expect(promise).rejects.toBeInstanceOf(AuthDependencyUnavailableError);
|
|
674
|
+
});
|
|
675
|
+
});
|