@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/lib/jwt.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { SignJWT, jwtVerify } from "jose";
|
|
2
|
+
import { getJwtIssuer, getJwtAudience } from "./appConfig";
|
|
3
|
+
import { getSigningPrivateKey, getVerifyPublicKeys, isJwksLoaded } from "./jwks";
|
|
2
4
|
let _secret = null;
|
|
5
|
+
let _algorithm = "HS256";
|
|
3
6
|
function getSecret() {
|
|
4
7
|
if (_secret)
|
|
5
8
|
return _secret;
|
|
@@ -14,11 +17,95 @@ function getSecret() {
|
|
|
14
17
|
_secret = new TextEncoder().encode(rawSecret);
|
|
15
18
|
return _secret;
|
|
16
19
|
}
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
export function validateJwtSecrets() {
|
|
21
|
+
if (_algorithm !== "RS256") {
|
|
22
|
+
getSecret();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function signToken(claimsOrUserId, sessionIdOrExpiry, expirySeconds) {
|
|
26
|
+
let claims;
|
|
27
|
+
let expiry;
|
|
28
|
+
if (typeof claimsOrUserId === "string") {
|
|
29
|
+
// Legacy positional: signToken(userId, sessionId, expirySeconds?)
|
|
30
|
+
claims = { sub: claimsOrUserId, sid: sessionIdOrExpiry };
|
|
31
|
+
expiry = expirySeconds;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// New object form: signToken(claims, expirySeconds?)
|
|
35
|
+
claims = claimsOrUserId;
|
|
36
|
+
expiry = sessionIdOrExpiry;
|
|
37
|
+
}
|
|
38
|
+
if (_algorithm === "RS256") {
|
|
39
|
+
if (!isJwksLoaded()) {
|
|
40
|
+
throw new Error("RS256 requires OIDC key configuration — call loadJwksKey() first");
|
|
41
|
+
}
|
|
42
|
+
// Use RS256 with JWKS key
|
|
43
|
+
const privateKey = getSigningPrivateKey();
|
|
44
|
+
const jwt = new SignJWT(claims)
|
|
45
|
+
.setProtectedHeader({ alg: "RS256", kid: "key-1" })
|
|
46
|
+
.setIssuedAt()
|
|
47
|
+
.setExpirationTime(expiry ? `${expiry}s` : "7d");
|
|
48
|
+
const issuer = getJwtIssuer();
|
|
49
|
+
const audience = getJwtAudience();
|
|
50
|
+
if (issuer)
|
|
51
|
+
jwt.setIssuer(issuer);
|
|
52
|
+
if (audience)
|
|
53
|
+
jwt.setAudience(audience);
|
|
54
|
+
return jwt.sign(privateKey);
|
|
55
|
+
}
|
|
56
|
+
const jwt = new SignJWT(claims)
|
|
57
|
+
.setProtectedHeader({ alg: _algorithm })
|
|
58
|
+
.setIssuedAt()
|
|
59
|
+
.setExpirationTime(expiry ? `${expiry}s` : "7d");
|
|
60
|
+
const issuer = getJwtIssuer();
|
|
61
|
+
const audience = getJwtAudience();
|
|
62
|
+
if (issuer)
|
|
63
|
+
jwt.setIssuer(issuer);
|
|
64
|
+
if (audience)
|
|
65
|
+
jwt.setAudience(audience);
|
|
66
|
+
return jwt.sign(getSecret());
|
|
67
|
+
}
|
|
21
68
|
export const verifyToken = async (token) => {
|
|
22
|
-
|
|
69
|
+
if (_algorithm === "RS256") {
|
|
70
|
+
if (!isJwksLoaded()) {
|
|
71
|
+
throw new Error("RS256 requires OIDC key configuration");
|
|
72
|
+
}
|
|
73
|
+
const publicKeys = getVerifyPublicKeys();
|
|
74
|
+
const opts = { algorithms: ["RS256"] };
|
|
75
|
+
const issuer = getJwtIssuer();
|
|
76
|
+
const audience = getJwtAudience();
|
|
77
|
+
if (issuer)
|
|
78
|
+
opts.issuer = issuer;
|
|
79
|
+
if (audience)
|
|
80
|
+
opts.audience = audience;
|
|
81
|
+
// Try each key (supports key rotation)
|
|
82
|
+
for (const key of publicKeys) {
|
|
83
|
+
try {
|
|
84
|
+
const { payload } = await jwtVerify(token, key, opts);
|
|
85
|
+
return payload;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw new Error("JWT verification failed with all available keys");
|
|
92
|
+
}
|
|
93
|
+
const issuer = getJwtIssuer();
|
|
94
|
+
const audience = getJwtAudience();
|
|
95
|
+
const opts = { algorithms: [_algorithm] };
|
|
96
|
+
if (issuer)
|
|
97
|
+
opts.issuer = issuer;
|
|
98
|
+
if (audience)
|
|
99
|
+
opts.audience = audience;
|
|
100
|
+
const { payload } = await jwtVerify(token, getSecret(), opts);
|
|
23
101
|
return payload;
|
|
24
102
|
};
|
|
103
|
+
/** @internal — used by Feature 8 (OIDC) to switch to RS256 once key material is loaded */
|
|
104
|
+
export function _setAlgorithm(alg) {
|
|
105
|
+
_algorithm = alg;
|
|
106
|
+
}
|
|
107
|
+
/** @internal — reset for testing */
|
|
108
|
+
export function _resetJwtState() {
|
|
109
|
+
_secret = null;
|
|
110
|
+
_algorithm = "HS256";
|
|
111
|
+
}
|
package/dist/lib/logger.d.ts
CHANGED
package/dist/lib/logger.js
CHANGED
|
@@ -5,3 +5,9 @@ export const log = (...args) => {
|
|
|
5
5
|
if (verbose)
|
|
6
6
|
console.log(...args);
|
|
7
7
|
};
|
|
8
|
+
const authTraceEnabled = process.env.LOGGING_AUTH_TRACE === "true";
|
|
9
|
+
/** Like log(), but also requires LOGGING_AUTH_TRACE=true. Use for lines that include user/session IDs. */
|
|
10
|
+
export const authTrace = (...args) => {
|
|
11
|
+
if (authTraceEnabled)
|
|
12
|
+
log(...args);
|
|
13
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { M2MClientRecord } from "./authAdapter";
|
|
2
|
+
/**
|
|
3
|
+
* Look up an M2M client by clientId (active only).
|
|
4
|
+
* Returns the client record including clientSecretHash for verification.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getM2MClient(clientId: string): Promise<(M2MClientRecord & {
|
|
7
|
+
clientSecretHash: string;
|
|
8
|
+
}) | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Create a new M2M client. Returns the client ID and a plaintext secret (shown once).
|
|
11
|
+
* The secret is hashed with Bun.password before storage.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createM2MClient(opts: {
|
|
14
|
+
clientId: string;
|
|
15
|
+
name: string;
|
|
16
|
+
scopes?: string[];
|
|
17
|
+
}): Promise<{
|
|
18
|
+
id: string;
|
|
19
|
+
clientId: string;
|
|
20
|
+
clientSecret: string;
|
|
21
|
+
}>;
|
|
22
|
+
/**
|
|
23
|
+
* Delete an M2M client by clientId.
|
|
24
|
+
*/
|
|
25
|
+
export declare function deleteM2MClient(clientId: string): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* List all M2M clients (secrets not included).
|
|
28
|
+
*/
|
|
29
|
+
export declare function listM2MClients(): Promise<M2MClientRecord[]>;
|
package/dist/lib/m2m.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getAuthAdapter } from "./authAdapter";
|
|
2
|
+
/**
|
|
3
|
+
* Look up an M2M client by clientId (active only).
|
|
4
|
+
* Returns the client record including clientSecretHash for verification.
|
|
5
|
+
*/
|
|
6
|
+
export async function getM2MClient(clientId) {
|
|
7
|
+
const adapter = getAuthAdapter();
|
|
8
|
+
if (!adapter.getM2MClient)
|
|
9
|
+
return null;
|
|
10
|
+
return adapter.getM2MClient(clientId);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Create a new M2M client. Returns the client ID and a plaintext secret (shown once).
|
|
14
|
+
* The secret is hashed with Bun.password before storage.
|
|
15
|
+
*/
|
|
16
|
+
export async function createM2MClient(opts) {
|
|
17
|
+
const adapter = getAuthAdapter();
|
|
18
|
+
if (!adapter.createM2MClient) {
|
|
19
|
+
throw new Error("Auth adapter does not support M2M clients");
|
|
20
|
+
}
|
|
21
|
+
const clientSecret = crypto.randomUUID() + "-" + crypto.randomUUID();
|
|
22
|
+
const clientSecretHash = await Bun.password.hash(clientSecret);
|
|
23
|
+
const { id } = await adapter.createM2MClient({
|
|
24
|
+
clientId: opts.clientId,
|
|
25
|
+
clientSecretHash,
|
|
26
|
+
name: opts.name,
|
|
27
|
+
scopes: opts.scopes ?? [],
|
|
28
|
+
});
|
|
29
|
+
return { id, clientId: opts.clientId, clientSecret };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Delete an M2M client by clientId.
|
|
33
|
+
*/
|
|
34
|
+
export async function deleteM2MClient(clientId) {
|
|
35
|
+
const adapter = getAuthAdapter();
|
|
36
|
+
if (adapter.deleteM2MClient) {
|
|
37
|
+
await adapter.deleteM2MClient(clientId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* List all M2M clients (secrets not included).
|
|
42
|
+
*/
|
|
43
|
+
export async function listM2MClients() {
|
|
44
|
+
const adapter = getAuthAdapter();
|
|
45
|
+
if (!adapter.listM2MClients)
|
|
46
|
+
return [];
|
|
47
|
+
return adapter.listM2MClients();
|
|
48
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type Labels = Record<string, string>;
|
|
2
|
+
export declare function defaultNormalizePath(path: string): string;
|
|
3
|
+
export declare function incrementCounter(name: string, labels: Labels, amount?: number): void;
|
|
4
|
+
export declare function observeHistogram(name: string, labels: Labels, value: number, buckets?: number[]): void;
|
|
5
|
+
type GaugeCallback = () => Promise<{
|
|
6
|
+
labels: Labels;
|
|
7
|
+
value: number;
|
|
8
|
+
}[]>;
|
|
9
|
+
export declare function registerGaugeCallback(name: string, cb: GaugeCallback): void;
|
|
10
|
+
export declare function serializeMetrics(): Promise<string>;
|
|
11
|
+
export declare function resetMetrics(): void;
|
|
12
|
+
export declare function setMetricsQueues(map: Map<string, any>): void;
|
|
13
|
+
export declare function closeMetricsQueues(): Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// In-memory Prometheus-compatible metrics registry.
|
|
2
|
+
// Bun is single-threaded so no atomics are needed.
|
|
3
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
4
|
+
function labelKey(labels) {
|
|
5
|
+
return Object.entries(labels)
|
|
6
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
7
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
8
|
+
.join(",");
|
|
9
|
+
}
|
|
10
|
+
function formatLabels(labels) {
|
|
11
|
+
const pairs = Object.entries(labels)
|
|
12
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
13
|
+
.map(([k, v]) => `${k}="${v}"`);
|
|
14
|
+
return pairs.length ? `{${pairs.join(",")}}` : "";
|
|
15
|
+
}
|
|
16
|
+
// ── Path normalization ───────────────────────────────────────────────────────
|
|
17
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const OBJECTID_RE = /^[0-9a-f]{24}$/i;
|
|
19
|
+
const NUMERIC_RE = /^\d+$/;
|
|
20
|
+
export function defaultNormalizePath(path) {
|
|
21
|
+
return path
|
|
22
|
+
.split("/")
|
|
23
|
+
.map((seg) => {
|
|
24
|
+
if (!seg)
|
|
25
|
+
return seg;
|
|
26
|
+
if (UUID_RE.test(seg))
|
|
27
|
+
return ":id";
|
|
28
|
+
if (OBJECTID_RE.test(seg))
|
|
29
|
+
return ":id";
|
|
30
|
+
if (NUMERIC_RE.test(seg))
|
|
31
|
+
return ":id";
|
|
32
|
+
return seg;
|
|
33
|
+
})
|
|
34
|
+
.join("/");
|
|
35
|
+
}
|
|
36
|
+
const counters = new Map();
|
|
37
|
+
export function incrementCounter(name, labels, amount = 1) {
|
|
38
|
+
let metric = counters.get(name);
|
|
39
|
+
if (!metric) {
|
|
40
|
+
metric = new Map();
|
|
41
|
+
counters.set(name, metric);
|
|
42
|
+
}
|
|
43
|
+
const key = labelKey(labels);
|
|
44
|
+
const existing = metric.get(key);
|
|
45
|
+
if (existing) {
|
|
46
|
+
existing.value += amount;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
metric.set(key, { labels: { ...labels }, value: amount });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ── Histogram ────────────────────────────────────────────────────────────────
|
|
53
|
+
const DEFAULT_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
54
|
+
const histograms = new Map();
|
|
55
|
+
export function observeHistogram(name, labels, value, buckets = DEFAULT_BUCKETS) {
|
|
56
|
+
let metric = histograms.get(name);
|
|
57
|
+
if (!metric) {
|
|
58
|
+
metric = { boundaries: buckets, entries: new Map() };
|
|
59
|
+
histograms.set(name, metric);
|
|
60
|
+
}
|
|
61
|
+
const key = labelKey(labels);
|
|
62
|
+
let entry = metric.entries.get(key);
|
|
63
|
+
if (!entry) {
|
|
64
|
+
entry = { labels: { ...labels }, buckets: new Array(buckets.length).fill(0), sum: 0, count: 0 };
|
|
65
|
+
metric.entries.set(key, entry);
|
|
66
|
+
}
|
|
67
|
+
// Find the first (tightest) bucket the value fits in.
|
|
68
|
+
// Serialization will compute cumulative sums across buckets.
|
|
69
|
+
for (let i = 0; i < metric.boundaries.length; i++) {
|
|
70
|
+
if (value <= metric.boundaries[i]) {
|
|
71
|
+
entry.buckets[i]++;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
entry.sum += value;
|
|
76
|
+
entry.count++;
|
|
77
|
+
}
|
|
78
|
+
const gaugeCallbacks = new Map();
|
|
79
|
+
export function registerGaugeCallback(name, cb) {
|
|
80
|
+
gaugeCallbacks.set(name, cb);
|
|
81
|
+
}
|
|
82
|
+
// ── Serialize ────────────────────────────────────────────────────────────────
|
|
83
|
+
export async function serializeMetrics() {
|
|
84
|
+
const lines = [];
|
|
85
|
+
// Collect gauge callbacks first so any error-counter increments are
|
|
86
|
+
// included when we serialize counters below.
|
|
87
|
+
const gaugeLines = [];
|
|
88
|
+
for (const [name, cb] of gaugeCallbacks) {
|
|
89
|
+
try {
|
|
90
|
+
const results = await cb();
|
|
91
|
+
gaugeLines.push(`# HELP ${name} ${name.replace(/_/g, " ")}`);
|
|
92
|
+
gaugeLines.push(`# TYPE ${name} gauge`);
|
|
93
|
+
for (const { labels, value } of results) {
|
|
94
|
+
gaugeLines.push(`${name}${formatLabels(labels)} ${value}`);
|
|
95
|
+
}
|
|
96
|
+
gaugeLines.push("");
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
console.warn(`[metrics] Gauge callback "${name}" failed:`, err);
|
|
100
|
+
incrementCounter("bunshot_gauge_errors_total", { gauge: name });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Counters
|
|
104
|
+
for (const [name, entries] of counters) {
|
|
105
|
+
lines.push(`# HELP ${name} Total ${name.replace(/_/g, " ")}`);
|
|
106
|
+
lines.push(`# TYPE ${name} counter`);
|
|
107
|
+
for (const entry of entries.values()) {
|
|
108
|
+
lines.push(`${name}${formatLabels(entry.labels)} ${entry.value}`);
|
|
109
|
+
}
|
|
110
|
+
lines.push("");
|
|
111
|
+
}
|
|
112
|
+
// Histograms
|
|
113
|
+
for (const [name, metric] of histograms) {
|
|
114
|
+
lines.push(`# HELP ${name} ${name.replace(/_/g, " ")}`);
|
|
115
|
+
lines.push(`# TYPE ${name} histogram`);
|
|
116
|
+
for (const entry of metric.entries.values()) {
|
|
117
|
+
const lbls = formatLabels(entry.labels);
|
|
118
|
+
let cumulative = 0;
|
|
119
|
+
for (let i = 0; i < metric.boundaries.length; i++) {
|
|
120
|
+
cumulative += entry.buckets[i];
|
|
121
|
+
const bucketLabels = { ...entry.labels, le: String(metric.boundaries[i]) };
|
|
122
|
+
lines.push(`${name}_bucket${formatLabels(bucketLabels)} ${cumulative}`);
|
|
123
|
+
}
|
|
124
|
+
const infLabels = { ...entry.labels, le: "+Inf" };
|
|
125
|
+
lines.push(`${name}_bucket${formatLabels(infLabels)} ${entry.count}`);
|
|
126
|
+
lines.push(`${name}_sum${lbls} ${entry.sum}`);
|
|
127
|
+
lines.push(`${name}_count${lbls} ${entry.count}`);
|
|
128
|
+
}
|
|
129
|
+
lines.push("");
|
|
130
|
+
}
|
|
131
|
+
// Gauges (already collected above)
|
|
132
|
+
lines.push(...gaugeLines);
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
135
|
+
// ── Reset (for tests) ───────────────────────────────────────────────────────
|
|
136
|
+
export function resetMetrics() {
|
|
137
|
+
counters.clear();
|
|
138
|
+
histograms.clear();
|
|
139
|
+
gaugeCallbacks.clear();
|
|
140
|
+
}
|
|
141
|
+
// ── Queue cleanup ────────────────────────────────────────────────────────────
|
|
142
|
+
let metricsQueues = null;
|
|
143
|
+
export function setMetricsQueues(map) {
|
|
144
|
+
metricsQueues = map;
|
|
145
|
+
}
|
|
146
|
+
export async function closeMetricsQueues() {
|
|
147
|
+
if (!metricsQueues)
|
|
148
|
+
return;
|
|
149
|
+
for (const q of metricsQueues.values()) {
|
|
150
|
+
try {
|
|
151
|
+
await q.close();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// best-effort cleanup
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
metricsQueues.clear();
|
|
158
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type MfaChallengePurpose = "login" | "webauthn-registration";
|
|
1
|
+
export type MfaChallengePurpose = "login" | "webauthn-registration" | "passkey-login";
|
|
2
2
|
export interface MfaChallengeOptions {
|
|
3
3
|
emailOtpHash?: string;
|
|
4
4
|
webauthnChallenge?: string;
|
|
@@ -39,4 +39,17 @@ export declare const consumeWebAuthnRegistrationChallenge: (token: string) => Pr
|
|
|
39
39
|
userId: string;
|
|
40
40
|
challenge: string;
|
|
41
41
|
} | null>;
|
|
42
|
+
/**
|
|
43
|
+
* Create a passkey login challenge token. Not tied to a user — userId is resolved
|
|
44
|
+
* from the credential after assertion. Uses a fixed 120s TTL.
|
|
45
|
+
*/
|
|
46
|
+
export declare const createPasskeyLoginChallenge: (challenge: string) => Promise<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Consume a passkey login challenge token.
|
|
49
|
+
* Only accepts tokens with `purpose: "passkey-login"`.
|
|
50
|
+
* Returns the stored webauthnChallenge bytes or null if expired/invalid.
|
|
51
|
+
*/
|
|
52
|
+
export declare const consumePasskeyLoginChallenge: (token: string) => Promise<{
|
|
53
|
+
webauthnChallenge: string;
|
|
54
|
+
} | null>;
|
|
42
55
|
export {};
|
package/dist/lib/mfaChallenge.js
CHANGED
|
@@ -68,13 +68,35 @@ function ensureSqliteMfaTable() {
|
|
|
68
68
|
catch { /* already exists */ }
|
|
69
69
|
_sqliteTableCreated = true;
|
|
70
70
|
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Redis helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
/** Atomically GET+DEL a key. Uses native GETDEL (Redis >= 6.2) with a Lua fallback. */
|
|
75
|
+
async function redisGetDel(key) {
|
|
76
|
+
const redis = getRedis();
|
|
77
|
+
if (typeof redis.getdel === "function") {
|
|
78
|
+
try {
|
|
79
|
+
return await redis.getdel(key);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const msg = err?.message ?? "";
|
|
83
|
+
if (!/unknown command|ERR unknown command/i.test(msg))
|
|
84
|
+
throw err;
|
|
85
|
+
// Fall through to Lua on "unknown command"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const result = await redis.eval("local v = redis.call('GET', KEYS[1])\nif v then redis.call('DEL', KEYS[1]) end\nreturn v", 1, key);
|
|
89
|
+
return result ?? null;
|
|
90
|
+
}
|
|
71
91
|
let _store = "redis";
|
|
72
92
|
export const setMfaChallengeStore = (store) => { _store = store; };
|
|
73
93
|
// ---------------------------------------------------------------------------
|
|
74
94
|
// Public API
|
|
75
95
|
// ---------------------------------------------------------------------------
|
|
76
96
|
export const createMfaChallenge = async (userId, options) => {
|
|
77
|
-
const
|
|
97
|
+
const bytes = new Uint8Array(32);
|
|
98
|
+
crypto.getRandomValues(bytes);
|
|
99
|
+
const token = Buffer.from(bytes).toString("base64url");
|
|
78
100
|
const hash = sha256(token);
|
|
79
101
|
const ttl = getMfaChallengeTtl();
|
|
80
102
|
const now = Date.now();
|
|
@@ -135,10 +157,9 @@ export const consumeMfaChallenge = async (token) => {
|
|
|
135
157
|
}
|
|
136
158
|
// redis
|
|
137
159
|
const key = `mfachallenge:${getAppName()}:${hash}`;
|
|
138
|
-
const raw = await
|
|
160
|
+
const raw = await redisGetDel(key);
|
|
139
161
|
if (!raw)
|
|
140
162
|
return null;
|
|
141
|
-
await getRedis().del(key);
|
|
142
163
|
const data = JSON.parse(raw);
|
|
143
164
|
if (data.purpose !== "login")
|
|
144
165
|
return null;
|
|
@@ -220,7 +241,9 @@ export const replaceMfaChallengeOtp = async (token, newEmailOtpHash) => {
|
|
|
220
241
|
* uses `purpose: "webauthn-registration"` so it cannot be consumed by `consumeMfaChallenge`.
|
|
221
242
|
*/
|
|
222
243
|
export const createWebAuthnRegistrationChallenge = async (userId, challenge) => {
|
|
223
|
-
const
|
|
244
|
+
const bytes = new Uint8Array(32);
|
|
245
|
+
crypto.getRandomValues(bytes);
|
|
246
|
+
const token = Buffer.from(bytes).toString("base64url");
|
|
224
247
|
const hash = sha256(token);
|
|
225
248
|
const ttl = getMfaChallengeTtl();
|
|
226
249
|
const now = Date.now();
|
|
@@ -282,12 +305,94 @@ export const consumeWebAuthnRegistrationChallenge = async (token) => {
|
|
|
282
305
|
}
|
|
283
306
|
// redis
|
|
284
307
|
const key = `mfachallenge:${getAppName()}:${hash}`;
|
|
285
|
-
const raw = await
|
|
308
|
+
const raw = await redisGetDel(key);
|
|
286
309
|
if (!raw)
|
|
287
310
|
return null;
|
|
288
|
-
await getRedis().del(key);
|
|
289
311
|
const data = JSON.parse(raw);
|
|
290
312
|
if (data.purpose !== "webauthn-registration" || !data.webauthnChallenge)
|
|
291
313
|
return null;
|
|
292
314
|
return { userId: data.userId, challenge: data.webauthnChallenge };
|
|
293
315
|
};
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Passkey login challenge helpers (passwordless first-factor)
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
const PASSKEY_LOGIN_CHALLENGE_TTL = 120; // seconds — single-use, so longer TTL is safe
|
|
320
|
+
/**
|
|
321
|
+
* Create a passkey login challenge token. Not tied to a user — userId is resolved
|
|
322
|
+
* from the credential after assertion. Uses a fixed 120s TTL.
|
|
323
|
+
*/
|
|
324
|
+
export const createPasskeyLoginChallenge = async (challenge) => {
|
|
325
|
+
const bytes = new Uint8Array(32);
|
|
326
|
+
crypto.getRandomValues(bytes);
|
|
327
|
+
const token = Buffer.from(bytes).toString("base64url");
|
|
328
|
+
const hash = sha256(token);
|
|
329
|
+
const ttl = PASSKEY_LOGIN_CHALLENGE_TTL;
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
const purpose = "passkey-login";
|
|
332
|
+
const userId = ""; // anonymous — resolved from credential ID at login time
|
|
333
|
+
if (_store === "memory") {
|
|
334
|
+
_memoryChallenges.set(hash, { userId, purpose, webauthnChallenge: challenge, createdAt: now, resendCount: 0, expiresAt: now + ttl * 1000 });
|
|
335
|
+
return token;
|
|
336
|
+
}
|
|
337
|
+
if (_store === "sqlite") {
|
|
338
|
+
ensureSqliteMfaTable();
|
|
339
|
+
_sqliteDb.run("INSERT INTO mfa_challenges (token, userId, purpose, webauthnChallenge, createdAt, resendCount, expiresAt) VALUES (?, ?, ?, ?, ?, 0, ?)", [hash, userId, purpose, challenge, now, now + ttl * 1000]);
|
|
340
|
+
return token;
|
|
341
|
+
}
|
|
342
|
+
if (_store === "mongo") {
|
|
343
|
+
await getMfaChallengeModel().create({
|
|
344
|
+
token: hash,
|
|
345
|
+
userId,
|
|
346
|
+
purpose,
|
|
347
|
+
webauthnChallenge: challenge,
|
|
348
|
+
createdAt: new Date(now),
|
|
349
|
+
resendCount: 0,
|
|
350
|
+
expiresAt: new Date(now + ttl * 1000),
|
|
351
|
+
});
|
|
352
|
+
return token;
|
|
353
|
+
}
|
|
354
|
+
// redis
|
|
355
|
+
await getRedis().set(`mfachallenge:${getAppName()}:${hash}`, JSON.stringify({ userId, purpose, webauthnChallenge: challenge, createdAt: now, resendCount: 0 }), "EX", ttl);
|
|
356
|
+
return token;
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* Consume a passkey login challenge token.
|
|
360
|
+
* Only accepts tokens with `purpose: "passkey-login"`.
|
|
361
|
+
* Returns the stored webauthnChallenge bytes or null if expired/invalid.
|
|
362
|
+
*/
|
|
363
|
+
export const consumePasskeyLoginChallenge = async (token) => {
|
|
364
|
+
const hash = sha256(token);
|
|
365
|
+
if (_store === "memory") {
|
|
366
|
+
const entry = _memoryChallenges.get(hash);
|
|
367
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
368
|
+
_memoryChallenges.delete(hash);
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
_memoryChallenges.delete(hash);
|
|
372
|
+
if (entry.purpose !== "passkey-login" || !entry.webauthnChallenge)
|
|
373
|
+
return null;
|
|
374
|
+
return { webauthnChallenge: entry.webauthnChallenge };
|
|
375
|
+
}
|
|
376
|
+
if (_store === "sqlite") {
|
|
377
|
+
ensureSqliteMfaTable();
|
|
378
|
+
const row = _sqliteDb.query("DELETE FROM mfa_challenges WHERE token = ? AND expiresAt > ? RETURNING purpose, webauthnChallenge").get(hash, Date.now());
|
|
379
|
+
if (!row || row.purpose !== "passkey-login" || !row.webauthnChallenge)
|
|
380
|
+
return null;
|
|
381
|
+
return { webauthnChallenge: row.webauthnChallenge };
|
|
382
|
+
}
|
|
383
|
+
if (_store === "mongo") {
|
|
384
|
+
const doc = await getMfaChallengeModel().findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } });
|
|
385
|
+
if (!doc || doc.purpose !== "passkey-login" || !doc.webauthnChallenge)
|
|
386
|
+
return null;
|
|
387
|
+
return { webauthnChallenge: doc.webauthnChallenge };
|
|
388
|
+
}
|
|
389
|
+
// redis
|
|
390
|
+
const key = `mfachallenge:${getAppName()}:${hash}`;
|
|
391
|
+
const raw = await redisGetDel(key);
|
|
392
|
+
if (!raw)
|
|
393
|
+
return null;
|
|
394
|
+
const data = JSON.parse(raw);
|
|
395
|
+
if (data.purpose !== "passkey-login" || !data.webauthnChallenge)
|
|
396
|
+
return null;
|
|
397
|
+
return { webauthnChallenge: data.webauthnChallenge };
|
|
398
|
+
};
|
package/dist/lib/mongo.js
CHANGED
|
@@ -13,7 +13,7 @@ function requireMongoose() {
|
|
|
13
13
|
}
|
|
14
14
|
function buildUri(user, password, host, db) {
|
|
15
15
|
const [hostPart, queryPart] = host.split("?");
|
|
16
|
-
return `mongodb+srv://${user}:${password}@${hostPart.replace(/\/$/, "")}/${db}${queryPart ? `?${queryPart}` : ""}`;
|
|
16
|
+
return `mongodb+srv://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${hostPart.replace(/\/$/, "")}/${db}${queryPart ? `?${queryPart}` : ""}`;
|
|
17
17
|
}
|
|
18
18
|
// Internal mutable references — set inside connect functions
|
|
19
19
|
let _authConn = null;
|
package/dist/lib/oauthCode.js
CHANGED
|
@@ -18,6 +18,25 @@ function getOAuthCodeModel() {
|
|
|
18
18
|
}, { collection: "oauth_codes" });
|
|
19
19
|
return appConnection.model("OAuthCode", schema);
|
|
20
20
|
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Redis helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
/** Atomically GET+DEL a key. Uses native GETDEL (Redis >= 6.2) with a Lua fallback. */
|
|
25
|
+
async function redisGetDel(key) {
|
|
26
|
+
const redis = getRedis();
|
|
27
|
+
if (typeof redis.getdel === "function") {
|
|
28
|
+
try {
|
|
29
|
+
return await redis.getdel(key);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const msg = err?.message ?? "";
|
|
33
|
+
if (!/unknown command|ERR unknown command/i.test(msg))
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const result = await redis.eval("local v = redis.call('GET', KEYS[1])\nif v then redis.call('DEL', KEYS[1]) end\nreturn v", 1, key);
|
|
38
|
+
return result ?? null;
|
|
39
|
+
}
|
|
21
40
|
let _store = "redis";
|
|
22
41
|
export const setOAuthCodeStore = (store) => { _store = store; };
|
|
23
42
|
const CODE_TTL = 60; // 60 seconds
|
|
@@ -27,7 +46,9 @@ const CODE_TTL = 60; // 60 seconds
|
|
|
27
46
|
/** Store a one-time authorization code. Returns the raw code (for the redirect URL).
|
|
28
47
|
* Only the SHA-256 hash is persisted. */
|
|
29
48
|
export const storeOAuthCode = async (payload) => {
|
|
30
|
-
const
|
|
49
|
+
const bytes = new Uint8Array(32);
|
|
50
|
+
crypto.getRandomValues(bytes);
|
|
51
|
+
const code = Buffer.from(bytes).toString("base64url");
|
|
31
52
|
const hash = sha256(code);
|
|
32
53
|
if (_store === "memory") {
|
|
33
54
|
memoryStoreOAuthCode(hash, payload, CODE_TTL);
|
|
@@ -67,23 +88,7 @@ export const consumeOAuthCode = async (code) => {
|
|
|
67
88
|
}
|
|
68
89
|
// Redis
|
|
69
90
|
const key = `oauthcode:${getAppName()}:${hash}`;
|
|
70
|
-
const
|
|
71
|
-
let raw = null;
|
|
72
|
-
if (typeof redis.getdel === "function") {
|
|
73
|
-
try {
|
|
74
|
-
raw = await redis.getdel(key);
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
raw = await redis.get(key);
|
|
78
|
-
if (raw)
|
|
79
|
-
await redis.del(key);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
raw = await redis.get(key);
|
|
84
|
-
if (raw)
|
|
85
|
-
await redis.del(key);
|
|
86
|
-
}
|
|
91
|
+
const raw = await redisGetDel(key);
|
|
87
92
|
if (!raw)
|
|
88
93
|
return null;
|
|
89
94
|
return JSON.parse(raw);
|