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

saml-sso.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.

saml-sso.tsxBlame649 lines · 1 contributor
3a845e4Claude1/**
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;