1
29import { test, expect, request } from "@playwright/test";
30import type { PrismaClient } from "@prisma/client";
31import { startMockSamlIdp, type MockSamlIdp } from "../mocks/saml-idp";
32import { startMockOidc, type MockOidc } from "../mocks/oidc-issuer";
33
34const API = process.env.E2E_API_URL ?? "http://localhost:4000";
35const WEB = process.env.E2E_BASE_URL ?? "http://localhost:3000";
36const DSN = process.env.DATABASE_URL ?? "postgresql://factory:factory@localhost:5432/factory";
37const RUN = Math.random().toString(36).slice(2, 8);
38
39function orgName() {
40 return `sso-e2e-${RUN}`;
41}
42
43const cleanups: Array<() => Promise<void>> = [];
44test.afterAll(async () => {
45 const { PrismaClient: PC } = await import("@prisma/client");
46 const prisma = new PC({ datasources: { db: { url: DSN } } });
47 for (const fn of cleanups.splice(0).reverse()) {
48 try {
49 await fn();
50 } catch {
51
52 }
53 }
54 await prisma.$disconnect();
55});
56
57async function prisma(): Promise<PrismaClient> {
58 const { PrismaClient: PC } = await import("@prisma/client");
59 return new PC({ datasources: { db: { url: DSN } } });
60}
61
62
63async function setupOrg(token: string): Promise<{
64 orgId: string;
65 slug: string;
66 teamWsId: string;
67}> {
68 const orgRes = await (
69 await fetch(`${API}/api/v1/orgs`, {
70 method: "POST",
71 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
72 body: JSON.stringify({ name: orgName() })
73 })
74 ).json();
75 const orgId = orgRes.id as string;
76 const slug = orgRes.slug as string;
77
78 const wsRes = await (
79 await fetch(`${API}/api/v1/workspaces`, {
80 method: "POST",
81 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
82 body: JSON.stringify({ name: `Sso Team ${RUN}` })
83 })
84 ).json();
85 const teamWsId = wsRes.id as string;
86
87 const p = await prisma();
88 await p.workspace.update({ where: { id: teamWsId }, data: { organizationId: orgId } });
89 await p.$disconnect();
90 cleanups.push(async () => {
91 const pc = await prisma();
92
93 await pc.$transaction([
94 pc.organizationMember.deleteMany({ where: { organizationId: orgId } }),
95 pc.ssoProvider.deleteMany({ where: { organizationId: orgId } }),
96 pc.workspaceMember.deleteMany({ where: { workspaceId: teamWsId } }),
97 pc.workspace.delete({ where: { id: teamWsId } }),
98 pc.organization.delete({ where: { id: orgId } })
99 ]);
100 await pc.$disconnect();
101 });
102 return { orgId, slug, teamWsId };
103}
104
105
106async function configureSaml(
107 token: string,
108 orgId: string,
109 slug: string,
110 idp: MockSamlIdp,
111 overrides?: { metadataUrl?: string; ssoUrl?: string; acsUrl?: string; entityId?: string }
112) {
113 const res = await fetch(`${API}/api/v1/orgs/${orgId}/sso`, {
114 method: "POST",
115 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
116 body: JSON.stringify({
117 type: "SAML",
118 name: "Mock IdP",
119 certificate: idp.certPem,
120 config: {
121 entityId: overrides?.entityId ?? `${API}/saml/metadata/${slug}`,
122 ssoUrl: overrides?.ssoUrl ?? `${idp.url}/authorize`,
123 acsUrl: overrides?.acsUrl ?? `${API}/saml/acs`,
124 ...(overrides?.metadataUrl ? { metadataUrl: overrides.metadataUrl } : {})
125 }
126 })
127 });
128 expect(res.status, "saml provider create").toBe(201);
129}
130
131async function configureOidc(token: string, orgId: string, oidc: MockOidc) {
132 const res = await fetch(`${API}/api/v1/orgs/${orgId}/sso`, {
133 method: "POST",
134 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
135 body: JSON.stringify({
136 type: "OIDC",
137 name: "Mock OIDC",
138 clientSecret: oidc.clientSecret,
139 config: {
140 issuer: oidc.issuer,
141 clientId: oidc.clientId,
142 authorizationEndpoint: oidc.authorizationEndpoint,
143 tokenEndpoint: oidc.tokenEndpoint,
144 jwksUri: oidc.jwksUri,
145 redirectUri: `${API}/sso/oidc/${orgId}/callback`,
146 scope: "openid email profile"
147 }
148 })
149 });
150 expect(res.status, "oidc provider create").toBe(201);
151}
152
153
154test.describe("TASK-0131 SSO", () => {
155 test("SAML round trip: login page → mock IdP → ACS → workspace (JIT user)", async ({ page, browser }) => {
156 const ownerEmail = `sso-owner-${RUN}@factory.local`;
157 const ownerPassword = `Pw!${RUN}owner`;
158 const ssoEmail = `sso-jit-${RUN}@factory.local`;
159
160 const ownerCtx = await request.newContext();
161 const reg = await ownerCtx.post(`${API}/api/v1/auth/register`, {
162 data: { name: "Sso Owner", email: ownerEmail, password: ownerPassword }
163 });
164 expect(reg.status()).toBe(201);
165 const login = await ownerCtx.post(`${API}/api/v1/auth/login`, {
166 data: { email: ownerEmail, password: ownerPassword }
167 });
168 expect(login.status()).toBe(200);
169 const token = ((await login.json()) as { accessToken: string }).accessToken;
170 const { orgId, slug, teamWsId } = await setupOrg(token);
171
172
173 const idp = await startMockSamlIdp({
174 acsUrl: `${API}/saml/acs`,
175 spEntityId: `${API}/saml/metadata/${slug}`,
176 user: { nameId: ssoEmail, email: ssoEmail, displayName: "Jit Doe" }
177 });
178 cleanups.push(() => idp.close());
179 await configureSaml(token, orgId, slug, idp, { metadataUrl: idp.metadataUrl });
180
181
182 await page.goto(`${WEB}/login`);
183 await page.getByTestId("auth-email").fill(ssoEmail);
184
185
186
187
188
189
190
191 const startUrl = `${API}/sso/start/${slug}`;
192
193
194
195 await page.goto(startUrl);
196 await page.waitForURL(new RegExp("127\\.0\\.0\\.1:\\d+/authorize"), { timeout: 15_000 });
197 await page.waitForURL(`${WEB}/w/**`, { timeout: 25_000 });
198
199
200
201 await page.goto(`${WEB}/w/${teamWsId}`);
202 await page.waitForSelector('main, [data-testid="user-menu"]', { timeout: 20_000 });
203 const wsCookies = await page.context().cookies(`${API}`);
204 expect(wsCookies.some((c) => c.name === "factory_refresh")).toBe(true);
205
206
207 const p = await prisma();
208 const user = await p.user.findUnique({ where: { email: ssoEmail } });
209 expect(user, "JIT user created").toBeTruthy();
210 expect(user?.passwordHash).toBeNull();
211 expect(user?.emailVerification).toBe("VERIFIED");
212 const identity = await p.ssoIdentity.findFirst({ where: { userId: user!.id, externalId: ssoEmail } });
213 expect(identity, "SsoIdentity linked").toBeTruthy();
214 const orgMember = await p.organizationMember.findUnique({
215 where: { organizationId_userId: { organizationId: orgId, userId: user!.id } }
216 });
217 expect(orgMember?.role, "org membership MEMBER").toBe("MEMBER");
218 const wsMember = await p.workspaceMember.findUnique({
219 where: { workspaceId_userId: { workspaceId: teamWsId, userId: user!.id } }
220 });
221 expect(wsMember, "auto-joined team workspace").toBeTruthy();
222 const audit = await p.auditLog.findFirst({
223 where: { action: "sso.login.saml", resourceId: user!.id }
224 });
225 expect(audit, "sso.login.saml audit").toBeTruthy();
226 expect(audit?.ipAddress).toBeTruthy();
227 expect(audit?.userAgent).toBeTruthy();
228 const before = (await p.user.count({ where: { email: ssoEmail } })) as number;
229 await p.$disconnect();
230
231
232 const page2 = await (await browser.newContext()).newPage();
233 await page2.goto(startUrl);
234 await page2.waitForURL(`${WEB}/w/**`, { timeout: 20_000 });
235 const p2 = await prisma();
236 const after = await p2.user.count({ where: { email: ssoEmail } });
237 const auditCount = await p2.auditLog.count({ where: { action: "sso.login.saml", resourceId: user!.id } });
238 await p2.$disconnect();
239 expect(after, "no duplicate JIT user").toBe(before);
240 expect(auditCount).toBe(2);
241 await page2.context().close();
242
243
244 const page3 = await (await browser.newContext()).newPage();
245 await page3.goto(`${WEB}/login`);
246 await page3.getByTestId("auth-email").fill(ssoEmail);
247
248 await expect(page3.getByTestId("sso-start"), "login page shows SSO button").toBeVisible({
249 timeout: 10_000
250 });
251 await page3.context().close();
252 await ownerCtx.dispose();
253 });
254
255 test("SAML ACS tamper matrix → correct sso_<reason> + audit", async () => {
256 const ownerEmail = `sso-matrix-${RUN}@factory.local`;
257 const ownerPassword = `Pw!${RUN}matrix`;
258 const ctx = await request.newContext();
259 await ctx.post(`${API}/api/v1/auth/register`, {
260 data: { name: "Matrix Owner", email: ownerEmail, password: ownerPassword }
261 });
262 const login = await ctx.post(`${API}/api/v1/auth/login`, {
263 data: { email: ownerEmail, password: ownerPassword }
264 });
265 const token = ((await login.json()) as { accessToken: string }).accessToken;
266 const { orgId, slug } = await setupOrg(token);
267
268 const mkIdp = () =>
269 startMockSamlIdp({
270 acsUrl: `${API}/saml/acs`,
271 spEntityId: `${API}/saml/metadata/${slug}`,
272 user: { nameId: `matrix-${RUN}@factory.local`, email: `matrix-${RUN}@factory.local` }
273 });
274
275
276 {
277 const idp = await mkIdp();
278 cleanups.push(() => idp.close());
279 await configureSaml(token, orgId, slug, idp);
280 const resp = await idp.buildAuthnResponse({ notOnOrAfterOffsetMs: -120_000 });
281 const loc = await postAcs(ctx, resp);
282 expect(loc, "expired").toContain("sso_expired");
283 }
284
285 {
286 const idp = await startMockSamlIdp({
287 acsUrl: `${API}/saml/acs`,
288 spEntityId: `${API}/saml/metadata/${slug}`,
289 statusValue: "urn:oasis:names:tc:SAML:2.0:status:Requester",
290 user: { nameId: `x-${RUN}@factory.local` }
291 });
292 cleanups.push(() => idp.close());
293 await deleteProvider(token, orgId);
294 await configureSaml(token, orgId, slug, idp);
295 const resp = await idp.buildAuthnResponse();
296 const loc = await postAcs(ctx, resp);
297 expect(loc, "non-success").toContain("sso_status_not_success");
298 }
299
300 {
301 const idp = await startMockSamlIdp({
302 acsUrl: `${API}/saml/acs`,
303 spEntityId: `${API}/saml/metadata/${slug}`,
304 rogueKey: true,
305 user: { nameId: `y-${RUN}@factory.local` }
306 });
307 cleanups.push(() => idp.close());
308 await deleteProvider(token, orgId);
309 await configureSaml(token, orgId, slug, idp);
310 const resp = await idp.buildAuthnResponse();
311 const loc = await postAcs(ctx, resp);
312 expect(loc, "cert mismatch").toContain("sso_cert_mismatch");
313 }
314
315 {
316 const idp = await startMockSamlIdp({
317 acsUrl: `${API}/saml/acs`,
318 spEntityId: `${API}/saml/metadata/${slug}`,
319 responseId: `_${RUN}_fixed`,
320 user: { nameId: `z-${RUN}@factory.local` }
321 });
322 cleanups.push(() => idp.close());
323 await deleteProvider(token, orgId);
324 await configureSaml(token, orgId, slug, idp);
325 const resp = await idp.buildAuthnResponse();
326 const first = await postAcs(ctx, resp);
327
328 expect(first, "first use ok").toBe(`${WEB}/`);
329 const second = await postAcs(ctx, resp);
330 expect(second, "replay rejected").toContain("sso_replayed");
331 }
332
333 {
334 const good = await mkIdp();
335 const signer = await mkIdp();
336 cleanups.push(() => good.close(), () => signer.close());
337 await deleteProvider(token, orgId);
338
339 await configureSaml(token, orgId, slug, good);
340 const resp = await signer.buildAuthnResponse();
341 const loc = await postAcs(ctx, resp);
342
343 expect(loc, "unconfigured signer").toContain("sso_cert_mismatch");
344 }
345
346 const p = await prisma();
347 const failures = await p.auditLog.findMany({
348 where: { action: "sso.login_failed", resourceType: "user", resourceId: { contains: RUN } },
349 select: { metadata: true }
350 });
351 await p.$disconnect();
352 const reasons = failures.map((f) => (f.metadata as { reason?: string }).reason);
353 expect(reasons).toEqual(
354 expect.arrayContaining(["expired", "status_not_success", "cert_mismatch", "cert_mismatch", "replayed"])
355 );
356 await ctx.dispose();
357 });
358
359 test("OIDC round trip: authorize → callback → session", async () => {
360 const ownerEmail = `sso-oidc-${RUN}@factory.local`;
361 const ownerPassword = `Pw!${RUN}oidc`;
362 const ssoEmail = `sso-oidc-user-${RUN}@factory.local`;
363 const ctx = await request.newContext();
364 await ctx.post(`${API}/api/v1/auth/register`, {
365 data: { name: "Oidc Owner", email: ownerEmail, password: ownerPassword }
366 });
367 const login = await ctx.post(`${API}/api/v1/auth/login`, {
368 data: { email: ownerEmail, password: ownerPassword }
369 });
370 const token = ((await login.json()) as { accessToken: string }).accessToken;
371 const { orgId } = await setupOrg(token);
372
373 const oidc = await startMockOidc({
374 clientId: `cli_sso_${RUN}`,
375 clientSecret: `sec_${RUN}_client_secret`,
376 initialBehavior: { sub: `sub_${RUN}`, email: ssoEmail, name: "Oidc User" }
377 });
378 cleanups.push(() => oidc.close());
379 await configureOidc(token, orgId, oidc);
380
381
382
383
384
385
386
387 const authz = await ctx.get(`${API}/sso/oidc/${orgId}/authorize`, { maxRedirects: 0 });
388 expect(authz.status(), "RP → IdP authorize").toBe(302);
389 const idpAuthUrl = authz.headers()["location"] ?? "";
390 expect(idpAuthUrl, "targets the mock IdP").toContain(oidc.url);
391
392 const idpHop = await ctx.get(idpAuthUrl, { maxRedirects: 0 });
393 expect(idpHop.status(), "IdP auto-approve → callback").toBe(302);
394 const cbUrl = idpHop.headers()["location"] ?? "";
395 expect(cbUrl, "callback on the API origin").toContain(`${API}/sso/oidc/${orgId}/callback`);
396
397 const cb = await ctx.get(cbUrl, { maxRedirects: 0 });
398 expect(cb.status(), "callback → WEB landing").toBe(302);
399 expect(cb.headers()["location"]).toContain(`${WEB}/w/`);
400 expect(cb.headers()["set-cookie"] ?? "", "session cookie set").toContain("factory_refresh=");
401
402 const p = await prisma();
403 const user = await p.user.findUnique({ where: { email: ssoEmail } });
404 expect(user, "OIDC JIT user").toBeTruthy();
405 expect(user?.passwordHash).toBeNull();
406 const identity = await p.ssoIdentity.findFirst({ where: { userId: user!.id, externalId: `sub_${RUN}` } });
407 expect(identity, "SsoIdentity from sub").toBeTruthy();
408 const audit = await p.auditLog.findFirst({
409 where: { action: "sso.login.oidc", resourceId: user!.id }
410 });
411 expect(audit, "sso.login.oidc audit").toBeTruthy();
412 await p.$disconnect();
413 await ctx.dispose();
414 });
415
416 test("enforce mode: 403 sso_required for org user; non-org user unaffected", async () => {
417 const orgUserEmail = `sso-enf-${RUN}@factory.local`;
418 const orgPassword = `Pw!${RUN}enf`;
419 const otherEmail = `sso-free-${RUN}@factory.local`;
420 const otherPassword = `Pw!${RUN}free`;
421 const ctx = await request.newContext();
422 await ctx.post(`${API}/api/v1/auth/register`, {
423 data: { name: "Enf Owner", email: orgUserEmail, password: orgPassword }
424 });
425 const login = await ctx.post(`${API}/api/v1/auth/login`, {
426 data: { email: orgUserEmail, password: orgPassword }
427 });
428 const token = ((await login.json()) as { accessToken: string }).accessToken;
429 const { orgId, slug } = await setupOrg(token);
430
431
432 await ctx.post(`${API}/api/v1/auth/register`, {
433 data: { name: "Free User", email: otherEmail, password: otherPassword }
434 });
435
436 const idp = await startMockSamlIdp({
437 acsUrl: `${API}/saml/acs`,
438 spEntityId: `${API}/saml/metadata/${slug}`,
439 user: { nameId: orgUserEmail, email: orgUserEmail }
440 });
441 cleanups.push(() => idp.close());
442 await configureSaml(token, orgId, slug, idp);
443
444
445 const enf = await fetch(`${API}/api/v1/orgs/${orgId}/sso/enforce`, {
446 method: "PATCH",
447 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
448 body: JSON.stringify({ enforce: true })
449 });
450 expect(enf.status).toBe(200);
451 const org = await (await fetch(`${API}/api/v1/orgs/${orgId}`, {
452 headers: { authorization: `Bearer ${token}` }
453 })).json();
454 expect((org.policy as { ssoEnforced?: boolean }).ssoEnforced, "policy mirror").toBe(true);
455
456
457 const refused = await ctx.post(`${API}/api/v1/auth/login`, {
458 data: { email: orgUserEmail, password: orgPassword }
459 });
460 expect(refused.status(), "org user 403").toBe(403);
461 const refusedBody = (await refused.json()) as {
462 error: { code: string; details?: Array<{ reason?: string; orgs?: string[] }> };
463 };
464 expect(refusedBody.error.details?.[0]?.reason).toBe("sso_required");
465 expect(refusedBody.error.details?.[0]?.orgs).toEqual([slug]);
466
467
468 const free = await ctx.post(`${API}/api/v1/auth/login`, {
469 data: { email: otherEmail, password: otherPassword }
470 });
471 expect(free.status(), "non-org user OK").toBe(200);
472
473
474
475
476
477
478
479
480 const recovery = await ctx.get(`${API}/sso/start/${slug}`, { maxRedirects: 0 });
481 expect(recovery.status(), "owner recovers via SSO").toBe(302);
482 expect(recovery.headers()["location"]).toContain(`${API}/saml/start/${slug}`);
483
484
485 const unenf = await fetch(`${API}/api/v1/orgs/${orgId}/sso/enforce`, {
486 method: "PATCH",
487 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
488 body: JSON.stringify({ enforce: false })
489 });
490 expect(unenf.status).toBe(200);
491 const back = await ctx.post(`${API}/api/v1/auth/login`, {
492 data: { email: orgUserEmail, password: orgPassword }
493 });
494 expect(back.status(), "un-enforced again").toBe(200);
495 await ctx.dispose();
496 });
497
498 test("admin SSO tabs: configure SAML, test-connection green, enforce badge", async ({ page }) => {
499 const ownerEmail = `sso-ui-${RUN}@factory.local`;
500 const ownerPassword = `Pw!${RUN}ui`;
501
502
503 const regRes = await page.request.post(`${API}/api/v1/auth/register`, {
504 data: { name: "Ui Owner", email: ownerEmail, password: ownerPassword }
505 });
506 expect(regRes.status(), "register owner").toBe(201);
507 const loginRes = await page.request.post(`${API}/api/v1/auth/login`, {
508 data: { email: ownerEmail, password: ownerPassword }
509 });
510 expect(loginRes.status(), "login owner").toBe(200);
511 const owner = (await loginRes.json() as { accessToken: string }).accessToken;
512 const orgRes = await (
513 await fetch(`${API}/api/v1/orgs`, {
514 method: "POST",
515 headers: { "content-type": "application/json", authorization: `Bearer ${owner}` },
516 body: JSON.stringify({ name: `sso-ui-${RUN}` })
517 })
518 ).json();
519 const orgId = orgRes.id as string;
520 const slug = orgRes.slug as string;
521
522 const idp = await startMockSamlIdp({
523 acsUrl: `${API}/saml/acs`,
524 spEntityId: `${API}/saml/metadata/${slug}`,
525 user: { nameId: `ui-${RUN}@factory.local` }
526 });
527 cleanups.push(() => idp.close());
528
529
530 await page.goto(`${WEB}/org/${orgId}/admin/integrations`);
531 await expect(page.getByText("No SSO provider configured yet")).toBeVisible({ timeout: 15_000 });
532 await page.getByRole("button", { name: /Configure SSO/ }).click();
533
534
535 await page.getByRole("button", { name: "SAML 2.0" }).click();
536 await page.getByLabel("Display name").fill("Mock IdP (E2E)");
537 await page.getByLabel("SP entity ID").fill(`${API}/saml/metadata/${slug}`);
538 await page.getByLabel("IdP SSO URL").fill(`${idp.url}/authorize`);
539 await page.getByLabel("ACS URL").fill(`${API}/saml/acs`);
540 await page.getByTestId("sso-metadata-url").fill(idp.metadataUrl);
541 const certField = page.locator("textarea").first();
542 await certField.fill(idp.certPem);
543 await page.getByTestId("sso-save").click();
544
545
546 await expect(page.getByText("Mock IdP (E2E)")).toBeVisible({ timeout: 15_000 });
547 await expect(page.getByTestId("sso-enforce-toggle")).toBeVisible();
548
549
550
551 await page.getByTestId("sso-test").click();
552 await expect(page.getByTestId("sso-test-result")).toContainText("Connected", { timeout: 20_000 });
553
554
555 await page.getByTestId("sso-enforce-toggle").click();
556 await page.getByRole("dialog").getByRole("button", { name: "Enforce" }).click();
557
558 await expect(page.getByText("Enforce ON", { exact: true })).toBeVisible({ timeout: 15_000 });
559 });
560});
561
562async function postAcs(
563 ctx: Awaited<ReturnType<typeof request.newContext>>,
564 samlResponse: string
565): Promise<string> {
566 const res = await ctx.post(`${API}/saml/acs`, {
567 form: { SAMLResponse: Buffer.from(samlResponse, "utf8").toString("base64"), RelayState: "/" },
568 maxRedirects: 0
569 });
570 expect(res.status(), "ACS 302").toBe(302);
571 return res.headers()["location"] ?? "";
572}
573
574async function deleteProvider(token: string, orgId: string) {
575 await fetch(`${API}/api/v1/orgs/${orgId}/sso`, {
576 method: "DELETE",
577 headers: { authorization: `Bearer ${token}` }
578 });
579}
580