@lastshotlabs/bunshot 0.0.16 → 0.0.18
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 +322 -16
- package/dist/adapters/memoryAuth.d.ts +3 -0
- package/dist/adapters/memoryAuth.js +48 -2
- package/dist/adapters/mongoAuth.js +39 -1
- package/dist/adapters/sqliteAuth.d.ts +3 -0
- package/dist/adapters/sqliteAuth.js +53 -0
- package/dist/app.d.ts +45 -2
- package/dist/app.js +79 -4
- package/dist/index.d.ts +14 -7
- package/dist/index.js +8 -4
- package/dist/lib/appConfig.d.ts +35 -0
- package/dist/lib/appConfig.js +10 -0
- package/dist/lib/authAdapter.d.ts +24 -0
- package/dist/lib/authRateLimit.d.ts +2 -0
- package/dist/lib/authRateLimit.js +4 -0
- package/dist/lib/clientIp.d.ts +14 -0
- package/dist/lib/clientIp.js +52 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/crypto.d.ts +11 -0
- package/dist/lib/crypto.js +22 -0
- package/dist/lib/emailVerification.d.ts +4 -0
- package/dist/lib/emailVerification.js +20 -12
- package/dist/lib/jwt.js +17 -4
- package/dist/lib/mfaChallenge.d.ts +23 -1
- package/dist/lib/mfaChallenge.js +151 -42
- package/dist/lib/oauth.d.ts +14 -1
- package/dist/lib/oauth.js +19 -1
- package/dist/lib/oauthCode.d.ts +15 -0
- package/dist/lib/oauthCode.js +90 -0
- package/dist/lib/resetPassword.js +12 -16
- package/dist/lib/session.js +6 -4
- package/dist/lib/ws.js +5 -1
- package/dist/middleware/bearerAuth.js +4 -3
- package/dist/middleware/botProtection.js +2 -2
- package/dist/middleware/cacheResponse.d.ts +1 -0
- package/dist/middleware/cacheResponse.js +14 -2
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.js +22 -8
- package/dist/middleware/csrf.d.ts +18 -0
- package/dist/middleware/csrf.js +115 -0
- package/dist/middleware/rateLimit.js +2 -3
- package/dist/models/AuthUser.d.ts +9 -0
- package/dist/models/AuthUser.js +9 -0
- package/dist/routes/auth.js +21 -9
- package/dist/routes/mfa.d.ts +5 -1
- package/dist/routes/mfa.js +221 -14
- package/dist/routes/oauth.js +274 -10
- package/dist/schemas/auth.d.ts +2 -0
- package/dist/schemas/auth.js +22 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +10 -3
- package/dist/services/auth.d.ts +1 -0
- package/dist/services/auth.js +21 -5
- package/dist/services/mfa.d.ts +47 -0
- package/dist/services/mfa.js +276 -9
- package/dist/ws/index.js +3 -2
- package/docs/sections/auth-flow/full.md +180 -2
- package/docs/sections/configuration/full.md +20 -0
- package/docs/sections/configuration/overview.md +1 -1
- package/docs/sections/configuration-example/full.md +19 -1
- package/docs/sections/exports/full.md +11 -2
- package/docs/sections/multi-tenancy/full.md +5 -1
- package/docs/sections/oauth/full.md +80 -10
- package/docs/sections/oauth/overview.md +2 -2
- package/docs/sections/peer-dependencies/full.md +6 -2
- package/docs/sections/response-caching/full.md +3 -1
- package/docs/sections/websocket/full.md +4 -3
- package/docs/sections/websocket/overview.md +1 -1
- 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
|
+
};
|
package/dist/lib/constants.d.ts
CHANGED
|
@@ -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";
|
package/dist/lib/constants.js
CHANGED
|
@@ -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(
|
|
31
|
+
memoryCreateVerificationToken(hash, userId, email, ttl);
|
|
28
32
|
return token;
|
|
29
33
|
}
|
|
30
34
|
if (_store === "sqlite") {
|
|
31
|
-
sqliteCreateVerificationToken(
|
|
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()}:${
|
|
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(
|
|
54
|
+
return memoryGetVerificationToken(hash);
|
|
49
55
|
if (_store === "sqlite")
|
|
50
|
-
return sqliteGetVerificationToken(
|
|
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()}:${
|
|
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(
|
|
74
|
+
memoryDeleteVerificationToken(hash);
|
|
67
75
|
return;
|
|
68
76
|
}
|
|
69
77
|
if (_store === "sqlite") {
|
|
70
|
-
sqliteDeleteVerificationToken(
|
|
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()}:${
|
|
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
|
-
|
|
3
|
-
|
|
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(
|
|
20
|
+
.sign(getSecret());
|
|
8
21
|
export const verifyToken = async (token) => {
|
|
9
|
-
const { payload } = await jwtVerify(token,
|
|
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,
|
|
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 {};
|
package/dist/lib/mfaChallenge.js
CHANGED
|
@@ -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
|
|
35
|
-
userId
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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, ?)", [
|
|
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()}:${
|
|
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(
|
|
113
|
+
const entry = _memoryChallenges.get(hash);
|
|
92
114
|
if (!entry || entry.expiresAt <= Date.now()) {
|
|
93
|
-
_memoryChallenges.delete(
|
|
115
|
+
_memoryChallenges.delete(hash);
|
|
94
116
|
return null;
|
|
95
117
|
}
|
|
96
|
-
_memoryChallenges.delete(
|
|
97
|
-
|
|
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(
|
|
102
|
-
|
|
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
|
-
|
|
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()}:${
|
|
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
|
-
|
|
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(
|
|
156
|
+
const entry = _memoryChallenges.get(hash);
|
|
126
157
|
if (!entry || entry.expiresAt <= Date.now()) {
|
|
127
|
-
_memoryChallenges.delete(
|
|
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(
|
|
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,
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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()}:${
|
|
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
|
+
};
|
package/dist/lib/oauth.d.ts
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
|
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);
|