/** * TASK-0131 — SSO provisioning (the SHARED match-or-create path for BOTH * SAML and OIDC — one code path, selected by provider type upstream). * * Steps (per the task spec): * 1. **Match or create** the user: * a. existing `SsoIdentity {providerId, externalId}` → that user; * b. else an existing user with a matching VERIFIED email → link the * identity to them (SsoIdentity row) — no new user; * c. else JIT-create: a passwordless user (`passwordHash: null`, * email verified) + the SsoIdentity row. JIT is disabled by * `policy.ssoJitProvisioning === false` (then a foreign email is a * hard failure with the `unknown_email` reason). * 2. **Org membership**: the user becomes an `OrganizationMember` of the * provider's org (role from `policy.ssoDefaultRole`, CAPPED at MEMBER — * owner/admin must be granted by an org admin, never by JIT). Existing * membership is untouched (never demoted by a login). * 3. **Workspace membership**: auto-join every non-personal workspace of * the org (role MEMBER) — the "auto-join the org's workspaces" policy. * Existing memberships are untouched. A JIT user is NOT auto-joined * into workspaces of OTHER orgs (the membership query is org-scoped by * construction — the acceptance criterion "only the SSO org's * workspaces"). * 4. **Session**: the same session creation as a password login * (`AuthService.issueTokens`) with `authMethod:"sso"` — so the refresh * rotation, reuse-detection and session revocation are IDENTICAL to a * password session (the spec: "SSO session refresh works like any * session"). * * Audit: `sso.login.saml` / `sso.login.oidc` (success) and * `sso.login_failed {reason}` (every rejection path — the matrix reasons * from the SAML validator + the OIDC/JIT failures). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request, Response } from "express"; import type { User } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { AuthService } from "../auth/auth.service.js"; import { SsoSecretsService } from "./sso-secrets.js"; export interface SsoProvisioningInput { req: Request; res: Response; /** The org the SsoProvider belongs to. */ organizationId: string; /** The SsoProvider row (config parsed + validated upstream). */ provider: { id: string; type: "SAML" | "OIDC"; name: string; config: Record }; /** The IdP-issued external identity id (NameID / `sub`). */ externalId: string; /** The IdP-issued email (assertion attribute / `email` claim). */ email: string; /** The IdP-issued display name (optional — falls back to the email local part). */ displayName?: string | null; /** Extra claims (attribute mapping / nested claim paths, already resolved). */ claims?: Record; } export interface SsoLoginResult { user: { id: string; email: string; name: string }; provisioned: "existing" | "linked" | "created"; tokens: { accessToken: string; refreshToken: string; expiresIn: number; session: { id: string; expiresAt: Date } }; } export type SsoFailureReason = | "unknown_email" | "suspended" | "jit_disabled" | (string & {}); @Injectable() export class SsoProvisioningService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(AuthService) private readonly auth: AuthService, @Inject(SsoSecretsService) private readonly secrets: SsoSecretsService ) {} /** Read the org's SSO policy keys (defaults per the task spec). */ private async ssoPolicy(organizationId: string) { const org = await this.prisma.organization.findUnique({ where: { id: organizationId }, select: { policy: true } }); const policy = (org?.policy as Record | null) ?? {}; return { ssoJitProvisioning: policy.ssoJitProvisioning !== false, ssoDefaultRole: policy.ssoDefaultRole === "GUEST" ? "GUEST" : "MEMBER", ssoEnforced: policy.ssoEnforced === true }; } /** Record an SSO login failure (audit only — never thrown by this service). */ private async loginFailed( input: SsoProvisioningInput, reason: string, extra?: Record ): Promise { await this.audit.audit(input.req, undefined, { action: "sso.login_failed", resourceType: "user", resourceId: input.email.toLowerCase(), metadata: { orgId: input.organizationId, provider: input.provider.name, type: input.provider.type, reason, externalId: input.externalId, ...extra } }); } /** * Match-or-create (the SSO identity → user resolution). Exactly one of the * three branches runs: an existing SsoIdentity link, a link of a * known-email account, or a JIT-created passwordless user. Returns BOTH * the user and which branch ran (for the `sso.login.*` audit * `provisioned` field). */ private async matchOrCreate(args: { providerId: string; externalId: string; email: string; displayName?: string | null; jitEnabled: boolean; fail: (reason: string, extra?: Record) => Promise; }): Promise<{ user: User; provisioned: SsoLoginResult["provisioned"] }> { const { providerId, externalId, email, displayName, jitEnabled, fail } = args; const byIdentity = await this.prisma.ssoIdentity.findUnique({ where: { providerId_externalId: { providerId, externalId } } }); if (byIdentity) { const linked = await this.prisma.user.findUnique({ where: { id: byIdentity.userId } }); if (linked) return { user: linked, provisioned: "existing" }; } const byEmail = await this.prisma.user.findUnique({ where: { email } }); if (byEmail) { await this.prisma.ssoIdentity.create({ data: { providerId, externalId, userId: byEmail.id } }); return { user: byEmail, provisioned: "linked" }; } if (!jitEnabled) await fail("jit_disabled", { email }); const created = await this.prisma.user.create({ data: { email, name: displayName?.trim() || email.split("@")[0], displayName: displayName?.trim() || null, emailVerifiedAt: new Date(), emailVerification: "VERIFIED", passwordHash: null } }); await this.prisma.ssoIdentity.create({ data: { providerId, externalId, userId: created.id } }); return { user: created, provisioned: "created" }; } /** * The full provisioning + session path. Throws `ApiError.forbidden` * (reason `sso_login_failed` + the specific `sso.login_failed` reason in * metadata) for any rejection — the controller turns that into the * redirect-to-login error state. */ async provision(input: SsoProvisioningInput): Promise { const email = input.email.toLowerCase(); const policy = await this.ssoPolicy(input.organizationId); const protocol = input.provider.type.toLowerCase(); // "saml" | "oidc" const fail = async (reason: string, extra?: Record): Promise => { await this.loginFailed(input, reason, extra); throw new ApiError( "FORBIDDEN", 403, "SSO sign-in failed.", [{ reason: "sso_login_failed", detail: reason }] ); }; // ---- 1. match or create (a single helper returns BOTH so control flow // is provably total for the type-checker) ---- const providerId = input.provider.id; const { user, provisioned } = await this.matchOrCreate({ providerId, externalId: input.externalId, email, displayName: input.displayName, jitEnabled: policy.ssoJitProvisioning, fail }); if (user.status === "SUSPENDED") { await fail("suspended", { userId: user.id }); } // ---- 2. org membership (role CAPPED at MEMBER; never demotes) ---- const existingOrgMembership = await this.prisma.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: input.organizationId, userId: user.id } } }); if (!existingOrgMembership) { const role = policy.ssoDefaultRole; // "GUEST" | "MEMBER" (capped by ssoPolicy) await this.prisma.organizationMember.create({ data: { organizationId: input.organizationId, userId: user.id, role: role as never } }); } // ---- 3. auto-join the org's (non-personal) workspaces ---- const orgWorkspaces = await this.prisma.workspace.findMany({ where: { organizationId: input.organizationId, isPersonal: false, deletedAt: null }, select: { id: true } }); const memberships = await this.prisma.workspaceMember.findMany({ where: { userId: user.id, workspaceId: { in: orgWorkspaces.map((w) => w.id) } }, select: { workspaceId: true } }); const missing = orgWorkspaces.filter((w) => !memberships.some((m) => m.workspaceId === w.id)); if (missing.length) { await this.prisma.workspaceMember.createMany({ data: missing.map((w) => ({ workspaceId: w.id, userId: user.id, role: "MEMBER" as never, joinedAt: new Date() })), skipDuplicates: true }); } // ---- 4. session (same as password login, authMethod:"sso") ---- await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); const tokens = await this.auth.issueTokens(user, { req: input.req, res: input.res, ip: input.req.ip, userAgent: input.req.headers["user-agent"], authMethod: "sso" }); await this.audit.audit(input.req, undefined, { action: `sso.login.${protocol}`, resourceType: "user", resourceId: user.id, metadata: { orgId: input.organizationId, provider: input.provider.name, nameId: input.externalId, provisioned, ip: input.req.ip, userAgent: input.req.headers["user-agent"] ?? null } }); return { user: { id: user.id, email: user.email, name: user.displayName ?? user.name }, provisioned, tokens: { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresIn: tokens.expiresIn, session: tokens.session } }; } /** * Enforce-mode resolution for a PASSWORD login (TASK-0131): given the * login email, find the orgs the user belongs to that have an ACTIVE, * ENFORCED SSO provider. An empty list = normal password login; a * non-empty list = `403 {reason:"sso_required", orgs:[slugs]}`. * * Org resolution is email → user → org memberships (a user with NO org * never sees enforcement — the spec: "no org → normal login"). */ async enforceCheck(email: string): Promise> { const user = await this.prisma.user.findUnique({ where: { email: email.toLowerCase() }, select: { id: true, orgMemberships: { select: { organizationId: true } } } }); if (!user || user.orgMemberships.length === 0) return []; const providers = await this.prisma.ssoProvider.findMany({ where: { organizationId: { in: user.orgMemberships.map((m) => m.organizationId) }, enforce: true }, include: { organization: { select: { id: true, slug: true } } } }); // Only enforce for orgs where the user is actually a member (the // provider query is already org-scoped; the membership join above is // the scope). return providers.map((p) => ({ orgId: p.organizationId, slug: p.organization.slug, type: p.type })); } }