@lastshotlabs/bunshot 0.0.16 → 0.0.19

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 (72) hide show
  1. package/README.md +322 -16
  2. package/dist/adapters/memoryAuth.d.ts +3 -0
  3. package/dist/adapters/memoryAuth.js +48 -2
  4. package/dist/adapters/mongoAuth.js +39 -1
  5. package/dist/adapters/sqliteAuth.d.ts +3 -0
  6. package/dist/adapters/sqliteAuth.js +53 -0
  7. package/dist/app.d.ts +45 -2
  8. package/dist/app.js +79 -4
  9. package/dist/index.d.ts +14 -7
  10. package/dist/index.js +8 -4
  11. package/dist/lib/appConfig.d.ts +35 -0
  12. package/dist/lib/appConfig.js +10 -0
  13. package/dist/lib/authAdapter.d.ts +24 -0
  14. package/dist/lib/authRateLimit.d.ts +2 -0
  15. package/dist/lib/authRateLimit.js +4 -0
  16. package/dist/lib/clientIp.d.ts +14 -0
  17. package/dist/lib/clientIp.js +52 -0
  18. package/dist/lib/constants.d.ts +2 -0
  19. package/dist/lib/constants.js +2 -0
  20. package/dist/lib/crypto.d.ts +11 -0
  21. package/dist/lib/crypto.js +22 -0
  22. package/dist/lib/emailVerification.d.ts +4 -0
  23. package/dist/lib/emailVerification.js +20 -12
  24. package/dist/lib/jwt.js +17 -4
  25. package/dist/lib/mfaChallenge.d.ts +23 -1
  26. package/dist/lib/mfaChallenge.js +151 -42
  27. package/dist/lib/oauth.d.ts +14 -1
  28. package/dist/lib/oauth.js +19 -1
  29. package/dist/lib/oauthCode.d.ts +15 -0
  30. package/dist/lib/oauthCode.js +90 -0
  31. package/dist/lib/resetPassword.js +12 -16
  32. package/dist/lib/session.js +6 -4
  33. package/dist/lib/ws.js +5 -1
  34. package/dist/lib/zodToMongoose.d.ts +2 -2
  35. package/dist/lib/zodToMongoose.js +7 -3
  36. package/dist/middleware/bearerAuth.js +4 -3
  37. package/dist/middleware/botProtection.js +2 -2
  38. package/dist/middleware/cacheResponse.d.ts +1 -0
  39. package/dist/middleware/cacheResponse.js +14 -2
  40. package/dist/middleware/cors.d.ts +2 -0
  41. package/dist/middleware/cors.js +22 -8
  42. package/dist/middleware/csrf.d.ts +18 -0
  43. package/dist/middleware/csrf.js +115 -0
  44. package/dist/middleware/rateLimit.js +2 -3
  45. package/dist/models/AuthUser.d.ts +9 -0
  46. package/dist/models/AuthUser.js +9 -0
  47. package/dist/routes/auth.js +21 -9
  48. package/dist/routes/mfa.d.ts +5 -1
  49. package/dist/routes/mfa.js +221 -14
  50. package/dist/routes/oauth.js +274 -10
  51. package/dist/schemas/auth.d.ts +2 -0
  52. package/dist/schemas/auth.js +22 -1
  53. package/dist/server.d.ts +6 -0
  54. package/dist/server.js +10 -3
  55. package/dist/services/auth.d.ts +1 -0
  56. package/dist/services/auth.js +21 -5
  57. package/dist/services/mfa.d.ts +47 -0
  58. package/dist/services/mfa.js +276 -9
  59. package/dist/ws/index.js +3 -2
  60. package/docs/sections/auth-flow/full.md +180 -2
  61. package/docs/sections/configuration/full.md +20 -0
  62. package/docs/sections/configuration/overview.md +1 -1
  63. package/docs/sections/configuration-example/full.md +19 -1
  64. package/docs/sections/exports/full.md +11 -2
  65. package/docs/sections/multi-tenancy/full.md +5 -1
  66. package/docs/sections/oauth/full.md +80 -10
  67. package/docs/sections/oauth/overview.md +2 -2
  68. package/docs/sections/peer-dependencies/full.md +6 -2
  69. package/docs/sections/response-caching/full.md +3 -1
  70. package/docs/sections/websocket/full.md +4 -3
  71. package/docs/sections/websocket/overview.md +1 -1
  72. package/package.json +16 -4
@@ -0,0 +1,52 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Trust-proxy configuration (set once at startup via setTrustProxy)
3
+ // ---------------------------------------------------------------------------
4
+ let _trustProxy = false;
5
+ export const setTrustProxy = (value) => {
6
+ _trustProxy = value;
7
+ };
8
+ // ---------------------------------------------------------------------------
9
+ // Centralized client IP extraction
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Returns the client IP address, respecting the `trustProxy` setting.
13
+ *
14
+ * - When `trustProxy` is `false`: returns the socket-level IP (via Bun's
15
+ * `server.requestIP()`), ignoring `X-Forwarded-For` entirely.
16
+ * - When `trustProxy` is a number N: takes the Nth-from-right entry in the
17
+ * `X-Forwarded-For` chain (skipping N trusted proxy hops), falling back to
18
+ * the socket-level IP.
19
+ *
20
+ * Returns `"unknown"` if no IP can be determined.
21
+ */
22
+ export const getClientIp = (c) => {
23
+ // Socket-level IP via Bun's server (passed as c.env by Bun.serve)
24
+ let socketIp;
25
+ try {
26
+ const server = c.env;
27
+ if (server?.requestIP) {
28
+ const info = server.requestIP(c.req.raw);
29
+ if (info)
30
+ socketIp = info.address;
31
+ }
32
+ }
33
+ catch { /* not running under Bun.serve — e.g. test environment */ }
34
+ if (_trustProxy === false) {
35
+ return socketIp ?? "unknown";
36
+ }
37
+ // Trust N proxy hops: take the Nth-from-right in XFF
38
+ const xff = c.req.header("x-forwarded-for");
39
+ if (xff) {
40
+ const ips = xff.split(",").map(s => s.trim()).filter(Boolean);
41
+ // Index from the right: trustProxy=1 means 1 proxy, so take ips[length - 2]
42
+ const idx = ips.length - _trustProxy - 1;
43
+ if (idx >= 0 && ips[idx]) {
44
+ return ips[idx];
45
+ }
46
+ // If fewer entries than expected, fall back to leftmost (or socket IP)
47
+ if (ips.length > 0)
48
+ return ips[0];
49
+ }
50
+ // Fallback: X-Real-IP header, then socket IP
51
+ return c.req.header("x-real-ip") ?? socketIp ?? "unknown";
52
+ };
@@ -2,3 +2,5 @@ export declare const COOKIE_TOKEN = "token";
2
2
  export declare const HEADER_USER_TOKEN = "x-user-token";
3
3
  export declare const COOKIE_REFRESH_TOKEN = "refresh_token";
4
4
  export declare const HEADER_REFRESH_TOKEN = "x-refresh-token";
5
+ export declare const COOKIE_CSRF_TOKEN = "csrf_token";
6
+ export declare const HEADER_CSRF_TOKEN = "x-csrf-token";
@@ -2,3 +2,5 @@ export const COOKIE_TOKEN = "token";
2
2
  export const HEADER_USER_TOKEN = "x-user-token";
3
3
  export const COOKIE_REFRESH_TOKEN = "refresh_token";
4
4
  export const HEADER_REFRESH_TOKEN = "x-refresh-token";
5
+ export const COOKIE_CSRF_TOKEN = "csrf_token";
6
+ export const HEADER_CSRF_TOKEN = "x-csrf-token";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Constant-time string comparison to prevent timing attacks.
3
+ * Returns true if both strings are equal, false otherwise.
4
+ * Always compares the full length even on mismatch.
5
+ */
6
+ export declare function timingSafeEqual(a: string, b: string): boolean;
7
+ /**
8
+ * SHA-256 hash a string and return the hex digest.
9
+ * Centralized to avoid duplicate implementations across modules.
10
+ */
11
+ export declare function sha256(input: string): string;
@@ -0,0 +1,22 @@
1
+ import { createHash, timingSafeEqual as nodeTimingSafeEqual } from "crypto";
2
+ /**
3
+ * Constant-time string comparison to prevent timing attacks.
4
+ * Returns true if both strings are equal, false otherwise.
5
+ * Always compares the full length even on mismatch.
6
+ */
7
+ export function timingSafeEqual(a, b) {
8
+ if (a.length !== b.length) {
9
+ // Compare against self to burn the same time, then return false
10
+ const buf = Buffer.from(a, "utf-8");
11
+ nodeTimingSafeEqual(buf, buf);
12
+ return false;
13
+ }
14
+ return nodeTimingSafeEqual(Buffer.from(a, "utf-8"), Buffer.from(b, "utf-8"));
15
+ }
16
+ /**
17
+ * SHA-256 hash a string and return the hex digest.
18
+ * Centralized to avoid duplicate implementations across modules.
19
+ */
20
+ export function sha256(input) {
21
+ return createHash("sha256").update(input).digest("hex");
22
+ }
@@ -1,9 +1,13 @@
1
1
  type VerificationStore = "redis" | "mongo" | "sqlite" | "memory";
2
2
  export declare const setEmailVerificationStore: (store: VerificationStore) => void;
3
+ /** Create a verification token. Returns the raw token (for the email link).
4
+ * Only the SHA-256 hash is persisted in the store. */
3
5
  export declare const createVerificationToken: (userId: string, email: string) => Promise<string>;
6
+ /** Look up a verification token by its raw value. Hashes before lookup. */
4
7
  export declare const getVerificationToken: (token: string) => Promise<{
5
8
  userId: string;
6
9
  email: string;
7
10
  } | null>;
11
+ /** Delete a verification token by its raw value. Hashes before lookup. */
8
12
  export declare const deleteVerificationToken: (token: string) => Promise<void>;
9
13
  export {};
@@ -1,6 +1,7 @@
1
1
  import { getRedis } from "./redis";
2
2
  import { appConnection, mongoose } from "./mongo";
3
3
  import { getAppName, getTokenExpiry } from "./appConfig";
4
+ import { sha256 } from "./crypto";
4
5
  import { sqliteCreateVerificationToken, sqliteGetVerificationToken, sqliteDeleteVerificationToken, } from "../adapters/sqliteAuth";
5
6
  import { memoryCreateVerificationToken, memoryGetVerificationToken, memoryDeleteVerificationToken, } from "../adapters/memoryAuth";
6
7
  function getVerificationModel() {
@@ -20,59 +21,66 @@ export const setEmailVerificationStore = (store) => { _store = store; };
20
21
  // ---------------------------------------------------------------------------
21
22
  // Public API
22
23
  // ---------------------------------------------------------------------------
24
+ /** Create a verification token. Returns the raw token (for the email link).
25
+ * Only the SHA-256 hash is persisted in the store. */
23
26
  export const createVerificationToken = async (userId, email) => {
24
27
  const token = crypto.randomUUID();
28
+ const hash = sha256(token);
25
29
  const ttl = getTokenExpiry();
26
30
  if (_store === "memory") {
27
- memoryCreateVerificationToken(token, userId, email, ttl);
31
+ memoryCreateVerificationToken(hash, userId, email, ttl);
28
32
  return token;
29
33
  }
30
34
  if (_store === "sqlite") {
31
- sqliteCreateVerificationToken(token, userId, email, ttl);
35
+ sqliteCreateVerificationToken(hash, userId, email, ttl);
32
36
  return token;
33
37
  }
34
38
  if (_store === "mongo") {
35
39
  await getVerificationModel().create({
36
- token,
40
+ token: hash,
37
41
  userId,
38
42
  email,
39
43
  expiresAt: new Date(Date.now() + ttl * 1000),
40
44
  });
41
45
  return token;
42
46
  }
43
- await getRedis().set(`verify:${getAppName()}:${token}`, JSON.stringify({ userId, email }), "EX", ttl);
47
+ await getRedis().set(`verify:${getAppName()}:${hash}`, JSON.stringify({ userId, email }), "EX", ttl);
44
48
  return token;
45
49
  };
50
+ /** Look up a verification token by its raw value. Hashes before lookup. */
46
51
  export const getVerificationToken = async (token) => {
52
+ const hash = sha256(token);
47
53
  if (_store === "memory")
48
- return memoryGetVerificationToken(token);
54
+ return memoryGetVerificationToken(hash);
49
55
  if (_store === "sqlite")
50
- return sqliteGetVerificationToken(token);
56
+ return sqliteGetVerificationToken(hash);
51
57
  if (_store === "mongo") {
52
58
  const doc = await getVerificationModel()
53
- .findOne({ token, expiresAt: { $gt: new Date() } })
59
+ .findOne({ token: hash, expiresAt: { $gt: new Date() } })
54
60
  .lean();
55
61
  if (!doc)
56
62
  return null;
57
63
  return { userId: doc.userId, email: doc.email };
58
64
  }
59
- const raw = await getRedis().get(`verify:${getAppName()}:${token}`);
65
+ const raw = await getRedis().get(`verify:${getAppName()}:${hash}`);
60
66
  if (!raw)
61
67
  return null;
62
68
  return JSON.parse(raw);
63
69
  };
70
+ /** Delete a verification token by its raw value. Hashes before lookup. */
64
71
  export const deleteVerificationToken = async (token) => {
72
+ const hash = sha256(token);
65
73
  if (_store === "memory") {
66
- memoryDeleteVerificationToken(token);
74
+ memoryDeleteVerificationToken(hash);
67
75
  return;
68
76
  }
69
77
  if (_store === "sqlite") {
70
- sqliteDeleteVerificationToken(token);
78
+ sqliteDeleteVerificationToken(hash);
71
79
  return;
72
80
  }
73
81
  if (_store === "mongo") {
74
- await getVerificationModel().deleteOne({ token });
82
+ await getVerificationModel().deleteOne({ token: hash });
75
83
  return;
76
84
  }
77
- await getRedis().del(`verify:${getAppName()}:${token}`);
85
+ await getRedis().del(`verify:${getAppName()}:${hash}`);
78
86
  };
package/dist/lib/jwt.js CHANGED
@@ -1,11 +1,24 @@
1
1
  import { SignJWT, jwtVerify } from "jose";
2
- const isProd = process.env.NODE_ENV === "production";
3
- const secret = new TextEncoder().encode(isProd ? process.env.JWT_SECRET_PROD : process.env.JWT_SECRET_DEV);
2
+ let _secret = null;
3
+ function getSecret() {
4
+ if (_secret)
5
+ return _secret;
6
+ const isProd = process.env.NODE_ENV === "production";
7
+ const envKey = isProd ? "JWT_SECRET_PROD" : "JWT_SECRET_DEV";
8
+ const rawSecret = process.env[envKey];
9
+ if (!rawSecret || rawSecret.length < 32) {
10
+ throw new Error(`[security] ${envKey} is missing or too short (${rawSecret?.length ?? 0} chars). ` +
11
+ `JWT secrets must be at least 32 characters. Generate one with: ` +
12
+ `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`);
13
+ }
14
+ _secret = new TextEncoder().encode(rawSecret);
15
+ return _secret;
16
+ }
4
17
  export const signToken = async (userId, sessionId, expirySeconds) => new SignJWT({ sub: userId, sid: sessionId })
5
18
  .setProtectedHeader({ alg: "HS256" })
6
19
  .setExpirationTime(expirySeconds ? `${expirySeconds}s` : "7d")
7
- .sign(secret);
20
+ .sign(getSecret());
8
21
  export const verifyToken = async (token) => {
9
- const { payload } = await jwtVerify(token, secret);
22
+ const { payload } = await jwtVerify(token, getSecret());
10
23
  return payload;
11
24
  };
@@ -1,12 +1,21 @@
1
+ export type MfaChallengePurpose = "login" | "webauthn-registration";
2
+ export interface MfaChallengeOptions {
3
+ emailOtpHash?: string;
4
+ webauthnChallenge?: string;
5
+ }
1
6
  export interface MfaChallengeData {
2
7
  userId: string;
8
+ purpose: MfaChallengePurpose;
3
9
  emailOtpHash?: string;
10
+ webauthnChallenge?: string;
4
11
  }
12
+ /** Reset all in-memory MFA challenge state. Called by clearMemoryStore(). */
13
+ export declare const clearMemoryMfaChallenges: () => void;
5
14
  /** Must be called when store is "sqlite" to inject the db instance. */
6
15
  export declare const setMfaChallengeSqliteDb: (db: any) => void;
7
16
  type MfaChallengeStore = "redis" | "mongo" | "sqlite" | "memory";
8
17
  export declare const setMfaChallengeStore: (store: MfaChallengeStore) => void;
9
- export declare const createMfaChallenge: (userId: string, emailOtpHash?: string) => Promise<string>;
18
+ export declare const createMfaChallenge: (userId: string, options?: MfaChallengeOptions) => Promise<string>;
10
19
  export declare const consumeMfaChallenge: (token: string) => Promise<MfaChallengeData | null>;
11
20
  /**
12
21
  * Replace the email OTP hash on an existing challenge without consuming it.
@@ -17,4 +26,17 @@ export declare const replaceMfaChallengeOtp: (token: string, newEmailOtpHash: st
17
26
  userId: string;
18
27
  resendCount: number;
19
28
  } | null>;
29
+ /**
30
+ * Create a WebAuthn registration challenge token. Separate from the login flow —
31
+ * uses `purpose: "webauthn-registration"` so it cannot be consumed by `consumeMfaChallenge`.
32
+ */
33
+ export declare const createWebAuthnRegistrationChallenge: (userId: string, challenge: string) => Promise<string>;
34
+ /**
35
+ * Consume a WebAuthn registration challenge token.
36
+ * Only accepts tokens with `purpose: "webauthn-registration"`.
37
+ */
38
+ export declare const consumeWebAuthnRegistrationChallenge: (token: string) => Promise<{
39
+ userId: string;
40
+ challenge: string;
41
+ } | null>;
20
42
  export {};
@@ -1,6 +1,7 @@
1
1
  import { getRedis } from "./redis";
2
2
  import { appConnection, mongoose } from "./mongo";
3
3
  import { getAppName, getMfaChallengeTtl } from "./appConfig";
4
+ import { sha256 } from "./crypto";
4
5
  const MAX_RESENDS = 3;
5
6
  function getMfaChallengeModel() {
6
7
  if (appConnection.models["MfaChallenge"])
@@ -9,7 +10,9 @@ function getMfaChallengeModel() {
9
10
  const schema = new Schema({
10
11
  token: { type: String, required: true, unique: true },
11
12
  userId: { type: String, required: true },
13
+ purpose: { type: String, required: true, default: "login" },
12
14
  emailOtpHash: { type: String },
15
+ webauthnChallenge: { type: String },
13
16
  createdAt: { type: Date, required: true },
14
17
  resendCount: { type: Number, required: true, default: 0 },
15
18
  expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
@@ -20,6 +23,8 @@ function getMfaChallengeModel() {
20
23
  // In-memory store
21
24
  // ---------------------------------------------------------------------------
22
25
  const _memoryChallenges = new Map();
26
+ /** Reset all in-memory MFA challenge state. Called by clearMemoryStore(). */
27
+ export const clearMemoryMfaChallenges = () => { _memoryChallenges.clear(); };
23
28
  // ---------------------------------------------------------------------------
24
29
  // SQLite store (reuses the existing SQLite DB instance)
25
30
  // ---------------------------------------------------------------------------
@@ -31,14 +36,16 @@ function ensureSqliteMfaTable() {
31
36
  if (_sqliteTableCreated || !_sqliteDb)
32
37
  return;
33
38
  _sqliteDb.run(`CREATE TABLE IF NOT EXISTS mfa_challenges (
34
- token TEXT PRIMARY KEY,
35
- userId TEXT NOT NULL,
36
- emailOtpHash TEXT,
37
- createdAt INTEGER NOT NULL,
38
- resendCount INTEGER NOT NULL DEFAULT 0,
39
- expiresAt INTEGER NOT NULL
39
+ token TEXT PRIMARY KEY,
40
+ userId TEXT NOT NULL,
41
+ purpose TEXT NOT NULL DEFAULT 'login',
42
+ emailOtpHash TEXT,
43
+ webauthnChallenge TEXT,
44
+ createdAt INTEGER NOT NULL,
45
+ resendCount INTEGER NOT NULL DEFAULT 0,
46
+ expiresAt INTEGER NOT NULL
40
47
  )`);
41
- // Migrate pre-existing tables that lack the new columns
48
+ // Migrate pre-existing tables that lack newer columns
42
49
  try {
43
50
  _sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN emailOtpHash TEXT");
44
51
  }
@@ -51,6 +58,14 @@ function ensureSqliteMfaTable() {
51
58
  _sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN resendCount INTEGER NOT NULL DEFAULT 0");
52
59
  }
53
60
  catch { /* already exists */ }
61
+ try {
62
+ _sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN purpose TEXT NOT NULL DEFAULT 'login'");
63
+ }
64
+ catch { /* already exists */ }
65
+ try {
66
+ _sqliteDb.run("ALTER TABLE mfa_challenges ADD COLUMN webauthnChallenge TEXT");
67
+ }
68
+ catch { /* already exists */ }
54
69
  _sqliteTableCreated = true;
55
70
  }
56
71
  let _store = "redis";
@@ -58,24 +73,30 @@ export const setMfaChallengeStore = (store) => { _store = store; };
58
73
  // ---------------------------------------------------------------------------
59
74
  // Public API
60
75
  // ---------------------------------------------------------------------------
61
- export const createMfaChallenge = async (userId, emailOtpHash) => {
76
+ export const createMfaChallenge = async (userId, options) => {
62
77
  const token = crypto.randomUUID();
78
+ const hash = sha256(token);
63
79
  const ttl = getMfaChallengeTtl();
64
80
  const now = Date.now();
81
+ const purpose = "login";
82
+ const emailOtpHash = options?.emailOtpHash;
83
+ const webauthnChallenge = options?.webauthnChallenge;
65
84
  if (_store === "memory") {
66
- _memoryChallenges.set(token, { userId, emailOtpHash, createdAt: now, resendCount: 0, expiresAt: now + ttl * 1000 });
85
+ _memoryChallenges.set(hash, { userId, purpose, emailOtpHash, webauthnChallenge, createdAt: now, resendCount: 0, expiresAt: now + ttl * 1000 });
67
86
  return token;
68
87
  }
69
88
  if (_store === "sqlite") {
70
89
  ensureSqliteMfaTable();
71
- _sqliteDb.run("INSERT INTO mfa_challenges (token, userId, emailOtpHash, createdAt, resendCount, expiresAt) VALUES (?, ?, ?, ?, 0, ?)", [token, userId, emailOtpHash ?? null, now, now + ttl * 1000]);
90
+ _sqliteDb.run("INSERT INTO mfa_challenges (token, userId, purpose, emailOtpHash, webauthnChallenge, createdAt, resendCount, expiresAt) VALUES (?, ?, ?, ?, ?, ?, 0, ?)", [hash, userId, purpose, emailOtpHash ?? null, webauthnChallenge ?? null, now, now + ttl * 1000]);
72
91
  return token;
73
92
  }
74
93
  if (_store === "mongo") {
75
94
  await getMfaChallengeModel().create({
76
- token,
95
+ token: hash,
77
96
  userId,
97
+ purpose,
78
98
  emailOtpHash,
99
+ webauthnChallenge,
79
100
  createdAt: new Date(now),
80
101
  resendCount: 0,
81
102
  expiresAt: new Date(now + ttl * 1000),
@@ -83,36 +104,45 @@ export const createMfaChallenge = async (userId, emailOtpHash) => {
83
104
  return token;
84
105
  }
85
106
  // redis
86
- await getRedis().set(`mfachallenge:${getAppName()}:${token}`, JSON.stringify({ userId, emailOtpHash, createdAt: now, resendCount: 0 }), "EX", ttl);
107
+ await getRedis().set(`mfachallenge:${getAppName()}:${hash}`, JSON.stringify({ userId, purpose, emailOtpHash, webauthnChallenge, createdAt: now, resendCount: 0 }), "EX", ttl);
87
108
  return token;
88
109
  };
89
110
  export const consumeMfaChallenge = async (token) => {
111
+ const hash = sha256(token);
90
112
  if (_store === "memory") {
91
- const entry = _memoryChallenges.get(token);
113
+ const entry = _memoryChallenges.get(hash);
92
114
  if (!entry || entry.expiresAt <= Date.now()) {
93
- _memoryChallenges.delete(token);
115
+ _memoryChallenges.delete(hash);
94
116
  return null;
95
117
  }
96
- _memoryChallenges.delete(token);
97
- return { userId: entry.userId, emailOtpHash: entry.emailOtpHash };
118
+ _memoryChallenges.delete(hash);
119
+ if (entry.purpose !== "login")
120
+ return null;
121
+ return { userId: entry.userId, purpose: entry.purpose, emailOtpHash: entry.emailOtpHash, webauthnChallenge: entry.webauthnChallenge };
98
122
  }
99
123
  if (_store === "sqlite") {
100
124
  ensureSqliteMfaTable();
101
- const row = _sqliteDb.query("DELETE FROM mfa_challenges WHERE token = ? AND expiresAt > ? RETURNING userId, emailOtpHash").get(token, Date.now());
102
- return row ? { userId: row.userId, emailOtpHash: row.emailOtpHash ?? undefined } : null;
125
+ const row = _sqliteDb.query("DELETE FROM mfa_challenges WHERE token = ? AND expiresAt > ? RETURNING userId, purpose, emailOtpHash, webauthnChallenge").get(hash, Date.now());
126
+ if (!row || row.purpose !== "login")
127
+ return null;
128
+ return { userId: row.userId, purpose: "login", emailOtpHash: row.emailOtpHash ?? undefined, webauthnChallenge: row.webauthnChallenge ?? undefined };
103
129
  }
104
130
  if (_store === "mongo") {
105
- const doc = await getMfaChallengeModel().findOneAndDelete({ token, expiresAt: { $gt: new Date() } });
106
- return doc ? { userId: doc.userId, emailOtpHash: doc.emailOtpHash } : null;
131
+ const doc = await getMfaChallengeModel().findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } });
132
+ if (!doc || doc.purpose !== "login")
133
+ return null;
134
+ return { userId: doc.userId, purpose: "login", emailOtpHash: doc.emailOtpHash, webauthnChallenge: doc.webauthnChallenge };
107
135
  }
108
136
  // redis
109
- const key = `mfachallenge:${getAppName()}:${token}`;
137
+ const key = `mfachallenge:${getAppName()}:${hash}`;
110
138
  const raw = await getRedis().get(key);
111
139
  if (!raw)
112
140
  return null;
113
141
  await getRedis().del(key);
114
142
  const data = JSON.parse(raw);
115
- return { userId: data.userId, emailOtpHash: data.emailOtpHash };
143
+ if (data.purpose !== "login")
144
+ return null;
145
+ return { userId: data.userId, purpose: "login", emailOtpHash: data.emailOtpHash, webauthnChallenge: data.webauthnChallenge };
116
146
  };
117
147
  /**
118
148
  * Replace the email OTP hash on an existing challenge without consuming it.
@@ -120,11 +150,12 @@ export const consumeMfaChallenge = async (token) => {
120
150
  * Returns { userId, resendCount } on success, null if challenge not found/expired/max resends reached.
121
151
  */
122
152
  export const replaceMfaChallengeOtp = async (token, newEmailOtpHash) => {
153
+ const hash = sha256(token);
123
154
  const ttl = getMfaChallengeTtl();
124
155
  if (_store === "memory") {
125
- const entry = _memoryChallenges.get(token);
156
+ const entry = _memoryChallenges.get(hash);
126
157
  if (!entry || entry.expiresAt <= Date.now()) {
127
- _memoryChallenges.delete(token);
158
+ _memoryChallenges.delete(hash);
128
159
  return null;
129
160
  }
130
161
  if (entry.resendCount >= MAX_RESENDS)
@@ -139,34 +170,33 @@ export const replaceMfaChallengeOtp = async (token, newEmailOtpHash) => {
139
170
  if (_store === "sqlite") {
140
171
  ensureSqliteMfaTable();
141
172
  const now = Date.now();
142
- const existing = _sqliteDb.query("SELECT createdAt, resendCount FROM mfa_challenges WHERE token = ? AND expiresAt > ?").get(token, now);
173
+ const existing = _sqliteDb.query("SELECT createdAt, resendCount FROM mfa_challenges WHERE token = ? AND expiresAt > ?").get(hash, now);
143
174
  if (!existing || existing.resendCount >= MAX_RESENDS)
144
175
  return null;
145
176
  const newExpiry = Math.min(now + ttl * 1000, existing.createdAt + ttl * 3 * 1000);
146
177
  const newCount = existing.resendCount + 1;
147
- const row = _sqliteDb.query("UPDATE mfa_challenges SET emailOtpHash = ?, resendCount = ?, expiresAt = ? WHERE token = ? RETURNING userId").get(newEmailOtpHash, newCount, newExpiry, token);
178
+ const row = _sqliteDb.query("UPDATE mfa_challenges SET emailOtpHash = ?, resendCount = ?, expiresAt = ? WHERE token = ? RETURNING userId").get(newEmailOtpHash, newCount, newExpiry, hash);
148
179
  return row ? { userId: row.userId, resendCount: newCount } : null;
149
180
  }
150
181
  if (_store === "mongo") {
151
182
  const now = new Date();
152
- const doc = await getMfaChallengeModel().findOneAndUpdate({ token, expiresAt: { $gt: now }, resendCount: { $lt: MAX_RESENDS } }, [
153
- {
154
- $set: {
155
- emailOtpHash: newEmailOtpHash,
156
- resendCount: { $add: ["$resendCount", 1] },
157
- expiresAt: {
158
- $min: [
159
- new Date(Date.now() + ttl * 1000),
160
- { $add: ["$createdAt", ttl * 3 * 1000] },
161
- ],
162
- },
163
- },
164
- },
165
- ], { new: true });
166
- return doc ? { userId: doc.userId, resendCount: doc.resendCount } : null;
183
+ const existing = await getMfaChallengeModel().findOne({
184
+ token: hash,
185
+ expiresAt: { $gt: now },
186
+ resendCount: { $lt: MAX_RESENDS },
187
+ });
188
+ if (!existing)
189
+ return null;
190
+ const newCount = existing.resendCount + 1;
191
+ const newExpiry = new Date(Math.min(Date.now() + ttl * 1000, existing.createdAt.getTime() + ttl * 3 * 1000));
192
+ existing.emailOtpHash = newEmailOtpHash;
193
+ existing.resendCount = newCount;
194
+ existing.expiresAt = newExpiry;
195
+ await existing.save();
196
+ return { userId: existing.userId, resendCount: newCount };
167
197
  }
168
198
  // redis
169
- const key = `mfachallenge:${getAppName()}:${token}`;
199
+ const key = `mfachallenge:${getAppName()}:${hash}`;
170
200
  const raw = await getRedis().get(key);
171
201
  if (!raw)
172
202
  return null;
@@ -182,3 +212,82 @@ export const replaceMfaChallengeOtp = async (token, newEmailOtpHash) => {
182
212
  await getRedis().set(key, JSON.stringify(data), "EX", remainingTtl);
183
213
  return { userId: data.userId, resendCount: data.resendCount };
184
214
  };
215
+ // ---------------------------------------------------------------------------
216
+ // WebAuthn registration challenge helpers
217
+ // ---------------------------------------------------------------------------
218
+ /**
219
+ * Create a WebAuthn registration challenge token. Separate from the login flow —
220
+ * uses `purpose: "webauthn-registration"` so it cannot be consumed by `consumeMfaChallenge`.
221
+ */
222
+ export const createWebAuthnRegistrationChallenge = async (userId, challenge) => {
223
+ const token = crypto.randomUUID();
224
+ const hash = sha256(token);
225
+ const ttl = getMfaChallengeTtl();
226
+ const now = Date.now();
227
+ const purpose = "webauthn-registration";
228
+ if (_store === "memory") {
229
+ _memoryChallenges.set(hash, { userId, purpose, webauthnChallenge: challenge, createdAt: now, resendCount: 0, expiresAt: now + ttl * 1000 });
230
+ return token;
231
+ }
232
+ if (_store === "sqlite") {
233
+ ensureSqliteMfaTable();
234
+ _sqliteDb.run("INSERT INTO mfa_challenges (token, userId, purpose, webauthnChallenge, createdAt, resendCount, expiresAt) VALUES (?, ?, ?, ?, ?, 0, ?)", [hash, userId, purpose, challenge, now, now + ttl * 1000]);
235
+ return token;
236
+ }
237
+ if (_store === "mongo") {
238
+ await getMfaChallengeModel().create({
239
+ token: hash,
240
+ userId,
241
+ purpose,
242
+ webauthnChallenge: challenge,
243
+ createdAt: new Date(now),
244
+ resendCount: 0,
245
+ expiresAt: new Date(now + ttl * 1000),
246
+ });
247
+ return token;
248
+ }
249
+ // redis
250
+ await getRedis().set(`mfachallenge:${getAppName()}:${hash}`, JSON.stringify({ userId, purpose, webauthnChallenge: challenge, createdAt: now, resendCount: 0 }), "EX", ttl);
251
+ return token;
252
+ };
253
+ /**
254
+ * Consume a WebAuthn registration challenge token.
255
+ * Only accepts tokens with `purpose: "webauthn-registration"`.
256
+ */
257
+ export const consumeWebAuthnRegistrationChallenge = async (token) => {
258
+ const hash = sha256(token);
259
+ if (_store === "memory") {
260
+ const entry = _memoryChallenges.get(hash);
261
+ if (!entry || entry.expiresAt <= Date.now()) {
262
+ _memoryChallenges.delete(hash);
263
+ return null;
264
+ }
265
+ _memoryChallenges.delete(hash);
266
+ if (entry.purpose !== "webauthn-registration" || !entry.webauthnChallenge)
267
+ return null;
268
+ return { userId: entry.userId, challenge: entry.webauthnChallenge };
269
+ }
270
+ if (_store === "sqlite") {
271
+ ensureSqliteMfaTable();
272
+ const row = _sqliteDb.query("DELETE FROM mfa_challenges WHERE token = ? AND expiresAt > ? RETURNING userId, purpose, webauthnChallenge").get(hash, Date.now());
273
+ if (!row || row.purpose !== "webauthn-registration" || !row.webauthnChallenge)
274
+ return null;
275
+ return { userId: row.userId, challenge: row.webauthnChallenge };
276
+ }
277
+ if (_store === "mongo") {
278
+ const doc = await getMfaChallengeModel().findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } });
279
+ if (!doc || doc.purpose !== "webauthn-registration" || !doc.webauthnChallenge)
280
+ return null;
281
+ return { userId: doc.userId, challenge: doc.webauthnChallenge };
282
+ }
283
+ // redis
284
+ const key = `mfachallenge:${getAppName()}:${hash}`;
285
+ const raw = await getRedis().get(key);
286
+ if (!raw)
287
+ return null;
288
+ await getRedis().del(key);
289
+ const data = JSON.parse(raw);
290
+ if (data.purpose !== "webauthn-registration" || !data.webauthnChallenge)
291
+ return null;
292
+ return { userId: data.userId, challenge: data.webauthnChallenge };
293
+ };
@@ -1,4 +1,4 @@
1
- import { Google, Apple, generateState, generateCodeVerifier } from "arctic";
1
+ import { Google, Apple, MicrosoftEntraId, GitHub, generateState, generateCodeVerifier } from "arctic";
2
2
  export type OAuthProviderConfig = {
3
3
  google?: {
4
4
  clientId: string;
@@ -12,10 +12,23 @@ export type OAuthProviderConfig = {
12
12
  privateKey: string;
13
13
  redirectUri: string;
14
14
  };
15
+ microsoft?: {
16
+ tenantId: string;
17
+ clientId: string;
18
+ clientSecret: string;
19
+ redirectUri: string;
20
+ };
21
+ github?: {
22
+ clientId: string;
23
+ clientSecret: string;
24
+ redirectUri: string;
25
+ };
15
26
  };
16
27
  export declare const initOAuthProviders: (config: OAuthProviderConfig) => void;
17
28
  export declare const getGoogle: () => Google;
18
29
  export declare const getApple: () => Apple;
30
+ export declare const getMicrosoft: () => MicrosoftEntraId;
31
+ export declare const getGitHub: () => GitHub;
19
32
  export declare const getConfiguredOAuthProviders: () => string[];
20
33
  type OAuthStateStore = "redis" | "mongo" | "sqlite" | "memory";
21
34
  export declare const setOAuthStateStore: (store: OAuthStateStore) => void;
package/dist/lib/oauth.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Google, Apple, generateState, generateCodeVerifier } from "arctic";
1
+ import { Google, Apple, MicrosoftEntraId, GitHub, generateState, generateCodeVerifier } from "arctic";
2
2
  import { getRedis } from "./redis";
3
3
  import { appConnection, mongoose } from "./mongo";
4
4
  import { getAppName } from "./appConfig";
@@ -14,6 +14,14 @@ export const initOAuthProviders = (config) => {
14
14
  const { clientId, teamId, keyId, privateKey, redirectUri } = config.apple;
15
15
  _providers.apple = new Apple(clientId, teamId, keyId, new TextEncoder().encode(privateKey), redirectUri);
16
16
  }
17
+ if (config.microsoft) {
18
+ const { tenantId, clientId, clientSecret, redirectUri } = config.microsoft;
19
+ _providers.microsoft = new MicrosoftEntraId(tenantId, clientId, clientSecret, redirectUri);
20
+ }
21
+ if (config.github) {
22
+ const { clientId, clientSecret, redirectUri } = config.github;
23
+ _providers.github = new GitHub(clientId, clientSecret, redirectUri);
24
+ }
17
25
  };
18
26
  export const getGoogle = () => {
19
27
  if (!_providers.google)
@@ -25,6 +33,16 @@ export const getApple = () => {
25
33
  throw new Error("Apple OAuth not configured");
26
34
  return _providers.apple;
27
35
  };
36
+ export const getMicrosoft = () => {
37
+ if (!_providers.microsoft)
38
+ throw new Error("Microsoft Entra ID OAuth not configured");
39
+ return _providers.microsoft;
40
+ };
41
+ export const getGitHub = () => {
42
+ if (!_providers.github)
43
+ throw new Error("GitHub OAuth not configured");
44
+ return _providers.github;
45
+ };
28
46
  export const getConfiguredOAuthProviders = () => Object.entries(_providers)
29
47
  .filter(([, v]) => v != null)
30
48
  .map(([k]) => k);