Skip to content

Commit 6baadf0

Browse files
steebchenclaude
andcommitted
feat(auth): auto-join org by google sso email domain
Let enterprise org admins configure an email domain so users signing in with a Google account on that domain auto-join the org as a developer. - schema: organization.ssoAutoJoinDomain + unique index - api: PATCH /orgs guards (enterprise + owner/admin), domain validation, 409 on duplicate domain, audit logging - auth hook: auto-join on verified Google sign-in before default-org creation; runs for new and existing users, idempotent per membership - email: notify the joined user via a transactional email - ui: org SSO auto-join settings card on the Team page (enterprise gated) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 38447ef commit 6baadf0

12 files changed

Lines changed: 27374 additions & 23 deletions

File tree

apps/api/src/auth/config.ts

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { validateEmail } from "@/utils/email-validation.js";
1414
import { sendTransactionalEmail } from "@/utils/email.js";
1515
import { resolveSignupName } from "@/utils/infer-name.js";
1616
import { getOrCreatePersonalOrg } from "@/utils/personal-org.js";
17+
import { autoJoinByEmailDomain } from "@/utils/sso-domain.js";
1718

1819
import { logAuditEvent } from "@llmgateway/audit";
1920
import { db, eq, tables } from "@llmgateway/db";
@@ -1131,13 +1132,40 @@ The LLM Gateway Team`.trim();
11311132
const activeOrganizations = userOrganizations.filter(
11321133
(uo) => uo.organization?.status !== "deleted",
11331134
);
1134-
const hasActiveDashboardOrganization = activeOrganizations.some(
1135+
const hadActiveDashboardOrganization = activeOrganizations.some(
11351136
(uo) => uo.organization?.kind === "default",
11361137
);
11371138
const hasActivePersonalOrganization = activeOrganizations.some(
11381139
(uo) => uo.organization?.kind === "devpass",
11391140
);
11401141

1142+
// Google SSO domain auto-join: if this user has a Google account and
1143+
// their verified email domain matches an enterprise org's configured
1144+
// SSO domain, add them to that org as a developer. A successful join
1145+
// gives them an active dashboard org, so the default-org creation below
1146+
// is skipped (no redundant personal "Default Organization"). Existing
1147+
// members are a no-op. Never fatal to login.
1148+
let autoJoinedOrgId: string | null = null;
1149+
if (newSession.user.emailVerified && dbUser?.email) {
1150+
const googleAccount = await db.query.account.findFirst({
1151+
where: {
1152+
userId: { eq: userId },
1153+
providerId: { eq: "google" },
1154+
},
1155+
});
1156+
if (googleAccount) {
1157+
try {
1158+
autoJoinedOrgId = await autoJoinByEmailDomain({
1159+
userId,
1160+
email: dbUser.email,
1161+
name: dbUser.name,
1162+
});
1163+
} catch (error) {
1164+
logger.error("SSO auto-join failed", error);
1165+
}
1166+
}
1167+
}
1168+
11411169
// DevPass (code app) signups get a personal organization instead of
11421170
// the shared "Default Organization" used by the main LLM Gateway
11431171
// dashboard. For social sign-in the request hits the OAuth callback
@@ -1154,29 +1182,33 @@ The LLM Gateway Team`.trim();
11541182
});
11551183
if (
11561184
hasActivePersonalOrganization ||
1157-
hasActiveDashboardOrganization
1185+
hadActiveDashboardOrganization
11581186
) {
11591187
return;
11601188
}
1161-
} else if (hasActiveDashboardOrganization) {
1189+
} else if (hadActiveDashboardOrganization) {
11621190
return;
11631191
} else {
1164-
const cookieHeader = ctx.request?.headers.get("cookie") ?? "";
1165-
const referralMatch = cookieHeader.match(
1166-
/llmgateway_referral=([^;]+)/,
1167-
);
1192+
// A domain auto-join already gave the user an active dashboard org,
1193+
// so only create the fallback default org when they didn't join one.
1194+
if (!autoJoinedOrgId) {
1195+
const cookieHeader = ctx.request?.headers.get("cookie") ?? "";
1196+
const referralMatch = cookieHeader.match(
1197+
/llmgateway_referral=([^;]+)/,
1198+
);
11681199

1169-
await getOrCreateDefaultOrganization(
1170-
{
1171-
id: userId,
1172-
email: newSession.user.email,
1173-
},
1174-
{
1175-
referralOrganizationId: referralMatch
1176-
? decodeURIComponent(referralMatch[1])
1177-
: null,
1178-
},
1179-
);
1200+
await getOrCreateDefaultOrganization(
1201+
{
1202+
id: userId,
1203+
email: newSession.user.email,
1204+
},
1205+
{
1206+
referralOrganizationId: referralMatch
1207+
? decodeURIComponent(referralMatch[1])
1208+
: null,
1209+
},
1210+
);
1211+
}
11801212

11811213
if (activeOrganizations.length > 0) {
11821214
return;

apps/api/src/routes/organization.ts

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isInvoiceableTransaction,
1414
isRefundTransaction,
1515
} from "@/utils/invoice.js";
16+
import { isConfigurableDomain, normalizeDomain } from "@/utils/sso-domain.js";
1617

1718
import { logAuditEvent } from "@llmgateway/audit";
1819
import {
@@ -63,6 +64,7 @@ const organizationSchema = z.object({
6364
apiKeyLimit: z.number().nullable(),
6465
retentionLevel: z.enum(["retain", "none"]),
6566
providerCompliancePolicy: providerCompliancePolicySchema.nullable(),
67+
ssoAutoJoinDomain: z.string().nullable(),
6668
status: z.enum(["active", "inactive", "deleted"]).nullable(),
6769
autoTopUpEnabled: z.boolean(),
6870
autoTopUpThreshold: z.string().nullable(),
@@ -138,6 +140,7 @@ const updateOrganizationSchema = z.object({
138140
providerCompliancePolicy: providerCompliancePolicySchema
139141
.nullable()
140142
.optional(),
143+
ssoAutoJoinDomain: z.string().max(253).nullable().optional(),
141144
autoTopUpEnabled: z.boolean().optional(),
142145
autoTopUpThreshold: z.number().min(5).optional(),
143146
autoTopUpAmount: z
@@ -494,6 +497,7 @@ organization.openapi(updateOrganization, async (c) => {
494497
billingNotes,
495498
retentionLevel,
496499
providerCompliancePolicy,
500+
ssoAutoJoinDomain,
497501
autoTopUpEnabled,
498502
autoTopUpThreshold,
499503
autoTopUpAmount,
@@ -559,6 +563,37 @@ organization.openapi(updateOrganization, async (c) => {
559563
}
560564
}
561565

566+
// Google SSO domain auto-join is an enterprise feature managed by owners and
567+
// admins. The value is normalized and validated before storage.
568+
let normalizedSsoDomain: string | null | undefined;
569+
if (ssoAutoJoinDomain !== undefined) {
570+
if (userOrganization.organization?.plan !== "enterprise") {
571+
throw new HTTPException(403, {
572+
message: "SSO auto-join requires an enterprise plan",
573+
});
574+
}
575+
if (
576+
userOrganization.role !== "owner" &&
577+
userOrganization.role !== "admin"
578+
) {
579+
throw new HTTPException(403, {
580+
message: "Only owners and admins can configure SSO auto-join",
581+
});
582+
}
583+
if (ssoAutoJoinDomain === null || ssoAutoJoinDomain.trim() === "") {
584+
normalizedSsoDomain = null;
585+
} else {
586+
const normalized = normalizeDomain(ssoAutoJoinDomain);
587+
if (!isConfigurableDomain(normalized)) {
588+
throw new HTTPException(400, {
589+
message:
590+
"Invalid or disallowed domain. Use a corporate domain like acme.com (consumer email domains are not allowed).",
591+
});
592+
}
593+
normalizedSsoDomain = normalized;
594+
}
595+
}
596+
562597
const updateData: any = {};
563598
if (name !== undefined) {
564599
updateData.name = name;
@@ -584,6 +619,9 @@ organization.openapi(updateOrganization, async (c) => {
584619
if (providerCompliancePolicy !== undefined) {
585620
updateData.providerCompliancePolicy = providerCompliancePolicy;
586621
}
622+
if (normalizedSsoDomain !== undefined) {
623+
updateData.ssoAutoJoinDomain = normalizedSsoDomain;
624+
}
587625
if (autoTopUpEnabled !== undefined) {
588626
updateData.autoTopUpEnabled = autoTopUpEnabled;
589627
if (autoTopUpEnabled && !userOrganization.organization?.autoTopUpEnabled) {
@@ -599,11 +637,24 @@ organization.openapi(updateOrganization, async (c) => {
599637
updateData.autoTopUpAmount = autoTopUpAmount.toString();
600638
}
601639

602-
const [updatedOrganization] = await db
603-
.update(tables.organization)
604-
.set(updateData)
605-
.where(eq(tables.organization.id, id))
606-
.returning();
640+
let updatedOrganization;
641+
try {
642+
[updatedOrganization] = await db
643+
.update(tables.organization)
644+
.set(updateData)
645+
.where(eq(tables.organization.id, id))
646+
.returning();
647+
} catch (err) {
648+
const code =
649+
(err as { code?: string; cause?: { code?: string } })?.code ??
650+
(err as { cause?: { code?: string } })?.cause?.code;
651+
if (code === "23505" && normalizedSsoDomain) {
652+
throw new HTTPException(409, {
653+
message: "This domain is already configured by another organization.",
654+
});
655+
}
656+
throw err;
657+
}
607658

608659
// Build changes metadata for audit log
609660
const changes: Record<string, { old: unknown; new: unknown }> = {};
@@ -717,6 +768,27 @@ organization.openapi(updateOrganization, async (c) => {
717768
});
718769
}
719770

771+
if (
772+
normalizedSsoDomain !== undefined &&
773+
normalizedSsoDomain !== oldOrg.ssoAutoJoinDomain
774+
) {
775+
await logAuditEvent({
776+
organizationId: id,
777+
userId: user.id,
778+
action: "organization.sso_auto_join.update",
779+
resourceType: "organization",
780+
resourceId: id,
781+
metadata: {
782+
changes: {
783+
ssoAutoJoinDomain: {
784+
old: oldOrg.ssoAutoJoinDomain,
785+
new: normalizedSsoDomain,
786+
},
787+
},
788+
},
789+
});
790+
}
791+
720792
return c.json({
721793
message: "Organization updated successfully",
722794
organization: updatedOrganization,

apps/api/src/utils/email.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,96 @@ export function generatePaymentFailureEmailHtml(
283283
`.trim();
284284
}
285285

286+
export function generateAutoJoinEmailHtml(
287+
userName: string,
288+
organizationName: string,
289+
organizationId: string,
290+
): string {
291+
const escapedOrgName = escapeHtml(organizationName);
292+
const greetingName = userName.trim() ? escapeHtml(userName.trim()) : "there";
293+
const uiUrl = process.env.UI_URL ?? "https://llmgateway.io";
294+
const dashboardUrl = `${uiUrl}/dashboard/${encodeURIComponent(organizationId)}`;
295+
296+
return `
297+
<!DOCTYPE html>
298+
<html lang="en">
299+
<head>
300+
<meta charset="UTF-8">
301+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
302+
<title>You've been added to ${escapedOrgName} - LLMGateway</title>
303+
</head>
304+
<body
305+
style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #ffffff;"
306+
>
307+
<table role="presentation" style="width: 100%; border-collapse: collapse;">
308+
<tr>
309+
<td align="center" style="padding: 40px 20px;">
310+
<table role="presentation" style="max-width: 600px; width: 100%; border-collapse: collapse;">
311+
<!-- Header -->
312+
<tr>
313+
<td
314+
style="background-color: #000000; padding: 40px 30px; text-align: center; border-radius: 8px 8px 0 0;"
315+
>
316+
<h1 style="margin: 0; color: #ffffff; font-size: 28px; font-weight: 600;">Welcome to ${escapedOrgName}</h1>
317+
</td>
318+
</tr>
319+
320+
<!-- Main Content -->
321+
<tr>
322+
<td style="background-color: #f8f9fa; padding: 40px 30px; border-radius: 0 0 8px 8px;">
323+
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #333333;">
324+
Hi ${greetingName},
325+
</p>
326+
327+
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #333333;">
328+
You've been added to <strong>${escapedOrgName}</strong> on LLM Gateway because your
329+
email domain matches the organization's single sign-on settings. You now have access
330+
to its projects and shared resources.
331+
</p>
332+
333+
<!-- CTA Button -->
334+
<table role="presentation" style="width: 100%; border-collapse: collapse;">
335+
<tr>
336+
<td align="center" style="padding: 10px 0;">
337+
<a
338+
href="${dashboardUrl}"
339+
style="display: inline-block; background-color: #000000; color: #ffffff; padding: 14px 40px; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;"
340+
>Open dashboard</a>
341+
</td>
342+
</tr>
343+
</table>
344+
345+
<p style="margin: 30px 0 0 0; font-size: 14px; line-height: 1.6; color: #666666;">
346+
If you don't think you should have access to this organization, please reply to this
347+
email and we'll help sort it out.
348+
</p>
349+
</td>
350+
</tr>
351+
352+
<!-- Footer -->
353+
<tr>
354+
<td
355+
style="padding: 30px 40px; background-color: #f8f9fa; border-radius: 0 0 8px 8px; border-top: 1px solid #e9ecef;"
356+
>
357+
<p style="margin: 0 0 12px; color: #666666; font-size: 14px; line-height: 1.6;">
358+
Need help? Check out our <a
359+
href="https://docs.llmgateway.io" style="color: #000000; text-decoration: none;"
360+
>documentation</a> or reply to this email for any questions.
361+
</p>
362+
<p style="margin: 0; color: #999999; font-size: 12px;">
363+
© 2025 LLM Gateway. All rights reserved. This is a transactional email and it can't be unsubscribed from.
364+
</p>
365+
</td>
366+
</tr>
367+
</table>
368+
</td>
369+
</tr>
370+
</table>
371+
</body>
372+
</html>
373+
`.trim();
374+
}
375+
286376
export function generateDevPlanDuplicateCardEmailHtml(
287377
organizationName: string,
288378
): string {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
CONSUMER_EMAIL_DOMAINS,
5+
extractEmailDomain,
6+
isConfigurableDomain,
7+
normalizeDomain,
8+
} from "./sso-domain.js";
9+
10+
describe("normalizeDomain", () => {
11+
it("lowercases, trims, and strips a leading @", () => {
12+
expect(normalizeDomain(" @Acme.COM ")).toBe("acme.com");
13+
expect(normalizeDomain("Example.io")).toBe("example.io");
14+
expect(normalizeDomain("acme.com")).toBe("acme.com");
15+
});
16+
});
17+
18+
describe("extractEmailDomain", () => {
19+
it("returns the lowercased domain after the last @", () => {
20+
expect(extractEmailDomain("Jane.Doe@Acme.com")).toBe("acme.com");
21+
expect(extractEmailDomain("weird@sub@corp.example.com")).toBe(
22+
"corp.example.com",
23+
);
24+
});
25+
26+
it("returns null for malformed addresses", () => {
27+
expect(extractEmailDomain("no-at-sign")).toBeNull();
28+
expect(extractEmailDomain("@leading.com")).toBeNull();
29+
expect(extractEmailDomain("trailing@")).toBeNull();
30+
expect(extractEmailDomain("")).toBeNull();
31+
});
32+
});
33+
34+
describe("isConfigurableDomain", () => {
35+
it("accepts well-formed corporate domains", () => {
36+
expect(isConfigurableDomain("acme.com")).toBe(true);
37+
expect(isConfigurableDomain("mail.corp.example.io")).toBe(true);
38+
});
39+
40+
it("rejects consumer email providers", () => {
41+
for (const consumer of CONSUMER_EMAIL_DOMAINS) {
42+
expect(isConfigurableDomain(consumer)).toBe(false);
43+
}
44+
});
45+
46+
it("rejects malformed domains", () => {
47+
expect(isConfigurableDomain("acme")).toBe(false);
48+
expect(isConfigurableDomain("acme.")).toBe(false);
49+
expect(isConfigurableDomain("@acme.com")).toBe(false);
50+
expect(isConfigurableDomain("")).toBe(false);
51+
});
52+
});

0 commit comments

Comments
 (0)