@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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
2
|
+
import { timingSafeEqual } from "../lib/crypto";
|
|
3
|
+
import { COOKIE_TOKEN, COOKIE_CSRF_TOKEN, HEADER_CSRF_TOKEN } from "../lib/constants";
|
|
4
|
+
import { createHmac, randomBytes } from "crypto";
|
|
5
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
6
|
+
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
7
|
+
function getJwtSecret() {
|
|
8
|
+
const secret = isProd ? process.env.JWT_SECRET_PROD : process.env.JWT_SECRET_DEV;
|
|
9
|
+
if (!secret)
|
|
10
|
+
throw new Error("CSRF middleware requires JWT_SECRET_DEV/JWT_SECRET_PROD to be set");
|
|
11
|
+
return secret;
|
|
12
|
+
}
|
|
13
|
+
function generateCsrfToken(secret) {
|
|
14
|
+
const token = randomBytes(32).toString("hex");
|
|
15
|
+
const sig = createHmac("sha256", secret).update(token).digest("hex");
|
|
16
|
+
return `${token}.${sig}`;
|
|
17
|
+
}
|
|
18
|
+
function verifyCsrfSignature(cookieValue, secret) {
|
|
19
|
+
const dotIdx = cookieValue.indexOf(".");
|
|
20
|
+
if (dotIdx === -1)
|
|
21
|
+
return false;
|
|
22
|
+
const token = cookieValue.substring(0, dotIdx);
|
|
23
|
+
const sig = cookieValue.substring(dotIdx + 1);
|
|
24
|
+
const expected = createHmac("sha256", secret).update(token).digest("hex");
|
|
25
|
+
return timingSafeEqual(sig, expected);
|
|
26
|
+
}
|
|
27
|
+
const csrfCookieOptions = {
|
|
28
|
+
httpOnly: false,
|
|
29
|
+
secure: isProd,
|
|
30
|
+
sameSite: "Lax",
|
|
31
|
+
path: "/",
|
|
32
|
+
maxAge: 60 * 60 * 24 * 365, // 1 year — tied to browser, not session
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Refreshes the CSRF token cookie — call on login/register to prevent
|
|
36
|
+
* session fixation-adjacent attacks.
|
|
37
|
+
*/
|
|
38
|
+
export function refreshCsrfToken(c) {
|
|
39
|
+
const secret = getJwtSecret();
|
|
40
|
+
const token = generateCsrfToken(secret);
|
|
41
|
+
setCookie(c, COOKIE_CSRF_TOKEN, token, csrfCookieOptions);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Clears the CSRF token cookie — call on logout.
|
|
45
|
+
*/
|
|
46
|
+
export function clearCsrfToken(c) {
|
|
47
|
+
deleteCookie(c, COOKIE_CSRF_TOKEN, { path: "/" });
|
|
48
|
+
}
|
|
49
|
+
export const csrfProtection = (options = {}) => {
|
|
50
|
+
const { exemptPaths = [], checkOrigin = true, allowedOrigins } = options;
|
|
51
|
+
// Normalize allowed origins for origin validation
|
|
52
|
+
const originSet = new Set();
|
|
53
|
+
if (allowedOrigins) {
|
|
54
|
+
const origins = Array.isArray(allowedOrigins) ? allowedOrigins : [allowedOrigins];
|
|
55
|
+
for (const o of origins) {
|
|
56
|
+
if (o !== "*")
|
|
57
|
+
originSet.add(o.replace(/\/$/, ""));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return async (c, next) => {
|
|
61
|
+
const secret = getJwtSecret();
|
|
62
|
+
// Set CSRF cookie on every response if not already present
|
|
63
|
+
const existingCsrf = getCookie(c, COOKIE_CSRF_TOKEN);
|
|
64
|
+
if (!existingCsrf) {
|
|
65
|
+
const token = generateCsrfToken(secret);
|
|
66
|
+
setCookie(c, COOKIE_CSRF_TOKEN, token, csrfCookieOptions);
|
|
67
|
+
}
|
|
68
|
+
// Only validate state-changing methods
|
|
69
|
+
if (!STATE_CHANGING_METHODS.has(c.req.method)) {
|
|
70
|
+
return next();
|
|
71
|
+
}
|
|
72
|
+
// Skip if no auth cookie present — not vulnerable to CSRF
|
|
73
|
+
const authCookie = getCookie(c, COOKIE_TOKEN);
|
|
74
|
+
if (!authCookie) {
|
|
75
|
+
return next();
|
|
76
|
+
}
|
|
77
|
+
// Skip exempt paths
|
|
78
|
+
const path = c.req.path;
|
|
79
|
+
for (const exempt of exemptPaths) {
|
|
80
|
+
if (exempt.endsWith("*")) {
|
|
81
|
+
if (path.startsWith(exempt.slice(0, -1)))
|
|
82
|
+
return next();
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (path === exempt)
|
|
86
|
+
return next();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Origin validation (secondary layer)
|
|
90
|
+
if (checkOrigin && originSet.size > 0) {
|
|
91
|
+
const origin = c.req.header("origin");
|
|
92
|
+
if (origin) {
|
|
93
|
+
const normalized = origin.replace(/\/$/, "");
|
|
94
|
+
if (!originSet.has(normalized)) {
|
|
95
|
+
return c.json({ error: "CSRF origin mismatch" }, 403);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Double submit cookie validation
|
|
100
|
+
const csrfCookie = getCookie(c, COOKIE_CSRF_TOKEN);
|
|
101
|
+
const csrfHeader = c.req.header(HEADER_CSRF_TOKEN);
|
|
102
|
+
if (!csrfCookie || !csrfHeader) {
|
|
103
|
+
return c.json({ error: "CSRF token missing" }, 403);
|
|
104
|
+
}
|
|
105
|
+
// Verify the cookie's HMAC signature (prevents cookie injection)
|
|
106
|
+
if (!verifyCsrfSignature(csrfCookie, secret)) {
|
|
107
|
+
return c.json({ error: "CSRF token invalid" }, 403);
|
|
108
|
+
}
|
|
109
|
+
// Compare header value to cookie value
|
|
110
|
+
if (!timingSafeEqual(csrfHeader, csrfCookie)) {
|
|
111
|
+
return c.json({ error: "CSRF token mismatch" }, 403);
|
|
112
|
+
}
|
|
113
|
+
return next();
|
|
114
|
+
};
|
|
115
|
+
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
2
3
|
export interface RateLimitOptions {
|
|
3
4
|
windowMs: number;
|
|
4
5
|
max: number;
|
|
5
6
|
/** Also rate-limit by HTTP fingerprint in addition to IP. Default: false */
|
|
6
7
|
fingerprintLimit?: boolean;
|
|
7
8
|
}
|
|
8
|
-
export declare const rateLimit: ({ windowMs, max, fingerprintLimit, }: RateLimitOptions) => MiddlewareHandler
|
|
9
|
+
export declare const rateLimit: ({ windowMs, max, fingerprintLimit, }: RateLimitOptions) => MiddlewareHandler<AppEnv>;
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { trackAttempt } from "../lib/authRateLimit";
|
|
2
2
|
import { buildFingerprint } from "../lib/fingerprint";
|
|
3
|
+
import { getClientIp } from "../lib/clientIp";
|
|
3
4
|
export const rateLimit = ({ windowMs, max, fingerprintLimit = false, }) => {
|
|
4
5
|
const opts = { windowMs, max };
|
|
5
6
|
return async (c, next) => {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
7
|
+
const ip = getClientIp(c);
|
|
8
|
+
// Per-tenant namespacing: each tenant gets independent rate limit buckets
|
|
9
|
+
const tenantId = c.get("tenantId");
|
|
10
|
+
const prefix = tenantId ? `t:${tenantId}:` : "";
|
|
11
|
+
if (await trackAttempt(`${prefix}ip:${ip}`, opts)) {
|
|
10
12
|
return c.json({ error: "Too Many Requests" }, 429);
|
|
11
13
|
}
|
|
12
14
|
if (fingerprintLimit) {
|
|
13
15
|
const fp = await buildFingerprint(c.req.raw);
|
|
14
|
-
if (await trackAttempt(
|
|
16
|
+
if (await trackAttempt(`${prefix}fp:${fp}`, opts)) {
|
|
15
17
|
return c.json({ error: "Too Many Requests" }, 429);
|
|
16
18
|
}
|
|
17
19
|
}
|
|
@@ -3,9 +3,11 @@ import type { AppEnv } from "../lib/context";
|
|
|
3
3
|
/**
|
|
4
4
|
* Middleware factory that enforces role-based access.
|
|
5
5
|
* Requires `identify` to have run first (authUserId must be set).
|
|
6
|
-
* Roles are fetched lazily on the first role-checked route and cached on the context.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
7
|
+
* When tenant context exists (`tenantId` set on context), checks tenant-scoped roles.
|
|
8
|
+
* Falls back to app-wide roles when no tenant context is present.
|
|
9
|
+
*
|
|
10
|
+
* The adapter must implement `getRoles` (and `getTenantRoles` for tenant-scoped checks).
|
|
9
11
|
*
|
|
10
12
|
* @example
|
|
11
13
|
* // Allow any authenticated user with the "admin" role
|
|
@@ -14,4 +16,13 @@ import type { AppEnv } from "../lib/context";
|
|
|
14
16
|
* // Allow users with either "admin" or "moderator"
|
|
15
17
|
* app.get("/mod", userAuth, requireRole("admin", "moderator"), handler)
|
|
16
18
|
*/
|
|
17
|
-
export declare const requireRole: (...roles: string[]) => MiddlewareHandler<AppEnv
|
|
19
|
+
export declare const requireRole: ((...roles: string[]) => MiddlewareHandler<AppEnv>) & {
|
|
20
|
+
/**
|
|
21
|
+
* Always checks app-wide roles regardless of tenant context.
|
|
22
|
+
* Use for super-admin gates that should ignore tenant scoping.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* app.get("/super-admin", userAuth, requireRole.global("superadmin"), handler)
|
|
26
|
+
*/
|
|
27
|
+
global: (...roles: string[]) => MiddlewareHandler<AppEnv>;
|
|
28
|
+
};
|
|
@@ -2,9 +2,11 @@ import { getAuthAdapter } from "../lib/authAdapter";
|
|
|
2
2
|
/**
|
|
3
3
|
* Middleware factory that enforces role-based access.
|
|
4
4
|
* Requires `identify` to have run first (authUserId must be set).
|
|
5
|
-
* Roles are fetched lazily on the first role-checked route and cached on the context.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
6
|
+
* When tenant context exists (`tenantId` set on context), checks tenant-scoped roles.
|
|
7
|
+
* Falls back to app-wide roles when no tenant context is present.
|
|
8
|
+
*
|
|
9
|
+
* The adapter must implement `getRoles` (and `getTenantRoles` for tenant-scoped checks).
|
|
8
10
|
*
|
|
9
11
|
* @example
|
|
10
12
|
* // Allow any authenticated user with the "admin" role
|
|
@@ -13,15 +15,25 @@ import { getAuthAdapter } from "../lib/authAdapter";
|
|
|
13
15
|
* // Allow users with either "admin" or "moderator"
|
|
14
16
|
* app.get("/mod", userAuth, requireRole("admin", "moderator"), handler)
|
|
15
17
|
*/
|
|
16
|
-
export const requireRole = (...roles) => async (c, next) => {
|
|
18
|
+
export const requireRole = Object.assign((...roles) => async (c, next) => {
|
|
17
19
|
const userId = c.get("authUserId");
|
|
18
20
|
if (!userId) {
|
|
19
21
|
return c.json({ error: "Unauthorized" }, 401);
|
|
20
22
|
}
|
|
21
|
-
|
|
23
|
+
const adapter = getAuthAdapter();
|
|
24
|
+
const tenantId = c.get("tenantId");
|
|
25
|
+
// When tenant context exists and adapter supports tenant roles, check tenant-scoped roles
|
|
26
|
+
if (tenantId && adapter.getTenantRoles) {
|
|
27
|
+
const tenantRoles = await adapter.getTenantRoles(userId, tenantId);
|
|
28
|
+
const hasRole = roles.some((role) => tenantRoles.includes(role));
|
|
29
|
+
if (!hasRole) {
|
|
30
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
31
|
+
}
|
|
32
|
+
return next();
|
|
33
|
+
}
|
|
34
|
+
// Fall back to app-wide roles
|
|
22
35
|
let userRoles = c.get("roles");
|
|
23
36
|
if (userRoles === null) {
|
|
24
|
-
const adapter = getAuthAdapter();
|
|
25
37
|
if (!adapter.getRoles) {
|
|
26
38
|
throw new Error("requireRole used but auth adapter does not implement getRoles");
|
|
27
39
|
}
|
|
@@ -33,4 +45,32 @@ export const requireRole = (...roles) => async (c, next) => {
|
|
|
33
45
|
return c.json({ error: "Forbidden" }, 403);
|
|
34
46
|
}
|
|
35
47
|
await next();
|
|
36
|
-
}
|
|
48
|
+
}, {
|
|
49
|
+
/**
|
|
50
|
+
* Always checks app-wide roles regardless of tenant context.
|
|
51
|
+
* Use for super-admin gates that should ignore tenant scoping.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* app.get("/super-admin", userAuth, requireRole.global("superadmin"), handler)
|
|
55
|
+
*/
|
|
56
|
+
global: (...roles) => async (c, next) => {
|
|
57
|
+
const userId = c.get("authUserId");
|
|
58
|
+
if (!userId) {
|
|
59
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
60
|
+
}
|
|
61
|
+
let userRoles = c.get("roles");
|
|
62
|
+
if (userRoles === null) {
|
|
63
|
+
const adapter = getAuthAdapter();
|
|
64
|
+
if (!adapter.getRoles) {
|
|
65
|
+
throw new Error("requireRole.global used but auth adapter does not implement getRoles");
|
|
66
|
+
}
|
|
67
|
+
userRoles = await adapter.getRoles(userId);
|
|
68
|
+
c.set("roles", userRoles);
|
|
69
|
+
}
|
|
70
|
+
const hasRole = roles.some((role) => userRoles.includes(role));
|
|
71
|
+
if (!hasRole) {
|
|
72
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
73
|
+
}
|
|
74
|
+
await next();
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
import type { TenancyConfig } from "../app";
|
|
4
|
+
export declare const invalidateTenantCache: (tenantId: string) => void;
|
|
5
|
+
export declare const createTenantMiddleware: (config: TenancyConfig) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
class LruCache {
|
|
2
|
+
_map = new Map();
|
|
3
|
+
_maxSize;
|
|
4
|
+
_ttlMs;
|
|
5
|
+
constructor(maxSize, ttlMs) {
|
|
6
|
+
this._maxSize = maxSize;
|
|
7
|
+
this._ttlMs = ttlMs;
|
|
8
|
+
}
|
|
9
|
+
get(key) {
|
|
10
|
+
const entry = this._map.get(key);
|
|
11
|
+
if (!entry)
|
|
12
|
+
return undefined; // cache miss
|
|
13
|
+
if (entry.expiresAt <= Date.now()) {
|
|
14
|
+
this._map.delete(key);
|
|
15
|
+
return undefined; // expired
|
|
16
|
+
}
|
|
17
|
+
// Move to end (most recently used)
|
|
18
|
+
this._map.delete(key);
|
|
19
|
+
this._map.set(key, entry);
|
|
20
|
+
return entry.value;
|
|
21
|
+
}
|
|
22
|
+
set(key, value) {
|
|
23
|
+
// Remove first if exists (for re-insertion at end)
|
|
24
|
+
this._map.delete(key);
|
|
25
|
+
// Evict oldest if at capacity
|
|
26
|
+
if (this._map.size >= this._maxSize) {
|
|
27
|
+
const oldest = this._map.keys().next().value;
|
|
28
|
+
if (oldest !== undefined)
|
|
29
|
+
this._map.delete(oldest);
|
|
30
|
+
}
|
|
31
|
+
this._map.set(key, { value, expiresAt: Date.now() + this._ttlMs });
|
|
32
|
+
}
|
|
33
|
+
delete(key) {
|
|
34
|
+
this._map.delete(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Exported cache invalidation (used by tenant provisioning helpers)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
let _cache = null;
|
|
41
|
+
export const invalidateTenantCache = (tenantId) => {
|
|
42
|
+
_cache?.delete(tenantId);
|
|
43
|
+
};
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Tenant resolution middleware
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
const DEFAULT_EXEMPT = ["/health", "/docs", "/openapi.json", "/auth/"];
|
|
48
|
+
function extractTenantId(c, config) {
|
|
49
|
+
if (config.resolution === "header") {
|
|
50
|
+
const headerName = config.headerName ?? "x-tenant-id";
|
|
51
|
+
return c.req.header(headerName) ?? null;
|
|
52
|
+
}
|
|
53
|
+
if (config.resolution === "subdomain") {
|
|
54
|
+
const host = c.req.header("host") ?? "";
|
|
55
|
+
// Extract first subdomain: "acme.myapp.com" → "acme"
|
|
56
|
+
const parts = host.split(".");
|
|
57
|
+
if (parts.length < 3)
|
|
58
|
+
return null; // no subdomain
|
|
59
|
+
return parts[0] || null;
|
|
60
|
+
}
|
|
61
|
+
if (config.resolution === "path") {
|
|
62
|
+
const segmentIndex = config.pathSegment ?? 0;
|
|
63
|
+
// Path: "/acme/api/users" → segments after split: ["", "acme", "api", "users"]
|
|
64
|
+
const segments = c.req.path.split("/").filter(Boolean);
|
|
65
|
+
return segments[segmentIndex] ?? null;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
export const createTenantMiddleware = (config) => {
|
|
70
|
+
const exemptPaths = [...DEFAULT_EXEMPT, ...(config.exemptPaths ?? [])];
|
|
71
|
+
const rejectionStatus = config.rejectionStatus ?? 403;
|
|
72
|
+
const cacheTtlMs = config.cacheTtlMs ?? 60_000;
|
|
73
|
+
const cacheMaxSize = config.cacheMaxSize ?? 500;
|
|
74
|
+
// Initialize LRU cache if caching is enabled and onResolve is provided
|
|
75
|
+
if (config.onResolve && cacheTtlMs > 0) {
|
|
76
|
+
_cache = new LruCache(cacheMaxSize, cacheTtlMs);
|
|
77
|
+
}
|
|
78
|
+
return async (c, next) => {
|
|
79
|
+
const path = c.req.path;
|
|
80
|
+
// Check exempt paths using startsWith
|
|
81
|
+
for (const exempt of exemptPaths) {
|
|
82
|
+
if (path === exempt || path.startsWith(exempt)) {
|
|
83
|
+
c.set("tenantId", null);
|
|
84
|
+
c.set("tenantConfig", null);
|
|
85
|
+
return next();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const tenantId = extractTenantId(c, config);
|
|
89
|
+
if (!tenantId) {
|
|
90
|
+
return c.json({ error: "Tenant ID required" }, 400);
|
|
91
|
+
}
|
|
92
|
+
// Validate via onResolve (with caching)
|
|
93
|
+
if (config.onResolve) {
|
|
94
|
+
let tenantConfig;
|
|
95
|
+
if (_cache) {
|
|
96
|
+
tenantConfig = _cache.get(tenantId);
|
|
97
|
+
}
|
|
98
|
+
// undefined = cache miss, null = onResolve returned null (rejected)
|
|
99
|
+
if (tenantConfig === undefined) {
|
|
100
|
+
tenantConfig = await config.onResolve(tenantId);
|
|
101
|
+
_cache?.set(tenantId, tenantConfig);
|
|
102
|
+
}
|
|
103
|
+
if (tenantConfig === null) {
|
|
104
|
+
return c.json({ error: "Access denied" }, rejectionStatus);
|
|
105
|
+
}
|
|
106
|
+
c.set("tenantId", tenantId);
|
|
107
|
+
c.set("tenantConfig", tenantConfig);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// No onResolve — trust the tenant ID
|
|
111
|
+
c.set("tenantId", tenantId);
|
|
112
|
+
c.set("tenantConfig", null);
|
|
113
|
+
}
|
|
114
|
+
return next();
|
|
115
|
+
};
|
|
116
|
+
};
|
|
@@ -8,6 +8,23 @@ interface IAuthUser {
|
|
|
8
8
|
roles: string[];
|
|
9
9
|
/** Whether the user's email address has been verified. */
|
|
10
10
|
emailVerified: boolean;
|
|
11
|
+
/** TOTP secret for MFA. Null when MFA is not set up. */
|
|
12
|
+
mfaSecret?: string | null;
|
|
13
|
+
/** Whether MFA is enabled (secret stored + confirmed via TOTP code). */
|
|
14
|
+
mfaEnabled?: boolean;
|
|
15
|
+
/** SHA-256 hashed recovery codes for MFA. */
|
|
16
|
+
recoveryCodes?: string[];
|
|
17
|
+
/** MFA methods enabled for this user (e.g., ["totp"], ["emailOtp"], ["totp", "emailOtp"]). */
|
|
18
|
+
mfaMethods?: string[];
|
|
19
|
+
/** WebAuthn credentials (security keys / platform authenticators). */
|
|
20
|
+
webauthnCredentials?: Array<{
|
|
21
|
+
credentialId: string;
|
|
22
|
+
publicKey: string;
|
|
23
|
+
signCount: number;
|
|
24
|
+
transports?: string[];
|
|
25
|
+
name?: string;
|
|
26
|
+
createdAt: Date;
|
|
27
|
+
}>;
|
|
11
28
|
}
|
|
12
29
|
type AuthUserDocument = IAuthUser & Document;
|
|
13
30
|
export declare const AuthUser: Model<AuthUserDocument, {}, {}, {}, Document<unknown, {}, AuthUserDocument, {}, import("mongoose").DefaultSchemaOptions> & IAuthUser & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
|
package/dist/models/AuthUser.js
CHANGED
|
@@ -14,6 +14,23 @@ function getAuthUser() {
|
|
|
14
14
|
roles: [{ type: String }],
|
|
15
15
|
/** Whether the user's email address has been verified. */
|
|
16
16
|
emailVerified: { type: Boolean, default: false },
|
|
17
|
+
/** TOTP secret for MFA. */
|
|
18
|
+
mfaSecret: { type: String, default: null },
|
|
19
|
+
/** Whether MFA is enabled. */
|
|
20
|
+
mfaEnabled: { type: Boolean, default: false },
|
|
21
|
+
/** SHA-256 hashed recovery codes for MFA. */
|
|
22
|
+
recoveryCodes: [{ type: String }],
|
|
23
|
+
/** MFA methods enabled for this user. */
|
|
24
|
+
mfaMethods: [{ type: String }],
|
|
25
|
+
/** WebAuthn credentials (security keys / platform authenticators). */
|
|
26
|
+
webauthnCredentials: [{
|
|
27
|
+
credentialId: { type: String, required: true },
|
|
28
|
+
publicKey: { type: String, required: true },
|
|
29
|
+
signCount: { type: Number, required: true, default: 0 },
|
|
30
|
+
transports: [{ type: String }],
|
|
31
|
+
name: { type: String },
|
|
32
|
+
createdAt: { type: Date, default: Date.now },
|
|
33
|
+
}],
|
|
17
34
|
}, { timestamps: true });
|
|
18
35
|
schema.index({ providerIds: 1 });
|
|
19
36
|
_AuthUser = authConnection.model("AuthUser", schema);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Document, Model } from "mongoose";
|
|
2
|
+
interface ITenantRole {
|
|
3
|
+
userId: string;
|
|
4
|
+
tenantId: string;
|
|
5
|
+
roles: string[];
|
|
6
|
+
}
|
|
7
|
+
type TenantRoleDocument = ITenantRole & Document;
|
|
8
|
+
export declare const TenantRole: Model<TenantRoleDocument, {}, {}, {}, Document<unknown, {}, TenantRoleDocument, {}, import("mongoose").DefaultSchemaOptions> & ITenantRole & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
|
|
9
|
+
_id: import("mongoose").Types.ObjectId;
|
|
10
|
+
}> & {
|
|
11
|
+
__v: number;
|
|
12
|
+
} & {
|
|
13
|
+
id: string;
|
|
14
|
+
}, any, TenantRoleDocument>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { authConnection, mongoose } from "../lib/mongo";
|
|
2
|
+
let _TenantRole = null;
|
|
3
|
+
function getTenantRole() {
|
|
4
|
+
if (!_TenantRole) {
|
|
5
|
+
const { Schema } = mongoose;
|
|
6
|
+
const schema = new Schema({
|
|
7
|
+
userId: { type: String, required: true },
|
|
8
|
+
tenantId: { type: String, required: true },
|
|
9
|
+
roles: [{ type: String }],
|
|
10
|
+
}, { timestamps: true });
|
|
11
|
+
schema.index({ userId: 1, tenantId: 1 }, { unique: true });
|
|
12
|
+
schema.index({ tenantId: 1 });
|
|
13
|
+
_TenantRole = authConnection.model("TenantRole", schema);
|
|
14
|
+
}
|
|
15
|
+
return _TenantRole;
|
|
16
|
+
}
|
|
17
|
+
export const TenantRole = new Proxy({}, {
|
|
18
|
+
get(_, prop) {
|
|
19
|
+
const model = getTenantRole();
|
|
20
|
+
const val = model[prop];
|
|
21
|
+
return typeof val === "function" ? val.bind(model) : val;
|
|
22
|
+
},
|
|
23
|
+
});
|
package/dist/routes/auth.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig } from "../lib/appConfig";
|
|
2
|
-
import type { AuthRateLimitConfig } from "../app";
|
|
1
|
+
import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig } from "../lib/appConfig";
|
|
2
|
+
import type { AuthRateLimitConfig, AccountDeletionConfig } from "../app";
|
|
3
3
|
export interface AuthRouterOptions {
|
|
4
4
|
primaryField: PrimaryField;
|
|
5
5
|
emailVerification?: EmailVerificationConfig;
|
|
6
6
|
passwordReset?: PasswordResetConfig;
|
|
7
7
|
rateLimit?: AuthRateLimitConfig;
|
|
8
|
+
accountDeletion?: AccountDeletionConfig;
|
|
9
|
+
refreshTokens?: RefreshTokenConfig;
|
|
8
10
|
}
|
|
9
|
-
export declare const createAuthRouter: ({ primaryField, emailVerification, passwordReset, rateLimit }: AuthRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
|
|
11
|
+
export declare const createAuthRouter: ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }: AuthRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
|