1
27import { Inject, Injectable } from "@nestjs/common";
28import type { Prisma } from "@prisma/client";
29import type { Request } from "express";
30import { PrismaService } from "../../common/prisma.service.js";
31import { AuditService } from "../../common/audit.service.js";
32import { ApiError } from "../../common/errors.js";
33import { requireUser } from "../../common/types.js";
34import {
35 OidcConfigSchema,
36 SamlConfigSchema,
37 SsoProviderAdminInputSchema,
38 assertNoPlaintextSecrets,
39 type SsoProviderAdminInput
40} from "./schemas.js";
41import { SsoSecretsService } from "./sso-secrets.js";
42import { assertOrgAdmin } from "../org/guards.js";
43
44@Injectable()
45export class SsoAdminService {
46 constructor(
47 @Inject(PrismaService) private readonly prisma: PrismaService,
48 @Inject(AuditService) private readonly audit: AuditService,
49 @Inject(SsoSecretsService) private readonly secrets: SsoSecretsService
50 ) {}
51
52
59 private async requireOrgOwner(orgId: string, userId: string) {
60 await assertOrgAdmin(this.prisma, orgId, userId);
61 const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
62 if (!org) throw ApiError.notFound("Organization not found.");
63 return org;
64 }
65
66 private async providerOf(orgId: string) {
67 return this.prisma.ssoProvider.findUnique({ where: { organizationId: orgId } });
68 }
69
70
71 private view(
72 p: {
73 id: string;
74 name: string;
75 type: "SAML" | "OIDC";
76 enforce: boolean;
77 config: Prisma.JsonValue;
78 createdAt: Date;
79 updatedAt: Date;
80 }) {
81 const cfg = p.config as Record<string, unknown>;
82 return {
83 id: p.id,
84 name: p.name,
85 type: p.type,
86 enforce: p.enforce,
87 config: p.type === "SAML" ? SamlConfigSchema.parse(cfg) : OidcConfigSchema.parse(cfg),
88
89
90 certificateConfigured: undefined as boolean | undefined,
91 clientSecretConfigured: undefined as boolean | undefined,
92 createdAt: p.createdAt,
93 updatedAt: p.updatedAt
94 };
95 }
96
97 async get(orgId: string, req: Request) {
98 const userId = requireUser(req);
99 await this.requireOrgOwner(orgId, userId);
100 const p = await this.providerOf(orgId);
101 if (!p) return { provider: null };
102 const hasCert = Boolean((await this.secrets.getSecrets(orgId)).certificate);
103 const hasSecret = await this.secrets.hasClientSecret(orgId);
104 const cfg = this.view(p);
105 cfg.certificateConfigured = hasCert;
106 cfg.clientSecretConfigured = hasSecret;
107 return { provider: cfg };
108 }
109
110 async create(orgId: string, body: SsoProviderAdminInput, req: Request) {
111 const userId = requireUser(req);
112 await this.requireOrgOwner(orgId, userId);
113 const existing = await this.providerOf(orgId);
114 if (existing) {
115 throw new ApiError("CONFLICT", 409, "Organization already has an SSO provider.", [{ reason: "provider_exists" }]);
116 }
117 const parsed = SsoProviderAdminInputSchema.parse(body);
118 assertNoPlaintextSecrets(parsed.config);
119 if (parsed.type === "SAML") {
120 await this.secrets.saveSecrets(orgId, {
121 certificate: parsed.certificate,
122 previousCertificate: parsed.previousCertificate
123 });
124 } else {
125 await this.secrets.saveSecrets(orgId, { clientSecret: parsed.clientSecret });
126 }
127 const row = await this.prisma.ssoProvider.create({
128 data: {
129 organizationId: orgId,
130 type: parsed.type,
131 name: parsed.name,
132 config: parsed.config as Prisma.InputJsonValue
133 }
134 });
135 await this.audit.audit(req, undefined, {
136 action: "sso.provider_configured",
137 resourceType: "org",
138 resourceId: orgId,
139 metadata: { type: parsed.type, name: parsed.name, enforce: false }
140 });
141 return { provider: this.view(row) };
142 }
143
144 async update(
145 orgId: string,
146 body: { name?: string; config?: Record<string, unknown>; certificate?: string; previousCertificate?: string; clientSecret?: string },
147 req: Request
148 ) {
149 const userId = requireUser(req);
150 await this.requireOrgOwner(orgId, userId);
151 const existing = await this.providerOf(orgId);
152 if (!existing) throw new ApiError("NOT_FOUND", 404, "No SSO provider configured.", [{ reason: "no_provider" }]);
153
154 const secretsPatch: { certificate?: string | null; previousCertificate?: string | null; clientSecret?: string | null } = {};
155 if (existing.type === "SAML" && body.certificate) {
156
157 const current = (await this.secrets.getSecrets(orgId)).certificate;
158 secretsPatch.previousCertificate = current ?? null;
159 secretsPatch.certificate = body.certificate;
160 }
161 if (existing.type === "OIDC" && body.clientSecret) {
162 secretsPatch.clientSecret = body.clientSecret;
163 }
164 if (Object.keys(secretsPatch).length) {
165 await this.secrets.saveSecrets(orgId, secretsPatch);
166 }
167
168 const configPatch: Prisma.InputJsonValue = existing.config as Prisma.InputJsonValue;
169 if (body.config) {
170 const merged = { ...((existing.config as Record<string, unknown>) ?? {}), ...body.config };
171 if (existing.type === "SAML") assertNoPlaintextSecrets(SamlConfigSchema.parse(merged));
172 else assertNoPlaintextSecrets(OidcConfigSchema.parse(merged));
173 (configPatch as Record<string, unknown>) = merged;
174 }
175 const row = await this.prisma.ssoProvider.update({
176 where: { organizationId: orgId },
177 data: {
178 ...(body.name ? { name: body.name } : {}),
179 ...(body.config ? { config: configPatch } : {})
180 }
181 });
182 await this.audit.audit(req, undefined, {
183 action: "sso.provider_configured",
184 resourceType: "org",
185 resourceId: orgId,
186 metadata: { type: existing.type, name: row.name, rotated: Boolean(Object.keys(secretsPatch).length) }
187 });
188 return { provider: this.view(row) };
189 }
190
191 async remove(orgId: string, req: Request) {
192 const userId = requireUser(req);
193 await this.requireOrgOwner(orgId, userId);
194 const existing = await this.providerOf(orgId);
195 if (!existing) throw new ApiError("NOT_FOUND", 404, "No SSO provider configured.", [{ reason: "no_provider" }]);
196 await this.secrets.saveSecrets(orgId, {
197 certificate: null,
198 previousCertificate: null,
199 clientSecret: null
200 });
201 await this.prisma.ssoProvider.delete({ where: { organizationId: orgId } });
202
203 await this.patchPolicySsoEnforced(orgId, false);
204 await this.audit.audit(req, undefined, {
205 action: "sso.provider_deleted",
206 resourceType: "org",
207 resourceId: orgId,
208 metadata: { type: existing.type, name: existing.name }
209 });
210 return { deleted: true };
211 }
212
213 async setEnforce(orgId: string, body: { enforce: boolean }, req: Request) {
214 const userId = requireUser(req);
215 await this.requireOrgOwner(orgId, userId);
216 const existing = await this.providerOf(orgId);
217 if (!existing) throw new ApiError("NOT_FOUND", 404, "No SSO provider configured.", [{ reason: "no_provider" }]);
218 await this.prisma.ssoProvider.update({ where: { organizationId: orgId }, data: { enforce: body.enforce } });
219 await this.patchPolicySsoEnforced(orgId, body.enforce);
220 await this.audit.audit(req, undefined, {
221 action: "sso.enforce_changed",
222 resourceType: "org",
223 resourceId: orgId,
224 metadata: { enforce: body.enforce, type: existing.type }
225 });
226 return { enforce: body.enforce };
227 }
228
229
230 private async patchPolicySsoEnforced(orgId: string, enforced: boolean): Promise<void> {
231 const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
232 if (!org) return;
233 const policy = { ...((org.policy as Record<string, unknown> | null) ?? {}) };
234 policy.ssoEnforced = enforced;
235 await this.prisma.organization.update({
236 where: { id: orgId },
237 data: { policy: policy as Prisma.InputJsonValue }
238 });
239 }
240
241
247 async test(orgId: string, req: Request): Promise<{ ok: boolean; reason?: string; details?: Record<string, unknown> }> {
248 const userId = requireUser(req);
249 await this.requireOrgOwner(orgId, userId);
250 const p = await this.providerOf(orgId);
251 if (!p) return { ok: false, reason: "no_provider" };
252 try {
253 if (p.type === "SAML") {
254 const cfg = SamlConfigSchema.parse(p.config as Record<string, unknown>);
255 if (!cfg.metadataUrl) return { ok: false, reason: "no_metadata_url" };
256 const res = await fetch(cfg.metadataUrl, { signal: AbortSignal.timeout(10_000) });
257 if (!res.ok) return { ok: false, reason: "metadata_fetch_failed", details: { status: res.status } };
258 const xml = await res.text();
259 const entityMatch = /entityID="([^"]+)"/.exec(xml);
260 const ssoMatch = /Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"[^>]*Location="([^"]+)"/.exec(xml)
261 ?? /Location="([^"]+)"[^>]*Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"/.exec(xml);
262 const hasCert = /<X509Certificate>/.test(xml);
263 return {
264 ok: Boolean(entityMatch && ssoMatch),
265 reason: entityMatch && ssoMatch ? undefined : "metadata_incomplete",
266 details: {
267 idpEntityId: entityMatch?.[1] ?? null,
268 ssoUrl: ssoMatch?.[1] ?? null,
269 certificateInMetadata: hasCert
270 }
271 };
272 }
273 const cfg = OidcConfigSchema.parse(p.config as Record<string, unknown>);
274 const disc = await fetch(`${cfg.issuer}/.well-known/openid-configuration`, {
275 signal: AbortSignal.timeout(10_000)
276 });
277 if (!disc.ok) return { ok: false, reason: "discovery_failed", details: { status: disc.status } };
278 const doc = (await disc.json()) as { issuer?: string; jwks_uri?: string };
279 if (doc.issuer && doc.issuer !== cfg.issuer) return { ok: false, reason: "issuer_mismatch" };
280 const jwksRes = await fetch(doc.jwks_uri ?? cfg.jwksUri ?? `${cfg.issuer}/.well-known/jwks.json`, {
281 signal: AbortSignal.timeout(10_000)
282 });
283 if (!jwksRes.ok) return { ok: false, reason: "jwks_failed", details: { status: jwksRes.status } };
284 const jwks = (await jwksRes.json()) as { keys?: Array<{ kty?: string }> };
285 const hasRsa = (jwks.keys ?? []).some((k) => k.kty === "RSA");
286 return {
287 ok: hasRsa,
288 reason: hasRsa ? undefined : "no_rsa_keys",
289 details: { keyCount: jwks.keys?.length ?? 0 }
290 };
291 } catch (err) {
292 return { ok: false, reason: "probe_failed", details: { error: (err as Error).message } };
293 }
294 }
295}
296