Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
231 lines
5.8 KiB
TypeScript
231 lines
5.8 KiB
TypeScript
import {
|
|
Injectable,
|
|
Logger,
|
|
ForbiddenException,
|
|
UnauthorizedException,
|
|
ConflictException,
|
|
InternalServerErrorException,
|
|
} from "@nestjs/common";
|
|
import { WorkspaceMemberRole } from "@prisma/client";
|
|
import { hash, compare } from "bcryptjs";
|
|
import { randomBytes, timingSafeEqual } from "crypto";
|
|
import { PrismaService } from "../../prisma/prisma.service";
|
|
|
|
const BCRYPT_ROUNDS = 12;
|
|
|
|
/** Session expiry: 7 days (matches BetterAuth config in auth.config.ts) */
|
|
const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
interface SetupResult {
|
|
user: {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
isLocalAuth: boolean;
|
|
createdAt: Date;
|
|
};
|
|
session: {
|
|
token: string;
|
|
expiresAt: Date;
|
|
};
|
|
}
|
|
|
|
interface LoginResult {
|
|
user: {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
};
|
|
session: {
|
|
token: string;
|
|
expiresAt: Date;
|
|
};
|
|
}
|
|
|
|
@Injectable()
|
|
export class LocalAuthService {
|
|
private readonly logger = new Logger(LocalAuthService.name);
|
|
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
/**
|
|
* First-time break-glass user creation.
|
|
* Validates the setup token, creates a local auth user with bcrypt-hashed password,
|
|
* and assigns OWNER role on the default workspace.
|
|
*/
|
|
async setup(
|
|
email: string,
|
|
name: string,
|
|
password: string,
|
|
setupToken: string,
|
|
ipAddress?: string,
|
|
userAgent?: string
|
|
): Promise<SetupResult> {
|
|
this.validateSetupToken(setupToken);
|
|
|
|
const existing = await this.prisma.user.findUnique({ where: { email } });
|
|
if (existing) {
|
|
throw new ConflictException("A user with this email already exists");
|
|
}
|
|
|
|
const passwordHash = await hash(password, BCRYPT_ROUNDS);
|
|
|
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
const user = await tx.user.create({
|
|
data: {
|
|
email,
|
|
name,
|
|
isLocalAuth: true,
|
|
passwordHash,
|
|
emailVerified: true,
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
isLocalAuth: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
|
|
// Find or create a default workspace and assign OWNER role
|
|
await this.assignDefaultWorkspace(tx, user.id);
|
|
|
|
// Create a BetterAuth-compatible session
|
|
const session = await this.createSession(tx, user.id, ipAddress, userAgent);
|
|
|
|
return { user, session };
|
|
});
|
|
|
|
this.logger.log(`Break-glass user created: ${email}`);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Break-glass login: verify email + password against bcrypt hash.
|
|
* Only works for users with isLocalAuth=true.
|
|
*/
|
|
async login(
|
|
email: string,
|
|
password: string,
|
|
ipAddress?: string,
|
|
userAgent?: string
|
|
): Promise<LoginResult> {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { email },
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
isLocalAuth: true,
|
|
passwordHash: true,
|
|
deactivatedAt: true,
|
|
},
|
|
});
|
|
|
|
if (!user?.isLocalAuth) {
|
|
throw new UnauthorizedException("Invalid email or password");
|
|
}
|
|
|
|
if (user.deactivatedAt) {
|
|
throw new UnauthorizedException("Account has been deactivated");
|
|
}
|
|
|
|
if (!user.passwordHash) {
|
|
this.logger.error(`Local auth user ${email} has no password hash`);
|
|
throw new InternalServerErrorException("Account configuration error");
|
|
}
|
|
|
|
const passwordValid = await compare(password, user.passwordHash);
|
|
if (!passwordValid) {
|
|
throw new UnauthorizedException("Invalid email or password");
|
|
}
|
|
|
|
const session = await this.createSession(this.prisma, user.id, ipAddress, userAgent);
|
|
|
|
this.logger.log(`Break-glass login: ${email}`);
|
|
return {
|
|
user: { id: user.id, email: user.email, name: user.name },
|
|
session,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate the setup token against the environment variable.
|
|
*/
|
|
private validateSetupToken(token: string): void {
|
|
const expectedToken = process.env.BREAKGLASS_SETUP_TOKEN;
|
|
|
|
if (!expectedToken || expectedToken.trim() === "") {
|
|
throw new ForbiddenException(
|
|
"Break-glass setup is not configured. Set BREAKGLASS_SETUP_TOKEN environment variable."
|
|
);
|
|
}
|
|
|
|
const tokenBuffer = Buffer.from(token);
|
|
const expectedBuffer = Buffer.from(expectedToken);
|
|
if (
|
|
tokenBuffer.length !== expectedBuffer.length ||
|
|
!timingSafeEqual(tokenBuffer, expectedBuffer)
|
|
) {
|
|
this.logger.warn("Invalid break-glass setup token attempt");
|
|
throw new ForbiddenException("Invalid setup token");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the first workspace or create a default one, then assign OWNER role.
|
|
*/
|
|
private async assignDefaultWorkspace(
|
|
tx: Parameters<Parameters<PrismaService["$transaction"]>[0]>[0],
|
|
userId: string
|
|
): Promise<void> {
|
|
let workspace = await tx.workspace.findFirst({
|
|
orderBy: { createdAt: "asc" },
|
|
select: { id: true },
|
|
});
|
|
|
|
workspace ??= await tx.workspace.create({
|
|
data: {
|
|
name: "Default Workspace",
|
|
ownerId: userId,
|
|
settings: {},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
|
|
await tx.workspaceMember.create({
|
|
data: {
|
|
workspaceId: workspace.id,
|
|
userId,
|
|
role: WorkspaceMemberRole.OWNER,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a BetterAuth-compatible session record.
|
|
*/
|
|
private async createSession(
|
|
tx: { session: { create: typeof PrismaService.prototype.session.create } },
|
|
userId: string,
|
|
ipAddress?: string,
|
|
userAgent?: string
|
|
): Promise<{ token: string; expiresAt: Date }> {
|
|
const token = randomBytes(32).toString("hex");
|
|
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
|
|
|
await tx.session.create({
|
|
data: {
|
|
userId,
|
|
token,
|
|
expiresAt,
|
|
ipAddress: ipAddress ?? null,
|
|
userAgent: userAgent ?? null,
|
|
},
|
|
});
|
|
|
|
return { token, expiresAt };
|
|
}
|
|
}
|