1import { Inject, Injectable } from "@nestjs/common";
2import type { Response, Request } from "express";
3import bcrypt from "bcryptjs";
4import { v4 as uuid } from "uuid";
5import { PrismaService } from "../../common/prisma.service.js";
6import { AuditService } from "../../common/audit.service.js";
7import { EmailService } from "../../common/email.service.js";
8import { EventBus } from "../../common/event-bus.js";
9import { ApiError } from "../../common/errors.js";
10import { sha256hex, randomToken } from "../../lib/crypto.js";
11import { resolveEnforcedSsoOrgs } from "../sso/enforcement.js";
12import { signToken } from "../../lib/jwt.js";
13import { loadEnv } from "../../config/env.js";
14import { EVENTS, REFRESH_COOKIE } from "../../constants.js";
15import { buildRefreshCookieHeader } from "../../common/cookies.js";
16import { TwoFactorService } from "../org/two-factor.service.js";
17import { resolveUserPolicy, resolveSessionLifetimeHours } from "../org/org-policy.service.js";
18import { seedBuiltinTemplatesForWorkspace } from "@factory/shared";
19
20const BCRYPT_COST = 12;
21
22
33@Injectable()
34export class AuthService {
35 constructor(
36 @Inject(PrismaService) private readonly prisma: PrismaService,
37 @Inject(AuditService) private readonly audit: AuditService,
38 @Inject(EmailService) private readonly email: EmailService,
39 @Inject(EventBus) private readonly events: EventBus,
40 @Inject(TwoFactorService) private readonly twoFactor: TwoFactorService
41 ) {}
42
43
44
45
51 async issueTokens(
52 user: { id: string },
53 ctx: { req?: Request; res?: Response; ip?: string; userAgent?: string; authMethod?: "password" | "sso" }
54 ) {
55 const env = loadEnv();
56 const memberships = await this.prisma.workspaceMember.findMany({
57 where: { userId: user.id },
58 select: { workspaceId: true }
59 });
60 const sessionId = uuid();
61 const jti = uuid();
62
63 const accessToken = signToken(
64 {
65 sub: user.id,
66 workspaces: memberships.map((m) => m.workspaceId),
67 jti,
68 sid: sessionId,
69 ...(ctx.authMethod === "sso" ? { authMethod: "sso" } : {})
70 },
71 env.ACCESS_TOKEN_TTL,
72 env.JWT_SECRET
73 );
74 const refreshPlain = `rft_${randomToken(32)}`;
75
76
77
78 const lifetimeHours = await resolveSessionLifetimeHours(this.prisma, user.id);
79 const refreshExpiry = new Date(
80 Date.now() + (lifetimeHours != null ? Math.min(ttlToMs(env.REFRESH_TOKEN_TTL), lifetimeHours * 3600_000) : ttlToMs(env.REFRESH_TOKEN_TTL))
81 );
82
83 await this.prisma.$transaction([
84 this.prisma.session.create({
85 data: {
86 id: sessionId,
87 userId: user.id,
88 tokenHash: sha256hex(refreshPlain),
89 userAgent: ctx.userAgent ?? null,
90 ipAddress: ctx.ip ?? null,
91 status: "ACTIVE",
92 expiresAt: refreshExpiry
93 }
94 }),
95 this.prisma.refreshToken.create({
96 data: {
97 userId: user.id,
98 tokenHash: sha256hex(refreshPlain),
99 sessionAgent: sessionId,
100 expiresAt: refreshExpiry
101 }
102 })
103 ]);
104
105 if (ctx.res) this.setRefreshCookie(ctx.res, refreshPlain);
106 return {
107 accessToken,
108 refreshToken: refreshPlain,
109 expiresIn: Math.floor(ttlToMs(env.ACCESS_TOKEN_TTL) / 1000),
110 session: { id: sessionId, expiresAt: refreshExpiry }
111 };
112 }
113
114
115
116
117
118 setRefreshCookie(res: Response, token: string): void {
119 const env = loadEnv();
120 const maxAge = Math.floor(ttlToMs(env.REFRESH_TOKEN_TTL) / 1000);
121 res.setHeader("Set-Cookie", buildRefreshCookieHeader(token, { maxAgeSeconds: maxAge, secure: env.NODE_ENV === "production" }));
122 }
123
124 clearRefreshCookie(res: Response): void {
125
126
127
128 const env = loadEnv();
129 res.setHeader("Set-Cookie", buildRefreshCookieHeader(null, { maxAgeSeconds: 0, secure: env.NODE_ENV === "production" }));
130 }
131
132
133 readRefreshCookie(req: Request): string | undefined {
134 const header = req.headers.cookie;
135 if (!header) return undefined;
136 for (const part of header.split(";")) {
137 const idx = part.indexOf("=");
138 if (idx === -1) continue;
139 if (part.slice(0, idx).trim() === REFRESH_COOKIE) {
140 return decodeURIComponent(part.slice(idx + 1).trim());
141 }
142 }
143 return undefined;
144 }
145
146 private publicUser(user: {
147 id: string; email: string; name: string; displayName: string | null; avatarUrl: string | null;
148 emailVerification: string; emailVerifiedAt: Date | null; status: string; locale: string;
149 timezone: string; theme: string; lastLoginAt: Date | null; createdAt: Date;
150 }) {
151 return {
152 id: user.id,
153 email: user.email,
154 name: user.name,
155 displayName: user.displayName ?? user.name,
156 avatarUrl: user.avatarUrl,
157 emailVerification: user.emailVerification,
158 emailVerifiedAt: user.emailVerifiedAt,
159 status: user.status,
160 locale: user.locale,
161 timezone: user.timezone,
162 theme: user.theme,
163 lastLoginAt: user.lastLoginAt,
164 createdAt: user.createdAt
165 };
166 }
167
168
169
170 async register(input: { name: string; email: string; password: string; workspaceName?: string }, ctx: { req: Request; res: Response }) {
171 const email = input.email.toLowerCase();
172 const existing = await this.prisma.user.findUnique({ where: { email } });
173 if (existing) {
174 throw new ApiError("CONFLICT", 409, "Email already registered.", [{ field: "email", issue: "already_exists" }]);
175 }
176 const passwordHash = await bcrypt.hash(input.password, BCRYPT_COST);
177 const personalSlug = `${slugify(input.name)}-personal-${randomToken(3).toLowerCase()}`;
178
179 const user = await this.prisma.user.create({ data: { email, name: input.name, passwordHash } });
180
181
182 const personalWs = await this.prisma.workspace.create({
183 data: {
184 name: `${firstName(input.name)} Personal`,
185 slug: personalSlug,
186 isPersonal: true,
187 ownerId: user.id
188 }
189 });
190 await this.prisma.workspaceMember.create({
191 data: { workspaceId: personalWs.id, userId: user.id, role: "OWNER", joinedAt: new Date() }
192 });
193
194
195 let teamWs: { id: string; name: string } | null = null;
196 if (input.workspaceName) {
197 const created = await this.prisma.workspace.create({
198 data: {
199 name: input.workspaceName,
200 slug: `${slugify(input.workspaceName)}-${randomToken(3).toLowerCase()}`,
201 isPersonal: false,
202 ownerId: user.id
203 }
204 });
205 await this.prisma.workspaceMember.create({
206 data: { workspaceId: created.id, userId: user.id, role: "OWNER", joinedAt: new Date() }
207 });
208 teamWs = { id: created.id, name: created.name };
209 await this.events.publish(EVENTS.WORKSPACE_CREATED, { workspaceId: created.id, name: created.name, personal: false });
210 }
211
212 await this.email.sendVerification(user.id, user.email);
213 const tokens = await this.issueTokens(user, { req: ctx.req, res: ctx.res });
214 await this.audit.audit(ctx.req, undefined, {
215 action: EVENTS.USER_REGISTER,
216 resourceType: "user",
217 resourceId: user.id,
218 workspaceId: personalWs.id,
219 metadata: { email, personalWorkspace: personalWs.id, teamWorkspace: teamWs?.id ?? null }
220 });
221 await this.events.publish(EVENTS.USER_REGISTER, { userId: user.id, workspaceId: personalWs.id });
222
223 return {
224 user: this.publicUser(user),
225 workspace: teamWs ?? personalWs,
226 personalWorkspace: personalWs,
227 ...tokens
228 };
229 }
230
231
240 async ssoOptions(email: string): Promise<{ orgs: Array<{ slug: string; ssoAvailable: boolean; startUrl: string }> }> {
241 const env = loadEnv();
242
243
244 const origin = (env.SSO_ORIGIN || env.WEB_ORIGIN).replace(/\/$/, "");
245 const user = await this.prisma.user.findUnique({
246 where: { email: email.toLowerCase() },
247 select: { orgMemberships: { select: { organizationId: true } } }
248 });
249 if (!user || user.orgMemberships.length === 0) return { orgs: [] };
250 const providers = await this.prisma.ssoProvider.findMany({
251 where: { organizationId: { in: user.orgMemberships.map((m) => m.organizationId) } },
252 select: { organizationId: true, type: true, organization: { select: { slug: true } } }
253 });
254
255
256
257
258 return {
259 orgs: providers.map((p) => ({
260 slug: p.organization.slug,
261 ssoAvailable: true,
262 startUrl: `${origin}/sso/start/${p.organization.slug}`
263 }))
264 };
265 }
266
267
276 private async assertSsoNotEnforced(email: string): Promise<void> {
277 const enforced = await resolveEnforcedSsoOrgs(this.prisma, email);
278 if (enforced.length) {
279 throw new ApiError(
280 "FORBIDDEN",
281 403,
282 "This organization requires SSO sign-in.",
283 [{ reason: "sso_required", orgs: enforced.map((o) => o.slug) }]
284 );
285 }
286 }
287
288 async login(input: { email: string; password: string }, ctx: { req: Request; res: Response }) {
289 const email = input.email.toLowerCase();
290
291
292
293 await this.assertSsoNotEnforced(email);
294 const user = await this.prisma.user.findUnique({ where: { email } });
295 const hash = user?.passwordHash;
296
297 const ok = hash ? await bcrypt.compare(input.password, hash) : (await bcrypt.compare(input.password, DUMMY_HASH));
298 if (!user || !hash || !ok) {
299 throw ApiError.unauthorized("Invalid email or password.");
300 }
301 if (user.status === "SUSPENDED") {
302 throw ApiError.forbidden("Account suspended.", [{ reason: "account_suspended" }]);
303 }
304
305
306
307
308 const policy = await resolveUserPolicy(this.prisma, email);
309 if (policy.require2FA) {
310 const verificationId = await this.twoFactor.createPending(user.id);
311 await this.audit.audit(ctx.req, undefined, {
312 action: "auth.two_factor_required",
313 resourceType: "user",
314 resourceId: user.id,
315 metadata: { email }
316 });
317
318
319 ctx.res.status(202);
320 return { twoFactorRequired: true, reason: "2fa_required", verificationId };
321 }
322 await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
323 const tokens = await this.issueTokens(user, {
324 req: ctx.req,
325 res: ctx.res,
326 ip: ctx.req.ip,
327 userAgent: ctx.req.headers["user-agent"]
328 });
329 await this.audit.audit(ctx.req, undefined, {
330 action: EVENTS.USER_LOGIN,
331 resourceType: "user",
332 resourceId: user.id,
333 metadata: { email }
334 });
335 await this.events.publish(EVENTS.USER_LOGIN, { userId: user.id });
336 return {
337 user: this.publicUser(user),
338 ...tokens
339 };
340 }
341
342
348 async complete2Fa(
349 input: { verificationId: string; code: string },
350 ctx: { req: Request; res: Response }
351 ) {
352 const userId = await this.twoFactor.verifyPending(input.verificationId, input.code);
353 const user = await this.prisma.user.findUnique({ where: { id: userId } });
354 if (!user || user.status === "SUSPENDED") {
355 throw ApiError.unauthorized("Account unavailable.", [{ reason: "account_suspended" }]);
356 }
357 await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
358 const tokens = await this.issueTokens(user, {
359 req: ctx.req,
360 res: ctx.res,
361 ip: ctx.req.ip,
362 userAgent: ctx.req.headers["user-agent"]
363 });
364 await this.audit.audit(ctx.req, undefined, {
365 action: EVENTS.USER_LOGIN,
366 resourceType: "user",
367 resourceId: user.id,
368 metadata: { email: user.email, twoFactor: true }
369 });
370 await this.events.publish(EVENTS.USER_LOGIN, { userId: user.id });
371 return { user: this.publicUser(user), ...tokens };
372 }
373
374 async refresh(refreshPlain: string | undefined, ctx: { req: Request; res: Response }) {
375 if (!refreshPlain) {
376 throw ApiError.unauthorized("Missing refresh token.", [{ reason: "refresh_token_missing" }]);
377 }
378 const tokenHash = sha256hex(refreshPlain);
379 const row = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
380 if (!row) {
381
382
383
384
385 this.clearRefreshCookie(ctx.res);
386 throw ApiError.unauthorized("Unknown refresh token.", [{ reason: "refresh_token_invalid" }]);
387 }
388 if (row.revokedAt) {
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408 const successor = row.replacedBy
409 ? await this.prisma.refreshToken.findUnique({
410 where: { id: row.replacedBy },
411 select: { id: true, revokedAt: true, expiresAt: true }
412 })
413 : null;
414 const successorLive = Boolean(successor && successor.revokedAt === null);
415 if (successorLive) {
416
417
418
419
420
421
422
423
424 this.clearRefreshCookie(ctx.res);
425 await this.audit.audit(ctx.req, undefined, {
426 action: "auth.refresh_stale_retry",
427 resourceType: "user",
428 resourceId: row.userId,
429 metadata: { sessionId: row.sessionAgent ?? null }
430 });
431 throw ApiError.unauthorized("Refresh token already rotated.", [{ reason: "refresh_token_stale" }]);
432 }
433
434 await this.prisma.$transaction([
435 this.prisma.refreshToken.updateMany({
436 where: { userId: row.userId, revokedAt: null },
437 data: { revokedAt: new Date() }
438 }),
439 this.prisma.session.updateMany({
440 where: { userId: row.userId, status: "ACTIVE" },
441 data: { status: "REVOKED", revokedAt: new Date() }
442 })
443 ]);
444
445
446
447 this.clearRefreshCookie(ctx.res);
448 await this.audit.audit(ctx.req, undefined, {
449 action: "auth.refresh_reuse_detected",
450 resourceType: "user",
451 resourceId: row.userId,
452 metadata: {
453 sessionId: row.sessionAgent ?? null,
454
455
456 successorPresent: Boolean(row.replacedBy)
457 }
458 });
459 throw ApiError.unauthorized("Refresh token reused; all sessions revoked.", [{ reason: "refresh_token_reused" }]);
460 }
461 const user = await this.prisma.user.findUnique({ where: { id: row.userId } });
462 if (!user || user.status === "SUSPENDED") {
463 throw ApiError.forbidden("User unavailable.", [{ reason: "account_suspended" }]);
464 }
465
466
467
468
469
470
471 const session = row.sessionAgent
472 ? await this.prisma.session.findUnique({
473 where: { id: row.sessionAgent },
474 select: { createdAt: true }
475 })
476 : null;
477 const lifetimeHours = await resolveSessionLifetimeHours(this.prisma, row.userId);
478 const policyDeadline =
479 lifetimeHours != null && session ? session.createdAt.getTime() + lifetimeHours * 3600_000 : null;
480 if (policyDeadline != null && policyDeadline < Date.now()) {
481 this.clearRefreshCookie(ctx.res);
482 throw ApiError.unauthorized("Session expired (org policy).", [{ reason: "session_expired" }]);
483 }
484 if (row.expiresAt.getTime() < Date.now()) {
485
486
487 this.clearRefreshCookie(ctx.res);
488 throw ApiError.unauthorized("Refresh token expired.", [{ reason: "refresh_token_expired" }]);
489 }
490
491
492
493
494
495
496
497
498
499
500
501
502 const env = loadEnv();
503 const newPlain = `rft_${randomToken(32)}`;
504 const newHash = sha256hex(newPlain);
505
506
507 const expiresAt = new Date(
508 policyDeadline != null
509 ? Math.min(Date.now() + ttlToMs(env.REFRESH_TOKEN_TTL), policyDeadline)
510 : Date.now() + ttlToMs(env.REFRESH_TOKEN_TTL)
511 );
512 let rotated = false;
513 try {
514 await this.prisma.$transaction(async (tx) => {
515 const newRow = await tx.refreshToken.create({
516 data: {
517 userId: row.userId,
518 tokenHash: newHash,
519 sessionAgent: row.sessionAgent ?? undefined,
520 expiresAt
521 }
522 });
523 const updated = await tx.refreshToken.updateMany({
524 where: { id: row.id, revokedAt: null },
525 data: { revokedAt: new Date(), replacedBy: newRow.id }
526 });
527 if (updated.count === 0) {
528
529
530
531
532 throw new Error("rotation_race_lost");
533 }
534 if (row.sessionAgent) {
535 await tx.session.updateMany({
536 where: { id: row.sessionAgent, userId: row.userId },
537 data: { expiresAt }
538 });
539 }
540 rotated = true;
541 });
542 } catch (err) {
543 if (rotated) throw err;
544 if (!(err instanceof Error && err.message === "rotation_race_lost")) throw err;
545 }
546 if (!rotated) {
547
548
549
550 this.clearRefreshCookie(ctx.res);
551 await this.audit.audit(ctx.req, undefined, {
552 action: "auth.refresh_stale_retry",
553 resourceType: "user",
554 resourceId: row.userId,
555 metadata: { sessionId: row.sessionAgent ?? null, raceLost: true }
556 });
557 throw ApiError.unauthorized("Refresh token already rotated.", [{ reason: "refresh_token_stale" }]);
558 }
559
560
561
562
563
564
565 this.setRefreshCookie(ctx.res, newPlain);
566 const tokens = await this.issueAccess(user, row.sessionAgent ?? undefined, ctx.res);
567 await this.audit.audit(ctx.req, undefined, {
568 action: "auth.refresh",
569 resourceType: "user",
570 resourceId: user.id,
571 metadata: { sessionId: row.sessionAgent ?? null }
572 });
573 return { ...tokens, refreshToken: newPlain };
574 }
575
576 private async issueAccess(user: { id: string }, sessionId: string | undefined, res?: Response) {
577 const env = loadEnv();
578 const memberships = await this.prisma.workspaceMember.findMany({
579 where: { userId: user.id },
580 select: { workspaceId: true }
581 });
582 const accessToken = signToken(
583 { sub: user.id, workspaces: memberships.map((m) => m.workspaceId), jti: uuid(), ...(sessionId ? { sid: sessionId } : {}) },
584 env.ACCESS_TOKEN_TTL,
585 env.JWT_SECRET
586 );
587 return {
588 accessToken,
589 expiresIn: Math.floor(ttlToMs(env.ACCESS_TOKEN_TTL) / 1000),
590 session: sessionId ? { id: sessionId } : undefined
591 };
592 }
593
594 async logout(refreshPlain: string | undefined, sessionId: string | undefined, ctx: { req: Request; res: Response }, userId?: string) {
595 const id = userId ?? ctx.req.auth?.userId;
596 let revokedSession = false;
597 if (sessionId && id) {
598 const session = await this.prisma.session.findFirst({ where: { id: sessionId, userId: id } });
599 if (session) {
600 await this.prisma.$transaction([
601 this.prisma.session.update({ where: { id: session.id }, data: { status: "REVOKED", revokedAt: new Date() } }),
602 this.prisma.refreshToken.updateMany({
603 where: { userId: id, sessionAgent: session.id, revokedAt: null },
604 data: { revokedAt: new Date() }
605 })
606 ]);
607 revokedSession = true;
608 }
609 }
610 if (refreshPlain && id) {
611 await this.prisma.refreshToken.updateMany({
612 where: { userId: id, tokenHash: sha256hex(refreshPlain), revokedAt: null },
613 data: { revokedAt: new Date() }
614 });
615 }
616 this.clearRefreshCookie(ctx.res);
617 if (id) {
618 await this.audit.audit(ctx.req, ctx.req.auth, {
619 action: EVENTS.USER_LOGOUT,
620 resourceType: "user",
621 resourceId: id,
622 metadata: { sessionId: sessionId ?? null, revokedSession }
623 });
624 await this.events.publish(EVENTS.USER_LOGOUT, { userId: id });
625 }
626 }
627
628 async me(userId: string) {
629 const user = await this.prisma.user.findUnique({ where: { id: userId } });
630 if (!user) throw ApiError.notFound();
631 const memberships = await this.prisma.workspaceMember.findMany({
632 where: { userId },
633 include: { workspace: { select: { id: true, name: true, slug: true, isPersonal: true } } }
634 });
635 return {
636 user: this.publicUser(user),
637 workspaces: memberships.map((m) => ({
638 id: m.workspace.id,
639 name: m.workspace.name,
640 slug: m.workspace.slug,
641 isPersonal: m.workspace.isPersonal,
642 role: m.role
643 }))
644 };
645 }
646
647
648 async resetPassword(email: string): Promise<void> {
649 const user = await this.prisma.user.findUnique({ where: { email: email.toLowerCase() } });
650 if (user && user.status !== "SUSPENDED") {
651 await this.email.sendPasswordResetCode(user.id, user.email);
652 }
653 }
654
655 async resetPasswordConfirm(input: { email: string; code: string; newPassword: string }, ctx: { req: Request }) {
656 const user = await this.prisma.user.findUnique({ where: { email: input.email.toLowerCase() } });
657 if (!user) throw ApiError.validation("Invalid reset code.", [{ field: "code", issue: "invalid_code" }]);
658 const result = await this.email.consumePasswordResetCode(user.id, input.code);
659 if (result.status === "NOT_FOUND") {
660 throw ApiError.validation("Invalid reset code.", [{ field: "code", issue: "invalid_code" }]);
661 }
662 if (result.status === "EXPIRED") {
663 throw new ApiError("GONE", 410, "Reset code expired.", [{ reason: "token_expired" }]);
664 }
665 const passwordHash = await bcrypt.hash(input.newPassword, BCRYPT_COST);
666 await this.prisma.$transaction([
667 this.prisma.user.update({ where: { id: user.id }, data: { passwordHash } }),
668 this.prisma.session.updateMany({ where: { userId: user.id, status: "ACTIVE" }, data: { status: "REVOKED", revokedAt: new Date() } }),
669 this.prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } })
670 ]);
671 await this.audit.audit(ctx.req, undefined, {
672 action: "user.password_reset",
673 resourceType: "user",
674 resourceId: user.id
675 });
676 return { reset: true as const };
677 }
678}
679
680
681const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy";
682
683function ttlToMs(ttl: string): number {
684 const m = /^(\d+)(ms|s|m|h|d)$/.exec(ttl.trim());
685 if (!m) throw new Error(`invalid ttl: ${ttl}`);
686 const n = Number(m[1]);
687 switch (m[2]) {
688 case "ms": return n;
689 case "s": return n * 1000;
690 case "m": return n * 60_000;
691 case "h": return n * 3_600_000;
692 case "d": return n * 86_400_000;
693 }
694 return 0;
695}
696
697function firstName(name: string): string {
698 return name.trim().split(/\s+/)[0] ?? name;
699}
700
701function slugify(name: string): string {
702 return name
703 .toLowerCase()
704 .replace(/[^a-z0-9]+/g, "-")
705 .replace(/^-+|-+$/g, "")
706 .slice(0, 40) || "workspace";
707}
708