1
24import { Body, Controller, Get, Inject, Param, Post, Req, Res } from "@nestjs/common";
25import type { Request, Response } from "express";
26import { PrismaService } from "../../common/prisma.service.js";
27import { RedisService } from "../../common/redis.service.js";
28import { AuditService } from "../../common/audit.service.js";
29import { ApiError } from "../../common/errors.js";
30import { loadEnv } from "../../config/env.js";
31import { SamlConfigSchema } from "./schemas.js";
32import { buildSamlStartRedirect, validateAuthnResponse } from "./saml.js";
33import { SsoSecretsService } from "./sso-secrets.js";
34import { SsoProvisioningService } from "./provisioning.service.js";
35
36const REPLAY_TTL_SECONDS = 600;
37
38@Controller("saml")
39export class SamlController {
40 constructor(
41 @Inject(PrismaService) private readonly prisma: PrismaService,
42 @Inject(RedisService) private readonly redis: RedisService,
43 @Inject(AuditService) private readonly audit: AuditService,
44 @Inject(SsoSecretsService) private readonly secrets: SsoSecretsService,
45 @Inject(SsoProvisioningService) private readonly provisioning: SsoProvisioningService
46 ) {}
47
48
55 private origin(): string {
56 const env = loadEnv();
57 return (env.SSO_ORIGIN || env.WEB_ORIGIN).replace(/\/$/, "");
58 }
59
60
61 private webOrigin(): string {
62 return loadEnv().WEB_ORIGIN.replace(/\/$/, "");
63 }
64
65
66 private spEntityId(orgSlug: string): string {
67 return `${this.origin()}/saml/metadata/${orgSlug}`;
68 }
69
70
81 @Get("start/:orgSlug")
82 async start(@Param("orgSlug") orgSlug: string, @Res() res: Response): Promise<void> {
83 const org = await this.prisma.organization.findUnique({ where: { slug: orgSlug } });
84 if (!org) {
85 res.status(404).json({ error: { code: "NOT_FOUND", message: "Unknown organization." } });
86 return;
87 }
88 const provider = await this.prisma.ssoProvider.findUnique({ where: { organizationId: org.id } });
89 if (!provider || provider.type !== "SAML") {
90 res
91 .status(404)
92 .json({ error: { code: "NOT_FOUND", message: "Organization has no SAML SSO provider." } });
93 return;
94 }
95 const config = SamlConfigSchema.parse(provider.config);
96 const location = buildSamlStartRedirect({
97 ssoUrl: config.ssoUrl,
98 spEntityId: this.spEntityId(org.slug),
99 acsUrl: `${this.origin()}/saml/acs`
100 });
101 res.status(302).header("location", location).end();
102 }
103
104
105 @Get("metadata/:orgSlug")
106 async metadata(@Param("orgSlug") orgSlug: string, @Res() res: Response) {
107 const org = await this.prisma.organization.findUnique({ where: { slug: orgSlug } });
108 if (!org) throw ApiError.notFound("Unknown organization.");
109 const provider = await this.prisma.ssoProvider.findUnique({
110 where: { organizationId: org.id }
111 });
112 if (!provider || provider.type !== "SAML") {
113 throw new ApiError(
114 "NOT_FOUND",
115 404,
116 "Organization has no SAML SSO provider.",
117 [{ reason: "no_sso_provider" }]
118 );
119 }
120 const entityId = this.spEntityId(orgSlug);
121 const acsUrl = `${this.origin()}/saml/acs`;
122 const xml = `<?xml version="1.0"?>
123<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${entityId}">
124 <SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
125 <AssertionConsumerService index="1" isDefault="true" Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${acsUrl}"/>
126 <SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${acsUrl}"/>
127 </SPSSODescriptor>
128</EntityDescriptor>`;
129 res.setHeader("content-type", "application/xml");
130 res.setHeader("cache-control", "no-store");
131 res.status(200).send(xml);
132 }
133
134
139 @Post("acs")
140 async acs(
141 @Body() body: { SAMLResponse?: string; RelayState?: string },
142 @Req() req: Request,
143 @Res() res: Response
144 ): Promise<void> {
145 const redirectTo = (error: string, orgSlug?: string) => {
146 const target = new URL(`${this.webOrigin()}/login`);
147 target.searchParams.set("error", error);
148 if (orgSlug) target.searchParams.set("org", orgSlug);
149 res.status(302).header("location", target.toString()).end();
150 };
151
152 const samlResponse = body?.SAMLResponse;
153 if (!samlResponse) {
154 redirectTo("sso_missing_response");
155 return;
156 }
157 let xml: string;
158 try {
159 xml = Buffer.from(samlResponse, "base64").toString("utf8");
160 } catch {
161 await this.audit.audit(req, undefined, {
162 action: "sso.login_failed",
163 resourceType: "user",
164 resourceId: "saml",
165 metadata: { reason: "malformed_base64" }
166 });
167 redirectTo("sso_malformed_response");
168 return;
169 }
170
171
172
173 const audienceMatch = /<saml:Audience>([^<]+)<\/saml:Audience>/.exec(xml);
174 const slugFromAudience = audienceMatch ? audienceMatch[1].match(/saml\/metadata\/([^"<>?\s]+)$/) : null;
175 const org = slugFromAudience
176 ? await this.prisma.organization.findUnique({ where: { slug: slugFromAudience[1] } })
177 : null;
178 if (!org) {
179 await this.audit.audit(req, undefined, {
180 action: "sso.login_failed",
181 resourceType: "user",
182 resourceId: "saml",
183 metadata: { reason: "no_sso_provider" }
184 });
185 redirectTo("sso_unknown_org");
186 return;
187 }
188 const provider = await this.prisma.ssoProvider.findUnique({ where: { organizationId: org.id } });
189 if (!provider || provider.type !== "SAML") {
190 await this.audit.audit(req, undefined, {
191 action: "sso.login_failed",
192 resourceType: "user",
193 resourceId: org.slug,
194 metadata: { reason: "no_sso_provider" }
195 });
196 redirectTo("sso_unknown_org", org.slug);
197 return;
198 }
199 const config = SamlConfigSchema.parse(provider.config);
200 const secrets = await this.secrets.getSecrets(org.id);
201 const certificates = [secrets.certificate, secrets.previousCertificate].filter(
202 (c): c is string => Boolean(c)
203 );
204 if (!certificates.length) {
205 await this.audit.audit(req, undefined, {
206 action: "sso.login_failed",
207 resourceType: "user",
208 resourceId: org.slug,
209 metadata: { reason: "no_certificate_configured" }
210 });
211 redirectTo("sso_misconfigured", org.slug);
212 return;
213 }
214
215 const result = validateAuthnResponse(xml, {
216 entityId: this.spEntityId(org.slug),
217 acsUrl: `${this.origin()}/saml/acs`,
218 issuer: config.idpEntityId,
219 certificates,
220 nameIdAttribute: config.nameIdAttribute,
221 emailAttribute: config.emailAttribute
222 });
223 if (!result.ok) {
224 await this.audit.audit(req, undefined, {
225 action: "sso.login_failed",
226 resourceType: "user",
227 resourceId: org.slug,
228 metadata: { reason: result.reason, detail: result.detail ?? null }
229 });
230 redirectTo(`sso_${result.reason}`, org.slug);
231 return;
232 }
233
234
235
236 const replayKey = `sso:replay:${result.data.id}`;
237 let seen: string | null = null;
238 try {
239 seen = await this.redis.getRedis().set(replayKey, "1", "EX", REPLAY_TTL_SECONDS, "NX");
240 } catch {
241
242
243 await this.audit.audit(req, undefined, {
244 action: "sso.login_failed",
245 resourceType: "user",
246 resourceId: org.slug,
247 metadata: { reason: "replay_store_unavailable" }
248 });
249 }
250 if (seen === null) {
251 await this.audit.audit(req, undefined, {
252 action: "sso.login_failed",
253 resourceType: "user",
254 resourceId: org.slug,
255 metadata: { reason: "replayed", responseId: result.data.id }
256 });
257 redirectTo("sso_replayed", org.slug);
258 return;
259 }
260
261
262 try {
263 await this.provisioning.provision({
264 req,
265 res,
266 organizationId: org.id,
267 provider: {
268 id: provider.id,
269 type: "SAML",
270 name: provider.name,
271 config: provider.config as Record<string, unknown>
272 },
273 externalId: result.data.nameId,
274 email: result.data.email ?? result.data.nameId,
275 displayName: result.data.attributes.displayName ?? result.data.attributes.name ?? null,
276 claims: result.data.attributes
277 });
278 } catch (err) {
279
280
281 const detail =
282 err instanceof ApiError
283 ? (err.details?.[0]?.detail as string | undefined)
284 : "provisioning_failed";
285 redirectTo(`sso_${detail ?? "provisioning_failed"}`, org.slug);
286 return;
287 }
288
289
290
291
292 const relay = body?.RelayState;
293 const target = relay && relay.startsWith("/") && !relay.startsWith("//") ? relay : "/w/default";
294 res.status(302).header("location", this.webOrigin() + target).end();
295 }
296}
297