@lastshotlabs/bunshot 0.0.16 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/lib/zodToMongoose.d.ts +2 -2
- package/dist/lib/zodToMongoose.js +7 -3
- 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
package/dist/routes/auth.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createRoute, withSecurity } from "../lib/createRoute";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { setCookie, getCookie, deleteCookie } from "hono/cookie";
|
|
4
4
|
import * as AuthService from "../services/auth";
|
|
5
|
-
import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
|
|
5
|
+
import { makeRegisterSchema, makeLoginSchema, resetPasswordSchema } from "../schemas/auth";
|
|
6
6
|
import { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN } from "../lib/constants";
|
|
7
7
|
import { userAuth } from "../middleware/userAuth";
|
|
8
8
|
import { isLimited, trackAttempt, bustAuthLimit } from "../lib/authRateLimit";
|
|
@@ -10,8 +10,10 @@ import { getAuthAdapter } from "../lib/authAdapter";
|
|
|
10
10
|
import { createRouter } from "../lib/context";
|
|
11
11
|
import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "../lib/emailVerification";
|
|
12
12
|
import { createResetToken, consumeResetToken } from "../lib/resetPassword";
|
|
13
|
-
import { getRefreshTokenExpiry, getAccessTokenExpiry } from "../lib/appConfig";
|
|
13
|
+
import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
|
|
14
|
+
import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
|
|
14
15
|
import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
|
|
16
|
+
import { getClientIp } from "../lib/clientIp";
|
|
15
17
|
const isProd = process.env.NODE_ENV === "production";
|
|
16
18
|
const TokenResponse = z.object({
|
|
17
19
|
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
|
|
@@ -33,7 +35,6 @@ const cookieOptions = (maxAge) => ({
|
|
|
33
35
|
path: "/",
|
|
34
36
|
maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
|
|
35
37
|
});
|
|
36
|
-
const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
|
|
37
38
|
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
|
|
38
39
|
const router = createRouter();
|
|
39
40
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
@@ -61,7 +62,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
61
62
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
|
|
62
63
|
},
|
|
63
64
|
}), async (c) => {
|
|
64
|
-
const ip =
|
|
65
|
+
const ip = getClientIp(c);
|
|
65
66
|
if (await trackAttempt(`register:${ip}`, registerOpts)) {
|
|
66
67
|
return c.json({ error: "Too many registration attempts. Try again later." }, 429);
|
|
67
68
|
}
|
|
@@ -76,6 +77,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
76
77
|
if (result.refreshToken) {
|
|
77
78
|
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
78
79
|
}
|
|
80
|
+
if (getCsrfEnabled())
|
|
81
|
+
refreshCsrfToken(c);
|
|
79
82
|
return c.json(result, 201);
|
|
80
83
|
});
|
|
81
84
|
router.openapi(createRoute({
|
|
@@ -99,7 +102,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
99
102
|
return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
|
|
100
103
|
}
|
|
101
104
|
const metadata = {
|
|
102
|
-
ipAddress:
|
|
105
|
+
ipAddress: getClientIp(c),
|
|
103
106
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
104
107
|
};
|
|
105
108
|
try {
|
|
@@ -110,6 +113,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
110
113
|
if (result.refreshToken) {
|
|
111
114
|
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
112
115
|
}
|
|
116
|
+
if (getCsrfEnabled())
|
|
117
|
+
refreshCsrfToken(c);
|
|
113
118
|
}
|
|
114
119
|
return c.json(result, 200);
|
|
115
120
|
}
|
|
@@ -255,6 +260,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
255
260
|
await AuthService.logout(token);
|
|
256
261
|
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
257
262
|
deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
|
|
263
|
+
if (getCsrfEnabled())
|
|
264
|
+
clearCsrfToken(c);
|
|
258
265
|
return c.json({ message: "Logged out" }, 200);
|
|
259
266
|
});
|
|
260
267
|
// Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
|
|
@@ -272,7 +279,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
272
279
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
|
|
273
280
|
},
|
|
274
281
|
}), async (c) => {
|
|
275
|
-
const ip = c
|
|
282
|
+
const ip = getClientIp(c);
|
|
276
283
|
if (await trackAttempt(`verify:${ip}`, verifyOpts)) {
|
|
277
284
|
return c.json({ error: "Too many verification attempts. Try again later." }, 429);
|
|
278
285
|
}
|
|
@@ -341,7 +348,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
341
348
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
|
|
342
349
|
},
|
|
343
350
|
}), async (c) => {
|
|
344
|
-
const ip =
|
|
351
|
+
const ip = getClientIp(c);
|
|
345
352
|
const { email } = c.req.valid("json");
|
|
346
353
|
// Rate-limit by both IP and email to prevent distributed email-bombing
|
|
347
354
|
const ipLimited = await trackAttempt(`forgot:ip:${ip}`, forgotOpts);
|
|
@@ -379,7 +386,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
379
386
|
"application/json": {
|
|
380
387
|
schema: z.object({
|
|
381
388
|
token: z.string().describe("Single-use reset token received via email."),
|
|
382
|
-
password:
|
|
389
|
+
password: resetPasswordSchema().describe("New password."),
|
|
383
390
|
}),
|
|
384
391
|
},
|
|
385
392
|
},
|
|
@@ -393,7 +400,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
393
400
|
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
394
401
|
},
|
|
395
402
|
}), async (c) => {
|
|
396
|
-
const ip =
|
|
403
|
+
const ip = getClientIp(c);
|
|
397
404
|
if (await trackAttempt(`reset:${ip}`, resetOpts)) {
|
|
398
405
|
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
399
406
|
}
|
|
@@ -444,8 +451,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
444
451
|
responses: {
|
|
445
452
|
200: { content: { "application/json": { schema: RefreshResponse } }, description: "New access and refresh tokens." },
|
|
446
453
|
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired refresh token, or session invalidated due to token theft detection." },
|
|
454
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many refresh attempts. Try again later." },
|
|
447
455
|
},
|
|
448
456
|
}), async (c) => {
|
|
457
|
+
const ip = getClientIp(c);
|
|
458
|
+
if (await trackAttempt(`refresh:ip:${ip}`, { max: 30, windowMs: 60_000 })) {
|
|
459
|
+
return c.json({ error: "Too many refresh attempts. Try again later." }, 429);
|
|
460
|
+
}
|
|
449
461
|
const body = c.req.valid("json");
|
|
450
462
|
const rt = body.refreshToken ?? getCookie(c, COOKIE_REFRESH_TOKEN) ?? c.req.header(HEADER_REFRESH_TOKEN) ?? null;
|
|
451
463
|
if (!rt) {
|
package/dist/routes/mfa.d.ts
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import type { AuthRateLimitConfig } from "../app";
|
|
2
|
+
export interface MfaRouterOptions {
|
|
3
|
+
rateLimit?: AuthRateLimitConfig;
|
|
4
|
+
}
|
|
5
|
+
export declare const createMfaRouter: ({ rateLimit }?: MfaRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
|
package/dist/routes/mfa.js
CHANGED
|
@@ -7,8 +7,11 @@ import * as MfaService from "../services/mfa";
|
|
|
7
7
|
import * as AuthService from "../services/auth";
|
|
8
8
|
import { consumeMfaChallenge, replaceMfaChallengeOtp } from "../lib/mfaChallenge";
|
|
9
9
|
import { COOKIE_TOKEN, COOKIE_REFRESH_TOKEN } from "../lib/constants";
|
|
10
|
-
import { getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getMfaEmailOtpConfig } from "../lib/appConfig";
|
|
10
|
+
import { getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getMfaEmailOtpConfig, getMfaWebAuthnConfig, getCsrfEnabled } from "../lib/appConfig";
|
|
11
|
+
import { refreshCsrfToken } from "../middleware/csrf";
|
|
11
12
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
13
|
+
import { trackAttempt } from "../lib/authRateLimit";
|
|
14
|
+
import { getClientIp } from "../lib/clientIp";
|
|
12
15
|
const isProd = process.env.NODE_ENV === "production";
|
|
13
16
|
const cookieOptions = (maxAge) => ({
|
|
14
17
|
httpOnly: true,
|
|
@@ -19,8 +22,11 @@ const cookieOptions = (maxAge) => ({
|
|
|
19
22
|
});
|
|
20
23
|
const tags = ["MFA"];
|
|
21
24
|
const ErrorResponse = z.object({ error: z.string() }).openapi("MfaErrorResponse");
|
|
22
|
-
export const createMfaRouter = () => {
|
|
25
|
+
export const createMfaRouter = ({ rateLimit } = {}) => {
|
|
23
26
|
const router = createRouter();
|
|
27
|
+
// Resolve MFA rate limits with defaults
|
|
28
|
+
const mfaVerifyOpts = { windowMs: rateLimit?.mfaVerify?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.mfaVerify?.max ?? 10 };
|
|
29
|
+
const mfaResendOpts = { windowMs: rateLimit?.mfaResend?.windowMs ?? 60 * 1000, max: rateLimit?.mfaResend?.max ?? 5 };
|
|
24
30
|
// All MFA setup/management routes require auth
|
|
25
31
|
router.use("/auth/mfa/setup", userAuth);
|
|
26
32
|
router.use("/auth/mfa/verify-setup", userAuth);
|
|
@@ -107,7 +113,7 @@ export const createMfaRouter = () => {
|
|
|
107
113
|
method: "post",
|
|
108
114
|
path: "/auth/mfa/verify",
|
|
109
115
|
summary: "Complete MFA login",
|
|
110
|
-
description: "Completes login by verifying a TOTP code, email OTP code,
|
|
116
|
+
description: "Completes login by verifying a TOTP code, email OTP code, recovery code, or WebAuthn assertion after password authentication. Requires the mfaToken returned from the login endpoint. Optionally specify 'method' to target a specific verification method.",
|
|
111
117
|
tags,
|
|
112
118
|
request: {
|
|
113
119
|
body: {
|
|
@@ -115,8 +121,9 @@ export const createMfaRouter = () => {
|
|
|
115
121
|
"application/json": {
|
|
116
122
|
schema: z.object({
|
|
117
123
|
mfaToken: z.string().describe("MFA challenge token from the login response."),
|
|
118
|
-
code: z.string().describe("6-digit TOTP/email OTP code or 8-character recovery code."),
|
|
119
|
-
method: z.enum(["totp", "emailOtp"]).optional().describe("Specify which MFA method to verify. If omitted, methods are tried automatically."),
|
|
124
|
+
code: z.string().optional().describe("6-digit TOTP/email OTP code or 8-character recovery code. Required unless using WebAuthn."),
|
|
125
|
+
method: z.enum(["totp", "emailOtp", "webauthn"]).optional().describe("Specify which MFA method to verify. If omitted, methods are tried automatically."),
|
|
126
|
+
webauthnResponse: z.record(z.string(), z.unknown()).optional().describe("WebAuthn authentication response from navigator.credentials.get(). Pass the entire response object."),
|
|
120
127
|
}),
|
|
121
128
|
},
|
|
122
129
|
},
|
|
@@ -125,24 +132,39 @@ export const createMfaRouter = () => {
|
|
|
125
132
|
responses: {
|
|
126
133
|
200: { content: { "application/json": { schema: MfaLoginResponse } }, description: "MFA verified. Session created." },
|
|
127
134
|
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired MFA token, or invalid code." },
|
|
135
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many MFA verification attempts. Try again later." },
|
|
128
136
|
},
|
|
129
137
|
}), async (c) => {
|
|
130
|
-
const
|
|
138
|
+
const ip = getClientIp(c);
|
|
139
|
+
if (await trackAttempt(`mfa-verify:${ip}`, mfaVerifyOpts)) {
|
|
140
|
+
return c.json({ error: "Too many MFA verification attempts. Try again later." }, 429);
|
|
141
|
+
}
|
|
142
|
+
const { mfaToken, code, method, webauthnResponse } = c.req.valid("json");
|
|
143
|
+
if (!code && !webauthnResponse) {
|
|
144
|
+
return c.json({ error: "Either 'code' or 'webauthnResponse' is required" }, 401);
|
|
145
|
+
}
|
|
131
146
|
const challenge = await consumeMfaChallenge(mfaToken);
|
|
132
147
|
if (!challenge)
|
|
133
148
|
return c.json({ error: "Invalid or expired MFA token" }, 401);
|
|
134
|
-
const { userId, emailOtpHash } = challenge;
|
|
149
|
+
const { userId, emailOtpHash, webauthnChallenge } = challenge;
|
|
135
150
|
let valid = false;
|
|
136
|
-
if (method === "
|
|
151
|
+
if (method === "webauthn" || (!method && webauthnResponse)) {
|
|
152
|
+
// WebAuthn verification
|
|
153
|
+
if (webauthnResponse && webauthnChallenge) {
|
|
154
|
+
valid = await MfaService.verifyWebAuthn(userId, webauthnResponse, webauthnChallenge);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (method === "totp") {
|
|
137
158
|
// Only try TOTP
|
|
138
|
-
|
|
159
|
+
if (code)
|
|
160
|
+
valid = await MfaService.verifyTotp(userId, code);
|
|
139
161
|
}
|
|
140
162
|
else if (method === "emailOtp") {
|
|
141
163
|
// Only try email OTP
|
|
142
|
-
if (emailOtpHash)
|
|
164
|
+
if (code && emailOtpHash)
|
|
143
165
|
valid = MfaService.verifyEmailOtp(emailOtpHash, code);
|
|
144
166
|
}
|
|
145
|
-
else {
|
|
167
|
+
else if (code) {
|
|
146
168
|
// Auto-detect: use emailOtpHash presence to pick order
|
|
147
169
|
if (emailOtpHash) {
|
|
148
170
|
// Email OTP first, then TOTP, then recovery
|
|
@@ -155,15 +177,15 @@ export const createMfaRouter = () => {
|
|
|
155
177
|
valid = await MfaService.verifyTotp(userId, code);
|
|
156
178
|
}
|
|
157
179
|
}
|
|
158
|
-
// Always try recovery code as fallback
|
|
159
|
-
if (!valid) {
|
|
180
|
+
// Always try recovery code as fallback (code-based only)
|
|
181
|
+
if (!valid && code) {
|
|
160
182
|
valid = await MfaService.verifyRecoveryCode(userId, code);
|
|
161
183
|
}
|
|
162
184
|
if (!valid)
|
|
163
185
|
return c.json({ error: "Invalid MFA code" }, 401);
|
|
164
186
|
// Create session — reuse the service helper for refresh token support
|
|
165
187
|
const result = await AuthService.createSessionForUser(userId, {
|
|
166
|
-
ipAddress: (c
|
|
188
|
+
ipAddress: getClientIp(c),
|
|
167
189
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
168
190
|
});
|
|
169
191
|
const rtConfig = getRefreshTokenConfig();
|
|
@@ -171,6 +193,8 @@ export const createMfaRouter = () => {
|
|
|
171
193
|
if (result.refreshToken) {
|
|
172
194
|
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
173
195
|
}
|
|
196
|
+
if (getCsrfEnabled())
|
|
197
|
+
refreshCsrfToken(c);
|
|
174
198
|
return c.json({ token: result.token, userId, refreshToken: result.refreshToken }, 200);
|
|
175
199
|
});
|
|
176
200
|
// ─── Disable MFA ──────────────────────────────────────────────────────────
|
|
@@ -364,6 +388,10 @@ export const createMfaRouter = () => {
|
|
|
364
388
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Maximum resends reached." },
|
|
365
389
|
},
|
|
366
390
|
}), async (c) => {
|
|
391
|
+
const ip = getClientIp(c);
|
|
392
|
+
if (await trackAttempt(`mfa-resend:${ip}`, mfaResendOpts)) {
|
|
393
|
+
return c.json({ error: "Too many resend attempts. Try again later." }, 429);
|
|
394
|
+
}
|
|
367
395
|
const { mfaToken } = c.req.valid("json");
|
|
368
396
|
const emailOtpConfig = getMfaEmailOtpConfig();
|
|
369
397
|
if (!emailOtpConfig)
|
|
@@ -405,5 +433,184 @@ export const createMfaRouter = () => {
|
|
|
405
433
|
const methods = await MfaService.getMfaMethods(userId);
|
|
406
434
|
return c.json({ methods }, 200);
|
|
407
435
|
});
|
|
436
|
+
// ─── WebAuthn / Security Keys ─────────────────────────────────────────────
|
|
437
|
+
if (getMfaWebAuthnConfig()) {
|
|
438
|
+
// Eager dependency check — fail fast at server start
|
|
439
|
+
MfaService.assertWebAuthnDependency().catch((err) => { throw err; });
|
|
440
|
+
router.use("/auth/mfa/webauthn/*", userAuth);
|
|
441
|
+
// Register options
|
|
442
|
+
router.openapi(withSecurity(createRoute({
|
|
443
|
+
method: "post",
|
|
444
|
+
path: "/auth/mfa/webauthn/register-options",
|
|
445
|
+
summary: "Generate WebAuthn registration options",
|
|
446
|
+
description: "Generates registration options for the client to pass to navigator.credentials.create(). Returns a registrationToken to confirm registration.",
|
|
447
|
+
tags,
|
|
448
|
+
responses: {
|
|
449
|
+
200: {
|
|
450
|
+
content: {
|
|
451
|
+
"application/json": {
|
|
452
|
+
schema: z.object({
|
|
453
|
+
options: z.record(z.string(), z.unknown()).describe("PublicKeyCredentialCreationOptions — pass directly to navigator.credentials.create()."),
|
|
454
|
+
registrationToken: z.string().describe("Token to pass back when completing registration."),
|
|
455
|
+
}),
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
description: "Registration options generated.",
|
|
459
|
+
},
|
|
460
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
461
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "WebAuthn not configured or adapter does not support it." },
|
|
462
|
+
},
|
|
463
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
464
|
+
const userId = c.get("authUserId");
|
|
465
|
+
const result = await MfaService.initiateWebAuthnRegistration(userId);
|
|
466
|
+
return c.json(result, 200);
|
|
467
|
+
});
|
|
468
|
+
// Complete registration
|
|
469
|
+
router.openapi(withSecurity(createRoute({
|
|
470
|
+
method: "post",
|
|
471
|
+
path: "/auth/mfa/webauthn/register",
|
|
472
|
+
summary: "Complete WebAuthn registration",
|
|
473
|
+
description: "Verifies the attestation response from navigator.credentials.create() and stores the credential. Returns recovery codes.",
|
|
474
|
+
tags,
|
|
475
|
+
request: {
|
|
476
|
+
body: {
|
|
477
|
+
content: {
|
|
478
|
+
"application/json": {
|
|
479
|
+
schema: z.object({
|
|
480
|
+
registrationToken: z.string().describe("Token from POST /auth/mfa/webauthn/register-options."),
|
|
481
|
+
attestationResponse: z.record(z.string(), z.unknown()).describe("Full response from navigator.credentials.create()."),
|
|
482
|
+
name: z.string().optional().describe("User-friendly name for the key (e.g. 'YubiKey 5')."),
|
|
483
|
+
}),
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
responses: {
|
|
489
|
+
200: {
|
|
490
|
+
content: {
|
|
491
|
+
"application/json": {
|
|
492
|
+
schema: z.object({
|
|
493
|
+
message: z.string(),
|
|
494
|
+
credentialId: z.string(),
|
|
495
|
+
recoveryCodes: z.array(z.string()).nullable().describe("Recovery codes (always returned when WebAuthn is enabled)."),
|
|
496
|
+
}),
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
description: "Security key registered.",
|
|
500
|
+
},
|
|
501
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid registration token or verification failed." },
|
|
502
|
+
409: { content: { "application/json": { schema: ErrorResponse } }, description: "Security key already registered to another account." },
|
|
503
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "WebAuthn not configured or adapter does not support it." },
|
|
504
|
+
},
|
|
505
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
506
|
+
const userId = c.get("authUserId");
|
|
507
|
+
const { registrationToken, attestationResponse, name } = c.req.valid("json");
|
|
508
|
+
const result = await MfaService.completeWebAuthnRegistration(userId, registrationToken, attestationResponse, name);
|
|
509
|
+
return c.json({ message: "Security key registered", ...result }, 200);
|
|
510
|
+
});
|
|
511
|
+
// List credentials
|
|
512
|
+
router.openapi(withSecurity(createRoute({
|
|
513
|
+
method: "get",
|
|
514
|
+
path: "/auth/mfa/webauthn/credentials",
|
|
515
|
+
summary: "List WebAuthn credentials",
|
|
516
|
+
description: "Returns the security keys registered for the authenticated user. Does not include private key data.",
|
|
517
|
+
tags,
|
|
518
|
+
responses: {
|
|
519
|
+
200: {
|
|
520
|
+
content: {
|
|
521
|
+
"application/json": {
|
|
522
|
+
schema: z.object({
|
|
523
|
+
credentials: z.array(z.object({
|
|
524
|
+
credentialId: z.string(),
|
|
525
|
+
name: z.string().optional(),
|
|
526
|
+
createdAt: z.number(),
|
|
527
|
+
transports: z.array(z.string()).optional(),
|
|
528
|
+
})),
|
|
529
|
+
}),
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
description: "List of registered security keys.",
|
|
533
|
+
},
|
|
534
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
|
|
535
|
+
},
|
|
536
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
537
|
+
const userId = c.get("authUserId");
|
|
538
|
+
const adapter = getAuthAdapter();
|
|
539
|
+
const creds = adapter.getWebAuthnCredentials ? await adapter.getWebAuthnCredentials(userId) : [];
|
|
540
|
+
return c.json({
|
|
541
|
+
credentials: creds.map((cr) => ({
|
|
542
|
+
credentialId: cr.credentialId,
|
|
543
|
+
name: cr.name,
|
|
544
|
+
createdAt: cr.createdAt,
|
|
545
|
+
transports: cr.transports,
|
|
546
|
+
})),
|
|
547
|
+
}, 200);
|
|
548
|
+
});
|
|
549
|
+
// Remove a single credential
|
|
550
|
+
router.openapi(withSecurity(createRoute({
|
|
551
|
+
method: "delete",
|
|
552
|
+
path: "/auth/mfa/webauthn/credentials/{credentialId}",
|
|
553
|
+
summary: "Remove a WebAuthn credential",
|
|
554
|
+
description: "Removes a single security key. Identity verification is only required when removing the last MFA credential.",
|
|
555
|
+
tags,
|
|
556
|
+
request: {
|
|
557
|
+
params: z.object({ credentialId: z.string() }),
|
|
558
|
+
body: {
|
|
559
|
+
content: {
|
|
560
|
+
"application/json": {
|
|
561
|
+
schema: z.object({
|
|
562
|
+
code: z.string().optional().describe("TOTP code (required when removing the last MFA credential, if TOTP is enabled)."),
|
|
563
|
+
password: z.string().optional().describe("Password (required when removing the last MFA credential, if no TOTP)."),
|
|
564
|
+
}),
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
responses: {
|
|
570
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Credential removed." },
|
|
571
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Missing required verification." },
|
|
572
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid code/password or no valid session." },
|
|
573
|
+
404: { content: { "application/json": { schema: ErrorResponse } }, description: "Credential not found." },
|
|
574
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "Adapter does not support WebAuthn." },
|
|
575
|
+
},
|
|
576
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
577
|
+
const userId = c.get("authUserId");
|
|
578
|
+
const { credentialId } = c.req.valid("param");
|
|
579
|
+
const { code, password } = c.req.valid("json");
|
|
580
|
+
await MfaService.removeWebAuthnCredential(userId, credentialId, { code, password });
|
|
581
|
+
return c.json({ message: "Credential removed" }, 200);
|
|
582
|
+
});
|
|
583
|
+
// Disable WebAuthn entirely
|
|
584
|
+
router.openapi(withSecurity(createRoute({
|
|
585
|
+
method: "delete",
|
|
586
|
+
path: "/auth/mfa/webauthn",
|
|
587
|
+
summary: "Disable WebAuthn MFA",
|
|
588
|
+
description: "Removes all WebAuthn credentials and disables WebAuthn as an MFA method. Requires identity verification.",
|
|
589
|
+
tags,
|
|
590
|
+
request: {
|
|
591
|
+
body: {
|
|
592
|
+
content: {
|
|
593
|
+
"application/json": {
|
|
594
|
+
schema: z.object({
|
|
595
|
+
code: z.string().optional().describe("TOTP code (if TOTP is enabled)."),
|
|
596
|
+
password: z.string().optional().describe("Password (if TOTP is not enabled)."),
|
|
597
|
+
}),
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
responses: {
|
|
603
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "WebAuthn disabled." },
|
|
604
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Missing required verification." },
|
|
605
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid code/password or no valid session." },
|
|
606
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "Adapter does not support WebAuthn." },
|
|
607
|
+
},
|
|
608
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
609
|
+
const userId = c.get("authUserId");
|
|
610
|
+
const { code, password } = c.req.valid("json");
|
|
611
|
+
await MfaService.disableWebAuthn(userId, { code, password });
|
|
612
|
+
return c.json({ message: "WebAuthn disabled" }, 200);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
408
615
|
return router;
|
|
409
616
|
};
|