Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

scim.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

scim.tsxBlame479 lines · 1 contributor
3a845e4Claude1/**
2 * SCIM 2.0 — System for Cross-domain Identity Management.
3 *
4 * Identity providers (Okta, Azure AD, Google Workspace) use SCIM to
5 * automatically provision and deprovision users from Gluecron orgs.
6 *
7 * Endpoints:
8 * GET /scim/v2/:orgId/Users — list users in the org
9 * POST /scim/v2/:orgId/Users — provision (create) a user
10 * GET /scim/v2/:orgId/Users/:userId — get user details
11 * PUT /scim/v2/:orgId/Users/:userId — replace user
12 * PATCH /scim/v2/:orgId/Users/:userId — partial update (deactivate, etc.)
13 * DELETE /scim/v2/:orgId/Users/:userId — deprovision (disable, keep data)
14 *
15 * Auth: Bearer token → validated against scim_tokens.token_hash (SHA-256).
16 *
17 * All responses follow RFC 7643 (SCIM Core Schema) and RFC 7644 (SCIM Protocol).
18 */
19
20import { Hono } from "hono";
21import { eq, and } from "drizzle-orm";
22import * as crypto from "crypto";
23import { db } from "../db";
24import {
25 scimTokens,
26 orgMembers,
27 organizations,
28 users,
29} from "../db/schema";
30import type { AuthEnv } from "../middleware/auth";
31
32const scim = new Hono<AuthEnv>();
33
34// ---------------------------------------------------------------------------
35// SCIM schemas / constants
36// ---------------------------------------------------------------------------
37
38const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
39const SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
40const SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
41
42// ---------------------------------------------------------------------------
43// Auth middleware
44// ---------------------------------------------------------------------------
45
46async function scimAuth(
47 c: any,
48 orgId: string
49): Promise<{ ok: true; token: typeof scimTokens.$inferSelect } | { ok: false }> {
50 const authHeader = c.req.header("authorization") || "";
51 if (!authHeader.startsWith("Bearer ")) return { ok: false };
52 const rawToken = authHeader.slice(7);
53 const tokenHash = crypto
54 .createHash("sha256")
55 .update(rawToken)
56 .digest("hex");
57
58 const [token] = await db
59 .select()
60 .from(scimTokens)
61 .where(
62 and(
63 eq(scimTokens.tokenHash, tokenHash),
64 eq(scimTokens.orgId, orgId)
65 )
66 )
67 .limit(1);
68
69 if (!token) return { ok: false };
70
71 // Update last_used_at lazily (fire-and-forget)
72 db.update(scimTokens)
73 .set({ lastUsedAt: new Date() })
74 .where(eq(scimTokens.id, token.id))
75 .catch(() => {});
76
77 return { ok: true, token };
78}
79
80function scimError(c: any, status: number, detail: string, scimType?: string) {
81 return c.json(
82 {
83 schemas: [SCIM_ERROR_SCHEMA],
84 status,
85 ...(scimType ? { scimType } : {}),
86 detail,
87 },
88 status
89 );
90}
91
92/** Convert a Drizzle user row to a SCIM User resource. */
93function toScimUser(user: {
94 id: string;
95 username: string;
96 email: string;
97 displayName: string | null;
98 createdAt: Date;
99 updatedAt: Date;
100 deletedAt: Date | null;
101}) {
102 const [firstName, ...rest] = (user.displayName || user.username).split(" ");
103 const lastName = rest.join(" ") || "";
104 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
105 return {
106 schemas: [SCIM_USER_SCHEMA],
107 id: user.id,
108 userName: user.email,
109 name: {
110 formatted: user.displayName || user.username,
111 givenName: firstName,
112 familyName: lastName,
113 },
114 emails: [{ value: user.email, primary: true }],
115 active: !user.deletedAt,
116 meta: {
117 resourceType: "User",
118 created: user.createdAt.toISOString(),
119 lastModified: user.updatedAt.toISOString(),
120 location: `${base}/scim/v2/${user.id}`,
121 },
122 };
123}
124
125// ---------------------------------------------------------------------------
126// GET /scim/v2/:orgId/Users — list users in the org
127// ---------------------------------------------------------------------------
128
129scim.get("/scim/v2/:orgId/Users", async (c) => {
130 const { orgId } = c.req.param();
131 const auth = await scimAuth(c, orgId);
132 if (!auth.ok) return scimError(c, 401, "Unauthorized");
133
134 // Verify org exists
135 const [org] = await db
136 .select({ id: organizations.id })
137 .from(organizations)
138 .where(eq(organizations.id, orgId))
139 .limit(1);
140 if (!org) return scimError(c, 404, "Organization not found");
141
142 // Pagination params
143 const startIndex = Math.max(1, parseInt(c.req.query("startIndex") || "1", 10));
144 const count = Math.min(100, Math.max(1, parseInt(c.req.query("count") || "100", 10)));
145 const offset = startIndex - 1;
146
147 // Get org members and their user data
148 const members = await db
149 .select({
150 id: users.id,
151 username: users.username,
152 email: users.email,
153 displayName: users.displayName,
154 createdAt: users.createdAt,
155 updatedAt: users.updatedAt,
156 deletedAt: users.deletedAt,
157 })
158 .from(orgMembers)
159 .innerJoin(users, eq(users.id, orgMembers.userId))
160 .where(eq(orgMembers.orgId, orgId))
161 .limit(count)
162 .offset(offset);
163
164 const resources = members.map(toScimUser);
165
166 return c.json({
167 schemas: [SCIM_LIST_SCHEMA],
168 totalResults: resources.length + offset, // approximate
169 startIndex,
170 itemsPerPage: count,
171 Resources: resources,
172 });
173});
174
175// ---------------------------------------------------------------------------
176// POST /scim/v2/:orgId/Users — provision a user
177// ---------------------------------------------------------------------------
178
179scim.post("/scim/v2/:orgId/Users", async (c) => {
180 const { orgId } = c.req.param();
181 const auth = await scimAuth(c, orgId);
182 if (!auth.ok) return scimError(c, 401, "Unauthorized");
183
184 const [org] = await db
185 .select({ id: organizations.id })
186 .from(organizations)
187 .where(eq(organizations.id, orgId))
188 .limit(1);
189 if (!org) return scimError(c, 404, "Organization not found");
190
191 let body: {
192 userName?: string;
193 name?: { formatted?: string; givenName?: string; familyName?: string };
194 emails?: Array<{ value: string; primary?: boolean }>;
195 active?: boolean;
196 displayName?: string;
197 };
198 try {
199 body = await c.req.json();
200 } catch {
201 return scimError(c, 400, "Invalid JSON", "invalidValue");
202 }
203
204 const email =
205 body.emails?.find((e) => e.primary)?.value ||
206 body.emails?.[0]?.value ||
207 body.userName ||
208 "";
209 if (!email || !email.includes("@")) {
210 return scimError(c, 400, "email is required", "invalidValue");
211 }
212
213 // Check if user already exists
214 const [existing] = await db
215 .select({ id: users.id })
216 .from(users)
217 .where(eq(users.email, email))
218 .limit(1);
219
220 let userId: string;
221
222 if (existing) {
223 userId = existing.id;
224 } else {
225 // Auto-derive a username
226 let username = email.split("@")[0].toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, 39);
227 const [taken] = await db
228 .select({ id: users.id })
229 .from(users)
230 .where(eq(users.username, username))
231 .limit(1);
232 if (taken) username = username + "-" + crypto.randomBytes(3).toString("hex");
233
234 const displayName =
235 body.displayName ||
236 body.name?.formatted ||
237 [body.name?.givenName, body.name?.familyName].filter(Boolean).join(" ") ||
238 username;
239
240 const [created] = await db
241 .insert(users)
242 .values({
243 username,
244 email,
245 displayName,
246 passwordHash: await Bun.password.hash(crypto.randomBytes(32).toString("hex"), {
247 algorithm: "bcrypt",
248 cost: 10,
249 }),
250 emailVerifiedAt: new Date(),
251 })
252 .returning({ id: users.id });
253
254 if (!created) return scimError(c, 500, "Failed to create user");
255 userId = created.id;
256 }
257
258 // Add to org if not already a member
259 const [isMember] = await db
260 .select({ id: orgMembers.id })
261 .from(orgMembers)
262 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
263 .limit(1);
264
265 if (!isMember) {
266 await db.insert(orgMembers).values({
267 orgId,
268 userId,
269 role: "member",
270 });
271 }
272
273 const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
274 return c.json(toScimUser(userRow), 201);
275});
276
277// ---------------------------------------------------------------------------
278// GET /scim/v2/:orgId/Users/:userId — get a single user
279// ---------------------------------------------------------------------------
280
281scim.get("/scim/v2/:orgId/Users/:userId", async (c) => {
282 const { orgId, userId } = c.req.param();
283 const auth = await scimAuth(c, orgId);
284 if (!auth.ok) return scimError(c, 401, "Unauthorized");
285
286 const [member] = await db
287 .select({
288 id: users.id,
289 username: users.username,
290 email: users.email,
291 displayName: users.displayName,
292 createdAt: users.createdAt,
293 updatedAt: users.updatedAt,
294 deletedAt: users.deletedAt,
295 })
296 .from(orgMembers)
297 .innerJoin(users, eq(users.id, orgMembers.userId))
298 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
299 .limit(1);
300
301 if (!member) return scimError(c, 404, "User not found");
302
303 return c.json(toScimUser(member));
304});
305
306// ---------------------------------------------------------------------------
307// PUT /scim/v2/:orgId/Users/:userId — replace a user
308// ---------------------------------------------------------------------------
309
310scim.put("/scim/v2/:orgId/Users/:userId", async (c) => {
311 const { orgId, userId } = c.req.param();
312 const auth = await scimAuth(c, orgId);
313 if (!auth.ok) return scimError(c, 401, "Unauthorized");
314
315 const [member] = await db
316 .select({ id: orgMembers.id })
317 .from(orgMembers)
318 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
319 .limit(1);
320 if (!member) return scimError(c, 404, "User not found in this organization");
321
322 let body: {
323 displayName?: string;
324 name?: { formatted?: string; givenName?: string; familyName?: string };
325 active?: boolean;
326 };
327 try {
328 body = await c.req.json();
329 } catch {
330 return scimError(c, 400, "Invalid JSON", "invalidValue");
331 }
332
333 const displayName =
334 body.displayName ||
335 body.name?.formatted ||
336 [body.name?.givenName, body.name?.familyName].filter(Boolean).join(" ") ||
337 undefined;
338
339 const updates: Record<string, unknown> = { updatedAt: new Date() };
340 if (displayName) updates.displayName = displayName;
341 if (body.active === false) {
342 updates.deletedAt = new Date();
343 updates.deletionScheduledFor = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
344 } else if (body.active === true) {
345 updates.deletedAt = null;
346 updates.deletionScheduledFor = null;
347 }
348
349 await db.update(users).set(updates).where(eq(users.id, userId));
350
351 const [updated] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
352 return c.json(toScimUser(updated));
353});
354
355// ---------------------------------------------------------------------------
356// PATCH /scim/v2/:orgId/Users/:userId — partial update
357// ---------------------------------------------------------------------------
358
359scim.patch("/scim/v2/:orgId/Users/:userId", async (c) => {
360 const { orgId, userId } = c.req.param();
361 const auth = await scimAuth(c, orgId);
362 if (!auth.ok) return scimError(c, 401, "Unauthorized");
363
364 const [member] = await db
365 .select({ id: orgMembers.id })
366 .from(orgMembers)
367 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
368 .limit(1);
369 if (!member) return scimError(c, 404, "User not found in this organization");
370
371 let body: {
372 Operations?: Array<{
373 op: string;
374 path?: string;
375 value?: unknown;
376 }>;
377 };
378 try {
379 body = await c.req.json();
380 } catch {
381 return scimError(c, 400, "Invalid JSON", "invalidValue");
382 }
383
384 const updates: Record<string, unknown> = { updatedAt: new Date() };
385
386 for (const op of body.Operations || []) {
387 const opLower = (op.op || "").toLowerCase();
388 if (op.path === "active" || (typeof op.value === "object" && op.value !== null && "active" in (op.value as object))) {
389 const activeVal =
390 op.path === "active"
391 ? op.value
392 : (op.value as Record<string, unknown>)["active"];
393 if (opLower === "replace" || opLower === "add") {
394 if (activeVal === false || activeVal === "false") {
395 updates.deletedAt = new Date();
396 updates.deletionScheduledFor = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
397 } else {
398 updates.deletedAt = null;
399 updates.deletionScheduledFor = null;
400 }
401 }
402 }
403 if (op.path === "displayName" && (opLower === "replace" || opLower === "add")) {
404 updates.displayName = String(op.value || "");
405 }
406 }
407
408 await db.update(users).set(updates).where(eq(users.id, userId));
409
410 const [updated] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
411 return c.json(toScimUser(updated));
412});
413
414// ---------------------------------------------------------------------------
415// DELETE /scim/v2/:orgId/Users/:userId — deprovision (soft-delete)
416// ---------------------------------------------------------------------------
417
418scim.delete("/scim/v2/:orgId/Users/:userId", async (c) => {
419 const { orgId, userId } = c.req.param();
420 const auth = await scimAuth(c, orgId);
421 if (!auth.ok) return scimError(c, 401, "Unauthorized");
422
423 const [member] = await db
424 .select({ id: orgMembers.id })
425 .from(orgMembers)
426 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
427 .limit(1);
428 if (!member) return scimError(c, 404, "User not found in this organization");
429
430 // Remove from org membership (preserves user account + git history)
431 await db
432 .delete(orgMembers)
433 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)));
434
435 // Soft-disable the account
436 await db.update(users).set({
437 deletedAt: new Date(),
438 deletionScheduledFor: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
439 updatedAt: new Date(),
440 }).where(eq(users.id, userId));
441
442 return c.body(null, 204);
443});
444
445// ---------------------------------------------------------------------------
446// GET /scim/v2/:orgId/ServiceProviderConfig — SCIM capability discovery
447// ---------------------------------------------------------------------------
448
449scim.get("/scim/v2/:orgId/ServiceProviderConfig", async (c) => {
450 const { orgId } = c.req.param();
451 const auth = await scimAuth(c, orgId);
452 if (!auth.ok) return scimError(c, 401, "Unauthorized");
453
454 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
455 return c.json({
456 schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
457 patch: { supported: true },
458 bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
459 filter: { supported: false, maxResults: 100 },
460 changePassword: { supported: false },
461 sort: { supported: false },
462 etag: { supported: false },
463 authenticationSchemes: [
464 {
465 type: "oauthbearertoken",
466 name: "OAuth Bearer Token",
467 description: "Authentication scheme using the OAuth Bearer Token Standard",
468 specUri: "http://www.rfc-editor.org/info/rfc6750",
469 primary: true,
470 },
471 ],
472 meta: {
473 resourceType: "ServiceProviderConfig",
474 location: `${base}/scim/v2/${orgId}/ServiceProviderConfig`,
475 },
476 });
477});
478
479export default scim;