@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
package/dist/app.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import { cors } from "hono/cors";
|
|
3
|
-
import { logger } from "hono/logger";
|
|
4
3
|
import { secureHeaders } from "hono/secure-headers";
|
|
5
4
|
import { Scalar } from "@scalar/hono-api-reference";
|
|
6
|
-
import { HttpError } from "./lib/HttpError";
|
|
5
|
+
import { HttpError, ValidationError } from "./lib/HttpError";
|
|
7
6
|
import { rateLimit } from "./middleware/rateLimit";
|
|
8
7
|
import { bearerAuth } from "./middleware/bearerAuth";
|
|
9
8
|
import { identify } from "./middleware/identify";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { defaultValidationErrorFormatter } from "./lib/context";
|
|
10
|
+
import { HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN, HEADER_CSRF_TOKEN, HEADER_REQUEST_ID } from "./lib/constants";
|
|
11
|
+
import { requestId } from "./middleware/requestId";
|
|
12
|
+
import { requestLogger } from "./middleware/requestLogger";
|
|
13
|
+
import { setAppName, setAppRoles, setDefaultRole, setPrimaryField, setEmailVerificationConfig, setPasswordResetConfig, setPasswordPolicy, setMaxSessions, setPersistSessionMetadata, setIncludeInactiveSessions, setTrackLastActive, setRefreshTokenConfig, setMfaConfig, setCsrfEnabled, setSigningConfig, setJwtConfig, setCheckSuspensionOnIdentify, setBreachedPasswordConfig, setCaptchaConfig, setStepUpConfig, setM2MConfig, setOidcConfig, setSamlConfig, setScimConfig } from "./lib/appConfig";
|
|
12
14
|
import { setEmailVerificationStore } from "./lib/emailVerification";
|
|
13
15
|
import { setPasswordResetStore } from "./lib/resetPassword";
|
|
14
16
|
import { setAuthRateLimitStore } from "./lib/authRateLimit";
|
|
@@ -22,10 +24,18 @@ import { connectRedis } from "./lib/redis";
|
|
|
22
24
|
import { setSessionStore } from "./lib/session";
|
|
23
25
|
import { setCacheStore } from "./middleware/cacheResponse";
|
|
24
26
|
import { maybeAutoRegister } from "./lib/createRoute";
|
|
27
|
+
import { setStorageAdapter, setUploadConfig } from "./lib/upload";
|
|
28
|
+
import { validateJwtSecrets } from "./lib/jwt";
|
|
25
29
|
export const createApp = async (config) => {
|
|
26
30
|
const { routesDir, app: appConfig = {}, auth: authConfig = {}, security: securityConfig = {}, middleware = [], db = {}, } = config;
|
|
31
|
+
if (config.securityEvents) {
|
|
32
|
+
const { setSecurityEventConfig } = await import("./lib/securityEvents");
|
|
33
|
+
setSecurityEventConfig(config.securityEvents);
|
|
34
|
+
}
|
|
27
35
|
const appName = appConfig.name ?? "Bun Core API";
|
|
28
36
|
const openApiVersion = appConfig.version ?? "1.0.0";
|
|
37
|
+
// Validate JWT secrets eagerly so misconfiguration is caught at startup
|
|
38
|
+
validateJwtSecrets();
|
|
29
39
|
// Trust-proxy for IP extraction
|
|
30
40
|
const { setTrustProxy } = await import("./lib/clientIp");
|
|
31
41
|
setTrustProxy(securityConfig.trustProxy ?? false);
|
|
@@ -33,6 +43,13 @@ export const createApp = async (config) => {
|
|
|
33
43
|
if (corsOrigins === "*" && process.env.NODE_ENV === "production") {
|
|
34
44
|
console.warn("[security] CORS is set to wildcard (*) in production. Configure security.cors with specific origins to restrict cross-origin access.");
|
|
35
45
|
}
|
|
46
|
+
if (securityConfig.csrf?.enabled && corsOrigins === "*") {
|
|
47
|
+
if (process.env.NODE_ENV === "production") {
|
|
48
|
+
throw new Error("[security] CSRF protection with wildcard CORS (*) is unsafe. " +
|
|
49
|
+
"Set security.cors to specific origins when using CSRF.");
|
|
50
|
+
}
|
|
51
|
+
console.warn("[security] CSRF is enabled with wildcard CORS. This will be rejected in production.");
|
|
52
|
+
}
|
|
36
53
|
const rlConfig = securityConfig.rateLimit ?? { windowMs: 60_000, max: 100 };
|
|
37
54
|
const botCfg = securityConfig.botProtection ?? {};
|
|
38
55
|
const enableBearerAuth = securityConfig.bearerAuth !== false;
|
|
@@ -136,16 +153,76 @@ export const createApp = async (config) => {
|
|
|
136
153
|
setPasswordResetConfig(passwordReset ?? null);
|
|
137
154
|
setPasswordPolicy(authConfig.passwordPolicy ?? {});
|
|
138
155
|
setPasswordResetStore(sessions);
|
|
156
|
+
const { setDeletionCancelTokenStore } = await import("./lib/deletionCancelToken");
|
|
157
|
+
setDeletionCancelTokenStore(sessions);
|
|
139
158
|
setAuthRateLimitStore(authRateLimit?.store ?? (enableRedis ? "redis" : "memory"));
|
|
159
|
+
if (authRateLimit?.credentialStuffing) {
|
|
160
|
+
const { setCredentialStuffingConfig } = await import("./lib/credentialStuffing");
|
|
161
|
+
setCredentialStuffingConfig(authRateLimit.credentialStuffing);
|
|
162
|
+
}
|
|
140
163
|
setMaxSessions(sessionPolicy.maxSessions ?? 6);
|
|
141
164
|
setPersistSessionMetadata(sessionPolicy.persistSessionMetadata ?? true);
|
|
142
165
|
setIncludeInactiveSessions(sessionPolicy.includeInactiveSessions ?? false);
|
|
143
166
|
setTrackLastActive(sessionPolicy.trackLastActive ?? false);
|
|
144
167
|
setRefreshTokenConfig(authConfig.refreshTokens ?? null);
|
|
145
168
|
setMfaConfig(authConfig.mfa ?? null);
|
|
169
|
+
if (authConfig.jwt)
|
|
170
|
+
setJwtConfig(authConfig.jwt);
|
|
171
|
+
if (authConfig.checkSuspensionOnIdentify)
|
|
172
|
+
setCheckSuspensionOnIdentify(true);
|
|
173
|
+
if (authConfig.breachedPasswordCheck)
|
|
174
|
+
setBreachedPasswordConfig(authConfig.breachedPasswordCheck);
|
|
175
|
+
if (authConfig.stepUp)
|
|
176
|
+
setStepUpConfig(authConfig.stepUp);
|
|
177
|
+
// JWT config
|
|
178
|
+
if (authConfig.jwt)
|
|
179
|
+
setJwtConfig(authConfig.jwt);
|
|
180
|
+
// OIDC: load keys, set RS256, mount discovery routes
|
|
181
|
+
if (authConfig.oidc) {
|
|
182
|
+
setOidcConfig(authConfig.oidc);
|
|
183
|
+
// Override JWT config with OIDC issuer and RS256
|
|
184
|
+
setJwtConfig({ ...(authConfig.jwt ?? {}), issuer: authConfig.oidc.issuer, algorithm: "RS256" });
|
|
185
|
+
const { loadJwksKey, generateAndLoadKeyPair, loadPreviousKey } = await import("./lib/jwks");
|
|
186
|
+
const { _setAlgorithm } = await import("./lib/jwt");
|
|
187
|
+
if (authConfig.oidc.signingKey) {
|
|
188
|
+
await loadJwksKey(authConfig.oidc.signingKey);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
await generateAndLoadKeyPair();
|
|
192
|
+
}
|
|
193
|
+
for (const prev of authConfig.oidc.previousKeys ?? []) {
|
|
194
|
+
await loadPreviousKey(prev);
|
|
195
|
+
}
|
|
196
|
+
_setAlgorithm("RS256");
|
|
197
|
+
}
|
|
146
198
|
if (oauthProviders)
|
|
147
199
|
initOAuthProviders(oauthProviders);
|
|
148
200
|
const configuredOAuth = getConfiguredOAuthProviders();
|
|
201
|
+
// Start the account deletion worker when queued deletion is configured.
|
|
202
|
+
// The worker runs in-process alongside the API server.
|
|
203
|
+
if (authConfig.accountDeletion?.queued && enableAuthRoutes) {
|
|
204
|
+
try {
|
|
205
|
+
const { createWorker } = await import("./lib/queue");
|
|
206
|
+
const appName_ = appName;
|
|
207
|
+
const accountDeletion_ = authConfig.accountDeletion;
|
|
208
|
+
createWorker(`${appName_}:account-deletions`, async (job) => {
|
|
209
|
+
const { userId } = job.data;
|
|
210
|
+
const adapter_ = authAdapter;
|
|
211
|
+
if (accountDeletion_.onBeforeDelete)
|
|
212
|
+
await accountDeletion_.onBeforeDelete(userId);
|
|
213
|
+
if (adapter_.deleteUser)
|
|
214
|
+
await adapter_.deleteUser(userId);
|
|
215
|
+
if (accountDeletion_.onAfterDelete)
|
|
216
|
+
await accountDeletion_.onAfterDelete(userId);
|
|
217
|
+
}, { concurrency: 1 });
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
if (err?.message?.includes("bullmq is not installed")) {
|
|
221
|
+
throw new Error("createApp: accountDeletion.queued requires BullMQ. Run: bun add bullmq");
|
|
222
|
+
}
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
149
226
|
// OAuth paths must bypass bearer auth — initiation and link routes are browser redirects,
|
|
150
227
|
// callbacks come from external providers; none can send a bearer token header.
|
|
151
228
|
const oauthBypass = configuredOAuth.flatMap((p) => [
|
|
@@ -153,10 +230,44 @@ export const createApp = async (config) => {
|
|
|
153
230
|
`/auth/${p}/callback`,
|
|
154
231
|
`/auth/${p}/link`,
|
|
155
232
|
]);
|
|
156
|
-
const DEFAULT_BYPASS = ["/docs", "/openapi.json", "/sw.js", "/health", "/"];
|
|
157
|
-
|
|
233
|
+
const DEFAULT_BYPASS = ["/docs", "/openapi.json", "/sw.js", "/health", "/", "/metrics", "/oauth/token", "/.well-known/openid-configuration", "/.well-known/jwks.json", "/auth/saml/*", "/scim/v2/*"];
|
|
234
|
+
// Add per-version docs/spec paths when versioning is configured
|
|
235
|
+
const versionBypass = config.versioning
|
|
236
|
+
? config.versioning.versions.flatMap((v) => [`/${v}/docs`, `/${v}/openapi.json`])
|
|
237
|
+
: [];
|
|
238
|
+
const bearerAuthBypass = [...DEFAULT_BYPASS, ...versionBypass, ...oauthBypass, ...extraBypass];
|
|
158
239
|
const app = new OpenAPIHono();
|
|
159
|
-
app.use(
|
|
240
|
+
app.use(requestId);
|
|
241
|
+
// Set the validation error formatter on context so defaultHook and onError both pick it up
|
|
242
|
+
const validationFormatter = config.validation?.formatError ?? defaultValidationErrorFormatter;
|
|
243
|
+
app.use("*", async (c, next) => {
|
|
244
|
+
c.set("validationErrorFormatter", validationFormatter);
|
|
245
|
+
await next();
|
|
246
|
+
});
|
|
247
|
+
// Metrics collection middleware (before requestLogger so it captures all requests)
|
|
248
|
+
if (config.metrics?.enabled) {
|
|
249
|
+
const metricsAuth = config.metrics.auth ?? "none";
|
|
250
|
+
if (metricsAuth === "none" && !config.metrics.unsafePublic) {
|
|
251
|
+
if (process.env.NODE_ENV === "production") {
|
|
252
|
+
throw new Error("[security] metrics.auth is required in production. Set metrics.auth or explicitly set unsafePublic: true with auth: \"none\".");
|
|
253
|
+
}
|
|
254
|
+
console.warn("[security] /metrics is enabled without auth. Configure metrics.auth for production.");
|
|
255
|
+
}
|
|
256
|
+
const { metricsCollector } = await import("./middleware/metrics");
|
|
257
|
+
app.use(metricsCollector({
|
|
258
|
+
excludePaths: config.metrics.excludePaths,
|
|
259
|
+
normalizePath: config.metrics.normalizePath,
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
const loggingConfig = config.logging ?? {};
|
|
263
|
+
if (loggingConfig.enabled !== false) {
|
|
264
|
+
app.use(requestLogger({
|
|
265
|
+
onLog: loggingConfig.onLog,
|
|
266
|
+
level: loggingConfig.level,
|
|
267
|
+
excludePaths: loggingConfig.excludePaths,
|
|
268
|
+
excludeMethods: loggingConfig.excludeMethods,
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
160
271
|
const headerOpts = {};
|
|
161
272
|
if (securityConfig.headers?.contentSecurityPolicy) {
|
|
162
273
|
headerOpts["Content-Security-Policy"] = securityConfig.headers.contentSecurityPolicy;
|
|
@@ -176,7 +287,7 @@ export const createApp = async (config) => {
|
|
|
176
287
|
const corsAllowHeaders = ["Content-Type", "Authorization", HEADER_USER_TOKEN, HEADER_REFRESH_TOKEN];
|
|
177
288
|
if (securityConfig.csrf?.enabled)
|
|
178
289
|
corsAllowHeaders.push(HEADER_CSRF_TOKEN);
|
|
179
|
-
app.use(cors({ origin: corsOrigins, allowHeaders: corsAllowHeaders, exposeHeaders: ["x-cache"], credentials: true }));
|
|
290
|
+
app.use(cors({ origin: corsOrigins, allowHeaders: corsAllowHeaders, exposeHeaders: ["x-cache", HEADER_REQUEST_ID], credentials: true }));
|
|
180
291
|
if ((botCfg.blockList?.length ?? 0) > 0) {
|
|
181
292
|
const { botProtection } = await import("./middleware/botProtection");
|
|
182
293
|
app.use(botProtection({ blockList: botCfg.blockList }));
|
|
@@ -185,13 +296,22 @@ export const createApp = async (config) => {
|
|
|
185
296
|
if (enableBearerAuth) {
|
|
186
297
|
app.use(async (c, next) => {
|
|
187
298
|
const path = c.req.path;
|
|
188
|
-
|
|
299
|
+
const bypassed = bearerAuthBypass.some((entry) => entry.endsWith("*") ? path.startsWith(entry.slice(0, -1)) : path === entry);
|
|
300
|
+
if (bypassed) {
|
|
189
301
|
return next();
|
|
190
302
|
}
|
|
191
303
|
return bearerAuth(c, next);
|
|
192
304
|
});
|
|
193
305
|
}
|
|
194
306
|
app.use(identify);
|
|
307
|
+
// Signing config — make available to pagination, identify, and other lib modules
|
|
308
|
+
if (securityConfig.signing) {
|
|
309
|
+
setSigningConfig(securityConfig.signing);
|
|
310
|
+
}
|
|
311
|
+
// CAPTCHA config — store globally so requireCaptcha() can read it without explicit param
|
|
312
|
+
if (securityConfig.captcha) {
|
|
313
|
+
setCaptchaConfig(securityConfig.captcha);
|
|
314
|
+
}
|
|
195
315
|
// CSRF protection (after identify so we can check for auth cookie presence)
|
|
196
316
|
if (securityConfig.csrf?.enabled) {
|
|
197
317
|
setCsrfEnabled(true);
|
|
@@ -224,6 +344,10 @@ export const createApp = async (config) => {
|
|
|
224
344
|
}
|
|
225
345
|
for (const mw of middleware)
|
|
226
346
|
app.use(mw);
|
|
347
|
+
if (authConfig.mfa?.required) {
|
|
348
|
+
const { requireMfaSetup } = await import("./middleware/requireMfaSetup");
|
|
349
|
+
app.use(requireMfaSetup);
|
|
350
|
+
}
|
|
227
351
|
setAppName(appName);
|
|
228
352
|
// Schema pre-loading — import shared schema files before routes so registerSchema /
|
|
229
353
|
// registerSchemas calls run first, guaranteeing $ref instead of inline shapes.
|
|
@@ -273,13 +397,15 @@ export const createApp = async (config) => {
|
|
|
273
397
|
continue; // mounted separately below when mfa is configured
|
|
274
398
|
if (file === "jobs.ts")
|
|
275
399
|
continue; // mounted separately below when jobs.statusEndpoint is true
|
|
400
|
+
if (file === "oidc.ts")
|
|
401
|
+
continue; // mounted separately below when oidc is configured
|
|
276
402
|
const mod = await import(`${coreRoutesDir}/${file}`);
|
|
277
403
|
if (mod.router)
|
|
278
404
|
app.route("/", mod.router);
|
|
279
405
|
}
|
|
280
406
|
if (enableAuthRoutes) {
|
|
281
407
|
const { createAuthRouter } = await import(`${coreRoutesDir}/auth`);
|
|
282
|
-
app.route("/", createAuthRouter({ primaryField, emailVerification, passwordReset, rateLimit: authRateLimit, accountDeletion: authConfig.accountDeletion, refreshTokens: authConfig.refreshTokens }));
|
|
408
|
+
app.route("/", createAuthRouter({ primaryField, emailVerification, passwordReset, rateLimit: authRateLimit, accountDeletion: authConfig.accountDeletion, refreshTokens: authConfig.refreshTokens, stepUp: authConfig.stepUp }));
|
|
283
409
|
}
|
|
284
410
|
if (configuredOAuth.length > 0) {
|
|
285
411
|
const { createOAuthRouter } = await import(`${coreRoutesDir}/oauth`);
|
|
@@ -295,53 +421,231 @@ export const createApp = async (config) => {
|
|
|
295
421
|
const { createMfaRouter } = await import(`${coreRoutesDir}/mfa`);
|
|
296
422
|
app.route("/", createMfaRouter({ rateLimit: authRateLimit }));
|
|
297
423
|
}
|
|
424
|
+
if (authConfig.mfa?.webauthn?.allowPasswordlessLogin && enableAuthRoutes) {
|
|
425
|
+
const { assertWebAuthnDependency } = await import("./services/mfa");
|
|
426
|
+
await assertWebAuthnDependency();
|
|
427
|
+
const { createPasskeyRouter } = await import(`${coreRoutesDir}/passkey`);
|
|
428
|
+
app.route("/", createPasskeyRouter());
|
|
429
|
+
}
|
|
430
|
+
if (authConfig.m2m?.enabled !== false && authConfig.m2m) {
|
|
431
|
+
setM2MConfig(authConfig.m2m);
|
|
432
|
+
const { createM2MRouter } = await import(`${coreRoutesDir}/m2m`);
|
|
433
|
+
app.route("/", createM2MRouter());
|
|
434
|
+
}
|
|
435
|
+
if (config.jobs?.statusEndpoint) {
|
|
436
|
+
const jobsAuth = config.jobs.auth ?? "none";
|
|
437
|
+
if (jobsAuth === "none" && !config.jobs.unsafePublic) {
|
|
438
|
+
if (process.env.NODE_ENV === "production") {
|
|
439
|
+
throw new Error("[security] jobs.auth is required in production. Set jobs.auth or explicitly set unsafePublic: true with auth: \"none\".");
|
|
440
|
+
}
|
|
441
|
+
console.warn("[security] /jobs is enabled without auth. Configure jobs.auth for production.");
|
|
442
|
+
}
|
|
443
|
+
}
|
|
298
444
|
if (config.jobs?.statusEndpoint) {
|
|
299
445
|
const { createJobsRouter } = await import(`${coreRoutesDir}/jobs`);
|
|
300
446
|
app.route("/", createJobsRouter(config.jobs));
|
|
301
447
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}
|
|
448
|
+
if (config.metrics?.enabled) {
|
|
449
|
+
const { createMetricsRouter } = await import(`${coreRoutesDir}/metrics`);
|
|
450
|
+
app.route("/", createMetricsRouter({
|
|
451
|
+
auth: config.metrics.auth,
|
|
452
|
+
queues: config.metrics.queues,
|
|
453
|
+
unsafePublic: config.metrics.unsafePublic,
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
if (config.groups?.managementRoutes) {
|
|
457
|
+
const { createGroupsRouter } = await import(`${coreRoutesDir}/groups`);
|
|
458
|
+
app.route("/", createGroupsRouter(config.groups));
|
|
459
|
+
}
|
|
460
|
+
if (authConfig.oidc) {
|
|
461
|
+
const { createOidcRouter } = await import(`${coreRoutesDir}/oidc`);
|
|
462
|
+
app.route("/", createOidcRouter());
|
|
463
|
+
}
|
|
464
|
+
if (authConfig.saml) {
|
|
465
|
+
setSamlConfig(authConfig.saml);
|
|
466
|
+
const { createSamlRouter } = await import(`${coreRoutesDir}/saml`);
|
|
467
|
+
app.route("/", createSamlRouter());
|
|
468
|
+
}
|
|
469
|
+
if (authConfig.scim) {
|
|
470
|
+
const { setScimTokens } = await import("./middleware/scimAuth");
|
|
471
|
+
setScimConfig(authConfig.scim);
|
|
472
|
+
setScimTokens(authConfig.scim.bearerTokens);
|
|
473
|
+
const { createScimRouter } = await import(`${coreRoutesDir}/scim`);
|
|
474
|
+
app.route("/", createScimRouter());
|
|
475
|
+
}
|
|
476
|
+
if (config.upload) {
|
|
477
|
+
const { storage, presignedUrls, authorization, allowExternalKeys, ...uploadOpts } = config.upload;
|
|
478
|
+
setStorageAdapter(storage);
|
|
479
|
+
setUploadConfig(uploadOpts);
|
|
480
|
+
// Wire upload registry store to match session store backend
|
|
481
|
+
const { setUploadRegistryStore } = await import("./lib/uploadRegistry");
|
|
482
|
+
setUploadRegistryStore(sessions);
|
|
483
|
+
if (presignedUrls) {
|
|
484
|
+
const { createUploadsRouter } = await import(`${coreRoutesDir}/uploads`);
|
|
485
|
+
const presignConfig = presignedUrls === true ? {} : presignedUrls;
|
|
486
|
+
app.route("/", createUploadsRouter({
|
|
487
|
+
...presignConfig,
|
|
488
|
+
authorization,
|
|
489
|
+
allowExternalKeys,
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// Helper to register standard security schemes on an OpenAPI registry
|
|
494
|
+
const registerSecuritySchemes = (registry) => {
|
|
495
|
+
registry.registerComponent("securitySchemes", "cookieAuth", {
|
|
496
|
+
type: "apiKey",
|
|
497
|
+
in: "cookie",
|
|
498
|
+
name: "token",
|
|
499
|
+
description: "Session cookie set automatically on login/register.",
|
|
500
|
+
});
|
|
501
|
+
registry.registerComponent("securitySchemes", "userToken", {
|
|
502
|
+
type: "apiKey",
|
|
503
|
+
in: "header",
|
|
504
|
+
name: "x-user-token",
|
|
505
|
+
description: "JWT session token passed as the x-user-token request header (alternative to the session cookie).",
|
|
506
|
+
});
|
|
507
|
+
registry.registerComponent("securitySchemes", "bearerAuth", {
|
|
508
|
+
type: "http",
|
|
509
|
+
scheme: "bearer",
|
|
510
|
+
description: "API key passed as Authorization: Bearer <token>. Required on all endpoints unless bearer auth is disabled in CreateAppConfig or the path is in the bypass list.",
|
|
511
|
+
});
|
|
512
|
+
};
|
|
513
|
+
if (config.versioning) {
|
|
514
|
+
// Version-aware route discovery — each version gets its own OpenAPIHono instance
|
|
515
|
+
const { versions, sharedDir = "shared", defaultVersion = versions[versions.length - 1] } = config.versioning;
|
|
516
|
+
const { setVersionPrefix, clearVersionPrefix, getVersionToken, drainCapturedTokens, assertCapturedTokens } = await import("./lib/createRoute");
|
|
517
|
+
const { defaultHook } = await import("./lib/context");
|
|
518
|
+
const { stripUnreferencedSchemas } = await import("./lib/stripUnreferencedSchemas");
|
|
519
|
+
// Import shared routes with no prefix — schemas stay unprefixed (version-agnostic)
|
|
520
|
+
let sharedMods = [];
|
|
521
|
+
if (sharedDir !== false) {
|
|
522
|
+
const sharedRoutesDir = `${routesDir}/${sharedDir}`;
|
|
523
|
+
try {
|
|
524
|
+
const sharedGlob = new Bun.Glob("**/*.ts");
|
|
525
|
+
const sharedFiles = [];
|
|
526
|
+
for await (const file of sharedGlob.scan({ cwd: sharedRoutesDir })) {
|
|
527
|
+
sharedFiles.push(file);
|
|
528
|
+
}
|
|
529
|
+
sharedMods = await Promise.all(sharedFiles.map(async (file) => ({ file, mod: await import(`${sharedRoutesDir}/${file}`) })));
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// sharedDir doesn't exist — fine
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// Drain any tokens captured during shared route imports (token=null, correct since no prefix was set)
|
|
536
|
+
// to prevent null tokens from bleeding into per-version assertions below.
|
|
537
|
+
drainCapturedTokens();
|
|
538
|
+
// For each version sequentially: set prefix, import routes, mount on isolated OpenAPIHono
|
|
539
|
+
for (const version of versions) {
|
|
540
|
+
setVersionPrefix(version);
|
|
541
|
+
const expectedToken = getVersionToken();
|
|
542
|
+
const vApp = new OpenAPIHono({ defaultHook });
|
|
543
|
+
const versionRoutesDir = `${routesDir}/${version}`;
|
|
544
|
+
const versionFiles = [];
|
|
545
|
+
try {
|
|
546
|
+
const versionGlob = new Bun.Glob("**/*.ts");
|
|
547
|
+
for await (const file of versionGlob.scan({ cwd: versionRoutesDir })) {
|
|
548
|
+
versionFiles.push(file);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
// version dir doesn't exist — fine
|
|
553
|
+
}
|
|
554
|
+
// Import all version route files in parallel
|
|
555
|
+
const versionMods = await Promise.all(versionFiles.map(async (file) => ({ file, mod: await import(`${versionRoutesDir}/${file}`) })));
|
|
556
|
+
// Assert version token to catch top-level await interleaving bugs at startup
|
|
557
|
+
assertCapturedTokens(drainCapturedTokens(), expectedToken);
|
|
558
|
+
// Mount version-specific routes (sorted by priority)
|
|
559
|
+
versionMods
|
|
560
|
+
.sort((a, b) => (a.mod.priority ?? Infinity) - (b.mod.priority ?? Infinity))
|
|
561
|
+
.forEach(({ mod }) => {
|
|
562
|
+
if (mod.router)
|
|
563
|
+
vApp.route("/", mod.router);
|
|
564
|
+
});
|
|
565
|
+
// Mount shared routes on this versioned app
|
|
566
|
+
for (const { mod } of sharedMods) {
|
|
567
|
+
if (mod.router)
|
|
568
|
+
vApp.route("/", mod.router);
|
|
569
|
+
}
|
|
570
|
+
registerSecuritySchemes(vApp.openAPIRegistry);
|
|
571
|
+
// Serve per-version spec stripped of schemas from other versions
|
|
572
|
+
vApp.get("/openapi.json", (c) => {
|
|
573
|
+
const spec = vApp.getOpenAPIDocument({
|
|
574
|
+
openapi: "3.0.0",
|
|
575
|
+
info: { title: `${appName} ${version.toUpperCase()}`, version: openApiVersion },
|
|
576
|
+
});
|
|
577
|
+
return c.json(stripUnreferencedSchemas(spec));
|
|
578
|
+
});
|
|
579
|
+
// Per-version Scalar docs
|
|
580
|
+
vApp.get("/docs", Scalar({ url: `/${version}/openapi.json` }));
|
|
581
|
+
clearVersionPrefix();
|
|
582
|
+
// Mount versioned app under /v1, /v2, etc.
|
|
583
|
+
app.route(`/${version}`, vApp);
|
|
584
|
+
}
|
|
585
|
+
// Root /docs → version selector page
|
|
586
|
+
app.get("/docs", (c) => {
|
|
587
|
+
const links = versions
|
|
588
|
+
.map((v) => `<li><a href="/${v}/docs" style="font-size:1.1em">${v.toUpperCase()}</a></li>`)
|
|
589
|
+
.join("\n");
|
|
590
|
+
const html = `<!DOCTYPE html>
|
|
591
|
+
<html lang="en">
|
|
592
|
+
<head><meta charset="utf-8"><title>${appName} — API Docs</title>
|
|
593
|
+
<style>body{font-family:sans-serif;padding:2rem}ul{list-style:none;padding:0}li{margin:.5rem 0}</style>
|
|
594
|
+
</head>
|
|
595
|
+
<body>
|
|
596
|
+
<h1>${appName}</h1>
|
|
597
|
+
<h2>API Documentation</h2>
|
|
598
|
+
<ul>${links}</ul>
|
|
599
|
+
</body></html>`;
|
|
600
|
+
return c.html(html);
|
|
601
|
+
});
|
|
602
|
+
// Root /openapi.json → 302 to default version (no merged spec exists)
|
|
603
|
+
app.get("/openapi.json", (c) => c.redirect(`/${defaultVersion}/openapi.json`, 302));
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
// Non-versioned path — existing behavior unchanged
|
|
607
|
+
// Service routes — collect all, sort by optional exported `priority`, then mount
|
|
608
|
+
const serviceGlob = new Bun.Glob("**/*.ts");
|
|
609
|
+
const serviceFiles = [];
|
|
610
|
+
for await (const file of serviceGlob.scan({ cwd: routesDir })) {
|
|
611
|
+
serviceFiles.push(file);
|
|
612
|
+
}
|
|
613
|
+
const serviceMods = await Promise.all(serviceFiles.map(async (file) => ({
|
|
614
|
+
file,
|
|
615
|
+
mod: await import(`${routesDir}/${file}`),
|
|
616
|
+
})));
|
|
617
|
+
serviceMods
|
|
618
|
+
.sort((a, b) => (a.mod.priority ?? Infinity) - (b.mod.priority ?? Infinity))
|
|
619
|
+
.forEach(({ mod }) => {
|
|
620
|
+
if (mod.router)
|
|
621
|
+
app.route("/", mod.router);
|
|
622
|
+
});
|
|
623
|
+
registerSecuritySchemes(app.openAPIRegistry);
|
|
624
|
+
app.doc("/openapi.json", { openapi: "3.0.0", info: { title: appName, version: openApiVersion } });
|
|
625
|
+
app.get("/docs", Scalar({ url: "/openapi.json" }));
|
|
626
|
+
}
|
|
318
627
|
app.onError((err, c) => {
|
|
628
|
+
const reqId = c.get("requestId") ?? "unknown";
|
|
629
|
+
// ValidationError extends HttpError — must check first or the details payload is lost
|
|
630
|
+
if (err instanceof ValidationError) {
|
|
631
|
+
const fmt = c.get("validationErrorFormatter") ?? defaultValidationErrorFormatter;
|
|
632
|
+
try {
|
|
633
|
+
return c.json(fmt(err.issues, reqId), 400);
|
|
634
|
+
}
|
|
635
|
+
catch {
|
|
636
|
+
return c.json(defaultValidationErrorFormatter(err.issues, reqId), 400);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
319
639
|
if (err instanceof HttpError) {
|
|
320
|
-
|
|
640
|
+
const body = { error: err.message, requestId: reqId };
|
|
641
|
+
if (err.code !== undefined)
|
|
642
|
+
body.code = err.code;
|
|
643
|
+
return c.json(body, err.status);
|
|
321
644
|
}
|
|
322
645
|
console.error(err);
|
|
323
|
-
return c.json({ error: "Internal Server Error" }, 500);
|
|
324
|
-
});
|
|
325
|
-
app.notFound((c) => c.json({ error: "Not Found" }, 404));
|
|
326
|
-
app.openAPIRegistry.registerComponent("securitySchemes", "cookieAuth", {
|
|
327
|
-
type: "apiKey",
|
|
328
|
-
in: "cookie",
|
|
329
|
-
name: "token",
|
|
330
|
-
description: "Session cookie set automatically on login/register.",
|
|
331
|
-
});
|
|
332
|
-
app.openAPIRegistry.registerComponent("securitySchemes", "userToken", {
|
|
333
|
-
type: "apiKey",
|
|
334
|
-
in: "header",
|
|
335
|
-
name: "x-user-token",
|
|
336
|
-
description: "JWT session token passed as the x-user-token request header (alternative to the session cookie).",
|
|
337
|
-
});
|
|
338
|
-
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
|
339
|
-
type: "http",
|
|
340
|
-
scheme: "bearer",
|
|
341
|
-
description: "API key passed as Authorization: Bearer <token>. Required on all endpoints unless bearer auth is disabled in CreateAppConfig or the path is in the bypass list.",
|
|
646
|
+
return c.json({ error: "Internal Server Error", requestId: reqId }, 500);
|
|
342
647
|
});
|
|
343
|
-
app.
|
|
344
|
-
app.get("/docs", Scalar({ url: "/openapi.json" }));
|
|
648
|
+
app.notFound((c) => c.json({ error: "Not Found", requestId: c.get("requestId") ?? "unknown" }, 404));
|
|
345
649
|
app.get("/sw.js", (c) => c.body("", 200, { "Content-Type": "application/javascript" }));
|
|
346
650
|
return app;
|
|
347
651
|
};
|