@enderworld/onlyapi 1.5.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.
Files changed (160) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/LICENSE +21 -0
  3. package/README.md +338 -0
  4. package/dist/cli.js +14 -0
  5. package/package.json +69 -0
  6. package/src/application/dtos/admin.dto.ts +25 -0
  7. package/src/application/dtos/auth.dto.ts +97 -0
  8. package/src/application/dtos/index.ts +40 -0
  9. package/src/application/index.ts +2 -0
  10. package/src/application/services/admin.service.ts +150 -0
  11. package/src/application/services/api-key.service.ts +65 -0
  12. package/src/application/services/auth.service.ts +606 -0
  13. package/src/application/services/health.service.ts +97 -0
  14. package/src/application/services/index.ts +10 -0
  15. package/src/application/services/user.service.ts +95 -0
  16. package/src/cli/commands/help.ts +86 -0
  17. package/src/cli/commands/init.ts +301 -0
  18. package/src/cli/commands/upgrade.ts +471 -0
  19. package/src/cli/index.ts +76 -0
  20. package/src/cli/ui.ts +189 -0
  21. package/src/cluster.ts +62 -0
  22. package/src/core/entities/index.ts +1 -0
  23. package/src/core/entities/user.entity.ts +24 -0
  24. package/src/core/errors/app-error.ts +81 -0
  25. package/src/core/errors/index.ts +15 -0
  26. package/src/core/index.ts +7 -0
  27. package/src/core/ports/account-lockout.ts +15 -0
  28. package/src/core/ports/alert-sink.ts +27 -0
  29. package/src/core/ports/api-key.ts +37 -0
  30. package/src/core/ports/audit-log.ts +46 -0
  31. package/src/core/ports/cache.ts +24 -0
  32. package/src/core/ports/circuit-breaker.ts +42 -0
  33. package/src/core/ports/event-bus.ts +78 -0
  34. package/src/core/ports/index.ts +62 -0
  35. package/src/core/ports/job-queue.ts +73 -0
  36. package/src/core/ports/logger.ts +21 -0
  37. package/src/core/ports/metrics.ts +49 -0
  38. package/src/core/ports/oauth.ts +55 -0
  39. package/src/core/ports/password-hasher.ts +10 -0
  40. package/src/core/ports/password-history.ts +23 -0
  41. package/src/core/ports/password-policy.ts +43 -0
  42. package/src/core/ports/refresh-token-store.ts +37 -0
  43. package/src/core/ports/retry.ts +23 -0
  44. package/src/core/ports/token-blacklist.ts +16 -0
  45. package/src/core/ports/token-service.ts +23 -0
  46. package/src/core/ports/totp-service.ts +16 -0
  47. package/src/core/ports/user.repository.ts +40 -0
  48. package/src/core/ports/verification-token.ts +41 -0
  49. package/src/core/ports/webhook.ts +58 -0
  50. package/src/core/types/brand.ts +19 -0
  51. package/src/core/types/index.ts +19 -0
  52. package/src/core/types/pagination.ts +28 -0
  53. package/src/core/types/result.ts +52 -0
  54. package/src/infrastructure/alerting/index.ts +1 -0
  55. package/src/infrastructure/alerting/webhook.ts +100 -0
  56. package/src/infrastructure/cache/in-memory-cache.ts +111 -0
  57. package/src/infrastructure/cache/index.ts +6 -0
  58. package/src/infrastructure/cache/redis-cache.ts +204 -0
  59. package/src/infrastructure/config/config.ts +185 -0
  60. package/src/infrastructure/config/index.ts +1 -0
  61. package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
  62. package/src/infrastructure/database/index.ts +37 -0
  63. package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
  64. package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
  65. package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
  66. package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
  67. package/src/infrastructure/database/migrations/runner.ts +120 -0
  68. package/src/infrastructure/database/mssql/index.ts +14 -0
  69. package/src/infrastructure/database/mssql/migrations.ts +299 -0
  70. package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
  71. package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
  72. package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
  73. package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
  74. package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
  75. package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
  76. package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
  77. package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
  78. package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
  79. package/src/infrastructure/database/postgres/index.ts +14 -0
  80. package/src/infrastructure/database/postgres/migrations.ts +235 -0
  81. package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
  82. package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
  83. package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
  84. package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
  85. package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
  86. package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
  87. package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
  88. package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
  89. package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
  90. package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
  91. package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
  92. package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
  93. package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
  94. package/src/infrastructure/database/sqlite-password-history.ts +54 -0
  95. package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
  96. package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
  97. package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
  98. package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
  99. package/src/infrastructure/events/event-bus.ts +105 -0
  100. package/src/infrastructure/events/event-factory.ts +31 -0
  101. package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
  102. package/src/infrastructure/events/index.ts +4 -0
  103. package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
  104. package/src/infrastructure/index.ts +58 -0
  105. package/src/infrastructure/jobs/index.ts +1 -0
  106. package/src/infrastructure/jobs/job-queue.ts +185 -0
  107. package/src/infrastructure/logging/index.ts +1 -0
  108. package/src/infrastructure/logging/logger.ts +63 -0
  109. package/src/infrastructure/metrics/index.ts +1 -0
  110. package/src/infrastructure/metrics/prometheus.ts +231 -0
  111. package/src/infrastructure/oauth/github.ts +116 -0
  112. package/src/infrastructure/oauth/google.ts +83 -0
  113. package/src/infrastructure/oauth/index.ts +2 -0
  114. package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
  115. package/src/infrastructure/resilience/index.ts +2 -0
  116. package/src/infrastructure/resilience/retry.ts +50 -0
  117. package/src/infrastructure/security/account-lockout.ts +73 -0
  118. package/src/infrastructure/security/index.ts +6 -0
  119. package/src/infrastructure/security/password-hasher.ts +31 -0
  120. package/src/infrastructure/security/password-policy.ts +77 -0
  121. package/src/infrastructure/security/token-blacklist.ts +45 -0
  122. package/src/infrastructure/security/token-service.ts +144 -0
  123. package/src/infrastructure/security/totp-service.ts +142 -0
  124. package/src/infrastructure/tracing/index.ts +7 -0
  125. package/src/infrastructure/tracing/trace-context.ts +93 -0
  126. package/src/main.ts +479 -0
  127. package/src/presentation/context.ts +26 -0
  128. package/src/presentation/handlers/admin.handler.ts +114 -0
  129. package/src/presentation/handlers/api-key.handler.ts +68 -0
  130. package/src/presentation/handlers/auth.handler.ts +218 -0
  131. package/src/presentation/handlers/health.handler.ts +27 -0
  132. package/src/presentation/handlers/index.ts +15 -0
  133. package/src/presentation/handlers/metrics.handler.ts +21 -0
  134. package/src/presentation/handlers/oauth.handler.ts +61 -0
  135. package/src/presentation/handlers/openapi.handler.ts +543 -0
  136. package/src/presentation/handlers/response.ts +29 -0
  137. package/src/presentation/handlers/sse.handler.ts +165 -0
  138. package/src/presentation/handlers/user.handler.ts +81 -0
  139. package/src/presentation/handlers/webhook.handler.ts +92 -0
  140. package/src/presentation/handlers/websocket.handler.ts +226 -0
  141. package/src/presentation/i18n/index.ts +254 -0
  142. package/src/presentation/index.ts +5 -0
  143. package/src/presentation/middleware/api-key.ts +18 -0
  144. package/src/presentation/middleware/auth.ts +39 -0
  145. package/src/presentation/middleware/cors.ts +41 -0
  146. package/src/presentation/middleware/index.ts +12 -0
  147. package/src/presentation/middleware/rate-limit.ts +65 -0
  148. package/src/presentation/middleware/security-headers.ts +18 -0
  149. package/src/presentation/middleware/validate.ts +16 -0
  150. package/src/presentation/middleware/versioning.ts +69 -0
  151. package/src/presentation/routes/index.ts +1 -0
  152. package/src/presentation/routes/router.ts +272 -0
  153. package/src/presentation/server.ts +381 -0
  154. package/src/shared/cli.ts +294 -0
  155. package/src/shared/container.ts +65 -0
  156. package/src/shared/index.ts +2 -0
  157. package/src/shared/log-format.ts +148 -0
  158. package/src/shared/utils/id.ts +5 -0
  159. package/src/shared/utils/index.ts +2 -0
  160. package/src/shared/utils/timing-safe.ts +20 -0
@@ -0,0 +1,606 @@
1
+ import { UserRole } from "../../core/entities/user.entity.js";
2
+ import type { AppError } from "../../core/errors/app-error.js";
3
+ import { badRequest, forbidden, unauthorized } from "../../core/errors/app-error.js";
4
+ import type { AccountLockout } from "../../core/ports/account-lockout.js";
5
+ import type { Logger } from "../../core/ports/logger.js";
6
+ import type { OAuthAccountRepository, OAuthProvider } from "../../core/ports/oauth.js";
7
+ import type { PasswordHasher } from "../../core/ports/password-hasher.js";
8
+ import type { PasswordHistory } from "../../core/ports/password-history.js";
9
+ import type { PasswordPolicy } from "../../core/ports/password-policy.js";
10
+ import type { RefreshTokenStore } from "../../core/ports/refresh-token-store.js";
11
+ import type { TokenBlacklist } from "../../core/ports/token-blacklist.js";
12
+ import type { TokenPair, TokenPayload, TokenService } from "../../core/ports/token-service.js";
13
+ import type { TotpService } from "../../core/ports/totp-service.js";
14
+ import type { UserRepository } from "../../core/ports/user.repository.js";
15
+ import type { VerificationTokenRepository } from "../../core/ports/verification-token.js";
16
+ import { VerificationTokenType } from "../../core/ports/verification-token.js";
17
+ import type { UserId } from "../../core/types/brand.js";
18
+ import { type Result, err, ok } from "../../core/types/result.js";
19
+ import type {
20
+ ForgotPasswordDto,
21
+ LoginDto,
22
+ LogoutDto,
23
+ MfaDisableDto,
24
+ MfaEnableDto,
25
+ MfaVerifyDto,
26
+ RefreshDto,
27
+ RegisterDto,
28
+ ResetPasswordDto,
29
+ VerifyEmailDto,
30
+ } from "../dtos/auth.dto.js";
31
+
32
+ export interface MfaSetupResponse {
33
+ readonly secret: string;
34
+ readonly uri: string;
35
+ }
36
+
37
+ export interface LoginResponse {
38
+ readonly accessToken: string;
39
+ readonly refreshToken: string;
40
+ readonly mfaRequired?: boolean;
41
+ readonly mfaToken?: string;
42
+ }
43
+
44
+ export interface AuthService {
45
+ register(dto: RegisterDto): Promise<Result<LoginResponse, AppError>>;
46
+ login(dto: LoginDto): Promise<Result<LoginResponse, AppError>>;
47
+ refresh(dto: RefreshDto): Promise<Result<TokenPair, AppError>>;
48
+ logout(dto: LogoutDto): Promise<Result<void, AppError>>;
49
+ verifyEmail(dto: VerifyEmailDto): Promise<Result<void, AppError>>;
50
+ resendVerification(userId: UserId): Promise<Result<{ token: string }, AppError>>;
51
+ forgotPassword(dto: ForgotPasswordDto): Promise<Result<{ token: string }, AppError>>;
52
+ resetPassword(dto: ResetPasswordDto): Promise<Result<void, AppError>>;
53
+ mfaSetup(userId: UserId, email: string): Promise<Result<MfaSetupResponse, AppError>>;
54
+ mfaEnable(userId: UserId, dto: MfaEnableDto): Promise<Result<void, AppError>>;
55
+ mfaDisable(userId: UserId, dto: MfaDisableDto): Promise<Result<void, AppError>>;
56
+ mfaVerify(dto: MfaVerifyDto): Promise<Result<TokenPair, AppError>>;
57
+ oauthLogin(
58
+ provider: string,
59
+ code: string,
60
+ redirectUri: string,
61
+ ): Promise<Result<LoginResponse, AppError>>;
62
+ }
63
+
64
+ interface Deps {
65
+ readonly userRepo: UserRepository;
66
+ readonly passwordHasher: PasswordHasher;
67
+ readonly tokenService: TokenService;
68
+ readonly tokenBlacklist: TokenBlacklist;
69
+ readonly accountLockout: AccountLockout;
70
+ readonly verificationTokens: VerificationTokenRepository;
71
+ readonly refreshTokenStore: RefreshTokenStore;
72
+ readonly passwordHistory: PasswordHistory;
73
+ readonly passwordPolicy: PasswordPolicy;
74
+ readonly totpService: TotpService;
75
+ readonly oauthProviders: ReadonlyMap<string, OAuthProvider>;
76
+ readonly oauthAccounts: OAuthAccountRepository;
77
+ readonly logger: Logger;
78
+ }
79
+
80
+ /** Hash a token for storage (never store raw tokens) */
81
+ const hashToken = async (token: string): Promise<string> => {
82
+ const data = new TextEncoder().encode(token);
83
+ const buf = await crypto.subtle.digest("SHA-256", data);
84
+ return Array.from(new Uint8Array(buf))
85
+ .map((b) => b.toString(16).padStart(2, "0"))
86
+ .join("");
87
+ };
88
+
89
+ /** Email verification token TTL: 24 hours */
90
+ const EMAIL_VERIFY_TTL = 24 * 60 * 60 * 1000;
91
+ /** Password reset token TTL: 1 hour */
92
+ const PASSWORD_RESET_TTL = 60 * 60 * 1000;
93
+
94
+ export const createAuthService = (deps: Deps): AuthService => {
95
+ const {
96
+ userRepo,
97
+ passwordHasher,
98
+ tokenService,
99
+ tokenBlacklist,
100
+ accountLockout,
101
+ verificationTokens,
102
+ refreshTokenStore,
103
+ passwordHistory,
104
+ passwordPolicy,
105
+ totpService,
106
+ oauthProviders,
107
+ oauthAccounts,
108
+ logger,
109
+ } = deps;
110
+
111
+ /** Validate password against policy */
112
+ const validatePassword = async (
113
+ password: string,
114
+ userId?: UserId,
115
+ ): Promise<Result<void, AppError>> => {
116
+ const policyResult = passwordPolicy.validate(password);
117
+ if (!policyResult.valid) {
118
+ return err(
119
+ badRequest("Password does not meet policy requirements", {
120
+ violations: policyResult.violations,
121
+ }),
122
+ );
123
+ }
124
+ if (userId) {
125
+ const historyResult = await passwordPolicy.checkHistory(
126
+ userId,
127
+ password,
128
+ passwordHasher,
129
+ passwordHistory,
130
+ );
131
+ if (!historyResult.ok) return historyResult;
132
+ if (historyResult.value) {
133
+ return err(badRequest("Password was recently used. Choose a different password."));
134
+ }
135
+ }
136
+ return ok(undefined);
137
+ };
138
+
139
+ /** Create token pair and store refresh token family */
140
+ const createTokens = async (payload: TokenPayload): Promise<Result<TokenPair, AppError>> => {
141
+ const tokenResult = await tokenService.sign(payload);
142
+ if (!tokenResult.ok) return tokenResult;
143
+
144
+ // Store refresh token in family for rotation tracking
145
+ const refreshHash = await hashToken(tokenResult.value.refreshToken);
146
+ await refreshTokenStore.createFamily(payload.sub, refreshHash);
147
+
148
+ return ok(tokenResult.value);
149
+ };
150
+
151
+ return {
152
+ async register(dto: RegisterDto): Promise<Result<LoginResponse, AppError>> {
153
+ logger.info("Registering user", { email: dto.email });
154
+
155
+ // Validate password against policy
156
+ const policyCheck = await validatePassword(dto.password);
157
+ if (!policyCheck.ok) return policyCheck;
158
+
159
+ const hashResult = await passwordHasher.hash(dto.password);
160
+ if (!hashResult.ok) {
161
+ logger.error("Password hashing failed during registration", { email: dto.email });
162
+ return hashResult;
163
+ }
164
+
165
+ const createResult = await userRepo.create({
166
+ email: dto.email,
167
+ passwordHash: hashResult.value,
168
+ role: UserRole.USER,
169
+ });
170
+ if (!createResult.ok) return createResult;
171
+
172
+ const user = createResult.value;
173
+
174
+ // Store in password history
175
+ await passwordHistory.add(user.id, hashResult.value);
176
+
177
+ // Generate email verification token
178
+ const verifyTokenResult = await verificationTokens.create(
179
+ user.id,
180
+ VerificationTokenType.EMAIL_VERIFICATION,
181
+ EMAIL_VERIFY_TTL,
182
+ );
183
+
184
+ const tokenPayload: TokenPayload = { sub: user.id, role: user.role };
185
+ const tokenResult = await createTokens(tokenPayload);
186
+ if (!tokenResult.ok) return tokenResult;
187
+
188
+ logger.info("User registered", { userId: user.id });
189
+
190
+ const response: LoginResponse = {
191
+ ...tokenResult.value,
192
+ };
193
+
194
+ // In development, include verification token for easy testing
195
+ if (verifyTokenResult.ok) {
196
+ logger.info("Email verification token generated", {
197
+ userId: user.id,
198
+ token: verifyTokenResult.value,
199
+ });
200
+ }
201
+
202
+ return ok(response);
203
+ },
204
+
205
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: login flow with lockout, MFA, and password expiry
206
+ async login(dto: LoginDto): Promise<Result<LoginResponse, AppError>> {
207
+ logger.info("Login attempt", { email: dto.email });
208
+
209
+ // Check account lockout
210
+ const lockResult = await accountLockout.isLocked(dto.email);
211
+ if (lockResult.ok && lockResult.value !== null) {
212
+ const remainingMs = lockResult.value - Date.now();
213
+ const remainingMin = Math.ceil(remainingMs / 60_000);
214
+ logger.warn("Login blocked — account locked", { email: dto.email, remainingMin });
215
+ return err(forbidden(`Account locked. Try again in ${remainingMin} minute(s).`));
216
+ }
217
+
218
+ const findResult = await userRepo.findByEmail(dto.email);
219
+ if (!findResult.ok) {
220
+ await accountLockout.recordFailedAttempt(dto.email);
221
+ logger.warn("Login failed — user not found", { email: dto.email });
222
+ return err(unauthorized("Invalid credentials"));
223
+ }
224
+
225
+ const user = findResult.value;
226
+ const verifyResult = await passwordHasher.verify(dto.password, user.passwordHash);
227
+ if (!verifyResult.ok) return verifyResult;
228
+ if (!verifyResult.value) {
229
+ const locked = await accountLockout.recordFailedAttempt(dto.email);
230
+ if (locked.ok && locked.value) {
231
+ logger.warn("Account locked after failed attempts", { email: dto.email });
232
+ return err(forbidden("Account locked due to too many failed attempts. Try again later."));
233
+ }
234
+ logger.warn("Login failed — invalid password", { email: dto.email });
235
+ return err(unauthorized("Invalid credentials"));
236
+ }
237
+
238
+ // Successful password check — reset lockout counter
239
+ await accountLockout.resetAttempts(dto.email);
240
+
241
+ // Check password expiry
242
+ if (passwordPolicy.isExpired(user.passwordChangedAt)) {
243
+ logger.warn("Login blocked — password expired", { email: dto.email });
244
+ return err(forbidden("Password has expired. Please reset your password."));
245
+ }
246
+
247
+ // If MFA is enabled, return a partial MFA token instead of full auth
248
+ if (user.mfaEnabled) {
249
+ // Create a short-lived MFA challenge token (5 minutes)
250
+ const mfaPayload: TokenPayload = { sub: user.id, role: user.role };
251
+ const mfaTokenResult = await tokenService.sign(mfaPayload);
252
+ if (!mfaTokenResult.ok) return mfaTokenResult;
253
+
254
+ logger.info("MFA challenge issued", { userId: user.id });
255
+ return ok({
256
+ accessToken: "",
257
+ refreshToken: "",
258
+ mfaRequired: true,
259
+ mfaToken: mfaTokenResult.value.accessToken,
260
+ });
261
+ }
262
+
263
+ const tokenPayload: TokenPayload = { sub: user.id, role: user.role };
264
+ const tokenResult = await createTokens(tokenPayload);
265
+ if (!tokenResult.ok) return tokenResult;
266
+
267
+ logger.info("User logged in", { userId: user.id });
268
+ return ok(tokenResult.value);
269
+ },
270
+
271
+ async refresh(dto: RefreshDto): Promise<Result<TokenPair, AppError>> {
272
+ logger.debug("Token refresh attempt");
273
+
274
+ const tokenHash = await hashToken(dto.refreshToken);
275
+
276
+ // Check blacklist first
277
+ const blacklisted = await tokenBlacklist.isBlacklisted(tokenHash);
278
+ if (blacklisted.ok && blacklisted.value) {
279
+ logger.warn("Refresh attempt with blacklisted token");
280
+ return err(unauthorized("Token has been revoked"));
281
+ }
282
+
283
+ // Check refresh token family for reuse detection
284
+ const familyResult = await refreshTokenStore.findByTokenHash(tokenHash);
285
+ if (!familyResult.ok) return familyResult;
286
+
287
+ if (familyResult.value !== null) {
288
+ const family = familyResult.value;
289
+ if (family.revoked) {
290
+ // Reuse detected! Revoke ALL tokens for this user
291
+ logger.warn("Refresh token reuse detected — revoking all sessions", {
292
+ familyId: family.id,
293
+ userId: family.userId,
294
+ });
295
+ await refreshTokenStore.revokeAllForUser(family.userId);
296
+ return err(unauthorized("Token reuse detected. All sessions revoked."));
297
+ }
298
+ }
299
+
300
+ const result = await tokenService.refresh(dto.refreshToken);
301
+ if (!result.ok) {
302
+ logger.warn("Token refresh failed", { code: result.error.code });
303
+ return result;
304
+ }
305
+
306
+ // Rotate: blacklist old, store new in family
307
+ await tokenBlacklist.add(tokenHash, Date.now() + 7 * 24 * 60 * 60 * 1000);
308
+
309
+ if (familyResult.value !== null) {
310
+ const newHash = await hashToken(result.value.refreshToken);
311
+ await refreshTokenStore.rotate(familyResult.value.id, tokenHash, newHash);
312
+ }
313
+
314
+ logger.info("Token refreshed successfully");
315
+ return result;
316
+ },
317
+
318
+ async logout(dto: LogoutDto): Promise<Result<void, AppError>> {
319
+ logger.info("Logout attempt");
320
+
321
+ const [accessHash, refreshHash] = await Promise.all([
322
+ hashToken(dto.accessToken),
323
+ hashToken(dto.refreshToken),
324
+ ]);
325
+
326
+ const accessExpiry = Date.now() + 15 * 60 * 1000;
327
+ const refreshExpiry = Date.now() + 7 * 24 * 60 * 60 * 1000;
328
+
329
+ const [r1, r2] = await Promise.all([
330
+ tokenBlacklist.add(accessHash, accessExpiry),
331
+ tokenBlacklist.add(refreshHash, refreshExpiry),
332
+ ]);
333
+
334
+ if (!r1.ok) return r1;
335
+ if (!r2.ok) return r2;
336
+
337
+ // Revoke refresh token family
338
+ const familyResult = await refreshTokenStore.findByTokenHash(refreshHash);
339
+ if (familyResult.ok && familyResult.value) {
340
+ await refreshTokenStore.revokeFamily(familyResult.value.id);
341
+ }
342
+
343
+ logger.info("User logged out successfully");
344
+ return ok(undefined);
345
+ },
346
+
347
+ async verifyEmail(dto: VerifyEmailDto): Promise<Result<void, AppError>> {
348
+ logger.info("Email verification attempt");
349
+
350
+ const result = await verificationTokens.verify(
351
+ dto.token,
352
+ VerificationTokenType.EMAIL_VERIFICATION,
353
+ );
354
+ if (!result.ok) return result;
355
+
356
+ const userId = result.value;
357
+ const updateResult = await userRepo.update(userId, { emailVerified: true });
358
+ if (!updateResult.ok) return updateResult;
359
+
360
+ logger.info("Email verified", { userId });
361
+ return ok(undefined);
362
+ },
363
+
364
+ async resendVerification(userId: UserId): Promise<Result<{ token: string }, AppError>> {
365
+ logger.info("Resend email verification", { userId });
366
+
367
+ // Invalidate existing tokens
368
+ await verificationTokens.invalidateAll(userId, VerificationTokenType.EMAIL_VERIFICATION);
369
+
370
+ const tokenResult = await verificationTokens.create(
371
+ userId,
372
+ VerificationTokenType.EMAIL_VERIFICATION,
373
+ EMAIL_VERIFY_TTL,
374
+ );
375
+ if (!tokenResult.ok) return tokenResult;
376
+
377
+ logger.info("Verification token regenerated", { userId });
378
+ return ok({ token: tokenResult.value });
379
+ },
380
+
381
+ async forgotPassword(dto: ForgotPasswordDto): Promise<Result<{ token: string }, AppError>> {
382
+ logger.info("Password reset requested", { email: dto.email });
383
+
384
+ const findResult = await userRepo.findByEmail(dto.email);
385
+ if (!findResult.ok) {
386
+ // Don't reveal whether the email exists — always return success
387
+ logger.debug("Password reset for unknown email", { email: dto.email });
388
+ return ok({ token: "" });
389
+ }
390
+
391
+ const user = findResult.value;
392
+
393
+ // Invalidate existing reset tokens
394
+ await verificationTokens.invalidateAll(user.id, VerificationTokenType.PASSWORD_RESET);
395
+
396
+ const tokenResult = await verificationTokens.create(
397
+ user.id,
398
+ VerificationTokenType.PASSWORD_RESET,
399
+ PASSWORD_RESET_TTL,
400
+ );
401
+ if (!tokenResult.ok) return tokenResult;
402
+
403
+ logger.info("Password reset token generated", { userId: user.id });
404
+ return ok({ token: tokenResult.value });
405
+ },
406
+
407
+ async resetPassword(dto: ResetPasswordDto): Promise<Result<void, AppError>> {
408
+ logger.info("Password reset attempt");
409
+
410
+ const verifyResult = await verificationTokens.verify(
411
+ dto.token,
412
+ VerificationTokenType.PASSWORD_RESET,
413
+ );
414
+ if (!verifyResult.ok) return verifyResult;
415
+
416
+ const userId = verifyResult.value;
417
+
418
+ // Validate new password against policy
419
+ const policyCheck = await validatePassword(dto.password, userId);
420
+ if (!policyCheck.ok) return policyCheck;
421
+
422
+ const hashResult = await passwordHasher.hash(dto.password);
423
+ if (!hashResult.ok) return hashResult;
424
+
425
+ const updateResult = await userRepo.update(userId, {
426
+ passwordHash: hashResult.value,
427
+ passwordChangedAt: Date.now(),
428
+ });
429
+ if (!updateResult.ok) return updateResult;
430
+
431
+ // Store in password history
432
+ await passwordHistory.add(userId, hashResult.value);
433
+ await passwordHistory.prune(userId, passwordPolicy.config.historyCount);
434
+
435
+ // Revoke all refresh tokens (force re-login)
436
+ await refreshTokenStore.revokeAllForUser(userId);
437
+
438
+ logger.info("Password reset completed", { userId });
439
+ return ok(undefined);
440
+ },
441
+
442
+ async mfaSetup(userId: UserId, email: string): Promise<Result<MfaSetupResponse, AppError>> {
443
+ logger.info("MFA setup initiated", { userId });
444
+
445
+ const secret = totpService.generateSecret();
446
+ const uri = totpService.generateUri(secret, email, "onlyApi");
447
+
448
+ return ok({ secret, uri });
449
+ },
450
+
451
+ async mfaEnable(userId: UserId, dto: MfaEnableDto): Promise<Result<void, AppError>> {
452
+ logger.info("MFA enable attempt", { userId });
453
+
454
+ // Verify the code against the provided secret before enabling
455
+ const verifyResult = totpService.verify(dto.secret, dto.code);
456
+ if (!verifyResult.ok) return verifyResult;
457
+ if (!verifyResult.value) {
458
+ return err(unauthorized("Invalid MFA code"));
459
+ }
460
+
461
+ const updateResult = await userRepo.update(userId, {
462
+ mfaSecret: dto.secret,
463
+ mfaEnabled: true,
464
+ });
465
+ if (!updateResult.ok) return updateResult;
466
+
467
+ logger.info("MFA enabled", { userId });
468
+ return ok(undefined);
469
+ },
470
+
471
+ async mfaDisable(userId: UserId, dto: MfaDisableDto): Promise<Result<void, AppError>> {
472
+ logger.info("MFA disable attempt", { userId });
473
+
474
+ // Verify the code first
475
+ const findResult = await userRepo.findById(userId);
476
+ if (!findResult.ok) return findResult;
477
+
478
+ const user = findResult.value;
479
+ if (!user.mfaEnabled || !user.mfaSecret) {
480
+ return err(badRequest("MFA is not enabled"));
481
+ }
482
+
483
+ const verifyResult = totpService.verify(user.mfaSecret, dto.code);
484
+ if (!verifyResult.ok) return verifyResult;
485
+ if (!verifyResult.value) {
486
+ return err(unauthorized("Invalid MFA code"));
487
+ }
488
+
489
+ const updateResult = await userRepo.update(userId, {
490
+ mfaSecret: null,
491
+ mfaEnabled: false,
492
+ });
493
+ if (!updateResult.ok) return updateResult;
494
+
495
+ logger.info("MFA disabled", { userId });
496
+ return ok(undefined);
497
+ },
498
+
499
+ async mfaVerify(dto: MfaVerifyDto): Promise<Result<TokenPair, AppError>> {
500
+ logger.info("MFA verification attempt");
501
+
502
+ // Verify the MFA token to get the user
503
+ const tokenResult = await tokenService.verify(dto.mfaToken);
504
+ if (!tokenResult.ok) {
505
+ return err(unauthorized("Invalid or expired MFA token"));
506
+ }
507
+
508
+ const userId = tokenResult.value.sub;
509
+ const findResult = await userRepo.findById(userId);
510
+ if (!findResult.ok) return findResult;
511
+
512
+ const user = findResult.value;
513
+ if (!user.mfaEnabled || !user.mfaSecret) {
514
+ return err(badRequest("MFA is not enabled for this account"));
515
+ }
516
+
517
+ const verifyResult = totpService.verify(user.mfaSecret, dto.code);
518
+ if (!verifyResult.ok) return verifyResult;
519
+ if (!verifyResult.value) {
520
+ return err(unauthorized("Invalid MFA code"));
521
+ }
522
+
523
+ // MFA passed — issue full token pair
524
+ const payload: TokenPayload = { sub: user.id, role: user.role };
525
+ const fullTokenResult = await createTokens(payload);
526
+ if (!fullTokenResult.ok) return fullTokenResult;
527
+
528
+ logger.info("MFA verified — user logged in", { userId: user.id });
529
+ return ok(fullTokenResult.value);
530
+ },
531
+
532
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: OAuth login orchestrates provider exchange, user lookup/creation, and token issuance
533
+ async oauthLogin(
534
+ provider: string,
535
+ code: string,
536
+ redirectUri: string,
537
+ ): Promise<Result<LoginResponse, AppError>> {
538
+ logger.info("OAuth login attempt", { provider });
539
+
540
+ const oauthProvider = oauthProviders.get(provider);
541
+ if (!oauthProvider) {
542
+ return err(badRequest(`Unknown OAuth provider: ${provider}`));
543
+ }
544
+
545
+ // Exchange code for user info
546
+ const exchangeResult = await oauthProvider.exchangeCode(code, redirectUri);
547
+ if (!exchangeResult.ok) return exchangeResult;
548
+
549
+ const oauthUser = exchangeResult.value;
550
+
551
+ // Check if this OAuth identity is already linked
552
+ const existingOAuth = await oauthAccounts.findByProvider(provider, oauthUser.providerId);
553
+ if (!existingOAuth.ok) return existingOAuth;
554
+
555
+ let userId: UserId;
556
+
557
+ if (existingOAuth.value) {
558
+ // Existing linked account — just login
559
+ userId = existingOAuth.value.userId;
560
+ } else {
561
+ // New OAuth user — find by email or create
562
+ const findResult = await userRepo.findByEmail(oauthUser.email);
563
+ if (findResult.ok) {
564
+ // Link OAuth to existing user
565
+ userId = findResult.value.id;
566
+ } else {
567
+ // Create new user (no password — OAuth only)
568
+ const randomHash = await passwordHasher.hash(crypto.randomUUID());
569
+ if (!randomHash.ok) return randomHash;
570
+
571
+ const createResult = await userRepo.create({
572
+ email: oauthUser.email,
573
+ passwordHash: randomHash.value,
574
+ role: UserRole.USER,
575
+ });
576
+ if (!createResult.ok) return createResult;
577
+
578
+ userId = createResult.value.id;
579
+
580
+ // Mark email as verified (OAuth provider already verified it)
581
+ await userRepo.update(userId, { emailVerified: true });
582
+ }
583
+
584
+ // Link the OAuth account
585
+ const linkResult = await oauthAccounts.link(
586
+ userId,
587
+ provider,
588
+ oauthUser.providerId,
589
+ oauthUser.email,
590
+ );
591
+ if (!linkResult.ok) return linkResult;
592
+ }
593
+
594
+ // Find user for role info
595
+ const user = await userRepo.findById(userId);
596
+ if (!user.ok) return user;
597
+
598
+ const payload: TokenPayload = { sub: user.value.id, role: user.value.role };
599
+ const tokenResult = await createTokens(payload);
600
+ if (!tokenResult.ok) return tokenResult;
601
+
602
+ logger.info("OAuth login successful", { provider, userId });
603
+ return ok(tokenResult.value);
604
+ },
605
+ };
606
+ };
@@ -0,0 +1,97 @@
1
+ import type { CircuitBreaker } from "../../core/ports/circuit-breaker.js";
2
+ import { CircuitState } from "../../core/ports/circuit-breaker.js";
3
+ import type { Logger } from "../../core/ports/logger.js";
4
+
5
+ export interface HealthStatus {
6
+ readonly status: "ok" | "degraded" | "down";
7
+ readonly version: string;
8
+ readonly uptime: number;
9
+ readonly timestamp: string;
10
+ readonly checks: Record<string, ComponentHealth>;
11
+ }
12
+
13
+ export interface ComponentHealth {
14
+ readonly status: "ok" | "degraded" | "down";
15
+ readonly latencyMs?: number | undefined;
16
+ readonly details?: string | undefined;
17
+ }
18
+
19
+ export interface HealthService {
20
+ check(): Promise<HealthStatus>;
21
+ }
22
+
23
+ interface Deps {
24
+ readonly logger: Logger;
25
+ readonly version: string;
26
+ /** Optional circuit breakers to monitor for graceful degradation */
27
+ readonly circuitBreakers?: readonly CircuitBreaker[];
28
+ }
29
+
30
+ export const createHealthService = (deps: Deps): HealthService => {
31
+ const { logger, version } = deps;
32
+ const circuitBreakers = deps.circuitBreakers ?? [];
33
+
34
+ return {
35
+ async check(): Promise<HealthStatus> {
36
+ logger.debug("Running deep health check");
37
+ const start = performance.now();
38
+
39
+ const checks: Record<string, ComponentHealth> = {};
40
+
41
+ // Memory check
42
+ checks["memory"] = {
43
+ status: "ok",
44
+ latencyMs: Math.round((performance.now() - start) * 100) / 100,
45
+ };
46
+
47
+ // Circuit breaker checks — graceful degradation awareness
48
+ for (const cb of circuitBreakers) {
49
+ const cbState = cb.state;
50
+ if (cbState === CircuitState.OPEN) {
51
+ checks[`circuit:${cb.name}`] = {
52
+ status: "down",
53
+ details: `Circuit breaker OPEN — failures: ${cb.failureCount}`,
54
+ };
55
+ } else if (cbState === CircuitState.HALF_OPEN) {
56
+ checks[`circuit:${cb.name}`] = {
57
+ status: "degraded",
58
+ details: "Circuit breaker HALF_OPEN — recovery in progress",
59
+ };
60
+ } else {
61
+ checks[`circuit:${cb.name}`] = { status: "ok" };
62
+ }
63
+ }
64
+
65
+ // Determine overall status
66
+ const allChecks = Object.entries(checks);
67
+ const downComponents = allChecks.filter(([, c]) => c.status === "down");
68
+ const degradedComponents = allChecks.filter(([, c]) => c.status === "degraded");
69
+
70
+ let overallStatus: "ok" | "degraded" | "down" = "ok";
71
+ if (downComponents.length > 0) {
72
+ overallStatus = "degraded"; // downstream is down, but we're still serving
73
+ } else if (degradedComponents.length > 0) {
74
+ overallStatus = "degraded";
75
+ }
76
+
77
+ const overall: HealthStatus = {
78
+ status: overallStatus,
79
+ version,
80
+ uptime: process.uptime(),
81
+ timestamp: new Date().toISOString(),
82
+ checks,
83
+ };
84
+
85
+ if (overallStatus !== "ok") {
86
+ const failedNames = [...downComponents, ...degradedComponents].map(([name]) => name);
87
+ logger.warn("Health check degraded", { failedComponents: failedNames });
88
+ } else {
89
+ logger.debug("Health check passed", {
90
+ latencyMs: Math.round((performance.now() - start) * 100) / 100,
91
+ });
92
+ }
93
+
94
+ return overall;
95
+ },
96
+ };
97
+ };