@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.
- package/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +59 -0
- package/dist/adapters/memoryAuth.d.ts +13 -0
- package/dist/adapters/memoryAuth.js +261 -2
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +217 -1
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +30 -0
- package/dist/adapters/sqliteAuth.js +352 -2
- package/dist/app.d.ts +203 -3
- package/dist/app.js +352 -48
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +69 -8
- package/dist/index.js +46 -5
- package/dist/lib/HttpError.d.ts +7 -1
- package/dist/lib/HttpError.js +10 -1
- package/dist/lib/appConfig.d.ts +157 -0
- package/dist/lib/appConfig.js +54 -0
- package/dist/lib/auditLog.d.ts +58 -0
- package/dist/lib/auditLog.js +218 -0
- package/dist/lib/authAdapter.d.ts +140 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +24 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- package/dist/lib/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +14 -0
- package/dist/lib/session.js +121 -5
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +183 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +39 -0
- package/dist/lib/upload.js +112 -0
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +28 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +18 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +89 -14
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +100 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +37 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- package/dist/middleware/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +58 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -0
- package/dist/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +238 -21
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +66 -46
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +8 -0
- package/dist/routes/metrics.js +55 -0
- package/dist/routes/mfa.js +13 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +14 -0
- package/dist/routes/uploads.js +227 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +5 -1
- package/docs/sections/auth-flow/full.md +203 -47
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +388 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +131 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +208 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +95 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +18 -5
package/dist/routes/mfa.js
CHANGED
|
@@ -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 ──────────────────────────────────────────────────────────
|
package/dist/routes/oauth.js
CHANGED
|
@@ -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,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,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,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 {};
|