@lastshotlabs/bunshot 0.0.13 → 0.0.18
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/README.md +2816 -1747
- package/dist/adapters/memoryAuth.d.ts +7 -0
- package/dist/adapters/memoryAuth.js +177 -2
- package/dist/adapters/mongoAuth.js +94 -0
- package/dist/adapters/sqliteAuth.d.ts +9 -0
- package/dist/adapters/sqliteAuth.js +190 -2
- package/dist/app.d.ts +120 -2
- package/dist/app.js +104 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +24 -8
- package/dist/index.js +15 -5
- package/dist/lib/appConfig.d.ts +81 -0
- package/dist/lib/appConfig.js +30 -0
- package/dist/lib/authAdapter.d.ts +54 -0
- package/dist/lib/authRateLimit.d.ts +2 -0
- package/dist/lib/authRateLimit.js +4 -0
- package/dist/lib/clientIp.d.ts +14 -0
- package/dist/lib/clientIp.js +52 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +2 -0
- package/dist/lib/createDtoMapper.d.ts +33 -0
- package/dist/lib/createDtoMapper.js +69 -0
- package/dist/lib/crypto.d.ts +11 -0
- package/dist/lib/crypto.js +22 -0
- package/dist/lib/emailVerification.d.ts +4 -0
- package/dist/lib/emailVerification.js +20 -12
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +19 -6
- package/dist/lib/mfaChallenge.d.ts +42 -0
- package/dist/lib/mfaChallenge.js +293 -0
- package/dist/lib/oauth.d.ts +14 -1
- package/dist/lib/oauth.js +19 -1
- package/dist/lib/oauthCode.d.ts +15 -0
- package/dist/lib/oauthCode.js +90 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- package/dist/lib/resetPassword.js +12 -16
- package/dist/lib/roles.d.ts +4 -0
- package/dist/lib/roles.js +27 -0
- package/dist/lib/session.d.ts +12 -0
- package/dist/lib/session.js +165 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/ws.js +5 -1
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/bearerAuth.js +4 -3
- package/dist/middleware/botProtection.js +2 -2
- package/dist/middleware/cacheResponse.d.ts +1 -0
- package/dist/middleware/cacheResponse.js +18 -3
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.js +22 -8
- package/dist/middleware/csrf.d.ts +18 -0
- package/dist/middleware/csrf.js +115 -0
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +7 -5
- package/dist/middleware/requireRole.d.ts +14 -3
- package/dist/middleware/requireRole.js +46 -6
- package/dist/middleware/tenant.d.ts +5 -0
- package/dist/middleware/tenant.js +116 -0
- package/dist/models/AuthUser.d.ts +17 -0
- package/dist/models/AuthUser.js +17 -0
- package/dist/models/TenantRole.d.ts +15 -0
- package/dist/models/TenantRole.js +23 -0
- package/dist/routes/auth.d.ts +5 -3
- package/dist/routes/auth.js +173 -30
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +5 -0
- package/dist/routes/mfa.js +616 -0
- package/dist/routes/oauth.js +378 -23
- package/dist/schemas/auth.d.ts +2 -0
- package/dist/schemas/auth.js +22 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +19 -3
- package/dist/services/auth.d.ts +18 -5
- package/dist/services/auth.js +112 -18
- package/dist/services/mfa.d.ts +84 -0
- package/dist/services/mfa.js +543 -0
- package/dist/ws/index.js +3 -2
- package/docs/sections/adding-middleware/full.md +35 -0
- package/docs/sections/adding-models/full.md +125 -0
- package/docs/sections/adding-models/overview.md +13 -0
- package/docs/sections/adding-routes/full.md +182 -0
- package/docs/sections/adding-routes/overview.md +23 -0
- package/docs/sections/auth-flow/full.md +634 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +155 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +117 -0
- package/docs/sections/configuration-example/overview.md +30 -0
- package/docs/sections/documentation/full.md +171 -0
- package/docs/sections/environment-variables/full.md +55 -0
- package/docs/sections/exports/full.md +92 -0
- package/docs/sections/extending-context/full.md +59 -0
- package/docs/sections/header.md +3 -0
- package/docs/sections/installation/full.md +6 -0
- package/docs/sections/jobs/full.md +140 -0
- package/docs/sections/jobs/overview.md +15 -0
- package/docs/sections/mongodb-connections/full.md +45 -0
- package/docs/sections/mongodb-connections/overview.md +7 -0
- package/docs/sections/multi-tenancy/full.md +66 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +189 -0
- package/docs/sections/oauth/overview.md +16 -0
- package/docs/sections/package-development/full.md +7 -0
- package/docs/sections/peer-dependencies/full.md +47 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +117 -0
- package/docs/sections/response-caching/overview.md +13 -0
- package/docs/sections/roles/full.md +136 -0
- package/docs/sections/roles/overview.md +12 -0
- package/docs/sections/running-without-redis/full.md +16 -0
- package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
- package/docs/sections/stack/full.md +10 -0
- package/docs/sections/websocket/full.md +101 -0
- package/docs/sections/websocket/overview.md +5 -0
- package/docs/sections/websocket-rooms/full.md +97 -0
- package/docs/sections/websocket-rooms/overview.md +5 -0
- package/package.json +30 -9
|
@@ -3,6 +3,20 @@ export interface OAuthProfile {
|
|
|
3
3
|
name?: string;
|
|
4
4
|
avatarUrl?: string;
|
|
5
5
|
}
|
|
6
|
+
export interface WebAuthnCredential {
|
|
7
|
+
/** Base64url-encoded credential ID. */
|
|
8
|
+
credentialId: string;
|
|
9
|
+
/** Base64url-encoded public key. */
|
|
10
|
+
publicKey: string;
|
|
11
|
+
/** Counter for signature verification (replay protection). */
|
|
12
|
+
signCount: number;
|
|
13
|
+
/** Transport hints from the authenticator (usb, ble, nfc, internal). */
|
|
14
|
+
transports?: string[];
|
|
15
|
+
/** User-assigned name for the key (e.g. "YubiKey 5"). */
|
|
16
|
+
name?: string;
|
|
17
|
+
/** When the credential was registered (epoch ms). */
|
|
18
|
+
createdAt: number;
|
|
19
|
+
}
|
|
6
20
|
export interface AuthAdapter {
|
|
7
21
|
findByEmail(email: string): Promise<{
|
|
8
22
|
id: string;
|
|
@@ -48,6 +62,46 @@ export interface AuthAdapter {
|
|
|
48
62
|
setEmailVerified?(userId: string, verified: boolean): Promise<void>;
|
|
49
63
|
/** Optional. Return whether a user's email address has been verified. */
|
|
50
64
|
getEmailVerified?(userId: string): Promise<boolean>;
|
|
65
|
+
/** Optional. Permanently delete a user account. Used by DELETE /auth/me. */
|
|
66
|
+
deleteUser?(userId: string): Promise<void>;
|
|
67
|
+
/** Optional. Check whether a user has a password set (credential account vs OAuth-only). */
|
|
68
|
+
hasPassword?(userId: string): Promise<boolean>;
|
|
69
|
+
/** Optional. Store the TOTP secret for MFA setup (encrypted or plaintext, adapter decides). */
|
|
70
|
+
setMfaSecret?(userId: string, secret: string | null): Promise<void>;
|
|
71
|
+
/** Optional. Retrieve the TOTP secret for MFA verification. */
|
|
72
|
+
getMfaSecret?(userId: string): Promise<string | null>;
|
|
73
|
+
/** Optional. Check whether MFA is enabled for a user. */
|
|
74
|
+
isMfaEnabled?(userId: string): Promise<boolean>;
|
|
75
|
+
/** Optional. Enable or disable MFA for a user. */
|
|
76
|
+
setMfaEnabled?(userId: string, enabled: boolean): Promise<void>;
|
|
77
|
+
/** Optional. Store hashed recovery codes for MFA. */
|
|
78
|
+
setRecoveryCodes?(userId: string, codes: string[]): Promise<void>;
|
|
79
|
+
/** Optional. Retrieve hashed recovery codes for MFA. */
|
|
80
|
+
getRecoveryCodes?(userId: string): Promise<string[]>;
|
|
81
|
+
/** Optional. Remove a single recovery code after use. */
|
|
82
|
+
removeRecoveryCode?(userId: string, code: string): Promise<void>;
|
|
83
|
+
/** Optional. Get the MFA methods enabled for a user (e.g., ["totp"], ["emailOtp"], ["totp", "emailOtp"]). */
|
|
84
|
+
getMfaMethods?(userId: string): Promise<string[]>;
|
|
85
|
+
/** Optional. Set the MFA methods enabled for a user. */
|
|
86
|
+
setMfaMethods?(userId: string, methods: string[]): Promise<void>;
|
|
87
|
+
/** Optional. Get roles for a user within a specific tenant. */
|
|
88
|
+
getTenantRoles?(userId: string, tenantId: string): Promise<string[]>;
|
|
89
|
+
/** Optional. Set roles for a user within a specific tenant (replaces existing). */
|
|
90
|
+
setTenantRoles?(userId: string, tenantId: string, roles: string[]): Promise<void>;
|
|
91
|
+
/** Optional. Add a single role to a user within a specific tenant. */
|
|
92
|
+
addTenantRole?(userId: string, tenantId: string, role: string): Promise<void>;
|
|
93
|
+
/** Optional. Remove a single role from a user within a specific tenant. */
|
|
94
|
+
removeTenantRole?(userId: string, tenantId: string, role: string): Promise<void>;
|
|
95
|
+
/** Optional. Get all WebAuthn credentials for a user. */
|
|
96
|
+
getWebAuthnCredentials?(userId: string): Promise<WebAuthnCredential[]>;
|
|
97
|
+
/** Optional. Add a WebAuthn credential for a user. */
|
|
98
|
+
addWebAuthnCredential?(userId: string, credential: WebAuthnCredential): Promise<void>;
|
|
99
|
+
/** Optional. Remove a WebAuthn credential by its credential ID. */
|
|
100
|
+
removeWebAuthnCredential?(userId: string, credentialId: string): Promise<void>;
|
|
101
|
+
/** Optional. Update the sign count for a WebAuthn credential after successful authentication. */
|
|
102
|
+
updateWebAuthnCredentialSignCount?(userId: string, credentialId: string, signCount: number): Promise<void>;
|
|
103
|
+
/** Optional. Find the user who owns a WebAuthn credential. Returns userId or null. Used for cross-user uniqueness checks. */
|
|
104
|
+
findUserByWebAuthnCredentialId?(credentialId: string): Promise<string | null>;
|
|
51
105
|
}
|
|
52
106
|
export declare const setAuthAdapter: (adapter: AuthAdapter) => void;
|
|
53
107
|
export declare const getAuthAdapter: () => AuthAdapter;
|
|
@@ -9,3 +9,5 @@ export declare const isLimited: (key: string, opts: LimitOpts) => Promise<boolea
|
|
|
9
9
|
export declare const trackAttempt: (key: string, opts: LimitOpts) => Promise<boolean>;
|
|
10
10
|
/** Resets a rate limit key. Use on login success or for admin unlock. */
|
|
11
11
|
export declare const bustAuthLimit: (key: string) => Promise<void>;
|
|
12
|
+
/** Clears all in-memory rate limit entries. Called by clearMemoryStore(). */
|
|
13
|
+
export declare const clearMemoryRateLimitStore: () => void;
|
|
@@ -75,3 +75,7 @@ export const trackAttempt = async (key, opts) => {
|
|
|
75
75
|
export const bustAuthLimit = async (key) => {
|
|
76
76
|
await _store.delete(key);
|
|
77
77
|
};
|
|
78
|
+
/** Clears all in-memory rate limit entries. Called by clearMemoryStore(). */
|
|
79
|
+
export const clearMemoryRateLimitStore = () => {
|
|
80
|
+
_memoryStore.clear();
|
|
81
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
export declare const setTrustProxy: (value: false | number) => void;
|
|
3
|
+
/**
|
|
4
|
+
* Returns the client IP address, respecting the `trustProxy` setting.
|
|
5
|
+
*
|
|
6
|
+
* - When `trustProxy` is `false`: returns the socket-level IP (via Bun's
|
|
7
|
+
* `server.requestIP()`), ignoring `X-Forwarded-For` entirely.
|
|
8
|
+
* - When `trustProxy` is a number N: takes the Nth-from-right entry in the
|
|
9
|
+
* `X-Forwarded-For` chain (skipping N trusted proxy hops), falling back to
|
|
10
|
+
* the socket-level IP.
|
|
11
|
+
*
|
|
12
|
+
* Returns `"unknown"` if no IP can be determined.
|
|
13
|
+
*/
|
|
14
|
+
export declare const getClientIp: (c: Context<any>) => string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Trust-proxy configuration (set once at startup via setTrustProxy)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
let _trustProxy = false;
|
|
5
|
+
export const setTrustProxy = (value) => {
|
|
6
|
+
_trustProxy = value;
|
|
7
|
+
};
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Centralized client IP extraction
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/**
|
|
12
|
+
* Returns the client IP address, respecting the `trustProxy` setting.
|
|
13
|
+
*
|
|
14
|
+
* - When `trustProxy` is `false`: returns the socket-level IP (via Bun's
|
|
15
|
+
* `server.requestIP()`), ignoring `X-Forwarded-For` entirely.
|
|
16
|
+
* - When `trustProxy` is a number N: takes the Nth-from-right entry in the
|
|
17
|
+
* `X-Forwarded-For` chain (skipping N trusted proxy hops), falling back to
|
|
18
|
+
* the socket-level IP.
|
|
19
|
+
*
|
|
20
|
+
* Returns `"unknown"` if no IP can be determined.
|
|
21
|
+
*/
|
|
22
|
+
export const getClientIp = (c) => {
|
|
23
|
+
// Socket-level IP via Bun's server (passed as c.env by Bun.serve)
|
|
24
|
+
let socketIp;
|
|
25
|
+
try {
|
|
26
|
+
const server = c.env;
|
|
27
|
+
if (server?.requestIP) {
|
|
28
|
+
const info = server.requestIP(c.req.raw);
|
|
29
|
+
if (info)
|
|
30
|
+
socketIp = info.address;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch { /* not running under Bun.serve — e.g. test environment */ }
|
|
34
|
+
if (_trustProxy === false) {
|
|
35
|
+
return socketIp ?? "unknown";
|
|
36
|
+
}
|
|
37
|
+
// Trust N proxy hops: take the Nth-from-right in XFF
|
|
38
|
+
const xff = c.req.header("x-forwarded-for");
|
|
39
|
+
if (xff) {
|
|
40
|
+
const ips = xff.split(",").map(s => s.trim()).filter(Boolean);
|
|
41
|
+
// Index from the right: trustProxy=1 means 1 proxy, so take ips[length - 2]
|
|
42
|
+
const idx = ips.length - _trustProxy - 1;
|
|
43
|
+
if (idx >= 0 && ips[idx]) {
|
|
44
|
+
return ips[idx];
|
|
45
|
+
}
|
|
46
|
+
// If fewer entries than expected, fall back to leftmost (or socket IP)
|
|
47
|
+
if (ips.length > 0)
|
|
48
|
+
return ips[0];
|
|
49
|
+
}
|
|
50
|
+
// Fallback: X-Real-IP header, then socket IP
|
|
51
|
+
return c.req.header("x-real-ip") ?? socketIp ?? "unknown";
|
|
52
|
+
};
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
export declare const COOKIE_TOKEN = "token";
|
|
2
2
|
export declare const HEADER_USER_TOKEN = "x-user-token";
|
|
3
|
+
export declare const COOKIE_REFRESH_TOKEN = "refresh_token";
|
|
4
|
+
export declare const HEADER_REFRESH_TOKEN = "x-refresh-token";
|
|
5
|
+
export declare const COOKIE_CSRF_TOKEN = "csrf_token";
|
|
6
|
+
export declare const HEADER_CSRF_TOKEN = "x-csrf-token";
|
package/dist/lib/constants.js
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
export const COOKIE_TOKEN = "token";
|
|
2
2
|
export const HEADER_USER_TOKEN = "x-user-token";
|
|
3
|
+
export const COOKIE_REFRESH_TOKEN = "refresh_token";
|
|
4
|
+
export const HEADER_REFRESH_TOKEN = "x-refresh-token";
|
|
5
|
+
export const COOKIE_CSRF_TOKEN = "csrf_token";
|
|
6
|
+
export const HEADER_CSRF_TOKEN = "x-csrf-token";
|
package/dist/lib/context.d.ts
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
type ZodSchema = any;
|
|
2
|
+
export type DtoMapperConfig = {
|
|
3
|
+
/** DB field name → API field name for ObjectId refs (e.g., { account: "accountId" }) */
|
|
4
|
+
refs?: Record<string, string>;
|
|
5
|
+
/** API field names that are Date in DB, string in DTO */
|
|
6
|
+
dates?: string[];
|
|
7
|
+
/** Subdocument array fields mapped with a sub-mapper: { items: itemMapper } */
|
|
8
|
+
subdocs?: Record<string, (item: any) => any>;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Create a toDto mapper function from a Zod schema.
|
|
12
|
+
*
|
|
13
|
+
* The Zod schema defines which fields exist in the DTO. The config declares
|
|
14
|
+
* how to transform DB-specific types (ObjectId refs, Dates, subdocuments).
|
|
15
|
+
*
|
|
16
|
+
* Handles automatically:
|
|
17
|
+
* - `_id` → `id` (toString)
|
|
18
|
+
* - ObjectId refs → string (toString), with field renaming via `refs`
|
|
19
|
+
* - Date fields → ISO string via `dates`
|
|
20
|
+
* - Subdocument arrays via `subdocs`
|
|
21
|
+
* - Nullable/optional fields → `null` coercion (from `undefined`)
|
|
22
|
+
* - All other fields → passthrough
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const toDto = createDtoMapper<LedgerItemDto>(LedgerItemSchema, {
|
|
27
|
+
* refs: { account: "accountId" },
|
|
28
|
+
* dates: ["date"],
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function createDtoMapper<TDto>(zodSchema: ZodSchema, config?: DtoMapperConfig): (doc: any) => TDto;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Check if a Zod type is nullable or optional */
|
|
2
|
+
function isNullable(zodType) {
|
|
3
|
+
const defType = zodType?._zod?.def?.type;
|
|
4
|
+
if (defType === "nullable")
|
|
5
|
+
return true;
|
|
6
|
+
if (defType === "optional")
|
|
7
|
+
return true;
|
|
8
|
+
if (defType === "default")
|
|
9
|
+
return isNullable(zodType._zod.def.innerType);
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Create a toDto mapper function from a Zod schema.
|
|
14
|
+
*
|
|
15
|
+
* The Zod schema defines which fields exist in the DTO. The config declares
|
|
16
|
+
* how to transform DB-specific types (ObjectId refs, Dates, subdocuments).
|
|
17
|
+
*
|
|
18
|
+
* Handles automatically:
|
|
19
|
+
* - `_id` → `id` (toString)
|
|
20
|
+
* - ObjectId refs → string (toString), with field renaming via `refs`
|
|
21
|
+
* - Date fields → ISO string via `dates`
|
|
22
|
+
* - Subdocument arrays via `subdocs`
|
|
23
|
+
* - Nullable/optional fields → `null` coercion (from `undefined`)
|
|
24
|
+
* - All other fields → passthrough
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const toDto = createDtoMapper<LedgerItemDto>(LedgerItemSchema, {
|
|
29
|
+
* refs: { account: "accountId" },
|
|
30
|
+
* dates: ["date"],
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function createDtoMapper(zodSchema, config = {}) {
|
|
35
|
+
const apiFields = Object.keys(zodSchema.shape);
|
|
36
|
+
const shape = zodSchema.shape;
|
|
37
|
+
// Build reverse lookup: apiField → dbField for refs
|
|
38
|
+
const refByApiField = new Map();
|
|
39
|
+
if (config.refs) {
|
|
40
|
+
for (const [dbField, apiField] of Object.entries(config.refs)) {
|
|
41
|
+
refByApiField.set(apiField, dbField);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const dateSet = new Set(config.dates ?? []);
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
return (doc) => {
|
|
47
|
+
const dto = {};
|
|
48
|
+
for (const field of apiFields) {
|
|
49
|
+
if (field === "id") {
|
|
50
|
+
dto.id = doc._id.toString();
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (refByApiField.has(field)) {
|
|
54
|
+
dto[field] = doc[refByApiField.get(field)].toString();
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (dateSet.has(field)) {
|
|
58
|
+
dto[field] = doc[field].toISOString();
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (config.subdocs?.[field]) {
|
|
62
|
+
dto[field] = (doc[field] ?? []).map(config.subdocs[field]);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
dto[field] = isNullable(shape[field]) ? (doc[field] ?? null) : doc[field];
|
|
66
|
+
}
|
|
67
|
+
return dto;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time string comparison to prevent timing attacks.
|
|
3
|
+
* Returns true if both strings are equal, false otherwise.
|
|
4
|
+
* Always compares the full length even on mismatch.
|
|
5
|
+
*/
|
|
6
|
+
export declare function timingSafeEqual(a: string, b: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* SHA-256 hash a string and return the hex digest.
|
|
9
|
+
* Centralized to avoid duplicate implementations across modules.
|
|
10
|
+
*/
|
|
11
|
+
export declare function sha256(input: string): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual as nodeTimingSafeEqual } from "crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Constant-time string comparison to prevent timing attacks.
|
|
4
|
+
* Returns true if both strings are equal, false otherwise.
|
|
5
|
+
* Always compares the full length even on mismatch.
|
|
6
|
+
*/
|
|
7
|
+
export function timingSafeEqual(a, b) {
|
|
8
|
+
if (a.length !== b.length) {
|
|
9
|
+
// Compare against self to burn the same time, then return false
|
|
10
|
+
const buf = Buffer.from(a, "utf-8");
|
|
11
|
+
nodeTimingSafeEqual(buf, buf);
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return nodeTimingSafeEqual(Buffer.from(a, "utf-8"), Buffer.from(b, "utf-8"));
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* SHA-256 hash a string and return the hex digest.
|
|
18
|
+
* Centralized to avoid duplicate implementations across modules.
|
|
19
|
+
*/
|
|
20
|
+
export function sha256(input) {
|
|
21
|
+
return createHash("sha256").update(input).digest("hex");
|
|
22
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
type VerificationStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
2
2
|
export declare const setEmailVerificationStore: (store: VerificationStore) => void;
|
|
3
|
+
/** Create a verification token. Returns the raw token (for the email link).
|
|
4
|
+
* Only the SHA-256 hash is persisted in the store. */
|
|
3
5
|
export declare const createVerificationToken: (userId: string, email: string) => Promise<string>;
|
|
6
|
+
/** Look up a verification token by its raw value. Hashes before lookup. */
|
|
4
7
|
export declare const getVerificationToken: (token: string) => Promise<{
|
|
5
8
|
userId: string;
|
|
6
9
|
email: string;
|
|
7
10
|
} | null>;
|
|
11
|
+
/** Delete a verification token by its raw value. Hashes before lookup. */
|
|
8
12
|
export declare const deleteVerificationToken: (token: string) => Promise<void>;
|
|
9
13
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getRedis } from "./redis";
|
|
2
2
|
import { appConnection, mongoose } from "./mongo";
|
|
3
3
|
import { getAppName, getTokenExpiry } from "./appConfig";
|
|
4
|
+
import { sha256 } from "./crypto";
|
|
4
5
|
import { sqliteCreateVerificationToken, sqliteGetVerificationToken, sqliteDeleteVerificationToken, } from "../adapters/sqliteAuth";
|
|
5
6
|
import { memoryCreateVerificationToken, memoryGetVerificationToken, memoryDeleteVerificationToken, } from "../adapters/memoryAuth";
|
|
6
7
|
function getVerificationModel() {
|
|
@@ -20,59 +21,66 @@ export const setEmailVerificationStore = (store) => { _store = store; };
|
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
21
22
|
// Public API
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
24
|
+
/** Create a verification token. Returns the raw token (for the email link).
|
|
25
|
+
* Only the SHA-256 hash is persisted in the store. */
|
|
23
26
|
export const createVerificationToken = async (userId, email) => {
|
|
24
27
|
const token = crypto.randomUUID();
|
|
28
|
+
const hash = sha256(token);
|
|
25
29
|
const ttl = getTokenExpiry();
|
|
26
30
|
if (_store === "memory") {
|
|
27
|
-
memoryCreateVerificationToken(
|
|
31
|
+
memoryCreateVerificationToken(hash, userId, email, ttl);
|
|
28
32
|
return token;
|
|
29
33
|
}
|
|
30
34
|
if (_store === "sqlite") {
|
|
31
|
-
sqliteCreateVerificationToken(
|
|
35
|
+
sqliteCreateVerificationToken(hash, userId, email, ttl);
|
|
32
36
|
return token;
|
|
33
37
|
}
|
|
34
38
|
if (_store === "mongo") {
|
|
35
39
|
await getVerificationModel().create({
|
|
36
|
-
token,
|
|
40
|
+
token: hash,
|
|
37
41
|
userId,
|
|
38
42
|
email,
|
|
39
43
|
expiresAt: new Date(Date.now() + ttl * 1000),
|
|
40
44
|
});
|
|
41
45
|
return token;
|
|
42
46
|
}
|
|
43
|
-
await getRedis().set(`verify:${getAppName()}:${
|
|
47
|
+
await getRedis().set(`verify:${getAppName()}:${hash}`, JSON.stringify({ userId, email }), "EX", ttl);
|
|
44
48
|
return token;
|
|
45
49
|
};
|
|
50
|
+
/** Look up a verification token by its raw value. Hashes before lookup. */
|
|
46
51
|
export const getVerificationToken = async (token) => {
|
|
52
|
+
const hash = sha256(token);
|
|
47
53
|
if (_store === "memory")
|
|
48
|
-
return memoryGetVerificationToken(
|
|
54
|
+
return memoryGetVerificationToken(hash);
|
|
49
55
|
if (_store === "sqlite")
|
|
50
|
-
return sqliteGetVerificationToken(
|
|
56
|
+
return sqliteGetVerificationToken(hash);
|
|
51
57
|
if (_store === "mongo") {
|
|
52
58
|
const doc = await getVerificationModel()
|
|
53
|
-
.findOne({ token, expiresAt: { $gt: new Date() } })
|
|
59
|
+
.findOne({ token: hash, expiresAt: { $gt: new Date() } })
|
|
54
60
|
.lean();
|
|
55
61
|
if (!doc)
|
|
56
62
|
return null;
|
|
57
63
|
return { userId: doc.userId, email: doc.email };
|
|
58
64
|
}
|
|
59
|
-
const raw = await getRedis().get(`verify:${getAppName()}:${
|
|
65
|
+
const raw = await getRedis().get(`verify:${getAppName()}:${hash}`);
|
|
60
66
|
if (!raw)
|
|
61
67
|
return null;
|
|
62
68
|
return JSON.parse(raw);
|
|
63
69
|
};
|
|
70
|
+
/** Delete a verification token by its raw value. Hashes before lookup. */
|
|
64
71
|
export const deleteVerificationToken = async (token) => {
|
|
72
|
+
const hash = sha256(token);
|
|
65
73
|
if (_store === "memory") {
|
|
66
|
-
memoryDeleteVerificationToken(
|
|
74
|
+
memoryDeleteVerificationToken(hash);
|
|
67
75
|
return;
|
|
68
76
|
}
|
|
69
77
|
if (_store === "sqlite") {
|
|
70
|
-
sqliteDeleteVerificationToken(
|
|
78
|
+
sqliteDeleteVerificationToken(hash);
|
|
71
79
|
return;
|
|
72
80
|
}
|
|
73
81
|
if (_store === "mongo") {
|
|
74
|
-
await getVerificationModel().deleteOne({ token });
|
|
82
|
+
await getVerificationModel().deleteOne({ token: hash });
|
|
75
83
|
return;
|
|
76
84
|
}
|
|
77
|
-
await getRedis().del(`verify:${getAppName()}:${
|
|
85
|
+
await getRedis().del(`verify:${getAppName()}:${hash}`);
|
|
78
86
|
};
|
package/dist/lib/jwt.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const signToken: (userId: string, sessionId: string) => Promise<string>;
|
|
1
|
+
export declare const signToken: (userId: string, sessionId: string, expirySeconds?: number) => Promise<string>;
|
|
2
2
|
export declare const verifyToken: (token: string) => Promise<import("jose").JWTPayload>;
|
package/dist/lib/jwt.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { SignJWT, jwtVerify } from "jose";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
let _secret = null;
|
|
3
|
+
function getSecret() {
|
|
4
|
+
if (_secret)
|
|
5
|
+
return _secret;
|
|
6
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
7
|
+
const envKey = isProd ? "JWT_SECRET_PROD" : "JWT_SECRET_DEV";
|
|
8
|
+
const rawSecret = process.env[envKey];
|
|
9
|
+
if (!rawSecret || rawSecret.length < 32) {
|
|
10
|
+
throw new Error(`[security] ${envKey} is missing or too short (${rawSecret?.length ?? 0} chars). ` +
|
|
11
|
+
`JWT secrets must be at least 32 characters. Generate one with: ` +
|
|
12
|
+
`node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`);
|
|
13
|
+
}
|
|
14
|
+
_secret = new TextEncoder().encode(rawSecret);
|
|
15
|
+
return _secret;
|
|
16
|
+
}
|
|
17
|
+
export const signToken = async (userId, sessionId, expirySeconds) => new SignJWT({ sub: userId, sid: sessionId })
|
|
5
18
|
.setProtectedHeader({ alg: "HS256" })
|
|
6
|
-
.setExpirationTime("7d")
|
|
7
|
-
.sign(
|
|
19
|
+
.setExpirationTime(expirySeconds ? `${expirySeconds}s` : "7d")
|
|
20
|
+
.sign(getSecret());
|
|
8
21
|
export const verifyToken = async (token) => {
|
|
9
|
-
const { payload } = await jwtVerify(token,
|
|
22
|
+
const { payload } = await jwtVerify(token, getSecret());
|
|
10
23
|
return payload;
|
|
11
24
|
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type MfaChallengePurpose = "login" | "webauthn-registration";
|
|
2
|
+
export interface MfaChallengeOptions {
|
|
3
|
+
emailOtpHash?: string;
|
|
4
|
+
webauthnChallenge?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface MfaChallengeData {
|
|
7
|
+
userId: string;
|
|
8
|
+
purpose: MfaChallengePurpose;
|
|
9
|
+
emailOtpHash?: string;
|
|
10
|
+
webauthnChallenge?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Reset all in-memory MFA challenge state. Called by clearMemoryStore(). */
|
|
13
|
+
export declare const clearMemoryMfaChallenges: () => void;
|
|
14
|
+
/** Must be called when store is "sqlite" to inject the db instance. */
|
|
15
|
+
export declare const setMfaChallengeSqliteDb: (db: any) => void;
|
|
16
|
+
type MfaChallengeStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
17
|
+
export declare const setMfaChallengeStore: (store: MfaChallengeStore) => void;
|
|
18
|
+
export declare const createMfaChallenge: (userId: string, options?: MfaChallengeOptions) => Promise<string>;
|
|
19
|
+
export declare const consumeMfaChallenge: (token: string) => Promise<MfaChallengeData | null>;
|
|
20
|
+
/**
|
|
21
|
+
* Replace the email OTP hash on an existing challenge without consuming it.
|
|
22
|
+
* Used for the resend flow. Increments resendCount and caps the challenge lifetime.
|
|
23
|
+
* Returns { userId, resendCount } on success, null if challenge not found/expired/max resends reached.
|
|
24
|
+
*/
|
|
25
|
+
export declare const replaceMfaChallengeOtp: (token: string, newEmailOtpHash: string) => Promise<{
|
|
26
|
+
userId: string;
|
|
27
|
+
resendCount: number;
|
|
28
|
+
} | null>;
|
|
29
|
+
/**
|
|
30
|
+
* Create a WebAuthn registration challenge token. Separate from the login flow —
|
|
31
|
+
* uses `purpose: "webauthn-registration"` so it cannot be consumed by `consumeMfaChallenge`.
|
|
32
|
+
*/
|
|
33
|
+
export declare const createWebAuthnRegistrationChallenge: (userId: string, challenge: string) => Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Consume a WebAuthn registration challenge token.
|
|
36
|
+
* Only accepts tokens with `purpose: "webauthn-registration"`.
|
|
37
|
+
*/
|
|
38
|
+
export declare const consumeWebAuthnRegistrationChallenge: (token: string) => Promise<{
|
|
39
|
+
userId: string;
|
|
40
|
+
challenge: string;
|
|
41
|
+
} | null>;
|
|
42
|
+
export {};
|