1
34import { Inject, Injectable } from "@nestjs/common";
35import type { Request, Response } from "express";
36import type { User } from "@prisma/client";
37import { PrismaService } from "../../common/prisma.service.js";
38import { AuditService } from "../../common/audit.service.js";
39import { ApiError } from "../../common/errors.js";
40import { AuthService } from "../auth/auth.service.js";
41import { SsoSecretsService } from "./sso-secrets.js";
42
43export interface SsoProvisioningInput {
44 req: Request;
45 res: Response;
46
47 organizationId: string;
48
49 provider: { id: string; type: "SAML" | "OIDC"; name: string; config: Record<string, unknown> };
50
51 externalId: string;
52
53 email: string;
54
55 displayName?: string | null;
56
57 claims?: Record<string, unknown>;
58}
59
60export interface SsoLoginResult {
61 user: { id: string; email: string; name: string };
62 provisioned: "existing" | "linked" | "created";
63 tokens: { accessToken: string; refreshToken: string; expiresIn: number; session: { id: string; expiresAt: Date } };
64}
65
66export type SsoFailureReason =
67 | "unknown_email"
68 | "suspended"
69 | "jit_disabled"
70 | (string & {});
71
72@Injectable()
73export class SsoProvisioningService {
74 constructor(
75 @Inject(PrismaService) private readonly prisma: PrismaService,
76 @Inject(AuditService) private readonly audit: AuditService,
77 @Inject(AuthService) private readonly auth: AuthService,
78 @Inject(SsoSecretsService) private readonly secrets: SsoSecretsService
79 ) {}
80
81
82 private async ssoPolicy(organizationId: string) {
83 const org = await this.prisma.organization.findUnique({
84 where: { id: organizationId },
85 select: { policy: true }
86 });
87 const policy = (org?.policy as Record<string, unknown> | null) ?? {};
88 return {
89 ssoJitProvisioning: policy.ssoJitProvisioning !== false,
90 ssoDefaultRole: policy.ssoDefaultRole === "GUEST" ? "GUEST" : "MEMBER",
91 ssoEnforced: policy.ssoEnforced === true
92 };
93 }
94
95
96 private async loginFailed(
97 input: SsoProvisioningInput,
98 reason: string,
99 extra?: Record<string, unknown>
100 ): Promise<void> {
101 await this.audit.audit(input.req, undefined, {
102 action: "sso.login_failed",
103 resourceType: "user",
104 resourceId: input.email.toLowerCase(),
105 metadata: {
106 orgId: input.organizationId,
107 provider: input.provider.name,
108 type: input.provider.type,
109 reason,
110 externalId: input.externalId,
111 ...extra
112 }
113 });
114 }
115
116
123 private async matchOrCreate(args: {
124 providerId: string;
125 externalId: string;
126 email: string;
127 displayName?: string | null;
128 jitEnabled: boolean;
129 fail: (reason: string, extra?: Record<string, unknown>) => Promise<never>;
130 }): Promise<{ user: User; provisioned: SsoLoginResult["provisioned"] }> {
131 const { providerId, externalId, email, displayName, jitEnabled, fail } = args;
132 const byIdentity = await this.prisma.ssoIdentity.findUnique({
133 where: { providerId_externalId: { providerId, externalId } }
134 });
135 if (byIdentity) {
136 const linked = await this.prisma.user.findUnique({ where: { id: byIdentity.userId } });
137 if (linked) return { user: linked, provisioned: "existing" };
138 }
139 const byEmail = await this.prisma.user.findUnique({ where: { email } });
140 if (byEmail) {
141 await this.prisma.ssoIdentity.create({
142 data: { providerId, externalId, userId: byEmail.id }
143 });
144 return { user: byEmail, provisioned: "linked" };
145 }
146 if (!jitEnabled) await fail("jit_disabled", { email });
147 const created = await this.prisma.user.create({
148 data: {
149 email,
150 name: displayName?.trim() || email.split("@")[0],
151 displayName: displayName?.trim() || null,
152 emailVerifiedAt: new Date(),
153 emailVerification: "VERIFIED",
154 passwordHash: null
155 }
156 });
157 await this.prisma.ssoIdentity.create({
158 data: { providerId, externalId, userId: created.id }
159 });
160 return { user: created, provisioned: "created" };
161 }
162
163
169 async provision(input: SsoProvisioningInput): Promise<SsoLoginResult> {
170 const email = input.email.toLowerCase();
171 const policy = await this.ssoPolicy(input.organizationId);
172 const protocol = input.provider.type.toLowerCase();
173
174 const fail = async (reason: string, extra?: Record<string, unknown>): Promise<never> => {
175 await this.loginFailed(input, reason, extra);
176 throw new ApiError(
177 "FORBIDDEN",
178 403,
179 "SSO sign-in failed.",
180 [{ reason: "sso_login_failed", detail: reason }]
181 );
182 };
183
184
185
186 const providerId = input.provider.id;
187 const { user, provisioned } = await this.matchOrCreate({
188 providerId,
189 externalId: input.externalId,
190 email,
191 displayName: input.displayName,
192 jitEnabled: policy.ssoJitProvisioning,
193 fail
194 });
195
196 if (user.status === "SUSPENDED") {
197 await fail("suspended", { userId: user.id });
198 }
199
200
201 const existingOrgMembership = await this.prisma.organizationMember.findUnique({
202 where: { organizationId_userId: { organizationId: input.organizationId, userId: user.id } }
203 });
204 if (!existingOrgMembership) {
205 const role = policy.ssoDefaultRole;
206 await this.prisma.organizationMember.create({
207 data: { organizationId: input.organizationId, userId: user.id, role: role as never }
208 });
209 }
210
211
212 const orgWorkspaces = await this.prisma.workspace.findMany({
213 where: { organizationId: input.organizationId, isPersonal: false, deletedAt: null },
214 select: { id: true }
215 });
216 const memberships = await this.prisma.workspaceMember.findMany({
217 where: { userId: user.id, workspaceId: { in: orgWorkspaces.map((w) => w.id) } },
218 select: { workspaceId: true }
219 });
220 const missing = orgWorkspaces.filter((w) => !memberships.some((m) => m.workspaceId === w.id));
221 if (missing.length) {
222 await this.prisma.workspaceMember.createMany({
223 data: missing.map((w) => ({
224 workspaceId: w.id,
225 userId: user.id,
226 role: "MEMBER" as never,
227 joinedAt: new Date()
228 })),
229 skipDuplicates: true
230 });
231 }
232
233
234 await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
235 const tokens = await this.auth.issueTokens(user, {
236 req: input.req,
237 res: input.res,
238 ip: input.req.ip,
239 userAgent: input.req.headers["user-agent"],
240 authMethod: "sso"
241 });
242 await this.audit.audit(input.req, undefined, {
243 action: `sso.login.${protocol}`,
244 resourceType: "user",
245 resourceId: user.id,
246 metadata: {
247 orgId: input.organizationId,
248 provider: input.provider.name,
249 nameId: input.externalId,
250 provisioned,
251 ip: input.req.ip,
252 userAgent: input.req.headers["user-agent"] ?? null
253 }
254 });
255
256 return {
257 user: { id: user.id, email: user.email, name: user.displayName ?? user.name },
258 provisioned,
259 tokens: {
260 accessToken: tokens.accessToken,
261 refreshToken: tokens.refreshToken,
262 expiresIn: tokens.expiresIn,
263 session: tokens.session
264 }
265 };
266 }
267
268
277 async enforceCheck(email: string): Promise<Array<{ orgId: string; slug: string; type: "SAML" | "OIDC" }>> {
278 const user = await this.prisma.user.findUnique({
279 where: { email: email.toLowerCase() },
280 select: {
281 id: true,
282 orgMemberships: { select: { organizationId: true } }
283 }
284 });
285 if (!user || user.orgMemberships.length === 0) return [];
286 const providers = await this.prisma.ssoProvider.findMany({
287 where: {
288 organizationId: { in: user.orgMemberships.map((m) => m.organizationId) },
289 enforce: true
290 },
291 include: { organization: { select: { id: true, slug: true } } }
292 });
293
294
295
296 return providers.map((p) => ({ orgId: p.organizationId, slug: p.organization.slug, type: p.type }));
297 }
298}
299