@lastshotlabs/bunshot 0.0.25 → 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/dist/adapters/localStorage.js +20 -5
- package/dist/adapters/memoryAuth.d.ts +6 -0
- package/dist/adapters/memoryAuth.js +117 -2
- package/dist/adapters/mongoAuth.js +97 -1
- package/dist/adapters/sqliteAuth.d.ts +23 -0
- package/dist/adapters/sqliteAuth.js +153 -2
- package/dist/app.d.ts +105 -2
- package/dist/app.js +112 -9
- package/dist/index.d.ts +23 -4
- package/dist/index.js +13 -2
- package/dist/lib/HttpError.d.ts +2 -1
- package/dist/lib/HttpError.js +3 -1
- package/dist/lib/appConfig.d.ts +113 -0
- package/dist/lib/appConfig.js +38 -0
- package/dist/lib/auditLog.d.ts +6 -0
- package/dist/lib/auditLog.js +17 -0
- package/dist/lib/authAdapter.d.ts +71 -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/context.d.ts +5 -0
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- 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/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/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 +10 -0
- package/dist/lib/session.js +67 -5
- package/dist/lib/signing.js +5 -2
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/upload.d.ts +4 -0
- package/dist/lib/upload.js +26 -1
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/ws.js +7 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +8 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +40 -13
- package/dist/middleware/requestSigning.js +6 -5
- package/dist/middleware/requireMfaSetup.js +2 -1
- 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/webhookAuth.d.ts +1 -1
- package/dist/middleware/webhookAuth.js +6 -5
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -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 +155 -16
- package/dist/routes/jobs.js +21 -3
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +1 -0
- package/dist/routes/metrics.js +3 -0
- package/dist/routes/mfa.js +9 -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 +13 -1
- package/dist/routes/uploads.js +98 -6
- 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 +2 -1
- package/docs/sections/auth-flow/full.md +790 -779
- package/docs/sections/auth-security-examples/full.md +23 -0
- package/docs/sections/metrics/full.md +6 -2
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/uploads/full.md +11 -2
- package/docs/sections/webhook-auth/full.md +1 -1
- package/docs/sections/websocket/full.md +12 -0
- package/package.json +3 -2
package/dist/lib/saml.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
let _sp = null;
|
|
2
|
+
let _idp = null;
|
|
3
|
+
export async function initSaml(config) {
|
|
4
|
+
const samlify = await import("samlify");
|
|
5
|
+
_sp = samlify.ServiceProvider({
|
|
6
|
+
entityID: config.entityId,
|
|
7
|
+
assertionConsumerService: [{
|
|
8
|
+
Binding: samlify.Constants.BindingNamespace.Post,
|
|
9
|
+
Location: config.acsUrl,
|
|
10
|
+
}],
|
|
11
|
+
signingCert: config.signingCert,
|
|
12
|
+
privateKey: config.signingKey,
|
|
13
|
+
allowCreate: true,
|
|
14
|
+
});
|
|
15
|
+
// Load IdP metadata
|
|
16
|
+
if (config.idpMetadata.startsWith("http")) {
|
|
17
|
+
// URL — fetch it
|
|
18
|
+
const res = await fetch(config.idpMetadata);
|
|
19
|
+
const xml = await res.text();
|
|
20
|
+
_idp = samlify.IdentityProvider({ metadata: xml });
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
// XML string
|
|
24
|
+
_idp = samlify.IdentityProvider({ metadata: config.idpMetadata });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function createAuthnRequest() {
|
|
28
|
+
if (!_sp || !_idp)
|
|
29
|
+
throw new Error("SAML not initialized");
|
|
30
|
+
const { context, entityEndpoint } = _sp.createLoginRequest(_idp, "redirect");
|
|
31
|
+
return { redirectUrl: entityEndpoint + "?" + context, id: context };
|
|
32
|
+
}
|
|
33
|
+
export async function validateSamlResponse(body, config) {
|
|
34
|
+
if (!_sp || !_idp)
|
|
35
|
+
throw new Error("SAML not initialized");
|
|
36
|
+
const { extract } = await _sp.parseLoginResponse(_idp, "post", { body: { SAMLResponse: body } });
|
|
37
|
+
const mapping = config.attributeMapping ?? {};
|
|
38
|
+
const attrs = extract.attributes ?? {};
|
|
39
|
+
const emailKey = mapping.email ?? "email";
|
|
40
|
+
const firstNameKey = mapping.firstName ?? "firstName";
|
|
41
|
+
const lastNameKey = mapping.lastName ?? "lastName";
|
|
42
|
+
const groupsKey = mapping.groups ?? "groups";
|
|
43
|
+
const nameId = extract.nameID;
|
|
44
|
+
const email = attrs[emailKey] ?? nameId;
|
|
45
|
+
const firstName = attrs[firstNameKey];
|
|
46
|
+
const lastName = attrs[lastNameKey];
|
|
47
|
+
const displayName = firstName && lastName ? `${firstName} ${lastName}` : undefined;
|
|
48
|
+
const rawGroups = attrs[groupsKey];
|
|
49
|
+
const groups = rawGroups ? (Array.isArray(rawGroups) ? rawGroups : [rawGroups]) : undefined;
|
|
50
|
+
return { nameId, email, firstName, lastName, displayName, groups, attributes: attrs };
|
|
51
|
+
}
|
|
52
|
+
export function samlProfileToIdentityProfile(profile) {
|
|
53
|
+
return {
|
|
54
|
+
email: profile.email,
|
|
55
|
+
displayName: profile.displayName,
|
|
56
|
+
firstName: profile.firstName,
|
|
57
|
+
lastName: profile.lastName,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function getSamlSpMetadata() {
|
|
61
|
+
if (!_sp)
|
|
62
|
+
throw new Error("SAML not initialized");
|
|
63
|
+
return _sp.getMetadata();
|
|
64
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { UserRecord } from "./authAdapter";
|
|
2
|
+
export interface ScimUser {
|
|
3
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"];
|
|
4
|
+
id: string;
|
|
5
|
+
externalId?: string;
|
|
6
|
+
userName: string;
|
|
7
|
+
displayName?: string;
|
|
8
|
+
name?: {
|
|
9
|
+
givenName?: string;
|
|
10
|
+
familyName?: string;
|
|
11
|
+
formatted?: string;
|
|
12
|
+
};
|
|
13
|
+
emails?: Array<{
|
|
14
|
+
value: string;
|
|
15
|
+
primary: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
active: boolean;
|
|
18
|
+
meta: {
|
|
19
|
+
resourceType: "User";
|
|
20
|
+
created?: string;
|
|
21
|
+
lastModified?: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface ScimListResponse {
|
|
25
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
|
|
26
|
+
totalResults: number;
|
|
27
|
+
startIndex: number;
|
|
28
|
+
itemsPerPage: number;
|
|
29
|
+
Resources: ScimUser[];
|
|
30
|
+
}
|
|
31
|
+
export interface ScimError {
|
|
32
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"];
|
|
33
|
+
status: string;
|
|
34
|
+
detail: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function userRecordToScim(user: UserRecord, config?: {
|
|
37
|
+
userName?: "email" | "username";
|
|
38
|
+
}): ScimUser;
|
|
39
|
+
/**
|
|
40
|
+
* Parse a simple SCIM filter string into a UserQuery object.
|
|
41
|
+
* Supports: userName eq "val", email eq "val", externalId eq "val", active eq true/false
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseScimFilter(filter?: string): import("./authAdapter").UserQuery;
|
|
44
|
+
export declare function scimError(status: number, detail: string): Response;
|
package/dist/lib/scim.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function userRecordToScim(user, config) {
|
|
2
|
+
const userName = user.email ?? user.id;
|
|
3
|
+
return {
|
|
4
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
|
5
|
+
id: user.id,
|
|
6
|
+
externalId: user.externalId,
|
|
7
|
+
userName,
|
|
8
|
+
displayName: user.displayName,
|
|
9
|
+
name: (user.firstName || user.lastName) ? {
|
|
10
|
+
givenName: user.firstName,
|
|
11
|
+
familyName: user.lastName,
|
|
12
|
+
formatted: [user.firstName, user.lastName].filter(Boolean).join(" ") || undefined,
|
|
13
|
+
} : undefined,
|
|
14
|
+
emails: user.email ? [{ value: user.email, primary: true }] : undefined,
|
|
15
|
+
active: !user.suspended,
|
|
16
|
+
meta: { resourceType: "User" },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse a simple SCIM filter string into a UserQuery object.
|
|
21
|
+
* Supports: userName eq "val", email eq "val", externalId eq "val", active eq true/false
|
|
22
|
+
*/
|
|
23
|
+
export function parseScimFilter(filter) {
|
|
24
|
+
if (!filter)
|
|
25
|
+
return {};
|
|
26
|
+
const query = {};
|
|
27
|
+
// Simple single-clause filter: `attr op "value"`
|
|
28
|
+
const match = filter.trim().match(/^(\w+)\s+eq\s+"?([^"]*)"?$/i);
|
|
29
|
+
if (!match)
|
|
30
|
+
return {};
|
|
31
|
+
const [, attr, value] = match;
|
|
32
|
+
const attrLower = attr.toLowerCase();
|
|
33
|
+
if (attrLower === "username" || attrLower === "email") {
|
|
34
|
+
query.email = value;
|
|
35
|
+
}
|
|
36
|
+
else if (attrLower === "externalid") {
|
|
37
|
+
query.externalId = value;
|
|
38
|
+
}
|
|
39
|
+
else if (attrLower === "active") {
|
|
40
|
+
query.suspended = value.toLowerCase() !== "true"; // active=true means suspended=false
|
|
41
|
+
}
|
|
42
|
+
return query;
|
|
43
|
+
}
|
|
44
|
+
export function scimError(status, detail) {
|
|
45
|
+
const body = {
|
|
46
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
|
47
|
+
status: String(status),
|
|
48
|
+
detail,
|
|
49
|
+
};
|
|
50
|
+
return new Response(JSON.stringify(body), {
|
|
51
|
+
status,
|
|
52
|
+
headers: { "Content-Type": "application/scim+json" },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type SecurityEventType = "auth.login.success" | "auth.login.failure" | "auth.login.blocked" | "auth.register.success" | "auth.register.failure" | "auth.logout" | "auth.password.reset" | "auth.password.change" | "auth.mfa.setup" | "auth.mfa.verify.success" | "auth.mfa.verify.failure" | "auth.step_up.success" | "auth.step_up.failure" | "auth.session.created" | "auth.session.revoked" | "auth.account.suspended" | "auth.account.deleted" | "auth.oauth.link" | "auth.oauth.unlink" | "security.rate_limit.exceeded" | "security.credential_stuffing.detected" | "security.captcha.failed" | "security.csrf.failed" | "security.breached_password.detected" | "admin.role.changed" | "admin.user.modified";
|
|
2
|
+
export interface SecurityEvent {
|
|
3
|
+
eventType: SecurityEventType;
|
|
4
|
+
severity: "info" | "warn" | "critical";
|
|
5
|
+
timestamp: string;
|
|
6
|
+
requestId?: string;
|
|
7
|
+
userId?: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
tenantId?: string;
|
|
10
|
+
ip?: string;
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
meta?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
export interface SecurityEventConfig {
|
|
15
|
+
/** Called for each security event. Non-blocking — errors are swallowed. */
|
|
16
|
+
onEvent: (event: SecurityEvent) => void | Promise<void>;
|
|
17
|
+
/** Only emit events of these types. If omitted, all events are emitted. */
|
|
18
|
+
include?: SecurityEventType[];
|
|
19
|
+
/** Skip events of these types. Applied after include. */
|
|
20
|
+
exclude?: SecurityEventType[];
|
|
21
|
+
}
|
|
22
|
+
export declare function setSecurityEventConfig(config: SecurityEventConfig | null): void;
|
|
23
|
+
export declare function getSecurityEventConfig(): SecurityEventConfig | null;
|
|
24
|
+
/**
|
|
25
|
+
* Emit a security event. Non-blocking — the call returns immediately.
|
|
26
|
+
* If onEvent throws, the error is silently swallowed (never propagates to the caller).
|
|
27
|
+
*/
|
|
28
|
+
export declare function emitSecurityEvent(event: SecurityEvent): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
let _config = null;
|
|
2
|
+
export function setSecurityEventConfig(config) {
|
|
3
|
+
_config = config;
|
|
4
|
+
}
|
|
5
|
+
export function getSecurityEventConfig() {
|
|
6
|
+
return _config;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Emit a security event. Non-blocking — the call returns immediately.
|
|
10
|
+
* If onEvent throws, the error is silently swallowed (never propagates to the caller).
|
|
11
|
+
*/
|
|
12
|
+
export function emitSecurityEvent(event) {
|
|
13
|
+
if (!_config)
|
|
14
|
+
return;
|
|
15
|
+
const { onEvent, include, exclude } = _config;
|
|
16
|
+
if (include && include.length > 0 && !include.includes(event.eventType))
|
|
17
|
+
return;
|
|
18
|
+
if (exclude && exclude.includes(event.eventType))
|
|
19
|
+
return;
|
|
20
|
+
// Fire and forget — never block the request
|
|
21
|
+
Promise.resolve()
|
|
22
|
+
.then(() => onEvent({ ...event, timestamp: event.timestamp ?? new Date().toISOString() }))
|
|
23
|
+
.catch(() => {
|
|
24
|
+
// Swallow errors — security event emission must never crash the app
|
|
25
|
+
});
|
|
26
|
+
}
|
package/dist/lib/session.d.ts
CHANGED
|
@@ -36,4 +36,14 @@ export declare const rotateRefreshToken: (sessionId: string, newRefreshToken: st
|
|
|
36
36
|
export declare const getSessionFingerprint: (sessionId: string) => Promise<string | null>;
|
|
37
37
|
/** Store a fingerprint on an existing session. No-op if the session does not exist. */
|
|
38
38
|
export declare const setSessionFingerprint: (sessionId: string, fingerprint: string) => Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Store the timestamp when MFA was last verified in the session metadata.
|
|
41
|
+
* Used by requireStepUp middleware.
|
|
42
|
+
*/
|
|
43
|
+
export declare const setMfaVerifiedAt: (sessionId: string) => Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Get the Unix timestamp (seconds) when MFA was last verified for this session.
|
|
46
|
+
* Returns null if MFA has never been verified or session not found.
|
|
47
|
+
*/
|
|
48
|
+
export declare const getMfaVerifiedAt: (sessionId: string) => Promise<number | null>;
|
|
39
49
|
export {};
|
package/dist/lib/session.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { getRedis } from "./redis";
|
|
2
2
|
import { appConnection, mongoose } from "./mongo";
|
|
3
3
|
import { getAppName, getPersistSessionMetadata, getIncludeInactiveSessions, getRotationGraceSeconds, getRefreshTokenExpiry } from "./appConfig";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { timingSafeEqual } from "./crypto";
|
|
5
|
+
import { sqliteCreateSession, sqliteGetSession, sqliteDeleteSession, sqliteGetUserSessions, sqliteGetActiveSessionCount, sqliteEvictOldestSession, sqliteUpdateSessionLastActive, sqliteSetRefreshToken, sqliteGetSessionByRefreshToken, sqliteRotateRefreshToken, sqliteGetSessionFingerprint, sqliteSetSessionFingerprint, sqliteGetMfaVerifiedAt, sqliteSetMfaVerifiedAt, } from "../adapters/sqliteAuth";
|
|
6
|
+
import { memoryCreateSession, memoryGetSession, memoryDeleteSession, memoryGetUserSessions, memoryGetActiveSessionCount, memoryEvictOldestSession, memoryUpdateSessionLastActive, memorySetRefreshToken, memoryGetSessionByRefreshToken, memoryRotateRefreshToken, memoryGetSessionFingerprint, memorySetSessionFingerprint, memoryGetMfaVerifiedAt, memorySetMfaVerifiedAt, } from "../adapters/memoryAuth";
|
|
6
7
|
function getSessionModel() {
|
|
7
8
|
if (appConnection.models["Session"])
|
|
8
9
|
return appConnection.models["Session"];
|
|
@@ -20,6 +21,7 @@ function getSessionModel() {
|
|
|
20
21
|
prevRefreshToken: { type: String, default: null },
|
|
21
22
|
prevTokenExpiresAt: { type: Date, default: null },
|
|
22
23
|
fingerprint: { type: String, default: null },
|
|
24
|
+
mfaVerifiedAt: { type: Number, default: null },
|
|
23
25
|
}, { collection: "sessions", timestamps: false });
|
|
24
26
|
sessionSchema.index({ refreshToken: 1 }, { unique: true, partialFilterExpression: { refreshToken: { $type: "string" } } });
|
|
25
27
|
// Add TTL index only when metadata is not persisted — docs auto-delete at expiresAt.
|
|
@@ -203,16 +205,16 @@ async function redisGetSessionByRefreshToken(refreshToken) {
|
|
|
203
205
|
return null;
|
|
204
206
|
const rec = JSON.parse(raw);
|
|
205
207
|
// Current refresh token matches
|
|
206
|
-
if (rec.refreshToken
|
|
208
|
+
if (timingSafeEqual(rec.refreshToken ?? "", refreshToken)) {
|
|
207
209
|
return { sessionId: rec.sessionId, userId: rec.userId, newRefreshToken: refreshToken };
|
|
208
210
|
}
|
|
209
211
|
// Check grace window: old token used within grace period
|
|
210
|
-
if (rec.prevRefreshToken
|
|
212
|
+
if (timingSafeEqual(rec.prevRefreshToken ?? "", refreshToken) && rec.prevTokenExpiresAt && rec.prevTokenExpiresAt > Date.now()) {
|
|
211
213
|
// Return current refresh token — client missed the rotation response
|
|
212
214
|
return { sessionId: rec.sessionId, userId: rec.userId, newRefreshToken: rec.refreshToken };
|
|
213
215
|
}
|
|
214
216
|
// Old token used after grace window — token family theft detected, invalidate session
|
|
215
|
-
if (rec.prevRefreshToken
|
|
217
|
+
if (timingSafeEqual(rec.prevRefreshToken ?? "", refreshToken)) {
|
|
216
218
|
await redisDeleteSession(sessionId);
|
|
217
219
|
return null;
|
|
218
220
|
}
|
|
@@ -533,3 +535,63 @@ export const setSessionFingerprint = async (sessionId, fingerprint) => {
|
|
|
533
535
|
// mongo
|
|
534
536
|
await getSessionModel().updateOne({ sessionId }, { $set: { fingerprint } });
|
|
535
537
|
};
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// Step-up MFA API (mfaVerifiedAt)
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
/**
|
|
542
|
+
* Store the timestamp when MFA was last verified in the session metadata.
|
|
543
|
+
* Used by requireStepUp middleware.
|
|
544
|
+
*/
|
|
545
|
+
export const setMfaVerifiedAt = async (sessionId) => {
|
|
546
|
+
const now = Math.floor(Date.now() / 1000);
|
|
547
|
+
if (_store === "memory") {
|
|
548
|
+
memorySetMfaVerifiedAt(sessionId, now);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (_store === "sqlite") {
|
|
552
|
+
sqliteSetMfaVerifiedAt(sessionId, now);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (_store === "redis") {
|
|
556
|
+
const redis = getRedis();
|
|
557
|
+
const raw = await redis.get(redisSessionKey(sessionId));
|
|
558
|
+
if (!raw)
|
|
559
|
+
return;
|
|
560
|
+
const rec = JSON.parse(raw);
|
|
561
|
+
rec.mfaVerifiedAt = now;
|
|
562
|
+
if (getPersistSessionMetadata()) {
|
|
563
|
+
await redis.set(redisSessionKey(sessionId), JSON.stringify(rec));
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const nowMs = Date.now();
|
|
567
|
+
if (rec.expiresAt <= nowMs)
|
|
568
|
+
return;
|
|
569
|
+
const ttlRemaining = Math.max(1, Math.ceil((rec.expiresAt - nowMs) / 1000));
|
|
570
|
+
await redis.set(redisSessionKey(sessionId), JSON.stringify(rec), "EX", ttlRemaining);
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
// mongo
|
|
575
|
+
await getSessionModel().updateOne({ sessionId }, { $set: { mfaVerifiedAt: now } });
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* Get the Unix timestamp (seconds) when MFA was last verified for this session.
|
|
579
|
+
* Returns null if MFA has never been verified or session not found.
|
|
580
|
+
*/
|
|
581
|
+
export const getMfaVerifiedAt = async (sessionId) => {
|
|
582
|
+
if (_store === "memory")
|
|
583
|
+
return memoryGetMfaVerifiedAt(sessionId);
|
|
584
|
+
if (_store === "sqlite")
|
|
585
|
+
return sqliteGetMfaVerifiedAt(sessionId);
|
|
586
|
+
if (_store === "redis") {
|
|
587
|
+
const redis = getRedis();
|
|
588
|
+
const raw = await redis.get(redisSessionKey(sessionId));
|
|
589
|
+
if (!raw)
|
|
590
|
+
return null;
|
|
591
|
+
const rec = JSON.parse(raw);
|
|
592
|
+
return rec.mfaVerifiedAt ?? null;
|
|
593
|
+
}
|
|
594
|
+
// mongo
|
|
595
|
+
const doc = await getSessionModel().findOne({ sessionId }, "mfaVerifiedAt").lean();
|
|
596
|
+
return doc?.mfaVerifiedAt ?? null;
|
|
597
|
+
};
|
package/dist/lib/signing.js
CHANGED
|
@@ -33,7 +33,10 @@ export function hmacVerify(data, sig, secret) {
|
|
|
33
33
|
return true;
|
|
34
34
|
}
|
|
35
35
|
catch {
|
|
36
|
-
// timingSafeEqual
|
|
36
|
+
// timingSafeEqual (src/lib/crypto.ts) handles length mismatches itself:
|
|
37
|
+
// it returns false rather than throwing, so this catch block is never
|
|
38
|
+
// reached under normal conditions. It is kept as a defensive no-op in
|
|
39
|
+
// case the underlying implementation changes in the future.
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
return false;
|
|
@@ -163,7 +166,7 @@ export function verifyPresignedUrl(url, method, secret) {
|
|
|
163
166
|
return null;
|
|
164
167
|
// Expiry check
|
|
165
168
|
const expNum = parseInt(exp, 10);
|
|
166
|
-
if (
|
|
169
|
+
if (!isFinite(expNum) || expNum < Math.floor(Date.now() / 1000))
|
|
167
170
|
return null;
|
|
168
171
|
// Signature check
|
|
169
172
|
const data = `${urlMethod}\n${key}\n${exp}`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suspend or unsuspend a user.
|
|
3
|
+
* No-op when the adapter does not implement setSuspended.
|
|
4
|
+
*/
|
|
5
|
+
export declare function setSuspended(userId: string, suspended: boolean, reason?: string): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* Get the suspension status of a user.
|
|
8
|
+
* Returns { suspended: false } when the adapter does not implement getSuspended.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getSuspended(userId: string): Promise<{
|
|
11
|
+
suspended: boolean;
|
|
12
|
+
suspendedReason?: string;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getAuthAdapter } from "./authAdapter";
|
|
2
|
+
/**
|
|
3
|
+
* Suspend or unsuspend a user.
|
|
4
|
+
* No-op when the adapter does not implement setSuspended.
|
|
5
|
+
*/
|
|
6
|
+
export async function setSuspended(userId, suspended, reason) {
|
|
7
|
+
const adapter = getAuthAdapter();
|
|
8
|
+
if (adapter.setSuspended) {
|
|
9
|
+
await adapter.setSuspended(userId, suspended, reason);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Get the suspension status of a user.
|
|
14
|
+
* Returns { suspended: false } when the adapter does not implement getSuspended.
|
|
15
|
+
*/
|
|
16
|
+
export async function getSuspended(userId) {
|
|
17
|
+
const adapter = getAuthAdapter();
|
|
18
|
+
if (adapter.getSuspended) {
|
|
19
|
+
const result = await adapter.getSuspended(userId);
|
|
20
|
+
return result ?? { suspended: false };
|
|
21
|
+
}
|
|
22
|
+
return { suspended: false };
|
|
23
|
+
}
|
package/dist/lib/upload.d.ts
CHANGED
|
@@ -21,6 +21,10 @@ export declare const generateUploadKey: (file: File, ctx: {
|
|
|
21
21
|
userId?: string;
|
|
22
22
|
tenantId?: string;
|
|
23
23
|
}, opts?: UploadOpts) => string;
|
|
24
|
+
export declare const generateUploadKeyFromFilename: (filename: string | undefined, ctx: {
|
|
25
|
+
userId?: string;
|
|
26
|
+
tenantId?: string;
|
|
27
|
+
}, opts?: UploadOpts) => string;
|
|
24
28
|
export declare const validateFile: (file: File, opts: {
|
|
25
29
|
maxFileSize?: number;
|
|
26
30
|
allowedMimeTypes?: string[];
|
package/dist/lib/upload.js
CHANGED
|
@@ -10,7 +10,22 @@ export const generateUploadKey = (file, ctx, opts) => {
|
|
|
10
10
|
const merged = { ..._config, ...opts };
|
|
11
11
|
if (merged.generateKey)
|
|
12
12
|
return merged.generateKey(file, ctx);
|
|
13
|
-
const
|
|
13
|
+
const rawExt = extname(file.name);
|
|
14
|
+
const ext = /^\.[a-zA-Z0-9]{1,10}$/.test(rawExt) ? rawExt : "";
|
|
15
|
+
const uuid = crypto.randomUUID();
|
|
16
|
+
const prefix = merged.keyPrefix ?? "uploads/";
|
|
17
|
+
const tenantPrefix = merged.tenantScopedKeys && ctx.tenantId ? `${ctx.tenantId}/` : "";
|
|
18
|
+
return `${prefix}${tenantPrefix}${uuid}${ext}`;
|
|
19
|
+
};
|
|
20
|
+
export const generateUploadKeyFromFilename = (filename, ctx, opts) => {
|
|
21
|
+
const merged = { ..._config, ...opts };
|
|
22
|
+
if (merged.generateKey) {
|
|
23
|
+
// Create minimal File-like for custom generators that expect a File object
|
|
24
|
+
const stub = new File([], filename ?? "upload");
|
|
25
|
+
return merged.generateKey(stub, ctx);
|
|
26
|
+
}
|
|
27
|
+
const rawExt = filename ? extname(filename) : "";
|
|
28
|
+
const ext = /^\.[a-zA-Z0-9]{1,10}$/.test(rawExt) ? rawExt : "";
|
|
14
29
|
const uuid = crypto.randomUUID();
|
|
15
30
|
const prefix = merged.keyPrefix ?? "uploads/";
|
|
16
31
|
const tenantPrefix = merged.tenantScopedKeys && ctx.tenantId ? `${ctx.tenantId}/` : "";
|
|
@@ -49,6 +64,16 @@ export const processUpload = async (file, opts) => {
|
|
|
49
64
|
size: file.size,
|
|
50
65
|
bucket: opts.bucket,
|
|
51
66
|
});
|
|
67
|
+
// Register the upload for ownership tracking
|
|
68
|
+
const { registerUpload } = await import("./uploadRegistry");
|
|
69
|
+
await registerUpload({
|
|
70
|
+
key,
|
|
71
|
+
ownerUserId: opts.ctx?.userId,
|
|
72
|
+
tenantId: opts.ctx?.tenantId,
|
|
73
|
+
mimeType: file.type,
|
|
74
|
+
bucket: opts.bucket,
|
|
75
|
+
createdAt: Date.now(),
|
|
76
|
+
});
|
|
52
77
|
return {
|
|
53
78
|
key,
|
|
54
79
|
originalName: file.name,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface UploadRecord {
|
|
2
|
+
key: string;
|
|
3
|
+
ownerUserId?: string;
|
|
4
|
+
tenantId?: string;
|
|
5
|
+
mimeType?: string;
|
|
6
|
+
bucket?: string;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
}
|
|
9
|
+
type UploadRegistryStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
10
|
+
export declare const setUploadRegistryStore: (store: UploadRegistryStore) => void;
|
|
11
|
+
export declare const clearUploadRegistry: () => void;
|
|
12
|
+
/** Store a new upload record. Keyed by the storage key. */
|
|
13
|
+
export declare const registerUpload: (record: UploadRecord) => Promise<void>;
|
|
14
|
+
/** Retrieve an upload record by key. Returns null if not found. */
|
|
15
|
+
export declare const getUploadRecord: (key: string) => Promise<UploadRecord | null>;
|
|
16
|
+
/** Delete an upload record by key. Returns true if it existed. */
|
|
17
|
+
export declare const deleteUploadRecord: (key: string) => Promise<boolean>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { getRedis } from "./redis";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
3
|
+
import { getAppName } from "./appConfig";
|
|
4
|
+
let _store = "memory";
|
|
5
|
+
export const setUploadRegistryStore = (store) => { _store = store; };
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Memory backend
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const _memoryRegistry = new Map();
|
|
10
|
+
export const clearUploadRegistry = () => { _memoryRegistry.clear(); };
|
|
11
|
+
function getUploadRegistryModel() {
|
|
12
|
+
if (appConnection.models["UploadRegistry"])
|
|
13
|
+
return appConnection.models["UploadRegistry"];
|
|
14
|
+
const { Schema } = mongoose;
|
|
15
|
+
const schema = new Schema({
|
|
16
|
+
key: { type: String, required: true, unique: true },
|
|
17
|
+
ownerUserId: { type: String },
|
|
18
|
+
tenantId: { type: String },
|
|
19
|
+
mimeType: { type: String },
|
|
20
|
+
bucket: { type: String },
|
|
21
|
+
createdAt: { type: Number, required: true },
|
|
22
|
+
}, { collection: "upload_registry" });
|
|
23
|
+
return appConnection.model("UploadRegistry", schema);
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Public API
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/** Store a new upload record. Keyed by the storage key. */
|
|
29
|
+
export const registerUpload = async (record) => {
|
|
30
|
+
if (_store === "memory") {
|
|
31
|
+
_memoryRegistry.set(record.key, record);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (_store === "sqlite") {
|
|
35
|
+
const { sqliteRegisterUpload } = await import("../adapters/sqliteAuth");
|
|
36
|
+
sqliteRegisterUpload(record);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (_store === "mongo") {
|
|
40
|
+
await getUploadRegistryModel().updateOne({ key: record.key }, { $set: record }, { upsert: true });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Redis — no TTL (upload records are permanent until deleted)
|
|
44
|
+
await getRedis().set(`ur:${getAppName()}:${record.key}`, JSON.stringify(record));
|
|
45
|
+
};
|
|
46
|
+
/** Retrieve an upload record by key. Returns null if not found. */
|
|
47
|
+
export const getUploadRecord = async (key) => {
|
|
48
|
+
if (_store === "memory") {
|
|
49
|
+
return _memoryRegistry.get(key) ?? null;
|
|
50
|
+
}
|
|
51
|
+
if (_store === "sqlite") {
|
|
52
|
+
const { sqliteGetUploadRecord } = await import("../adapters/sqliteAuth");
|
|
53
|
+
return sqliteGetUploadRecord(key);
|
|
54
|
+
}
|
|
55
|
+
if (_store === "mongo") {
|
|
56
|
+
const doc = await getUploadRegistryModel().findOne({ key }).lean();
|
|
57
|
+
if (!doc)
|
|
58
|
+
return null;
|
|
59
|
+
return { key: doc.key, ownerUserId: doc.ownerUserId, tenantId: doc.tenantId, mimeType: doc.mimeType, bucket: doc.bucket, createdAt: doc.createdAt };
|
|
60
|
+
}
|
|
61
|
+
// Redis
|
|
62
|
+
const raw = await getRedis().get(`ur:${getAppName()}:${key}`);
|
|
63
|
+
if (!raw)
|
|
64
|
+
return null;
|
|
65
|
+
return JSON.parse(raw);
|
|
66
|
+
};
|
|
67
|
+
/** Delete an upload record by key. Returns true if it existed. */
|
|
68
|
+
export const deleteUploadRecord = async (key) => {
|
|
69
|
+
if (_store === "memory") {
|
|
70
|
+
return _memoryRegistry.delete(key);
|
|
71
|
+
}
|
|
72
|
+
if (_store === "sqlite") {
|
|
73
|
+
const { sqliteDeleteUploadRecord } = await import("../adapters/sqliteAuth");
|
|
74
|
+
return sqliteDeleteUploadRecord(key);
|
|
75
|
+
}
|
|
76
|
+
if (_store === "mongo") {
|
|
77
|
+
const result = await getUploadRegistryModel().deleteOne({ key });
|
|
78
|
+
return result.deletedCount > 0;
|
|
79
|
+
}
|
|
80
|
+
// Redis
|
|
81
|
+
const deleted = await getRedis().del(`ur:${getAppName()}:${key}`);
|
|
82
|
+
return deleted > 0;
|
|
83
|
+
};
|
package/dist/lib/ws.js
CHANGED
|
@@ -15,6 +15,9 @@ export const getRooms = () => [..._roomRegistry.keys()];
|
|
|
15
15
|
/** Socket IDs subscribed to a given room */
|
|
16
16
|
export const getRoomSubscribers = (room) => [...(_roomRegistry.get(room) ?? [])];
|
|
17
17
|
const MAX_ROOM_ACTION_SIZE = 4096; // 4 KB — room actions are small JSON payloads
|
|
18
|
+
function isValidRoomName(room) {
|
|
19
|
+
return typeof room === "string" && /^[a-zA-Z0-9_:./\-]{1,128}$/.test(room);
|
|
20
|
+
}
|
|
18
21
|
export const handleRoomActions = async (ws, message, onSubscribe) => {
|
|
19
22
|
try {
|
|
20
23
|
const raw = typeof message === "string" ? message : Buffer.from(message).toString();
|
|
@@ -22,6 +25,8 @@ export const handleRoomActions = async (ws, message, onSubscribe) => {
|
|
|
22
25
|
return false; // not a room action
|
|
23
26
|
const data = JSON.parse(raw);
|
|
24
27
|
if (data.action === "subscribe" && typeof data.room === "string") {
|
|
28
|
+
if (!isValidRoomName(data.room))
|
|
29
|
+
return true; // silently drop invalid room names
|
|
25
30
|
if (onSubscribe && !(await onSubscribe(ws, data.room))) {
|
|
26
31
|
ws.send(JSON.stringify({ event: "subscribe_denied", room: data.room }));
|
|
27
32
|
}
|
|
@@ -32,6 +37,8 @@ export const handleRoomActions = async (ws, message, onSubscribe) => {
|
|
|
32
37
|
return true;
|
|
33
38
|
}
|
|
34
39
|
if (data.action === "unsubscribe" && typeof data.room === "string") {
|
|
40
|
+
if (!isValidRoomName(data.room))
|
|
41
|
+
return true; // silently drop invalid room names
|
|
35
42
|
unsubscribe(ws, data.room);
|
|
36
43
|
ws.send(JSON.stringify({ event: "unsubscribed", room: data.room }));
|
|
37
44
|
return true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { timingSafeEqual } from "../lib/crypto";
|
|
2
2
|
export const bearerAuth = async (c, next) => {
|
|
3
3
|
const isProd = process.env.NODE_ENV === "production";
|
|
4
|
-
const validToken = isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV;
|
|
4
|
+
const validToken = (isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV) ?? undefined;
|
|
5
5
|
const header = c.req.header("Authorization");
|
|
6
6
|
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
|
7
7
|
if (!token || !validToken || !timingSafeEqual(token, validToken)) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
import { type CaptchaConfig } from "../lib/captcha";
|
|
4
|
+
/**
|
|
5
|
+
* Middleware factory that verifies a CAPTCHA token from the request body.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* router.post("/contact", requireCaptcha({ provider: "turnstile", secretKey: "..." }), handler);
|
|
9
|
+
*/
|
|
10
|
+
export declare const requireCaptcha: (config?: CaptchaConfig) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { verifyCaptcha } from "../lib/captcha";
|
|
2
|
+
import { HttpError } from "../lib/HttpError";
|
|
3
|
+
import { getClientIp } from "../lib/clientIp";
|
|
4
|
+
/**
|
|
5
|
+
* Middleware factory that verifies a CAPTCHA token from the request body.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* router.post("/contact", requireCaptcha({ provider: "turnstile", secretKey: "..." }), handler);
|
|
9
|
+
*/
|
|
10
|
+
export const requireCaptcha = (config) => async (c, next) => {
|
|
11
|
+
// Get effective config: param takes precedence, then global config
|
|
12
|
+
const { getCaptchaConfig } = await import("../lib/appConfig");
|
|
13
|
+
const effectiveConfig = config ?? getCaptchaConfig();
|
|
14
|
+
if (!effectiveConfig) {
|
|
15
|
+
await next();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const tokenField = effectiveConfig.tokenField ?? "captcha-token";
|
|
19
|
+
let body;
|
|
20
|
+
try {
|
|
21
|
+
body = await c.req.json();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
body = {};
|
|
25
|
+
}
|
|
26
|
+
const token = body[tokenField];
|
|
27
|
+
if (!token) {
|
|
28
|
+
throw new HttpError(400, "CAPTCHA token is required", "CAPTCHA_MISSING");
|
|
29
|
+
}
|
|
30
|
+
const ip = getClientIp(c) ?? undefined;
|
|
31
|
+
const result = await verifyCaptcha(token, effectiveConfig, ip);
|
|
32
|
+
if (!result.success) {
|
|
33
|
+
throw new HttpError(400, "CAPTCHA verification failed", "CAPTCHA_FAILED");
|
|
34
|
+
}
|
|
35
|
+
await next();
|
|
36
|
+
};
|