@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,83 @@
|
|
|
1
|
+
import { type AppError, internal, unauthorized } from "../../core/errors/app-error.js";
|
|
2
|
+
import type { OAuthProvider, OAuthUserInfo } from "../../core/ports/oauth.js";
|
|
3
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Google OAuth2 provider adapter.
|
|
7
|
+
* Uses Google's OAuth 2.0 endpoints for authorization code flow.
|
|
8
|
+
* Zero external dependencies — uses native fetch.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface GoogleOAuthConfig {
|
|
12
|
+
readonly clientId: string;
|
|
13
|
+
readonly clientSecret: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const createGoogleOAuthProvider = (config: GoogleOAuthConfig): OAuthProvider => ({
|
|
17
|
+
name: "google",
|
|
18
|
+
|
|
19
|
+
getAuthorizationUrl(state: string, redirectUri: string): string {
|
|
20
|
+
const params = new URLSearchParams({
|
|
21
|
+
client_id: config.clientId,
|
|
22
|
+
redirect_uri: redirectUri,
|
|
23
|
+
response_type: "code",
|
|
24
|
+
scope: "openid email profile",
|
|
25
|
+
state,
|
|
26
|
+
access_type: "offline",
|
|
27
|
+
});
|
|
28
|
+
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async exchangeCode(code: string, redirectUri: string): Promise<Result<OAuthUserInfo, AppError>> {
|
|
32
|
+
try {
|
|
33
|
+
// Exchange authorization code for tokens
|
|
34
|
+
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
37
|
+
body: new URLSearchParams({
|
|
38
|
+
code,
|
|
39
|
+
client_id: config.clientId,
|
|
40
|
+
client_secret: config.clientSecret,
|
|
41
|
+
redirect_uri: redirectUri,
|
|
42
|
+
grant_type: "authorization_code",
|
|
43
|
+
}).toString(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!tokenRes.ok) {
|
|
47
|
+
return err(unauthorized("Failed to exchange authorization code with Google"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const tokenData = (await tokenRes.json()) as { access_token?: string };
|
|
51
|
+
if (!tokenData.access_token) {
|
|
52
|
+
return err(unauthorized("No access token in Google response"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fetch user info
|
|
56
|
+
const userRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
|
|
57
|
+
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!userRes.ok) {
|
|
61
|
+
return err(unauthorized("Failed to fetch Google user info"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const userData = (await userRes.json()) as {
|
|
65
|
+
id?: string;
|
|
66
|
+
email?: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (!userData.id || !userData.email) {
|
|
71
|
+
return err(unauthorized("Incomplete Google user info"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return ok({
|
|
75
|
+
providerId: userData.id,
|
|
76
|
+
email: userData.email,
|
|
77
|
+
...(userData.name ? { name: userData.name } : {}),
|
|
78
|
+
});
|
|
79
|
+
} catch (e: unknown) {
|
|
80
|
+
return err(internal("Google OAuth exchange failed", e));
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit breaker implementation — resilience pattern for external calls.
|
|
3
|
+
*
|
|
4
|
+
* State machine:
|
|
5
|
+
* CLOSED → (failures ≥ threshold) → OPEN
|
|
6
|
+
* OPEN → (timeout elapsed) → HALF_OPEN
|
|
7
|
+
* HALF_OPEN → (successes ≥ threshold) → CLOSED
|
|
8
|
+
* HALF_OPEN → (any failure) → OPEN
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type CircuitBreaker,
|
|
13
|
+
type CircuitBreakerOptions,
|
|
14
|
+
CircuitState,
|
|
15
|
+
} from "../../core/ports/circuit-breaker.js";
|
|
16
|
+
|
|
17
|
+
export class CircuitBreakerOpenError extends Error {
|
|
18
|
+
readonly circuitName: string;
|
|
19
|
+
|
|
20
|
+
constructor(name: string) {
|
|
21
|
+
super(`Circuit breaker "${name}" is OPEN — request short-circuited`);
|
|
22
|
+
this.circuitName = name;
|
|
23
|
+
this.name = "CircuitBreakerOpenError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const createCircuitBreaker = (options: CircuitBreakerOptions): CircuitBreaker => {
|
|
28
|
+
const { name, failureThreshold, resetTimeoutMs, halfOpenSuccessThreshold, onStateChange } =
|
|
29
|
+
options;
|
|
30
|
+
|
|
31
|
+
let state: CircuitState = CircuitState.CLOSED;
|
|
32
|
+
let failures = 0;
|
|
33
|
+
let successes = 0;
|
|
34
|
+
let lastFailureTime = 0;
|
|
35
|
+
let halfOpenInFlight = 0;
|
|
36
|
+
|
|
37
|
+
const transition = (to: CircuitState): void => {
|
|
38
|
+
if (state === to) return;
|
|
39
|
+
const from = state;
|
|
40
|
+
state = to;
|
|
41
|
+
onStateChange?.(name, from, to);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const recordSuccess = (): void => {
|
|
45
|
+
if (state === CircuitState.HALF_OPEN) {
|
|
46
|
+
successes++;
|
|
47
|
+
halfOpenInFlight--;
|
|
48
|
+
if (successes >= halfOpenSuccessThreshold) {
|
|
49
|
+
failures = 0;
|
|
50
|
+
successes = 0;
|
|
51
|
+
transition(CircuitState.CLOSED);
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
failures = 0;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const recordFailure = (): void => {
|
|
59
|
+
failures++;
|
|
60
|
+
lastFailureTime = Date.now();
|
|
61
|
+
|
|
62
|
+
if (state === CircuitState.HALF_OPEN) {
|
|
63
|
+
halfOpenInFlight--;
|
|
64
|
+
successes = 0;
|
|
65
|
+
transition(CircuitState.OPEN);
|
|
66
|
+
} else if (failures >= failureThreshold) {
|
|
67
|
+
transition(CircuitState.OPEN);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
get state() {
|
|
73
|
+
// Check if it's time to transition OPEN → HALF_OPEN
|
|
74
|
+
if (state === CircuitState.OPEN) {
|
|
75
|
+
const elapsed = Date.now() - lastFailureTime;
|
|
76
|
+
if (elapsed >= resetTimeoutMs) {
|
|
77
|
+
successes = 0;
|
|
78
|
+
halfOpenInFlight = 0;
|
|
79
|
+
transition(CircuitState.HALF_OPEN);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return state;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
get name() {
|
|
86
|
+
return name;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
get failureCount() {
|
|
90
|
+
return failures;
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
reset(): void {
|
|
94
|
+
failures = 0;
|
|
95
|
+
successes = 0;
|
|
96
|
+
halfOpenInFlight = 0;
|
|
97
|
+
transition(CircuitState.CLOSED);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
|
101
|
+
// Re-check state (may transition OPEN → HALF_OPEN)
|
|
102
|
+
if (state === CircuitState.OPEN) {
|
|
103
|
+
const elapsed = Date.now() - lastFailureTime;
|
|
104
|
+
if (elapsed >= resetTimeoutMs) {
|
|
105
|
+
successes = 0;
|
|
106
|
+
halfOpenInFlight = 0;
|
|
107
|
+
transition(CircuitState.HALF_OPEN);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (state === CircuitState.OPEN) {
|
|
112
|
+
throw new CircuitBreakerOpenError(name);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// In HALF_OPEN, limit concurrent probes
|
|
116
|
+
if (state === CircuitState.HALF_OPEN) {
|
|
117
|
+
if (halfOpenInFlight >= halfOpenSuccessThreshold) {
|
|
118
|
+
throw new CircuitBreakerOpenError(name);
|
|
119
|
+
}
|
|
120
|
+
halfOpenInFlight++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const result = await fn();
|
|
125
|
+
recordSuccess();
|
|
126
|
+
return result;
|
|
127
|
+
} catch (error: unknown) {
|
|
128
|
+
recordFailure();
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry with exponential backoff — configurable retry policy for transient failures.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Exponential backoff: delay = baseDelay × 2^attempt
|
|
6
|
+
* - Jitter: ±random factor to prevent thundering herd
|
|
7
|
+
* - Max delay cap
|
|
8
|
+
* - Retryable predicate for selective retry
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RetryOptions, RetryPolicy } from "../../core/ports/retry.js";
|
|
12
|
+
|
|
13
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
|
|
15
|
+
export const createRetryPolicy = (options: RetryOptions): RetryPolicy => {
|
|
16
|
+
const { maxRetries, baseDelayMs, maxDelayMs, jitter, retryable, onRetry } = options;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
|
20
|
+
let lastError: unknown;
|
|
21
|
+
|
|
22
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
23
|
+
try {
|
|
24
|
+
return await fn();
|
|
25
|
+
} catch (error: unknown) {
|
|
26
|
+
lastError = error;
|
|
27
|
+
|
|
28
|
+
// Don't retry on final attempt
|
|
29
|
+
if (attempt === maxRetries) break;
|
|
30
|
+
|
|
31
|
+
// Check if error is retryable
|
|
32
|
+
if (retryable && !retryable(error)) break;
|
|
33
|
+
|
|
34
|
+
// Calculate delay with exponential backoff + jitter
|
|
35
|
+
const exponentialDelay = baseDelayMs * 2 ** attempt;
|
|
36
|
+
const cappedDelay = Math.min(exponentialDelay, maxDelayMs);
|
|
37
|
+
const jitterRange = cappedDelay * jitter;
|
|
38
|
+
const jitterOffset = (Math.random() * 2 - 1) * jitterRange;
|
|
39
|
+
const finalDelay = Math.max(0, Math.round(cappedDelay + jitterOffset));
|
|
40
|
+
|
|
41
|
+
onRetry?.(attempt + 1, error, finalDelay);
|
|
42
|
+
|
|
43
|
+
await sleep(finalDelay);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
throw lastError;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AppError } from "../../core/errors/app-error.js";
|
|
2
|
+
import type { AccountLockout } from "../../core/ports/account-lockout.js";
|
|
3
|
+
import { type Result, ok } from "../../core/types/result.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory account lockout tracker.
|
|
7
|
+
* Locks account after maxAttempts failed logins for lockoutDurationMs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface LockoutEntry {
|
|
11
|
+
attempts: number;
|
|
12
|
+
lockedUntil: number | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface LockoutConfig {
|
|
16
|
+
readonly maxAttempts: number;
|
|
17
|
+
readonly lockoutDurationMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const createInMemoryAccountLockout = (
|
|
21
|
+
config: LockoutConfig = { maxAttempts: 5, lockoutDurationMs: 15 * 60 * 1000 },
|
|
22
|
+
): AccountLockout => {
|
|
23
|
+
const store = new Map<string, LockoutEntry>();
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
async recordFailedAttempt(email: string): Promise<Result<boolean, AppError>> {
|
|
27
|
+
let entry = store.get(email);
|
|
28
|
+
if (!entry) {
|
|
29
|
+
entry = { attempts: 0, lockedUntil: null };
|
|
30
|
+
store.set(email, entry);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// If currently locked and lock hasn't expired, just report still locked
|
|
34
|
+
if (entry.lockedUntil !== null && entry.lockedUntil > Date.now()) {
|
|
35
|
+
return ok(true);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// If lock expired, reset
|
|
39
|
+
if (entry.lockedUntil !== null && entry.lockedUntil <= Date.now()) {
|
|
40
|
+
entry.attempts = 0;
|
|
41
|
+
entry.lockedUntil = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
entry.attempts++;
|
|
45
|
+
|
|
46
|
+
if (entry.attempts >= config.maxAttempts) {
|
|
47
|
+
entry.lockedUntil = Date.now() + config.lockoutDurationMs;
|
|
48
|
+
return ok(true);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return ok(false);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async resetAttempts(email: string): Promise<Result<void, AppError>> {
|
|
55
|
+
store.delete(email);
|
|
56
|
+
return ok(undefined);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async isLocked(email: string): Promise<Result<number | null, AppError>> {
|
|
60
|
+
const entry = store.get(email);
|
|
61
|
+
if (!entry || entry.lockedUntil === null) return ok(null);
|
|
62
|
+
|
|
63
|
+
if (entry.lockedUntil <= Date.now()) {
|
|
64
|
+
// Lock expired — clean up
|
|
65
|
+
entry.attempts = 0;
|
|
66
|
+
entry.lockedUntil = null;
|
|
67
|
+
return ok(null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return ok(entry.lockedUntil);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createPasswordHasher } from "./password-hasher.js";
|
|
2
|
+
export { createTokenService } from "./token-service.js";
|
|
3
|
+
export { createInMemoryTokenBlacklist } from "./token-blacklist.js";
|
|
4
|
+
export { createInMemoryAccountLockout } from "./account-lockout.js";
|
|
5
|
+
export { createTotpService } from "./totp-service.js";
|
|
6
|
+
export { createPasswordPolicy } from "./password-policy.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type AppError, internal } from "../../core/errors/app-error.js";
|
|
2
|
+
import type { PasswordHasher } from "../../core/ports/password-hasher.js";
|
|
3
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bun-native password hasher using Argon2id via Bun.password.
|
|
7
|
+
* Zero external dependencies — Bun ships Argon2 in its runtime.
|
|
8
|
+
*/
|
|
9
|
+
export const createPasswordHasher = (): PasswordHasher => ({
|
|
10
|
+
async hash(plain: string): Promise<Result<string, AppError>> {
|
|
11
|
+
try {
|
|
12
|
+
const hashed = await Bun.password.hash(plain, {
|
|
13
|
+
algorithm: "argon2id",
|
|
14
|
+
memoryCost: 65536, // 64 MiB
|
|
15
|
+
timeCost: 3,
|
|
16
|
+
});
|
|
17
|
+
return ok(hashed);
|
|
18
|
+
} catch (e: unknown) {
|
|
19
|
+
return err(internal("Failed to hash password", e));
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
async verify(plain: string, hash: string): Promise<Result<boolean, AppError>> {
|
|
24
|
+
try {
|
|
25
|
+
const matches = await Bun.password.verify(plain, hash);
|
|
26
|
+
return ok(matches);
|
|
27
|
+
} catch (e: unknown) {
|
|
28
|
+
return err(internal("Failed to verify password", e));
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type AppError, internal } from "../../core/errors/app-error.js";
|
|
2
|
+
import type { PasswordHasher } from "../../core/ports/password-hasher.js";
|
|
3
|
+
import type { PasswordHistory } from "../../core/ports/password-history.js";
|
|
4
|
+
import type {
|
|
5
|
+
PasswordPolicy,
|
|
6
|
+
PasswordPolicyConfig,
|
|
7
|
+
PasswordPolicyResult,
|
|
8
|
+
} from "../../core/ports/password-policy.js";
|
|
9
|
+
import type { UserId } from "../../core/types/brand.js";
|
|
10
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Password policy validator.
|
|
14
|
+
* Checks complexity rules, history reuse, and expiry.
|
|
15
|
+
* Zero external dependencies.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SPECIAL_CHARS = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/;
|
|
19
|
+
|
|
20
|
+
export const createPasswordPolicy = (config: PasswordPolicyConfig): PasswordPolicy => ({
|
|
21
|
+
config,
|
|
22
|
+
|
|
23
|
+
validate(password: string): PasswordPolicyResult {
|
|
24
|
+
const violations: string[] = [];
|
|
25
|
+
|
|
26
|
+
if (password.length < config.minLength) {
|
|
27
|
+
violations.push(`Password must be at least ${config.minLength} characters`);
|
|
28
|
+
}
|
|
29
|
+
if (config.requireUppercase && !/[A-Z]/.test(password)) {
|
|
30
|
+
violations.push("Password must contain at least one uppercase letter");
|
|
31
|
+
}
|
|
32
|
+
if (config.requireLowercase && !/[a-z]/.test(password)) {
|
|
33
|
+
violations.push("Password must contain at least one lowercase letter");
|
|
34
|
+
}
|
|
35
|
+
if (config.requireDigit && !/\d/.test(password)) {
|
|
36
|
+
violations.push("Password must contain at least one digit");
|
|
37
|
+
}
|
|
38
|
+
if (config.requireSpecial && !SPECIAL_CHARS.test(password)) {
|
|
39
|
+
violations.push("Password must contain at least one special character");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { valid: violations.length === 0, violations };
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async checkHistory(
|
|
46
|
+
userId: UserId,
|
|
47
|
+
password: string,
|
|
48
|
+
passwordHasher: PasswordHasher,
|
|
49
|
+
history: PasswordHistory,
|
|
50
|
+
): Promise<Result<boolean, AppError>> {
|
|
51
|
+
if (config.historyCount <= 0) return ok(false);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const recentResult = await history.getRecent(userId, config.historyCount);
|
|
55
|
+
if (!recentResult.ok) return recentResult;
|
|
56
|
+
|
|
57
|
+
for (const oldHash of recentResult.value) {
|
|
58
|
+
const verifyResult = await passwordHasher.verify(password, oldHash);
|
|
59
|
+
if (!verifyResult.ok) return verifyResult;
|
|
60
|
+
if (verifyResult.value) {
|
|
61
|
+
return ok(true); // password was recently used
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return ok(false);
|
|
66
|
+
} catch (e: unknown) {
|
|
67
|
+
return err(internal("Failed to check password history", e));
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
isExpired(passwordChangedAt: number | null): boolean {
|
|
72
|
+
if (config.maxAgeDays <= 0) return false;
|
|
73
|
+
if (passwordChangedAt === null) return false;
|
|
74
|
+
const maxAgeMs = config.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
75
|
+
return Date.now() - passwordChangedAt > maxAgeMs;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AppError } from "../../core/errors/app-error.js";
|
|
2
|
+
import type { TokenBlacklist } from "../../core/ports/token-blacklist.js";
|
|
3
|
+
import { type Result, ok } from "../../core/types/result.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory token blacklist — suitable for single-process deployments.
|
|
7
|
+
* For multi-process / clustered deployments, swap for Redis-backed adapter.
|
|
8
|
+
*/
|
|
9
|
+
export const createInMemoryTokenBlacklist = (): TokenBlacklist => {
|
|
10
|
+
const store = new Map<string, number>(); // tokenHash → expiresAt
|
|
11
|
+
let lastPrune = 0;
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
async add(tokenHash: string, expiresAt: number): Promise<Result<void, AppError>> {
|
|
15
|
+
store.set(tokenHash, expiresAt);
|
|
16
|
+
return ok(undefined);
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
async isBlacklisted(tokenHash: string): Promise<Result<boolean, AppError>> {
|
|
20
|
+
const expiresAt = store.get(tokenHash);
|
|
21
|
+
if (expiresAt === undefined) return ok(false);
|
|
22
|
+
// Auto-remove expired entries on read
|
|
23
|
+
if (expiresAt <= Date.now()) {
|
|
24
|
+
store.delete(tokenHash);
|
|
25
|
+
return ok(false);
|
|
26
|
+
}
|
|
27
|
+
return ok(true);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async prune(): Promise<Result<number, AppError>> {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
if (now - lastPrune < 60_000) return ok(0); // at most once per minute
|
|
33
|
+
lastPrune = now;
|
|
34
|
+
|
|
35
|
+
let pruned = 0;
|
|
36
|
+
for (const [hash, expiresAt] of store) {
|
|
37
|
+
if (expiresAt <= now) {
|
|
38
|
+
store.delete(hash);
|
|
39
|
+
pruned++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return ok(pruned);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { UserRole } from "../../core/entities/user.entity.js";
|
|
2
|
+
import { type AppError, internal, unauthorized } from "../../core/errors/app-error.js";
|
|
3
|
+
import type { TokenPair, TokenPayload, TokenService } from "../../core/ports/token-service.js";
|
|
4
|
+
import type { UserId } from "../../core/types/brand.js";
|
|
5
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* JWT token service using Web Crypto API (Bun-native, zero deps).
|
|
9
|
+
* Signs with HMAC-SHA256.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface JwtConfig {
|
|
13
|
+
readonly secret: string;
|
|
14
|
+
readonly expiresIn: string;
|
|
15
|
+
readonly refreshExpiresIn: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parseDuration = (duration: string): number => {
|
|
19
|
+
const match = duration.match(/^(\d+)([smhd])$/);
|
|
20
|
+
if (!match) throw new Error(`Invalid duration format: ${duration}`);
|
|
21
|
+
const value = Number(match[1]);
|
|
22
|
+
const unit = match[2];
|
|
23
|
+
const multipliers: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
24
|
+
return value * (multipliers[unit ?? "m"] ?? 60);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const base64url = (buf: ArrayBuffer): string =>
|
|
28
|
+
btoa(String.fromCharCode(...new Uint8Array(buf)))
|
|
29
|
+
.replace(/\+/g, "-")
|
|
30
|
+
.replace(/\//g, "_")
|
|
31
|
+
.replace(/=+$/, "");
|
|
32
|
+
|
|
33
|
+
const base64urlEncode = (str: string): string =>
|
|
34
|
+
base64url(new TextEncoder().encode(str).buffer as ArrayBuffer);
|
|
35
|
+
|
|
36
|
+
const base64urlDecode = (str: string): string => {
|
|
37
|
+
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
|
38
|
+
return atob(padded);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const importKey = async (secret: string): Promise<CryptoKey> =>
|
|
42
|
+
crypto.subtle.importKey(
|
|
43
|
+
"raw",
|
|
44
|
+
new TextEncoder().encode(secret),
|
|
45
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
46
|
+
false,
|
|
47
|
+
["sign", "verify"],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const createJwt = async (
|
|
51
|
+
payload: Record<string, unknown>,
|
|
52
|
+
secret: string,
|
|
53
|
+
expiresInSeconds: number,
|
|
54
|
+
): Promise<string> => {
|
|
55
|
+
const header = base64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
56
|
+
const now = Math.floor(Date.now() / 1000);
|
|
57
|
+
const body = base64urlEncode(
|
|
58
|
+
JSON.stringify({ ...payload, iat: now, exp: now + expiresInSeconds }),
|
|
59
|
+
);
|
|
60
|
+
const data = `${header}.${body}`;
|
|
61
|
+
const key = await importKey(secret);
|
|
62
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
63
|
+
return `${data}.${base64url(sig)}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const verifyJwt = async (
|
|
67
|
+
token: string,
|
|
68
|
+
secret: string,
|
|
69
|
+
): Promise<Record<string, unknown> | null> => {
|
|
70
|
+
const parts = token.split(".");
|
|
71
|
+
if (parts.length !== 3) return null;
|
|
72
|
+
const [header, body, sig] = parts as [string, string, string];
|
|
73
|
+
const key = await importKey(secret);
|
|
74
|
+
const data = `${header}.${body}`;
|
|
75
|
+
|
|
76
|
+
// Reconstruct signature buffer
|
|
77
|
+
const sigStr = sig.replace(/-/g, "+").replace(/_/g, "/");
|
|
78
|
+
const sigBuf = Uint8Array.from(atob(sigStr), (c) => c.charCodeAt(0));
|
|
79
|
+
|
|
80
|
+
const valid = await crypto.subtle.verify("HMAC", key, sigBuf, new TextEncoder().encode(data));
|
|
81
|
+
if (!valid) return null;
|
|
82
|
+
|
|
83
|
+
const payload = JSON.parse(base64urlDecode(body)) as Record<string, unknown>;
|
|
84
|
+
const now = Math.floor(Date.now() / 1000);
|
|
85
|
+
if (typeof payload["exp"] === "number" && payload["exp"] < now) return null;
|
|
86
|
+
|
|
87
|
+
return payload;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const createTokenService = (config: JwtConfig): TokenService => {
|
|
91
|
+
const accessTtl = parseDuration(config.expiresIn);
|
|
92
|
+
const refreshTtl = parseDuration(config.refreshExpiresIn);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
async sign(payload: TokenPayload): Promise<Result<TokenPair, AppError>> {
|
|
96
|
+
try {
|
|
97
|
+
const claims = { sub: payload.sub, role: payload.role, type: "access" };
|
|
98
|
+
const refreshClaims = { sub: payload.sub, role: payload.role, type: "refresh" };
|
|
99
|
+
const [accessToken, refreshToken] = await Promise.all([
|
|
100
|
+
createJwt(claims, config.secret, accessTtl),
|
|
101
|
+
createJwt(refreshClaims, config.secret, refreshTtl),
|
|
102
|
+
]);
|
|
103
|
+
return ok({ accessToken, refreshToken });
|
|
104
|
+
} catch (e: unknown) {
|
|
105
|
+
return err(internal("Failed to sign token", e));
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async verify(token: string): Promise<Result<TokenPayload, AppError>> {
|
|
110
|
+
try {
|
|
111
|
+
const payload = await verifyJwt(token, config.secret);
|
|
112
|
+
if (!payload) return err(unauthorized("Invalid or expired token"));
|
|
113
|
+
return ok({
|
|
114
|
+
sub: payload["sub"] as UserId,
|
|
115
|
+
role: payload["role"] as UserRole,
|
|
116
|
+
});
|
|
117
|
+
} catch (e: unknown) {
|
|
118
|
+
return err(unauthorized("Token verification failed"));
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async refresh(refreshToken: string): Promise<Result<TokenPair, AppError>> {
|
|
123
|
+
try {
|
|
124
|
+
const payload = await verifyJwt(refreshToken, config.secret);
|
|
125
|
+
if (!payload || payload["type"] !== "refresh") {
|
|
126
|
+
return err(unauthorized("Invalid refresh token"));
|
|
127
|
+
}
|
|
128
|
+
const newClaims = {
|
|
129
|
+
sub: payload["sub"] as UserId,
|
|
130
|
+
role: payload["role"] as UserRole,
|
|
131
|
+
};
|
|
132
|
+
const refreshClaims = { ...newClaims, type: "refresh" };
|
|
133
|
+
const accessClaims = { ...newClaims, type: "access" };
|
|
134
|
+
const [accessTk, refreshTk] = await Promise.all([
|
|
135
|
+
createJwt(accessClaims, config.secret, accessTtl),
|
|
136
|
+
createJwt(refreshClaims, config.secret, refreshTtl),
|
|
137
|
+
]);
|
|
138
|
+
return ok({ accessToken: accessTk, refreshToken: refreshTk });
|
|
139
|
+
} catch (e: unknown) {
|
|
140
|
+
return err(internal("Failed to refresh token", e));
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
};
|