@lastshotlabs/bunshot 0.0.21 → 0.0.27
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 +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +59 -0
- package/dist/adapters/memoryAuth.d.ts +13 -0
- package/dist/adapters/memoryAuth.js +261 -2
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +217 -1
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +30 -0
- package/dist/adapters/sqliteAuth.js +352 -2
- package/dist/app.d.ts +203 -3
- package/dist/app.js +352 -48
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +69 -8
- package/dist/index.js +46 -5
- package/dist/lib/HttpError.d.ts +7 -1
- package/dist/lib/HttpError.js +10 -1
- package/dist/lib/appConfig.d.ts +157 -0
- package/dist/lib/appConfig.js +54 -0
- package/dist/lib/auditLog.d.ts +58 -0
- package/dist/lib/auditLog.js +218 -0
- package/dist/lib/authAdapter.d.ts +140 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +24 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +14 -0
- package/dist/lib/session.js +121 -5
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +183 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +39 -0
- package/dist/lib/upload.js +112 -0
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +28 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +18 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +89 -14
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +100 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +37 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +58 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +238 -21
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +66 -46
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +8 -0
- package/dist/routes/metrics.js +55 -0
- package/dist/routes/mfa.js +13 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +14 -0
- package/dist/routes/uploads.js +227 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +5 -1
- package/docs/sections/auth-flow/full.md +203 -47
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +388 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +131 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +208 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +95 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +18 -5
|
@@ -1,34 +1,107 @@
|
|
|
1
1
|
import { getCookie } from "hono/cookie";
|
|
2
2
|
import { verifyToken } from "../lib/jwt";
|
|
3
|
-
import { getSession, updateSessionLastActive } from "../lib/session";
|
|
3
|
+
import { getSession, updateSessionLastActive, getSessionFingerprint, setSessionFingerprint } from "../lib/session";
|
|
4
4
|
import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "../lib/constants";
|
|
5
|
-
import { log } from "../lib/logger";
|
|
6
|
-
import { getTrackLastActive } from "../lib/appConfig";
|
|
5
|
+
import { log, authTrace } from "../lib/logger";
|
|
6
|
+
import { getTrackLastActive, getSigningConfig, getCheckSuspensionOnIdentify } from "../lib/appConfig";
|
|
7
|
+
import { getSuspended } from "../lib/suspension";
|
|
8
|
+
import { getClientIp } from "../lib/clientIp";
|
|
9
|
+
import { sha256, timingSafeEqual } from "../lib/crypto";
|
|
10
|
+
import { HttpError } from "../lib/HttpError";
|
|
11
|
+
function computeFingerprint(c, fields) {
|
|
12
|
+
const parts = fields.map((f) => {
|
|
13
|
+
if (f === "ip")
|
|
14
|
+
return getClientIp(c) ?? "";
|
|
15
|
+
if (f === "ua")
|
|
16
|
+
return c.req.header("user-agent") ?? "";
|
|
17
|
+
return c.req.header("accept-language") ?? "";
|
|
18
|
+
});
|
|
19
|
+
return sha256(parts.join(":"));
|
|
20
|
+
}
|
|
7
21
|
export const identify = async (c, next) => {
|
|
8
22
|
c.set("authUserId", null);
|
|
9
23
|
c.set("roles", null);
|
|
10
24
|
c.set("sessionId", null);
|
|
25
|
+
c.set("authClientId", null);
|
|
26
|
+
c.set("tokenPayload", null);
|
|
11
27
|
// cookie for browsers, x-user-token header for non-browser clients
|
|
12
28
|
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
13
29
|
log(`[identify] token=${token ? "present" : "absent"}`);
|
|
14
30
|
if (token) {
|
|
15
31
|
try {
|
|
16
32
|
const payload = await verifyToken(token);
|
|
33
|
+
c.set("tokenPayload", payload);
|
|
17
34
|
const sessionId = payload.sid;
|
|
18
35
|
if (!sessionId) {
|
|
19
|
-
|
|
36
|
+
// Check for M2M token (scope present, no sid)
|
|
37
|
+
if (payload.scope && payload.sub) {
|
|
38
|
+
c.set("authClientId", payload.sub);
|
|
39
|
+
log(`[identify] M2M token for clientId=${payload.sub}`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
log("[identify] token missing sid claim — unauthenticated");
|
|
43
|
+
}
|
|
20
44
|
}
|
|
21
45
|
else {
|
|
22
46
|
const stored = await getSession(sessionId);
|
|
23
|
-
log(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
log("[identify] token verified, checking session...");
|
|
48
|
+
authTrace(`[identify] authUserId=${payload.sub}`);
|
|
49
|
+
if (timingSafeEqual(stored ?? "", token)) {
|
|
50
|
+
const signingCfg = getSigningConfig();
|
|
51
|
+
const bindingCfg = signingCfg?.sessionBinding;
|
|
52
|
+
if (bindingCfg) {
|
|
53
|
+
const bindingOpts = typeof bindingCfg === "object" ? bindingCfg : {};
|
|
54
|
+
const fields = bindingOpts.fields ?? ["ip", "ua"];
|
|
55
|
+
const onMismatch = bindingOpts.onMismatch ?? "unauthenticate";
|
|
56
|
+
const current = computeFingerprint(c, fields);
|
|
57
|
+
const storedFp = await getSessionFingerprint(sessionId);
|
|
58
|
+
if (storedFp === null) {
|
|
59
|
+
// First authenticated request — store the fingerprint
|
|
60
|
+
setSessionFingerprint(sessionId, current).catch(() => {
|
|
61
|
+
log("[identify] failed to store session fingerprint");
|
|
62
|
+
});
|
|
63
|
+
c.set("authUserId", payload.sub);
|
|
64
|
+
c.set("sessionId", sessionId);
|
|
65
|
+
}
|
|
66
|
+
else if (timingSafeEqual(storedFp, current)) {
|
|
67
|
+
c.set("authUserId", payload.sub);
|
|
68
|
+
c.set("sessionId", sessionId);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
log(`[identify] fingerprint mismatch, onMismatch=${onMismatch}`);
|
|
72
|
+
authTrace(`[identify] sessionId=${sessionId}`);
|
|
73
|
+
if (onMismatch === "reject") {
|
|
74
|
+
throw new HttpError(401, "Unauthorized", "FINGERPRINT_MISMATCH");
|
|
75
|
+
}
|
|
76
|
+
else if (onMismatch === "log-only") {
|
|
77
|
+
c.set("authUserId", payload.sub);
|
|
78
|
+
c.set("sessionId", sessionId);
|
|
79
|
+
}
|
|
80
|
+
// onMismatch === "unauthenticate" — leave authUserId null (already null)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
c.set("authUserId", payload.sub);
|
|
85
|
+
c.set("sessionId", sessionId);
|
|
86
|
+
}
|
|
87
|
+
if (c.get("authUserId")) {
|
|
88
|
+
if (getCheckSuspensionOnIdentify()) {
|
|
89
|
+
const suspensionStatus = await getSuspended(payload.sub).catch(() => ({ suspended: false }));
|
|
90
|
+
if (suspensionStatus.suspended) {
|
|
91
|
+
c.set("authUserId", null);
|
|
92
|
+
c.set("sessionId", null);
|
|
93
|
+
c.set("roles", null);
|
|
94
|
+
log(`[identify] userId=${payload.sub} is suspended — unauthenticated`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (c.get("authUserId")) {
|
|
99
|
+
authTrace(`[identify] authUserId=${payload.sub} sessionId=${sessionId}`);
|
|
100
|
+
if (getTrackLastActive()) {
|
|
101
|
+
updateSessionLastActive(sessionId).catch(() => {
|
|
102
|
+
log("[identify] failed to update session lastActiveAt");
|
|
103
|
+
});
|
|
104
|
+
}
|
|
32
105
|
}
|
|
33
106
|
}
|
|
34
107
|
else {
|
|
@@ -36,7 +109,9 @@ export const identify = async (c, next) => {
|
|
|
36
109
|
}
|
|
37
110
|
}
|
|
38
111
|
}
|
|
39
|
-
catch {
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (err instanceof HttpError)
|
|
114
|
+
throw err;
|
|
40
115
|
log("[identify] invalid token — unauthenticated");
|
|
41
116
|
}
|
|
42
117
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
export interface MetricsMiddlewareOptions {
|
|
4
|
+
/** Paths to exclude from metrics collection. Strings use prefix matching. */
|
|
5
|
+
excludePaths?: (string | RegExp)[];
|
|
6
|
+
/** Custom path normalizer to prevent cardinality explosion. */
|
|
7
|
+
normalizePath?: (path: string) => string;
|
|
8
|
+
}
|
|
9
|
+
export declare const metricsCollector: (options?: MetricsMiddlewareOptions) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { incrementCounter, observeHistogram, defaultNormalizePath } from "../lib/metrics";
|
|
2
|
+
const DEFAULT_EXCLUDE = ["/metrics", "/health", "/docs", "/openapi.json"];
|
|
3
|
+
export const metricsCollector = (options = {}) => {
|
|
4
|
+
const { excludePaths = DEFAULT_EXCLUDE, normalizePath = defaultNormalizePath, } = options;
|
|
5
|
+
return async (c, next) => {
|
|
6
|
+
const rawPath = c.req.path;
|
|
7
|
+
const excluded = excludePaths.some(p => typeof p === "string" ? rawPath.startsWith(p) : p.test(rawPath));
|
|
8
|
+
if (excluded)
|
|
9
|
+
return next();
|
|
10
|
+
const start = performance.now();
|
|
11
|
+
await next();
|
|
12
|
+
const duration = (performance.now() - start) / 1000; // seconds
|
|
13
|
+
const method = c.req.method;
|
|
14
|
+
const path = normalizePath(rawPath);
|
|
15
|
+
const status = String(c.res.status);
|
|
16
|
+
const tenantId = c.get("tenantId") ?? undefined;
|
|
17
|
+
const labels = { method, path, status };
|
|
18
|
+
const durationLabels = { method, path };
|
|
19
|
+
if (tenantId) {
|
|
20
|
+
labels.tenant = tenantId;
|
|
21
|
+
durationLabels.tenant = tenantId;
|
|
22
|
+
}
|
|
23
|
+
incrementCounter("http_requests_total", labels);
|
|
24
|
+
observeHistogram("http_request_duration_seconds", durationLabels, duration);
|
|
25
|
+
};
|
|
26
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
export type LogLevel = "info" | "warn" | "error";
|
|
4
|
+
export interface RequestLogEntry {
|
|
5
|
+
level: LogLevel;
|
|
6
|
+
time: number;
|
|
7
|
+
msg: string;
|
|
8
|
+
requestId: string;
|
|
9
|
+
method: string;
|
|
10
|
+
path: string;
|
|
11
|
+
statusCode: number;
|
|
12
|
+
responseTime: number;
|
|
13
|
+
ip: string;
|
|
14
|
+
userAgent: string | null;
|
|
15
|
+
userId: string | null;
|
|
16
|
+
sessionId: string | null;
|
|
17
|
+
tenantId: string | null;
|
|
18
|
+
err?: {
|
|
19
|
+
message: string;
|
|
20
|
+
stack?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export interface RequestLoggerOptions {
|
|
24
|
+
/** Custom log handler. Default: `console.log(JSON.stringify(entry))`. */
|
|
25
|
+
onLog?: (entry: RequestLogEntry) => void | Promise<void>;
|
|
26
|
+
/** Minimum level to emit. Entries below this level are dropped. */
|
|
27
|
+
level?: LogLevel;
|
|
28
|
+
/**
|
|
29
|
+
* Paths to exclude from logging. Strings use **prefix matching**
|
|
30
|
+
* (`"/health"` matches `/health`, `/health/live`, `/health/ready`).
|
|
31
|
+
* RegExp entries use `.test()`.
|
|
32
|
+
* Default: `["/health", "/docs", "/openapi.json"]`.
|
|
33
|
+
*/
|
|
34
|
+
excludePaths?: (string | RegExp)[];
|
|
35
|
+
/** HTTP methods to exclude from logging (e.g. `["OPTIONS"]`). */
|
|
36
|
+
excludeMethods?: string[];
|
|
37
|
+
}
|
|
38
|
+
export declare const requestLogger: (options?: RequestLoggerOptions) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getClientIp } from "../lib/clientIp";
|
|
2
|
+
const LEVEL_ORDER = { info: 0, warn: 1, error: 2 };
|
|
3
|
+
function statusToLevel(status) {
|
|
4
|
+
if (status >= 500)
|
|
5
|
+
return "error";
|
|
6
|
+
if (status >= 400)
|
|
7
|
+
return "warn";
|
|
8
|
+
return "info";
|
|
9
|
+
}
|
|
10
|
+
const DEFAULT_EXCLUDE_PATHS = ["/health", "/docs", "/openapi.json", "/metrics"];
|
|
11
|
+
export const requestLogger = (options = {}) => {
|
|
12
|
+
const { onLog = (entry) => console.log(JSON.stringify(entry)), level: minLevel, excludePaths = DEFAULT_EXCLUDE_PATHS, excludeMethods, } = options;
|
|
13
|
+
return async (c, next) => {
|
|
14
|
+
const method = c.req.method;
|
|
15
|
+
if (excludeMethods?.includes(method)) {
|
|
16
|
+
return next();
|
|
17
|
+
}
|
|
18
|
+
const path = c.req.path;
|
|
19
|
+
const excluded = excludePaths.some(p => typeof p === "string" ? path.startsWith(p) : p.test(path));
|
|
20
|
+
if (excluded) {
|
|
21
|
+
return next();
|
|
22
|
+
}
|
|
23
|
+
const start = performance.now();
|
|
24
|
+
let error;
|
|
25
|
+
try {
|
|
26
|
+
await next();
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
error = e;
|
|
30
|
+
}
|
|
31
|
+
const statusCode = error ? 500 : c.res.status;
|
|
32
|
+
const level = statusToLevel(statusCode);
|
|
33
|
+
if (minLevel && LEVEL_ORDER[level] < LEVEL_ORDER[minLevel]) {
|
|
34
|
+
if (error)
|
|
35
|
+
throw error;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const entry = {
|
|
39
|
+
level,
|
|
40
|
+
time: Date.now(),
|
|
41
|
+
msg: `${method} ${path} ${error ? "ERROR" : statusCode}`,
|
|
42
|
+
requestId: c.get("requestId") ?? "unknown",
|
|
43
|
+
method,
|
|
44
|
+
path,
|
|
45
|
+
statusCode,
|
|
46
|
+
responseTime: Math.round((performance.now() - start) * 100) / 100,
|
|
47
|
+
ip: getClientIp(c),
|
|
48
|
+
userAgent: c.req.header("user-agent") ?? null,
|
|
49
|
+
userId: c.get("authUserId") ?? null,
|
|
50
|
+
sessionId: c.get("sessionId") ?? null,
|
|
51
|
+
tenantId: c.get("tenantId") ?? null,
|
|
52
|
+
};
|
|
53
|
+
if (error) {
|
|
54
|
+
entry.err = {
|
|
55
|
+
message: error instanceof Error ? error.message : String(error),
|
|
56
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
onLog(entry);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// onLog error isolation — never affect the response
|
|
64
|
+
}
|
|
65
|
+
if (error)
|
|
66
|
+
throw error;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
export interface RequestSigningOptions {
|
|
4
|
+
/** Allowed age of the timestamp in milliseconds. Default: 300_000 (5 min). */
|
|
5
|
+
tolerance?: number;
|
|
6
|
+
/** Header carrying the HMAC signature. Default: "x-signature". */
|
|
7
|
+
header?: string;
|
|
8
|
+
/** Header carrying the Unix timestamp (seconds or ms). Default: "x-timestamp". */
|
|
9
|
+
timestampHeader?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Middleware that verifies the client has HMAC-signed the canonical request.
|
|
13
|
+
*
|
|
14
|
+
* Canonical string:
|
|
15
|
+
* METHOD\nPATH\nCANONICAL_QUERY\nTIMESTAMP\nBODY
|
|
16
|
+
*
|
|
17
|
+
* When `signing.requestSigning` is false (or not configured), the middleware
|
|
18
|
+
* is a no-op pass-through.
|
|
19
|
+
*/
|
|
20
|
+
export declare const requireSignedRequest: (opts?: RequestSigningOptions) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { getSigningConfig, getSigningSecret } from "../lib/appConfig";
|
|
2
|
+
import { hmacVerify } from "../lib/signing";
|
|
3
|
+
import { HEADER_SIGNATURE, HEADER_TIMESTAMP } from "../lib/constants";
|
|
4
|
+
import { HttpError } from "../lib/HttpError";
|
|
5
|
+
/**
|
|
6
|
+
* Canonicalize the query string for signing.
|
|
7
|
+
*
|
|
8
|
+
* - Sort params by key, then by value for repeated keys
|
|
9
|
+
* - Normalize percent-encoding: decode then re-encode via encodeURIComponent
|
|
10
|
+
* so that %20 and + both canonicalize to %20 (most common source of
|
|
11
|
+
* signature mismatches between clients)
|
|
12
|
+
* - Returns "" when there are no query params (not omitted — omitting the
|
|
13
|
+
* query line would allow ?foo=1 and ?foo=2 to share a valid signature)
|
|
14
|
+
*/
|
|
15
|
+
function canonicalizeQuery(search) {
|
|
16
|
+
// Remove leading "?"
|
|
17
|
+
const qs = search.startsWith("?") ? search.slice(1) : search;
|
|
18
|
+
if (!qs)
|
|
19
|
+
return "";
|
|
20
|
+
const pairs = [];
|
|
21
|
+
for (const part of qs.split("&")) {
|
|
22
|
+
if (!part)
|
|
23
|
+
continue;
|
|
24
|
+
const eqIdx = part.indexOf("=");
|
|
25
|
+
const rawKey = eqIdx === -1 ? part : part.slice(0, eqIdx);
|
|
26
|
+
const rawVal = eqIdx === -1 ? "" : part.slice(eqIdx + 1);
|
|
27
|
+
// Normalize encoding: decode then re-encode
|
|
28
|
+
const key = encodeURIComponent(decodeURIComponent(rawKey.replace(/\+/g, " ")));
|
|
29
|
+
const val = encodeURIComponent(decodeURIComponent(rawVal.replace(/\+/g, " ")));
|
|
30
|
+
pairs.push([key, val]);
|
|
31
|
+
}
|
|
32
|
+
// Sort by key, then by value for repeated keys
|
|
33
|
+
pairs.sort((a, b) => {
|
|
34
|
+
if (a[0] !== b[0])
|
|
35
|
+
return a[0] < b[0] ? -1 : 1;
|
|
36
|
+
return a[1] < b[1] ? -1 : 1;
|
|
37
|
+
});
|
|
38
|
+
return pairs.map(([k, v]) => `${k}=${v}`).join("&");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Middleware that verifies the client has HMAC-signed the canonical request.
|
|
42
|
+
*
|
|
43
|
+
* Canonical string:
|
|
44
|
+
* METHOD\nPATH\nCANONICAL_QUERY\nTIMESTAMP\nBODY
|
|
45
|
+
*
|
|
46
|
+
* When `signing.requestSigning` is false (or not configured), the middleware
|
|
47
|
+
* is a no-op pass-through.
|
|
48
|
+
*/
|
|
49
|
+
export const requireSignedRequest = (opts) => async (c, next) => {
|
|
50
|
+
const cfg = getSigningConfig();
|
|
51
|
+
// No-op when request signing is not enabled
|
|
52
|
+
if (!cfg?.requestSigning) {
|
|
53
|
+
await next();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const signingOpts = typeof cfg.requestSigning === "object" ? cfg.requestSigning : {};
|
|
57
|
+
const tolerance = opts?.tolerance ?? signingOpts.tolerance ?? 300_000;
|
|
58
|
+
const sigHeader = opts?.header ?? signingOpts.header ?? HEADER_SIGNATURE;
|
|
59
|
+
const tsHeader = opts?.timestampHeader ?? signingOpts.timestampHeader ?? HEADER_TIMESTAMP;
|
|
60
|
+
// --- Timestamp validation (replay protection) ---
|
|
61
|
+
const rawTs = c.req.header(tsHeader);
|
|
62
|
+
const tsNum = rawTs !== undefined ? parseInt(rawTs, 10) : NaN;
|
|
63
|
+
if (isNaN(tsNum)) {
|
|
64
|
+
throw new HttpError(401, "Unauthorized", "EXPIRED_TIMESTAMP");
|
|
65
|
+
}
|
|
66
|
+
// Auto-detect Unix seconds (< 1e10) vs milliseconds
|
|
67
|
+
const tsMs = tsNum < 1e10 ? tsNum * 1000 : tsNum;
|
|
68
|
+
if (Math.abs(Date.now() - tsMs) > tolerance) {
|
|
69
|
+
throw new HttpError(401, "Unauthorized", "EXPIRED_TIMESTAMP");
|
|
70
|
+
}
|
|
71
|
+
// --- Signature header ---
|
|
72
|
+
const sig = c.req.header(sigHeader);
|
|
73
|
+
if (!sig) {
|
|
74
|
+
throw new HttpError(401, "Unauthorized", "INVALID_SIGNATURE");
|
|
75
|
+
}
|
|
76
|
+
// --- Secret resolution ---
|
|
77
|
+
const secret = getSigningSecret();
|
|
78
|
+
if (!secret) {
|
|
79
|
+
throw new HttpError(500, "Internal Server Error", "SIGNING_SECRET_MISSING");
|
|
80
|
+
}
|
|
81
|
+
// --- Build canonical string ---
|
|
82
|
+
const method = c.req.method.toUpperCase();
|
|
83
|
+
const url = new URL(c.req.url);
|
|
84
|
+
const path = url.pathname;
|
|
85
|
+
const query = canonicalizeQuery(url.search);
|
|
86
|
+
const body = await c.req.text();
|
|
87
|
+
const canonical = `${method}\n${path}\n${query}\n${rawTs}\n${body}`;
|
|
88
|
+
// --- HMAC verification ---
|
|
89
|
+
let valid;
|
|
90
|
+
try {
|
|
91
|
+
valid = hmacVerify(canonical, sig, secret);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
valid = false;
|
|
95
|
+
}
|
|
96
|
+
if (!valid) {
|
|
97
|
+
throw new HttpError(401, "Unauthorized", "INVALID_SIGNATURE");
|
|
98
|
+
}
|
|
99
|
+
await next();
|
|
100
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
/**
|
|
4
|
+
* Middleware that blocks authenticated users who have not completed MFA setup.
|
|
5
|
+
*
|
|
6
|
+
* When `auth.mfa.required` is `true`, this middleware is applied globally by
|
|
7
|
+
* `createApp`. It can also be applied per-route for finer control:
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* import { requireMfaSetup } from "@lastshotlabs/bunshot";
|
|
11
|
+
* router.use("/dashboard", userAuth, requireMfaSetup);
|
|
12
|
+
*
|
|
13
|
+
* Exempt paths: `/auth/*`, `/health`, `/docs`, `/openapi.json`, and the root `/`.
|
|
14
|
+
* Unauthenticated requests pass through — use `userAuth` to block those.
|
|
15
|
+
*/
|
|
16
|
+
export declare const requireMfaSetup: MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getAuthAdapter } from "../lib/authAdapter";
|
|
2
|
+
import { HttpError } from "../lib/HttpError";
|
|
3
|
+
const EXEMPT_PREFIXES = ["/auth/", "/health", "/docs", "/openapi.json"];
|
|
4
|
+
/**
|
|
5
|
+
* Middleware that blocks authenticated users who have not completed MFA setup.
|
|
6
|
+
*
|
|
7
|
+
* When `auth.mfa.required` is `true`, this middleware is applied globally by
|
|
8
|
+
* `createApp`. It can also be applied per-route for finer control:
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { requireMfaSetup } from "@lastshotlabs/bunshot";
|
|
12
|
+
* router.use("/dashboard", userAuth, requireMfaSetup);
|
|
13
|
+
*
|
|
14
|
+
* Exempt paths: `/auth/*`, `/health`, `/docs`, `/openapi.json`, and the root `/`.
|
|
15
|
+
* Unauthenticated requests pass through — use `userAuth` to block those.
|
|
16
|
+
*/
|
|
17
|
+
export const requireMfaSetup = async (c, next) => {
|
|
18
|
+
const path = c.req.path;
|
|
19
|
+
// Exempt paths — auth routes (including MFA setup), health, docs, root
|
|
20
|
+
if (path === "/" || EXEMPT_PREFIXES.some((p) => path.startsWith(p))) {
|
|
21
|
+
return next();
|
|
22
|
+
}
|
|
23
|
+
// Only applies to authenticated users — unauthenticated requests pass through
|
|
24
|
+
const userId = c.get("authUserId");
|
|
25
|
+
if (!userId) {
|
|
26
|
+
return next();
|
|
27
|
+
}
|
|
28
|
+
const adapter = getAuthAdapter();
|
|
29
|
+
if (!adapter.isMfaEnabled) {
|
|
30
|
+
return next();
|
|
31
|
+
}
|
|
32
|
+
const enabled = await adapter.isMfaEnabled(userId);
|
|
33
|
+
if (!enabled) {
|
|
34
|
+
throw new HttpError(403, "MFA setup required", "MFA_SETUP_REQUIRED");
|
|
35
|
+
}
|
|
36
|
+
return next();
|
|
37
|
+
};
|
|
@@ -4,10 +4,11 @@ import type { AppEnv } from "../lib/context";
|
|
|
4
4
|
* Middleware factory that enforces role-based access.
|
|
5
5
|
* Requires `identify` to have run first (authUserId must be set).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* Falls back to app-wide roles when no tenant context is present.
|
|
7
|
+
* Effective roles = direct roles ∪ group baseline roles ∪ per-membership roles.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
9
|
+
* When tenant context exists (`tenantId` set on context), checks tenant-scoped
|
|
10
|
+
* direct roles + tenant-scoped group roles.
|
|
11
|
+
* When no tenant context is present, checks app-wide direct roles + app-wide group roles.
|
|
11
12
|
*
|
|
12
13
|
* @example
|
|
13
14
|
* // Allow any authenticated user with the "admin" role
|
|
@@ -21,6 +22,11 @@ export declare const requireRole: ((...roles: string[]) => MiddlewareHandler<App
|
|
|
21
22
|
* Always checks app-wide roles regardless of tenant context.
|
|
22
23
|
* Use for super-admin gates that should ignore tenant scoping.
|
|
23
24
|
*
|
|
25
|
+
* SCOPE CONTRACT: only app-wide direct roles + app-wide group roles count here.
|
|
26
|
+
* Tenant-scoped roles and tenant-scoped group roles are NEVER considered.
|
|
27
|
+
* A user who is "admin" only in a tenant-scoped group is NOT a global admin —
|
|
28
|
+
* they must be assigned the role app-wide.
|
|
29
|
+
*
|
|
24
30
|
* @example
|
|
25
31
|
* app.get("/super-admin", userAuth, requireRole.global("superadmin"), handler)
|
|
26
32
|
*/
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getEffectiveRoles } from "../lib/groups";
|
|
2
2
|
/**
|
|
3
3
|
* Middleware factory that enforces role-based access.
|
|
4
4
|
* Requires `identify` to have run first (authUserId must be set).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* Falls back to app-wide roles when no tenant context is present.
|
|
6
|
+
* Effective roles = direct roles ∪ group baseline roles ∪ per-membership roles.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
8
|
+
* When tenant context exists (`tenantId` set on context), checks tenant-scoped
|
|
9
|
+
* direct roles + tenant-scoped group roles.
|
|
10
|
+
* When no tenant context is present, checks app-wide direct roles + app-wide group roles.
|
|
10
11
|
*
|
|
11
12
|
* @example
|
|
12
13
|
* // Allow any authenticated user with the "admin" role
|
|
@@ -20,28 +21,10 @@ export const requireRole = Object.assign((...roles) => async (c, next) => {
|
|
|
20
21
|
if (!userId) {
|
|
21
22
|
return c.json({ error: "Unauthorized" }, 401);
|
|
22
23
|
}
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
if (
|
|
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
|
|
35
|
-
let userRoles = c.get("roles");
|
|
36
|
-
if (userRoles === null) {
|
|
37
|
-
if (!adapter.getRoles) {
|
|
38
|
-
throw new Error("requireRole used but auth adapter does not implement getRoles");
|
|
39
|
-
}
|
|
40
|
-
userRoles = await adapter.getRoles(userId);
|
|
41
|
-
c.set("roles", userRoles);
|
|
42
|
-
}
|
|
43
|
-
const hasRole = roles.some((role) => userRoles.includes(role));
|
|
44
|
-
if (!hasRole) {
|
|
24
|
+
const tenantId = c.get("tenantId") ?? null;
|
|
25
|
+
const effective = await getEffectiveRoles(userId, tenantId);
|
|
26
|
+
c.set("roles", effective);
|
|
27
|
+
if (!roles.some((r) => effective.includes(r))) {
|
|
45
28
|
return c.json({ error: "Forbidden" }, 403);
|
|
46
29
|
}
|
|
47
30
|
await next();
|
|
@@ -50,6 +33,11 @@ export const requireRole = Object.assign((...roles) => async (c, next) => {
|
|
|
50
33
|
* Always checks app-wide roles regardless of tenant context.
|
|
51
34
|
* Use for super-admin gates that should ignore tenant scoping.
|
|
52
35
|
*
|
|
36
|
+
* SCOPE CONTRACT: only app-wide direct roles + app-wide group roles count here.
|
|
37
|
+
* Tenant-scoped roles and tenant-scoped group roles are NEVER considered.
|
|
38
|
+
* A user who is "admin" only in a tenant-scoped group is NOT a global admin —
|
|
39
|
+
* they must be assigned the role app-wide.
|
|
40
|
+
*
|
|
53
41
|
* @example
|
|
54
42
|
* app.get("/super-admin", userAuth, requireRole.global("superadmin"), handler)
|
|
55
43
|
*/
|
|
@@ -58,17 +46,16 @@ export const requireRole = Object.assign((...roles) => async (c, next) => {
|
|
|
58
46
|
if (!userId) {
|
|
59
47
|
return c.json({ error: "Unauthorized" }, 401);
|
|
60
48
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
userRoles = await adapter.getRoles(userId);
|
|
68
|
-
c.set("roles", userRoles);
|
|
49
|
+
// In development, log when tenant context is present but intentionally ignored.
|
|
50
|
+
// console.info is used deliberately: console.debug is suppressed by default in most
|
|
51
|
+
// runtimes, so info gives reliably visible output during development without being
|
|
52
|
+
// noisy in production (this branch never executes there).
|
|
53
|
+
if (process.env.NODE_ENV !== "production" && c.get("tenantId")) {
|
|
54
|
+
console.info("[requireRole.global] tenant context present but intentionally ignored — checking app-wide roles only");
|
|
69
55
|
}
|
|
70
|
-
const
|
|
71
|
-
|
|
56
|
+
const effective = await getEffectiveRoles(userId, null);
|
|
57
|
+
c.set("roles", effective);
|
|
58
|
+
if (!roles.some((r) => effective.includes(r))) {
|
|
72
59
|
return c.json({ error: "Forbidden" }, 403);
|
|
73
60
|
}
|
|
74
61
|
await next();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
/**
|
|
4
|
+
* Middleware that requires the JWT to contain all specified scopes.
|
|
5
|
+
* Reads scope from `tokenPayload.scope` (set by identify middleware).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* router.get("/data", requireScope("read:data"), handler);
|
|
9
|
+
*/
|
|
10
|
+
export declare const requireScope: (...requiredScopes: string[]) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { HttpError } from "../lib/HttpError";
|
|
2
|
+
/**
|
|
3
|
+
* Middleware that requires the JWT to contain all specified scopes.
|
|
4
|
+
* Reads scope from `tokenPayload.scope` (set by identify middleware).
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* router.get("/data", requireScope("read:data"), handler);
|
|
8
|
+
*/
|
|
9
|
+
export const requireScope = (...requiredScopes) => async (c, next) => {
|
|
10
|
+
const payload = c.get("tokenPayload");
|
|
11
|
+
if (!payload) {
|
|
12
|
+
throw new HttpError(401, "Authentication required");
|
|
13
|
+
}
|
|
14
|
+
const scope = payload.scope;
|
|
15
|
+
if (!scope) {
|
|
16
|
+
throw new HttpError(403, "Insufficient scope", "INSUFFICIENT_SCOPE");
|
|
17
|
+
}
|
|
18
|
+
const grantedScopes = scope.split(" ");
|
|
19
|
+
for (const required of requiredScopes) {
|
|
20
|
+
if (!grantedScopes.includes(required)) {
|
|
21
|
+
throw new HttpError(403, "Insufficient scope", "INSUFFICIENT_SCOPE");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
await next();
|
|
25
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
export interface StepUpOptions {
|
|
4
|
+
/** Max age in seconds since last MFA verification. Default: 300 (5 min). */
|
|
5
|
+
maxAge?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Middleware that requires the user to have recently completed step-up MFA.
|
|
9
|
+
*
|
|
10
|
+
* Attach to sensitive routes that require fresh MFA verification:
|
|
11
|
+
* ```
|
|
12
|
+
* router.post("/transfer", userAuth, requireStepUp(), transferHandler);
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The user completes step-up via POST /auth/step-up.
|
|
16
|
+
* After successful step-up, mfaVerifiedAt is stored in their session.
|
|
17
|
+
*/
|
|
18
|
+
export declare const requireStepUp: (opts?: StepUpOptions) => MiddlewareHandler<AppEnv>;
|