Skip to content

Commit 3c86c6f

Browse files
authored
Merge pull request #2926 from dubinc/fix-saml
Fix SAML SSO Enforcement Implementation
2 parents 3a6d6cb + 49afc34 commit 3c86c6f

File tree

8 files changed

+134
-48
lines changed

8 files changed

+134
-48
lines changed

apps/web/app/api/workspaces/[idOrSlug]/route.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { deleteWorkspace } from "@/lib/api/workspaces/delete-workspace";
66
import { prefixWorkspaceId } from "@/lib/api/workspaces/workspace-id";
77
import { withWorkspace } from "@/lib/auth";
88
import { getFeatureFlags } from "@/lib/edge-config";
9-
import { isGenericEmail } from "@/lib/is-generic-email";
109
import { jackson } from "@/lib/jackson";
1110
import { storage } from "@/lib/storage";
1211
import z from "@/lib/zod";
@@ -107,24 +106,14 @@ export const PATCH = withWorkspace(
107106
)
108107
: null;
109108

110-
let ssoEmailDomain: string | null | undefined;
111-
112109
if (enforceSAML) {
113110
if (workspace.plan !== "enterprise") {
114111
throw new DubApiError({
115112
code: "forbidden",
116113
message: "SAML SSO is only available on enterprise plans.",
117114
});
118115
}
119-
ssoEmailDomain = session.user.email.split("@")[1];
120-
if (isGenericEmail(session.user.email)) {
121-
throw new DubApiError({
122-
code: "forbidden",
123-
message: "SAML SSO is not available for generic emails.",
124-
});
125-
}
126116

127-
// Check if SAML is configured before enforcing ssoEmailDomain
128117
const { apiController } = await jackson();
129118

130119
const connections = await apiController.getConnections({
@@ -138,8 +127,6 @@ export const PATCH = withWorkspace(
138127
message: "SAML SSO is not configured for this workspace.",
139128
});
140129
}
141-
} else if (enforceSAML === false) {
142-
ssoEmailDomain = null;
143130
}
144131

145132
try {
@@ -156,7 +143,9 @@ export const PATCH = withWorkspace(
156143
allowedHostnames: validHostnames,
157144
}),
158145
...(publishableKey !== undefined && { publishableKey }),
159-
...(ssoEmailDomain !== undefined && { ssoEmailDomain }),
146+
...(enforceSAML !== undefined && {
147+
ssoEnforcedAt: enforceSAML ? new Date() : null,
148+
}),
160149
},
161150
include: {
162151
domains: {
@@ -224,7 +213,7 @@ export const PATCH = withWorkspace(
224213
if (error.code === "P2002") {
225214
throw new DubApiError({
226215
code: "conflict",
227-
message: `The ${ssoEmailDomain ? "email domain" : "slug"} "${ssoEmailDomain || slug}" is already in use.`,
216+
message: `The slug "${slug}" is already in use.`,
228217
});
229218
} else {
230219
throw new DubApiError({

apps/web/app/api/workspaces/[idOrSlug]/saml/route.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { DubApiError } from "@/lib/api/errors";
12
import { withWorkspace } from "@/lib/auth";
3+
import { isGenericEmail } from "@/lib/is-generic-email";
24
import { jackson, samlAudience } from "@/lib/jackson";
35
import z from "@/lib/zod";
46
import { prisma } from "@dub/prisma";
@@ -52,10 +54,20 @@ export const GET = withWorkspace(
5254

5355
// POST /api/workspaces/[idOrSlug]/saml – create a new SAML connection
5456
export const POST = withWorkspace(
55-
async ({ req, workspace }) => {
57+
async ({ req, workspace, session }) => {
5658
const { metadataUrl, encodedRawMetadata } =
5759
createSAMLConnectionSchema.parse(await req.json());
5860

61+
const ssoEmailDomain = session.user.email.split("@")[1].toLocaleLowerCase();
62+
63+
if (isGenericEmail(ssoEmailDomain)) {
64+
throw new DubApiError({
65+
code: "bad_request",
66+
message:
67+
"SAML configuration requires you to be logged in with your organization’s work email.",
68+
});
69+
}
70+
5971
const { apiController } = await jackson();
6072

6173
const data = await apiController.createSAMLConnection({
@@ -67,6 +79,15 @@ export const POST = withWorkspace(
6779
product: "Dub",
6880
});
6981

82+
await prisma.project.update({
83+
where: {
84+
id: workspace.id,
85+
},
86+
data: {
87+
ssoEmailDomain,
88+
},
89+
});
90+
7091
return NextResponse.json(data);
7192
},
7293
{
@@ -94,6 +115,7 @@ export const DELETE = withWorkspace(
94115
},
95116
data: {
96117
ssoEmailDomain: null,
118+
ssoEnforcedAt: null,
97119
},
98120
});
99121

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/security/saml.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,10 @@ import {
1717
} from "@dub/ui";
1818
import { SAML_PROVIDERS } from "@dub/utils";
1919
import { Lock, ShieldOff } from "lucide-react";
20-
import { useSession } from "next-auth/react";
2120
import { useMemo, useState } from "react";
2221

2322
export function SAML() {
24-
const { data: session } = useSession();
25-
const { id: workspaceId, plan } = useWorkspace();
23+
const { id: workspaceId, plan, ssoEmailDomain } = useWorkspace();
2624
const { SAMLModal, setShowSAMLModal } = useSAMLModal();
2725
const { RemoveSAMLModal, setShowRemoveSAMLModal } = useRemoveSAMLModal();
2826
const { provider, configured, loading } = useSAML();
@@ -33,7 +31,7 @@ export function SAML() {
3331
isLoading,
3432
update,
3533
} = useOptimisticUpdate<{
36-
ssoEmailDomain: string | null;
34+
ssoEnforcedAt: string | null;
3735
}>(`/api/workspaces/${workspaceId}`, {
3836
loading: "Saving SAML SSO login setting...",
3937
success: "SAML SSO login setting has been updated successfully.",
@@ -96,17 +94,16 @@ export function SAML() {
9694
const { error } = await response.json();
9795
throw new Error(error.message || "Failed to update workspace.");
9896
}
97+
9998
const data = await response.json();
10099

101100
return {
102-
ssoEmailDomain: data.ssoEmailDomain,
101+
ssoEnforcedAt: data.ssoEnforcedAt,
103102
};
104103
};
105104

106105
await update(updateWorkspace, {
107-
ssoEmailDomain: workspaceData?.ssoEmailDomain
108-
? null
109-
: session?.user?.email?.split("@")[1] || "domain.com", // fallback to dummy domain for optimistic update
106+
ssoEnforcedAt: enforceSAML ? new Date().toISOString() : null,
110107
});
111108
};
112109

@@ -208,18 +205,18 @@ export function SAML() {
208205
Require workspace members to login with SAML to access this
209206
workspace
210207
</label>
211-
{workspaceData?.ssoEmailDomain && (
208+
{workspaceData?.ssoEnforcedAt && (
212209
<Badge
213210
variant="blueGradient"
214211
className="flex items-center gap-1"
215212
>
216213
<Globe2 className="size-3" />
217-
{workspaceData.ssoEmailDomain}
214+
{ssoEmailDomain}
218215
</Badge>
219216
)}
220217
</div>
221218
<Switch
222-
checked={!!workspaceData?.ssoEmailDomain}
219+
checked={workspaceData?.ssoEnforcedAt !== null}
223220
loading={isLoading}
224221
disabled={plan !== "enterprise"}
225222
fn={handleSSOEnforcementChange}

apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { isGenericEmail } from "../../is-generic-email";
66
// Checks if SAML SSO is enforced for a given email domain
77
export const isSamlEnforcedForEmailDomain = async (email: string) => {
88
const hostname = (await headers()).get("host");
9-
const emailDomain = email.split("@")[1].toLowerCase();
9+
const emailDomain = email.split("@")[1].toLocaleLowerCase();
1010

1111
if (
1212
!hostname ||
@@ -17,11 +17,18 @@ export const isSamlEnforcedForEmailDomain = async (email: string) => {
1717
return false;
1818
}
1919

20-
const workspace = await prisma.project.count({
20+
const workspace = await prisma.project.findUnique({
2121
where: {
2222
ssoEmailDomain: emailDomain,
2323
},
24+
select: {
25+
ssoEnforcedAt: true,
26+
},
2427
});
2528

26-
return workspace > 0;
29+
if (workspace?.ssoEnforcedAt) {
30+
return true;
31+
}
32+
33+
return false;
2734
};

apps/web/lib/auth/options.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -427,16 +427,19 @@ export const authOptions: NextAuthOptions = {
427427

428428
if (workspace) {
429429
const { ssoEmailDomain } = workspace;
430+
const emailDomain = user.email.split("@")[1];
430431

431-
if (ssoEmailDomain) {
432-
const emailDomain = user.email.split("@")[1];
432+
// ssoEmailDomain should be required for all SAML enabled workspace
433+
// this should not happen
434+
if (!ssoEmailDomain) {
435+
return false;
436+
}
433437

434-
if (
435-
emailDomain.toLocaleLowerCase() !==
436-
ssoEmailDomain.toLocaleLowerCase()
437-
) {
438-
return false;
439-
}
438+
if (
439+
emailDomain.toLocaleLowerCase() !==
440+
ssoEmailDomain.toLocaleLowerCase()
441+
) {
442+
return false;
440443
}
441444

442445
await Promise.allSettled([

apps/web/lib/zod/schemas/workspaces.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ export const WorkspaceSchema = z
122122
.nullable()
123123
.describe("Specifies hostnames permitted for client-side click tracking.")
124124
.openapi({ example: ["dub.sh"] }),
125+
ssoEmailDomain: z.string().nullable(),
126+
ssoEnforcedAt: z.date().nullable(),
125127
})
126128
.openapi({
127129
title: "Workspace",
@@ -167,7 +169,6 @@ export const WorkspaceSchemaExtended = WorkspaceSchema.extend({
167169
}),
168170
),
169171
publishableKey: z.string().nullable(),
170-
ssoEmailDomain: z.string().nullable(),
171172
});
172173

173174
export const OnboardingUsageSchema = z.object({
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { prisma } from "@dub/prisma";
2+
import "dotenv-flow/config";
3+
4+
async function main() {
5+
const workspaces = await prisma.project.findMany({
6+
where: {
7+
ssoEmailDomain: null,
8+
plan: "enterprise",
9+
},
10+
select: {
11+
id: true,
12+
name: true,
13+
ssoEmailDomain: true,
14+
users: {
15+
where: {
16+
role: "owner",
17+
},
18+
orderBy: {
19+
createdAt: "asc",
20+
},
21+
take: 1,
22+
select: {
23+
user: {
24+
select: {
25+
email: true,
26+
},
27+
},
28+
},
29+
},
30+
},
31+
});
32+
33+
const workspacesToUpdate: any[] = [];
34+
35+
for (const workspace of workspaces) {
36+
const email = workspace.users[0]?.user?.email;
37+
const emailDomain = email ? email.split("@")[1]?.toLowerCase() : undefined;
38+
39+
if (!emailDomain) {
40+
console.log(`Workspace ${workspace.name} has no email domain`);
41+
continue;
42+
}
43+
44+
workspacesToUpdate.push({
45+
id: workspace.id,
46+
name: workspace.name,
47+
ssoEmailDomain: emailDomain,
48+
});
49+
}
50+
51+
console.table(workspacesToUpdate);
52+
53+
// await Promise.allSettled(
54+
// workspacesToUpdate.map((workspace) =>
55+
// prisma.project.update({
56+
// where: {
57+
// id: workspace.id,
58+
// },
59+
// data: {
60+
// ssoEmailDomain: workspace.ssoEmailDomain,
61+
// },
62+
// }),
63+
// ),
64+
// );
65+
}
66+
67+
main();

packages/prisma/schema/workspace.prisma

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,15 @@ model Project {
4141
allowedHostnames Json?
4242
publishableKey String? @unique // for the client-side publishable key
4343
44-
conversionEnabled Boolean @default(false) // Whether to enable conversion tracking for links by default
45-
webhookEnabled Boolean @default(false)
46-
ssoEnabled Boolean @default(false) // TODO: this is not used
47-
dotLinkClaimed Boolean @default(false)
48-
ssoEmailDomain String? @unique
49-
50-
createdAt DateTime @default(now())
51-
updatedAt DateTime @updatedAt
52-
usageLastChecked DateTime @default(now())
44+
conversionEnabled Boolean @default(false) // Whether to enable conversion tracking for links by default
45+
webhookEnabled Boolean @default(false)
46+
ssoEnabled Boolean @default(false) // TODO: this is not used
47+
dotLinkClaimed Boolean @default(false)
48+
ssoEmailDomain String? @unique
49+
ssoEnforcedAt DateTime?
50+
createdAt DateTime @default(now())
51+
updatedAt DateTime @updatedAt
52+
usageLastChecked DateTime @default(now())
5353
5454
users ProjectUsers[]
5555
invites ProjectInvite[]

0 commit comments

Comments
 (0)