@restingowlorg/owlauth 1.0.1
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 +373 -0
- package/README.md +307 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +16 -0
- package/dist/core/auth.manager.d.ts +6 -0
- package/dist/core/auth.manager.js +21 -0
- package/dist/core/auth.service.init.d.ts +2 -0
- package/dist/core/auth.service.init.js +26 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +11 -0
- package/dist/infra/databases/mongo/adapter.d.ts +6 -0
- package/dist/infra/databases/mongo/adapter.js +24 -0
- package/dist/infra/databases/mongo/db.d.ts +5 -0
- package/dist/infra/databases/mongo/db.js +43 -0
- package/dist/infra/databases/mongo/mongo.d.ts +5 -0
- package/dist/infra/databases/mongo/mongo.js +11 -0
- package/dist/infra/databases/postgresql/adapter.d.ts +6 -0
- package/dist/infra/databases/postgresql/adapter.js +26 -0
- package/dist/infra/databases/postgresql/db.d.ts +5 -0
- package/dist/infra/databases/postgresql/db.js +50 -0
- package/dist/infra/databases/postgresql/helpers.d.ts +8 -0
- package/dist/infra/databases/postgresql/helpers.js +55 -0
- package/dist/infra/databases/postgresql/postgres.d.ts +5 -0
- package/dist/infra/databases/postgresql/postgres.js +11 -0
- package/dist/infra/databases/postgresql/schema.d.ts +6 -0
- package/dist/infra/databases/postgresql/schema.js +9 -0
- package/dist/infra/security/bcrypt.adapter.d.ts +9 -0
- package/dist/infra/security/bcrypt.adapter.js +62 -0
- package/dist/infra/security/bcrypt.adapter.test.d.ts +1 -0
- package/dist/infra/security/bcrypt.adapter.test.js +67 -0
- package/dist/infra/security/pwned-passwords.d.ts +5 -0
- package/dist/infra/security/pwned-passwords.js +45 -0
- package/dist/infra/security/pwned-passwords.test.d.ts +1 -0
- package/dist/infra/security/pwned-passwords.test.js +62 -0
- package/dist/infra/security/security-audit-logger.d.ts +11 -0
- package/dist/infra/security/security-audit-logger.js +90 -0
- package/dist/repositories/contracts.d.ts +21 -0
- package/dist/repositories/contracts.js +2 -0
- package/dist/repositories/mongo/magicLink.repo.d.ts +26 -0
- package/dist/repositories/mongo/magicLink.repo.js +106 -0
- package/dist/repositories/mongo/user.repo.d.ts +16 -0
- package/dist/repositories/mongo/user.repo.js +84 -0
- package/dist/repositories/postgresql/magic.link.repo.d.ts +21 -0
- package/dist/repositories/postgresql/magic.link.repo.js +97 -0
- package/dist/repositories/postgresql/user.repo.d.ts +14 -0
- package/dist/repositories/postgresql/user.repo.js +50 -0
- package/dist/services/auth.service.d.ts +22 -0
- package/dist/services/auth.service.js +362 -0
- package/dist/services/auth.service.test.d.ts +1 -0
- package/dist/services/auth.service.test.js +297 -0
- package/dist/services/magic-link.service.d.ts +22 -0
- package/dist/services/magic-link.service.js +196 -0
- package/dist/services/magic-link.service.test.d.ts +1 -0
- package/dist/services/magic-link.service.test.js +230 -0
- package/dist/strategies/CredentialsStrategy.d.ts +4 -0
- package/dist/strategies/CredentialsStrategy.js +32 -0
- package/dist/strategies/CredentialsStrategy.test.d.ts +1 -0
- package/dist/strategies/CredentialsStrategy.test.js +29 -0
- package/dist/strategies/MagicLinkStrategy.d.ts +4 -0
- package/dist/strategies/MagicLinkStrategy.js +21 -0
- package/dist/strategies/MagicLinkStrategy.test.d.ts +1 -0
- package/dist/strategies/MagicLinkStrategy.test.js +38 -0
- package/dist/types/index.d.ts +224 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/check-blocked-passwords.d.ts +1 -0
- package/dist/utils/check-blocked-passwords.js +10 -0
- package/dist/utils/check-blocked-passwords.test.d.ts +1 -0
- package/dist/utils/check-blocked-passwords.test.js +27 -0
- package/package.json +102 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const auth_service_1 = require("./auth.service");
|
|
4
|
+
const core_1 = require("@zxcvbn-ts/core");
|
|
5
|
+
const pwned_passwords_1 = require("../infra/security/pwned-passwords");
|
|
6
|
+
const check_blocked_passwords_1 = require("../utils/check-blocked-passwords");
|
|
7
|
+
// Mock dependencies
|
|
8
|
+
jest.mock("@zxcvbn-ts/core");
|
|
9
|
+
jest.mock("../infra/security/pwned-passwords");
|
|
10
|
+
jest.mock("../utils/check-blocked-passwords");
|
|
11
|
+
describe("AuthService", () => {
|
|
12
|
+
let authService;
|
|
13
|
+
let mockUserRepo;
|
|
14
|
+
let mockCrypto;
|
|
15
|
+
let mockLogger;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockUserRepo = {
|
|
18
|
+
findById: jest.fn(),
|
|
19
|
+
findByEmail: jest.fn(),
|
|
20
|
+
findByUsername: jest.fn(),
|
|
21
|
+
create: jest.fn(),
|
|
22
|
+
updatePassword: jest.fn()
|
|
23
|
+
};
|
|
24
|
+
mockCrypto = {
|
|
25
|
+
hashPassword: jest.fn(),
|
|
26
|
+
verifyPassword: jest.fn(),
|
|
27
|
+
generateToken: jest.fn(),
|
|
28
|
+
hashToken: jest.fn(),
|
|
29
|
+
verifyToken: jest.fn()
|
|
30
|
+
};
|
|
31
|
+
mockLogger = {
|
|
32
|
+
audit: jest.fn(),
|
|
33
|
+
info: jest.fn(),
|
|
34
|
+
warn: jest.fn(),
|
|
35
|
+
error: jest.fn()
|
|
36
|
+
};
|
|
37
|
+
authService = new auth_service_1.AuthService(mockUserRepo, mockCrypto, mockLogger);
|
|
38
|
+
});
|
|
39
|
+
describe("signup", () => {
|
|
40
|
+
const signupData = {
|
|
41
|
+
email: "test@example.com",
|
|
42
|
+
username: "testuser",
|
|
43
|
+
password: "Password123!"
|
|
44
|
+
};
|
|
45
|
+
it("should successfully sign up a user", async () => {
|
|
46
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
47
|
+
core_1.zxcvbn.mockReturnValue({ score: 4 });
|
|
48
|
+
pwned_passwords_1.isBreachedPassword.mockResolvedValue({ detected: false });
|
|
49
|
+
mockUserRepo.findByUsername.mockResolvedValue(null);
|
|
50
|
+
mockUserRepo.findByEmail.mockResolvedValue(null);
|
|
51
|
+
mockCrypto.hashPassword.mockResolvedValue("hashed_password");
|
|
52
|
+
mockUserRepo.create.mockResolvedValue({
|
|
53
|
+
id: "1",
|
|
54
|
+
email: signupData.email,
|
|
55
|
+
username: signupData.username
|
|
56
|
+
});
|
|
57
|
+
const result = await authService.signup(signupData.email, signupData.username, signupData.password);
|
|
58
|
+
expect(result.success).toBe(true);
|
|
59
|
+
expect(result.httpCode).toBe(201);
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
61
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({ type: "SIGNUP" }));
|
|
62
|
+
});
|
|
63
|
+
it("should fail if required fields are missing", async () => {
|
|
64
|
+
const result = await authService.signup("", "", "");
|
|
65
|
+
expect(result.success).toBe(false);
|
|
66
|
+
expect(result.httpCode).toBe(400);
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
68
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({ type: "SIGNUP_FAILURE" }));
|
|
69
|
+
});
|
|
70
|
+
it("should fail if username format is invalid", async () => {
|
|
71
|
+
const result = await authService.signup("test@example.com", "us", "Password123!");
|
|
72
|
+
expect(result.success).toBe(false);
|
|
73
|
+
expect(result.httpCode).toBe(400);
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
75
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({ type: "SIGNUP_FAILURE" }));
|
|
76
|
+
});
|
|
77
|
+
it("should fail if password contains blocked terms", async () => {
|
|
78
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(true);
|
|
79
|
+
const result = await authService.signup(signupData.email, signupData.username, "blockedpassword");
|
|
80
|
+
expect(result.success).toBe(false);
|
|
81
|
+
expect(result.httpCode).toBe(400);
|
|
82
|
+
});
|
|
83
|
+
it("should fail if password is too weak", async () => {
|
|
84
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
85
|
+
core_1.zxcvbn.mockReturnValue({ score: 1 });
|
|
86
|
+
const result = await authService.signup(signupData.email, signupData.username, "weak");
|
|
87
|
+
expect(result.success).toBe(false);
|
|
88
|
+
expect(result.httpCode).toBe(400);
|
|
89
|
+
});
|
|
90
|
+
it("should fail if password is found in a data breach", async () => {
|
|
91
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
92
|
+
core_1.zxcvbn.mockReturnValue({ score: 4 });
|
|
93
|
+
pwned_passwords_1.isBreachedPassword.mockResolvedValue({ detected: true });
|
|
94
|
+
const result = await authService.signup(signupData.email, signupData.username, "breachedpassword");
|
|
95
|
+
expect(result.success).toBe(false);
|
|
96
|
+
expect(result.httpCode).toBe(400);
|
|
97
|
+
});
|
|
98
|
+
it("should fail if username is already taken", async () => {
|
|
99
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
100
|
+
core_1.zxcvbn.mockReturnValue({ score: 4 });
|
|
101
|
+
pwned_passwords_1.isBreachedPassword.mockResolvedValue({ detected: false });
|
|
102
|
+
mockUserRepo.findByUsername.mockResolvedValue({ id: "1" });
|
|
103
|
+
const result = await authService.signup(signupData.email, signupData.username, signupData.password);
|
|
104
|
+
expect(result.success).toBe(false);
|
|
105
|
+
expect(result.httpCode).toBe(400);
|
|
106
|
+
expect(result.message).toBe("Username already taken.");
|
|
107
|
+
});
|
|
108
|
+
it("should fail if email is already registered", async () => {
|
|
109
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
110
|
+
core_1.zxcvbn.mockReturnValue({ score: 4 });
|
|
111
|
+
pwned_passwords_1.isBreachedPassword.mockResolvedValue({ detected: false });
|
|
112
|
+
mockUserRepo.findByUsername.mockResolvedValue(null);
|
|
113
|
+
mockUserRepo.findByEmail.mockResolvedValue({ id: "1" });
|
|
114
|
+
const result = await authService.signup(signupData.email, signupData.username, signupData.password);
|
|
115
|
+
expect(result.success).toBe(false);
|
|
116
|
+
expect(result.httpCode).toBe(400);
|
|
117
|
+
expect(result.message).toBe("Email already registered.");
|
|
118
|
+
});
|
|
119
|
+
it("should return 503 SERVICE_UNAVAILABLE if pwned check fails and pwnedPasswordFailClosed is true", async () => {
|
|
120
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
121
|
+
core_1.zxcvbn.mockReturnValue({ score: 4 });
|
|
122
|
+
pwned_passwords_1.isBreachedPassword.mockResolvedValue({
|
|
123
|
+
detected: false,
|
|
124
|
+
error: new Error("HIBP API Down")
|
|
125
|
+
});
|
|
126
|
+
const result = await authService.signup(signupData.email, signupData.username, signupData.password, { pwnedPasswordFailClosed: true });
|
|
127
|
+
expect(result.success).toBe(false);
|
|
128
|
+
expect(result.httpCode).toBe(503);
|
|
129
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
130
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({
|
|
131
|
+
metadata: expect.objectContaining({
|
|
132
|
+
reason: expect.stringContaining("Fail-Closed")
|
|
133
|
+
})
|
|
134
|
+
}));
|
|
135
|
+
});
|
|
136
|
+
it("should propagate correlationId to auditLogger during signup", async () => {
|
|
137
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
138
|
+
core_1.zxcvbn.mockReturnValue({ score: 4 });
|
|
139
|
+
pwned_passwords_1.isBreachedPassword.mockResolvedValue({ detected: false });
|
|
140
|
+
mockUserRepo.findByUsername.mockResolvedValue(null);
|
|
141
|
+
mockUserRepo.findByEmail.mockResolvedValue(null);
|
|
142
|
+
mockCrypto.hashPassword.mockResolvedValue("hashed");
|
|
143
|
+
mockUserRepo.create.mockResolvedValue({
|
|
144
|
+
id: "1",
|
|
145
|
+
email: signupData.email,
|
|
146
|
+
username: signupData.username
|
|
147
|
+
});
|
|
148
|
+
const correlationId = "test-corr-id";
|
|
149
|
+
await authService.signup(signupData.email, signupData.username, signupData.password, {
|
|
150
|
+
correlationId
|
|
151
|
+
});
|
|
152
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
153
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({ correlationId }));
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe("login", () => {
|
|
157
|
+
const loginData = {
|
|
158
|
+
email: "test@example.com",
|
|
159
|
+
password: "Password123!"
|
|
160
|
+
};
|
|
161
|
+
it("should successfully log in a user", async () => {
|
|
162
|
+
mockUserRepo.findByEmail.mockResolvedValue({
|
|
163
|
+
id: "1",
|
|
164
|
+
email: loginData.email,
|
|
165
|
+
password: "hashed_password"
|
|
166
|
+
});
|
|
167
|
+
mockCrypto.verifyPassword.mockResolvedValue(true);
|
|
168
|
+
const result = await authService.login(loginData.email, loginData.password);
|
|
169
|
+
expect(result.success).toBe(true);
|
|
170
|
+
expect(result.httpCode).toBe(200);
|
|
171
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
172
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({ type: "LOGIN_SUCCESS" }));
|
|
173
|
+
});
|
|
174
|
+
it("should fail if credentials are missing", async () => {
|
|
175
|
+
const result = await authService.login("", "");
|
|
176
|
+
expect(result.success).toBe(false);
|
|
177
|
+
expect(result.httpCode).toBe(400);
|
|
178
|
+
});
|
|
179
|
+
it("should fail if user not found", async () => {
|
|
180
|
+
mockUserRepo.findByEmail.mockResolvedValue(null);
|
|
181
|
+
const result = await authService.login(loginData.email, loginData.password);
|
|
182
|
+
expect(result.success).toBe(false);
|
|
183
|
+
expect(result.httpCode).toBe(401);
|
|
184
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
185
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({ type: "LOGIN_FAILURE", metadata: { reason: "User not found" } }));
|
|
186
|
+
});
|
|
187
|
+
it("should fail if password does not match", async () => {
|
|
188
|
+
mockUserRepo.findByEmail.mockResolvedValue({
|
|
189
|
+
id: "1",
|
|
190
|
+
email: loginData.email,
|
|
191
|
+
password: "hashed_password"
|
|
192
|
+
});
|
|
193
|
+
mockCrypto.verifyPassword.mockResolvedValue(false);
|
|
194
|
+
const result = await authService.login(loginData.email, loginData.password);
|
|
195
|
+
expect(result.success).toBe(false);
|
|
196
|
+
expect(result.httpCode).toBe(401);
|
|
197
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
198
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({ type: "LOGIN_FAILURE", metadata: { reason: "Invalid password" } }));
|
|
199
|
+
});
|
|
200
|
+
it("should propagate correlationId to auditLogger and error logs during login", async () => {
|
|
201
|
+
const email = "test@example.com";
|
|
202
|
+
const correlationId = "login-trace-id";
|
|
203
|
+
// 1. Audit log on failure
|
|
204
|
+
mockUserRepo.findByEmail.mockResolvedValue(null);
|
|
205
|
+
await authService.login(email, "pass", { correlationId });
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
207
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({ type: "LOGIN_FAILURE", correlationId }));
|
|
208
|
+
// 2. Error log on exception
|
|
209
|
+
const error = new Error("DB Error");
|
|
210
|
+
mockUserRepo.findByEmail.mockRejectedValue(error);
|
|
211
|
+
await authService.login(email, "pass", { correlationId });
|
|
212
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
213
|
+
expect(mockLogger.error).toHaveBeenCalledWith("Login exception", error, { email }, correlationId);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
describe("changePassword", () => {
|
|
217
|
+
const changePwdData = {
|
|
218
|
+
userId: "1",
|
|
219
|
+
currentPassword: "OldPassword123!",
|
|
220
|
+
newPassword: "NewPassword123!"
|
|
221
|
+
};
|
|
222
|
+
it("should successfully change password", async () => {
|
|
223
|
+
mockUserRepo.findById.mockResolvedValue({
|
|
224
|
+
id: "1",
|
|
225
|
+
email: "test@example.com",
|
|
226
|
+
username: "testuser",
|
|
227
|
+
password: "old_hashed_password"
|
|
228
|
+
});
|
|
229
|
+
mockCrypto.verifyPassword.mockResolvedValue(true);
|
|
230
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
231
|
+
core_1.zxcvbn.mockReturnValue({ score: 4 });
|
|
232
|
+
pwned_passwords_1.isBreachedPassword.mockResolvedValue({ detected: false });
|
|
233
|
+
mockCrypto.hashPassword.mockResolvedValue("new_hashed_password");
|
|
234
|
+
mockUserRepo.updatePassword.mockResolvedValue(true);
|
|
235
|
+
const result = await authService.changePassword(changePwdData.userId, changePwdData.currentPassword, changePwdData.newPassword);
|
|
236
|
+
expect(result.success).toBe(true);
|
|
237
|
+
expect(result.httpCode).toBe(200);
|
|
238
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
239
|
+
expect(mockLogger.audit).toHaveBeenCalledWith(expect.objectContaining({ type: "PASSWORD_CHANGE" }));
|
|
240
|
+
});
|
|
241
|
+
it("should fail if user not found", async () => {
|
|
242
|
+
mockUserRepo.findById.mockResolvedValue(null);
|
|
243
|
+
const result = await authService.changePassword("99", "pass", "newpass");
|
|
244
|
+
expect(result.success).toBe(false);
|
|
245
|
+
expect(result.httpCode).toBe(404);
|
|
246
|
+
});
|
|
247
|
+
it("should fail if current password is incorrect", async () => {
|
|
248
|
+
mockUserRepo.findById.mockResolvedValue({
|
|
249
|
+
id: "1",
|
|
250
|
+
password: "hashed_password"
|
|
251
|
+
});
|
|
252
|
+
mockCrypto.verifyPassword.mockResolvedValue(false);
|
|
253
|
+
const result = await authService.changePassword("1", "wrong", "newpass");
|
|
254
|
+
expect(result.success).toBe(false);
|
|
255
|
+
expect(result.httpCode).toBe(401);
|
|
256
|
+
});
|
|
257
|
+
it("should fail if new password is too weak", async () => {
|
|
258
|
+
mockUserRepo.findById.mockResolvedValue({
|
|
259
|
+
id: "1",
|
|
260
|
+
password: "hashed_password"
|
|
261
|
+
});
|
|
262
|
+
mockCrypto.verifyPassword.mockResolvedValue(true);
|
|
263
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
264
|
+
core_1.zxcvbn.mockReturnValue({ score: 1 });
|
|
265
|
+
const result = await authService.changePassword("1", "old", "weak");
|
|
266
|
+
expect(result.success).toBe(false);
|
|
267
|
+
expect(result.httpCode).toBe(400);
|
|
268
|
+
});
|
|
269
|
+
it("should return 503 SERVICE_UNAVAILABLE during password change if fail-closed is enabled and check fails", async () => {
|
|
270
|
+
mockUserRepo.findById.mockResolvedValue({
|
|
271
|
+
id: "1",
|
|
272
|
+
email: "test@example.com",
|
|
273
|
+
username: "testuser",
|
|
274
|
+
password: "old"
|
|
275
|
+
});
|
|
276
|
+
mockCrypto.verifyPassword.mockResolvedValue(true);
|
|
277
|
+
check_blocked_passwords_1.containsBlockedPasswords.mockReturnValue(false);
|
|
278
|
+
core_1.zxcvbn.mockReturnValue({ score: 4 });
|
|
279
|
+
pwned_passwords_1.isBreachedPassword.mockResolvedValue({
|
|
280
|
+
detected: false,
|
|
281
|
+
error: new Error("Network Error")
|
|
282
|
+
});
|
|
283
|
+
const result = await authService.changePassword("1", "old", "new", {
|
|
284
|
+
pwnedPasswordFailClosed: true
|
|
285
|
+
});
|
|
286
|
+
expect(result.success).toBe(false);
|
|
287
|
+
expect(result.httpCode).toBe(503);
|
|
288
|
+
});
|
|
289
|
+
it("should propagate correlationId to all logs during password change", async () => {
|
|
290
|
+
const correlationId = "change-pwd-trace";
|
|
291
|
+
mockUserRepo.findById.mockRejectedValue(new Error("DB Error")); // Force exception for error log check
|
|
292
|
+
await authService.changePassword("1", "old", "new", { correlationId });
|
|
293
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
294
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.any(String), expect.any(Error), undefined, correlationId);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { UserRepository, MagicLinkRepository } from "../repositories/contracts";
|
|
2
|
+
import { ICryptoAdapter, IAuditLogger } from "../types";
|
|
3
|
+
import { AuthResult, RequestMagicLinkResult, VerifyMagicLinkResult, ConsumeMagicLinkResult } from "../types/index";
|
|
4
|
+
export declare class MagicLinkService {
|
|
5
|
+
private users;
|
|
6
|
+
private magicLinks;
|
|
7
|
+
private crypto;
|
|
8
|
+
private logger;
|
|
9
|
+
constructor(users: UserRepository, magicLinks: MagicLinkRepository, crypto: ICryptoAdapter, logger: IAuditLogger);
|
|
10
|
+
/** Request a magic link (passwordless login) */
|
|
11
|
+
request(email: string, options?: {
|
|
12
|
+
correlationId?: string;
|
|
13
|
+
}): Promise<AuthResult<RequestMagicLinkResult>>;
|
|
14
|
+
/** Verify a magic link token without consuming it */
|
|
15
|
+
verify(token: string, options?: {
|
|
16
|
+
correlationId?: string;
|
|
17
|
+
}): Promise<AuthResult<VerifyMagicLinkResult>>;
|
|
18
|
+
/** Consume a magic link token */
|
|
19
|
+
consume(token: string, options?: {
|
|
20
|
+
correlationId?: string;
|
|
21
|
+
}): Promise<AuthResult<ConsumeMagicLinkResult>>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MagicLinkService = void 0;
|
|
4
|
+
class MagicLinkService {
|
|
5
|
+
constructor(users, magicLinks, crypto, logger) {
|
|
6
|
+
this.users = users;
|
|
7
|
+
this.magicLinks = magicLinks;
|
|
8
|
+
this.crypto = crypto;
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
}
|
|
11
|
+
/** Request a magic link (passwordless login) */
|
|
12
|
+
async request(email, options) {
|
|
13
|
+
try {
|
|
14
|
+
const user = await this.users.findByEmail(email);
|
|
15
|
+
if (!user) {
|
|
16
|
+
this.logger.audit({
|
|
17
|
+
type: "MAGIC_LINK_FAILURE",
|
|
18
|
+
email,
|
|
19
|
+
metadata: { reason: "User not found" },
|
|
20
|
+
correlationId: options === null || options === void 0 ? void 0 : options.correlationId
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
success: false,
|
|
24
|
+
data: undefined,
|
|
25
|
+
message: "User not found",
|
|
26
|
+
httpCode: 404
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const token = this.crypto.generateToken();
|
|
30
|
+
const tokenHash = await this.crypto.hashToken(token);
|
|
31
|
+
// invalidate existing tokens for this user
|
|
32
|
+
const invalidated = await this.magicLinks.invalidateByUserId(user.id);
|
|
33
|
+
if (!invalidated) {
|
|
34
|
+
this.logger.error("Failed to invalidate magic links", new Error("DB update failed"), undefined, options === null || options === void 0 ? void 0 : options.correlationId);
|
|
35
|
+
return {
|
|
36
|
+
success: false,
|
|
37
|
+
data: undefined,
|
|
38
|
+
message: "Failed to invalidate existing magic links",
|
|
39
|
+
httpCode: 500
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
// create new token
|
|
43
|
+
const record = await this.magicLinks.create({
|
|
44
|
+
userId: user.id,
|
|
45
|
+
tokenHash,
|
|
46
|
+
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
|
|
47
|
+
});
|
|
48
|
+
if (!record) {
|
|
49
|
+
this.logger.error("Failed to create magic link", new Error("DB insert failed"), undefined, options === null || options === void 0 ? void 0 : options.correlationId);
|
|
50
|
+
return {
|
|
51
|
+
success: false,
|
|
52
|
+
data: undefined,
|
|
53
|
+
message: "Failed to create magic link",
|
|
54
|
+
httpCode: 500
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
this.logger.audit({
|
|
58
|
+
type: "MAGIC_LINK_REQUESTED",
|
|
59
|
+
email: user.email,
|
|
60
|
+
correlationId: options === null || options === void 0 ? void 0 : options.correlationId
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
success: true,
|
|
64
|
+
data: `${record.id}.${token}`,
|
|
65
|
+
message: "Magic link created",
|
|
66
|
+
httpCode: 200
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
71
|
+
this.logger.error("Magic link request exception", err, { email }, options === null || options === void 0 ? void 0 : options.correlationId);
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
data: undefined,
|
|
75
|
+
message: "Failed to request magic link: " + message,
|
|
76
|
+
httpCode: 500
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Verify a magic link token without consuming it */
|
|
81
|
+
async verify(token, options) {
|
|
82
|
+
try {
|
|
83
|
+
const parts = token.split(".");
|
|
84
|
+
if (parts.length !== 2) {
|
|
85
|
+
this.logger.audit({
|
|
86
|
+
type: "MAGIC_LINK_FAILURE",
|
|
87
|
+
metadata: { reason: "Malformed token" },
|
|
88
|
+
correlationId: options === null || options === void 0 ? void 0 : options.correlationId
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
data: undefined,
|
|
93
|
+
message: "Invalid or malformed magic link token",
|
|
94
|
+
httpCode: 400
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const [tokenId, tokenValue] = parts;
|
|
98
|
+
const record = await this.magicLinks.findById(tokenId);
|
|
99
|
+
if (!record || record.usedAt || record.expiresAt.getTime() < Date.now()) {
|
|
100
|
+
this.logger.audit({
|
|
101
|
+
type: "MAGIC_LINK_FAILURE",
|
|
102
|
+
metadata: { reason: "Invalid or expired token" },
|
|
103
|
+
correlationId: options === null || options === void 0 ? void 0 : options.correlationId
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
data: undefined,
|
|
108
|
+
message: "Invalid or expired magic link",
|
|
109
|
+
httpCode: 401
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const match = await this.crypto.verifyToken(tokenValue, record.tokenHash);
|
|
113
|
+
if (!match) {
|
|
114
|
+
this.logger.audit({
|
|
115
|
+
type: "MAGIC_LINK_FAILURE",
|
|
116
|
+
metadata: { reason: "Token mismatch" },
|
|
117
|
+
correlationId: options === null || options === void 0 ? void 0 : options.correlationId
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
data: undefined,
|
|
122
|
+
message: "Invalid or expired magic link",
|
|
123
|
+
httpCode: 401
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
this.logger.audit({ type: "MAGIC_LINK_VERIFIED", correlationId: options === null || options === void 0 ? void 0 : options.correlationId });
|
|
127
|
+
return {
|
|
128
|
+
success: true,
|
|
129
|
+
data: { isValid: true, userId: String(record.userId), tokenId: String(record.id) },
|
|
130
|
+
message: "Magic link is valid",
|
|
131
|
+
httpCode: 200
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
136
|
+
this.logger.error("Magic link verify exception", err, undefined, options === null || options === void 0 ? void 0 : options.correlationId);
|
|
137
|
+
return {
|
|
138
|
+
success: false,
|
|
139
|
+
data: undefined,
|
|
140
|
+
message: "Failed to verify magic link: " + message,
|
|
141
|
+
httpCode: 500
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Consume a magic link token */
|
|
146
|
+
async consume(token, options) {
|
|
147
|
+
try {
|
|
148
|
+
const verifyResult = await this.verify(token, options);
|
|
149
|
+
if (!verifyResult.success) {
|
|
150
|
+
return {
|
|
151
|
+
success: false,
|
|
152
|
+
data: undefined,
|
|
153
|
+
message: verifyResult.message,
|
|
154
|
+
httpCode: verifyResult.httpCode
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const consumed = await this.magicLinks.consume(verifyResult.data.tokenId);
|
|
158
|
+
if (!consumed) {
|
|
159
|
+
this.logger.error("Failed to consume magic link", new Error("DB update failed"), undefined, options === null || options === void 0 ? void 0 : options.correlationId);
|
|
160
|
+
this.logger.audit({
|
|
161
|
+
type: "MAGIC_LINK_FAILURE",
|
|
162
|
+
metadata: { reason: "Token already used" },
|
|
163
|
+
correlationId: options === null || options === void 0 ? void 0 : options.correlationId
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
success: false,
|
|
167
|
+
message: "Magic link already used",
|
|
168
|
+
httpCode: 401
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
this.logger.audit({ type: "MAGIC_LINK_CONSUMED", correlationId: options === null || options === void 0 ? void 0 : options.correlationId });
|
|
172
|
+
this.logger.audit({
|
|
173
|
+
type: "LOGIN_SUCCESS",
|
|
174
|
+
metadata: { method: "magic-link" },
|
|
175
|
+
correlationId: options === null || options === void 0 ? void 0 : options.correlationId
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
success: true,
|
|
179
|
+
data: { userId: verifyResult.data.userId },
|
|
180
|
+
message: "Magic link consumed",
|
|
181
|
+
httpCode: 200
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
186
|
+
this.logger.error("Magic link consume exception", err, undefined, options === null || options === void 0 ? void 0 : options.correlationId);
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
data: undefined,
|
|
190
|
+
message: "Failed to consume magic link: " + message,
|
|
191
|
+
httpCode: 500
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
exports.MagicLinkService = MagicLinkService;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|