@lastshotlabs/bunshot 0.0.25 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/localStorage.js +20 -5
- package/dist/adapters/memoryAuth.d.ts +6 -0
- package/dist/adapters/memoryAuth.js +117 -2
- package/dist/adapters/mongoAuth.js +97 -1
- package/dist/adapters/sqliteAuth.d.ts +23 -0
- package/dist/adapters/sqliteAuth.js +153 -2
- package/dist/app.d.ts +105 -2
- package/dist/app.js +112 -9
- package/dist/index.d.ts +23 -4
- package/dist/index.js +13 -2
- package/dist/lib/HttpError.d.ts +2 -1
- package/dist/lib/HttpError.js +3 -1
- package/dist/lib/appConfig.d.ts +113 -0
- package/dist/lib/appConfig.js +38 -0
- package/dist/lib/auditLog.d.ts +6 -0
- package/dist/lib/auditLog.js +17 -0
- package/dist/lib/authAdapter.d.ts +71 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/context.d.ts +5 -0
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +10 -0
- package/dist/lib/session.js +67 -5
- package/dist/lib/signing.js +5 -2
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/upload.d.ts +4 -0
- package/dist/lib/upload.js +26 -1
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/ws.js +7 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +8 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +40 -13
- package/dist/middleware/requestSigning.js +6 -5
- package/dist/middleware/requireMfaSetup.js +2 -1
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- package/dist/middleware/webhookAuth.d.ts +1 -1
- package/dist/middleware/webhookAuth.js +6 -5
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +155 -16
- package/dist/routes/jobs.js +21 -3
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +1 -0
- package/dist/routes/metrics.js +3 -0
- package/dist/routes/mfa.js +9 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +13 -1
- package/dist/routes/uploads.js +98 -6
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +2 -1
- package/docs/sections/auth-flow/full.md +790 -779
- package/docs/sections/auth-security-examples/full.md +23 -0
- package/docs/sections/metrics/full.md +6 -2
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/uploads/full.md +11 -2
- package/docs/sections/webhook-auth/full.md +1 -1
- package/docs/sections/websocket/full.md +12 -0
- package/package.json +3 -2
package/dist/routes/uploads.js
CHANGED
|
@@ -2,14 +2,54 @@ import { z } from "zod";
|
|
|
2
2
|
import { createRouter } from "../lib/context";
|
|
3
3
|
import { createRoute } from "../lib/createRoute";
|
|
4
4
|
import { userAuth } from "../middleware/userAuth";
|
|
5
|
-
import { getStorageAdapter,
|
|
5
|
+
import { getStorageAdapter, generateUploadKeyFromFilename } from "../lib/upload";
|
|
6
6
|
import { getSigningConfig, getSigningSecret } from "../lib/appConfig";
|
|
7
7
|
import { createPresignedUrl } from "../lib/signing";
|
|
8
|
+
import { getUploadRecord, deleteUploadRecord, registerUpload } from "../lib/uploadRegistry";
|
|
8
9
|
const tags = ["Uploads"];
|
|
10
|
+
async function checkUploadAccess(action, key, userId, tenantId, config) {
|
|
11
|
+
const record = await getUploadRecord(key);
|
|
12
|
+
const authorize = config.authorization?.authorize;
|
|
13
|
+
const allowExternalKeys = config.allowExternalKeys ?? false;
|
|
14
|
+
if (record) {
|
|
15
|
+
// If the registry record has a tenantId, the requester must match — period.
|
|
16
|
+
if (record.tenantId && record.tenantId !== tenantId) {
|
|
17
|
+
return { allowed: false, notFound: false };
|
|
18
|
+
}
|
|
19
|
+
// Owner match → allow
|
|
20
|
+
if (record.ownerUserId && record.ownerUserId === userId) {
|
|
21
|
+
return { allowed: true, notFound: false };
|
|
22
|
+
}
|
|
23
|
+
// No owner or owner mismatch → try callback
|
|
24
|
+
if (authorize) {
|
|
25
|
+
const ok = await authorize({ action, key, userId: userId ?? undefined, tenantId: tenantId ?? undefined });
|
|
26
|
+
return { allowed: ok, notFound: false };
|
|
27
|
+
}
|
|
28
|
+
return { allowed: false, notFound: false };
|
|
29
|
+
}
|
|
30
|
+
// Record not in registry
|
|
31
|
+
if (allowExternalKeys) {
|
|
32
|
+
if (authorize) {
|
|
33
|
+
const ok = await authorize({ action, key, userId: userId ?? undefined, tenantId: tenantId ?? undefined });
|
|
34
|
+
return { allowed: ok, notFound: false };
|
|
35
|
+
}
|
|
36
|
+
return { allowed: false, notFound: false };
|
|
37
|
+
}
|
|
38
|
+
return { allowed: false, notFound: true };
|
|
39
|
+
}
|
|
9
40
|
export const createUploadsRouter = (config) => {
|
|
10
41
|
const router = createRouter();
|
|
11
42
|
const basePath = (config.path ?? "/uploads").replace(/\/$/, "");
|
|
12
43
|
router.use(`${basePath}/*`, userAuth);
|
|
44
|
+
const BLOCKED_MIME_TYPES = new Set([
|
|
45
|
+
"application/x-executable",
|
|
46
|
+
"application/x-sh",
|
|
47
|
+
"application/x-msdownload",
|
|
48
|
+
"text/html",
|
|
49
|
+
"application/x-httpd-php",
|
|
50
|
+
"application/javascript",
|
|
51
|
+
"text/javascript",
|
|
52
|
+
]);
|
|
13
53
|
const presignRoute = createRoute({
|
|
14
54
|
method: "post",
|
|
15
55
|
path: `${basePath}/presign`,
|
|
@@ -20,9 +60,11 @@ export const createUploadsRouter = (config) => {
|
|
|
20
60
|
content: {
|
|
21
61
|
"application/json": {
|
|
22
62
|
schema: z.object({
|
|
23
|
-
|
|
63
|
+
filename: z.string().optional().describe("Original filename (used to derive the storage key extension)"),
|
|
24
64
|
mimeType: z.string().optional().describe("MIME type of the file"),
|
|
25
65
|
expirySeconds: z.number().int().positive().optional().describe("URL expiry in seconds"),
|
|
66
|
+
maxBytes: z.number().int().positive().max(100 * 1024 * 1024).optional()
|
|
67
|
+
.describe("Maximum allowed file size in bytes (client-enforced via Content-Length header). Defaults to 10MB. Maximum: 100MB."),
|
|
26
68
|
}),
|
|
27
69
|
},
|
|
28
70
|
},
|
|
@@ -31,7 +73,11 @@ export const createUploadsRouter = (config) => {
|
|
|
31
73
|
responses: {
|
|
32
74
|
200: {
|
|
33
75
|
description: "Presigned URL generated",
|
|
34
|
-
content: { "application/json": { schema: z.object({ url: z.string(), key: z.string() }) } },
|
|
76
|
+
content: { "application/json": { schema: z.object({ url: z.string(), key: z.string(), maxBytes: z.number().optional() }) } },
|
|
77
|
+
},
|
|
78
|
+
400: {
|
|
79
|
+
description: "File type not allowed",
|
|
80
|
+
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
35
81
|
},
|
|
36
82
|
501: {
|
|
37
83
|
description: "Not implemented by adapter",
|
|
@@ -44,11 +90,26 @@ export const createUploadsRouter = (config) => {
|
|
|
44
90
|
if (!adapter?.presignPut) {
|
|
45
91
|
return c.json({ error: "Presigned URLs not supported by the configured storage adapter" }, 501);
|
|
46
92
|
}
|
|
47
|
-
const {
|
|
48
|
-
|
|
93
|
+
const { filename, mimeType, expirySeconds, maxBytes } = c.req.valid("json");
|
|
94
|
+
if (mimeType && BLOCKED_MIME_TYPES.has(mimeType)) {
|
|
95
|
+
return c.json({ error: "File type not allowed." }, 400);
|
|
96
|
+
}
|
|
97
|
+
const userId = c.get("authUserId") ?? undefined;
|
|
98
|
+
const tenantId = c.get("tenantId") ?? undefined;
|
|
99
|
+
// Server-generates the key — client cannot control the storage path
|
|
100
|
+
const key = generateUploadKeyFromFilename(filename, { userId, tenantId });
|
|
49
101
|
const expiry = expirySeconds ?? (typeof config.expirySeconds === "number" ? config.expirySeconds : 3600);
|
|
50
102
|
const url = await adapter.presignPut(key, { expirySeconds: expiry, mimeType });
|
|
51
|
-
|
|
103
|
+
// Register the upload for ownership tracking
|
|
104
|
+
await registerUpload({
|
|
105
|
+
key,
|
|
106
|
+
ownerUserId: userId,
|
|
107
|
+
tenantId,
|
|
108
|
+
mimeType,
|
|
109
|
+
bucket: c.get("uploadBucket") ?? undefined,
|
|
110
|
+
createdAt: Date.now(),
|
|
111
|
+
});
|
|
112
|
+
return c.json({ url, key, ...(maxBytes !== undefined ? { maxBytes } : {}) }, 200);
|
|
52
113
|
});
|
|
53
114
|
const presignGetRoute = createRoute({
|
|
54
115
|
method: "get",
|
|
@@ -73,6 +134,14 @@ export const createUploadsRouter = (config) => {
|
|
|
73
134
|
},
|
|
74
135
|
},
|
|
75
136
|
},
|
|
137
|
+
403: {
|
|
138
|
+
description: "Forbidden — not the owner or unauthorized",
|
|
139
|
+
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
140
|
+
},
|
|
141
|
+
404: {
|
|
142
|
+
description: "Key not found in upload registry",
|
|
143
|
+
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
144
|
+
},
|
|
76
145
|
501: {
|
|
77
146
|
description: "Not implemented",
|
|
78
147
|
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
@@ -82,6 +151,13 @@ export const createUploadsRouter = (config) => {
|
|
|
82
151
|
router.openapi(presignGetRoute, async (c) => {
|
|
83
152
|
const { key } = c.req.valid("param");
|
|
84
153
|
const { expiry: expiryStr } = c.req.valid("query");
|
|
154
|
+
const userId = c.get("authUserId");
|
|
155
|
+
const tenantId = c.get("tenantId");
|
|
156
|
+
const { allowed, notFound } = await checkUploadAccess("read", key, userId, tenantId, config);
|
|
157
|
+
if (notFound)
|
|
158
|
+
return c.json({ error: "Not found" }, 404);
|
|
159
|
+
if (!allowed)
|
|
160
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
85
161
|
const expirySeconds = expiryStr ? parseInt(expiryStr, 10) : (typeof config.expirySeconds === "number" ? config.expirySeconds : 3600);
|
|
86
162
|
const signingCfg = getSigningConfig();
|
|
87
163
|
if (signingCfg?.presignedUrls) {
|
|
@@ -117,6 +193,14 @@ export const createUploadsRouter = (config) => {
|
|
|
117
193
|
},
|
|
118
194
|
responses: {
|
|
119
195
|
204: { description: "Deleted" },
|
|
196
|
+
403: {
|
|
197
|
+
description: "Forbidden — not the owner or unauthorized",
|
|
198
|
+
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
199
|
+
},
|
|
200
|
+
404: {
|
|
201
|
+
description: "Key not found in upload registry",
|
|
202
|
+
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
203
|
+
},
|
|
120
204
|
500: {
|
|
121
205
|
description: "No storage adapter configured",
|
|
122
206
|
content: { "application/json": { schema: z.object({ error: z.string() }) } },
|
|
@@ -128,7 +212,15 @@ export const createUploadsRouter = (config) => {
|
|
|
128
212
|
if (!adapter)
|
|
129
213
|
return c.json({ error: "No storage adapter configured" }, 500);
|
|
130
214
|
const { key } = c.req.valid("param");
|
|
215
|
+
const userId = c.get("authUserId");
|
|
216
|
+
const tenantId = c.get("tenantId");
|
|
217
|
+
const { allowed, notFound } = await checkUploadAccess("delete", key, userId, tenantId, config);
|
|
218
|
+
if (notFound)
|
|
219
|
+
return c.json({ error: "Not found" }, 404);
|
|
220
|
+
if (!allowed)
|
|
221
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
131
222
|
await adapter.delete(key);
|
|
223
|
+
await deleteUploadRecord(key);
|
|
132
224
|
return c.body(null, 204);
|
|
133
225
|
});
|
|
134
226
|
return router;
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export interface AuthResult {
|
|
|
15
15
|
export declare const createSessionForUser: (userId: string, metadata?: SessionMetadata) => Promise<{
|
|
16
16
|
token: string;
|
|
17
17
|
refreshToken?: string;
|
|
18
|
+
sessionId: string;
|
|
18
19
|
}>;
|
|
19
20
|
export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<AuthResult>;
|
|
20
21
|
export declare const login: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<AuthResult>;
|
|
@@ -25,3 +26,4 @@ export declare const refresh: (refreshTokenValue: string) => Promise<{
|
|
|
25
26
|
}>;
|
|
26
27
|
export declare const deleteAccount: (userId: string, password?: string) => Promise<void>;
|
|
27
28
|
export declare const logout: (token: string | null) => Promise<void>;
|
|
29
|
+
export declare const passkeyLogin: (passkeyToken: string, assertionResponse: any, metadata?: SessionMetadata) => Promise<AuthResult>;
|
package/dist/services/auth.js
CHANGED
|
@@ -2,14 +2,16 @@ import { getAuthAdapter } from "../lib/authAdapter";
|
|
|
2
2
|
import { HttpError } from "../lib/HttpError";
|
|
3
3
|
import { signToken, verifyToken } from "../lib/jwt";
|
|
4
4
|
import { createSession, deleteSession, getActiveSessionCount, evictOldestSession, deleteUserSessions, setRefreshToken, getSessionByRefreshToken, rotateRefreshToken } from "../lib/session";
|
|
5
|
-
import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getMfaConfig, getMfaEmailOtpConfig, getMfaWebAuthnConfig } from "../lib/appConfig";
|
|
5
|
+
import { getDefaultRole, getPrimaryField, getEmailVerificationConfig, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getMfaConfig, getMfaEmailOtpConfig, getMfaWebAuthnConfig, getMfaWebAuthnPasskeyMfaBypass } from "../lib/appConfig";
|
|
6
|
+
import { getSuspended } from "../lib/suspension";
|
|
6
7
|
import { createVerificationToken } from "../lib/emailVerification";
|
|
7
8
|
import { createMfaChallenge } from "../lib/mfaChallenge";
|
|
8
9
|
import { generateEmailOtpCode, generateWebAuthnAuthenticationOptions } from "./mfa";
|
|
10
|
+
import { emitSecurityEvent } from "../lib/securityEvents";
|
|
9
11
|
async function createSessionWithRefreshToken(userId, sessionId, metadata) {
|
|
10
12
|
const rtConfig = getRefreshTokenConfig();
|
|
11
13
|
const expirySeconds = rtConfig ? getAccessTokenExpiry() : undefined;
|
|
12
|
-
const token = await signToken(userId, sessionId, expirySeconds);
|
|
14
|
+
const token = await signToken({ sub: userId, sid: sessionId }, expirySeconds);
|
|
13
15
|
while (await getActiveSessionCount(userId) >= getMaxSessions()) {
|
|
14
16
|
await evictOldestSession(userId);
|
|
15
17
|
}
|
|
@@ -27,25 +29,32 @@ export const createSessionForUser = async (userId, metadata) => {
|
|
|
27
29
|
return createSessionWithRefreshToken(userId, sessionId, metadata);
|
|
28
30
|
};
|
|
29
31
|
export const register = async (identifier, password, metadata) => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
32
|
+
try {
|
|
33
|
+
const hashed = await Bun.password.hash(password);
|
|
34
|
+
const adapter = getAuthAdapter();
|
|
35
|
+
const user = await adapter.create(identifier, hashed);
|
|
36
|
+
const role = getDefaultRole();
|
|
37
|
+
if (role)
|
|
38
|
+
await adapter.setRoles(user.id, [role]);
|
|
39
|
+
const sessionId = crypto.randomUUID();
|
|
40
|
+
const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
|
|
41
|
+
const evConfig = getEmailVerificationConfig();
|
|
42
|
+
if (evConfig && getPrimaryField() === "email") {
|
|
43
|
+
try {
|
|
44
|
+
const verificationToken = await createVerificationToken(user.id, identifier);
|
|
45
|
+
await evConfig.onSend(identifier, verificationToken);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
console.error("[email-verification] Failed to send verification email:", e);
|
|
49
|
+
}
|
|
46
50
|
}
|
|
51
|
+
emitSecurityEvent({ eventType: "auth.register.success", severity: "info", timestamp: new Date().toISOString(), userId: user.id });
|
|
52
|
+
return { token, userId: user.id, email: identifier, refreshToken };
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
emitSecurityEvent({ eventType: "auth.register.failure", severity: "warn", timestamp: new Date().toISOString() });
|
|
56
|
+
throw err;
|
|
47
57
|
}
|
|
48
|
-
return { token, userId: user.id, email: identifier, refreshToken };
|
|
49
58
|
};
|
|
50
59
|
// Pre-computed dummy hash so non-existent-user login takes the same time as wrong-password login
|
|
51
60
|
const DUMMY_HASH = await Bun.password.hash("dummy-timing-safe-placeholder");
|
|
@@ -57,8 +66,15 @@ export const login = async (identifier, password, metadata) => {
|
|
|
57
66
|
const hashToVerify = user?.passwordHash ?? DUMMY_HASH;
|
|
58
67
|
const passwordValid = await Bun.password.verify(password, hashToVerify);
|
|
59
68
|
if (!user || !passwordValid) {
|
|
69
|
+
emitSecurityEvent({ eventType: "auth.login.failure", severity: "warn", timestamp: new Date().toISOString(), meta: { identifier } });
|
|
60
70
|
throw new HttpError(401, "Invalid credentials");
|
|
61
71
|
}
|
|
72
|
+
// Check suspension
|
|
73
|
+
const suspensionStatus = await getSuspended(user.id);
|
|
74
|
+
if (suspensionStatus.suspended) {
|
|
75
|
+
emitSecurityEvent({ eventType: "auth.login.blocked", severity: "critical", timestamp: new Date().toISOString(), meta: { reason: "suspended" } });
|
|
76
|
+
throw new HttpError(403, "Account suspended", "ACCOUNT_SUSPENDED");
|
|
77
|
+
}
|
|
62
78
|
// Check email verification before MFA to avoid leaking MFA status to unverified users
|
|
63
79
|
const fullUser = adapter.getUser ? await adapter.getUser(user.id) : null;
|
|
64
80
|
const googleLinked = fullUser?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
|
|
@@ -100,6 +116,7 @@ export const login = async (identifier, password, metadata) => {
|
|
|
100
116
|
}
|
|
101
117
|
const sessionId = crypto.randomUUID();
|
|
102
118
|
const { token, refreshToken } = await createSessionWithRefreshToken(user.id, sessionId, metadata);
|
|
119
|
+
emitSecurityEvent({ eventType: "auth.login.success", severity: "info", timestamp: new Date().toISOString(), userId: user.id });
|
|
103
120
|
if (evConfig && getPrimaryField() === "email" && adapter.getEmailVerified) {
|
|
104
121
|
const verified = await adapter.getEmailVerified(user.id);
|
|
105
122
|
return { token, userId: user.id, email: fullUser?.email, emailVerified: verified, googleLinked, refreshToken };
|
|
@@ -115,12 +132,12 @@ export const refresh = async (refreshTokenValue) => {
|
|
|
115
132
|
// If the returned newRefreshToken differs from what was sent, we're in a grace window replay.
|
|
116
133
|
// Return the current tokens without rotating again.
|
|
117
134
|
if (newRefreshToken !== refreshTokenValue) {
|
|
118
|
-
const accessToken = await signToken(userId, sessionId, getAccessTokenExpiry());
|
|
135
|
+
const accessToken = await signToken({ sub: userId, sid: sessionId }, getAccessTokenExpiry());
|
|
119
136
|
return { token: accessToken, refreshToken: newRefreshToken, userId };
|
|
120
137
|
}
|
|
121
138
|
// Normal rotation: generate new refresh + access tokens
|
|
122
139
|
const newRT = crypto.randomUUID();
|
|
123
|
-
const newAccessToken = await signToken(userId, sessionId, getAccessTokenExpiry());
|
|
140
|
+
const newAccessToken = await signToken({ sub: userId, sid: sessionId }, getAccessTokenExpiry());
|
|
124
141
|
await rotateRefreshToken(sessionId, newRT, newAccessToken);
|
|
125
142
|
return { token: newAccessToken, refreshToken: newRT, userId };
|
|
126
143
|
};
|
|
@@ -148,12 +165,74 @@ export const deleteAccount = async (userId, password) => {
|
|
|
148
165
|
await deleteUserSessions(userId);
|
|
149
166
|
// Delete the user
|
|
150
167
|
await adapter.deleteUser(userId);
|
|
168
|
+
emitSecurityEvent({ eventType: "auth.account.deleted", severity: "warn", timestamp: new Date().toISOString(), userId });
|
|
151
169
|
};
|
|
152
170
|
export const logout = async (token) => {
|
|
153
171
|
if (token) {
|
|
154
172
|
const payload = await verifyToken(token);
|
|
155
173
|
const sessionId = payload.sid;
|
|
156
|
-
if (sessionId)
|
|
174
|
+
if (sessionId) {
|
|
157
175
|
await deleteSession(sessionId);
|
|
176
|
+
emitSecurityEvent({ eventType: "auth.logout", severity: "info", timestamp: new Date().toISOString(), sessionId });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
export const passkeyLogin = async (passkeyToken, assertionResponse, metadata) => {
|
|
181
|
+
const adapter = getAuthAdapter();
|
|
182
|
+
if (!adapter.findUserByWebAuthnCredentialId || !adapter.getWebAuthnCredentials) {
|
|
183
|
+
throw new HttpError(501, "Auth adapter does not support passkey login");
|
|
184
|
+
}
|
|
185
|
+
const { consumePasskeyLoginChallenge } = await import("../lib/mfaChallenge");
|
|
186
|
+
const challengeData = await consumePasskeyLoginChallenge(passkeyToken);
|
|
187
|
+
if (!challengeData) {
|
|
188
|
+
throw new HttpError(401, "Invalid or expired passkey token");
|
|
189
|
+
}
|
|
190
|
+
const credentialId = assertionResponse?.id;
|
|
191
|
+
if (!credentialId) {
|
|
192
|
+
throw new HttpError(401, "Invalid assertion response");
|
|
193
|
+
}
|
|
194
|
+
const userId = await adapter.findUserByWebAuthnCredentialId(credentialId);
|
|
195
|
+
if (!userId) {
|
|
196
|
+
throw new HttpError(401, "Invalid credentials");
|
|
197
|
+
}
|
|
198
|
+
const { verifyWebAuthn } = await import("./mfa");
|
|
199
|
+
const verified = await verifyWebAuthn(userId, assertionResponse, challengeData.webauthnChallenge);
|
|
200
|
+
if (!verified) {
|
|
201
|
+
throw new HttpError(401, "WebAuthn verification failed");
|
|
202
|
+
}
|
|
203
|
+
// Check suspension
|
|
204
|
+
const suspensionStatus = await getSuspended(userId);
|
|
205
|
+
if (suspensionStatus.suspended) {
|
|
206
|
+
throw new HttpError(403, "Account suspended", "ACCOUNT_SUSPENDED");
|
|
207
|
+
}
|
|
208
|
+
// passkeyMfaBypass=true (default): passkey with userVerification=required satisfies both factors
|
|
209
|
+
const mfaBypass = getMfaWebAuthnPasskeyMfaBypass();
|
|
210
|
+
if (!mfaBypass && getMfaConfig() && adapter.isMfaEnabled && await adapter.isMfaEnabled(userId)) {
|
|
211
|
+
const methods = adapter.getMfaMethods ? await adapter.getMfaMethods(userId) : ["totp"];
|
|
212
|
+
let emailOtpHash;
|
|
213
|
+
const emailOtpConfig = getMfaEmailOtpConfig();
|
|
214
|
+
if (methods.includes("emailOtp") && emailOtpConfig) {
|
|
215
|
+
const { generateEmailOtpCode } = await import("./mfa");
|
|
216
|
+
const { code, hash } = generateEmailOtpCode();
|
|
217
|
+
emailOtpHash = hash;
|
|
218
|
+
const fullUser = adapter.getUser ? await adapter.getUser(userId) : null;
|
|
219
|
+
if (fullUser?.email)
|
|
220
|
+
await emailOtpConfig.onSend(fullUser.email, code);
|
|
221
|
+
}
|
|
222
|
+
let webauthnChallenge2;
|
|
223
|
+
let webauthnOptions;
|
|
224
|
+
if (methods.includes("webauthn") && getMfaWebAuthnConfig()) {
|
|
225
|
+
const { generateWebAuthnAuthenticationOptions } = await import("./mfa");
|
|
226
|
+
const result = await generateWebAuthnAuthenticationOptions(userId);
|
|
227
|
+
if (result) {
|
|
228
|
+
webauthnChallenge2 = result.challenge;
|
|
229
|
+
webauthnOptions = result.options;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const mfaToken = await createMfaChallenge(userId, { emailOtpHash, webauthnChallenge: webauthnChallenge2 });
|
|
233
|
+
return { token: "", userId, mfaRequired: true, mfaToken, mfaMethods: methods, webauthnOptions };
|
|
158
234
|
}
|
|
235
|
+
const { token, refreshToken } = await createSessionForUser(userId, metadata);
|
|
236
|
+
const fullUser = adapter.getUser ? await adapter.getUser(userId) : null;
|
|
237
|
+
return { token, userId, email: fullUser?.email, refreshToken };
|
|
159
238
|
};
|
package/dist/services/mfa.js
CHANGED
|
@@ -343,8 +343,8 @@ export const initiateWebAuthnRegistration = async (userId) => {
|
|
|
343
343
|
})),
|
|
344
344
|
authenticatorSelection: {
|
|
345
345
|
authenticatorAttachment: config.authenticatorAttachment,
|
|
346
|
-
userVerification: config.userVerification ?? "
|
|
347
|
-
residentKey: "
|
|
346
|
+
userVerification: config.userVerification ?? "required",
|
|
347
|
+
residentKey: "required",
|
|
348
348
|
},
|
|
349
349
|
timeout: config.timeout ?? 60000,
|
|
350
350
|
});
|
package/dist/ws/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { verifyToken } from "../lib/jwt";
|
|
|
2
2
|
import { getSession } from "../lib/session";
|
|
3
3
|
import { COOKIE_TOKEN } from "../lib/constants";
|
|
4
4
|
import { trackSocket, untrackSocket } from "../lib/wsPresence";
|
|
5
|
+
import { timingSafeEqual } from "../lib/crypto";
|
|
5
6
|
export const createWsUpgradeHandler = (server) => async (req) => {
|
|
6
7
|
let userId = null;
|
|
7
8
|
try {
|
|
@@ -12,7 +13,7 @@ export const createWsUpgradeHandler = (server) => async (req) => {
|
|
|
12
13
|
const sessionId = payload.sid;
|
|
13
14
|
if (sessionId) {
|
|
14
15
|
const stored = await getSession(sessionId);
|
|
15
|
-
if (stored
|
|
16
|
+
if (timingSafeEqual(stored ?? "", token))
|
|
16
17
|
userId = payload.sub;
|
|
17
18
|
}
|
|
18
19
|
}
|