1
11import { execFileSync } from "node:child_process";
12import { generateKeyPairSync } from "node:crypto";
13import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
14import { tmpdir } from "node:os";
15import { join } from "node:path";
16import { afterAll, beforeAll, describe, expect, it } from "vitest";
17import { createTestApp, type TestAppHandle } from "./helpers/api";
18import { authHeaders, createUser } from "./helpers/seed";
19
20let app: TestAppHandle;
21
22function selfSignedCert(): { certPem: string; keyPem: string } {
23 const dir = mkdtempSync(join(tmpdir(), "sso-integ-"));
24 const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
25 const keyPem = privateKey.export({ type: "pkcs8", format: "pem" }) as string;
26 const keyPath = join(dir, "key.pem");
27 const certPath = join(dir, "cert.pem");
28 writeFileSync(keyPath, keyPem);
29 execFileSync("openssl", ["req", "-x509", "-new", "-key", keyPath, "-subj", "/CN=integ-idp", "-days", "2", "-out", certPath]);
30 return { certPem: readFileSync(certPath, "utf8"), keyPem };
31}
32
33const json = async (res: Response): Promise<Record<string, unknown>> =>
34 (await res.json().catch(() => ({}))) as Record<string, unknown>;
35
36describe("TASK-0131 SSO admin + discovery + enforce", () => {
37 beforeAll(async () => {
38 app = await createTestApp();
39 }, 180_000);
40 afterAll(async () => {
41 await app?.close();
42 });
43
44 it("read: no provider → { provider: null }; non-owner → 403", async () => {
45 const owner = await createUser(app.request);
46 const orgRes = await app.request("POST", "/api/v1/orgs", {
47 headers: authHeaders(owner.accessToken!),
48 body: { name: `SSO Org ${Date.now()}` }
49 });
50 const org = (await json(orgRes)) as { id?: string; organization?: { id?: string } };
51 const orgId = org.id ?? org.organization?.id;
52 expect(orgId).toBeTruthy();
53
54 const none = await app.request("GET", `/api/v1/orgs/${orgId}/sso`, { headers: authHeaders(owner.accessToken!) });
55 expect(none.status).toBe(200);
56 expect((await json(none)).provider).toBeNull();
57
58
59 const other = await createUser(app.request);
60 const forbidden = await app.request("GET", `/api/v1/orgs/${orgId}/sso`, { headers: authHeaders(other.accessToken!) });
61 expect(forbidden.status).toBe(403);
62
63
64 const anon = await app.request("GET", `/api/v1/orgs/${orgId}/sso`);
65 expect(anon.status).toBe(401);
66 });
67
68 it("SAML lifecycle: create → masked read → rotate → enforce+mirror → delete clears", async () => {
69 const owner = await createUser(app.request, { name: "SSO Owner" });
70 const orgRes = await app.request("POST", "/api/v1/orgs", {
71 headers: authHeaders(owner.accessToken!),
72 body: { name: `SSO Life ${Date.now()}` }
73 });
74 const org = (await json(orgRes)) as { id?: string; organization?: { id?: string; slug?: string } };
75 const orgId = org.id ?? org.organization?.id;
76 const slug = (org as { slug?: string }).slug ?? org.organization?.slug;
77 const origin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
78 const entity = `${origin}/saml/metadata/${slug}`;
79 const { certPem } = selfSignedCert();
80
81
82 const created = await app.request("POST", `/api/v1/orgs/${orgId}/sso`, {
83 headers: authHeaders(owner.accessToken!),
84 body: {
85 type: "SAML",
86 name: "Acme Okta",
87 certificate: certPem,
88 config: {
89 entityId: entity,
90 ssoUrl: "https://idp.acme.test/sso/saml",
91 acsUrl: `${origin}/saml/acs`,
92 nameIdAttribute: "nameId",
93 emailAttribute: "mail"
94 }
95 }
96 });
97 expect(created.status).toBe(201);
98 const cbody = await json(created);
99 expect((cbody.provider as { type: string; name: string }).type).toBe("SAML");
100
101
102 const row = await app.prisma.ssoProvider.findUnique({ where: { organizationId: orgId! } });
103 expect(row).not.toBeNull();
104 expect(JSON.stringify(row?.config)).not.toContain("BEGIN CERTIFICATE");
105 expect(JSON.stringify(row?.config)).not.toContain(certPem);
106
107
108 const read = await app.request("GET", `/api/v1/orgs/${orgId}/sso`, { headers: authHeaders(owner.accessToken!) });
109 expect(read.status).toBe(200);
110 const rbody = await json(read);
111 const prov = rbody.provider as { certificateConfigured: boolean; clientSecretConfigured: boolean | null; config: Record<string, unknown> };
112 expect(prov.certificateConfigured).toBe(true);
113 expect(JSON.stringify(prov)).not.toContain("BEGIN CERTIFICATE");
114 expect(prov.clientSecretConfigured).toBeFalsy();
115
116
117 const dup = await app.request("POST", `/api/v1/orgs/${orgId}/sso`, {
118 headers: authHeaders(owner.accessToken!),
119 body: {
120 type: "SAML",
121 name: "Dup",
122 certificate: certPem,
123 config: { entityId: entity, ssoUrl: "https://idp.acme.test/sso/saml", acsUrl: `${origin}/saml/acs` }
124 }
125 });
126 expect(dup.status).toBe(409);
127
128
129 const { certPem: newCertPem } = selfSignedCert();
130 const rotated = await app.request("PATCH", `/api/v1/orgs/${orgId}/sso`, {
131 headers: authHeaders(owner.accessToken!),
132 body: { certificate: newCertPem }
133 });
134 expect(rotated.status).toBe(200);
135
136 const orgRow = await app.prisma.organization.findUnique({ where: { id: orgId! } });
137 const ssoConfig = (orgRow?.ssoConfig as Record<string, unknown>) ?? {};
138 expect(ssoConfig.certificateEncrypted).toBeTruthy();
139 expect(ssoConfig.previousCertificateEncrypted).toBeTruthy();
140
141
142 const enforce = await app.request("PATCH", `/api/v1/orgs/${orgId}/sso/enforce`, {
143 headers: authHeaders(owner.accessToken!),
144 body: { enforce: true }
145 });
146 expect(enforce.status).toBe(200);
147 expect((await json(enforce)).enforce).toBe(true);
148 const afterEnforce = await app.prisma.organization.findUnique({
149 where: { id: orgId! },
150 select: { policy: true }
151 });
152 expect((afterEnforce?.policy as { ssoEnforced?: boolean }).ssoEnforced).toBe(true);
153
154
155
156 const login = await app.request("POST", "/api/v1/auth/login", {
157 body: { email: owner.email, password: owner.password }
158 });
159 expect(login.status).toBe(403);
160 const lbody = await json(login);
161 expect((lbody.error as { reason?: string; details?: Array<{ reason?: string; orgs?: string[] }> })?.details?.[0]?.reason).toBe(
162 "sso_required"
163 );
164 expect((lbody.error as { details?: Array<{ orgs?: string[] }> }).details?.[0]?.orgs).toContain(slug);
165
166
167
168 const options = await app.request("GET", `/api/v1/auth/sso/options?email=${encodeURIComponent(owner.email)}`);
169 expect(options.status).toBe(200);
170 const obody = await json(options);
171 const oOrgs = obody.orgs as Array<{ slug: string; ssoAvailable: boolean; startUrl: string }>;
172 expect(oOrgs.some((o) => o.slug === slug && o.ssoAvailable && o.startUrl.includes(`/sso/start/${slug}`))).toBe(true);
173
174 expect(JSON.stringify(obody)).not.toContain("SAML");
175
176
177 const unknown = await app.request("GET", `/api/v1/auth/sso/options?email=${encodeURIComponent("ghost@nowhere.test")}`);
178 expect((await json(unknown)).orgs).toEqual([]);
179
180
181 const del = await app.request("DELETE", `/api/v1/orgs/${orgId}/sso`, { headers: authHeaders(owner.accessToken!) });
182 expect(del.status).toBe(200);
183 expect((await json(del)).deleted).toBe(true);
184 const gone = await app.prisma.ssoProvider.findUnique({ where: { organizationId: orgId! } });
185 expect(gone).toBeNull();
186 const afterDelete = await app.prisma.organization.findUnique({
187 where: { id: orgId! },
188 select: { policy: true }
189 });
190 expect((afterDelete?.policy as { ssoEnforced?: boolean }).ssoEnforced).toBe(false);
191
192 const loginAgain = await app.request("POST", "/api/v1/auth/login", {
193 body: { email: owner.email, password: owner.password }
194 });
195 expect(loginAgain.status).toBe(200);
196
197
198 const readAfter = await app.request("GET", `/api/v1/orgs/${orgId}/sso`, { headers: authHeaders(owner.accessToken!) });
199 expect((await json(readAfter)).provider).toBeNull();
200 });
201
202 it("OIDC create: reveal-once client secret (encrypted, masked on read)", async () => {
203 const owner = await createUser(app.request);
204 const orgRes = await app.request("POST", "/api/v1/orgs", {
205 headers: authHeaders(owner.accessToken!),
206 body: { name: `SSO OIDC ${Date.now()}` }
207 });
208 const org = (await json(orgRes)) as { id?: string; organization?: { id?: string } };
209 const orgId = org.id ?? org.organization?.id;
210 const origin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
211
212 const created = await app.request("POST", `/api/v1/orgs/${orgId}/sso`, {
213 headers: authHeaders(owner.accessToken!),
214 body: {
215 type: "OIDC",
216 name: "Acme Okta OIDC",
217 clientSecret: "super-secret-123",
218 config: {
219 issuer: "https://idp.acme.test",
220 clientId: "factory-client",
221 authorizationEndpoint: "https://idp.acme.test/authorize",
222 tokenEndpoint: "https://idp.acme.test/token",
223 redirectUri: `${origin}/sso/oidc/${orgId}/callback`
224 }
225 }
226 });
227 expect(created.status).toBe(201);
228
229
230 const row = await app.prisma.ssoProvider.findUnique({ where: { organizationId: orgId! } });
231 expect(JSON.stringify(row?.config)).not.toContain("super-secret-123");
232 const orgRow = await app.prisma.organization.findUnique({ where: { id: orgId! } });
233 expect(((orgRow?.ssoConfig as Record<string, unknown>) ?? {}).clientSecretEncrypted).toBeTruthy();
234
235
236 const read = await app.request("GET", `/api/v1/orgs/${orgId}/sso`, { headers: authHeaders(owner.accessToken!) });
237 const prov = (await json(read)).provider as { clientSecretConfigured: boolean; certificateConfigured: boolean | null };
238 expect(prov.clientSecretConfigured).toBe(true);
239 expect(prov.certificateConfigured).toBeFalsy();
240
241 await app.request("DELETE", `/api/v1/orgs/${orgId}/sso`, { headers: authHeaders(owner.accessToken!) });
242 });
243});
244