@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.
- package/dist/adapters/localStorage.js +20 -5
- package/dist/adapters/memoryAuth.d.ts +6 -0
- package/dist/adapters/memoryAuth.js +117 -2
- package/dist/adapters/mongoAuth.js +97 -1
- package/dist/adapters/sqliteAuth.d.ts +23 -0
- package/dist/adapters/sqliteAuth.js +153 -2
- package/dist/app.d.ts +105 -2
- package/dist/app.js +112 -9
- package/dist/index.d.ts +23 -4
- package/dist/index.js +13 -2
- package/dist/lib/HttpError.d.ts +2 -1
- package/dist/lib/HttpError.js +3 -1
- package/dist/lib/appConfig.d.ts +113 -0
- package/dist/lib/appConfig.js +38 -0
- package/dist/lib/auditLog.d.ts +6 -0
- package/dist/lib/auditLog.js +17 -0
- package/dist/lib/authAdapter.d.ts +71 -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/context.d.ts +5 -0
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- 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/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/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 +10 -0
- package/dist/lib/session.js +67 -5
- package/dist/lib/signing.js +5 -2
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/upload.d.ts +4 -0
- package/dist/lib/upload.js +26 -1
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/ws.js +7 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +8 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +40 -13
- package/dist/middleware/requestSigning.js +6 -5
- package/dist/middleware/requireMfaSetup.js +2 -1
- 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/webhookAuth.d.ts +1 -1
- package/dist/middleware/webhookAuth.js +6 -5
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -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 +155 -16
- package/dist/routes/jobs.js +21 -3
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +1 -0
- package/dist/routes/metrics.js +3 -0
- package/dist/routes/mfa.js +9 -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 +13 -1
- package/dist/routes/uploads.js +98 -6
- 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 +2 -1
- package/docs/sections/auth-flow/full.md +790 -779
- package/docs/sections/auth-security-examples/full.md +23 -0
- package/docs/sections/metrics/full.md +6 -2
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/uploads/full.md +11 -2
- package/docs/sections/webhook-auth/full.md +1 -1
- package/docs/sections/websocket/full.md +12 -0
- 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,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
|
+
}
|
package/dist/routes/uploads.d.ts
CHANGED
|
@@ -1,2 +1,14 @@
|
|
|
1
1
|
import type { PresignedUrlConfig } from "../app";
|
|
2
|
-
|
|
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 {};
|