@lastshotlabs/bunshot 0.0.20 → 0.0.25
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 +44 -0
- package/dist/adapters/memoryAuth.d.ts +7 -0
- package/dist/adapters/memoryAuth.js +144 -0
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +120 -0
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +7 -0
- package/dist/adapters/sqliteAuth.js +199 -0
- package/dist/app.d.ts +100 -3
- package/dist/app.js +248 -47
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +49 -7
- package/dist/index.js +35 -5
- package/dist/lib/HttpError.d.ts +5 -0
- package/dist/lib/HttpError.js +7 -0
- package/dist/lib/appConfig.d.ts +44 -0
- package/dist/lib/appConfig.js +16 -0
- package/dist/lib/auditLog.d.ts +52 -0
- package/dist/lib/auditLog.js +201 -0
- package/dist/lib/authAdapter.d.ts +69 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +19 -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/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- 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/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/session.d.ts +4 -0
- package/dist/lib/session.js +56 -2
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +180 -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/tenant.js +2 -2
- package/dist/lib/upload.d.ts +35 -0
- package/dist/lib/upload.js +87 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +21 -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/cacheResponse.js +5 -1
- package/dist/middleware/csrf.js +10 -0
- package/dist/middleware/identify.js +57 -9
- 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 +99 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +36 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- 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 +57 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -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/routes/auth.js +84 -6
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +47 -45
- package/dist/routes/metrics.d.ts +7 -0
- package/dist/routes/metrics.js +52 -0
- package/dist/routes/mfa.js +4 -0
- package/dist/routes/uploads.d.ts +2 -0
- package/dist/routes/uploads.js +135 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/ws/index.js +3 -0
- package/docs/sections/auth-flow/full.md +779 -634
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +365 -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 +127 -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/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 +199 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +83 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +16 -4
|
@@ -1,9 +1,21 @@
|
|
|
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
5
|
import { log } from "../lib/logger";
|
|
6
|
-
import { getTrackLastActive } from "../lib/appConfig";
|
|
6
|
+
import { getTrackLastActive, getSigningConfig } from "../lib/appConfig";
|
|
7
|
+
import { getClientIp } from "../lib/clientIp";
|
|
8
|
+
import { sha256 } from "../lib/crypto";
|
|
9
|
+
function computeFingerprint(c, fields) {
|
|
10
|
+
const parts = fields.map((f) => {
|
|
11
|
+
if (f === "ip")
|
|
12
|
+
return getClientIp(c) ?? "";
|
|
13
|
+
if (f === "ua")
|
|
14
|
+
return c.req.header("user-agent") ?? "";
|
|
15
|
+
return c.req.header("accept-language") ?? "";
|
|
16
|
+
});
|
|
17
|
+
return sha256(parts.join(":"));
|
|
18
|
+
}
|
|
7
19
|
export const identify = async (c, next) => {
|
|
8
20
|
c.set("authUserId", null);
|
|
9
21
|
c.set("roles", null);
|
|
@@ -22,13 +34,49 @@ export const identify = async (c, next) => {
|
|
|
22
34
|
const stored = await getSession(sessionId);
|
|
23
35
|
log(`[identify] token for authUserId=${payload.sub} verified, checking session...`);
|
|
24
36
|
if (stored === token) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
const signingCfg = getSigningConfig();
|
|
38
|
+
const bindingCfg = signingCfg?.sessionBinding;
|
|
39
|
+
if (bindingCfg) {
|
|
40
|
+
const bindingOpts = typeof bindingCfg === "object" ? bindingCfg : {};
|
|
41
|
+
const fields = bindingOpts.fields ?? ["ip", "ua"];
|
|
42
|
+
const onMismatch = bindingOpts.onMismatch ?? "unauthenticate";
|
|
43
|
+
const current = computeFingerprint(c, fields);
|
|
44
|
+
const storedFp = await getSessionFingerprint(sessionId);
|
|
45
|
+
if (storedFp === null) {
|
|
46
|
+
// First authenticated request — store the fingerprint
|
|
47
|
+
setSessionFingerprint(sessionId, current).catch(() => {
|
|
48
|
+
log(`[identify] failed to store fingerprint for sessionId=${sessionId}`);
|
|
49
|
+
});
|
|
50
|
+
c.set("authUserId", payload.sub);
|
|
51
|
+
c.set("sessionId", sessionId);
|
|
52
|
+
}
|
|
53
|
+
else if (storedFp === current) {
|
|
54
|
+
c.set("authUserId", payload.sub);
|
|
55
|
+
c.set("sessionId", sessionId);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
log(`[identify] fingerprint mismatch for sessionId=${sessionId} onMismatch=${onMismatch}`);
|
|
59
|
+
if (onMismatch === "reject") {
|
|
60
|
+
return c.json({ error: "Unauthorized", code: "FINGERPRINT_MISMATCH" }, 401);
|
|
61
|
+
}
|
|
62
|
+
else if (onMismatch === "log-only") {
|
|
63
|
+
c.set("authUserId", payload.sub);
|
|
64
|
+
c.set("sessionId", sessionId);
|
|
65
|
+
}
|
|
66
|
+
// onMismatch === "unauthenticate" — leave authUserId null (already null)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
c.set("authUserId", payload.sub);
|
|
71
|
+
c.set("sessionId", sessionId);
|
|
72
|
+
}
|
|
73
|
+
if (c.get("authUserId")) {
|
|
74
|
+
log(`[identify] authUserId=${payload.sub} sessionId=${sessionId}`);
|
|
75
|
+
if (getTrackLastActive()) {
|
|
76
|
+
updateSessionLastActive(sessionId).catch(() => {
|
|
77
|
+
log(`[identify] failed to update lastActiveAt for sessionId=${sessionId}`);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
32
80
|
}
|
|
33
81
|
}
|
|
34
82
|
else {
|
|
@@ -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,99 @@
|
|
|
1
|
+
import { getSigningConfig, getSigningSecret } from "../lib/appConfig";
|
|
2
|
+
import { hmacVerify } from "../lib/signing";
|
|
3
|
+
import { HEADER_SIGNATURE, HEADER_TIMESTAMP } from "../lib/constants";
|
|
4
|
+
/**
|
|
5
|
+
* Canonicalize the query string for signing.
|
|
6
|
+
*
|
|
7
|
+
* - Sort params by key, then by value for repeated keys
|
|
8
|
+
* - Normalize percent-encoding: decode then re-encode via encodeURIComponent
|
|
9
|
+
* so that %20 and + both canonicalize to %20 (most common source of
|
|
10
|
+
* signature mismatches between clients)
|
|
11
|
+
* - Returns "" when there are no query params (not omitted — omitting the
|
|
12
|
+
* query line would allow ?foo=1 and ?foo=2 to share a valid signature)
|
|
13
|
+
*/
|
|
14
|
+
function canonicalizeQuery(search) {
|
|
15
|
+
// Remove leading "?"
|
|
16
|
+
const qs = search.startsWith("?") ? search.slice(1) : search;
|
|
17
|
+
if (!qs)
|
|
18
|
+
return "";
|
|
19
|
+
const pairs = [];
|
|
20
|
+
for (const part of qs.split("&")) {
|
|
21
|
+
if (!part)
|
|
22
|
+
continue;
|
|
23
|
+
const eqIdx = part.indexOf("=");
|
|
24
|
+
const rawKey = eqIdx === -1 ? part : part.slice(0, eqIdx);
|
|
25
|
+
const rawVal = eqIdx === -1 ? "" : part.slice(eqIdx + 1);
|
|
26
|
+
// Normalize encoding: decode then re-encode
|
|
27
|
+
const key = encodeURIComponent(decodeURIComponent(rawKey.replace(/\+/g, " ")));
|
|
28
|
+
const val = encodeURIComponent(decodeURIComponent(rawVal.replace(/\+/g, " ")));
|
|
29
|
+
pairs.push([key, val]);
|
|
30
|
+
}
|
|
31
|
+
// Sort by key, then by value for repeated keys
|
|
32
|
+
pairs.sort((a, b) => {
|
|
33
|
+
if (a[0] !== b[0])
|
|
34
|
+
return a[0] < b[0] ? -1 : 1;
|
|
35
|
+
return a[1] < b[1] ? -1 : 1;
|
|
36
|
+
});
|
|
37
|
+
return pairs.map(([k, v]) => `${k}=${v}`).join("&");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Middleware that verifies the client has HMAC-signed the canonical request.
|
|
41
|
+
*
|
|
42
|
+
* Canonical string:
|
|
43
|
+
* METHOD\nPATH\nCANONICAL_QUERY\nTIMESTAMP\nBODY
|
|
44
|
+
*
|
|
45
|
+
* When `signing.requestSigning` is false (or not configured), the middleware
|
|
46
|
+
* is a no-op pass-through.
|
|
47
|
+
*/
|
|
48
|
+
export const requireSignedRequest = (opts) => async (c, next) => {
|
|
49
|
+
const cfg = getSigningConfig();
|
|
50
|
+
// No-op when request signing is not enabled
|
|
51
|
+
if (!cfg?.requestSigning) {
|
|
52
|
+
await next();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const signingOpts = typeof cfg.requestSigning === "object" ? cfg.requestSigning : {};
|
|
56
|
+
const tolerance = opts?.tolerance ?? signingOpts.tolerance ?? 300_000;
|
|
57
|
+
const sigHeader = opts?.header ?? signingOpts.header ?? HEADER_SIGNATURE;
|
|
58
|
+
const tsHeader = opts?.timestampHeader ?? signingOpts.timestampHeader ?? HEADER_TIMESTAMP;
|
|
59
|
+
// --- Timestamp validation (replay protection) ---
|
|
60
|
+
const rawTs = c.req.header(tsHeader);
|
|
61
|
+
const tsNum = rawTs !== undefined ? parseInt(rawTs, 10) : NaN;
|
|
62
|
+
if (isNaN(tsNum)) {
|
|
63
|
+
return c.json({ error: "Unauthorized", code: "EXPIRED_TIMESTAMP" }, 401);
|
|
64
|
+
}
|
|
65
|
+
// Auto-detect Unix seconds (< 1e10) vs milliseconds
|
|
66
|
+
const tsMs = tsNum < 1e10 ? tsNum * 1000 : tsNum;
|
|
67
|
+
if (Math.abs(Date.now() - tsMs) > tolerance) {
|
|
68
|
+
return c.json({ error: "Unauthorized", code: "EXPIRED_TIMESTAMP" }, 401);
|
|
69
|
+
}
|
|
70
|
+
// --- Signature header ---
|
|
71
|
+
const sig = c.req.header(sigHeader);
|
|
72
|
+
if (!sig) {
|
|
73
|
+
return c.json({ error: "Unauthorized", code: "INVALID_SIGNATURE" }, 401);
|
|
74
|
+
}
|
|
75
|
+
// --- Secret resolution ---
|
|
76
|
+
const secret = getSigningSecret();
|
|
77
|
+
if (!secret) {
|
|
78
|
+
return c.json({ error: "Internal Server Error", code: "SIGNING_SECRET_MISSING" }, 500);
|
|
79
|
+
}
|
|
80
|
+
// --- Build canonical string ---
|
|
81
|
+
const method = c.req.method.toUpperCase();
|
|
82
|
+
const url = new URL(c.req.url);
|
|
83
|
+
const path = url.pathname;
|
|
84
|
+
const query = canonicalizeQuery(url.search);
|
|
85
|
+
const body = await c.req.text();
|
|
86
|
+
const canonical = `${method}\n${path}\n${query}\n${rawTs}\n${body}`;
|
|
87
|
+
// --- HMAC verification ---
|
|
88
|
+
let valid;
|
|
89
|
+
try {
|
|
90
|
+
valid = hmacVerify(canonical, sig, secret);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
valid = false;
|
|
94
|
+
}
|
|
95
|
+
if (!valid) {
|
|
96
|
+
return c.json({ error: "Unauthorized", code: "INVALID_SIGNATURE" }, 401);
|
|
97
|
+
}
|
|
98
|
+
await next();
|
|
99
|
+
};
|
|
@@ -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,36 @@
|
|
|
1
|
+
import { getAuthAdapter } from "../lib/authAdapter";
|
|
2
|
+
const EXEMPT_PREFIXES = ["/auth/", "/health", "/docs", "/openapi.json"];
|
|
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 const requireMfaSetup = async (c, next) => {
|
|
17
|
+
const path = c.req.path;
|
|
18
|
+
// Exempt paths — auth routes (including MFA setup), health, docs, root
|
|
19
|
+
if (path === "/" || EXEMPT_PREFIXES.some((p) => path.startsWith(p))) {
|
|
20
|
+
return next();
|
|
21
|
+
}
|
|
22
|
+
// Only applies to authenticated users — unauthenticated requests pass through
|
|
23
|
+
const userId = c.get("authUserId");
|
|
24
|
+
if (!userId) {
|
|
25
|
+
return next();
|
|
26
|
+
}
|
|
27
|
+
const adapter = getAuthAdapter();
|
|
28
|
+
if (!adapter.isMfaEnabled) {
|
|
29
|
+
return next();
|
|
30
|
+
}
|
|
31
|
+
const enabled = await adapter.isMfaEnabled(userId);
|
|
32
|
+
if (!enabled) {
|
|
33
|
+
return c.json({ error: "MFA setup required", code: "MFA_SETUP_REQUIRED" }, 403);
|
|
34
|
+
}
|
|
35
|
+
return next();
|
|
36
|
+
};
|
|
@@ -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,5 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
import type { UploadOpts } from "../lib/upload";
|
|
4
|
+
export type UploadMiddlewareOptions = UploadOpts;
|
|
5
|
+
export declare const handleUpload: (opts?: UploadMiddlewareOptions) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { parseUpload, getUploadConfig } from "../lib/upload";
|
|
2
|
+
export const handleUpload = (opts) => {
|
|
3
|
+
return async (c, next) => {
|
|
4
|
+
const config = getUploadConfig();
|
|
5
|
+
const merged = { ...config, ...opts };
|
|
6
|
+
const maxFileSize = merged.maxFileSize ?? 10 * 1024 * 1024;
|
|
7
|
+
const maxFiles = merged.maxFiles ?? 10;
|
|
8
|
+
// Content-Length pre-check to avoid Bun killing the connection
|
|
9
|
+
const contentLength = Number(c.req.header("content-length") ?? 0);
|
|
10
|
+
if (contentLength > 0 && contentLength > maxFileSize * maxFiles) {
|
|
11
|
+
return c.json({ error: `Request body too large. Maximum is ${maxFileSize * maxFiles} bytes` }, 413);
|
|
12
|
+
}
|
|
13
|
+
let results;
|
|
14
|
+
try {
|
|
15
|
+
results = await parseUpload(c, opts);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err?.status === 400)
|
|
19
|
+
return c.json({ error: err.message }, 400);
|
|
20
|
+
if (err?.status === 413)
|
|
21
|
+
return c.json({ error: err.message }, 413);
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
c.set("uploadResults", results);
|
|
25
|
+
await next();
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MiddlewareHandler, Context } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
export interface WebhookTimestampOptions {
|
|
4
|
+
/** Header name containing the Unix timestamp (seconds or ms). */
|
|
5
|
+
header: string;
|
|
6
|
+
/**
|
|
7
|
+
* Allowed age of the timestamp in milliseconds.
|
|
8
|
+
* Values below 1e10 in the header are treated as Unix seconds and auto-converted.
|
|
9
|
+
*/
|
|
10
|
+
tolerance: number;
|
|
11
|
+
}
|
|
12
|
+
export interface WebhookAuthOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Shared HMAC secret. Pass a function for dynamic resolution
|
|
15
|
+
* (e.g. per-tenant secret lookup). If the function throws, a 500 is returned.
|
|
16
|
+
*/
|
|
17
|
+
secret: string | ((c: Context<AppEnv>) => string | Promise<string>);
|
|
18
|
+
/** Header that carries the signature. Default: `"x-webhook-signature"`. */
|
|
19
|
+
header?: string;
|
|
20
|
+
/** HMAC algorithm. Default: `"sha256"`. */
|
|
21
|
+
algorithm?: "sha256" | "sha512" | "sha1";
|
|
22
|
+
/**
|
|
23
|
+
* Strip this prefix from the signature header value before comparing.
|
|
24
|
+
* e.g. `"sha256="` for GitHub-style `X-Hub-Signature-256: sha256=<hex>`.
|
|
25
|
+
*/
|
|
26
|
+
prefix?: string;
|
|
27
|
+
/** Optional replay-protection via a timestamp header. */
|
|
28
|
+
timestamp?: WebhookTimestampOptions;
|
|
29
|
+
}
|
|
30
|
+
export declare const webhookAuth: (options: WebhookAuthOptions) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHmac } from "crypto";
|
|
2
|
+
import { timingSafeEqual } from "../lib/crypto";
|
|
3
|
+
export const webhookAuth = (options) => async (c, next) => {
|
|
4
|
+
const algorithm = options.algorithm ?? "sha256";
|
|
5
|
+
const sigHeader = options.header ?? "x-webhook-signature";
|
|
6
|
+
// --- Optional timestamp replay protection ---
|
|
7
|
+
if (options.timestamp) {
|
|
8
|
+
const { header: tsHeader, tolerance } = options.timestamp;
|
|
9
|
+
const rawTs = c.req.header(tsHeader);
|
|
10
|
+
const tsNum = rawTs !== undefined ? parseInt(rawTs, 10) : NaN;
|
|
11
|
+
if (isNaN(tsNum)) {
|
|
12
|
+
return c.json({ error: "Unauthorized", code: "EXPIRED_TIMESTAMP" }, 401);
|
|
13
|
+
}
|
|
14
|
+
// Auto-detect Unix seconds (< 1e10) vs milliseconds
|
|
15
|
+
const tsMs = tsNum < 1e10 ? tsNum * 1000 : tsNum;
|
|
16
|
+
if (Math.abs(Date.now() - tsMs) > tolerance) {
|
|
17
|
+
return c.json({ error: "Unauthorized", code: "EXPIRED_TIMESTAMP" }, 401);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// --- Signature header ---
|
|
21
|
+
const rawSig = c.req.header(sigHeader);
|
|
22
|
+
if (!rawSig) {
|
|
23
|
+
return c.json({ error: "Unauthorized", code: "INVALID_SIGNATURE" }, 401);
|
|
24
|
+
}
|
|
25
|
+
const provided = options.prefix && rawSig.startsWith(options.prefix)
|
|
26
|
+
? rawSig.slice(options.prefix.length)
|
|
27
|
+
: rawSig;
|
|
28
|
+
// --- Secret resolution ---
|
|
29
|
+
let secret;
|
|
30
|
+
if (typeof options.secret === "function") {
|
|
31
|
+
try {
|
|
32
|
+
secret = await options.secret(c);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return c.json({ error: "Internal Server Error", code: "WEBHOOK_SECRET_ERROR" }, 500);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
secret = options.secret;
|
|
40
|
+
}
|
|
41
|
+
// --- Body reading (Hono caches this — downstream c.req.json() still works) ---
|
|
42
|
+
const body = await c.req.text();
|
|
43
|
+
// --- HMAC computation & comparison ---
|
|
44
|
+
const computed = createHmac(algorithm, secret).update(body).digest("hex");
|
|
45
|
+
let valid;
|
|
46
|
+
try {
|
|
47
|
+
valid = timingSafeEqual(computed, provided);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// timingSafeEqual can throw if buffer byte lengths differ (e.g. multi-byte Unicode in sig)
|
|
51
|
+
valid = false;
|
|
52
|
+
}
|
|
53
|
+
if (!valid) {
|
|
54
|
+
return c.json({ error: "Unauthorized", code: "INVALID_SIGNATURE" }, 401);
|
|
55
|
+
}
|
|
56
|
+
await next();
|
|
57
|
+
};
|