import { Inject, Injectable } from "@nestjs/common"; import type { Response, Request } from "express"; import bcrypt from "bcryptjs"; import { v4 as uuid } from "uuid"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EmailService } from "../../common/email.service.js"; import { EventBus } from "../../common/event-bus.js"; import { ApiError } from "../../common/errors.js"; import { sha256hex, randomToken } from "../../lib/crypto.js"; import { resolveEnforcedSsoOrgs } from "../sso/enforcement.js"; import { signToken } from "../../lib/jwt.js"; import { loadEnv } from "../../config/env.js"; import { EVENTS, REFRESH_COOKIE } from "../../constants.js"; import { buildRefreshCookieHeader } from "../../common/cookies.js"; import { TwoFactorService } from "../org/two-factor.service.js"; import { resolveUserPolicy, resolveSessionLifetimeHours } from "../org/org-policy.service.js"; import { seedBuiltinTemplatesForWorkspace } from "@factory/shared"; const BCRYPT_COST = 12; /** * Authentication: registration, login, refresh-token rotation, logout, * password reset. Refresh tokens are opaque 256-bit strings stored hashed * (SHA-256) with 30-day expiry and rotated on every use. Presenting an * already-rotated (revoked) token is handled two ways (security-architecture * §1.2 step 3): if its successor is still live it is a benign STALE RETRY (a * concurrent refresh won the race — e.g. two in-flight bootstraps) → plain * 401, no session loss; if the successor is gone it is a REUSE ATTACK → the * user's whole session set is revoked. The rotation chain is anchored at the * session via the `sessionAgent` column which stores the session id. */ @Injectable() export class AuthService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EmailService) private readonly email: EmailService, @Inject(EventBus) private readonly events: EventBus, @Inject(TwoFactorService) private readonly twoFactor: TwoFactorService ) {} // ---------- token issuance ---------- /** * Issue a login session (Session row + rotating refresh token + access * JWT). `authMethod` (TASK-0131) tags SSO sign-ins with `authMethod:"sso"` * in the access JWT so the session layer can distinguish how the user got * in (audit + policy); password logins carry NO new claim (shape stable). */ async issueTokens( user: { id: string }, ctx: { req?: Request; res?: Response; ip?: string; userAgent?: string; authMethod?: "password" | "sso" } ) { const env = loadEnv(); const memberships = await this.prisma.workspaceMember.findMany({ where: { userId: user.id }, select: { workspaceId: true } }); const sessionId = uuid(); const jti = uuid(); const accessToken = signToken( { sub: user.id, workspaces: memberships.map((m) => m.workspaceId), jti, sid: sessionId, ...(ctx.authMethod === "sso" ? { authMethod: "sso" } : {}) }, env.ACCESS_TOKEN_TTL, env.JWT_SECRET ); const refreshPlain = `rft_${randomToken(32)}`; // TASK-0133 enforcement #4: an org `sessionLifetimeHours` policy CLAMPS // the session/refresh expiry (the platform refresh TTL is the ceiling; // the policy can only shorten it). `null` = no org set it → unchanged. const lifetimeHours = await resolveSessionLifetimeHours(this.prisma, user.id); const refreshExpiry = new Date( Date.now() + (lifetimeHours != null ? Math.min(ttlToMs(env.REFRESH_TOKEN_TTL), lifetimeHours * 3600_000) : ttlToMs(env.REFRESH_TOKEN_TTL)) ); await this.prisma.$transaction([ this.prisma.session.create({ data: { id: sessionId, userId: user.id, tokenHash: sha256hex(refreshPlain), userAgent: ctx.userAgent ?? null, ipAddress: ctx.ip ?? null, status: "ACTIVE", expiresAt: refreshExpiry } }), this.prisma.refreshToken.create({ data: { userId: user.id, tokenHash: sha256hex(refreshPlain), sessionAgent: sessionId, expiresAt: refreshExpiry } }) ]); if (ctx.res) this.setRefreshCookie(ctx.res, refreshPlain); return { accessToken, refreshToken: refreshPlain, expiresIn: Math.floor(ttlToMs(env.ACCESS_TOKEN_TTL) / 1000), session: { id: sessionId, expiresAt: refreshExpiry } }; } // TASK-0134: cookie flags (Secure/HttpOnly/SameSite/Path/Max-Age) are // centralized in common/cookies.ts — the single source the cookie-flags // security test asserts. Behavior is byte-identical to the previous // inline strings (the e2e Set-Cookie parser contract is unchanged). setRefreshCookie(res: Response, token: string): void { const env = loadEnv(); const maxAge = Math.floor(ttlToMs(env.REFRESH_TOKEN_TTL) / 1000); res.setHeader("Set-Cookie", buildRefreshCookieHeader(token, { maxAgeSeconds: maxAge, secure: env.NODE_ENV === "production" })); } clearRefreshCookie(res: Response): void { // The clear MUST mirror the set path's flags (same env-driven Secure): // a clear cookie with a different attribute set is a DIFFERENT cookie to // the browser, so the real session cookie would survive logout in prod. const env = loadEnv(); res.setHeader("Set-Cookie", buildRefreshCookieHeader(null, { maxAgeSeconds: 0, secure: env.NODE_ENV === "production" })); } /** Read the refresh cookie (no cookie-parser dependency). */ readRefreshCookie(req: Request): string | undefined { const header = req.headers.cookie; if (!header) return undefined; for (const part of header.split(";")) { const idx = part.indexOf("="); if (idx === -1) continue; if (part.slice(0, idx).trim() === REFRESH_COOKIE) { return decodeURIComponent(part.slice(idx + 1).trim()); } } return undefined; } private publicUser(user: { id: string; email: string; name: string; displayName: string | null; avatarUrl: string | null; emailVerification: string; emailVerifiedAt: Date | null; status: string; locale: string; timezone: string; theme: string; lastLoginAt: Date | null; createdAt: Date; }) { return { id: user.id, email: user.email, name: user.name, displayName: user.displayName ?? user.name, avatarUrl: user.avatarUrl, emailVerification: user.emailVerification, emailVerifiedAt: user.emailVerifiedAt, status: user.status, locale: user.locale, timezone: user.timezone, theme: user.theme, lastLoginAt: user.lastLoginAt, createdAt: user.createdAt }; } // ---------- endpoints ---------- async register(input: { name: string; email: string; password: string; workspaceName?: string }, ctx: { req: Request; res: Response }) { const email = input.email.toLowerCase(); const existing = await this.prisma.user.findUnique({ where: { email } }); if (existing) { throw new ApiError("CONFLICT", 409, "Email already registered.", [{ field: "email", issue: "already_exists" }]); } const passwordHash = await bcrypt.hash(input.password, BCRYPT_COST); const personalSlug = `${slugify(input.name)}-personal-${randomToken(3).toLowerCase()}`; const user = await this.prisma.user.create({ data: { email, name: input.name, passwordHash } }); // Personal workspace + owner membership. const personalWs = await this.prisma.workspace.create({ data: { name: `${firstName(input.name)} Personal`, slug: personalSlug, isPersonal: true, ownerId: user.id } }); await this.prisma.workspaceMember.create({ data: { workspaceId: personalWs.id, userId: user.id, role: "OWNER", joinedAt: new Date() } }); // Optional team workspace from the registration form. let teamWs: { id: string; name: string } | null = null; if (input.workspaceName) { const created = await this.prisma.workspace.create({ data: { name: input.workspaceName, slug: `${slugify(input.workspaceName)}-${randomToken(3).toLowerCase()}`, isPersonal: false, ownerId: user.id } }); await this.prisma.workspaceMember.create({ data: { workspaceId: created.id, userId: user.id, role: "OWNER", joinedAt: new Date() } }); teamWs = { id: created.id, name: created.name }; await this.events.publish(EVENTS.WORKSPACE_CREATED, { workspaceId: created.id, name: created.name, personal: false }); } await this.email.sendVerification(user.id, user.email); const tokens = await this.issueTokens(user, { req: ctx.req, res: ctx.res }); await this.audit.audit(ctx.req, undefined, { action: EVENTS.USER_REGISTER, resourceType: "user", resourceId: user.id, workspaceId: personalWs.id, metadata: { email, personalWorkspace: personalWs.id, teamWorkspace: teamWs?.id ?? null } }); await this.events.publish(EVENTS.USER_REGISTER, { userId: user.id, workspaceId: personalWs.id }); return { user: this.publicUser(user), workspace: teamWs ?? personalWs, personalWorkspace: personalWs, ...tokens }; } /** * TASK-0131: the login page's ANONYMOUS SSO discovery (GET * `/auth/sso/options?email=`). Minimal-info by design (spec): it reveals * ONLY the org SLUG(s) for which an SSO provider is enabled + a stable * start URL per org — NEVER the provider type, name, entity id, or any * URL that identifies the IdP. The browser then hits the server-side start * dispatcher (`/sso/start/:slug`) which does the protocol-specific 302, * so the client never learns the protocol before authenticating. */ async ssoOptions(email: string): Promise<{ orgs: Array<{ slug: string; ssoAvailable: boolean; startUrl: string }> }> { const env = loadEnv(); // The start URL is an API route (`/sso/start/:slug`) — the origin that // SERVES it (SSO_ORIGIN in two-origin dev, else WEB_ORIGIN). const origin = (env.SSO_ORIGIN || env.WEB_ORIGIN).replace(/\/$/, ""); const user = await this.prisma.user.findUnique({ where: { email: email.toLowerCase() }, select: { orgMemberships: { select: { organizationId: true } } } }); if (!user || user.orgMemberships.length === 0) return { orgs: [] }; const providers = await this.prisma.ssoProvider.findMany({ where: { organizationId: { in: user.orgMemberships.map((m) => m.organizationId) } }, select: { organizationId: true, type: true, organization: { select: { slug: true } } } }); // Minimal-info (spec): slug + a stable START URL that is protocol- // AGNOSTIC (the server-side `/sso/start/:slug` dispatcher does the // protocol-specific redirect). The caller never learns the provider // type or the org id — only that SSO exists for that org. return { orgs: providers.map((p) => ({ slug: p.organization.slug, ssoAvailable: true, startUrl: `${origin}/sso/start/${p.organization.slug}` })) }; } /** * TASK-0131: SSO enforce gate. If ANY of the user's orgs has an active, * enforced SSO provider, a PASSWORD login is refused with * `403 {reason:"sso_required", orgs:[slugs]}` so the web login page can * redirect to the org's SSO entry. A user with no org (or unknown email) * resolves to [] → normal password login (the fast path — the query only * runs when the email matches a known user, so enumeration timing is * unchanged). */ private async assertSsoNotEnforced(email: string): Promise { const enforced = await resolveEnforcedSsoOrgs(this.prisma, email); if (enforced.length) { throw new ApiError( "FORBIDDEN", 403, "This organization requires SSO sign-in.", [{ reason: "sso_required", orgs: enforced.map((o) => o.slug) }] ); } } async login(input: { email: string; password: string }, ctx: { req: Request; res: Response }) { const email = input.email.toLowerCase(); // Enforce BEFORE the bcrypt compare: an enforced org never does a // password login at all (fast + avoids a constant-time compare we don't // need when we're going to reject). await this.assertSsoNotEnforced(email); const user = await this.prisma.user.findUnique({ where: { email } }); const hash = user?.passwordHash; // Always run a bcrypt compare (constant-time shape) to avoid user-enumeration timing. const ok = hash ? await bcrypt.compare(input.password, hash) : (await bcrypt.compare(input.password, DUMMY_HASH)); if (!user || !hash || !ok) { throw ApiError.unauthorized("Invalid email or password."); } if (user.status === "SUSPENDED") { throw ApiError.forbidden("Account suspended.", [{ reason: "account_suspended" }]); } // TASK-0133 enforcement #1: any of the user's orgs with `require2FA` // gates the login on a TOTP step (the password alone is NOT enough). // The challenge is a short-lived (5 min) single-use Redis step — NO // session is issued until `complete2Fa` verifies the code. const policy = await resolveUserPolicy(this.prisma, email); if (policy.require2FA) { const verificationId = await this.twoFactor.createPending(user.id); await this.audit.audit(ctx.req, undefined, { action: "auth.two_factor_required", resourceType: "user", resourceId: user.id, metadata: { email } }); // The spec's 202 contract: the password was RIGHT but the login is // NOT complete — the client calls POST /auth/2fa/verify. ctx.res.status(202); return { twoFactorRequired: true, reason: "2fa_required", verificationId }; } await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); const tokens = await this.issueTokens(user, { req: ctx.req, res: ctx.res, ip: ctx.req.ip, userAgent: ctx.req.headers["user-agent"] }); await this.audit.audit(ctx.req, undefined, { action: EVENTS.USER_LOGIN, resourceType: "user", resourceId: user.id, metadata: { email } }); await this.events.publish(EVENTS.USER_LOGIN, { userId: user.id }); return { user: this.publicUser(user), ...tokens }; } /** * `POST /auth/2fa/verify` — complete a challenged login (TASK-0133 * enforcement #1). Verifies the TOTP code against the CONSUMED pending * step (single-use, 5 min), then issues the session exactly like a * successful password login (including `user.login` audit + event). */ async complete2Fa( input: { verificationId: string; code: string }, ctx: { req: Request; res: Response } ) { const userId = await this.twoFactor.verifyPending(input.verificationId, input.code); const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user || user.status === "SUSPENDED") { throw ApiError.unauthorized("Account unavailable.", [{ reason: "account_suspended" }]); } await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); const tokens = await this.issueTokens(user, { req: ctx.req, res: ctx.res, ip: ctx.req.ip, userAgent: ctx.req.headers["user-agent"] }); await this.audit.audit(ctx.req, undefined, { action: EVENTS.USER_LOGIN, resourceType: "user", resourceId: user.id, metadata: { email: user.email, twoFactor: true } }); await this.events.publish(EVENTS.USER_LOGIN, { userId: user.id }); return { user: this.publicUser(user), ...tokens }; } async refresh(refreshPlain: string | undefined, ctx: { req: Request; res: Response }) { if (!refreshPlain) { throw ApiError.unauthorized("Missing refresh token.", [{ reason: "refresh_token_missing" }]); } const tokenHash = sha256hex(refreshPlain); const row = await this.prisma.refreshToken.findUnique({ where: { tokenHash } }); if (!row) { // The cookie the client just presented matches no row — it is dead // (revoked long ago, or never valid). Clear it: the middleware gates on // cookie EXISTENCE only, so a dead-but-present cookie keeps bouncing // /login → /w/default (a stuck gate that can never authenticate). this.clearRefreshCookie(ctx.res); throw ApiError.unauthorized("Unknown refresh token.", [{ reason: "refresh_token_invalid" }]); } if (row.revokedAt) { // Re-presenting an already-rotated token. Two distinct cases // (security-architecture.md §1.2 step 3) — they MUST NOT be conflated: // // (a) STALE RETRY — the successor token (this row's `replacedBy`) is // still live: the presentation is a lost duplicate, e.g. two // in-flight bootstraps racing on the same cookie, or a client // whose cookie jar still holds the pre-rotation value after the // server already rotated. No compromise: a benign plain 401. The // client's live token is untouched (the documented "client // re-logins" path). // // (b) REUSE ATTACK — the row is dead and its successor is gone too // (never issued, or itself revoked): the token was cloned/stolen // and is being replayed AFTER the chain moved on. The whole // session set is compromised → family kill. // // `replacedBy` being set is the clean discriminator because every // other revocation path (logout, per-device revocation, admin // suspension) revokes WITHOUT setting it. const successor = row.replacedBy ? await this.prisma.refreshToken.findUnique({ where: { id: row.replacedBy }, select: { id: true, revokedAt: true, expiresAt: true } }) : null; const successorLive = Boolean(successor && successor.revokedAt === null); if (successorLive) { // The client's PRESENTED token is dead (rotated out), so the httpOnly // cookie that carried it is stale. Clear it — a browser that just // lost a navigation race (its document was destroyed before the // winner's Set-Cookie could land in the jar) would otherwise hold a // dead cookie whose existence alone gates the middleware, bouncing // /login → /w/default behind a gate that can never authenticate. // (When the winner's cookie DID land, clearing just makes the next // bootstrap a clean no-refresh → /login.) this.clearRefreshCookie(ctx.res); await this.audit.audit(ctx.req, undefined, { action: "auth.refresh_stale_retry", resourceType: "user", resourceId: row.userId, metadata: { sessionId: row.sessionAgent ?? null } }); throw ApiError.unauthorized("Refresh token already rotated.", [{ reason: "refresh_token_stale" }]); } // Reuse attack: revoke the whole session set. await this.prisma.$transaction([ this.prisma.refreshToken.updateMany({ where: { userId: row.userId, revokedAt: null }, data: { revokedAt: new Date() } }), this.prisma.session.updateMany({ where: { userId: row.userId, status: "ACTIVE" }, data: { status: "REVOKED", revokedAt: new Date() } }) ]); // Clear the (dead) cookie — the middleware gates on cookie EXISTENCE // only, so a lingering revoked value re-redirects /login → /w/default // and the dead page spins on a cookie that can never authenticate. this.clearRefreshCookie(ctx.res); await this.audit.audit(ctx.req, undefined, { action: "auth.refresh_reuse_detected", resourceType: "user", resourceId: row.userId, metadata: { sessionId: row.sessionAgent ?? null, // Distinguishes a suspected clone (successor existed but is now // dead) from a plain post-revocation replay (no successor row). successorPresent: Boolean(row.replacedBy) } }); throw ApiError.unauthorized("Refresh token reused; all sessions revoked.", [{ reason: "refresh_token_reused" }]); } const user = await this.prisma.user.findUnique({ where: { id: row.userId } }); if (!user || user.status === "SUSPENDED") { throw ApiError.forbidden("User unavailable.", [{ reason: "account_suspended" }]); } // TASK-0133 enforcement #4: an org `sessionLifetimeHours` policy expires // the session at CREATION + lifetime (non-sliding, the spec's "refresh // past it → 401 {reason:session_expired}"). Checked BEFORE the natural // refresh-TTL expiry (a clamped session can be expired by POLICY while // the token row is still inside its 30 d TTL). // The session's CREATION anchors the org-policy lifetime (TASK-0133 #4). const session = row.sessionAgent ? await this.prisma.session.findUnique({ where: { id: row.sessionAgent }, select: { createdAt: true } }) : null; const lifetimeHours = await resolveSessionLifetimeHours(this.prisma, row.userId); const policyDeadline = lifetimeHours != null && session ? session.createdAt.getTime() + lifetimeHours * 3600_000 : null; if (policyDeadline != null && policyDeadline < Date.now()) { this.clearRefreshCookie(ctx.res); throw ApiError.unauthorized("Session expired (org policy).", [{ reason: "session_expired" }]); } if (row.expiresAt.getTime() < Date.now()) { // Expired → the session has ended; clear the dead cookie for the same // middleware-existence-gate reason as the unknown-token path. this.clearRefreshCookie(ctx.res); throw ApiError.unauthorized("Refresh token expired.", [{ reason: "refresh_token_expired" }]); } // Rotate atomically: insert the new token AND revoke the old one (plus // the session sliding-window renewal) in ONE interactive transaction. // The revocation is a CONDITIONAL updateMany (… AND revokedAt IS NULL): // if a concurrent refresh already rotated this row, the update matches 0 // rows and the WHOLE transaction rolls back — including the new token — // so a lost race leaves no orphaned live token behind. The old // create-then-unconditional-update sequence let two concurrent refreshes // both rotate the same row (last-writer-wins on `replacedBy`), which is // how orphaned live tokens accumulated. (Server side of the race fix; // the client side is the single-flight bootstrap in apps/web // use-auth.ts.) const env = loadEnv(); const newPlain = `rft_${randomToken(32)}`; const newHash = sha256hex(newPlain); // The rotation's SLIDING renewal is clamped to the org-policy deadline // (a policy-lifetime session cannot be slid past its deadline). const expiresAt = new Date( policyDeadline != null ? Math.min(Date.now() + ttlToMs(env.REFRESH_TOKEN_TTL), policyDeadline) : Date.now() + ttlToMs(env.REFRESH_TOKEN_TTL) ); let rotated = false; try { await this.prisma.$transaction(async (tx) => { const newRow = await tx.refreshToken.create({ data: { userId: row.userId, tokenHash: newHash, sessionAgent: row.sessionAgent ?? undefined, expiresAt } }); const updated = await tx.refreshToken.updateMany({ where: { id: row.id, revokedAt: null }, data: { revokedAt: new Date(), replacedBy: newRow.id } }); if (updated.count === 0) { // Lost the race: a concurrent refresh revoked `row` first, so its // new token is live. Throwing rolls the transaction back (discards // the just-created row) — no orphaned token — and reports a benign // stale retry below instead of a reuse attack. throw new Error("rotation_race_lost"); } if (row.sessionAgent) { await tx.session.updateMany({ where: { id: row.sessionAgent, userId: row.userId }, data: { expiresAt } }); } rotated = true; }); } catch (err) { if (rotated) throw err; // real failure after the revocation committed if (!(err instanceof Error && err.message === "rotation_race_lost")) throw err; } if (!rotated) { // Lost the rotation race to a concurrent refresh: the presented token // is dead, so clear its cookie — same middleware-existence-gate reason // as the stale branch above. this.clearRefreshCookie(ctx.res); await this.audit.audit(ctx.req, undefined, { action: "auth.refresh_stale_retry", resourceType: "user", resourceId: row.userId, metadata: { sessionId: row.sessionAgent ?? null, raceLost: true } }); throw ApiError.unauthorized("Refresh token already rotated.", [{ reason: "refresh_token_stale" }]); } // Persist the ROTATED token to the client (httpOnly cookie) and hand back // the new plaintext token on the JSON surface (for non-browser clients). // Without the cookie update, a cookie-driven client keeps the now-REVOKED // token; its next refresh trips reuse-detection → the whole session set is // killed (the D2 defect). `issueAccess` intentionally returns no cookie; // the rotation path must set it explicitly here. this.setRefreshCookie(ctx.res, newPlain); const tokens = await this.issueAccess(user, row.sessionAgent ?? undefined, ctx.res); await this.audit.audit(ctx.req, undefined, { action: "auth.refresh", resourceType: "user", resourceId: user.id, metadata: { sessionId: row.sessionAgent ?? null } }); return { ...tokens, refreshToken: newPlain }; } private async issueAccess(user: { id: string }, sessionId: string | undefined, res?: Response) { const env = loadEnv(); const memberships = await this.prisma.workspaceMember.findMany({ where: { userId: user.id }, select: { workspaceId: true } }); const accessToken = signToken( { sub: user.id, workspaces: memberships.map((m) => m.workspaceId), jti: uuid(), ...(sessionId ? { sid: sessionId } : {}) }, env.ACCESS_TOKEN_TTL, env.JWT_SECRET ); return { accessToken, expiresIn: Math.floor(ttlToMs(env.ACCESS_TOKEN_TTL) / 1000), session: sessionId ? { id: sessionId } : undefined }; } async logout(refreshPlain: string | undefined, sessionId: string | undefined, ctx: { req: Request; res: Response }, userId?: string) { const id = userId ?? ctx.req.auth?.userId; let revokedSession = false; if (sessionId && id) { const session = await this.prisma.session.findFirst({ where: { id: sessionId, userId: id } }); if (session) { await this.prisma.$transaction([ this.prisma.session.update({ where: { id: session.id }, data: { status: "REVOKED", revokedAt: new Date() } }), this.prisma.refreshToken.updateMany({ where: { userId: id, sessionAgent: session.id, revokedAt: null }, data: { revokedAt: new Date() } }) ]); revokedSession = true; } } if (refreshPlain && id) { await this.prisma.refreshToken.updateMany({ where: { userId: id, tokenHash: sha256hex(refreshPlain), revokedAt: null }, data: { revokedAt: new Date() } }); } this.clearRefreshCookie(ctx.res); if (id) { await this.audit.audit(ctx.req, ctx.req.auth, { action: EVENTS.USER_LOGOUT, resourceType: "user", resourceId: id, metadata: { sessionId: sessionId ?? null, revokedSession } }); await this.events.publish(EVENTS.USER_LOGOUT, { userId: id }); } } async me(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw ApiError.notFound(); const memberships = await this.prisma.workspaceMember.findMany({ where: { userId }, include: { workspace: { select: { id: true, name: true, slug: true, isPersonal: true } } } }); return { user: this.publicUser(user), workspaces: memberships.map((m) => ({ id: m.workspace.id, name: m.workspace.name, slug: m.workspace.slug, isPersonal: m.workspace.isPersonal, role: m.role })) }; } /** Always 200 (no user enumeration). */ async resetPassword(email: string): Promise { const user = await this.prisma.user.findUnique({ where: { email: email.toLowerCase() } }); if (user && user.status !== "SUSPENDED") { await this.email.sendPasswordResetCode(user.id, user.email); } } async resetPasswordConfirm(input: { email: string; code: string; newPassword: string }, ctx: { req: Request }) { const user = await this.prisma.user.findUnique({ where: { email: input.email.toLowerCase() } }); if (!user) throw ApiError.validation("Invalid reset code.", [{ field: "code", issue: "invalid_code" }]); const result = await this.email.consumePasswordResetCode(user.id, input.code); if (result.status === "NOT_FOUND") { throw ApiError.validation("Invalid reset code.", [{ field: "code", issue: "invalid_code" }]); } if (result.status === "EXPIRED") { throw new ApiError("GONE", 410, "Reset code expired.", [{ reason: "token_expired" }]); } const passwordHash = await bcrypt.hash(input.newPassword, BCRYPT_COST); await this.prisma.$transaction([ this.prisma.user.update({ where: { id: user.id }, data: { passwordHash } }), this.prisma.session.updateMany({ where: { userId: user.id, status: "ACTIVE" }, data: { status: "REVOKED", revokedAt: new Date() } }), this.prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } }) ]); await this.audit.audit(ctx.req, undefined, { action: "user.password_reset", resourceType: "user", resourceId: user.id }); return { reset: true as const }; } } /** Valid bcrypt hash used for constant-time comparison when the user is unknown. */ const DUMMY_HASH = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; function ttlToMs(ttl: string): number { const m = /^(\d+)(ms|s|m|h|d)$/.exec(ttl.trim()); if (!m) throw new Error(`invalid ttl: ${ttl}`); const n = Number(m[1]); switch (m[2]) { case "ms": return n; case "s": return n * 1000; case "m": return n * 60_000; case "h": return n * 3_600_000; case "d": return n * 86_400_000; } return 0; } function firstName(name: string): string { return name.trim().split(/\s+/)[0] ?? name; } function slugify(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40) || "workspace"; }