Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit3a845e4unknown_key

feat: enterprise SSO (SAML 2.0 + OIDC) and SCIM user provisioning

feat: enterprise SSO (SAML 2.0 + OIDC) and SCIM user provisioning

Adds per-org enterprise SSO and SCIM provisioning — the critical unlock
for Fortune 500 evaluation. No additional npm packages required.

- Migration 0077: org_sso_configs, scim_tokens, org_sso_sessions tables
- src/routes/saml-sso.tsx: SAML 2.0 SP metadata, login redirect, ACS
  callback with XML signature verification; OIDC per-org login + callback
  via OIDC discovery document
- src/routes/scim.tsx: full SCIM 2.0 — list/create/get/put/patch/delete
  Users; SHA-256 bearer token auth; soft-deprovision (keeps git history)
- src/routes/org-sso-settings.tsx: /orgs/:slug/settings/sso admin UI —
  provider selector (SAML/OIDC), field forms, SP metadata download link,
  Test SSO button, SCIM token generator (shown once)
- src/routes/auth.tsx: domain-hint auto-routing on login POST; JS hint
  on login form showing "Your organization uses SSO" on domain match;
  /api/sso/domain-hint JSON endpoint
- src/db/schema.ts: orgSsoConfigs, scimTokens, orgSsoSessions tables +
  Drizzle types; orgSsoConfigs imported in auth.tsx

https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
Claude committed on June 7, 2026Parent: f5ad215
7 files changed+194113a845e4486ee4e507108a910e3162d6dad6d7b0a
7 changed files+1941−1
Addeddrizzle/0077_enterprise_sso.sql+47−0View fileUnifiedSplit
1-- Migration 0077: Enterprise SSO (SAML 2.0 + OIDC per-org) and SCIM user provisioning
2-- Org-level SSO configs (distinct from site-wide sso_config / Block I10)
3
4CREATE TABLE IF NOT EXISTS org_sso_configs (
5 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6 org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
7 provider text NOT NULL DEFAULT 'saml', -- 'saml' | 'oidc'
8 -- SAML fields
9 idp_entity_id text,
10 idp_sso_url text,
11 idp_certificate text, -- PEM cert from IdP
12 sp_entity_id text, -- our entity ID (computed)
13 -- OIDC fields
14 oidc_client_id text,
15 oidc_client_secret text,
16 oidc_discovery_url text,
17 -- Common
18 domain_hint text, -- e.g. "acme.com" — auto-routes users from this domain to SSO
19 attribute_mapping jsonb DEFAULT '{"email":"email","name":"name","username":"preferred_username"}',
20 enabled boolean NOT NULL DEFAULT false,
21 created_at timestamp DEFAULT now(),
22 updated_at timestamp DEFAULT now(),
23 UNIQUE(org_id)
24);
25
26CREATE INDEX IF NOT EXISTS org_sso_configs_domain ON org_sso_configs(domain_hint) WHERE domain_hint IS NOT NULL;
27
28CREATE TABLE IF NOT EXISTS scim_tokens (
29 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
30 org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
31 token_hash text NOT NULL UNIQUE, -- SHA-256 of the token
32 created_by uuid NOT NULL REFERENCES users(id),
33 created_at timestamp DEFAULT now(),
34 last_used_at timestamp
35);
36
37CREATE TABLE IF NOT EXISTS org_sso_sessions (
38 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
39 user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
40 org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
41 idp_session_id text,
42 created_at timestamp DEFAULT now(),
43 expires_at timestamp NOT NULL
44);
45
46CREATE INDEX IF NOT EXISTS org_sso_sessions_user ON org_sso_sessions(user_id);
47CREATE INDEX IF NOT EXISTS org_sso_sessions_expires ON org_sso_sessions(expires_at);
Modifiedsrc/app.tsx+9−0View fileUnifiedSplit
164164import signingKeysRoutes from "./routes/signing-keys";
165165import sponsorsRoutes from "./routes/sponsors";
166166import ssoRoutes from "./routes/sso";
167import samlSsoRoutes from "./routes/saml-sso";
168import scimRoutes from "./routes/scim";
169import orgSsoSettingsRoutes from "./routes/org-sso-settings";
167170import githubOauthRoutes from "./routes/github-oauth";
168171import googleOauthRoutes from "./routes/google-oauth";
169172import symbolsRoutes from "./routes/symbols";
738741app.route("/", signingKeysRoutes);
739742app.route("/", sponsorsRoutes);
740743app.route("/", ssoRoutes);
744// Enterprise per-org SAML 2.0 + OIDC SSO routes
745app.route("/", samlSsoRoutes);
746// SCIM 2.0 user provisioning endpoints
747app.route("/", scimRoutes);
748// Per-org SSO + SCIM admin settings UI
749app.route("/", orgSsoSettingsRoutes);
741750app.route("/", githubOauthRoutes);
742751app.route("/", googleOauthRoutes);
743752app.route("/", symbolsRoutes);
Modifiedsrc/db/schema.ts+80−0View fileUnifiedSplit
42914291
42924292export type PrPreview = typeof prPreviews.$inferSelect;
42934293export type NewPrPreview = typeof prPreviews.$inferInsert;
4294
4295// ----------------------------------------------------------------------------
4296// Migration 0092 — Enterprise SSO (SAML 2.0 + OIDC per-org) + SCIM
4297// ----------------------------------------------------------------------------
4298
4299/**
4300 * Per-org SSO configuration. One row per org. Supports both SAML 2.0 and OIDC.
4301 * Distinct from the site-wide `sso_config` table (Block I10 / OIDC-only).
4302 */
4303export const orgSsoConfigs = pgTable("org_sso_configs", {
4304 id: uuid("id").primaryKey().defaultRandom(),
4305 orgId: uuid("org_id")
4306 .notNull()
4307 .unique()
4308 .references(() => organizations.id, { onDelete: "cascade" }),
4309 provider: text("provider").notNull().default("saml"), // 'saml' | 'oidc'
4310 // SAML fields
4311 idpEntityId: text("idp_entity_id"),
4312 idpSsoUrl: text("idp_sso_url"),
4313 idpCertificate: text("idp_certificate"), // PEM cert from IdP
4314 spEntityId: text("sp_entity_id"), // our SP entity ID
4315 // OIDC fields
4316 oidcClientId: text("oidc_client_id"),
4317 oidcClientSecret: text("oidc_client_secret"),
4318 oidcDiscoveryUrl: text("oidc_discovery_url"),
4319 // Common
4320 domainHint: text("domain_hint"), // e.g. "acme.com" — auto-routes users from this domain
4321 attributeMapping: jsonb("attribute_mapping")
4322 .$type<Record<string, string>>()
4323 .default({ email: "email", name: "name", username: "preferred_username" }),
4324 enabled: boolean("enabled").notNull().default(false),
4325 createdAt: timestamp("created_at").defaultNow(),
4326 updatedAt: timestamp("updated_at").defaultNow(),
4327});
4328
4329/**
4330 * SCIM provisioning tokens. Hashed SHA-256 bearer tokens issued per-org.
4331 * Identity providers (Okta, Azure AD) use these to provision/deprovision users.
4332 */
4333export const scimTokens = pgTable("scim_tokens", {
4334 id: uuid("id").primaryKey().defaultRandom(),
4335 orgId: uuid("org_id")
4336 .notNull()
4337 .references(() => organizations.id, { onDelete: "cascade" }),
4338 tokenHash: text("token_hash").notNull().unique(), // SHA-256 of the token
4339 createdBy: uuid("created_by")
4340 .notNull()
4341 .references(() => users.id),
4342 createdAt: timestamp("created_at").defaultNow(),
4343 lastUsedAt: timestamp("last_used_at"),
4344});
4345
4346/**
4347 * Tracks active IdP-initiated SSO sessions per org. Allows per-org
4348 * SLO (Single Logout) and session audit.
4349 */
4350export const orgSsoSessions = pgTable(
4351 "org_sso_sessions",
4352 {
4353 id: uuid("id").primaryKey().defaultRandom(),
4354 userId: uuid("user_id")
4355 .notNull()
4356 .references(() => users.id, { onDelete: "cascade" }),
4357 orgId: uuid("org_id")
4358 .notNull()
4359 .references(() => organizations.id, { onDelete: "cascade" }),
4360 idpSessionId: text("idp_session_id"),
4361 createdAt: timestamp("created_at").defaultNow(),
4362 expiresAt: timestamp("expires_at").notNull(),
4363 },
4364 (table) => [
4365 index("org_sso_sessions_user").on(table.userId),
4366 index("org_sso_sessions_expires").on(table.expiresAt),
4367 ]
4368);
4369
4370export type OrgSsoConfig = typeof orgSsoConfigs.$inferSelect;
4371export type NewOrgSsoConfig = typeof orgSsoConfigs.$inferInsert;
4372export type ScimToken = typeof scimTokens.$inferSelect;
4373export type OrgSsoSession = typeof orgSsoSessions.$inferSelect;
Modifiedsrc/routes/auth.tsx+74−1View fileUnifiedSplit
1010 users,
1111 sessions,
1212 organizations,
13 orgSsoConfigs,
1314 userTotp,
1415 userRecoveryCodes,
1516 loginAttempts,
397398 <Button type="submit" variant="primary">
398399 Sign in
399400 </Button>
401 {/* SSO domain hint — shown dynamically when the typed email domain
402 matches a known org SSO config. JS fetches /api/sso/domain-hint. */}
403 <div id="sso-domain-hint" style="display:none;margin-top:10px;padding:10px 12px;background:rgba(140,109,255,0.10);border:1px solid rgba(140,109,255,0.30);border-radius:8px;font-size:13px;color:var(--text)" aria-live="polite">
404 Your organization uses SSO. You'll be redirected to sign in.
405 </div>
400406 </Form>
407 <script dangerouslySetInnerHTML={{__html: /* js */`
408 (function(){
409 var inp = document.querySelector('input[name="username"]');
410 var hint = document.getElementById('sso-domain-hint');
411 if(!inp || !hint) return;
412 var last = '';
413 inp.addEventListener('input', function(){
414 var val = inp.value.trim();
415 var at = val.indexOf('@');
416 if(at < 0) { hint.style.display='none'; return; }
417 var domain = val.slice(at+1).toLowerCase();
418 if(!domain || domain === last) return;
419 last = domain;
420 fetch('/api/sso/domain-hint?domain='+encodeURIComponent(domain))
421 .then(function(r){ return r.ok ? r.json() : null; })
422 .then(function(d){ hint.style.display = (d && d.sso) ? '' : 'none'; })
423 .catch(function(){ hint.style.display='none'; });
424 });
425 })();
426 `}} />
401427 {/* Provider buttons (Google + GitHub) — only rendered when the
402428 admin has configured + enabled them via /admin/google-oauth or
403429 /admin/github-oauth. The "or" divider above the first available
603629 return c.redirect("/login?error=All+fields+are+required");
604630 }
605631
606 // Resolve the canonical email for lockout checks regardless of whether
632 // Enterprise SSO domain-hint routing: if the identifier is an email and the
633 // domain matches an org's `domain_hint`, redirect to that org's SSO flow
634 // instead of checking the password.
635 // Also: resolve the canonical email for lockout checks regardless of whether
607636 // the user typed username or email.
608637 const isEmail = identifier.includes("@");
638 if (isEmail) {
639 const emailDomain = identifier.split("@")[1]?.toLowerCase();
640 if (emailDomain) {
641 const [ssoHint] = await db
642 .select({
643 provider: orgSsoConfigs.provider,
644 orgId: orgSsoConfigs.orgId,
645 })
646 .from(orgSsoConfigs)
647 .where(eq(orgSsoConfigs.domainHint, emailDomain))
648 .limit(1);
649
650 if (ssoHint) {
651 // Resolve org slug from org ID
652 const [orgRow] = await db
653 .select({ slug: organizations.slug })
654 .from(organizations)
655 .where(eq(organizations.id, ssoHint.orgId))
656 .limit(1);
657
658 if (orgRow) {
659 const protocol = ssoHint.provider === "oidc" ? "oidc" : "saml";
660 return c.redirect(`/sso/${protocol}/${orgRow.slug}/login`);
661 }
662 }
663 }
664 }
665
666 // Find user by username or email
609667 const [user] = await db
610668 .select()
611669 .from(users)
9671025 });
9681026});
9691027
1028// --- SSO domain-hint API (used by the login form JS) ---
1029
1030auth.get("/api/sso/domain-hint", async (c) => {
1031 const domain = String(c.req.query("domain") || "").toLowerCase().trim();
1032 if (!domain || domain.length > 253) {
1033 return c.json({ sso: false });
1034 }
1035 const [row] = await db
1036 .select({ orgId: orgSsoConfigs.orgId, provider: orgSsoConfigs.provider })
1037 .from(orgSsoConfigs)
1038 .where(eq(orgSsoConfigs.domainHint, domain))
1039 .limit(1);
1040 return c.json({ sso: !!row, provider: row?.provider ?? null });
1041});
1042
9701043/**
9711044 * Pick a friendly provider name for the "Sign in with X" button when the
9721045 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
Addedsrc/routes/org-sso-settings.tsx+603−0View fileUnifiedSplit
1/**
2 * Per-org enterprise SSO + SCIM settings.
3 *
4 * GET /orgs/:orgSlug/settings/sso — SSO config page
5 * POST /orgs/:orgSlug/settings/sso — save SSO config
6 * POST /orgs/:orgSlug/settings/sso/generate-scim-token — generate a new SCIM token
7 */
8
9import { Hono } from "hono";
10import { eq, and } from "drizzle-orm";
11import * as crypto from "crypto";
12import { db } from "../db";
13import {
14 orgSsoConfigs,
15 scimTokens,
16 organizations,
17 orgMembers,
18} from "../db/schema";
19import { Layout } from "../views/layout";
20import { requireAuth, softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22
23const orgSsoSettings = new Hono<AuthEnv>();
24orgSsoSettings.use("*", softAuth);
25
26// ---------------------------------------------------------------------------
27// Scoped CSS
28// ---------------------------------------------------------------------------
29
30const orgSsoCss = `
31 .org-sso-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
32
33 .org-sso-hero {
34 position: relative;
35 margin-bottom: var(--space-5);
36 padding: var(--space-5) var(--space-6);
37 background: var(--bg-elevated);
38 border: 1px solid var(--border);
39 border-radius: 16px;
40 overflow: hidden;
41 }
42 .org-sso-hero::before {
43 content: '';
44 position: absolute;
45 top: 0; left: 0; right: 0; height: 2px;
46 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
47 opacity: 0.7;
48 pointer-events: none;
49 }
50 .org-sso-title {
51 font-family: var(--font-display);
52 font-size: 24px;
53 font-weight: 800;
54 letter-spacing: -0.025em;
55 color: var(--text-strong);
56 margin: 0 0 6px;
57 }
58 .org-sso-sub { font-size: 13.5px; color: var(--text-muted); margin: 0; }
59
60 .org-sso-section {
61 margin-bottom: var(--space-5);
62 background: var(--bg-elevated);
63 border: 1px solid var(--border);
64 border-radius: 14px;
65 overflow: hidden;
66 }
67 .org-sso-section-head {
68 padding: var(--space-4) var(--space-5);
69 border-bottom: 1px solid var(--border);
70 }
71 .org-sso-section-title {
72 margin: 0 0 4px;
73 font-size: 15px;
74 font-weight: 700;
75 color: var(--text-strong);
76 }
77 .org-sso-section-sub { margin: 0; font-size: 12.5px; color: var(--text-muted); }
78 .org-sso-section-body { padding: var(--space-4) var(--space-5); }
79
80 .org-sso-field { margin-bottom: var(--space-4); }
81 .org-sso-field:last-child { margin-bottom: 0; }
82 .org-sso-field label {
83 display: block;
84 font-size: 12.5px;
85 font-weight: 600;
86 color: var(--text-strong);
87 margin-bottom: 5px;
88 font-family: var(--font-mono);
89 }
90 .org-sso-input {
91 width: 100%;
92 padding: 9px 12px;
93 font-size: 13.5px;
94 color: var(--text);
95 background: var(--bg);
96 border: 1px solid var(--border-strong);
97 border-radius: 8px;
98 box-sizing: border-box;
99 font-family: var(--font-mono);
100 outline: none;
101 }
102 .org-sso-input:focus {
103 border-color: var(--border-focus);
104 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
105 }
106 .org-sso-textarea {
107 min-height: 120px;
108 resize: vertical;
109 }
110 .org-sso-hint { font-size: 11.5px; color: var(--text-muted); margin-top: 5px; }
111
112 .org-sso-toggle {
113 display: flex;
114 gap: 10px;
115 align-items: flex-start;
116 padding: 10px 12px;
117 background: rgba(255,255,255,0.025);
118 border: 1px solid var(--border);
119 border-radius: 10px;
120 margin-bottom: var(--space-4);
121 }
122 .org-sso-toggle input { margin-top: 2px; flex-shrink: 0; }
123 .org-sso-toggle span { font-size: 13px; color: var(--text); line-height: 1.45; }
124
125 .org-sso-foot {
126 padding: var(--space-3) var(--space-5);
127 border-top: 1px solid var(--border);
128 display: flex;
129 justify-content: space-between;
130 align-items: center;
131 gap: var(--space-2);
132 }
133 .org-sso-foot-hint { font-size: 12.5px; color: var(--text-muted); }
134
135 .org-sso-btn {
136 display: inline-flex;
137 align-items: center;
138 gap: 8px;
139 padding: 10px 18px;
140 border-radius: 10px;
141 font-size: 13.5px;
142 font-weight: 600;
143 border: 1px solid transparent;
144 cursor: pointer;
145 font: inherit;
146 text-decoration: none;
147 }
148 .org-sso-btn-primary {
149 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
150 color: #fff;
151 }
152 .org-sso-btn-primary:hover { opacity: 0.9; text-decoration: none; color: #fff; }
153 .org-sso-btn-ghost {
154 background: transparent;
155 border-color: var(--border-strong);
156 color: var(--text);
157 }
158 .org-sso-btn-ghost:hover { background: rgba(140,109,255,0.07); border-color: rgba(140,109,255,0.45); color: var(--text-strong); text-decoration: none; }
159
160 .org-sso-banner {
161 margin-bottom: var(--space-4);
162 padding: 10px 14px;
163 border-radius: 10px;
164 font-size: 13.5px;
165 border: 1px solid var(--border);
166 }
167 .org-sso-banner.is-ok { border-color: rgba(52,211,153,0.4); background: rgba(52,211,153,0.07); color: #bbf7d0; }
168 .org-sso-banner.is-error { border-color: rgba(248,113,113,0.4); background: rgba(248,113,113,0.07); color: #fecaca; }
169
170 .org-sso-callout {
171 display: flex;
172 align-items: center;
173 gap: var(--space-3);
174 padding: 12px 14px;
175 background: rgba(140,109,255,0.05);
176 border: 1px dashed rgba(140,109,255,0.30);
177 border-radius: 12px;
178 flex-wrap: wrap;
179 margin-bottom: var(--space-4);
180 }
181 .org-sso-callout-label { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); flex-shrink: 0; }
182 .org-sso-callout code {
183 flex: 1;
184 font-family: var(--font-mono);
185 font-size: 12.5px;
186 color: var(--text);
187 background: rgba(255,255,255,0.04);
188 border: 1px solid var(--border);
189 padding: 6px 10px;
190 border-radius: 8px;
191 word-break: break-all;
192 }
193
194 .org-sso-token-box {
195 background: var(--bg);
196 border: 1px solid var(--border-strong);
197 border-radius: 10px;
198 padding: 12px 14px;
199 font-family: var(--font-mono);
200 font-size: 13px;
201 color: var(--text-strong);
202 word-break: break-all;
203 margin-top: var(--space-3);
204 }
205 .org-sso-token-warning {
206 font-size: 12px;
207 color: #fde68a;
208 margin-top: 8px;
209 }
210
211 .org-sso-provider-tabs {
212 display: flex;
213 gap: 8px;
214 margin-bottom: var(--space-4);
215 }
216 .org-sso-tab {
217 padding: 8px 16px;
218 border-radius: 8px;
219 font-size: 13px;
220 font-weight: 600;
221 border: 1px solid var(--border-strong);
222 background: transparent;
223 color: var(--text);
224 cursor: pointer;
225 font: inherit;
226 }
227 .org-sso-tab.is-active {
228 background: rgba(140,109,255,0.14);
229 border-color: rgba(140,109,255,0.45);
230 color: var(--text-strong);
231 }
232
233 .org-sso-status {
234 display: inline-flex;
235 align-items: center;
236 gap: 6px;
237 padding: 3px 10px;
238 border-radius: 9999px;
239 font-size: 11px;
240 font-weight: 700;
241 letter-spacing: 0.05em;
242 text-transform: uppercase;
243 margin-left: var(--space-3);
244 }
245 .org-sso-status.is-on { background: rgba(52,211,153,0.12); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); }
246 .org-sso-status.is-off { background: rgba(148,163,184,0.10); color: #cbd5e1; box-shadow: inset 0 0 0 1px rgba(148,163,184,0.28); }
247 .org-sso-status .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; }
248`;
249
250// ---------------------------------------------------------------------------
251// Auth + org-admin gate
252// ---------------------------------------------------------------------------
253
254async function requireOrgAdmin(c: any, orgSlug: string) {
255 const user = c.get("user");
256 if (!user) return null;
257
258 const [org] = await db
259 .select()
260 .from(organizations)
261 .where(eq(organizations.slug, orgSlug))
262 .limit(1);
263 if (!org) return null;
264
265 const [membership] = await db
266 .select({ role: orgMembers.role })
267 .from(orgMembers)
268 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
269 .limit(1);
270
271 // Only owners and admins can configure SSO
272 if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
273 return null;
274 }
275
276 return { user, org };
277}
278
279// ---------------------------------------------------------------------------
280// GET /orgs/:orgSlug/settings/sso
281// ---------------------------------------------------------------------------
282
283orgSsoSettings.get("/orgs/:orgSlug/settings/sso", requireAuth, async (c) => {
284 const { orgSlug } = c.req.param();
285 const ctx = await requireOrgAdmin(c, orgSlug);
286 if (!ctx) {
287 return c.html(
288 <Layout title="Forbidden" user={c.get("user")}>
289 <div style="max-width:540px;margin:80px auto;text-align:center;padding:40px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:16px">
290 <h2 style="font-size:20px;margin:0 0 8px;color:var(--text-strong)">403 — Access denied</h2>
291 <p style="color:var(--text-muted);margin:0">Only org owners and admins can configure SSO.</p>
292 </div>
293 </Layout>,
294 403
295 );
296 }
297
298 const { user, org } = ctx;
299 const success = c.req.query("success");
300 const error = c.req.query("error");
301 const newToken = c.req.query("token"); // plaintext token shown once after generation
302
303 const [cfg] = await db
304 .select()
305 .from(orgSsoConfigs)
306 .where(eq(orgSsoConfigs.orgId, org.id))
307 .limit(1);
308
309 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
310 const spMetadataUrl = `${base}/sso/saml/${orgSlug}/metadata`;
311 const spAcsUrl = `${base}/sso/saml/${orgSlug}/callback`;
312 const oidcCallbackUrl = `${base}/sso/oidc/${orgSlug}/callback`;
313 const scimBaseUrl = `${base}/scim/v2/${org.id}`;
314
315 const activeProvider = cfg?.provider || "saml";
316 const isEnabled = !!cfg?.enabled;
317
318 return c.html(
319 <Layout title={`SSO Settings — ${org.name}`} user={user}>
320 <style dangerouslySetInnerHTML={{ __html: orgSsoCss }} />
321 <div class="org-sso-wrap">
322 <div class="org-sso-hero">
323 <h1 class="org-sso-title">
324 Enterprise SSO
325 <span class={`org-sso-status ${isEnabled ? "is-on" : "is-off"}`}>
326 <span class="dot" />
327 {isEnabled ? "Enabled" : "Disabled"}
328 </span>
329 </h1>
330 <p class="org-sso-sub">
331 Configure SAML 2.0 or OIDC single sign-on and SCIM provisioning for <strong>{org.name}</strong>.
332 Members from <code style="font-size:12px;background:var(--bg-tertiary);padding:1px 4px;border-radius:4px">{cfg?.domainHint || "your domain"}</code> will be automatically routed to your identity provider.
333 </p>
334 </div>
335
336 {success && <div class="org-sso-banner is-ok">{decodeURIComponent(success)}</div>}
337 {error && <div class="org-sso-banner is-error">{decodeURIComponent(error)}</div>}
338 {newToken && (
339 <div class="org-sso-banner is-ok">
340 SCIM token generated. Copy it now — it won't be shown again.
341 <div class="org-sso-token-box">{newToken}</div>
342 <div class="org-sso-token-warning">Store this token securely. It grants full SCIM access to your org.</div>
343 </div>
344 )}
345
346 <form method="post" action={`/orgs/${orgSlug}/settings/sso`}>
347 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) ?? ""} />
348
349 {/* Provider tabs */}
350 <div class="org-sso-section">
351 <header class="org-sso-section-head">
352 <h2 class="org-sso-section-title">SSO Provider</h2>
353 <p class="org-sso-section-sub">Choose your identity protocol. SAML 2.0 is most common for enterprise IdPs (Okta, Azure AD, PingFederate). OIDC works with Google Workspace, Auth0, and modern Okta.</p>
354 </header>
355 <div class="org-sso-section-body">
356 <div class="org-sso-toggle">
357 <input
358 type="checkbox"
359 name="enabled"
360 value="1"
361 checked={isEnabled}
362 id="enabled"
363 aria-label="Enable SSO for this organization"
364 />
365 <span>
366 <strong style="color:var(--text-strong)">Enable SSO.</strong>
367 {" "}When enabled, users with a matching domain hint are automatically redirected to your IdP instead of the password form.
368 </span>
369 </div>
370
371 <div class="org-sso-field">
372 <label for="provider">Provider protocol</label>
373 <select id="provider" name="provider" class="org-sso-input" style="cursor:pointer">
374 <option value="saml" selected={activeProvider === "saml"}>SAML 2.0</option>
375 <option value="oidc" selected={activeProvider === "oidc"}>OIDC / OAuth 2.0</option>
376 </select>
377 </div>
378
379 <div class="org-sso-field">
380 <label for="domain_hint">Domain hint</label>
381 <input
382 id="domain_hint"
383 name="domain_hint"
384 type="text"
385 class="org-sso-input"
386 placeholder="acme.com"
387 value={cfg?.domainHint || ""}
388 />
389 <p class="org-sso-hint">When a user logs in with an email from this domain, they'll be redirected to SSO automatically. Leave blank to require manual SSO initiation.</p>
390 </div>
391 </div>
392 </div>
393
394 {/* SAML config */}
395 <div class="org-sso-section">
396 <header class="org-sso-section-head">
397 <h2 class="org-sso-section-title">SAML 2.0 Configuration</h2>
398 <p class="org-sso-section-sub">Provide these SP details to your IdP, then paste the IdP metadata fields below.</p>
399 </header>
400 <div class="org-sso-section-body">
401 <div class="org-sso-callout">
402 <span class="org-sso-callout-label">SP Metadata</span>
403 <code>{spMetadataUrl}</code>
404 <a href={spMetadataUrl} target="_blank" class="org-sso-btn org-sso-btn-ghost" style="padding:6px 12px;font-size:12px">Download</a>
405 </div>
406 <div class="org-sso-callout">
407 <span class="org-sso-callout-label">ACS URL</span>
408 <code>{spAcsUrl}</code>
409 </div>
410
411 <div class="org-sso-field">
412 <label for="idp_entity_id">IdP Entity ID</label>
413 <input id="idp_entity_id" name="idp_entity_id" type="text" class="org-sso-input" placeholder="https://idp.example.com/saml/metadata" value={cfg?.idpEntityId || ""} />
414 </div>
415 <div class="org-sso-field">
416 <label for="idp_sso_url">IdP SSO URL</label>
417 <input id="idp_sso_url" name="idp_sso_url" type="text" class="org-sso-input" placeholder="https://idp.example.com/saml/sso" value={cfg?.idpSsoUrl || ""} />
418 <p class="org-sso-hint">The HTTP-Redirect or HTTP-POST binding URL from your IdP's metadata.</p>
419 </div>
420 <div class="org-sso-field">
421 <label for="idp_certificate">IdP X.509 Certificate (PEM)</label>
422 <textarea id="idp_certificate" name="idp_certificate" class="org-sso-input org-sso-textarea" placeholder="-----BEGIN CERTIFICATE-----&#10;MIIC...&#10;-----END CERTIFICATE-----">{cfg?.idpCertificate || ""}</textarea>
423 <p class="org-sso-hint">Paste the full PEM-encoded certificate from your IdP. Used to verify SAML assertion signatures.</p>
424 </div>
425 </div>
426 </div>
427
428 {/* OIDC config */}
429 <div class="org-sso-section">
430 <header class="org-sso-section-head">
431 <h2 class="org-sso-section-title">OIDC / OAuth 2.0 Configuration</h2>
432 <p class="org-sso-section-sub">Fill these if your provider uses OIDC (OpenID Connect).</p>
433 </header>
434 <div class="org-sso-section-body">
435 <div class="org-sso-callout">
436 <span class="org-sso-callout-label">Redirect URI</span>
437 <code>{oidcCallbackUrl}</code>
438 </div>
439
440 <div class="org-sso-field">
441 <label for="oidc_discovery_url">OIDC Discovery / Issuer URL</label>
442 <input id="oidc_discovery_url" name="oidc_discovery_url" type="text" class="org-sso-input" placeholder="https://accounts.google.com or https://YOUR-TENANT.okta.com" value={cfg?.oidcDiscoveryUrl || ""} />
443 <p class="org-sso-hint">The base issuer URL — we'll append <code>/.well-known/openid-configuration</code> automatically.</p>
444 </div>
445 <div class="org-sso-field">
446 <label for="oidc_client_id">Client ID</label>
447 <input id="oidc_client_id" name="oidc_client_id" type="text" class="org-sso-input" autocomplete="off" value={cfg?.oidcClientId || ""} />
448 </div>
449 <div class="org-sso-field">
450 <label for="oidc_client_secret">Client Secret</label>
451 <input id="oidc_client_secret" name="oidc_client_secret" type="password" class="org-sso-input" autocomplete="off" placeholder={cfg?.oidcClientSecret ? "(storedleave blank to keep)" : ""} />
452 </div>
453 </div>
454 </div>
455
456 <div class="org-sso-section">
457 <div class="org-sso-foot">
458 <span class="org-sso-foot-hint">
459 <a href={`/sso/${activeProvider === "saml" ? "saml" : "oidc"}/${orgSlug}/login`} target="_blank" style="color:var(--accent);text-decoration:none">Test SSO</a> after saving.
460 </span>
461 <button type="submit" class="org-sso-btn org-sso-btn-primary">Save SSO settings</button>
462 </div>
463 </div>
464 </form>
465
466 {/* SCIM section */}
467 <div class="org-sso-section">
468 <header class="org-sso-section-head">
469 <h2 class="org-sso-section-title">SCIM User Provisioning</h2>
470 <p class="org-sso-section-sub">
471 Connect your IdP (Okta, Azure AD, Google Workspace) to automatically provision and deprovision members of <strong>{org.name}</strong>.
472 </p>
473 </header>
474 <div class="org-sso-section-body">
475 <div class="org-sso-callout">
476 <span class="org-sso-callout-label">SCIM Base URL</span>
477 <code>{scimBaseUrl}</code>
478 </div>
479 <p class="org-sso-hint" style="margin-bottom:var(--space-4)">
480 Configure your IdP with the SCIM base URL above and a bearer token generated below. Set the SCIM version to 2.0 in your IdP.
481 </p>
482 </div>
483 <div class="org-sso-foot">
484 <span class="org-sso-foot-hint">Tokens are shown once at creation. Store them securely.</span>
485 <form method="post" action={`/orgs/${orgSlug}/settings/sso/generate-scim-token`}>
486 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) ?? ""} />
487 <button type="submit" class="org-sso-btn org-sso-btn-ghost">Generate SCIM token</button>
488 </form>
489 </div>
490 </div>
491 </div>
492 </Layout>
493 );
494});
495
496// ---------------------------------------------------------------------------
497// POST /orgs/:orgSlug/settings/sso — save config
498// ---------------------------------------------------------------------------
499
500orgSsoSettings.post("/orgs/:orgSlug/settings/sso", requireAuth, async (c) => {
501 const { orgSlug } = c.req.param();
502 const ctx = await requireOrgAdmin(c, orgSlug);
503 if (!ctx) return c.redirect(`/orgs/${orgSlug}`);
504
505 const { org } = ctx;
506 const body = await c.req.parseBody();
507
508 const enabled = String(body.enabled || "") === "1";
509 const provider = String(body.provider || "saml");
510 const domainHint = String(body.domain_hint || "").trim() || null;
511
512 // SAML fields
513 const idpEntityId = String(body.idp_entity_id || "").trim() || null;
514 const idpSsoUrl = String(body.idp_sso_url || "").trim() || null;
515 const idpCertificate = String(body.idp_certificate || "").trim() || null;
516
517 // OIDC fields
518 const oidcDiscoveryUrl = String(body.oidc_discovery_url || "").trim() || null;
519 const oidcClientId = String(body.oidc_client_id || "").trim() || null;
520
521 // Existing config to preserve client_secret if not changed
522 const [existing] = await db
523 .select()
524 .from(orgSsoConfigs)
525 .where(eq(orgSsoConfigs.orgId, org.id))
526 .limit(1);
527
528 const oidcClientSecretNew = String(body.oidc_client_secret || "").trim();
529 const oidcClientSecret =
530 oidcClientSecretNew.length > 0 ? oidcClientSecretNew : existing?.oidcClientSecret || null;
531
532 const base = process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
533 const spEntityId = `${base}/sso/saml/${orgSlug}`;
534
535 if (existing) {
536 await db
537 .update(orgSsoConfigs)
538 .set({
539 enabled,
540 provider,
541 domainHint,
542 idpEntityId,
543 idpSsoUrl,
544 idpCertificate,
545 spEntityId,
546 oidcDiscoveryUrl,
547 oidcClientId,
548 oidcClientSecret,
549 updatedAt: new Date(),
550 })
551 .where(eq(orgSsoConfigs.orgId, org.id));
552 } else {
553 await db.insert(orgSsoConfigs).values({
554 orgId: org.id,
555 enabled,
556 provider,
557 domainHint,
558 idpEntityId,
559 idpSsoUrl,
560 idpCertificate,
561 spEntityId,
562 oidcDiscoveryUrl,
563 oidcClientId,
564 oidcClientSecret,
565 });
566 }
567
568 return c.redirect(
569 `/orgs/${orgSlug}/settings/sso?success=${encodeURIComponent("SSO settings saved.")}`
570 );
571});
572
573// ---------------------------------------------------------------------------
574// POST /orgs/:orgSlug/settings/sso/generate-scim-token
575// ---------------------------------------------------------------------------
576
577orgSsoSettings.post(
578 "/orgs/:orgSlug/settings/sso/generate-scim-token",
579 requireAuth,
580 async (c) => {
581 const { orgSlug } = c.req.param();
582 const ctx = await requireOrgAdmin(c, orgSlug);
583 if (!ctx) return c.redirect(`/orgs/${orgSlug}`);
584
585 const { user, org } = ctx;
586
587 // Generate a 40-byte random token
588 const rawToken = "glue_scim_" + crypto.randomBytes(30).toString("hex");
589 const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
590
591 await db.insert(scimTokens).values({
592 orgId: org.id,
593 tokenHash,
594 createdBy: user.id,
595 });
596
597 return c.redirect(
598 `/orgs/${orgSlug}/settings/sso?token=${encodeURIComponent(rawToken)}&success=${encodeURIComponent("SCIM token generated. Copy it now — it won't be shown again.")}`
599 );
600 }
601);
602
603export default orgSsoSettings;
Addedsrc/routes/saml-sso.tsx+649−0View fileUnifiedSplit
1/**
2 * Enterprise per-org SSO routes — SAML 2.0 and OIDC.
3 *
4 * GET /sso/saml/:orgSlug/metadata — SP metadata XML (configure your IdP with this)
5 * GET /sso/saml/:orgSlug/login — initiate SAML AuthnRequest → redirect to IdP
6 * POST /sso/saml/:orgSlug/callback — receive SAMLResponse from IdP, create session
7 * GET /sso/oidc/:orgSlug/login — initiate OIDC authorization code flow
8 * GET /sso/oidc/:orgSlug/callback — exchange code → tokens → create session
9 *
10 * Admin UI at /orgs/:orgSlug/settings/sso (see org-sso-settings.tsx).
11 */
12
13import { Hono } from "hono";
14import { eq } from "drizzle-orm";
15import { getCookie, setCookie, deleteCookie } from "hono/cookie";
16import * as crypto from "crypto";
17import { db } from "../db";
18import { orgSsoConfigs, orgSsoSessions, organizations, users, sessions } from "../db/schema";
19import { generateSessionToken, sessionCookieOptions, sessionExpiry } from "../lib/auth";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22
23const samlSso = new Hono<AuthEnv>();
24samlSso.use("*", softAuth);
25
26// ---------------------------------------------------------------------------
27// Helpers
28// ---------------------------------------------------------------------------
29
30function getBaseUrl(): string {
31 return process.env.APP_URL || process.env.BASE_URL || "https://gluecron.com";
32}
33
34function generateId(len = 16): string {
35 return crypto.randomBytes(len).toString("hex");
36}
37
38/** Resolve org by slug; returns the org row or null. */
39async function getOrgBySlug(slug: string) {
40 const [org] = await db
41 .select()
42 .from(organizations)
43 .where(eq(organizations.slug, slug))
44 .limit(1);
45 return org ?? null;
46}
47
48/** Resolve org SSO config (must be enabled). */
49async function getOrgSsoConfig(orgId: string) {
50 const [cfg] = await db
51 .select()
52 .from(orgSsoConfigs)
53 .where(eq(orgSsoConfigs.orgId, orgId))
54 .limit(1);
55 return cfg ?? null;
56}
57
58/** Find or create a local user from SSO identity claims. */
59async function findOrCreateSsoUser(
60 email: string,
61 name: string | undefined,
62 preferredUsername: string | undefined,
63 orgId: string
64): Promise<{ ok: true; userId: string } | { ok: false; error: string }> {
65 if (!email) return { ok: false, error: "No email returned from IdP" };
66
67 // Look up by email
68 const [existing] = await db
69 .select({ id: users.id })
70 .from(users)
71 .where(eq(users.email, email))
72 .limit(1);
73
74 if (existing) return { ok: true, userId: existing.id };
75
76 // Auto-create: derive a username from email / preferred_username
77 let username = (preferredUsername || email.split("@")[0])
78 .toLowerCase()
79 .replace(/[^a-z0-9_-]/g, "-")
80 .slice(0, 39);
81 if (!username) username = "user-" + generateId(4);
82
83 // Deduplicate username
84 const [taken] = await db
85 .select({ id: users.id })
86 .from(users)
87 .where(eq(users.username, username))
88 .limit(1);
89 if (taken) username = username + "-" + generateId(4);
90
91 const [created] = await db
92 .insert(users)
93 .values({
94 username,
95 email,
96 displayName: name || username,
97 // No password — SSO-only accounts use a random hash placeholder
98 passwordHash: await Bun.password.hash(generateId(32), {
99 algorithm: "bcrypt",
100 cost: 10,
101 }),
102 emailVerifiedAt: new Date(),
103 })
104 .returning({ id: users.id });
105
106 if (!created) return { ok: false, error: "Failed to create user account" };
107 return { ok: true, userId: created.id };
108}
109
110/** Create a standard session cookie for a user. */
111async function createUserSession(userId: string): Promise<string> {
112 const token = generateSessionToken();
113 await db.insert(sessions).values({
114 userId,
115 token,
116 expiresAt: sessionExpiry(),
117 });
118 return token;
119}
120
121// ---------------------------------------------------------------------------
122// SAML 2.0 — SP Metadata
123// ---------------------------------------------------------------------------
124
125samlSso.get("/sso/saml/:orgSlug/metadata", async (c) => {
126 const { orgSlug } = c.req.param();
127 const org = await getOrgBySlug(orgSlug);
128 if (!org) return c.text("Organization not found", 404);
129
130 const cfg = await getOrgSsoConfig(org.id);
131 if (!cfg || cfg.provider !== "saml") {
132 return c.text("SAML not configured for this organization", 404);
133 }
134
135 const base = getBaseUrl();
136 const spEntityId =
137 cfg.spEntityId || `${base}/sso/saml/${orgSlug}`;
138 const callbackUrl = `${base}/sso/saml/${orgSlug}/callback`;
139
140 const xml = `<?xml version="1.0" encoding="UTF-8"?>
141<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
142 entityID="${escapeXml(spEntityId)}">
143 <SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true"
144 protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
145 <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
146 <AssertionConsumerService
147 Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
148 Location="${escapeXml(callbackUrl)}"
149 index="1"/>
150 </SPSSODescriptor>
151</EntityDescriptor>`;
152
153 return c.body(xml, 200, {
154 "Content-Type": "application/xml; charset=utf-8",
155 "Cache-Control": "public, max-age=3600",
156 });
157});
158
159// ---------------------------------------------------------------------------
160// SAML 2.0 — SP-initiated login
161// ---------------------------------------------------------------------------
162
163samlSso.get("/sso/saml/:orgSlug/login", async (c) => {
164 const { orgSlug } = c.req.param();
165 const org = await getOrgBySlug(orgSlug);
166 if (!org) return c.redirect(`/login?error=${encodeURIComponent("Organization not found")}`);
167
168 const cfg = await getOrgSsoConfig(org.id);
169 if (!cfg || !cfg.enabled || cfg.provider !== "saml") {
170 return c.redirect(`/login?error=${encodeURIComponent("SAML SSO not enabled for this organization")}`);
171 }
172 if (!cfg.idpSsoUrl) {
173 return c.redirect(`/login?error=${encodeURIComponent("SAML IdP SSO URL not configured")}`);
174 }
175
176 const base = getBaseUrl();
177 const spEntityId = cfg.spEntityId || `${base}/sso/saml/${orgSlug}`;
178 const acsUrl = `${base}/sso/saml/${orgSlug}/callback`;
179 const requestId = "_" + generateId(20);
180 const issueInstant = new Date().toISOString();
181
182 const relayState = generateId(16);
183 const authnRequest = `<samlp:AuthnRequest
184 xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
185 xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
186 ID="${requestId}"
187 Version="2.0"
188 IssueInstant="${issueInstant}"
189 Destination="${escapeXml(cfg.idpSsoUrl)}"
190 AssertionConsumerServiceURL="${escapeXml(acsUrl)}"
191 ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
192 <saml:Issuer>${escapeXml(spEntityId)}</saml:Issuer>
193</samlp:AuthnRequest>`;
194
195 const encoded = Buffer.from(authnRequest).toString("base64");
196 const params = new URLSearchParams({
197 SAMLRequest: encoded,
198 RelayState: relayState,
199 });
200
201 // Store relay state for CSRF check
202 setCookie(c, "saml_relay_state", relayState, {
203 httpOnly: true,
204 secure: process.env.NODE_ENV === "production",
205 sameSite: "Lax",
206 path: "/",
207 maxAge: 600,
208 });
209 setCookie(c, "saml_org", orgSlug, {
210 httpOnly: true,
211 secure: process.env.NODE_ENV === "production",
212 sameSite: "Lax",
213 path: "/",
214 maxAge: 600,
215 });
216
217 return c.redirect(`${cfg.idpSsoUrl}?${params.toString()}`);
218});
219
220// ---------------------------------------------------------------------------
221// SAML 2.0 — ACS callback (HTTP-POST binding)
222// ---------------------------------------------------------------------------
223
224samlSso.post("/sso/saml/:orgSlug/callback", async (c) => {
225 const { orgSlug } = c.req.param();
226 const org = await getOrgBySlug(orgSlug);
227 if (!org) return c.redirect(`/login?error=${encodeURIComponent("Organization not found")}`);
228
229 const cfg = await getOrgSsoConfig(org.id);
230 if (!cfg || !cfg.enabled || cfg.provider !== "saml") {
231 return c.redirect(`/login?error=${encodeURIComponent("SAML SSO not enabled for this organization")}`);
232 }
233
234 // Validate relay state
235 const expectedRelay = getCookie(c, "saml_relay_state");
236 const expectedOrg = getCookie(c, "saml_org");
237 deleteCookie(c, "saml_relay_state", { path: "/" });
238 deleteCookie(c, "saml_org", { path: "/" });
239
240 if (!expectedRelay || expectedOrg !== orgSlug) {
241 return c.redirect(`/login?error=${encodeURIComponent("SAML relay state mismatch. Please try again.")}`);
242 }
243
244 const body = await c.req.parseBody();
245 const samlResponse = String(body.SAMLResponse || "");
246 if (!samlResponse) {
247 return c.redirect(`/login?error=${encodeURIComponent("No SAMLResponse received")}`);
248 }
249
250 try {
251 const claims = parseSamlResponse(samlResponse, cfg.idpCertificate || "", cfg);
252 if (!claims.ok) {
253 return c.redirect(`/login?error=${encodeURIComponent(claims.error)}`);
254 }
255
256 const result = await findOrCreateSsoUser(
257 claims.email,
258 claims.name,
259 claims.username,
260 org.id
261 );
262 if (!result.ok) {
263 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
264 }
265
266 // Record the SSO session
267 await db.insert(orgSsoSessions).values({
268 userId: result.userId,
269 orgId: org.id,
270 idpSessionId: claims.sessionIndex ?? null,
271 expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000), // 8h
272 });
273
274 const token = await createUserSession(result.userId);
275 setCookie(c, "session", token, sessionCookieOptions());
276 return c.redirect("/");
277 } catch (err) {
278 console.error("[saml-sso] callback error:", err);
279 return c.redirect(`/login?error=${encodeURIComponent("SAML authentication failed. Check IdP configuration.")}`);
280 }
281});
282
283// ---------------------------------------------------------------------------
284// OIDC — per-org login
285// ---------------------------------------------------------------------------
286
287samlSso.get("/sso/oidc/:orgSlug/login", async (c) => {
288 const { orgSlug } = c.req.param();
289 const org = await getOrgBySlug(orgSlug);
290 if (!org) return c.redirect(`/login?error=${encodeURIComponent("Organization not found")}`);
291
292 const cfg = await getOrgSsoConfig(org.id);
293 if (!cfg || !cfg.enabled || cfg.provider !== "oidc") {
294 return c.redirect(`/login?error=${encodeURIComponent("OIDC SSO not enabled for this organization")}`);
295 }
296 if (!cfg.oidcDiscoveryUrl || !cfg.oidcClientId) {
297 return c.redirect(`/login?error=${encodeURIComponent("OIDC not fully configured")}`);
298 }
299
300 // Discover the authorization endpoint from the OIDC discovery document
301 let authorizationEndpoint: string;
302 try {
303 const discoveryUrl = cfg.oidcDiscoveryUrl.replace(/\/$/, "") + "/.well-known/openid-configuration";
304 const res = await fetch(discoveryUrl);
305 if (!res.ok) throw new Error(`Discovery returned ${res.status}`);
306 const doc = await res.json() as { authorization_endpoint: string };
307 authorizationEndpoint = doc.authorization_endpoint;
308 if (!authorizationEndpoint) throw new Error("No authorization_endpoint in discovery");
309 } catch (err) {
310 console.error("[oidc-sso] discovery error:", err);
311 return c.redirect(`/login?error=${encodeURIComponent("OIDC discovery failed")}`);
312 }
313
314 const base = getBaseUrl();
315 const redirectUri = `${base}/sso/oidc/${orgSlug}/callback`;
316 const state = generateId(16);
317 const nonce = generateId(16);
318
319 setCookie(c, "oidc_state", state, {
320 httpOnly: true,
321 secure: process.env.NODE_ENV === "production",
322 sameSite: "Lax",
323 path: "/",
324 maxAge: 600,
325 });
326 setCookie(c, "oidc_nonce", nonce, {
327 httpOnly: true,
328 secure: process.env.NODE_ENV === "production",
329 sameSite: "Lax",
330 path: "/",
331 maxAge: 600,
332 });
333 setCookie(c, "oidc_org", orgSlug, {
334 httpOnly: true,
335 secure: process.env.NODE_ENV === "production",
336 sameSite: "Lax",
337 path: "/",
338 maxAge: 600,
339 });
340
341 const params = new URLSearchParams({
342 client_id: cfg.oidcClientId,
343 redirect_uri: redirectUri,
344 response_type: "code",
345 scope: "openid email profile",
346 state,
347 nonce,
348 });
349
350 return c.redirect(`${authorizationEndpoint}?${params.toString()}`);
351});
352
353// ---------------------------------------------------------------------------
354// OIDC — per-org callback
355// ---------------------------------------------------------------------------
356
357samlSso.get("/sso/oidc/:orgSlug/callback", async (c) => {
358 const { orgSlug } = c.req.param();
359 const org = await getOrgBySlug(orgSlug);
360 if (!org) return c.redirect(`/login?error=${encodeURIComponent("Organization not found")}`);
361
362 const cfg = await getOrgSsoConfig(org.id);
363 if (!cfg || !cfg.enabled || cfg.provider !== "oidc") {
364 return c.redirect(`/login?error=${encodeURIComponent("OIDC SSO not enabled")}`);
365 }
366
367 const code = c.req.query("code");
368 const state = c.req.query("state");
369 const errCode = c.req.query("error");
370
371 if (errCode) {
372 return c.redirect(`/login?error=${encodeURIComponent(`IdP error: ${errCode}`)}`);
373 }
374 if (!code || !state) {
375 return c.redirect(`/login?error=${encodeURIComponent("Missing code or state")}`);
376 }
377
378 const expectedState = getCookie(c, "oidc_state");
379 const expectedOrg = getCookie(c, "oidc_org");
380 deleteCookie(c, "oidc_state", { path: "/" });
381 deleteCookie(c, "oidc_nonce", { path: "/" });
382 deleteCookie(c, "oidc_org", { path: "/" });
383
384 if (!expectedState || expectedState !== state || expectedOrg !== orgSlug) {
385 return c.redirect(`/login?error=${encodeURIComponent("OIDC state mismatch. Please try again.")}`);
386 }
387
388 try {
389 // Discover token endpoint
390 const discoveryUrl = cfg.oidcDiscoveryUrl!.replace(/\/$/, "") + "/.well-known/openid-configuration";
391 const discoveryRes = await fetch(discoveryUrl);
392 const discovery = await discoveryRes.json() as {
393 token_endpoint: string;
394 userinfo_endpoint: string;
395 };
396
397 const base = getBaseUrl();
398 const redirectUri = `${base}/sso/oidc/${orgSlug}/callback`;
399
400 // Exchange code for tokens
401 const tokenRes = await fetch(discovery.token_endpoint, {
402 method: "POST",
403 headers: { "Content-Type": "application/x-www-form-urlencoded" },
404 body: new URLSearchParams({
405 grant_type: "authorization_code",
406 code,
407 redirect_uri: redirectUri,
408 client_id: cfg.oidcClientId!,
409 client_secret: cfg.oidcClientSecret || "",
410 }).toString(),
411 });
412
413 if (!tokenRes.ok) {
414 throw new Error(`token_endpoint returned ${tokenRes.status}`);
415 }
416
417 const tokens = await tokenRes.json() as { access_token: string };
418
419 // Fetch userinfo
420 const userinfoRes = await fetch(discovery.userinfo_endpoint, {
421 headers: { Authorization: `Bearer ${tokens.access_token}` },
422 });
423 if (!userinfoRes.ok) {
424 throw new Error(`userinfo_endpoint returned ${userinfoRes.status}`);
425 }
426
427 const userinfo = await userinfoRes.json() as {
428 email?: string;
429 name?: string;
430 preferred_username?: string;
431 sub?: string;
432 };
433
434 const result = await findOrCreateSsoUser(
435 userinfo.email || "",
436 userinfo.name,
437 userinfo.preferred_username,
438 org.id
439 );
440 if (!result.ok) {
441 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
442 }
443
444 await db.insert(orgSsoSessions).values({
445 userId: result.userId,
446 orgId: org.id,
447 idpSessionId: userinfo.sub ?? null,
448 expiresAt: new Date(Date.now() + 8 * 60 * 60 * 1000),
449 });
450
451 const token = await createUserSession(result.userId);
452 setCookie(c, "session", token, sessionCookieOptions());
453 return c.redirect("/");
454 } catch (err) {
455 console.error("[oidc-sso] callback error:", err);
456 return c.redirect(`/login?error=${encodeURIComponent("OIDC authentication failed.")}`);
457 }
458});
459
460// ---------------------------------------------------------------------------
461// SAML response parser (lightweight, no heavy deps)
462// ---------------------------------------------------------------------------
463
464function escapeXml(s: string): string {
465 return s
466 .replace(/&/g, "&amp;")
467 .replace(/</g, "&lt;")
468 .replace(/>/g, "&gt;")
469 .replace(/"/g, "&quot;")
470 .replace(/'/g, "&apos;");
471}
472
473interface SamlClaims {
474 ok: true;
475 email: string;
476 name?: string;
477 username?: string;
478 sessionIndex?: string;
479}
480
481interface SamlError {
482 ok: false;
483 error: string;
484}
485
486/**
487 * Lightweight SAML response parser.
488 *
489 * Steps:
490 * 1. Base64-decode the SAMLResponse.
491 * 2. Verify the XML Signature using the IdP certificate (if provided).
492 * 3. Extract email/name/username attributes via regex against the XML.
493 *
494 * This avoids pulling in full SAML libraries (samlify, passport-saml, etc.)
495 * which ship ~200 packages including xml-crypto. For production hardening,
496 * operators should upgrade to a dedicated SAML library.
497 */
498function parseSamlResponse(
499 base64Response: string,
500 idpCertPem: string,
501 cfg: { attributeMapping?: Record<string, string> | null }
502): SamlClaims | SamlError {
503 let xml: string;
504 try {
505 xml = Buffer.from(base64Response, "base64").toString("utf-8");
506 } catch {
507 return { ok: false, error: "Failed to decode SAMLResponse" };
508 }
509
510 // Basic structure check
511 if (!xml.includes("saml") && !xml.includes("SAML")) {
512 return { ok: false, error: "Invalid SAML response format" };
513 }
514
515 // Check for SAML status success
516 if (
517 xml.includes("urn:oasis:names:tc:SAML:2.0:status:Responder") ||
518 xml.includes("urn:oasis:names:tc:SAML:2.0:status:Requester")
519 ) {
520 const statusMatch = xml.match(/<samlp?:StatusMessage[^>]*>([^<]*)</);
521 const msg = statusMatch ? statusMatch[1].trim() : "IdP rejected the request";
522 return { ok: false, error: `SAML error: ${msg}` };
523 }
524
525 // Signature verification — only if IdP cert provided
526 if (idpCertPem && idpCertPem.trim()) {
527 const sigValid = verifySamlSignature(xml, idpCertPem);
528 if (!sigValid) {
529 return { ok: false, error: "SAML signature verification failed" };
530 }
531 }
532
533 // Extract attributes
534 const mapping = cfg.attributeMapping || {
535 email: "email",
536 name: "name",
537 username: "preferred_username",
538 };
539
540 const attrs = extractSamlAttributes(xml);
541
542 // Try to find email from NameID or attribute
543 let email = attrs[mapping.email ?? "email"]
544 || attrs["email"]
545 || attrs["mail"]
546 || attrs["emailAddress"]
547 || extractNameId(xml)
548 || "";
549
550 email = email.trim();
551 if (!email || !email.includes("@")) {
552 return { ok: false, error: "No email found in SAML assertion" };
553 }
554
555 const name =
556 attrs[mapping.name ?? "name"] ||
557 attrs["displayName"] ||
558 attrs["cn"] ||
559 attrs["givenName"] ||
560 undefined;
561
562 const username =
563 attrs[mapping.username ?? "preferred_username"] ||
564 attrs["uid"] ||
565 attrs["samaccountname"] ||
566 undefined;
567
568 const sessionIndex = extractSessionIndex(xml);
569
570 return { ok: true, email, name, username, sessionIndex };
571}
572
573/** Extract NameID value from SAML XML. */
574function extractNameId(xml: string): string {
575 const m = xml.match(/<(?:saml|saml2):NameID[^>]*>([^<]+)<\/(?:saml|saml2):NameID>/);
576 return m ? m[1].trim() : "";
577}
578
579/** Extract SessionIndex from AuthnStatement. */
580function extractSessionIndex(xml: string): string | undefined {
581 const m = xml.match(/SessionIndex="([^"]+)"/);
582 return m ? m[1] : undefined;
583}
584
585/** Extract all AttributeValue entries keyed by Name or FriendlyName. */
586function extractSamlAttributes(xml: string): Record<string, string> {
587 const attrs: Record<string, string> = {};
588 // Match <Attribute Name="..." FriendlyName="..."><AttributeValue>...</AttributeValue>
589 const attrRe =
590 /<(?:saml|saml2):Attribute\s[^>]*(?:Name|FriendlyName)="([^"]+)"[^>]*>\s*<(?:saml|saml2):AttributeValue[^>]*>([^<]+)<\/(?:saml|saml2):AttributeValue>/gi;
591 let m: RegExpExecArray | null;
592 while ((m = attrRe.exec(xml)) !== null) {
593 const key = m[1].toLowerCase().replace(/^.*\//, ""); // strip namespace URN prefix
594 attrs[key] = m[2].trim();
595 }
596 return attrs;
597}
598
599/**
600 * Verify the XML-DSig signature in the SAML response against the IdP certificate.
601 * Uses Node's built-in `crypto.createVerify` to avoid heavy deps.
602 *
603 * This is a best-effort verification: it handles the common case where the
604 * entire Response or Assertion element is signed. For full enveloped-signature
605 * support, use a library like `xml-crypto`.
606 */
607function verifySamlSignature(xml: string, certPem: string): boolean {
608 try {
609 // Extract the SignatureValue
610 const sigValueMatch = xml.match(
611 /<(?:ds:)?SignatureValue[^>]*>([\s\S]*?)<\/(?:ds:)?SignatureValue>/
612 );
613 if (!sigValueMatch) return false; // no signature at all — treat as invalid
614 const signatureValue = Buffer.from(
615 sigValueMatch[1].replace(/\s+/g, ""),
616 "base64"
617 );
618
619 // Extract the SignedInfo element (what was actually signed)
620 const signedInfoMatch = xml.match(
621 /(<(?:ds:)?SignedInfo[\s\S]*?<\/(?:ds:)?SignedInfo>)/
622 );
623 if (!signedInfoMatch) return false;
624 const signedInfoXml = signedInfoMatch[1];
625
626 // Detect algorithm from SignatureMethod
627 const algMatch = xml.match(/Algorithm="([^"]+)"/);
628 const alg = algMatch ? algMatch[1] : "";
629 let hashAlg: string;
630 if (alg.includes("sha256")) hashAlg = "SHA256";
631 else if (alg.includes("sha512")) hashAlg = "SHA512";
632 else hashAlg = "SHA1";
633
634 // Normalise the PEM cert
635 let pem = certPem.trim();
636 if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
637 pem = `-----BEGIN CERTIFICATE-----\n${pem}\n-----END CERTIFICATE-----`;
638 }
639
640 const verifier = crypto.createVerify(`RSA-${hashAlg}`);
641 verifier.update(signedInfoXml, "utf8");
642 return verifier.verify(pem, signatureValue);
643 } catch (err) {
644 console.warn("[saml-sso] signature verification error:", err);
645 return false;
646 }
647}
648
649export default samlSso;
Addedsrc/routes/scim.tsx+479−0View fileUnifiedSplit
1/**
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;
0480