@lastshotlabs/bunshot 0.0.21 → 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.
Files changed (185) hide show
  1. package/README.md +3035 -1249
  2. package/dist/adapters/localStorage.d.ts +6 -0
  3. package/dist/adapters/localStorage.js +59 -0
  4. package/dist/adapters/memoryAuth.d.ts +13 -0
  5. package/dist/adapters/memoryAuth.js +261 -2
  6. package/dist/adapters/memoryStorage.d.ts +3 -0
  7. package/dist/adapters/memoryStorage.js +44 -0
  8. package/dist/adapters/mongoAuth.js +217 -1
  9. package/dist/adapters/s3Storage.d.ts +14 -0
  10. package/dist/adapters/s3Storage.js +126 -0
  11. package/dist/adapters/sqliteAuth.d.ts +30 -0
  12. package/dist/adapters/sqliteAuth.js +352 -2
  13. package/dist/app.d.ts +203 -3
  14. package/dist/app.js +352 -48
  15. package/dist/cli.js +118 -38
  16. package/dist/index.d.ts +69 -8
  17. package/dist/index.js +46 -5
  18. package/dist/lib/HttpError.d.ts +7 -1
  19. package/dist/lib/HttpError.js +10 -1
  20. package/dist/lib/appConfig.d.ts +157 -0
  21. package/dist/lib/appConfig.js +54 -0
  22. package/dist/lib/auditLog.d.ts +58 -0
  23. package/dist/lib/auditLog.js +218 -0
  24. package/dist/lib/authAdapter.d.ts +140 -1
  25. package/dist/lib/authRateLimit.js +36 -0
  26. package/dist/lib/breachedPassword.d.ts +13 -0
  27. package/dist/lib/breachedPassword.js +48 -0
  28. package/dist/lib/captcha.d.ts +25 -0
  29. package/dist/lib/captcha.js +37 -0
  30. package/dist/lib/constants.d.ts +4 -0
  31. package/dist/lib/constants.js +4 -0
  32. package/dist/lib/context.d.ts +24 -1
  33. package/dist/lib/context.js +17 -3
  34. package/dist/lib/createRoute.d.ts +28 -2
  35. package/dist/lib/createRoute.js +54 -3
  36. package/dist/lib/credentialStuffing.d.ts +31 -0
  37. package/dist/lib/credentialStuffing.js +77 -0
  38. package/dist/lib/deletionCancelToken.d.ts +12 -0
  39. package/dist/lib/deletionCancelToken.js +88 -0
  40. package/dist/lib/emailVerification.d.ts +6 -0
  41. package/dist/lib/emailVerification.js +46 -3
  42. package/dist/lib/groups.d.ts +113 -0
  43. package/dist/lib/groups.js +133 -0
  44. package/dist/lib/idempotency.d.ts +22 -0
  45. package/dist/lib/idempotency.js +182 -0
  46. package/dist/lib/jwks.d.ts +25 -0
  47. package/dist/lib/jwks.js +51 -0
  48. package/dist/lib/jwt.d.ts +15 -2
  49. package/dist/lib/jwt.js +92 -5
  50. package/dist/lib/logger.d.ts +2 -0
  51. package/dist/lib/logger.js +6 -0
  52. package/dist/lib/m2m.d.ts +29 -0
  53. package/dist/lib/m2m.js +48 -0
  54. package/dist/lib/metrics.d.ts +14 -0
  55. package/dist/lib/metrics.js +158 -0
  56. package/dist/lib/mfaChallenge.d.ts +14 -1
  57. package/dist/lib/mfaChallenge.js +111 -6
  58. package/dist/lib/mongo.js +1 -1
  59. package/dist/lib/oauthCode.js +23 -18
  60. package/dist/lib/pagination.d.ts +119 -0
  61. package/dist/lib/pagination.js +166 -0
  62. package/dist/lib/resetPassword.js +3 -1
  63. package/dist/lib/saml.d.ts +25 -0
  64. package/dist/lib/saml.js +64 -0
  65. package/dist/lib/scim.d.ts +44 -0
  66. package/dist/lib/scim.js +54 -0
  67. package/dist/lib/securityEvents.d.ts +28 -0
  68. package/dist/lib/securityEvents.js +26 -0
  69. package/dist/lib/session.d.ts +14 -0
  70. package/dist/lib/session.js +121 -5
  71. package/dist/lib/signing.d.ts +52 -0
  72. package/dist/lib/signing.js +183 -0
  73. package/dist/lib/storageAdapter.d.ts +30 -0
  74. package/dist/lib/storageAdapter.js +1 -0
  75. package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
  76. package/dist/lib/stripUnreferencedSchemas.js +79 -0
  77. package/dist/lib/suspension.d.ts +13 -0
  78. package/dist/lib/suspension.js +23 -0
  79. package/dist/lib/tenant.js +2 -2
  80. package/dist/lib/upload.d.ts +39 -0
  81. package/dist/lib/upload.js +112 -0
  82. package/dist/lib/uploadRegistry.d.ts +18 -0
  83. package/dist/lib/uploadRegistry.js +83 -0
  84. package/dist/lib/validate.js +2 -2
  85. package/dist/lib/ws.d.ts +1 -0
  86. package/dist/lib/ws.js +28 -0
  87. package/dist/lib/wsHeartbeat.d.ts +12 -0
  88. package/dist/lib/wsHeartbeat.js +57 -0
  89. package/dist/lib/wsMessages.d.ts +40 -0
  90. package/dist/lib/wsMessages.js +330 -0
  91. package/dist/lib/wsPresence.d.ts +25 -0
  92. package/dist/lib/wsPresence.js +99 -0
  93. package/dist/middleware/auditLog.d.ts +22 -0
  94. package/dist/middleware/auditLog.js +39 -0
  95. package/dist/middleware/bearerAuth.js +1 -1
  96. package/dist/middleware/cacheResponse.js +5 -1
  97. package/dist/middleware/captcha.d.ts +10 -0
  98. package/dist/middleware/captcha.js +36 -0
  99. package/dist/middleware/csrf.js +18 -4
  100. package/dist/middleware/errorHandler.js +4 -1
  101. package/dist/middleware/identify.js +89 -14
  102. package/dist/middleware/metrics.d.ts +9 -0
  103. package/dist/middleware/metrics.js +26 -0
  104. package/dist/middleware/requestId.d.ts +3 -0
  105. package/dist/middleware/requestId.js +7 -0
  106. package/dist/middleware/requestLogger.d.ts +38 -0
  107. package/dist/middleware/requestLogger.js +68 -0
  108. package/dist/middleware/requestSigning.d.ts +20 -0
  109. package/dist/middleware/requestSigning.js +100 -0
  110. package/dist/middleware/requireMfaSetup.d.ts +16 -0
  111. package/dist/middleware/requireMfaSetup.js +37 -0
  112. package/dist/middleware/requireRole.d.ts +9 -3
  113. package/dist/middleware/requireRole.js +23 -36
  114. package/dist/middleware/requireScope.d.ts +10 -0
  115. package/dist/middleware/requireScope.js +25 -0
  116. package/dist/middleware/requireStepUp.d.ts +18 -0
  117. package/dist/middleware/requireStepUp.js +29 -0
  118. package/dist/middleware/scimAuth.d.ts +8 -0
  119. package/dist/middleware/scimAuth.js +29 -0
  120. package/dist/middleware/upload.d.ts +5 -0
  121. package/dist/middleware/upload.js +27 -0
  122. package/dist/middleware/webhookAuth.d.ts +30 -0
  123. package/dist/middleware/webhookAuth.js +58 -0
  124. package/dist/models/AuditLog.d.ts +30 -0
  125. package/dist/models/AuditLog.js +39 -0
  126. package/dist/models/AuthUser.d.ts +7 -0
  127. package/dist/models/AuthUser.js +7 -0
  128. package/dist/models/Group.d.ts +21 -0
  129. package/dist/models/Group.js +28 -0
  130. package/dist/models/GroupMembership.d.ts +21 -0
  131. package/dist/models/GroupMembership.js +25 -0
  132. package/dist/models/M2MClient.d.ts +18 -0
  133. package/dist/models/M2MClient.js +18 -0
  134. package/dist/routes/auth.d.ts +3 -2
  135. package/dist/routes/auth.js +238 -21
  136. package/dist/routes/groups.d.ts +21 -0
  137. package/dist/routes/groups.js +346 -0
  138. package/dist/routes/jobs.js +66 -46
  139. package/dist/routes/m2m.d.ts +2 -0
  140. package/dist/routes/m2m.js +72 -0
  141. package/dist/routes/metrics.d.ts +8 -0
  142. package/dist/routes/metrics.js +55 -0
  143. package/dist/routes/mfa.js +13 -1
  144. package/dist/routes/oauth.js +6 -0
  145. package/dist/routes/oidc.d.ts +2 -0
  146. package/dist/routes/oidc.js +29 -0
  147. package/dist/routes/passkey.d.ts +1 -0
  148. package/dist/routes/passkey.js +157 -0
  149. package/dist/routes/saml.d.ts +2 -0
  150. package/dist/routes/saml.js +86 -0
  151. package/dist/routes/scim.d.ts +2 -0
  152. package/dist/routes/scim.js +255 -0
  153. package/dist/routes/uploads.d.ts +14 -0
  154. package/dist/routes/uploads.js +227 -0
  155. package/dist/server.d.ts +26 -0
  156. package/dist/server.js +46 -3
  157. package/dist/services/auth.d.ts +2 -0
  158. package/dist/services/auth.js +101 -22
  159. package/dist/services/mfa.js +2 -2
  160. package/dist/ws/index.js +5 -1
  161. package/docs/sections/auth-flow/full.md +203 -47
  162. package/docs/sections/auth-flow/overview.md +2 -2
  163. package/docs/sections/auth-security-examples/full.md +388 -0
  164. package/docs/sections/authentication/full.md +130 -0
  165. package/docs/sections/authentication/overview.md +5 -0
  166. package/docs/sections/cli/full.md +13 -1
  167. package/docs/sections/configuration/full.md +17 -0
  168. package/docs/sections/configuration/overview.md +1 -0
  169. package/docs/sections/exports/full.md +34 -3
  170. package/docs/sections/logging/full.md +83 -0
  171. package/docs/sections/metrics/full.md +131 -0
  172. package/docs/sections/oauth/full.md +189 -189
  173. package/docs/sections/oauth/overview.md +1 -1
  174. package/docs/sections/pagination/full.md +93 -0
  175. package/docs/sections/passkey-login/full.md +90 -0
  176. package/docs/sections/passkey-login/overview.md +1 -0
  177. package/docs/sections/roles/full.md +224 -135
  178. package/docs/sections/roles/overview.md +3 -1
  179. package/docs/sections/signing/full.md +203 -0
  180. package/docs/sections/uploads/full.md +208 -0
  181. package/docs/sections/versioning/full.md +85 -0
  182. package/docs/sections/webhook-auth/full.md +100 -0
  183. package/docs/sections/websocket/full.md +95 -0
  184. package/docs/sections/websocket-rooms/full.md +6 -1
  185. package/package.json +18 -5
@@ -8,10 +8,12 @@ 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
10
  import { getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getMfaEmailOtpConfig, getMfaWebAuthnConfig, getCsrfEnabled } from "../lib/appConfig";
11
+ import { setMfaVerifiedAt } from "../lib/session";
11
12
  import { refreshCsrfToken } from "../middleware/csrf";
12
13
  import { getAuthAdapter } from "../lib/authAdapter";
13
14
  import { trackAttempt } from "../lib/authRateLimit";
14
15
  import { getClientIp } from "../lib/clientIp";
16
+ import { emitSecurityEvent } from "../lib/securityEvents";
15
17
  const isProd = process.env.NODE_ENV === "production";
16
18
  const cookieOptions = (maxAge) => ({
17
19
  httpOnly: true,
@@ -56,10 +58,14 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
56
58
  description: "TOTP secret generated. Scan the QR code with an authenticator app.",
57
59
  },
58
60
  401: { content: { "application/json": { schema: ErrorResponse } }, description: "No valid session." },
61
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many MFA setup attempts. Try again later." },
59
62
  501: { content: { "application/json": { schema: ErrorResponse } }, description: "Auth adapter does not support MFA." },
60
63
  },
61
64
  }), { cookieAuth: [] }, { userToken: [] }), async (c) => {
62
65
  const userId = c.get("authUserId");
66
+ if (await trackAttempt(`mfa-setup:${userId}`, { windowMs: 15 * 60 * 1000, max: 5 })) {
67
+ return c.json({ error: "Too many MFA setup attempts. Try again later." }, 429);
68
+ }
63
69
  const result = await MfaService.setupMfa(userId);
64
70
  return c.json(result, 200);
65
71
  });
@@ -101,6 +107,7 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
101
107
  const userId = c.get("authUserId");
102
108
  const { code } = c.req.valid("json");
103
109
  const recoveryCodes = await MfaService.verifySetup(userId, code);
110
+ emitSecurityEvent({ eventType: "auth.mfa.setup", severity: "info", timestamp: new Date().toISOString(), userId: c.get("authUserId") ?? undefined });
104
111
  return c.json({ message: "MFA enabled", recoveryCodes }, 200);
105
112
  });
106
113
  // ─── Verify (complete login after password) ───────────────────────────────
@@ -181,13 +188,17 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
181
188
  if (!valid && code) {
182
189
  valid = await MfaService.verifyRecoveryCode(userId, code);
183
190
  }
184
- if (!valid)
191
+ if (!valid) {
192
+ emitSecurityEvent({ eventType: "auth.mfa.verify.failure", severity: "warn", timestamp: new Date().toISOString() });
185
193
  return c.json({ error: "Invalid MFA code" }, 401);
194
+ }
186
195
  // Create session — reuse the service helper for refresh token support
187
196
  const result = await AuthService.createSessionForUser(userId, {
188
197
  ipAddress: getClientIp(c),
189
198
  userAgent: c.req.header("user-agent") ?? undefined,
190
199
  });
200
+ // Mark MFA as verified on the new session so step-up is satisfied immediately
201
+ await setMfaVerifiedAt(result.sessionId);
191
202
  const rtConfig = getRefreshTokenConfig();
192
203
  setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(rtConfig ? getAccessTokenExpiry() : undefined));
193
204
  if (result.refreshToken) {
@@ -195,6 +206,7 @@ export const createMfaRouter = ({ rateLimit } = {}) => {
195
206
  }
196
207
  if (getCsrfEnabled())
197
208
  refreshCsrfToken(c);
209
+ emitSecurityEvent({ eventType: "auth.mfa.verify.success", severity: "info", timestamp: new Date().toISOString() });
198
210
  return c.json({ token: result.token, userId, refreshToken: result.refreshToken }, 200);
199
211
  });
200
212
  // ─── Disable MFA ──────────────────────────────────────────────────────────
@@ -25,6 +25,12 @@ const cookieOptions = (maxAge) => ({
25
25
  });
26
26
  const tags = ["OAuth"];
27
27
  const OAuthErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("OAuthErrorResponse");
28
+ // `postLoginRedirect` is always sourced from server-side config (passed into
29
+ // `createOAuthRouter` at startup) and is never derived from user-supplied input
30
+ // or OAuth callback query parameters. No runtime allowlist validation is required
31
+ // because the value is not attacker-controlled. The `auth.oauth.allowedRedirectUrls`
32
+ // config is available for consuming apps that want to enforce an explicit allowlist
33
+ // at the framework level if they ever pass a dynamic value here.
28
34
  const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect) => {
29
35
  const adapter = getAuthAdapter();
30
36
  if (!adapter.findOrCreateByProvider) {
@@ -0,0 +1,2 @@
1
+ import { Hono } from "hono";
2
+ export declare function createOidcRouter(): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,29 @@
1
+ import { Hono } from "hono";
2
+ import { getJwks } from "../lib/jwks";
3
+ import { getOidcConfig } from "../lib/appConfig";
4
+ export function createOidcRouter() {
5
+ const router = new Hono();
6
+ router.get("/.well-known/openid-configuration", (c) => {
7
+ const config = getOidcConfig();
8
+ if (!config)
9
+ return c.json({ error: "OIDC not configured" }, 404);
10
+ const issuer = config.issuer;
11
+ const tokenEndpoint = config.tokenEndpoint ?? `${issuer}/oauth/token`;
12
+ return c.json({
13
+ issuer,
14
+ authorization_endpoint: `${issuer}/auth/oauth`,
15
+ token_endpoint: tokenEndpoint,
16
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
17
+ response_types_supported: ["code"],
18
+ subject_types_supported: ["public"],
19
+ id_token_signing_alg_values_supported: ["RS256"],
20
+ scopes_supported: config.scopes ?? ["openid"],
21
+ token_endpoint_auth_methods_supported: ["client_secret_post"],
22
+ claims_supported: ["sub", "iss", "aud", "exp", "iat"],
23
+ });
24
+ });
25
+ router.get("/.well-known/jwks.json", (c) => {
26
+ return c.json(getJwks());
27
+ });
28
+ return router;
29
+ }
@@ -0,0 +1 @@
1
+ export declare const createPasskeyRouter: () => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
@@ -0,0 +1,157 @@
1
+ import { createRoute } from "../lib/createRoute";
2
+ import { z } from "zod";
3
+ import { setCookie } from "hono/cookie";
4
+ import { createRouter } from "../lib/context";
5
+ import * as AuthService from "../services/auth";
6
+ import { getAuthAdapter } from "../lib/authAdapter";
7
+ import { getMfaWebAuthnConfig, getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
8
+ import { createPasskeyLoginChallenge } from "../lib/mfaChallenge";
9
+ import { trackAttempt } from "../lib/authRateLimit";
10
+ import { getClientIp } from "../lib/clientIp";
11
+ import { COOKIE_TOKEN, COOKIE_REFRESH_TOKEN } from "../lib/constants";
12
+ import { refreshCsrfToken } from "../middleware/csrf";
13
+ const isProd = process.env.NODE_ENV === "production";
14
+ const cookieOptions = (maxAge) => ({
15
+ httpOnly: true,
16
+ secure: isProd,
17
+ sameSite: "Lax",
18
+ path: "/",
19
+ maxAge: maxAge ?? 60 * 60 * 24 * 7,
20
+ });
21
+ const tags = ["Passkey"];
22
+ const ErrorResponse = z.object({ error: z.string() }).openapi("PasskeyErrorResponse");
23
+ export const createPasskeyRouter = () => {
24
+ const router = createRouter();
25
+ // ─── POST /auth/passkey/login-options ──────────────────────────────────────
26
+ router.openapi(createRoute({
27
+ method: "post",
28
+ path: "/auth/passkey/login-options",
29
+ summary: "Get passkey login options",
30
+ description: "Returns WebAuthn authentication options for passwordless login. Always returns valid-looking options regardless of whether the email exists (enumeration prevention).",
31
+ tags,
32
+ request: {
33
+ body: {
34
+ content: {
35
+ "application/json": {
36
+ schema: z.object({
37
+ email: z.string().optional().describe("Optional email hint. When provided and found, restricts the credential list for a faster prompt. Never reveals whether the email exists."),
38
+ }),
39
+ },
40
+ },
41
+ required: false,
42
+ },
43
+ },
44
+ responses: {
45
+ 200: {
46
+ content: {
47
+ "application/json": {
48
+ schema: z.object({
49
+ options: z.unknown().describe("PublicKeyCredentialRequestOptionsJSON — pass to @simplewebauthn/browser startAuthentication()."),
50
+ passkeyToken: z.string().describe("Short-lived single-use challenge token (120s). Pass to POST /auth/passkey/login."),
51
+ }),
52
+ },
53
+ },
54
+ description: "WebAuthn authentication options.",
55
+ },
56
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Rate limit exceeded." },
57
+ },
58
+ }), async (c) => {
59
+ const ip = getClientIp(c);
60
+ if (await trackAttempt(`passkey-login-options:${ip}`, { windowMs: 60 * 1000, max: 5 })) {
61
+ return c.json({ error: "Too many requests. Try again later." }, 429);
62
+ }
63
+ const webauthnConfig = getMfaWebAuthnConfig();
64
+ const adapter = getAuthAdapter();
65
+ // Resolve credential hints for the email (enumeration-safe: ignore all errors/misses)
66
+ let allowCredentials = [];
67
+ try {
68
+ const body = await c.req.json().catch(() => ({}));
69
+ const email = body?.email;
70
+ if (email && adapter.getWebAuthnCredentials) {
71
+ const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
72
+ const user = await findFn(email);
73
+ if (user) {
74
+ const creds = await adapter.getWebAuthnCredentials(user.id);
75
+ allowCredentials = creds.map((cr) => ({ id: cr.credentialId, transports: cr.transports }));
76
+ }
77
+ }
78
+ }
79
+ catch {
80
+ // Enumeration protection: swallow all errors, proceed with empty credential list
81
+ }
82
+ const { generateAuthenticationOptions } = await import("@simplewebauthn/server");
83
+ const options = await generateAuthenticationOptions({
84
+ rpID: webauthnConfig.rpId,
85
+ allowCredentials: allowCredentials.length > 0
86
+ ? allowCredentials.map((ac) => ({ id: ac.id, transports: ac.transports }))
87
+ : undefined,
88
+ userVerification: webauthnConfig.userVerification ?? "required",
89
+ timeout: webauthnConfig.timeout ?? 60000,
90
+ });
91
+ const passkeyToken = await createPasskeyLoginChallenge(options.challenge);
92
+ return c.json({ options: options, passkeyToken }, 200);
93
+ });
94
+ // ─── POST /auth/passkey/login ──────────────────────────────────────────────
95
+ router.openapi(createRoute({
96
+ method: "post",
97
+ path: "/auth/passkey/login",
98
+ summary: "Complete passkey login",
99
+ description: "Verifies the WebAuthn assertion and returns a session token. Satisfies both factors by default — no MFA prompt unless passkeyMfaBypass is disabled.",
100
+ tags,
101
+ request: {
102
+ body: {
103
+ content: {
104
+ "application/json": {
105
+ schema: z.object({
106
+ passkeyToken: z.string().describe("Token from POST /auth/passkey/login-options."),
107
+ assertionResponse: z.record(z.string(), z.unknown()).describe("AuthenticationResponseJSON from @simplewebauthn/browser startAuthentication()."),
108
+ }),
109
+ },
110
+ },
111
+ required: true,
112
+ },
113
+ },
114
+ responses: {
115
+ 200: {
116
+ content: {
117
+ "application/json": {
118
+ schema: z.object({
119
+ token: z.string(),
120
+ userId: z.string(),
121
+ email: z.string().optional(),
122
+ refreshToken: z.string().optional(),
123
+ mfaRequired: z.boolean().optional(),
124
+ mfaToken: z.string().optional(),
125
+ mfaMethods: z.array(z.string()).optional(),
126
+ }),
127
+ },
128
+ },
129
+ description: "Session token returned. Also set as HttpOnly cookie.",
130
+ },
131
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Authentication failed." },
132
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Rate limit exceeded." },
133
+ },
134
+ }), async (c) => {
135
+ const ip = getClientIp(c);
136
+ if (await trackAttempt(`passkey-login:${ip}`, { windowMs: 15 * 60 * 1000, max: 10 })) {
137
+ return c.json({ error: "Too many requests. Try again later." }, 429);
138
+ }
139
+ const { passkeyToken, assertionResponse } = c.req.valid("json");
140
+ const metadata = {
141
+ ipAddress: ip,
142
+ userAgent: c.req.header("user-agent") ?? undefined,
143
+ };
144
+ const result = await AuthService.passkeyLogin(passkeyToken, assertionResponse, metadata);
145
+ if (!result.mfaRequired) {
146
+ const rtConfig = getRefreshTokenConfig();
147
+ setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(rtConfig ? getAccessTokenExpiry() : undefined));
148
+ if (result.refreshToken) {
149
+ setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
150
+ }
151
+ if (getCsrfEnabled())
152
+ refreshCsrfToken(c);
153
+ }
154
+ return c.json(result);
155
+ });
156
+ return router;
157
+ };
@@ -0,0 +1,2 @@
1
+ import { Hono } from "hono";
2
+ export declare function createSamlRouter(): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,86 @@
1
+ import { Hono } from "hono";
2
+ import { HttpError } from "../lib/HttpError";
3
+ import { getSamlConfig } from "../lib/appConfig";
4
+ import { getAuthAdapter } from "../lib/authAdapter";
5
+ import { storeOAuthState, consumeOAuthState } from "../lib/oauth";
6
+ import { createSessionForUser } from "../services/auth";
7
+ import { setCookie } from "hono/cookie";
8
+ import { COOKIE_TOKEN } from "../lib/constants";
9
+ export function createSamlRouter() {
10
+ const router = new Hono();
11
+ // GET /auth/saml/login — initiate SAML login, redirect to IdP
12
+ router.get("/auth/saml/login", async (c) => {
13
+ const config = getSamlConfig();
14
+ if (!config)
15
+ throw new HttpError(404, "SAML not configured");
16
+ const { initSaml, createAuthnRequest } = await import("../lib/saml");
17
+ await initSaml(config);
18
+ // Store relay state — use codeVerifier slot to carry redirectUrl
19
+ const relayState = crypto.randomUUID();
20
+ const redirectAfter = c.req.query("redirect") ?? config.postLoginRedirect ?? "/";
21
+ await storeOAuthState(relayState, redirectAfter);
22
+ const { redirectUrl } = createAuthnRequest();
23
+ return c.redirect(`${redirectUrl}&RelayState=${encodeURIComponent(relayState)}`);
24
+ });
25
+ // POST /auth/saml/acs — handle SAML assertion from IdP
26
+ router.post("/auth/saml/acs", async (c) => {
27
+ const config = getSamlConfig();
28
+ if (!config)
29
+ throw new HttpError(404, "SAML not configured");
30
+ let formData;
31
+ try {
32
+ formData = await c.req.formData();
33
+ }
34
+ catch {
35
+ throw new HttpError(400, "Invalid SAML response");
36
+ }
37
+ const samlResponse = formData.get("SAMLResponse");
38
+ const relayState = formData.get("RelayState");
39
+ if (!samlResponse)
40
+ throw new HttpError(400, "Missing SAMLResponse");
41
+ const { initSaml, validateSamlResponse, samlProfileToIdentityProfile } = await import("../lib/saml");
42
+ await initSaml(config);
43
+ let samlProfile;
44
+ try {
45
+ samlProfile = await validateSamlResponse(samlResponse, config);
46
+ }
47
+ catch (err) {
48
+ throw new HttpError(401, "Invalid SAML assertion");
49
+ }
50
+ let userId;
51
+ if (config.onLogin) {
52
+ const result = await config.onLogin(samlProfile);
53
+ userId = result.userId;
54
+ }
55
+ else {
56
+ const adapter = getAuthAdapter();
57
+ if (!adapter.findOrCreateByProvider)
58
+ throw new HttpError(500, "Auth adapter missing findOrCreateByProvider");
59
+ const profile = samlProfileToIdentityProfile(samlProfile);
60
+ const result = await adapter.findOrCreateByProvider("saml", samlProfile.nameId, profile);
61
+ userId = result.id;
62
+ // Update profile fields from SAML attributes
63
+ if (adapter.updateProfile && (profile.firstName || profile.lastName || profile.displayName)) {
64
+ await adapter.updateProfile(userId, profile).catch(() => { });
65
+ }
66
+ }
67
+ const { token } = await createSessionForUser(userId);
68
+ // consumeOAuthState returns { codeVerifier?, linkUserId? } — redirectUrl was stored in codeVerifier
69
+ const redirectUrl = relayState
70
+ ? (await consumeOAuthState(relayState))?.codeVerifier ?? config.postLoginRedirect ?? "/"
71
+ : config.postLoginRedirect ?? "/";
72
+ setCookie(c, COOKIE_TOKEN, token, { httpOnly: true, path: "/", sameSite: "Lax" });
73
+ return c.redirect(redirectUrl);
74
+ });
75
+ // GET /auth/saml/metadata — serve SP metadata XML
76
+ router.get("/auth/saml/metadata", async (c) => {
77
+ const config = getSamlConfig();
78
+ if (!config)
79
+ throw new HttpError(404, "SAML not configured");
80
+ const { initSaml, getSamlSpMetadata } = await import("../lib/saml");
81
+ await initSaml(config);
82
+ const metadata = getSamlSpMetadata();
83
+ return c.body(metadata, 200, { "Content-Type": "application/xml" });
84
+ });
85
+ return router;
86
+ }
@@ -0,0 +1,2 @@
1
+ import { Hono } from "hono";
2
+ export declare function createScimRouter(): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,255 @@
1
+ import { Hono } from "hono";
2
+ import { getAuthAdapter } from "../lib/authAdapter";
3
+ import { scimAuth } from "../middleware/scimAuth";
4
+ import { userRecordToScim, parseScimFilter, scimError } from "../lib/scim";
5
+ import { getScimConfig } from "../lib/appConfig";
6
+ export function createScimRouter() {
7
+ const router = new Hono();
8
+ // All SCIM routes require SCIM bearer auth
9
+ router.use("/scim/v2/*", scimAuth);
10
+ // GET /scim/v2/Users — list/search users
11
+ router.get("/scim/v2/Users", async (c) => {
12
+ const config = getScimConfig();
13
+ if (!config)
14
+ return scimError(404, "SCIM not configured");
15
+ const adapter = getAuthAdapter();
16
+ if (!adapter.listUsers)
17
+ return scimError(501, "Auth adapter does not support listUsers");
18
+ const filter = c.req.query("filter");
19
+ const startIndex = parseInt(c.req.query("startIndex") ?? "1", 10);
20
+ const count = parseInt(c.req.query("count") ?? "100", 10);
21
+ const query = parseScimFilter(filter);
22
+ query.startIndex = Math.max(0, startIndex - 1); // SCIM is 1-based
23
+ query.count = Math.min(count, 200);
24
+ const { users, totalResults } = await adapter.listUsers(query);
25
+ const response = {
26
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
27
+ totalResults,
28
+ startIndex,
29
+ itemsPerPage: users.length,
30
+ Resources: users.map((u) => userRecordToScim(u, config.userMapping)),
31
+ };
32
+ return c.json(response, 200);
33
+ });
34
+ // GET /scim/v2/Users/:id — get a user
35
+ router.get("/scim/v2/Users/:id", async (c) => {
36
+ const config = getScimConfig();
37
+ if (!config)
38
+ return scimError(404, "SCIM not configured");
39
+ const adapter = getAuthAdapter();
40
+ if (!adapter.getUser)
41
+ return scimError(501, "Auth adapter does not support getUser");
42
+ const user = await adapter.getUser(c.req.param("id"));
43
+ if (!user)
44
+ return scimError(404, "User not found");
45
+ const scimUser = userRecordToScim({
46
+ id: c.req.param("id"),
47
+ email: user.email,
48
+ displayName: user.displayName,
49
+ firstName: user.firstName,
50
+ lastName: user.lastName,
51
+ externalId: user.externalId,
52
+ suspended: user.suspended ?? false,
53
+ suspendedReason: user.suspendedReason,
54
+ }, config.userMapping);
55
+ return c.json(scimUser, 200);
56
+ });
57
+ // POST /scim/v2/Users — create a user (provision)
58
+ router.post("/scim/v2/Users", async (c) => {
59
+ const config = getScimConfig();
60
+ if (!config)
61
+ return scimError(404, "SCIM not configured");
62
+ const adapter = getAuthAdapter();
63
+ if (!adapter.create)
64
+ return scimError(501, "Auth adapter does not support create");
65
+ let body;
66
+ try {
67
+ body = await c.req.json();
68
+ }
69
+ catch {
70
+ return scimError(400, "Invalid JSON");
71
+ }
72
+ const email = body.userName ?? body.emails?.[0]?.value;
73
+ if (!email)
74
+ return scimError(400, "userName is required");
75
+ const existingByEmail = await (adapter.findByEmail?.(email));
76
+ if (existingByEmail)
77
+ return scimError(409, "User already exists");
78
+ // Create user with a random placeholder password (SCIM users authenticate via SSO)
79
+ const { sha256 } = await import("../lib/crypto");
80
+ const placeholderHash = sha256(crypto.randomUUID());
81
+ const { id } = await adapter.create(email, placeholderHash);
82
+ // Set profile fields
83
+ if (adapter.updateProfile) {
84
+ const fields = {};
85
+ if (body.name?.givenName)
86
+ fields.firstName = body.name.givenName;
87
+ if (body.name?.familyName)
88
+ fields.lastName = body.name.familyName;
89
+ if (body.displayName)
90
+ fields.displayName = body.displayName;
91
+ if (body.externalId)
92
+ fields.externalId = body.externalId;
93
+ if (Object.keys(fields).length > 0)
94
+ await adapter.updateProfile(id, fields);
95
+ }
96
+ const scimUser = userRecordToScim({
97
+ id,
98
+ email,
99
+ displayName: body.displayName,
100
+ firstName: body.name?.givenName,
101
+ lastName: body.name?.familyName,
102
+ externalId: body.externalId,
103
+ suspended: body.active === false,
104
+ }, config.userMapping);
105
+ return c.json(scimUser, 201);
106
+ });
107
+ // PUT /scim/v2/Users/:id — replace a user
108
+ router.put("/scim/v2/Users/:id", async (c) => {
109
+ const config = getScimConfig();
110
+ if (!config)
111
+ return scimError(404, "SCIM not configured");
112
+ const adapter = getAuthAdapter();
113
+ const userId = c.req.param("id");
114
+ let body;
115
+ try {
116
+ body = await c.req.json();
117
+ }
118
+ catch {
119
+ return scimError(400, "Invalid JSON");
120
+ }
121
+ if (adapter.updateProfile) {
122
+ const fields = {};
123
+ if (body.name?.givenName !== undefined)
124
+ fields.firstName = body.name.givenName;
125
+ if (body.name?.familyName !== undefined)
126
+ fields.lastName = body.name.familyName;
127
+ if (body.displayName !== undefined)
128
+ fields.displayName = body.displayName;
129
+ if (body.externalId !== undefined)
130
+ fields.externalId = body.externalId;
131
+ if (Object.keys(fields).length > 0)
132
+ await adapter.updateProfile(userId, fields);
133
+ }
134
+ if (adapter.setSuspended && body.active !== undefined) {
135
+ await adapter.setSuspended(userId, !body.active);
136
+ }
137
+ const user = await adapter.getUser?.(userId);
138
+ if (!user)
139
+ return scimError(404, "User not found");
140
+ return c.json(userRecordToScim({
141
+ id: userId,
142
+ email: user.email,
143
+ displayName: user.displayName,
144
+ firstName: user.firstName,
145
+ lastName: user.lastName,
146
+ externalId: user.externalId,
147
+ suspended: user.suspended ?? false,
148
+ }, config.userMapping), 200);
149
+ });
150
+ // PATCH /scim/v2/Users/:id — partial update
151
+ router.patch("/scim/v2/Users/:id", async (c) => {
152
+ const config = getScimConfig();
153
+ if (!config)
154
+ return scimError(404, "SCIM not configured");
155
+ const adapter = getAuthAdapter();
156
+ const userId = c.req.param("id");
157
+ let body;
158
+ try {
159
+ body = await c.req.json();
160
+ }
161
+ catch {
162
+ return scimError(400, "Invalid JSON");
163
+ }
164
+ const operations = body.Operations ?? [];
165
+ for (const op of operations) {
166
+ const opType = op.op?.toLowerCase();
167
+ if (opType === "replace" || opType === "add") {
168
+ const value = op.value;
169
+ if (op.path === "active" && adapter.setSuspended) {
170
+ await adapter.setSuspended(userId, !value);
171
+ }
172
+ else if (!op.path && typeof value === "object" && adapter.updateProfile) {
173
+ // Bulk replace — map SCIM fields to profile fields
174
+ const fields = {};
175
+ if (value.displayName !== undefined)
176
+ fields.displayName = value.displayName;
177
+ if (value["name.givenName"] !== undefined)
178
+ fields.firstName = value["name.givenName"];
179
+ if (value["name.familyName"] !== undefined)
180
+ fields.lastName = value["name.familyName"];
181
+ if (value.externalId !== undefined)
182
+ fields.externalId = value.externalId;
183
+ if (value.active !== undefined && adapter.setSuspended) {
184
+ await adapter.setSuspended(userId, !value.active);
185
+ }
186
+ if (Object.keys(fields).length > 0)
187
+ await adapter.updateProfile(userId, fields);
188
+ }
189
+ }
190
+ else if (opType === "remove" && op.path === "active" && adapter.setSuspended) {
191
+ await adapter.setSuspended(userId, true);
192
+ }
193
+ }
194
+ const user = await adapter.getUser?.(userId);
195
+ if (!user)
196
+ return scimError(404, "User not found");
197
+ return c.json(userRecordToScim({
198
+ id: userId,
199
+ email: user.email,
200
+ displayName: user.displayName,
201
+ firstName: user.firstName,
202
+ lastName: user.lastName,
203
+ externalId: user.externalId,
204
+ suspended: user.suspended ?? false,
205
+ }, config.userMapping), 200);
206
+ });
207
+ // DELETE /scim/v2/Users/:id — deprovision
208
+ router.delete("/scim/v2/Users/:id", async (c) => {
209
+ const config = getScimConfig();
210
+ if (!config)
211
+ return scimError(404, "SCIM not configured");
212
+ const adapter = getAuthAdapter();
213
+ const userId = c.req.param("id");
214
+ const onDeprovision = config.onDeprovision ?? "suspend";
215
+ if (typeof onDeprovision === "function") {
216
+ await onDeprovision(userId);
217
+ }
218
+ else if (onDeprovision === "delete") {
219
+ if (adapter.deleteUser)
220
+ await adapter.deleteUser(userId);
221
+ }
222
+ else {
223
+ // Default: suspend
224
+ if (adapter.setSuspended)
225
+ await adapter.setSuspended(userId, true, "SCIM deprovisioned");
226
+ }
227
+ return c.body(null, 204);
228
+ });
229
+ // Discovery endpoints
230
+ router.get("/scim/v2/ServiceProviderConfig", (c) => {
231
+ return c.json({
232
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
233
+ patch: { supported: true },
234
+ bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
235
+ filter: { supported: true, maxResults: 200 },
236
+ changePassword: { supported: false },
237
+ sort: { supported: false },
238
+ etag: { supported: false },
239
+ });
240
+ });
241
+ router.get("/scim/v2/ResourceTypes", (c) => {
242
+ return c.json({
243
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
244
+ totalResults: 1,
245
+ Resources: [{
246
+ schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
247
+ id: "User",
248
+ name: "User",
249
+ endpoint: "/scim/v2/Users",
250
+ schema: "urn:ietf:params:scim:schemas:core:2.0:User",
251
+ }],
252
+ });
253
+ });
254
+ return router;
255
+ }
@@ -0,0 +1,14 @@
1
+ import type { PresignedUrlConfig } from "../app";
2
+ interface UploadsRouterConfig extends PresignedUrlConfig {
3
+ authorization?: {
4
+ authorize?: (input: {
5
+ action: "read" | "delete";
6
+ key: string;
7
+ userId?: string;
8
+ tenantId?: string;
9
+ }) => boolean | Promise<boolean>;
10
+ };
11
+ allowExternalKeys?: boolean;
12
+ }
13
+ export declare const createUploadsRouter: (config: UploadsRouterConfig) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
14
+ export {};