Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame572 lines · 2 contributors
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";
f06fc8fccantynz-alt21import { eq, and, ne } from "drizzle-orm";
3a845e4Claude22import * 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
f06fc8fccantynz-alt80/**
81 * May an org's SCIM connector change ACCOUNT-level state (the soft-delete
82 * flags) for this user — as opposed to merely changing their membership in
83 * that org?
84 *
85 * Only for a shadow account the org's own connector created and that nobody
86 * else relies on. Both conditions are required:
87 *
88 * 1. `scimProvisionedByOrgId` matches. It is stamped only on the SCIM
89 * create path, so an account that already existed when this connector
90 * first referenced it is NULL and can never qualify.
91 * 2. The user belongs to no OTHER organization. If they do, that org still
92 * depends on the account and this one does not get to disable it.
93 *
94 * Without this, a SCIM token — an ORG-scoped credential — could schedule any
95 * user's entire platform account for deletion, personal repos and every
96 * other org included, just by provisioning them by email and immediately
97 * deprovisioning them. Membership removal is always allowed; it is only the
98 * account-level flags that are gated here.
99 */
100export function mayScimDisableAccount(args: {
101 /** users.scimProvisionedByOrgId for the target account. */
102 provisionedByOrgId: string | null;
103 /** The org whose SCIM token is making the request. */
104 orgId: string;
105 /** Does the target belong to any organization other than `orgId`? */
106 hasOtherOrgMemberships: boolean;
107}): boolean {
108 const { provisionedByOrgId, orgId, hasOtherOrgMemberships } = args;
109 // A pre-existing account is NULL here and can never qualify.
110 if (!provisionedByOrgId) return false;
111 // Another org's shadow account is not this org's to disable.
112 if (provisionedByOrgId !== orgId) return false;
113 // Somebody else still depends on the account.
114 if (hasOtherOrgMemberships) return false;
115 return true;
116}
117
118/** Loads the two facts {@link mayScimDisableAccount} needs, then applies it. */
119async function canScimManageAccount(
120 userId: string,
121 orgId: string
122): Promise<boolean> {
123 const [target] = await db
124 .select({ provisionedBy: users.scimProvisionedByOrgId })
125 .from(users)
126 .where(eq(users.id, userId))
127 .limit(1);
128 if (!target) return false;
129
130 const elsewhere = await db
131 .select({ id: orgMembers.id })
132 .from(orgMembers)
133 .where(and(eq(orgMembers.userId, userId), ne(orgMembers.orgId, orgId)))
134 .limit(1);
135
136 return mayScimDisableAccount({
137 provisionedByOrgId: target.provisionedBy ?? null,
138 orgId,
139 hasOtherOrgMemberships: elsewhere.length > 0,
140 });
141}
142
3a845e4Claude143function scimError(c: any, status: number, detail: string, scimType?: string) {
144 return c.json(
145 {
146 schemas: [SCIM_ERROR_SCHEMA],
147 status,
148 ...(scimType ? { scimType } : {}),
149 detail,
150 },
151 status
152 );
153}
154
155/** Convert a Drizzle user row to a SCIM User resource. */
156function toScimUser(user: {
157 id: string;
158 username: string;
159 email: string;
160 displayName: string | null;
161 createdAt: Date;
162 updatedAt: Date;
163 deletedAt: Date | null;
164}) {
165 const [firstName, ...rest] = (user.displayName || user.username).split(" ");
166 const lastName = rest.join(" ") || "";
167 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
168 return {
169 schemas: [SCIM_USER_SCHEMA],
170 id: user.id,
171 userName: user.email,
172 name: {
173 formatted: user.displayName || user.username,
174 givenName: firstName,
175 familyName: lastName,
176 },
177 emails: [{ value: user.email, primary: true }],
178 active: !user.deletedAt,
179 meta: {
180 resourceType: "User",
181 created: user.createdAt.toISOString(),
182 lastModified: user.updatedAt.toISOString(),
183 location: `${base}/scim/v2/${user.id}`,
184 },
185 };
186}
187
188// ---------------------------------------------------------------------------
189// GET /scim/v2/:orgId/Users — list users in the org
190// ---------------------------------------------------------------------------
191
192scim.get("/scim/v2/:orgId/Users", async (c) => {
193 const { orgId } = c.req.param();
194 const auth = await scimAuth(c, orgId);
195 if (!auth.ok) return scimError(c, 401, "Unauthorized");
196
197 // Verify org exists
198 const [org] = await db
199 .select({ id: organizations.id })
200 .from(organizations)
201 .where(eq(organizations.id, orgId))
202 .limit(1);
203 if (!org) return scimError(c, 404, "Organization not found");
204
205 // Pagination params
206 const startIndex = Math.max(1, parseInt(c.req.query("startIndex") || "1", 10));
207 const count = Math.min(100, Math.max(1, parseInt(c.req.query("count") || "100", 10)));
208 const offset = startIndex - 1;
209
210 // Get org members and their user data
211 const members = await db
212 .select({
213 id: users.id,
214 username: users.username,
215 email: users.email,
216 displayName: users.displayName,
217 createdAt: users.createdAt,
218 updatedAt: users.updatedAt,
219 deletedAt: users.deletedAt,
220 })
221 .from(orgMembers)
222 .innerJoin(users, eq(users.id, orgMembers.userId))
223 .where(eq(orgMembers.orgId, orgId))
224 .limit(count)
225 .offset(offset);
226
227 const resources = members.map(toScimUser);
228
229 return c.json({
230 schemas: [SCIM_LIST_SCHEMA],
231 totalResults: resources.length + offset, // approximate
232 startIndex,
233 itemsPerPage: count,
234 Resources: resources,
235 });
236});
237
238// ---------------------------------------------------------------------------
239// POST /scim/v2/:orgId/Users — provision a user
240// ---------------------------------------------------------------------------
241
242scim.post("/scim/v2/:orgId/Users", async (c) => {
243 const { orgId } = c.req.param();
244 const auth = await scimAuth(c, orgId);
245 if (!auth.ok) return scimError(c, 401, "Unauthorized");
246
247 const [org] = await db
248 .select({ id: organizations.id })
249 .from(organizations)
250 .where(eq(organizations.id, orgId))
251 .limit(1);
252 if (!org) return scimError(c, 404, "Organization not found");
253
254 let body: {
255 userName?: string;
256 name?: { formatted?: string; givenName?: string; familyName?: string };
257 emails?: Array<{ value: string; primary?: boolean }>;
258 active?: boolean;
259 displayName?: string;
260 };
261 try {
262 body = await c.req.json();
263 } catch {
264 return scimError(c, 400, "Invalid JSON", "invalidValue");
265 }
266
267 const email =
268 body.emails?.find((e) => e.primary)?.value ||
269 body.emails?.[0]?.value ||
270 body.userName ||
271 "";
272 if (!email || !email.includes("@")) {
273 return scimError(c, 400, "email is required", "invalidValue");
274 }
275
276 // Check if user already exists
277 const [existing] = await db
278 .select({ id: users.id })
279 .from(users)
280 .where(eq(users.email, email))
281 .limit(1);
282
283 let userId: string;
284
285 if (existing) {
286 userId = existing.id;
287 } else {
288 // Auto-derive a username
289 let username = email.split("@")[0].toLowerCase().replace(/[^a-z0-9_-]/g, "-").slice(0, 39);
290 const [taken] = await db
291 .select({ id: users.id })
292 .from(users)
293 .where(eq(users.username, username))
294 .limit(1);
295 if (taken) username = username + "-" + crypto.randomBytes(3).toString("hex");
296
297 const displayName =
298 body.displayName ||
299 body.name?.formatted ||
300 [body.name?.givenName, body.name?.familyName].filter(Boolean).join(" ") ||
301 username;
302
303 const [created] = await db
304 .insert(users)
305 .values({
306 username,
307 email,
308 displayName,
309 passwordHash: await Bun.password.hash(crypto.randomBytes(32).toString("hex"), {
310 algorithm: "bcrypt",
311 cost: 10,
312 }),
313 emailVerifiedAt: new Date(),
f06fc8fccantynz-alt314 // Provenance: this org's connector is what brought this account into
315 // existence, so deprovisioning may later disable it. Only set on
316 // this create path — an account matched by email above keeps NULL
317 // and can never be disabled by SCIM.
318 scimProvisionedByOrgId: orgId,
3a845e4Claude319 })
320 .returning({ id: users.id });
321
322 if (!created) return scimError(c, 500, "Failed to create user");
323 userId = created.id;
324 }
325
326 // Add to org if not already a member
327 const [isMember] = await db
328 .select({ id: orgMembers.id })
329 .from(orgMembers)
330 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
331 .limit(1);
332
333 if (!isMember) {
334 await db.insert(orgMembers).values({
335 orgId,
336 userId,
337 role: "member",
338 });
339 }
340
341 const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
342 return c.json(toScimUser(userRow), 201);
343});
344
345// ---------------------------------------------------------------------------
346// GET /scim/v2/:orgId/Users/:userId — get a single user
347// ---------------------------------------------------------------------------
348
349scim.get("/scim/v2/:orgId/Users/:userId", async (c) => {
350 const { orgId, userId } = c.req.param();
351 const auth = await scimAuth(c, orgId);
352 if (!auth.ok) return scimError(c, 401, "Unauthorized");
353
354 const [member] = await db
355 .select({
356 id: users.id,
357 username: users.username,
358 email: users.email,
359 displayName: users.displayName,
360 createdAt: users.createdAt,
361 updatedAt: users.updatedAt,
362 deletedAt: users.deletedAt,
363 })
364 .from(orgMembers)
365 .innerJoin(users, eq(users.id, orgMembers.userId))
366 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
367 .limit(1);
368
369 if (!member) return scimError(c, 404, "User not found");
370
371 return c.json(toScimUser(member));
372});
373
374// ---------------------------------------------------------------------------
375// PUT /scim/v2/:orgId/Users/:userId — replace a user
376// ---------------------------------------------------------------------------
377
378scim.put("/scim/v2/:orgId/Users/:userId", async (c) => {
379 const { orgId, userId } = c.req.param();
380 const auth = await scimAuth(c, orgId);
381 if (!auth.ok) return scimError(c, 401, "Unauthorized");
382
383 const [member] = await db
384 .select({ id: orgMembers.id })
385 .from(orgMembers)
386 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
387 .limit(1);
388 if (!member) return scimError(c, 404, "User not found in this organization");
389
390 let body: {
391 displayName?: string;
392 name?: { formatted?: string; givenName?: string; familyName?: string };
393 active?: boolean;
394 };
395 try {
396 body = await c.req.json();
397 } catch {
398 return scimError(c, 400, "Invalid JSON", "invalidValue");
399 }
400
401 const displayName =
402 body.displayName ||
403 body.name?.formatted ||
404 [body.name?.givenName, body.name?.familyName].filter(Boolean).join(" ") ||
405 undefined;
406
407 const updates: Record<string, unknown> = { updatedAt: new Date() };
408 if (displayName) updates.displayName = displayName;
f06fc8fccantynz-alt409 // Same account-level gate as PATCH and DELETE: `active` reaches the global
410 // soft-delete flags, so only a shadow account this org created may be
411 // toggled through it.
412 if (body.active === false || body.active === true) {
413 if (await canScimManageAccount(userId, orgId)) {
414 if (body.active === false) {
415 updates.deletedAt = new Date();
416 updates.deletionScheduledFor = new Date(
417 Date.now() + 30 * 24 * 60 * 60 * 1000
418 );
419 } else {
420 updates.deletedAt = null;
421 updates.deletionScheduledFor = null;
422 }
423 }
3a845e4Claude424 }
425
426 await db.update(users).set(updates).where(eq(users.id, userId));
427
428 const [updated] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
429 return c.json(toScimUser(updated));
430});
431
432// ---------------------------------------------------------------------------
433// PATCH /scim/v2/:orgId/Users/:userId — partial update
434// ---------------------------------------------------------------------------
435
436scim.patch("/scim/v2/:orgId/Users/:userId", async (c) => {
437 const { orgId, userId } = c.req.param();
438 const auth = await scimAuth(c, orgId);
439 if (!auth.ok) return scimError(c, 401, "Unauthorized");
440
441 const [member] = await db
442 .select({ id: orgMembers.id })
443 .from(orgMembers)
444 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
445 .limit(1);
446 if (!member) return scimError(c, 404, "User not found in this organization");
447
448 let body: {
449 Operations?: Array<{
450 op: string;
451 path?: string;
452 value?: unknown;
453 }>;
454 };
455 try {
456 body = await c.req.json();
457 } catch {
458 return scimError(c, 400, "Invalid JSON", "invalidValue");
459 }
460
461 const updates: Record<string, unknown> = { updatedAt: new Date() };
462
463 for (const op of body.Operations || []) {
464 const opLower = (op.op || "").toLowerCase();
465 if (op.path === "active" || (typeof op.value === "object" && op.value !== null && "active" in (op.value as object))) {
466 const activeVal =
467 op.path === "active"
468 ? op.value
469 : (op.value as Record<string, unknown>)["active"];
f06fc8fccantynz-alt470 // `active: false` is the deprovision operation real IdPs (Okta, Entra)
471 // actually send — DELETE is the rarer path — so this needs the same
472 // account-level gate. Without it, flipping `active` here reached the
473 // global soft-delete flags on any account the org had enrolled.
3a845e4Claude474 if (opLower === "replace" || opLower === "add") {
f06fc8fccantynz-alt475 if (await canScimManageAccount(userId, orgId)) {
476 if (activeVal === false || activeVal === "false") {
477 updates.deletedAt = new Date();
478 updates.deletionScheduledFor = new Date(
479 Date.now() + 30 * 24 * 60 * 60 * 1000
480 );
481 } else {
482 updates.deletedAt = null;
483 updates.deletionScheduledFor = null;
484 }
3a845e4Claude485 }
486 }
487 }
488 if (op.path === "displayName" && (opLower === "replace" || opLower === "add")) {
489 updates.displayName = String(op.value || "");
490 }
491 }
492
493 await db.update(users).set(updates).where(eq(users.id, userId));
494
495 const [updated] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
496 return c.json(toScimUser(updated));
497});
498
499// ---------------------------------------------------------------------------
500// DELETE /scim/v2/:orgId/Users/:userId — deprovision (soft-delete)
501// ---------------------------------------------------------------------------
502
503scim.delete("/scim/v2/:orgId/Users/:userId", async (c) => {
504 const { orgId, userId } = c.req.param();
505 const auth = await scimAuth(c, orgId);
506 if (!auth.ok) return scimError(c, 401, "Unauthorized");
507
508 const [member] = await db
509 .select({ id: orgMembers.id })
510 .from(orgMembers)
511 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)))
512 .limit(1);
513 if (!member) return scimError(c, 404, "User not found in this organization");
514
515 // Remove from org membership (preserves user account + git history)
516 await db
517 .delete(orgMembers)
518 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.userId, userId)));
519
f06fc8fccantynz-alt520 // Disabling the ACCOUNT is a much bigger step than removing a membership,
521 // and it used to happen unconditionally here. It is only ever correct for
522 // a shadow account this org's own connector created — see
523 // canScimManageAccount.
524 if (await canScimManageAccount(userId, orgId)) {
525 await db
526 .update(users)
527 .set({
528 deletedAt: new Date(),
529 deletionScheduledFor: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
530 updatedAt: new Date(),
531 })
532 .where(eq(users.id, userId));
533 }
3a845e4Claude534
535 return c.body(null, 204);
536});
537
538// ---------------------------------------------------------------------------
539// GET /scim/v2/:orgId/ServiceProviderConfig — SCIM capability discovery
540// ---------------------------------------------------------------------------
541
542scim.get("/scim/v2/:orgId/ServiceProviderConfig", async (c) => {
543 const { orgId } = c.req.param();
544 const auth = await scimAuth(c, orgId);
545 if (!auth.ok) return scimError(c, 401, "Unauthorized");
546
547 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
548 return c.json({
549 schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
550 patch: { supported: true },
551 bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
552 filter: { supported: false, maxResults: 100 },
553 changePassword: { supported: false },
554 sort: { supported: false },
555 etag: { supported: false },
556 authenticationSchemes: [
557 {
558 type: "oauthbearertoken",
559 name: "OAuth Bearer Token",
560 description: "Authentication scheme using the OAuth Bearer Token Standard",
561 specUri: "http://www.rfc-editor.org/info/rfc6750",
562 primary: true,
563 },
564 ],
565 meta: {
566 resourceType: "ServiceProviderConfig",
567 location: `${base}/scim/v2/${orgId}/ServiceProviderConfig`,
568 },
569 });
570});
571
572export default scim;