Files
stack/apps/api/src/auth/local/local-auth.service.ts
Jason Woltje ac16d6ed88
Some checks failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/api Pipeline failed
ci/woodpecker/push/web Pipeline failed
feat(api): add break-glass local authentication module (#559)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 18:05:19 +00:00

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 };
}
}