@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.
- package/CHANGELOG.md +201 -0
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/dist/cli.js +14 -0
- package/package.json +69 -0
- package/src/application/dtos/admin.dto.ts +25 -0
- package/src/application/dtos/auth.dto.ts +97 -0
- package/src/application/dtos/index.ts +40 -0
- package/src/application/index.ts +2 -0
- package/src/application/services/admin.service.ts +150 -0
- package/src/application/services/api-key.service.ts +65 -0
- package/src/application/services/auth.service.ts +606 -0
- package/src/application/services/health.service.ts +97 -0
- package/src/application/services/index.ts +10 -0
- package/src/application/services/user.service.ts +95 -0
- package/src/cli/commands/help.ts +86 -0
- package/src/cli/commands/init.ts +301 -0
- package/src/cli/commands/upgrade.ts +471 -0
- package/src/cli/index.ts +76 -0
- package/src/cli/ui.ts +189 -0
- package/src/cluster.ts +62 -0
- package/src/core/entities/index.ts +1 -0
- package/src/core/entities/user.entity.ts +24 -0
- package/src/core/errors/app-error.ts +81 -0
- package/src/core/errors/index.ts +15 -0
- package/src/core/index.ts +7 -0
- package/src/core/ports/account-lockout.ts +15 -0
- package/src/core/ports/alert-sink.ts +27 -0
- package/src/core/ports/api-key.ts +37 -0
- package/src/core/ports/audit-log.ts +46 -0
- package/src/core/ports/cache.ts +24 -0
- package/src/core/ports/circuit-breaker.ts +42 -0
- package/src/core/ports/event-bus.ts +78 -0
- package/src/core/ports/index.ts +62 -0
- package/src/core/ports/job-queue.ts +73 -0
- package/src/core/ports/logger.ts +21 -0
- package/src/core/ports/metrics.ts +49 -0
- package/src/core/ports/oauth.ts +55 -0
- package/src/core/ports/password-hasher.ts +10 -0
- package/src/core/ports/password-history.ts +23 -0
- package/src/core/ports/password-policy.ts +43 -0
- package/src/core/ports/refresh-token-store.ts +37 -0
- package/src/core/ports/retry.ts +23 -0
- package/src/core/ports/token-blacklist.ts +16 -0
- package/src/core/ports/token-service.ts +23 -0
- package/src/core/ports/totp-service.ts +16 -0
- package/src/core/ports/user.repository.ts +40 -0
- package/src/core/ports/verification-token.ts +41 -0
- package/src/core/ports/webhook.ts +58 -0
- package/src/core/types/brand.ts +19 -0
- package/src/core/types/index.ts +19 -0
- package/src/core/types/pagination.ts +28 -0
- package/src/core/types/result.ts +52 -0
- package/src/infrastructure/alerting/index.ts +1 -0
- package/src/infrastructure/alerting/webhook.ts +100 -0
- package/src/infrastructure/cache/in-memory-cache.ts +111 -0
- package/src/infrastructure/cache/index.ts +6 -0
- package/src/infrastructure/cache/redis-cache.ts +204 -0
- package/src/infrastructure/config/config.ts +185 -0
- package/src/infrastructure/config/index.ts +1 -0
- package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
- package/src/infrastructure/database/index.ts +37 -0
- package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
- package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
- package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
- package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
- package/src/infrastructure/database/migrations/runner.ts +120 -0
- package/src/infrastructure/database/mssql/index.ts +14 -0
- package/src/infrastructure/database/mssql/migrations.ts +299 -0
- package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
- package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
- package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
- package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
- package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
- package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
- package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
- package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
- package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
- package/src/infrastructure/database/postgres/index.ts +14 -0
- package/src/infrastructure/database/postgres/migrations.ts +235 -0
- package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
- package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
- package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
- package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
- package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
- package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
- package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
- package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
- package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
- package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
- package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
- package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
- package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
- package/src/infrastructure/database/sqlite-password-history.ts +54 -0
- package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
- package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
- package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
- package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
- package/src/infrastructure/events/event-bus.ts +105 -0
- package/src/infrastructure/events/event-factory.ts +31 -0
- package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
- package/src/infrastructure/events/index.ts +4 -0
- package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
- package/src/infrastructure/index.ts +58 -0
- package/src/infrastructure/jobs/index.ts +1 -0
- package/src/infrastructure/jobs/job-queue.ts +185 -0
- package/src/infrastructure/logging/index.ts +1 -0
- package/src/infrastructure/logging/logger.ts +63 -0
- package/src/infrastructure/metrics/index.ts +1 -0
- package/src/infrastructure/metrics/prometheus.ts +231 -0
- package/src/infrastructure/oauth/github.ts +116 -0
- package/src/infrastructure/oauth/google.ts +83 -0
- package/src/infrastructure/oauth/index.ts +2 -0
- package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
- package/src/infrastructure/resilience/index.ts +2 -0
- package/src/infrastructure/resilience/retry.ts +50 -0
- package/src/infrastructure/security/account-lockout.ts +73 -0
- package/src/infrastructure/security/index.ts +6 -0
- package/src/infrastructure/security/password-hasher.ts +31 -0
- package/src/infrastructure/security/password-policy.ts +77 -0
- package/src/infrastructure/security/token-blacklist.ts +45 -0
- package/src/infrastructure/security/token-service.ts +144 -0
- package/src/infrastructure/security/totp-service.ts +142 -0
- package/src/infrastructure/tracing/index.ts +7 -0
- package/src/infrastructure/tracing/trace-context.ts +93 -0
- package/src/main.ts +479 -0
- package/src/presentation/context.ts +26 -0
- package/src/presentation/handlers/admin.handler.ts +114 -0
- package/src/presentation/handlers/api-key.handler.ts +68 -0
- package/src/presentation/handlers/auth.handler.ts +218 -0
- package/src/presentation/handlers/health.handler.ts +27 -0
- package/src/presentation/handlers/index.ts +15 -0
- package/src/presentation/handlers/metrics.handler.ts +21 -0
- package/src/presentation/handlers/oauth.handler.ts +61 -0
- package/src/presentation/handlers/openapi.handler.ts +543 -0
- package/src/presentation/handlers/response.ts +29 -0
- package/src/presentation/handlers/sse.handler.ts +165 -0
- package/src/presentation/handlers/user.handler.ts +81 -0
- package/src/presentation/handlers/webhook.handler.ts +92 -0
- package/src/presentation/handlers/websocket.handler.ts +226 -0
- package/src/presentation/i18n/index.ts +254 -0
- package/src/presentation/index.ts +5 -0
- package/src/presentation/middleware/api-key.ts +18 -0
- package/src/presentation/middleware/auth.ts +39 -0
- package/src/presentation/middleware/cors.ts +41 -0
- package/src/presentation/middleware/index.ts +12 -0
- package/src/presentation/middleware/rate-limit.ts +65 -0
- package/src/presentation/middleware/security-headers.ts +18 -0
- package/src/presentation/middleware/validate.ts +16 -0
- package/src/presentation/middleware/versioning.ts +69 -0
- package/src/presentation/routes/index.ts +1 -0
- package/src/presentation/routes/router.ts +272 -0
- package/src/presentation/server.ts +381 -0
- package/src/shared/cli.ts +294 -0
- package/src/shared/container.ts +65 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/log-format.ts +148 -0
- package/src/shared/utils/id.ts +5 -0
- package/src/shared/utils/index.ts +2 -0
- 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
|
+
};
|