@lastshotlabs/bunshot 0.0.13 → 0.0.16
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 +2510 -1747
- package/dist/adapters/memoryAuth.d.ts +4 -0
- package/dist/adapters/memoryAuth.js +131 -2
- package/dist/adapters/mongoAuth.js +56 -0
- package/dist/adapters/sqliteAuth.d.ts +6 -0
- package/dist/adapters/sqliteAuth.js +137 -2
- package/dist/app.d.ts +77 -2
- package/dist/app.js +29 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +14 -5
- package/dist/index.js +9 -3
- package/dist/lib/appConfig.d.ts +46 -0
- package/dist/lib/appConfig.js +20 -0
- package/dist/lib/authAdapter.d.ts +30 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/context.d.ts +2 -0
- package/dist/lib/createDtoMapper.d.ts +33 -0
- package/dist/lib/createDtoMapper.js +69 -0
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +2 -2
- package/dist/lib/mfaChallenge.d.ts +20 -0
- package/dist/lib/mfaChallenge.js +184 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- package/dist/lib/roles.d.ts +4 -0
- package/dist/lib/roles.js +27 -0
- package/dist/lib/session.d.ts +12 -0
- package/dist/lib/session.js +163 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/cacheResponse.js +4 -1
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +5 -2
- package/dist/middleware/requireRole.d.ts +14 -3
- package/dist/middleware/requireRole.js +46 -6
- package/dist/middleware/tenant.d.ts +5 -0
- package/dist/middleware/tenant.js +116 -0
- package/dist/models/AuthUser.d.ts +8 -0
- package/dist/models/AuthUser.js +8 -0
- package/dist/models/TenantRole.d.ts +15 -0
- package/dist/models/TenantRole.js +23 -0
- package/dist/routes/auth.d.ts +5 -3
- package/dist/routes/auth.js +153 -22
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +1 -0
- package/dist/routes/mfa.js +409 -0
- package/dist/routes/oauth.js +107 -16
- package/dist/server.js +9 -0
- package/dist/services/auth.d.ts +17 -5
- package/dist/services/auth.js +95 -17
- package/dist/services/mfa.d.ts +37 -0
- package/dist/services/mfa.js +276 -0
- package/docs/sections/adding-middleware/full.md +35 -0
- package/docs/sections/adding-models/full.md +125 -0
- package/docs/sections/adding-models/overview.md +13 -0
- package/docs/sections/adding-routes/full.md +182 -0
- package/docs/sections/adding-routes/overview.md +23 -0
- package/docs/sections/auth-flow/full.md +456 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +135 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +99 -0
- package/docs/sections/configuration-example/overview.md +30 -0
- package/docs/sections/documentation/full.md +171 -0
- package/docs/sections/environment-variables/full.md +55 -0
- package/docs/sections/exports/full.md +83 -0
- package/docs/sections/extending-context/full.md +59 -0
- package/docs/sections/header.md +3 -0
- package/docs/sections/installation/full.md +6 -0
- package/docs/sections/jobs/full.md +140 -0
- package/docs/sections/jobs/overview.md +15 -0
- package/docs/sections/mongodb-connections/full.md +45 -0
- package/docs/sections/mongodb-connections/overview.md +7 -0
- package/docs/sections/multi-tenancy/full.md +62 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +119 -0
- package/docs/sections/oauth/overview.md +16 -0
- package/docs/sections/package-development/full.md +7 -0
- package/docs/sections/peer-dependencies/full.md +43 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +115 -0
- package/docs/sections/response-caching/overview.md +13 -0
- package/docs/sections/roles/full.md +136 -0
- package/docs/sections/roles/overview.md +12 -0
- package/docs/sections/running-without-redis/full.md +16 -0
- package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
- package/docs/sections/stack/full.md +10 -0
- package/docs/sections/websocket/full.md +100 -0
- package/docs/sections/websocket/overview.md +5 -0
- package/docs/sections/websocket-rooms/full.md +97 -0
- package/docs/sections/websocket-rooms/overview.md +5 -0
- package/package.json +19 -10
package/dist/services/auth.js
CHANGED
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
2
2
|
import { HttpError } from "../lib/HttpError";
|
|
3
3
|
import { signToken, verifyToken } from "../lib/jwt";
|
|
4
|
-
import { createSession, deleteSession, getActiveSessionCount, evictOldestSession } from "../lib/session";
|
|
5
|
-
import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions } from "../lib/appConfig";
|
|
4
|
+
import { createSession, deleteSession, getActiveSessionCount, evictOldestSession, deleteUserSessions, setRefreshToken, getSessionByRefreshToken, rotateRefreshToken } from "../lib/session";
|
|
5
|
+
import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getMfaConfig, getMfaEmailOtpConfig } from "../lib/appConfig";
|
|
6
6
|
import { createVerificationToken } from "../lib/emailVerification";
|
|
7
|
+
import { createMfaChallenge } from "../lib/mfaChallenge";
|
|
8
|
+
import { generateEmailOtpCode } from "./mfa";
|
|
9
|
+
async function createSessionWithRefreshToken(userId, sessionId, metadata) {
|
|
10
|
+
const rtConfig = getRefreshTokenConfig();
|
|
11
|
+
const expirySeconds = rtConfig ? getAccessTokenExpiry() : undefined;
|
|
12
|
+
const token = await signToken(userId, sessionId, expirySeconds);
|
|
13
|
+
while (await getActiveSessionCount(userId) >= getMaxSessions()) {
|
|
14
|
+
await evictOldestSession(userId);
|
|
15
|
+
}
|
|
16
|
+
await createSession(userId, token, sessionId, metadata);
|
|
17
|
+
let refreshToken;
|
|
18
|
+
if (rtConfig) {
|
|
19
|
+
refreshToken = crypto.randomUUID();
|
|
20
|
+
await setRefreshToken(sessionId, refreshToken);
|
|
21
|
+
}
|
|
22
|
+
return { token, refreshToken, sessionId };
|
|
23
|
+
}
|
|
24
|
+
/** Create a session for a user (used internally and by MFA verify). */
|
|
25
|
+
export const createSessionForUser = async (userId, metadata) => {
|
|
26
|
+
const sessionId = crypto.randomUUID();
|
|
27
|
+
return createSessionWithRefreshToken(userId, sessionId, metadata);
|
|
28
|
+
};
|
|
7
29
|
export const register = async (identifier, password, metadata) => {
|
|
8
30
|
const hashed = await Bun.password.hash(password);
|
|
9
31
|
const adapter = getAuthAdapter();
|
|
@@ -12,11 +34,7 @@ export const register = async (identifier, password, metadata) => {
|
|
|
12
34
|
if (role)
|
|
13
35
|
await adapter.setRoles(user.id, [role]);
|
|
14
36
|
const sessionId = crypto.randomUUID();
|
|
15
|
-
const token = await
|
|
16
|
-
while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
|
|
17
|
-
await evictOldestSession(user.id);
|
|
18
|
-
}
|
|
19
|
-
await createSession(user.id, token, sessionId, metadata);
|
|
37
|
+
const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
|
|
20
38
|
const evConfig = getEmailVerificationConfig();
|
|
21
39
|
if (evConfig && getPrimaryField() === "email") {
|
|
22
40
|
try {
|
|
@@ -27,7 +45,7 @@ export const register = async (identifier, password, metadata) => {
|
|
|
27
45
|
console.error("[email-verification] Failed to send verification email:", e);
|
|
28
46
|
}
|
|
29
47
|
}
|
|
30
|
-
return { token, userId: user.id, email: identifier };
|
|
48
|
+
return { token, userId: user.id, email: identifier, refreshToken };
|
|
31
49
|
};
|
|
32
50
|
export const login = async (identifier, password, metadata) => {
|
|
33
51
|
const adapter = getAuthAdapter();
|
|
@@ -36,11 +54,7 @@ export const login = async (identifier, password, metadata) => {
|
|
|
36
54
|
if (!user || !(await Bun.password.verify(password, user.passwordHash))) {
|
|
37
55
|
throw new HttpError(401, "Invalid credentials");
|
|
38
56
|
}
|
|
39
|
-
|
|
40
|
-
const token = await signToken(user.id, sessionId);
|
|
41
|
-
while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
|
|
42
|
-
await evictOldestSession(user.id);
|
|
43
|
-
}
|
|
57
|
+
// Check email verification before MFA to avoid leaking MFA status to unverified users
|
|
44
58
|
const fullUser = adapter.getUser ? await adapter.getUser(user.id) : null;
|
|
45
59
|
const googleLinked = fullUser?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
|
|
46
60
|
const evConfig = getEmailVerificationConfig();
|
|
@@ -49,11 +63,75 @@ export const login = async (identifier, password, metadata) => {
|
|
|
49
63
|
if (evConfig.required && !verified) {
|
|
50
64
|
throw new HttpError(403, "Email not verified");
|
|
51
65
|
}
|
|
52
|
-
await createSession(user.id, token, sessionId, metadata);
|
|
53
|
-
return { token, userId: user.id, email: fullUser?.email, emailVerified: verified, googleLinked };
|
|
54
66
|
}
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
// Check MFA — if enabled, return challenge token instead of session
|
|
68
|
+
if (getMfaConfig() && adapter.isMfaEnabled && await adapter.isMfaEnabled(user.id)) {
|
|
69
|
+
const methods = adapter.getMfaMethods
|
|
70
|
+
? await adapter.getMfaMethods(user.id)
|
|
71
|
+
: ["totp"];
|
|
72
|
+
// Auto-send email OTP if enabled
|
|
73
|
+
let emailOtpHash;
|
|
74
|
+
const emailOtpConfig = getMfaEmailOtpConfig();
|
|
75
|
+
if (methods.includes("emailOtp") && emailOtpConfig) {
|
|
76
|
+
const { code, hash } = generateEmailOtpCode();
|
|
77
|
+
emailOtpHash = hash;
|
|
78
|
+
const email = fullUser?.email;
|
|
79
|
+
if (email)
|
|
80
|
+
await emailOtpConfig.onSend(email, code);
|
|
81
|
+
}
|
|
82
|
+
const mfaToken = await createMfaChallenge(user.id, emailOtpHash);
|
|
83
|
+
return { token: "", userId: user.id, mfaRequired: true, mfaToken, mfaMethods: methods };
|
|
84
|
+
}
|
|
85
|
+
const sessionId = crypto.randomUUID();
|
|
86
|
+
const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
|
|
87
|
+
if (evConfig && getPrimaryField() === "email" && adapter.getEmailVerified) {
|
|
88
|
+
const verified = await adapter.getEmailVerified(user.id);
|
|
89
|
+
return { token, userId: user.id, email: fullUser?.email, emailVerified: verified, googleLinked, refreshToken };
|
|
90
|
+
}
|
|
91
|
+
return { token, userId: user.id, email: fullUser?.email, googleLinked, refreshToken };
|
|
92
|
+
};
|
|
93
|
+
export const refresh = async (refreshTokenValue) => {
|
|
94
|
+
const result = await getSessionByRefreshToken(refreshTokenValue);
|
|
95
|
+
if (!result) {
|
|
96
|
+
throw new HttpError(401, "Invalid or expired refresh token");
|
|
97
|
+
}
|
|
98
|
+
const { sessionId, userId, newRefreshToken } = result;
|
|
99
|
+
// If the returned newRefreshToken differs from what was sent, we're in a grace window replay.
|
|
100
|
+
// Return the current tokens without rotating again.
|
|
101
|
+
if (newRefreshToken !== refreshTokenValue) {
|
|
102
|
+
const accessToken = await signToken(userId, sessionId, getAccessTokenExpiry());
|
|
103
|
+
return { token: accessToken, refreshToken: newRefreshToken, userId };
|
|
104
|
+
}
|
|
105
|
+
// Normal rotation: generate new refresh + access tokens
|
|
106
|
+
const newRT = crypto.randomUUID();
|
|
107
|
+
const newAccessToken = await signToken(userId, sessionId, getAccessTokenExpiry());
|
|
108
|
+
await rotateRefreshToken(sessionId, newRT, newAccessToken);
|
|
109
|
+
return { token: newAccessToken, refreshToken: newRT, userId };
|
|
110
|
+
};
|
|
111
|
+
export const deleteAccount = async (userId, password) => {
|
|
112
|
+
const adapter = getAuthAdapter();
|
|
113
|
+
if (!adapter.deleteUser) {
|
|
114
|
+
throw new HttpError(501, "Auth adapter does not support deleteUser");
|
|
115
|
+
}
|
|
116
|
+
// Verify password for credential accounts
|
|
117
|
+
if (password) {
|
|
118
|
+
const user = adapter.getUser ? await adapter.getUser(userId) : null;
|
|
119
|
+
const email = user?.email;
|
|
120
|
+
if (email) {
|
|
121
|
+
const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
|
|
122
|
+
const found = await findFn(email);
|
|
123
|
+
if (found && !(await Bun.password.verify(password, found.passwordHash))) {
|
|
124
|
+
throw new HttpError(401, "Invalid password");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (adapter.hasPassword && await adapter.hasPassword(userId)) {
|
|
129
|
+
throw new HttpError(400, "Password is required to delete a credential account");
|
|
130
|
+
}
|
|
131
|
+
// Revoke all sessions
|
|
132
|
+
await deleteUserSessions(userId);
|
|
133
|
+
// Delete the user
|
|
134
|
+
await adapter.deleteUser(userId);
|
|
57
135
|
};
|
|
58
136
|
export const logout = async (token) => {
|
|
59
137
|
if (token) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface MfaSetupResult {
|
|
2
|
+
secret: string;
|
|
3
|
+
uri: string;
|
|
4
|
+
}
|
|
5
|
+
export declare const setupMfa: (userId: string) => Promise<MfaSetupResult>;
|
|
6
|
+
export declare const verifySetup: (userId: string, code: string) => Promise<string[]>;
|
|
7
|
+
export declare const verifyTotp: (userId: string, code: string) => Promise<boolean>;
|
|
8
|
+
export declare const verifyRecoveryCode: (userId: string, code: string) => Promise<boolean>;
|
|
9
|
+
export declare const disableMfa: (userId: string, code: string) => Promise<void>;
|
|
10
|
+
export declare const regenerateRecoveryCodes: (userId: string, code: string) => Promise<string[]>;
|
|
11
|
+
/** Generate a cryptographically random numeric OTP code. Returns { code, hash }. */
|
|
12
|
+
export declare const generateEmailOtpCode: (length?: number) => {
|
|
13
|
+
code: string;
|
|
14
|
+
hash: string;
|
|
15
|
+
};
|
|
16
|
+
/** Verify an email OTP code against a stored hash. */
|
|
17
|
+
export declare const verifyEmailOtp: (emailOtpHash: string, code: string) => boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Initiate email OTP setup: sends a verification code to the user's email.
|
|
20
|
+
* Returns a setup challenge token that must be confirmed via confirmEmailOtp.
|
|
21
|
+
*/
|
|
22
|
+
export declare const initiateEmailOtp: (userId: string) => Promise<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Confirm email OTP setup: verifies the code sent during initiateEmailOtp.
|
|
25
|
+
* Enables email OTP as an MFA method. Returns recovery codes if MFA was not previously active.
|
|
26
|
+
*/
|
|
27
|
+
export declare const confirmEmailOtp: (userId: string, setupToken: string, code: string) => Promise<string[] | null>;
|
|
28
|
+
/**
|
|
29
|
+
* Disable email OTP for a user.
|
|
30
|
+
* If TOTP is also enabled, requires a TOTP code. Otherwise requires password.
|
|
31
|
+
*/
|
|
32
|
+
export declare const disableEmailOtp: (userId: string, params: {
|
|
33
|
+
code?: string;
|
|
34
|
+
password?: string;
|
|
35
|
+
}) => Promise<void>;
|
|
36
|
+
/** Get the MFA methods enabled for a user. */
|
|
37
|
+
export declare const getMfaMethods: (userId: string) => Promise<string[]>;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { getAuthAdapter } from "../lib/authAdapter";
|
|
2
|
+
import { HttpError } from "../lib/HttpError";
|
|
3
|
+
import { getMfaIssuer, getMfaAlgorithm, getMfaDigits, getMfaPeriod, getMfaRecoveryCodeCount, getMfaEmailOtpConfig, getMfaEmailOtpCodeLength } from "../lib/appConfig";
|
|
4
|
+
import { createMfaChallenge } from "../lib/mfaChallenge";
|
|
5
|
+
// Lazy-load otpauth to keep it as an optional peer dependency
|
|
6
|
+
let _otpauth = null;
|
|
7
|
+
async function getOtpAuth() {
|
|
8
|
+
if (!_otpauth)
|
|
9
|
+
_otpauth = await import("otpauth");
|
|
10
|
+
return _otpauth;
|
|
11
|
+
}
|
|
12
|
+
function sha256(input) {
|
|
13
|
+
const hash = new Bun.CryptoHasher("sha256");
|
|
14
|
+
hash.update(input);
|
|
15
|
+
return hash.digest("hex");
|
|
16
|
+
}
|
|
17
|
+
function generateRandomCode(length) {
|
|
18
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous chars: I/1/O/0
|
|
19
|
+
let code = "";
|
|
20
|
+
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
21
|
+
for (let i = 0; i < length; i++) {
|
|
22
|
+
code += chars[bytes[i] % chars.length];
|
|
23
|
+
}
|
|
24
|
+
return code;
|
|
25
|
+
}
|
|
26
|
+
function generateRecoveryCodes() {
|
|
27
|
+
const count = getMfaRecoveryCodeCount();
|
|
28
|
+
const plainCodes = [];
|
|
29
|
+
const hashedCodes = [];
|
|
30
|
+
for (let i = 0; i < count; i++) {
|
|
31
|
+
const plain = generateRandomCode(8);
|
|
32
|
+
plainCodes.push(plain);
|
|
33
|
+
hashedCodes.push(sha256(plain));
|
|
34
|
+
}
|
|
35
|
+
return { plainCodes, hashedCodes };
|
|
36
|
+
}
|
|
37
|
+
export const setupMfa = async (userId) => {
|
|
38
|
+
const adapter = getAuthAdapter();
|
|
39
|
+
if (!adapter.setMfaSecret)
|
|
40
|
+
throw new HttpError(501, "Auth adapter does not support MFA");
|
|
41
|
+
const otpauth = await getOtpAuth();
|
|
42
|
+
const secret = new otpauth.Secret();
|
|
43
|
+
const totp = new otpauth.TOTP({
|
|
44
|
+
issuer: getMfaIssuer(),
|
|
45
|
+
label: userId,
|
|
46
|
+
algorithm: getMfaAlgorithm(),
|
|
47
|
+
digits: getMfaDigits(),
|
|
48
|
+
period: getMfaPeriod(),
|
|
49
|
+
secret,
|
|
50
|
+
});
|
|
51
|
+
// Store the secret but don't enable MFA yet — user must confirm with a code
|
|
52
|
+
await adapter.setMfaSecret(userId, secret.base32);
|
|
53
|
+
return {
|
|
54
|
+
secret: secret.base32,
|
|
55
|
+
uri: totp.toString(),
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
export const verifySetup = async (userId, code) => {
|
|
59
|
+
const adapter = getAuthAdapter();
|
|
60
|
+
if (!adapter.getMfaSecret || !adapter.setMfaEnabled || !adapter.setRecoveryCodes) {
|
|
61
|
+
throw new HttpError(501, "Auth adapter does not support MFA");
|
|
62
|
+
}
|
|
63
|
+
const secretStr = await adapter.getMfaSecret(userId);
|
|
64
|
+
if (!secretStr)
|
|
65
|
+
throw new HttpError(400, "MFA setup not initiated. Call POST /auth/mfa/setup first.");
|
|
66
|
+
const otpauth = await getOtpAuth();
|
|
67
|
+
const totp = new otpauth.TOTP({
|
|
68
|
+
issuer: getMfaIssuer(),
|
|
69
|
+
algorithm: getMfaAlgorithm(),
|
|
70
|
+
digits: getMfaDigits(),
|
|
71
|
+
period: getMfaPeriod(),
|
|
72
|
+
secret: otpauth.Secret.fromBase32(secretStr),
|
|
73
|
+
});
|
|
74
|
+
const delta = totp.validate({ token: code, window: 1 });
|
|
75
|
+
if (delta === null)
|
|
76
|
+
throw new HttpError(401, "Invalid TOTP code");
|
|
77
|
+
// Generate recovery codes (regenerates if enabling a second method)
|
|
78
|
+
const { plainCodes, hashedCodes } = generateRecoveryCodes();
|
|
79
|
+
await adapter.setRecoveryCodes(userId, hashedCodes);
|
|
80
|
+
await adapter.setMfaEnabled(userId, true);
|
|
81
|
+
// Add "totp" to mfaMethods
|
|
82
|
+
if (adapter.getMfaMethods && adapter.setMfaMethods) {
|
|
83
|
+
const methods = await adapter.getMfaMethods(userId);
|
|
84
|
+
if (!methods.includes("totp")) {
|
|
85
|
+
await adapter.setMfaMethods(userId, [...methods, "totp"]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return plainCodes;
|
|
89
|
+
};
|
|
90
|
+
export const verifyTotp = async (userId, code) => {
|
|
91
|
+
const adapter = getAuthAdapter();
|
|
92
|
+
if (!adapter.getMfaSecret)
|
|
93
|
+
throw new HttpError(501, "Auth adapter does not support MFA");
|
|
94
|
+
const secretStr = await adapter.getMfaSecret(userId);
|
|
95
|
+
if (!secretStr)
|
|
96
|
+
return false;
|
|
97
|
+
const otpauth = await getOtpAuth();
|
|
98
|
+
const totp = new otpauth.TOTP({
|
|
99
|
+
issuer: getMfaIssuer(),
|
|
100
|
+
algorithm: getMfaAlgorithm(),
|
|
101
|
+
digits: getMfaDigits(),
|
|
102
|
+
period: getMfaPeriod(),
|
|
103
|
+
secret: otpauth.Secret.fromBase32(secretStr),
|
|
104
|
+
});
|
|
105
|
+
return totp.validate({ token: code, window: 1 }) !== null;
|
|
106
|
+
};
|
|
107
|
+
export const verifyRecoveryCode = async (userId, code) => {
|
|
108
|
+
const adapter = getAuthAdapter();
|
|
109
|
+
if (!adapter.getRecoveryCodes || !adapter.removeRecoveryCode)
|
|
110
|
+
return false;
|
|
111
|
+
const hashedCodes = await adapter.getRecoveryCodes(userId);
|
|
112
|
+
const hashedInput = sha256(code.toUpperCase());
|
|
113
|
+
const match = hashedCodes.find((h) => h === hashedInput);
|
|
114
|
+
if (!match)
|
|
115
|
+
return false;
|
|
116
|
+
await adapter.removeRecoveryCode(userId, match);
|
|
117
|
+
return true;
|
|
118
|
+
};
|
|
119
|
+
export const disableMfa = async (userId, code) => {
|
|
120
|
+
const adapter = getAuthAdapter();
|
|
121
|
+
if (!adapter.setMfaEnabled || !adapter.setMfaSecret || !adapter.setRecoveryCodes) {
|
|
122
|
+
throw new HttpError(501, "Auth adapter does not support MFA");
|
|
123
|
+
}
|
|
124
|
+
const valid = await verifyTotp(userId, code);
|
|
125
|
+
if (!valid)
|
|
126
|
+
throw new HttpError(401, "Invalid TOTP code");
|
|
127
|
+
await adapter.setMfaEnabled(userId, false);
|
|
128
|
+
await adapter.setMfaSecret(userId, null);
|
|
129
|
+
await adapter.setRecoveryCodes(userId, []);
|
|
130
|
+
// Clear all mfaMethods
|
|
131
|
+
if (adapter.setMfaMethods) {
|
|
132
|
+
await adapter.setMfaMethods(userId, []);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
export const regenerateRecoveryCodes = async (userId, code) => {
|
|
136
|
+
const adapter = getAuthAdapter();
|
|
137
|
+
if (!adapter.setRecoveryCodes)
|
|
138
|
+
throw new HttpError(501, "Auth adapter does not support MFA");
|
|
139
|
+
const valid = await verifyTotp(userId, code);
|
|
140
|
+
if (!valid)
|
|
141
|
+
throw new HttpError(401, "Invalid TOTP code");
|
|
142
|
+
const { plainCodes, hashedCodes } = generateRecoveryCodes();
|
|
143
|
+
await adapter.setRecoveryCodes(userId, hashedCodes);
|
|
144
|
+
return plainCodes;
|
|
145
|
+
};
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Email OTP
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
/** Generate a cryptographically random numeric OTP code. Returns { code, hash }. */
|
|
150
|
+
export const generateEmailOtpCode = (length) => {
|
|
151
|
+
const len = length ?? getMfaEmailOtpCodeLength();
|
|
152
|
+
const bytes = crypto.getRandomValues(new Uint8Array(len));
|
|
153
|
+
let code = "";
|
|
154
|
+
for (let i = 0; i < len; i++) {
|
|
155
|
+
code += (bytes[i] % 10).toString();
|
|
156
|
+
}
|
|
157
|
+
return { code, hash: sha256(code) };
|
|
158
|
+
};
|
|
159
|
+
/** Verify an email OTP code against a stored hash. */
|
|
160
|
+
export const verifyEmailOtp = (emailOtpHash, code) => {
|
|
161
|
+
return sha256(code) === emailOtpHash;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Initiate email OTP setup: sends a verification code to the user's email.
|
|
165
|
+
* Returns a setup challenge token that must be confirmed via confirmEmailOtp.
|
|
166
|
+
*/
|
|
167
|
+
export const initiateEmailOtp = async (userId) => {
|
|
168
|
+
const adapter = getAuthAdapter();
|
|
169
|
+
const emailOtpConfig = getMfaEmailOtpConfig();
|
|
170
|
+
if (!emailOtpConfig)
|
|
171
|
+
throw new HttpError(501, "Email OTP is not configured");
|
|
172
|
+
const user = adapter.getUser ? await adapter.getUser(userId) : null;
|
|
173
|
+
if (!user?.email)
|
|
174
|
+
throw new HttpError(400, "No email address on account");
|
|
175
|
+
const { code, hash } = generateEmailOtpCode();
|
|
176
|
+
await emailOtpConfig.onSend(user.email, code);
|
|
177
|
+
// Store the hash in a challenge token for verification
|
|
178
|
+
const setupToken = await createMfaChallenge(userId, hash);
|
|
179
|
+
return setupToken;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Confirm email OTP setup: verifies the code sent during initiateEmailOtp.
|
|
183
|
+
* Enables email OTP as an MFA method. Returns recovery codes if MFA was not previously active.
|
|
184
|
+
*/
|
|
185
|
+
export const confirmEmailOtp = async (userId, setupToken, code) => {
|
|
186
|
+
const adapter = getAuthAdapter();
|
|
187
|
+
if (!adapter.setMfaEnabled || !adapter.setRecoveryCodes) {
|
|
188
|
+
throw new HttpError(501, "Auth adapter does not support MFA");
|
|
189
|
+
}
|
|
190
|
+
// Import consumeMfaChallenge here to avoid circular dependency issues at module level
|
|
191
|
+
const { consumeMfaChallenge } = await import("../lib/mfaChallenge");
|
|
192
|
+
const challenge = await consumeMfaChallenge(setupToken);
|
|
193
|
+
if (!challenge)
|
|
194
|
+
throw new HttpError(401, "Invalid or expired setup token");
|
|
195
|
+
if (challenge.userId !== userId)
|
|
196
|
+
throw new HttpError(401, "Token does not match user");
|
|
197
|
+
if (!challenge.emailOtpHash)
|
|
198
|
+
throw new HttpError(400, "Invalid setup token — no OTP hash");
|
|
199
|
+
if (!verifyEmailOtp(challenge.emailOtpHash, code)) {
|
|
200
|
+
throw new HttpError(401, "Invalid verification code");
|
|
201
|
+
}
|
|
202
|
+
// Check if MFA was already active
|
|
203
|
+
const wasEnabled = adapter.isMfaEnabled ? await adapter.isMfaEnabled(userId) : false;
|
|
204
|
+
// Add "emailOtp" to mfaMethods
|
|
205
|
+
if (adapter.getMfaMethods && adapter.setMfaMethods) {
|
|
206
|
+
const methods = await adapter.getMfaMethods(userId);
|
|
207
|
+
if (!methods.includes("emailOtp")) {
|
|
208
|
+
await adapter.setMfaMethods(userId, [...methods, "emailOtp"]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
await adapter.setMfaEnabled(userId, true);
|
|
212
|
+
// Generate recovery codes if MFA was not previously active
|
|
213
|
+
if (!wasEnabled) {
|
|
214
|
+
const { plainCodes, hashedCodes } = generateRecoveryCodes();
|
|
215
|
+
await adapter.setRecoveryCodes(userId, hashedCodes);
|
|
216
|
+
return plainCodes;
|
|
217
|
+
}
|
|
218
|
+
// Regenerate recovery codes when adding a second method
|
|
219
|
+
const { plainCodes, hashedCodes } = generateRecoveryCodes();
|
|
220
|
+
await adapter.setRecoveryCodes(userId, hashedCodes);
|
|
221
|
+
return plainCodes;
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* Disable email OTP for a user.
|
|
225
|
+
* If TOTP is also enabled, requires a TOTP code. Otherwise requires password.
|
|
226
|
+
*/
|
|
227
|
+
export const disableEmailOtp = async (userId, params) => {
|
|
228
|
+
const adapter = getAuthAdapter();
|
|
229
|
+
if (!adapter.setMfaEnabled)
|
|
230
|
+
throw new HttpError(501, "Auth adapter does not support MFA");
|
|
231
|
+
// Get current methods
|
|
232
|
+
const methods = adapter.getMfaMethods ? await adapter.getMfaMethods(userId) : [];
|
|
233
|
+
const hasTotpEnabled = methods.includes("totp");
|
|
234
|
+
// Verify identity
|
|
235
|
+
if (hasTotpEnabled) {
|
|
236
|
+
if (!params.code)
|
|
237
|
+
throw new HttpError(400, "TOTP code required to disable email OTP");
|
|
238
|
+
const valid = await verifyTotp(userId, params.code);
|
|
239
|
+
if (!valid)
|
|
240
|
+
throw new HttpError(401, "Invalid TOTP code");
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
if (!params.password)
|
|
244
|
+
throw new HttpError(400, "Password required to disable email OTP");
|
|
245
|
+
// Verify password — look up the user's hash and compare
|
|
246
|
+
const user = adapter.findByIdentifier
|
|
247
|
+
? await adapter.findByIdentifier((await adapter.getUser?.(userId))?.email ?? "")
|
|
248
|
+
: await adapter.findByEmail((await adapter.getUser?.(userId))?.email ?? "");
|
|
249
|
+
if (!user)
|
|
250
|
+
throw new HttpError(404, "User not found");
|
|
251
|
+
const valid = await Bun.password.verify(params.password, user.passwordHash);
|
|
252
|
+
if (!valid)
|
|
253
|
+
throw new HttpError(401, "Invalid password");
|
|
254
|
+
}
|
|
255
|
+
// Remove "emailOtp" from methods
|
|
256
|
+
if (adapter.setMfaMethods) {
|
|
257
|
+
const updated = methods.filter((m) => m !== "emailOtp");
|
|
258
|
+
await adapter.setMfaMethods(userId, updated);
|
|
259
|
+
// If no methods remain, disable MFA entirely
|
|
260
|
+
if (updated.length === 0) {
|
|
261
|
+
await adapter.setMfaEnabled(userId, false);
|
|
262
|
+
if (adapter.setRecoveryCodes)
|
|
263
|
+
await adapter.setRecoveryCodes(userId, []);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
/** Get the MFA methods enabled for a user. */
|
|
268
|
+
export const getMfaMethods = async (userId) => {
|
|
269
|
+
const adapter = getAuthAdapter();
|
|
270
|
+
if (adapter.getMfaMethods)
|
|
271
|
+
return adapter.getMfaMethods(userId);
|
|
272
|
+
// Backward compat
|
|
273
|
+
if (adapter.isMfaEnabled && await adapter.isMfaEnabled(userId))
|
|
274
|
+
return ["totp"];
|
|
275
|
+
return [];
|
|
276
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
## Adding Middleware
|
|
2
|
+
|
|
3
|
+
### Global (runs on every request)
|
|
4
|
+
|
|
5
|
+
Pass via `middleware` config — injected after `identify`, before route matching:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
await createServer({
|
|
9
|
+
routesDir: import.meta.dir + "/routes",
|
|
10
|
+
app: { name: "My App", version: "1.0.0" },
|
|
11
|
+
middleware: [myMiddleware],
|
|
12
|
+
});
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Write it using core's exported types:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// src/middleware/tenant.ts
|
|
19
|
+
import type { MiddlewareHandler } from "hono";
|
|
20
|
+
import type { AppEnv } from "@lastshotlabs/bunshot";
|
|
21
|
+
|
|
22
|
+
export const tenantMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|
23
|
+
// c.get("userId") is available — identify has already run
|
|
24
|
+
await next();
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Per-route
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { userAuth, rateLimit } from "@lastshotlabs/bunshot";
|
|
32
|
+
|
|
33
|
+
router.use("/admin", userAuth);
|
|
34
|
+
router.use("/admin", rateLimit({ windowMs: 60_000, max: 10 }));
|
|
35
|
+
```
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
## Adding Models
|
|
2
|
+
|
|
3
|
+
Import `appConnection` and register models on it. This ensures your models use the correct connection whether you're on a single DB or a separate tenant DB.
|
|
4
|
+
|
|
5
|
+
`appConnection` is a lazy proxy — calling `.model()` at the top level works fine even before `connectMongo()` has been called. Mongoose buffers any queries until the connection is established.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// src/models/Product.ts
|
|
9
|
+
import { appConnection } from "@lastshotlabs/bunshot";
|
|
10
|
+
import { Schema } from "mongoose";
|
|
11
|
+
import type { HydratedDocument } from "mongoose";
|
|
12
|
+
|
|
13
|
+
interface IProduct {
|
|
14
|
+
name: string;
|
|
15
|
+
price: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ProductDocument = HydratedDocument<IProduct>;
|
|
19
|
+
|
|
20
|
+
const ProductSchema = new Schema<IProduct>({
|
|
21
|
+
name: { type: String, required: true },
|
|
22
|
+
price: { type: Number, required: true },
|
|
23
|
+
}, { timestamps: true });
|
|
24
|
+
|
|
25
|
+
export const Product = appConnection.model<IProduct>("Product", ProductSchema);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> **Note:** Import types (`HydratedDocument`, `Schema`, etc.) directly from `"mongoose"` — the `appConnection` and `mongoose` exports from bunshot are runtime proxies and cannot be used as TypeScript namespaces.
|
|
29
|
+
|
|
30
|
+
### Zod as Single Source of Truth
|
|
31
|
+
|
|
32
|
+
If you use Zod schemas for your OpenAPI spec (via `createRoute` or `modelSchemas`), you can derive your Mongoose schemas and DTO mappers from those same Zod definitions — so each entity is defined **once**.
|
|
33
|
+
|
|
34
|
+
#### `zodToMongoose` — Zod → Mongoose SchemaDefinition
|
|
35
|
+
|
|
36
|
+
Converts a Zod object schema into a Mongoose field definition. Business fields are auto-converted; DB-specific concerns (ObjectId refs, type overrides, subdocuments) are declared via config. The `id` field is automatically excluded since Mongoose provides `_id`.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { appConnection, zodToMongoose } from "@lastshotlabs/bunshot";
|
|
40
|
+
import { Schema, type HydratedDocument } from "mongoose";
|
|
41
|
+
import { ProductSchema } from "../schemas/product"; // your Zod schema
|
|
42
|
+
import type { ProductDto } from "../schemas/product";
|
|
43
|
+
|
|
44
|
+
// DB interface derives from Zod DTO type
|
|
45
|
+
interface IProduct extends Omit<ProductDto, "id" | "categoryId"> {
|
|
46
|
+
user: Types.ObjectId;
|
|
47
|
+
category: Types.ObjectId;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ProductMongoSchema = new Schema<IProduct>(
|
|
51
|
+
zodToMongoose(ProductSchema, {
|
|
52
|
+
dbFields: {
|
|
53
|
+
user: { type: Schema.Types.ObjectId, ref: "UserProfile", required: true },
|
|
54
|
+
},
|
|
55
|
+
refs: {
|
|
56
|
+
categoryId: { dbField: "category", ref: "Category" },
|
|
57
|
+
},
|
|
58
|
+
typeOverrides: {
|
|
59
|
+
createdAt: { type: Date, required: true },
|
|
60
|
+
},
|
|
61
|
+
}) as Record<string, unknown>,
|
|
62
|
+
{ timestamps: true }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
export type ProductDocument = HydratedDocument<IProduct>;
|
|
66
|
+
export const Product = appConnection.model<IProduct>("Product", ProductMongoSchema);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Config options:**
|
|
70
|
+
|
|
71
|
+
| Option | Description |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `dbFields` | Fields that exist only in the DB, not in the API schema (e.g., `user` ObjectId ref) |
|
|
74
|
+
| `refs` | API fields that map to ObjectId refs: `{ accountId: { dbField: "account", ref: "Account" } }` |
|
|
75
|
+
| `typeOverrides` | Override the auto-converted Mongoose type for a field (e.g., Zod `z.string()` for dates → Mongoose `Date`) |
|
|
76
|
+
| `subdocSchemas` | Subdocument array fields: `{ items: mongooseSubSchema }` |
|
|
77
|
+
|
|
78
|
+
**Auto-conversion mapping:**
|
|
79
|
+
|
|
80
|
+
| Zod type | Mongoose type |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `z.string()` | `String` |
|
|
83
|
+
| `z.number()` | `Number` |
|
|
84
|
+
| `z.boolean()` | `Boolean` |
|
|
85
|
+
| `z.date()` | `Date` |
|
|
86
|
+
| `z.enum([...])` | `String` with `enum` |
|
|
87
|
+
| `.nullable()` / `.optional()` | `required: false` |
|
|
88
|
+
|
|
89
|
+
#### `createDtoMapper` — Zod → toDto mapper
|
|
90
|
+
|
|
91
|
+
Creates a generic `toDto` function from a Zod schema. The schema defines which fields exist in the DTO; the config declares how to transform DB-specific types.
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { createDtoMapper } from "@lastshotlabs/bunshot";
|
|
95
|
+
import { ProductSchema, type ProductDto } from "../schemas/product";
|
|
96
|
+
|
|
97
|
+
const toDto = createDtoMapper<ProductDto>(ProductSchema, {
|
|
98
|
+
refs: { category: "categoryId" }, // ObjectId ref → string, with rename
|
|
99
|
+
dates: ["createdAt"], // Date → ISO string
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Use it
|
|
103
|
+
const product = await Product.findOne({ _id: id });
|
|
104
|
+
return product ? toDto(product) : null;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Auto-handled transforms:**
|
|
108
|
+
|
|
109
|
+
| Transform | Description |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `_id` → `id` | Always converted via `.toString()` |
|
|
112
|
+
| `refs` | ObjectId fields → string (`.toString()`), with DB→API field renaming |
|
|
113
|
+
| `dates` | `Date` objects → ISO strings (`.toISOString()`) |
|
|
114
|
+
| `subdocs` | Array fields mapped with a sub-mapper (for nested documents) |
|
|
115
|
+
| nullable/optional | `undefined` → `null` coercion (based on Zod schema) |
|
|
116
|
+
| everything else | Passthrough |
|
|
117
|
+
|
|
118
|
+
**Subdocument example:**
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const itemToDto = createDtoMapper<TemplateItemDto>(TemplateItemSchema);
|
|
122
|
+
const toDto = createDtoMapper<TemplateDto>(TemplateSchema, {
|
|
123
|
+
subdocs: { items: itemToDto },
|
|
124
|
+
});
|
|
125
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
## Adding Models
|
|
2
|
+
|
|
3
|
+
Import `appConnection` and register Mongoose models on it. `appConnection` is a lazy proxy — `.model()` works before `connectMongo()` has been called.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { appConnection } from "@lastshotlabs/bunshot";
|
|
7
|
+
import { Schema, type HydratedDocument } from "mongoose";
|
|
8
|
+
|
|
9
|
+
const ProductSchema = new Schema({ name: String, price: Number }, { timestamps: true });
|
|
10
|
+
export const Product = appConnection.model("Product", ProductSchema);
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Bunshot also provides `zodToMongoose` (Zod -> Mongoose schema conversion) and `createDtoMapper` (DB document -> API DTO) to use Zod as the single source of truth for your models and OpenAPI spec.
|