@lastshotlabs/bunshot 0.0.25 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/adapters/localStorage.js +20 -5
  2. package/dist/adapters/memoryAuth.d.ts +6 -0
  3. package/dist/adapters/memoryAuth.js +117 -2
  4. package/dist/adapters/mongoAuth.js +97 -1
  5. package/dist/adapters/sqliteAuth.d.ts +23 -0
  6. package/dist/adapters/sqliteAuth.js +153 -2
  7. package/dist/app.d.ts +105 -2
  8. package/dist/app.js +112 -9
  9. package/dist/index.d.ts +23 -4
  10. package/dist/index.js +13 -2
  11. package/dist/lib/HttpError.d.ts +2 -1
  12. package/dist/lib/HttpError.js +3 -1
  13. package/dist/lib/appConfig.d.ts +113 -0
  14. package/dist/lib/appConfig.js +38 -0
  15. package/dist/lib/auditLog.d.ts +6 -0
  16. package/dist/lib/auditLog.js +17 -0
  17. package/dist/lib/authAdapter.d.ts +71 -1
  18. package/dist/lib/authRateLimit.js +36 -0
  19. package/dist/lib/breachedPassword.d.ts +13 -0
  20. package/dist/lib/breachedPassword.js +48 -0
  21. package/dist/lib/captcha.d.ts +25 -0
  22. package/dist/lib/captcha.js +37 -0
  23. package/dist/lib/context.d.ts +5 -0
  24. package/dist/lib/credentialStuffing.d.ts +31 -0
  25. package/dist/lib/credentialStuffing.js +77 -0
  26. package/dist/lib/emailVerification.d.ts +6 -0
  27. package/dist/lib/emailVerification.js +46 -3
  28. package/dist/lib/jwks.d.ts +25 -0
  29. package/dist/lib/jwks.js +51 -0
  30. package/dist/lib/jwt.d.ts +15 -2
  31. package/dist/lib/jwt.js +92 -5
  32. package/dist/lib/logger.d.ts +2 -0
  33. package/dist/lib/logger.js +6 -0
  34. package/dist/lib/m2m.d.ts +29 -0
  35. package/dist/lib/m2m.js +48 -0
  36. package/dist/lib/mfaChallenge.d.ts +14 -1
  37. package/dist/lib/mfaChallenge.js +111 -6
  38. package/dist/lib/mongo.js +1 -1
  39. package/dist/lib/oauthCode.js +23 -18
  40. package/dist/lib/resetPassword.js +3 -1
  41. package/dist/lib/saml.d.ts +25 -0
  42. package/dist/lib/saml.js +64 -0
  43. package/dist/lib/scim.d.ts +44 -0
  44. package/dist/lib/scim.js +54 -0
  45. package/dist/lib/securityEvents.d.ts +28 -0
  46. package/dist/lib/securityEvents.js +26 -0
  47. package/dist/lib/session.d.ts +10 -0
  48. package/dist/lib/session.js +67 -5
  49. package/dist/lib/signing.js +5 -2
  50. package/dist/lib/suspension.d.ts +13 -0
  51. package/dist/lib/suspension.js +23 -0
  52. package/dist/lib/upload.d.ts +4 -0
  53. package/dist/lib/upload.js +26 -1
  54. package/dist/lib/uploadRegistry.d.ts +18 -0
  55. package/dist/lib/uploadRegistry.js +83 -0
  56. package/dist/lib/ws.js +7 -0
  57. package/dist/middleware/bearerAuth.js +1 -1
  58. package/dist/middleware/captcha.d.ts +10 -0
  59. package/dist/middleware/captcha.js +36 -0
  60. package/dist/middleware/csrf.js +8 -4
  61. package/dist/middleware/errorHandler.js +4 -1
  62. package/dist/middleware/identify.js +40 -13
  63. package/dist/middleware/requestSigning.js +6 -5
  64. package/dist/middleware/requireMfaSetup.js +2 -1
  65. package/dist/middleware/requireScope.d.ts +10 -0
  66. package/dist/middleware/requireScope.js +25 -0
  67. package/dist/middleware/requireStepUp.d.ts +18 -0
  68. package/dist/middleware/requireStepUp.js +29 -0
  69. package/dist/middleware/scimAuth.d.ts +8 -0
  70. package/dist/middleware/scimAuth.js +29 -0
  71. package/dist/middleware/webhookAuth.d.ts +1 -1
  72. package/dist/middleware/webhookAuth.js +6 -5
  73. package/dist/models/AuthUser.d.ts +7 -0
  74. package/dist/models/AuthUser.js +7 -0
  75. package/dist/models/M2MClient.d.ts +18 -0
  76. package/dist/models/M2MClient.js +18 -0
  77. package/dist/routes/auth.d.ts +3 -2
  78. package/dist/routes/auth.js +155 -16
  79. package/dist/routes/jobs.js +21 -3
  80. package/dist/routes/m2m.d.ts +2 -0
  81. package/dist/routes/m2m.js +72 -0
  82. package/dist/routes/metrics.d.ts +1 -0
  83. package/dist/routes/metrics.js +3 -0
  84. package/dist/routes/mfa.js +9 -1
  85. package/dist/routes/oauth.js +6 -0
  86. package/dist/routes/oidc.d.ts +2 -0
  87. package/dist/routes/oidc.js +29 -0
  88. package/dist/routes/passkey.d.ts +1 -0
  89. package/dist/routes/passkey.js +157 -0
  90. package/dist/routes/saml.d.ts +2 -0
  91. package/dist/routes/saml.js +86 -0
  92. package/dist/routes/scim.d.ts +2 -0
  93. package/dist/routes/scim.js +255 -0
  94. package/dist/routes/uploads.d.ts +13 -1
  95. package/dist/routes/uploads.js +98 -6
  96. package/dist/services/auth.d.ts +2 -0
  97. package/dist/services/auth.js +101 -22
  98. package/dist/services/mfa.js +2 -2
  99. package/dist/ws/index.js +2 -1
  100. package/docs/sections/auth-flow/full.md +790 -779
  101. package/docs/sections/auth-security-examples/full.md +23 -0
  102. package/docs/sections/metrics/full.md +6 -2
  103. package/docs/sections/passkey-login/full.md +90 -0
  104. package/docs/sections/passkey-login/overview.md +1 -0
  105. package/docs/sections/uploads/full.md +11 -2
  106. package/docs/sections/webhook-auth/full.md +1 -1
  107. package/docs/sections/websocket/full.md +12 -0
  108. package/package.json +3 -2
@@ -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
+ }
@@ -1,2 +1,14 @@
1
1
  import type { PresignedUrlConfig } from "../app";
2
- export declare const createUploadsRouter: (config: PresignedUrlConfig) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
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 {};