@lastshotlabs/bunshot 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +322 -16
  2. package/dist/adapters/memoryAuth.d.ts +3 -0
  3. package/dist/adapters/memoryAuth.js +48 -2
  4. package/dist/adapters/mongoAuth.js +39 -1
  5. package/dist/adapters/sqliteAuth.d.ts +3 -0
  6. package/dist/adapters/sqliteAuth.js +53 -0
  7. package/dist/app.d.ts +45 -2
  8. package/dist/app.js +79 -4
  9. package/dist/index.d.ts +14 -7
  10. package/dist/index.js +8 -4
  11. package/dist/lib/appConfig.d.ts +35 -0
  12. package/dist/lib/appConfig.js +10 -0
  13. package/dist/lib/authAdapter.d.ts +24 -0
  14. package/dist/lib/authRateLimit.d.ts +2 -0
  15. package/dist/lib/authRateLimit.js +4 -0
  16. package/dist/lib/clientIp.d.ts +14 -0
  17. package/dist/lib/clientIp.js +52 -0
  18. package/dist/lib/constants.d.ts +2 -0
  19. package/dist/lib/constants.js +2 -0
  20. package/dist/lib/crypto.d.ts +11 -0
  21. package/dist/lib/crypto.js +22 -0
  22. package/dist/lib/emailVerification.d.ts +4 -0
  23. package/dist/lib/emailVerification.js +20 -12
  24. package/dist/lib/jwt.js +17 -4
  25. package/dist/lib/mfaChallenge.d.ts +23 -1
  26. package/dist/lib/mfaChallenge.js +151 -42
  27. package/dist/lib/oauth.d.ts +14 -1
  28. package/dist/lib/oauth.js +19 -1
  29. package/dist/lib/oauthCode.d.ts +15 -0
  30. package/dist/lib/oauthCode.js +90 -0
  31. package/dist/lib/resetPassword.js +12 -16
  32. package/dist/lib/session.js +6 -4
  33. package/dist/lib/ws.js +5 -1
  34. package/dist/middleware/bearerAuth.js +4 -3
  35. package/dist/middleware/botProtection.js +2 -2
  36. package/dist/middleware/cacheResponse.d.ts +1 -0
  37. package/dist/middleware/cacheResponse.js +14 -2
  38. package/dist/middleware/cors.d.ts +2 -0
  39. package/dist/middleware/cors.js +22 -8
  40. package/dist/middleware/csrf.d.ts +18 -0
  41. package/dist/middleware/csrf.js +115 -0
  42. package/dist/middleware/rateLimit.js +2 -3
  43. package/dist/models/AuthUser.d.ts +9 -0
  44. package/dist/models/AuthUser.js +9 -0
  45. package/dist/routes/auth.js +21 -9
  46. package/dist/routes/mfa.d.ts +5 -1
  47. package/dist/routes/mfa.js +221 -14
  48. package/dist/routes/oauth.js +274 -10
  49. package/dist/schemas/auth.d.ts +2 -0
  50. package/dist/schemas/auth.js +22 -1
  51. package/dist/server.d.ts +6 -0
  52. package/dist/server.js +10 -3
  53. package/dist/services/auth.d.ts +1 -0
  54. package/dist/services/auth.js +21 -5
  55. package/dist/services/mfa.d.ts +47 -0
  56. package/dist/services/mfa.js +276 -9
  57. package/dist/ws/index.js +3 -2
  58. package/docs/sections/auth-flow/full.md +180 -2
  59. package/docs/sections/configuration/full.md +20 -0
  60. package/docs/sections/configuration/overview.md +1 -1
  61. package/docs/sections/configuration-example/full.md +19 -1
  62. package/docs/sections/exports/full.md +11 -2
  63. package/docs/sections/multi-tenancy/full.md +5 -1
  64. package/docs/sections/oauth/full.md +80 -10
  65. package/docs/sections/oauth/overview.md +2 -2
  66. package/docs/sections/peer-dependencies/full.md +6 -2
  67. package/docs/sections/response-caching/full.md +3 -1
  68. package/docs/sections/websocket/full.md +4 -3
  69. package/docs/sections/websocket/overview.md +1 -1
  70. package/package.json +16 -4
@@ -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, or recovery code after password authentication. Requires the mfaToken returned from the login endpoint. Optionally specify 'method' to target a specific verification method.",
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 { mfaToken, code, method } = c.req.valid("json");
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 === "totp") {
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
- valid = await MfaService.verifyTotp(userId, code);
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.req.header("x-forwarded-for")?.split(",")[0]?.trim()) ?? c.req.header("x-real-ip") ?? undefined,
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
  };
@@ -3,14 +3,18 @@ import { createRouter } from "../lib/context";
3
3
  import { setCookie } from "hono/cookie";
4
4
  import { decodeIdToken } from "arctic";
5
5
  import { z } from "zod";
6
- import { getGoogle, getApple, storeOAuthState, consumeOAuthState, generateState, generateCodeVerifier, } from "../lib/oauth";
6
+ import { getGoogle, getApple, getMicrosoft, getGitHub, storeOAuthState, consumeOAuthState, generateState, generateCodeVerifier, } from "../lib/oauth";
7
7
  import { getAuthAdapter } from "../lib/authAdapter";
8
8
  import { HttpError } from "../lib/HttpError";
9
9
  import { signToken } from "../lib/jwt";
10
10
  import { createSession, getActiveSessionCount, evictOldestSession, setRefreshToken } from "../lib/session";
11
+ import { storeOAuthCode, consumeOAuthCode } from "../lib/oauthCode";
11
12
  import { COOKIE_TOKEN, COOKIE_REFRESH_TOKEN } from "../lib/constants";
12
13
  import { userAuth } from "../middleware/userAuth";
13
- import { getDefaultRole, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry } from "../lib/appConfig";
14
+ import { getDefaultRole, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
15
+ import { refreshCsrfToken } from "../middleware/csrf";
16
+ import { trackAttempt } from "../lib/authRateLimit";
17
+ import { getClientIp } from "../lib/clientIp";
14
18
  const isProd = process.env.NODE_ENV === "production";
15
19
  const cookieOptions = (maxAge) => ({
16
20
  httpOnly: true,
@@ -44,27 +48,30 @@ const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect)
44
48
  const rtConfig = getRefreshTokenConfig();
45
49
  const expirySeconds = rtConfig ? getAccessTokenExpiry() : undefined;
46
50
  const token = await signToken(user.id, sessionId, expirySeconds);
47
- const xff = c.req.header("x-forwarded-for");
48
51
  const metadata = {
49
- ipAddress: (xff ? xff.split(",")[0]?.trim() : undefined) ?? c.req.header("x-real-ip") ?? undefined,
52
+ ipAddress: getClientIp(c),
50
53
  userAgent: c.req.header("user-agent") ?? undefined,
51
54
  };
52
55
  while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
53
56
  await evictOldestSession(user.id);
54
57
  }
55
58
  await createSession(user.id, token, sessionId, metadata);
56
- setCookie(c, COOKIE_TOKEN, token, cookieOptions(rtConfig ? getAccessTokenExpiry() : undefined));
57
59
  let refreshTokenValue;
58
60
  if (rtConfig) {
59
61
  refreshTokenValue = crypto.randomUUID();
60
62
  await setRefreshToken(sessionId, refreshTokenValue);
61
- setCookie(c, COOKIE_REFRESH_TOKEN, refreshTokenValue, cookieOptions(getRefreshTokenExpiry()));
62
63
  }
63
- // Append token to redirect so non-browser clients (mobile deep links) can extract it.
64
- // Browser apps can safely ignore the query param.
64
+ // Store a one-time authorization code instead of exposing the token in the redirect URL.
65
+ // The client exchanges this code via POST /auth/oauth/exchange to get the session token.
66
+ const code = await storeOAuthCode({
67
+ token,
68
+ userId: user.id,
69
+ email: profile.email,
70
+ refreshToken: refreshTokenValue,
71
+ });
65
72
  try {
66
73
  const url = new URL(postLoginRedirect);
67
- url.searchParams.set("token", token);
74
+ url.searchParams.set("code", code);
68
75
  if (profile.email)
69
76
  url.searchParams.set("user", profile.email);
70
77
  return c.redirect(url.toString());
@@ -73,7 +80,7 @@ const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect)
73
80
  // Relative path fallback
74
81
  const sep = postLoginRedirect.includes("?") ? "&" : "?";
75
82
  const userParam = profile.email ? `&user=${encodeURIComponent(profile.email)}` : "";
76
- return c.redirect(`${postLoginRedirect}${sep}token=${token}${userParam}`);
83
+ return c.redirect(`${postLoginRedirect}${sep}code=${code}${userParam}`);
77
84
  }
78
85
  };
79
86
  export const createOAuthRouter = (providers, postLoginRedirect) => {
@@ -246,5 +253,262 @@ export const createOAuthRouter = (providers, postLoginRedirect) => {
246
253
  return c.redirect(url.toString());
247
254
  });
248
255
  }
256
+ // ─── Microsoft ──────────────────────────────────────────────────────────
257
+ if (providers.includes("microsoft")) {
258
+ router.openapi(createRoute({
259
+ method: "get",
260
+ path: "/auth/microsoft",
261
+ summary: "Initiate Microsoft OAuth",
262
+ description: "Redirects the user to Microsoft's sign-in page to begin the OAuth login flow. After the user authorizes, Microsoft redirects back to `/auth/microsoft/callback`.",
263
+ tags,
264
+ responses: {
265
+ 302: { description: "Redirect to Microsoft's OAuth sign-in page." },
266
+ 500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "OAuth provider not configured." },
267
+ },
268
+ }), async (c) => {
269
+ const state = generateState();
270
+ const codeVerifier = generateCodeVerifier();
271
+ await storeOAuthState(state, codeVerifier);
272
+ const url = getMicrosoft().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
273
+ return c.redirect(url.toString());
274
+ });
275
+ router.openapi(createRoute({
276
+ method: "get",
277
+ path: "/auth/microsoft/callback",
278
+ summary: "Microsoft OAuth callback",
279
+ description: "Handles the redirect from Microsoft after user authorization. Validates the OAuth state and code, then creates or finds the user account. Sets a session cookie and redirects to the configured post-login URL.",
280
+ tags,
281
+ request: {
282
+ query: z.object({
283
+ code: z.string().describe("Authorization code from Microsoft."),
284
+ state: z.string().describe("OAuth state parameter for CSRF protection."),
285
+ }),
286
+ },
287
+ responses: {
288
+ 302: { description: "Redirect to the post-login URL with session token." },
289
+ 400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid callback parameters or expired state." },
290
+ },
291
+ }), async (c) => {
292
+ const { code, state } = c.req.valid("query");
293
+ if (!code || !state)
294
+ return c.json({ error: "Invalid callback" }, 400);
295
+ const stored = await consumeOAuthState(state);
296
+ if (!stored?.codeVerifier)
297
+ return c.json({ error: "Invalid or expired state" }, 400);
298
+ const tokens = await getMicrosoft().validateAuthorizationCode(code, stored.codeVerifier);
299
+ const info = await fetch("https://graph.microsoft.com/v1.0/me", {
300
+ headers: { Authorization: `Bearer ${tokens.accessToken()}` },
301
+ }).then((r) => r.json());
302
+ if (stored.linkUserId) {
303
+ const adapter = getAuthAdapter();
304
+ if (!adapter.linkProvider)
305
+ return c.json({ error: "Auth adapter does not support linkProvider" }, 500);
306
+ await adapter.linkProvider(stored.linkUserId, "microsoft", info.id);
307
+ const sep = postLoginRedirect.includes("?") ? "&" : "?";
308
+ return c.redirect(`${postLoginRedirect}${sep}linked=microsoft`);
309
+ }
310
+ return finishOAuth(c, "microsoft", info.id, { email: info.mail ?? info.userPrincipalName, name: info.displayName }, postLoginRedirect);
311
+ });
312
+ router.use("/auth/microsoft/link", userAuth);
313
+ router.openapi(withSecurity(createRoute({
314
+ method: "get",
315
+ path: "/auth/microsoft/link",
316
+ summary: "Link Microsoft account",
317
+ description: "Initiates an OAuth flow to link a Microsoft account to the authenticated user. Requires a valid session. Redirects to Microsoft's sign-in page.",
318
+ tags,
319
+ responses: {
320
+ 302: { description: "Redirect to Microsoft's OAuth sign-in page." },
321
+ 401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
322
+ },
323
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
324
+ const state = generateState();
325
+ const codeVerifier = generateCodeVerifier();
326
+ await storeOAuthState(state, codeVerifier, c.get("authUserId"));
327
+ const url = getMicrosoft().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
328
+ return c.redirect(url.toString());
329
+ });
330
+ router.openapi(withSecurity(createRoute({
331
+ method: "delete",
332
+ path: "/auth/microsoft/link",
333
+ summary: "Unlink Microsoft account",
334
+ description: "Removes the linked Microsoft OAuth account from the authenticated user. Requires a valid session.",
335
+ tags,
336
+ responses: {
337
+ 204: { description: "Microsoft account unlinked successfully." },
338
+ 401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
339
+ 500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Auth adapter does not support unlinkProvider." },
340
+ },
341
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
342
+ const adapter = getAuthAdapter();
343
+ if (!adapter.unlinkProvider) {
344
+ return c.json({ error: "Auth adapter does not support unlinkProvider" }, 500);
345
+ }
346
+ await adapter.unlinkProvider(c.get("authUserId"), "microsoft");
347
+ return c.body(null, 204);
348
+ });
349
+ }
350
+ // ─── GitHub ────────────────────────────────────────────────────────────
351
+ if (providers.includes("github")) {
352
+ router.openapi(createRoute({
353
+ method: "get",
354
+ path: "/auth/github",
355
+ summary: "Initiate GitHub OAuth",
356
+ description: "Redirects the user to GitHub's authorization page to begin the OAuth login flow. After the user authorizes, GitHub redirects back to `/auth/github/callback`.",
357
+ tags,
358
+ responses: {
359
+ 302: { description: "Redirect to GitHub's OAuth authorization page." },
360
+ 500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "OAuth provider not configured." },
361
+ },
362
+ }), async (c) => {
363
+ const state = generateState();
364
+ await storeOAuthState(state);
365
+ const url = getGitHub().createAuthorizationURL(state, ["read:user", "user:email"]);
366
+ return c.redirect(url.toString());
367
+ });
368
+ router.openapi(createRoute({
369
+ method: "get",
370
+ path: "/auth/github/callback",
371
+ summary: "GitHub OAuth callback",
372
+ description: "Handles the redirect from GitHub after user authorization. Validates the OAuth state and code, then creates or finds the user account. Sets a session cookie and redirects to the configured post-login URL.",
373
+ tags,
374
+ request: {
375
+ query: z.object({
376
+ code: z.string().describe("Authorization code from GitHub."),
377
+ state: z.string().describe("OAuth state parameter for CSRF protection."),
378
+ }),
379
+ },
380
+ responses: {
381
+ 302: { description: "Redirect to the post-login URL with session token." },
382
+ 400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid callback parameters or expired state." },
383
+ },
384
+ }), async (c) => {
385
+ const { code, state } = c.req.valid("query");
386
+ if (!code || !state)
387
+ return c.json({ error: "Invalid callback" }, 400);
388
+ const stored = await consumeOAuthState(state);
389
+ if (!stored)
390
+ return c.json({ error: "Invalid or expired state" }, 400);
391
+ const tokens = await getGitHub().validateAuthorizationCode(code);
392
+ const headers = { Authorization: `Bearer ${tokens.accessToken()}`, "User-Agent": "bunshot" };
393
+ const info = await fetch("https://api.github.com/user", { headers })
394
+ .then((r) => r.json());
395
+ // GitHub may not return email on /user if it's private — fetch from /user/emails
396
+ let email = info.email;
397
+ if (!email) {
398
+ const emails = await fetch("https://api.github.com/user/emails", { headers })
399
+ .then((r) => r.json());
400
+ email = emails.find((e) => e.primary && e.verified)?.email ?? emails.find((e) => e.verified)?.email;
401
+ }
402
+ if (stored.linkUserId) {
403
+ const adapter = getAuthAdapter();
404
+ if (!adapter.linkProvider)
405
+ return c.json({ error: "Auth adapter does not support linkProvider" }, 500);
406
+ await adapter.linkProvider(stored.linkUserId, "github", String(info.id));
407
+ const sep = postLoginRedirect.includes("?") ? "&" : "?";
408
+ return c.redirect(`${postLoginRedirect}${sep}linked=github`);
409
+ }
410
+ return finishOAuth(c, "github", String(info.id), { email, name: info.name, avatarUrl: info.avatar_url }, postLoginRedirect);
411
+ });
412
+ router.use("/auth/github/link", userAuth);
413
+ router.openapi(withSecurity(createRoute({
414
+ method: "get",
415
+ path: "/auth/github/link",
416
+ summary: "Link GitHub account",
417
+ description: "Initiates an OAuth flow to link a GitHub account to the authenticated user. Requires a valid session. Redirects to GitHub's authorization page.",
418
+ tags,
419
+ responses: {
420
+ 302: { description: "Redirect to GitHub's OAuth authorization page." },
421
+ 401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
422
+ },
423
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
424
+ const state = generateState();
425
+ await storeOAuthState(state, undefined, c.get("authUserId"));
426
+ const url = getGitHub().createAuthorizationURL(state, ["read:user", "user:email"]);
427
+ return c.redirect(url.toString());
428
+ });
429
+ router.openapi(withSecurity(createRoute({
430
+ method: "delete",
431
+ path: "/auth/github/link",
432
+ summary: "Unlink GitHub account",
433
+ description: "Removes the linked GitHub OAuth account from the authenticated user. Requires a valid session.",
434
+ tags,
435
+ responses: {
436
+ 204: { description: "GitHub account unlinked successfully." },
437
+ 401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
438
+ 500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Auth adapter does not support unlinkProvider." },
439
+ },
440
+ }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
441
+ const adapter = getAuthAdapter();
442
+ if (!adapter.unlinkProvider) {
443
+ return c.json({ error: "Auth adapter does not support unlinkProvider" }, 500);
444
+ }
445
+ await adapter.unlinkProvider(c.get("authUserId"), "github");
446
+ return c.body(null, 204);
447
+ });
448
+ }
449
+ // ─── Code Exchange ─────────────────────────────────────────────────────
450
+ router.openapi(createRoute({
451
+ method: "post",
452
+ path: "/auth/oauth/exchange",
453
+ summary: "Exchange OAuth authorization code for session token",
454
+ description: "Exchanges a one-time authorization code (received from the OAuth redirect) for a session token. The code is single-use and expires after 60 seconds. Sets session cookies for browser clients; returns the token in the JSON response for mobile/SPA clients.",
455
+ tags,
456
+ request: {
457
+ body: {
458
+ content: {
459
+ "application/json": {
460
+ schema: z.object({
461
+ code: z.string().describe("One-time authorization code from the OAuth redirect."),
462
+ }),
463
+ },
464
+ },
465
+ },
466
+ },
467
+ responses: {
468
+ 200: {
469
+ content: {
470
+ "application/json": {
471
+ schema: z.object({
472
+ token: z.string().describe("Session JWT."),
473
+ userId: z.string().describe("Authenticated user ID."),
474
+ email: z.string().optional().describe("User email if available."),
475
+ refreshToken: z.string().optional().describe("Refresh token if refresh tokens are configured."),
476
+ }),
477
+ },
478
+ },
479
+ description: "Session token and user info.",
480
+ },
481
+ 400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Missing code parameter." },
482
+ 401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid, expired, or already-used code." },
483
+ 429: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Rate limit exceeded." },
484
+ },
485
+ }), async (c) => {
486
+ // Rate limit by IP to prevent brute-forcing codes within the 60s TTL
487
+ const ip = getClientIp(c);
488
+ const limited = await trackAttempt(`oauth-exchange:ip:${ip}`, { max: 20, windowMs: 60_000 });
489
+ if (limited) {
490
+ return c.json({ error: "Too many requests" }, 429);
491
+ }
492
+ const { code } = c.req.valid("json");
493
+ if (!code)
494
+ return c.json({ error: "Missing code" }, 400);
495
+ const payload = await consumeOAuthCode(code);
496
+ if (!payload)
497
+ return c.json({ error: "Invalid or expired code" }, 401);
498
+ // Set session cookies for browser clients
499
+ const rtConfig = getRefreshTokenConfig();
500
+ setCookie(c, COOKIE_TOKEN, payload.token, cookieOptions(rtConfig ? getAccessTokenExpiry() : undefined));
501
+ if (payload.refreshToken && rtConfig) {
502
+ setCookie(c, COOKIE_REFRESH_TOKEN, payload.refreshToken, cookieOptions(getRefreshTokenExpiry()));
503
+ }
504
+ if (getCsrfEnabled())
505
+ refreshCsrfToken(c);
506
+ return c.json({
507
+ token: payload.token,
508
+ userId: payload.userId,
509
+ email: payload.email,
510
+ refreshToken: payload.refreshToken,
511
+ }, 200);
512
+ });
249
513
  return router;
250
514
  };
@@ -8,3 +8,5 @@ export declare const makeLoginSchema: (primaryField: PrimaryField) => z.ZodObjec
8
8
  [x: string]: z.ZodString;
9
9
  password: z.ZodString;
10
10
  }, z.core.$strip>;
11
+ /** Password schema for reset-password — same policy as registration. */
12
+ export declare const resetPasswordSchema: () => z.ZodString;