@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.
Files changed (185) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +59 -0
  4. package/dist/adapters/memoryAuth.d.ts +13 -0
  5. package/dist/adapters/memoryAuth.js +261 -2
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +217 -1
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +30 -0
  12. package/dist/adapters/sqliteAuth.js +352 -2
  13. package/dist/app.d.ts +203 -3
  14. package/dist/app.js +352 -48
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +69 -8
  17. package/dist/index.js +46 -5
  18. package/dist/lib/HttpError.d.ts +7 -1
  19. package/dist/lib/HttpError.js +10 -1
  20. package/dist/lib/appConfig.d.ts +157 -0
  21. package/dist/lib/appConfig.js +54 -0
  22. package/dist/lib/auditLog.d.ts +58 -0
  23. package/dist/lib/auditLog.js +218 -0
  24. package/dist/lib/authAdapter.d.ts +140 -1
  25. package/dist/lib/authRateLimit.js +36 -0
  26. package/dist/lib/breachedPassword.d.ts +13 -0
  27. package/dist/lib/breachedPassword.js +48 -0
  28. package/dist/lib/captcha.d.ts +25 -0
  29. package/dist/lib/captcha.js +37 -0
  30. package/dist/lib/constants.d.ts +4 -0
  31. package/dist/lib/constants.js +4 -0
  32. package/dist/lib/context.d.ts +24 -1
  33. package/dist/lib/context.js +17 -3
  34. package/dist/lib/createRoute.d.ts +28 -2
  35. package/dist/lib/createRoute.js +54 -3
  36. package/dist/lib/credentialStuffing.d.ts +31 -0
  37. package/dist/lib/credentialStuffing.js +77 -0
  38. package/dist/lib/deletionCancelToken.d.ts +12 -0
  39. package/dist/lib/deletionCancelToken.js +88 -0
  40. package/dist/lib/emailVerification.d.ts +6 -0
  41. package/dist/lib/emailVerification.js +46 -3
  42. package/dist/lib/groups.d.ts +113 -0
  43. package/dist/lib/groups.js +133 -0
  44. package/dist/lib/idempotency.d.ts +22 -0
  45. package/dist/lib/idempotency.js +182 -0
  46. package/dist/lib/jwks.d.ts +25 -0
  47. package/dist/lib/jwks.js +51 -0
  48. package/dist/lib/jwt.d.ts +15 -2
  49. package/dist/lib/jwt.js +92 -5
  50. package/dist/lib/logger.d.ts +2 -0
  51. package/dist/lib/logger.js +6 -0
  52. package/dist/lib/m2m.d.ts +29 -0
  53. package/dist/lib/m2m.js +48 -0
  54. package/dist/lib/metrics.d.ts +14 -0
  55. package/dist/lib/metrics.js +158 -0
  56. package/dist/lib/mfaChallenge.d.ts +14 -1
  57. package/dist/lib/mfaChallenge.js +111 -6
  58. package/dist/lib/mongo.js +1 -1
  59. package/dist/lib/oauthCode.js +23 -18
  60. package/dist/lib/pagination.d.ts +119 -0
  61. package/dist/lib/pagination.js +166 -0
  62. package/dist/lib/resetPassword.js +3 -1
  63. package/dist/lib/saml.d.ts +25 -0
  64. package/dist/lib/saml.js +64 -0
  65. package/dist/lib/scim.d.ts +44 -0
  66. package/dist/lib/scim.js +54 -0
  67. package/dist/lib/securityEvents.d.ts +28 -0
  68. package/dist/lib/securityEvents.js +26 -0
  69. package/dist/lib/session.d.ts +14 -0
  70. package/dist/lib/session.js +121 -5
  71. package/dist/lib/signing.d.ts +52 -0
  72. package/dist/lib/signing.js +183 -0
  73. package/dist/lib/storageAdapter.d.ts +30 -0
  74. package/dist/lib/storageAdapter.js +1 -0
  75. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  76. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  77. package/dist/lib/suspension.d.ts +13 -0
  78. package/dist/lib/suspension.js +23 -0
  79. package/dist/lib/tenant.js +2 -2
  80. package/dist/lib/upload.d.ts +39 -0
  81. package/dist/lib/upload.js +112 -0
  82. package/dist/lib/uploadRegistry.d.ts +18 -0
  83. package/dist/lib/uploadRegistry.js +83 -0
  84. package/dist/lib/validate.js +2 -2
  85. package/dist/lib/ws.d.ts +1 -0
  86. package/dist/lib/ws.js +28 -0
  87. package/dist/lib/wsHeartbeat.d.ts +12 -0
  88. package/dist/lib/wsHeartbeat.js +57 -0
  89. package/dist/lib/wsMessages.d.ts +40 -0
  90. package/dist/lib/wsMessages.js +330 -0
  91. package/dist/lib/wsPresence.d.ts +25 -0
  92. package/dist/lib/wsPresence.js +99 -0
  93. package/dist/middleware/auditLog.d.ts +22 -0
  94. package/dist/middleware/auditLog.js +39 -0
  95. package/dist/middleware/bearerAuth.js +1 -1
  96. package/dist/middleware/cacheResponse.js +5 -1
  97. package/dist/middleware/captcha.d.ts +10 -0
  98. package/dist/middleware/captcha.js +36 -0
  99. package/dist/middleware/csrf.js +18 -4
  100. package/dist/middleware/errorHandler.js +4 -1
  101. package/dist/middleware/identify.js +89 -14
  102. package/dist/middleware/metrics.d.ts +9 -0
  103. package/dist/middleware/metrics.js +26 -0
  104. package/dist/middleware/requestId.d.ts +3 -0
  105. package/dist/middleware/requestId.js +7 -0
  106. package/dist/middleware/requestLogger.d.ts +38 -0
  107. package/dist/middleware/requestLogger.js +68 -0
  108. package/dist/middleware/requestSigning.d.ts +20 -0
  109. package/dist/middleware/requestSigning.js +100 -0
  110. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  111. package/dist/middleware/requireMfaSetup.js +37 -0
  112. package/dist/middleware/requireRole.d.ts +9 -3
  113. package/dist/middleware/requireRole.js +23 -36
  114. package/dist/middleware/requireScope.d.ts +10 -0
  115. package/dist/middleware/requireScope.js +25 -0
  116. package/dist/middleware/requireStepUp.d.ts +18 -0
  117. package/dist/middleware/requireStepUp.js +29 -0
  118. package/dist/middleware/scimAuth.d.ts +8 -0
  119. package/dist/middleware/scimAuth.js +29 -0
  120. package/dist/middleware/upload.d.ts +5 -0
  121. package/dist/middleware/upload.js +27 -0
  122. package/dist/middleware/webhookAuth.d.ts +30 -0
  123. package/dist/middleware/webhookAuth.js +58 -0
  124. package/dist/models/AuditLog.d.ts +30 -0
  125. package/dist/models/AuditLog.js +39 -0
  126. package/dist/models/AuthUser.d.ts +7 -0
  127. package/dist/models/AuthUser.js +7 -0
  128. package/dist/models/Group.d.ts +21 -0
  129. package/dist/models/Group.js +28 -0
  130. package/dist/models/GroupMembership.d.ts +21 -0
  131. package/dist/models/GroupMembership.js +25 -0
  132. package/dist/models/M2MClient.d.ts +18 -0
  133. package/dist/models/M2MClient.js +18 -0
  134. package/dist/routes/auth.d.ts +3 -2
  135. package/dist/routes/auth.js +238 -21
  136. package/dist/routes/groups.d.ts +21 -0
  137. package/dist/routes/groups.js +346 -0
  138. package/dist/routes/jobs.js +66 -46
  139. package/dist/routes/m2m.d.ts +2 -0
  140. package/dist/routes/m2m.js +72 -0
  141. package/dist/routes/metrics.d.ts +8 -0
  142. package/dist/routes/metrics.js +55 -0
  143. package/dist/routes/mfa.js +13 -1
  144. package/dist/routes/oauth.js +6 -0
  145. package/dist/routes/oidc.d.ts +2 -0
  146. package/dist/routes/oidc.js +29 -0
  147. package/dist/routes/passkey.d.ts +1 -0
  148. package/dist/routes/passkey.js +157 -0
  149. package/dist/routes/saml.d.ts +2 -0
  150. package/dist/routes/saml.js +86 -0
  151. package/dist/routes/scim.d.ts +2 -0
  152. package/dist/routes/scim.js +255 -0
  153. package/dist/routes/uploads.d.ts +14 -0
  154. package/dist/routes/uploads.js +227 -0
  155. package/dist/server.d.ts +26 -0
  156. package/dist/server.js +46 -3
  157. package/dist/services/auth.d.ts +2 -0
  158. package/dist/services/auth.js +101 -22
  159. package/dist/services/mfa.js +2 -2
  160. package/dist/ws/index.js +5 -1
  161. package/docs/sections/auth-flow/full.md +203 -47
  162. package/docs/sections/auth-flow/overview.md +2 -2
  163. package/docs/sections/auth-security-examples/full.md +388 -0
  164. package/docs/sections/authentication/full.md +130 -0
  165. package/docs/sections/authentication/overview.md +5 -0
  166. package/docs/sections/cli/full.md +13 -1
  167. package/docs/sections/configuration/full.md +17 -0
  168. package/docs/sections/configuration/overview.md +1 -0
  169. package/docs/sections/exports/full.md +34 -3
  170. package/docs/sections/logging/full.md +83 -0
  171. package/docs/sections/metrics/full.md +131 -0
  172. package/docs/sections/oauth/full.md +189 -189
  173. package/docs/sections/oauth/overview.md +1 -1
  174. package/docs/sections/pagination/full.md +93 -0
  175. package/docs/sections/passkey-login/full.md +90 -0
  176. package/docs/sections/passkey-login/overview.md +1 -0
  177. package/docs/sections/roles/full.md +224 -135
  178. package/docs/sections/roles/overview.md +3 -1
  179. package/docs/sections/signing/full.md +203 -0
  180. package/docs/sections/uploads/full.md +208 -0
  181. package/docs/sections/versioning/full.md +85 -0
  182. package/docs/sections/webhook-auth/full.md +100 -0
  183. package/docs/sections/websocket/full.md +95 -0
  184. package/docs/sections/websocket-rooms/full.md +6 -1
  185. 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 const signToken = async (userId, sessionId, expirySeconds) => new SignJWT({ sub: userId, sid: sessionId })
18
- .setProtectedHeader({ alg: "HS256" })
19
- .setExpirationTime(expirySeconds ? `${expirySeconds}s` : "7d")
20
- .sign(getSecret());
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
- const { payload } = await jwtVerify(token, getSecret());
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
+ }
@@ -1 +1,3 @@
1
1
  export declare const log: (...args: unknown[]) => void;
2
+ /** Like log(), but also requires LOGGING_AUTH_TRACE=true. Use for lines that include user/session IDs. */
3
+ export declare const authTrace: (...args: unknown[]) => void;
@@ -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[]>;
@@ -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 {};
@@ -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 token = crypto.randomUUID();
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 getRedis().get(key);
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 token = crypto.randomUUID();
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 getRedis().get(key);
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;
@@ -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 code = crypto.randomUUID();
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 redis = getRedis();
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);