refactor(auth): auth, passkey, profile, token and admin services refuse with DomainError instead of returning error envelopes

This commit is contained in:
Maurice
2026-10-10 15:12:44 +02:00
parent 66042ae1c7
commit e81efcbc57
24 changed files with 460 additions and 516 deletions
+10 -25
View File
@@ -29,15 +29,6 @@ import { NotificationsService } from '../notifications/notifications.service';
import type { User } from '../../types';
import { ManagedForbidden } from '../common/managed';
/** Throw the legacy {error,status} envelope when a service call reports failure. */
function ok<T>(result: T): Exclude<T, { error: string }> {
if (result && typeof result === 'object' && 'error' in (result as Record<string, unknown>)) {
const r = result as unknown as { error: string; status?: number };
throw new HttpException({ error: r.error }, r.status ?? 400);
}
return result as Exclude<T, { error: string }>;
}
/**
* /api/admin — admin-only control surface (users, stats, permissions, audit log,
* OIDC settings, invites, feature toggles, packing templates, addons, MCP/OAuth
@@ -76,14 +67,14 @@ export class AdminController {
@Post('users')
@HttpCode(201)
async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
const result = ok(await this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
const result = await this.admin.createUser(body as Parameters<AdminService['createUser']>[0]);
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
return { user: result.user };
}
@Put('users/:id')
async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
const result = ok(await this.admin.updateUser(id, body));
const result = await this.admin.updateUser(id, body);
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
return { user: result.user };
@@ -91,7 +82,7 @@ export class AdminController {
@Delete('users/:id')
async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(await this.admin.deleteUser(id, user.id));
const result = await this.admin.deleteUser(id, user.id);
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
logInfo(`Admin ${user.email} deleted user ${result.email}`);
return { success: true };
@@ -99,14 +90,14 @@ export class AdminController {
@Delete('users/:id/passkeys')
async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(await this.admin.resetUserPasskeys(id));
const result = await this.admin.resetUserPasskeys(id);
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
return { success: true, deleted: result.deleted };
}
@Delete('users/:id/mfa')
async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(await this.admin.resetUserMfa(id, user.id));
const result = await this.admin.resetUserMfa(id, user.id);
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
return { success: true };
}
@@ -132,9 +123,6 @@ export class AdminController {
@HttpCode(200)
async saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
const result = await this.admin.saveDemoBaseline();
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
return { success: true, message: result.message };
}
@@ -175,7 +163,7 @@ export class AdminController {
@Delete('invites/:id')
async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(await this.invites.deleteInvite(id));
await this.invites.deleteInvite(id);
await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@@ -290,7 +278,7 @@ export class AdminController {
@Put('addons/:id')
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) {
const result = await ok(await this.admin.updateAddon(id, body));
const result = await this.admin.updateAddon(id, body);
await this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
// Sessions only need re-creating when the registered MCP surface can
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
@@ -316,7 +304,7 @@ export class AdminController {
@Delete('mcp-tokens/:id')
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(await this.tokens.adminDeleteMcpToken(id));
await this.tokens.adminDeleteMcpToken(id);
await this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@@ -326,7 +314,7 @@ export class AdminController {
@Delete('oauth-sessions/:id')
async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(await this.oauth.adminRevokeOAuthSession(id));
await this.oauth.adminRevokeOAuthSession(id);
await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
@@ -336,10 +324,7 @@ export class AdminController {
@Post('rotate-jwt-secret')
@HttpCode(200)
async rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
const result = this.admin.rotateJwtSecret();
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.admin.rotateJwtSecret();
await this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
return { success: true };
}
+30 -30
View File
@@ -1,3 +1,4 @@
import { DomainError } from '../common/domain-error';
import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import bcrypt from 'bcryptjs';
@@ -167,22 +168,22 @@ export class AdminService {
const password = data.password?.trim();
if (!username || !email || !password) {
return { error: 'Username, email and password are required', status: 400 };
throw new DomainError(400, 'Username, email and password are required');
}
const pwCheck = validatePassword(password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
if (data.role && !['user', 'admin'].includes(data.role)) {
return { error: 'Invalid role', status: 400 };
throw new DomainError(400, 'Invalid role');
}
// Guests (#1362) live in a reserved synthetic namespace; never let one block a real account.
const existingUsername = await this.users.findIdByUsernameExact(username);
if (existingUsername) return { error: 'Username already taken', status: 409 };
if (existingUsername) throw new DomainError(409, 'Username already taken');
const existingEmail = await this.users.findIdByEmailCI(email);
if (existingEmail) return { error: 'Email already taken', status: 409 };
if (existingEmail) throw new DomainError(409, 'Email already taken');
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
@@ -193,7 +194,7 @@ export class AdminService {
});
} catch (err) {
// Another write took the name or address between the check and the insert.
if (err instanceof UserIdentityTakenError) return { error: identityTakenMessage(err), status: 409 };
if (err instanceof UserIdentityTakenError) throw new DomainError(409, identityTakenMessage(err));
throw err;
}
@@ -213,29 +214,29 @@ export class AdminService {
const { role, password } = data;
const user = await this.users.findById(userId);
if (!user) return { error: 'User not found', status: 404 };
if (!user) throw new DomainError(404, 'User not found');
if (role && !['user', 'admin'].includes(role)) {
return { error: 'Invalid role', status: 400 };
throw new DomainError(400, 'Invalid role');
}
// An empty string used to fall through `username || null` into COALESCE and
// silently mean "leave unchanged". Say so instead of pretending it worked.
if (username === '') return { error: 'Username cannot be empty', status: 400 };
if (email === '') return { error: 'Email cannot be empty', status: 400 };
if (username === '') throw new DomainError(400, 'Username cannot be empty');
if (email === '') throw new DomainError(400, 'Email cannot be empty');
if (username && username !== user.username) {
const conflict = await this.users.findIdByUsernameExactExcluding(username, userId);
if (conflict) return { error: 'Username already taken', status: 409 };
if (conflict) throw new DomainError(409, 'Username already taken');
}
if (email && email !== user.email) {
const conflict = await this.users.findIdByEmailCI(email, userId);
if (conflict) return { error: 'Email already taken', status: 409 };
if (conflict) throw new DomainError(409, 'Email already taken');
}
if (password) {
const pwCheck = validatePassword(password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
}
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
@@ -248,7 +249,7 @@ export class AdminService {
const currentRole = await this.users.getRole(userId);
if (currentRole === 'admin') {
const adminCount = await this.users.countAdmins();
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
if (adminCount <= 1) throw new DomainError(400, 'Cannot remove the last admin');
}
}
@@ -291,7 +292,7 @@ export class AdminService {
}
});
} catch (err) {
if (err instanceof UserIdentityTakenError) return { error: identityTakenMessage(err), status: 409 };
if (err instanceof UserIdentityTakenError) throw new DomainError(409, identityTakenMessage(err));
throw err;
}
@@ -318,11 +319,11 @@ export class AdminService {
// SECURITY: plain JS comparison, deliberately kept exactly here — never
// folded into a repository method's WHERE clause (R4).
if (Number.parseInt(id) === currentUserId) {
return { error: 'Cannot delete own account', status: 400 };
throw new DomainError(400, 'Cannot delete own account');
}
const userToDel = await this.users.findIdAndEmail(Number(id));
if (!userToDel) return { error: 'User not found', status: 404 };
if (!userToDel) throw new DomainError(404, 'User not found');
await this.userCleanup.deleteUserCompletely(userToDel.id);
await emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
@@ -344,15 +345,15 @@ export class AdminService {
* making that reachable from here would turn a stolen admin session into a
* way to strip the second factor off the very account it came from.
*/
async resetUserMfa(id: string, actingUserId: number): Promise<{ error?: string; status?: number; success?: boolean; email?: string }> {
async resetUserMfa(id: string, actingUserId: number): Promise<{ success?: boolean; email?: string }> {
const targetId = Number(id);
// SECURITY: plain JS comparison, deliberately kept exactly here — never
// folded into a repository method's WHERE clause (R4).
if (targetId === actingUserId) {
return { error: 'Use Settings to change your own two-factor setup', status: 400 };
throw new DomainError(400, 'Use Settings to change your own two-factor setup');
}
const target = await this.users.findIdEmailMfaEnabled(targetId);
if (!target) return { error: 'User not found', status: 404 };
if (!target) throw new DomainError(404, 'User not found');
// Same three columns disableMfa clears, so an admin reset and a self-service
// disable leave the account in exactly one state rather than two; the
@@ -426,9 +427,9 @@ export class AdminService {
// ── Demo Baseline ──────────────────────────────────────────────────────────
async saveDemoBaseline(): Promise<{ error?: string; status?: number; message?: string }> {
async saveDemoBaseline(): Promise<{ message?: string }> {
if (!readEnv().demo.enabled) {
return { error: 'Not found', status: 404 };
throw new DomainError(404, 'Not found');
}
try {
// Lazy require: demo-reset is a demo-only module.
@@ -437,7 +438,7 @@ export class AdminService {
return { message: 'Demo baseline saved. Hourly resets will restore to this state.' };
} catch (err: unknown) {
console.error(err);
return { error: 'Failed to save baseline', status: 500 };
throw new DomainError(500, 'Failed to save baseline');
}
}
@@ -676,28 +677,28 @@ export class AdminService {
const addon = await this.addonsRepo.findById(id);
const provider = await this.photoProviders.findById(id);
const docProvider = await this.documentProviders.findById(id);
if (!addon && !provider && !docProvider) return { error: 'Addon not found', status: 404 };
if (!addon && !provider && !docProvider) throw new DomainError(404, 'Addon not found');
// The whole addon, not just its config: on a centrally administered install
// the operator owns the endpoint, the model and the per-document cost, so
// there is nothing here for an instance admin to set — including whether it
// runs at all. listAddons hides the row; this closes the route behind it.
if (readEnv().managed.enabled && id === ADDON_IDS.LLM_PARSING) {
return { error: MANAGED_FORBIDDEN_ERROR.error, status: 403 };
throw new DomainError(403, MANAGED_FORBIDDEN_ERROR.error);
}
// Photo providers are Journey's shelf rows — their whole UI lives inside
// journeys, so enabling one under a disabled journey addon would only
// advertise an integration nothing can reach.
if (provider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) {
return { error: 'Enable the Journey addon first', status: 409 };
throw new DomainError(409, 'Enable the Journey addon first');
}
// Same rule one shelf down: a document provider only exists to serve the
// file manager, so switching one on under a disabled Documents addon would
// advertise a sync nothing can reach.
if (docProvider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) {
return { error: 'Enable the Documents addon first', status: 409 };
throw new DomainError(409, 'Enable the Documents addon first');
}
await this.uow.transactional(async () => {
@@ -784,17 +785,16 @@ export class AdminService {
// ── JWT Rotation ───────────────────────────────────────────────────────────
rotateJwtSecret(): { error?: string; status?: number } {
rotateJwtSecret(): void {
const newSecret = crypto.randomBytes(32).toString('hex');
const { dataDir, jwtSecretFile: secretFile } = this.dataPaths;
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(secretFile, newSecret, { mode: 0o600 });
} catch {
return { error: 'Failed to persist new JWT secret to disk', status: 500 };
throw new DomainError(500, 'Failed to persist new JWT secret to disk');
}
updateJwtSecret(newSecret);
return {};
}
invalidateMcpSessions() { invalidateMcpSessions(); }
+5 -16
View File
@@ -12,6 +12,7 @@ import { Public } from '../auth-core/public.decorator';
import { MfaExempt } from '../auth-core/mfa-policy.guard';
import { extractToken, verifiedSessionClaims } from '../auth-core/jwt-verify';
import { SessionsService, sessionClientFrom } from '../sessions/sessions.service';
import { catchDomainError, DomainError } from '../common/domain-error';
const WINDOW = 15 * 60 * 1000;
const LOGIN_MIN_LATENCY_MS = 350;
@@ -60,9 +61,6 @@ export class AuthPublicController {
@HttpCode(200)
async demoLogin(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const result = await this.auth.demoLogin(sessionClientFrom(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.auth.setAuthCookie(res, result.token!, req);
return { token: result.token, user: result.user };
}
@@ -72,9 +70,6 @@ export class AuthPublicController {
async invite(@Param('token') token: string, @Req() req: Request) {
await this.limit('login', req, 10);
const result = await this.auth.validateInviteToken(token);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at };
}
@@ -84,9 +79,6 @@ export class AuthPublicController {
async register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
await this.limit('login', req, 10);
const result = await this.auth.registerUser(body, sessionClientFrom(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
this.auth.setAuthCookie(res, result.token!, req);
return { token: result.token, user: result.user };
@@ -156,10 +148,10 @@ export class AuthPublicController {
// a dedicated bucket) — without it reset tokens could be guessed unthrottled.
await this.limit('reset', req, 5);
const ip = getClientIp(req);
const result = await this.auth.resetPassword(body);
if (result.error) {
await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
throw new HttpException({ error: result.error }, result.status!);
const result = await catchDomainError(() => this.auth.resetPassword(body));
if (result instanceof DomainError) {
await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.publicMessage } });
throw result;
}
if (result.mfa_required) {
return { mfa_required: true };
@@ -174,9 +166,6 @@ export class AuthPublicController {
async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
await this.limit('mfa', req, 5);
const result = await this.auth.verifyMfaLogin(body, sessionClientFrom(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
this.auth.setAuthCookie(res, result.token!, req, result.remember);
return { token: result.token, user: result.user };
+3 -48
View File
@@ -119,9 +119,6 @@ export class AuthController {
const remember = decodeSessionClaims(cookie)?.remember;
const cookieSession = typeof cookie === 'string' && cookie.length > 0;
const result = await this.auth.changePassword(user.id, user.email, body, remember, sessionClientFrom(req), cookieSession);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
// Refresh this device's cookie with the new password_version so the user
// stays logged in here while all other sessions are invalidated.
if (result.token) this.auth.setAuthCookie(res, result.token, req, remember);
@@ -131,10 +128,7 @@ export class AuthController {
@Delete('me')
async deleteAccount(@CurrentUser() user: User, @Req() req: Request) {
const result = await this.auth.deleteAccount(user.id, user.email, user.role);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.auth.deleteAccount(user.id, user.email, user.role);
await this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
return { success: true };
}
@@ -180,9 +174,6 @@ export class AuthController {
@Put('me/settings')
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
const result = await this.profile.updateSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
return { success: result.success, user: result.user };
}
@@ -190,9 +181,6 @@ export class AuthController {
@Get('me/settings')
async getSettings(@CurrentUser() user: User) {
const result = await this.profile.getSettings(user.id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { settings: result.settings };
}
@@ -226,9 +214,6 @@ export class AuthController {
@Get('validate-keys')
async validateKeys(@CurrentUser() user: User) {
const result = await this.profile.validateKeys(user.id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { maps: result.maps, weather: result.weather, maps_details: result.maps_details };
}
@@ -236,9 +221,6 @@ export class AuthController {
@MfaExempt('the setup screen reads the policy it is asking the user to satisfy')
async getAppSettings(@CurrentUser() user: User) {
const result = await this.auth.getAppSettings(user.id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return result.data;
}
@@ -246,9 +228,6 @@ export class AuthController {
@MfaExempt('an admin locked out by their own policy must still be able to lift it')
async updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) {
const result = await this.auth.updateAppSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
// Named so the settings tab can say which fields the operator holds rather
// than showing a saved value that silently did not save.
@@ -264,9 +243,6 @@ export class AuthController {
@HttpCode(200)
async mfaSetup(@CurrentUser() user: User) {
const result = await this.auth.setupMfa(user.id, user.email);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
try {
const qr_svg = await result.qrPromise!;
return { secret: result.secret, otpauth_url: result.otpauth_url, qr_svg };
@@ -282,9 +258,6 @@ export class AuthController {
async mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) {
await this.limit('mfa', req, 5);
const result = await this.auth.enableMfa(user.id, body.code);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes };
}
@@ -294,9 +267,6 @@ export class AuthController {
async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
await this.limit('login', req, 5);
const result = await this.auth.disableMfa(user.id, user.email, body, currentSessionId(req));
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
return { success: true, mfa_enabled: result.mfa_enabled };
}
@@ -312,18 +282,12 @@ export class AuthController {
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
await this.limit('login', req, 5);
const result = await this.tokens.createMcpToken(user.id, body.name);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { token: result.token };
}
@Delete('mcp-tokens/:id')
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
const result = await this.tokens.deleteMcpToken(user.id, id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.tokens.deleteMcpToken(user.id, id);
return { success: true };
}
@@ -352,18 +316,12 @@ export class AuthController {
// before this field existed does. Narrowing stays opt-in so the change
// cannot break an integration that is already running.
const result = await this.tokens.createApiToken(user.id, body.name, body.scopes);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { token: result.token };
}
@Delete('api-tokens/:id')
async deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) {
const result = await this.tokens.deleteApiToken(user.id, id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
await this.tokens.deleteApiToken(user.id, id);
return { success: true };
}
@@ -377,9 +335,6 @@ export class AuthController {
// 503-ing every other user's ws and download tokens.
await this.limitUser('ws_token', user.id, 120);
const result = await this.tokens.createWsToken(user.id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
return { token: result.token };
}
+66 -71
View File
@@ -1,3 +1,4 @@
import { DomainError } from '../common/domain-error';
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
@@ -129,7 +130,6 @@ setInterval(() => {
}, 5 * 60 * 1000).unref?.();
export interface ResetPasswordOutcome {
error?: string;
status?: number;
success?: boolean;
/** When true the client must collect a TOTP/backup code and call again. */
@@ -410,28 +410,28 @@ export class AuthService {
// Auth: register, login, demo
// -------------------------------------------------------------------------
async demoLogin(client?: SessionClient): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown> }> {
async demoLogin(client?: SessionClient): Promise<{ token?: string; user?: Record<string, unknown> }> {
if (!readEnv().demo.enabled) {
return { error: 'Not found', status: 404 };
throw new DomainError(404, 'Not found');
}
const user = await this.usersRepo.findByEmailExact(DEMO_EMAIL_PRIMARY);
if (!user) return { error: 'Demo user not found', status: 500 };
if (!user) throw new DomainError(500, 'Demo user not found');
const token = await this.generateToken(user, undefined, client);
const safe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
return { token, user: { ...safe, avatar_url: avatarUrl(user) } };
}
async validateInviteToken(token: string): Promise<{ error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string | null }> {
async validateInviteToken(token: string): Promise<{ valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string | null }> {
const invite = await this.inviteTokens.findByToken(token);
if (!invite) return { error: 'Invalid invite link', status: 404 };
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
if (!invite) throw new DomainError(404, 'Invalid invite link');
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) throw new DomainError(410, 'Invite link has been fully used');
if (invite.expires_at && new Date(invite.expires_at) < new Date()) throw new DomainError(410, 'Invite link has expired');
// A nullable column stays null on the wire: `?? undefined` would make
// JSON.stringify drop the key for a never-expiring invite.
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at };
}
async registerUser(rawBody: unknown, client?: SessionClient): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
async registerUser(rawBody: unknown, client?: SessionClient): Promise<{ token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
const body = rawBody as { username?: string; email?: string; password?: string; invite_token?: string };
const username = typeof body.username === 'string' ? body.username.trim() : '';
const email = typeof body.email === 'string' ? body.email.trim() : '';
@@ -442,27 +442,27 @@ export class AuthService {
let validInvite: Awaited<ReturnType<InviteTokensRepository['findByToken']>> = null;
if (invite_token) {
validInvite = await this.inviteTokens.findByToken(invite_token);
if (!validInvite) return { error: 'Invalid invite link', status: 400 };
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
if (!validInvite) throw new DomainError(400, 'Invalid invite link');
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) throw new DomainError(410, 'Invite link has been fully used');
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) throw new DomainError(410, 'Invite link has expired');
}
if (userCount > 0 && !validInvite) {
const toggles = await this.resolveAuthToggles();
if (!toggles.password_registration) {
return { error: 'Password registration is disabled. Contact your administrator.', status: 403 };
throw new DomainError(403, 'Password registration is disabled. Contact your administrator.');
}
}
if (!username || !email || !password) {
return { error: 'Username, email and password are required', status: 400 };
throw new DomainError(400, 'Username, email and password are required');
}
const pwCheck = validatePassword(password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
if (!EMAIL_REGEX.test(email)) {
return { error: 'Invalid email format', status: 400 };
throw new DomainError(400, 'Invalid email format');
}
const password_hash = bcrypt.hashSync(password, BCRYPT_COST);
@@ -476,7 +476,7 @@ export class AuthService {
// Ignore guests (#1362): their synthetic username/email must never block a real signup.
const existingUserId = await this.usersRepo.findIdByEmailOrUsernameCI(email, username);
if (existingUserId !== null) {
return { error: 'Registration failed. Please try different credentials.', status: 409 };
throw new DomainError(409, 'Registration failed. Please try different credentials.');
}
const isFirstUser = (await this.usersRepo.countNonGuest()) === 0;
const role = isFirstUser ? 'admin' : 'user';
@@ -509,10 +509,11 @@ export class AuthService {
} catch (err) {
// The check above runs in the transaction, but a concurrent signup can
// still win the insert; the database's unique indexes refuse the second.
if (err instanceof DomainError) throw err;
if (err instanceof UserIdentityTakenError) {
return { error: 'Registration failed. Please try different credentials.', status: 409 };
throw new DomainError(409, 'Registration failed. Please try different credentials.');
}
return { error: 'Error creating user', status: 500 };
throw new DomainError(500, 'Error creating user');
}
}
@@ -623,25 +624,25 @@ export class AuthService {
remember?: boolean,
client?: SessionClient,
issueSession = true,
): Promise<{ error?: string; status?: number; success?: boolean; token?: string }> {
): Promise<{ success?: boolean; token?: string }> {
const body = rawBody as { current_password?: string; new_password?: string };
if (await this.isOidcOnlyMode()) {
return { error: 'Password authentication is disabled.', status: 403 };
throw new DomainError(403, 'Password authentication is disabled.');
}
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'Password change is disabled in demo mode.', status: 403 };
throw new DomainError(403, 'Password change is disabled in demo mode.');
}
const { current_password, new_password } = body;
if (!current_password) return { error: 'Current password is required', status: 400 };
if (!new_password) return { error: 'New password is required', status: 400 };
if (!current_password) throw new DomainError(400, 'Current password is required');
if (!new_password) throw new DomainError(400, 'New password is required');
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
const user = await this.usersRepo.getPasswordHashAndVersion(userId);
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
return { error: 'Current password is incorrect', status: 401 };
throw new DomainError(401, 'Current password is incorrect');
}
const hash = bcrypt.hashSync(new_password, BCRYPT_COST);
@@ -671,14 +672,14 @@ export class AuthService {
return { success: true, token };
}
async deleteAccount(userId: number, userEmail: string, userRole: string): Promise<{ error?: string; status?: number; success?: boolean }> {
async deleteAccount(userId: number, userEmail: string, userRole: string): Promise<{ success?: boolean }> {
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
throw new DomainError(403, 'Account deletion is disabled in demo mode.');
}
if (userRole === 'admin') {
const adminCount = await this.usersRepo.countAdmins();
if (adminCount <= 1) {
return { error: 'Cannot delete the last admin account', status: 400 };
throw new DomainError(400, 'Cannot delete the last admin account');
}
}
await this.userCleanup.deleteUserCompletely(userId);
@@ -700,9 +701,9 @@ export class AuthService {
// work, not with a move.
// -------------------------------------------------------------------------
async getAppSettings(userId: number): Promise<{ error?: string; status?: number; data?: Record<string, string> }> {
async getAppSettings(userId: number): Promise<{ data?: Record<string, string> }> {
const role = await this.usersRepo.getRole(userId);
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
if (role !== 'admin') throw new DomainError(403, 'Admin access required');
const result: Record<string, string> = {};
for (const key of ADMIN_FORM_SETTING_KEYS) {
@@ -716,8 +717,6 @@ export class AuthService {
userId: number,
rawBody: unknown
): Promise<{
error?: string;
status?: number;
success?: boolean;
auditSummary?: Record<string, unknown>;
auditDebugDetails?: Record<string, unknown>;
@@ -726,7 +725,7 @@ export class AuthService {
}> {
const body = rawBody as Record<string, unknown>;
const role = await this.usersRepo.getRole(userId);
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
if (role !== 'admin') throw new DomainError(403, 'Admin access required');
const { require_mfa } = body;
if (require_mfa === true || require_mfa === 'true') {
@@ -735,10 +734,7 @@ export class AuthService {
// their own account with a passkey may enable it too (not only TOTP).
const adminHasPasskey = await this.webauthnCredentials.hasAny(userId);
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
return {
error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.',
status: 400,
};
throw new DomainError(400, 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.');
}
}
@@ -749,7 +745,7 @@ export class AuthService {
const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login;
const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login;
if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) {
return { error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 };
throw new DomainError(400, 'Cannot disable all login methods. At least one must remain enabled.');
}
}
@@ -807,13 +803,13 @@ export class AuthService {
// MFA
// -------------------------------------------------------------------------
async setupMfa(userId: number, userEmail: string): Promise<{ error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise<string> }> {
async setupMfa(userId: number, userEmail: string): Promise<{ secret?: string; otpauth_url?: string; qrPromise?: Promise<string> }> {
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'MFA is not available in demo mode.', status: 403 };
throw new DomainError(403, 'MFA is not available in demo mode.');
}
const row = await this.usersRepo.getMfaEnabled(userId);
if (row?.mfa_enabled) {
return { error: 'MFA is already enabled', status: 400 };
throw new DomainError(400, 'MFA is already enabled');
}
let secret: string, otpauth_url: string;
try {
@@ -822,24 +818,24 @@ export class AuthService {
otpauth_url = authenticator.keyuri(userEmail, 'TREK', secret);
} catch (err) {
console.error('[MFA] Setup error:', err);
return { error: 'MFA setup failed', status: 500 };
throw new DomainError(500, 'MFA setup failed');
}
return { secret, otpauth_url, qrPromise: QRCode.toString(otpauth_url, { type: 'svg', width: 250 }) };
}
async enableMfa(userId: number, rawCode: unknown): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] }> {
async enableMfa(userId: number, rawCode: unknown): Promise<{ success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] }> {
const code = rawCode as string | undefined;
if (!code) {
return { error: 'Verification code is required', status: 400 };
throw new DomainError(400, 'Verification code is required');
}
const pending = this.getPendingMfaSecret(userId);
if (!pending) {
return { error: 'No MFA setup in progress. Start the setup again.', status: 400 };
throw new DomainError(400, 'No MFA setup in progress. Start the setup again.');
}
const tokenStr = String(code).replace(/\s/g, '');
const ok = authenticator.verify({ token: tokenStr, secret: pending });
if (!ok) {
return { error: 'Invalid verification code', status: 401 };
throw new DomainError(401, 'Invalid verification code');
}
const backupCodes = generateBackupCodes();
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
@@ -854,31 +850,31 @@ export class AuthService {
userEmail: string,
rawBody: unknown,
currentSessionId?: string,
): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean }> {
): Promise<{ success?: boolean; mfa_enabled?: boolean }> {
const body = rawBody as { password?: string; code?: string };
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
throw new DomainError(403, 'MFA cannot be changed in demo mode.');
}
const policy = await readAppSetting(this.appSettings, 'require_mfa');
if (policy === 'true') {
return { error: 'Two-factor authentication cannot be disabled while it is required for all users.', status: 403 };
throw new DomainError(403, 'Two-factor authentication cannot be disabled while it is required for all users.');
}
const { password, code } = body;
if (!password || !code) {
return { error: 'Password and authenticator code are required', status: 400 };
throw new DomainError(400, 'Password and authenticator code are required');
}
const user = await this.usersRepo.findById(userId);
if (!user?.mfa_enabled || !user.mfa_secret) {
return { error: 'MFA is not enabled', status: 400 };
throw new DomainError(400, 'MFA is not enabled');
}
if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
return { error: 'Incorrect password', status: 401 };
throw new DomainError(401, 'Incorrect password');
}
const secret = decryptMfaSecret(user.mfa_secret);
const tokenStr = String(code).replace(/\s/g, '');
const ok = authenticator.verify({ token: tokenStr, secret });
if (!ok) {
return { error: 'Invalid verification code', status: 401 };
throw new DomainError(401, 'Invalid verification code');
}
// Every other session ends with the second factor; this one just proved both.
await this.uow.transactional(async () => {
@@ -890,8 +886,6 @@ export class AuthService {
}
async verifyMfaLogin(rawBody: unknown, client?: SessionClient): Promise<{
error?: string;
status?: number;
token?: string;
user?: Record<string, unknown>;
remember?: boolean;
@@ -901,16 +895,16 @@ export class AuthService {
const { mfa_token, code, remember_me } = body;
const remember = remember_me === true;
if (!mfa_token || !code) {
return { error: 'Verification token and code are required', status: 400 };
throw new DomainError(400, 'Verification token and code are required');
}
try {
const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string };
if (decoded.purpose !== 'mfa_login') {
return { error: 'Invalid verification token', status: 401 };
throw new DomainError(401, 'Invalid verification token');
}
const user = await this.usersRepo.findById(decoded.id);
if (!user || user.mfa_enabled !== 1 || !user.mfa_secret) {
return { error: 'Invalid session', status: 401 };
throw new DomainError(401, 'Invalid session');
}
const secret = decryptMfaSecret(user.mfa_secret);
const tokenStr = String(code).trim();
@@ -921,7 +915,7 @@ export class AuthService {
// any store older than the bcrypt migration keeps working.
const idx = hashes.findIndex((h) => matchBackupCode(tokenStr, h));
if (idx === -1) {
return { error: 'Invalid verification code', status: 401 };
throw new DomainError(401, 'Invalid verification code');
}
hashes.splice(idx, 1);
// Consume the backup code and record the login atomically — the code
@@ -941,8 +935,9 @@ export class AuthService {
remember,
auditUserId: Number(user.id),
};
} catch {
return { error: 'Invalid or expired verification token', status: 401 };
} catch (err) {
if (err instanceof DomainError) throw err;
throw new DomainError(401, 'Invalid or expired verification token');
}
}
@@ -1021,28 +1016,28 @@ export class AuthService {
const body = rawBody as { token?: string; new_password?: string; mfa_code?: string };
const { token, new_password, mfa_code } = body;
if (!token || typeof token !== 'string') {
return { error: 'Reset token is required', status: 400 };
throw new DomainError(400, 'Reset token is required');
}
if (!new_password || typeof new_password !== 'string') {
return { error: 'New password is required', status: 400 };
throw new DomainError(400, 'New password is required');
}
// Check the policy BEFORE touching the token so an invalid password
// does not burn the user's one-time link.
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return { error: pwCheck.reason!, status: 400 };
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason!);
const tokenHash = hashResetToken(token);
const row = await this.passwordResetTokens.findByTokenHash(tokenHash);
if (!row) return { error: 'Invalid or expired reset link', status: 400 };
if (row.consumed_at) return { error: 'This reset link has already been used', status: 400 };
if (!row) throw new DomainError(400, 'Invalid or expired reset link');
if (row.consumed_at) throw new DomainError(400, 'This reset link has already been used');
if ((parseDbTimestamp(row.expires_at)?.getTime() ?? Number.NaN) < Date.now()) {
return { error: 'Reset link has expired. Please request a new one.', status: 400 };
throw new DomainError(400, 'Reset link has expired. Please request a new one.');
}
const user = await this.usersRepo.findResetTarget(row.user_id);
if (!user) return { error: 'Invalid or expired reset link', status: 400 };
if (!user) throw new DomainError(400, 'Invalid or expired reset link');
// MFA gate. If enabled, require a valid TOTP or backup code.
const mfaOn = user.mfa_enabled === 1;
@@ -1050,7 +1045,7 @@ export class AuthService {
if (mfaOn) {
if (!user.mfa_secret) {
// Data inconsistency — fail closed.
return { error: 'MFA is enabled but not configured. Contact your administrator.', status: 500 };
throw new DomainError(500, 'MFA is enabled but not configured. Contact your administrator.');
}
const supplied = typeof mfa_code === 'string' ? mfa_code.trim() : '';
if (!supplied) return { mfa_required: true, status: 200 };
@@ -1060,7 +1055,7 @@ export class AuthService {
if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
if (idx === -1) return { error: 'Invalid MFA code', status: 401 };
if (idx === -1) throw new DomainError(401, 'Invalid MFA code');
backupCodeConsumedIndex = idx;
}
}
+2 -7
View File
@@ -53,7 +53,6 @@ export class PasskeyController {
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
// — the RP ID itself is never derived from request headers.
const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password, req.headers.origin);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
return result.options;
}
@@ -63,7 +62,6 @@ export class PasskeyController {
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
return { success: true, credential: result.credential };
}
@@ -76,7 +74,6 @@ export class PasskeyController {
async loginOptions(@Req() req: Request) {
await this.limit('login', req, 10);
const result = await this.passkeys.passkeyLoginOptions(req.headers.origin);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
return result.options;
}
@@ -112,8 +109,7 @@ export class PasskeyController {
@Patch('credentials/:id')
@UseGuards(JwtAuthGuard)
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
const result = await this.passkeys.renamePasskey(user.id, id, body?.name);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
await this.passkeys.renamePasskey(user.id, id, body?.name);
return { success: true };
}
@@ -121,8 +117,7 @@ export class PasskeyController {
@UseGuards(JwtAuthGuard)
async remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) {
await this.limit('login', req, 5);
const result = await this.passkeys.deletePasskey(user.id, id, body?.password);
if (result.error) throw new HttpException({ error: result.error }, result.status!);
await this.passkeys.deletePasskey(user.id, id, body?.password);
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
+31 -29
View File
@@ -1,3 +1,4 @@
import { DomainError } from '../common/domain-error';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import bcrypt from 'bcryptjs';
@@ -41,6 +42,9 @@ const NOT_CONFIGURED = { error: 'Passkey login is not configured for this server
// used to tell "no such credential" apart from "bad signature" (CWE-203).
const AUTH_FAILED = { error: 'Authentication failed', status: 401 } as const;
/** One of the canned `{ error, status }` pairs above, raised. */
const refusal = (r: { error: string; status: number }) => new DomainError(r.status, r.error);
// Reference-compared sentinel (oidc invite_exhausted precedent): thrown inside
// the register transaction to keep the duplicate 409 distinct from the generic
// insert-failure 400 without string-matching SQLite errors.
@@ -224,19 +228,19 @@ export class PasskeyService {
userId: number,
password: string | undefined,
requestOrigin?: string,
): Promise<{ error?: string; status?: number; options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
): Promise<{ options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
const cfg = await this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
if (!cfg) throw refusal(NOT_CONFIGURED);
if (this.originCannotVerify(cfg, requestOrigin)) throw refusal(NOT_CONFIGURED);
const user = await this.users.findById(userId);
if (!user) return { error: 'User not found', status: 404 };
if (!user) throw new DomainError(404, 'User not found');
// Re-authentication: a hijacked session must not be able to silently plant an
// attacker-controlled passkey. Require the current password (parity with the
// change-password / disable-MFA step-up).
if (!password || !user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
return { error: 'Incorrect password', status: 401 };
throw new DomainError(401, 'Incorrect password');
}
const existing = await this.webauthnCredentials.listExcludeCredentials(userId);
@@ -264,20 +268,20 @@ export class PasskeyService {
async passkeyRegisterVerify(
userId: number,
body: { attestationResponse?: unknown; name?: unknown },
): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> {
): Promise<{ success?: boolean; credential?: unknown }> {
const cfg = await this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
if (!cfg) throw refusal(NOT_CONFIGURED);
const resp = body?.attestationResponse;
if (!resp) return { error: 'Invalid registration response', status: 400 };
if (!resp) throw new DomainError(400, 'Invalid registration response');
const challenge = challengeFromResponse(resp);
if (!challenge) return { error: 'Invalid registration response', status: 400 };
if (!challenge) throw new DomainError(400, 'Invalid registration response');
const now = Date.now();
const claimed = await this.claimChallenge(challenge, 'registration', now);
if (!claimed || claimed.user_id !== userId) {
return { error: 'Registration challenge expired. Please try again.', status: 400 };
throw new DomainError(400, 'Registration challenge expired. Please try again.');
}
const expectedOrigin = this.expectedOrigins(cfg, resp);
@@ -296,11 +300,11 @@ export class PasskeyService {
this.logger.warn(
`Passkey registration rejected (expectedRPID=${cfg.rpID}, expectedOrigin=[${expectedOrigin.join(', ')}]): ${err instanceof Error ? err.message : String(err)}`,
);
return { error: 'Could not register this passkey.', status: 400 };
throw new DomainError(400, 'Could not register this passkey.');
}
if (!verification.verified || !verification.registrationInfo) {
return { error: 'Could not register this passkey.', status: 400 };
throw new DomainError(400, 'Could not register this passkey.');
}
// Persist ONLY the values the verifier vouches for — never anything parsed
@@ -329,9 +333,9 @@ export class PasskeyService {
});
} catch (err) {
if (err === DUPLICATE_CREDENTIAL) {
return { error: 'This passkey is already registered.', status: 409 };
throw new DomainError(409, 'This passkey is already registered.');
}
return { error: 'Could not register this passkey.', status: 400 };
throw new DomainError(400, 'Could not register this passkey.');
}
const created = (await this.webauthnCredentials.findCreatedCredential(credential.id))!;
@@ -343,13 +347,11 @@ export class PasskeyService {
// -------------------------------------------------------------------------
async passkeyLoginOptions(requestOrigin?: string): Promise<{
error?: string;
status?: number;
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
}> {
const cfg = await this.webauthn.resolve();
if (!cfg) return { ...NOT_CONFIGURED };
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
if (!cfg) throw refusal(NOT_CONFIGURED);
if (this.originCannotVerify(cfg, requestOrigin)) throw refusal(NOT_CONFIGURED);
const now = Date.now();
await this.purgeExpiredChallenges(now);
@@ -452,9 +454,9 @@ export class PasskeyService {
return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 }));
}
async renamePasskey(userId: number, id: string, name: unknown): Promise<{ error?: string; status?: number; success?: boolean }> {
async renamePasskey(userId: number, id: string, name: unknown): Promise<{ success?: boolean }> {
const cleanName = sanitizeName(name);
if (!cleanName) return { error: 'Name is required', status: 400 };
if (!cleanName) throw new DomainError(400, 'Name is required');
// Convert, VALIDATE, and answer the legacy not-found before the
// repository call (program rule 15): the legacy `UPDATE ... WHERE id = ?
// AND user_id = ?` bound `Number(id)` as a plain parameter — a
@@ -466,10 +468,10 @@ export class PasskeyService {
// reference and throws — a 500 where the legacy 404'd (Plan 3b Task 3
// review, F1).
const rowId = toRowId(id);
if (rowId === null) return { error: 'Passkey not found', status: 404 };
if (rowId === null) throw new DomainError(404, 'Passkey not found');
// Ownership enforced in SQL (404 on miss, never a 403 that leaks existence).
const changes = await this.webauthnCredentials.renameOwned(rowId, userId, cleanName);
if (changes === 0) return { error: 'Passkey not found', status: 404 };
if (changes === 0) throw new DomainError(404, 'Passkey not found');
return { success: true };
}
@@ -477,27 +479,27 @@ export class PasskeyService {
userId: number,
id: string,
password: string | undefined,
): Promise<{ error?: string; status?: number; success?: boolean }> {
): Promise<{ success?: boolean }> {
// Re-auth before removing a credential (a hijacked session must not be able to
// strip the victim's passkeys). Deleting is always allowed because every
// account keeps a usable password as recovery fallback — losing all passkeys
// can never lock anyone out.
const passwordHash = await this.users.getPasswordHash(userId);
if (!passwordHash || !password || !bcrypt.compareSync(password, passwordHash)) {
return { error: 'Incorrect password', status: 401 };
throw new DomainError(401, 'Incorrect password');
}
// Same guard as `renamePasskey` (F1) — placed after the password check
// to match the legacy statement order exactly (a wrong password still
// answers 401 before a bad id is ever considered).
const rowId = toRowId(id);
if (rowId === null) return { error: 'Passkey not found', status: 404 };
if (rowId === null) throw new DomainError(404, 'Passkey not found');
const changes = await this.webauthnCredentials.deleteOwned(rowId, userId);
if (changes === 0) return { error: 'Passkey not found', status: 404 };
if (changes === 0) throw new DomainError(404, 'Passkey not found');
return { success: true };
}
/** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */
async adminResetPasskeys(targetUserId: number): Promise<{ error?: string; status?: number; success?: boolean; deleted?: number; email?: string }> {
async adminResetPasskeys(targetUserId: number): Promise<{ success?: boolean; deleted?: number; email?: string }> {
// `AdminService.resetUserPasskeys` converts the route param with a bare
// `Number(id)` before calling in — a non-numeric id arrives here as
// `NaN`, still typed `number` at the JS level. Same F1 guard: validate
@@ -505,9 +507,9 @@ export class PasskeyService {
// `SELECT id, email FROM users WHERE id = ?` bound `Number(id)` too and
// simply matched no row).
const rowId = toRowId(targetUserId);
if (rowId === null) return { error: 'User not found', status: 404 };
if (rowId === null) throw new DomainError(404, 'User not found');
const target = await this.users.findIdAndEmail(rowId);
if (!target) return { error: 'User not found', status: 404 };
if (!target) throw new DomainError(404, 'User not found');
const deleted = await this.webauthnCredentials.deleteAllForUser(rowId);
return { success: true, deleted, email: target.email };
}
@@ -1,3 +1,4 @@
import { DomainError } from '../common/domain-error';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import crypto from 'crypto';
@@ -101,7 +102,7 @@ export class RegistrationInvitesService {
const numericId = toRowId(id);
// RI6 — the 404 check.
if (numericId === null || (await this.inviteTokens.findIdById(numericId)) === null) {
return { error: 'Invite not found', status: 404 };
throw new DomainError(404, 'Invite not found');
}
// RI7.
await this.inviteTokens.deleteById(numericId);
+12 -11
View File
@@ -1,3 +1,4 @@
import { DomainError } from '../common/domain-error';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { readEnv, getAppUrl } from '../../app-config';
@@ -184,29 +185,29 @@ export class UserProfileService {
async updateSettings(
userId: number,
rawBody: unknown
): Promise<{ error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] }> {
): Promise<{ success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] }> {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string; username?: string; email?: string };
const { maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, username, email } = body;
if (username !== undefined) {
const trimmed = username.trim();
if (!trimmed || trimmed.length < 2 || trimmed.length > 50) {
return { error: 'Username must be between 2 and 50 characters', status: 400 };
throw new DomainError(400, 'Username must be between 2 and 50 characters');
}
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) {
return { error: 'Username can only contain letters, numbers, underscores, dots and hyphens', status: 400 };
throw new DomainError(400, 'Username can only contain letters, numbers, underscores, dots and hyphens');
}
const conflict = await this.usersRepo.findIdByUsernameCI(trimmed, userId);
if (conflict) return { error: 'Username already taken', status: 409 };
if (conflict) throw new DomainError(409, 'Username already taken');
}
if (email !== undefined) {
const trimmed = email.trim();
if (!trimmed || !EMAIL_REGEX.test(trimmed)) {
return { error: 'Invalid email format', status: 400 };
throw new DomainError(400, 'Invalid email format');
}
const conflict = await this.usersRepo.findIdByEmailCI(trimmed, userId);
if (conflict) return { error: 'Email already taken', status: 409 };
if (conflict) throw new DomainError(409, 'Email already taken');
}
// The name and email half of this body stays the user's own in every mode;
@@ -241,7 +242,7 @@ export class UserProfileService {
} catch (err) {
// Another write took the name or address between the check above and this one.
if (err instanceof UserIdentityTakenError) {
return { error: err.field === 'email' ? 'Email already taken' : 'Username already taken', status: 409 };
throw new DomainError(409, err.field === 'email' ? 'Email already taken' : 'Username already taken');
}
throw err;
}
@@ -258,9 +259,9 @@ export class UserProfileService {
};
}
async getSettings(userId: number): Promise<{ error?: string; status?: number; settings?: Record<string, unknown> }> {
async getSettings(userId: number): Promise<{ settings?: Record<string, unknown> }> {
const user = await this.usersRepo.getApiKeyColumns(userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
if (user?.role !== 'admin') throw new DomainError(403, 'Admin access required');
// The one endpoint in the codebase that hands back a stored key in the
// clear. That is fine when the admin pasted it in themselves and wrong when
@@ -348,9 +349,9 @@ export class UserProfileService {
// Key validation
// -------------------------------------------------------------------------
async validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
async validateKeys(userId: number): Promise<{ maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
const user = await this.usersRepo.getRoleAndWeatherKey(userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
if (user?.role !== 'admin') throw new DomainError(403, 'Admin access required');
const result: {
maps: boolean;
+4 -5
View File
@@ -79,14 +79,13 @@ export const conflict = (message: string, details?: Record<string, unknown>) =>
* Runs `fn` and returns the DomainError it raised instead of throwing it; any
* other error propagates. For the few callers that must act on a refusal before
* passing it on (an audit row, a constant-time pad) and for service tests.
* Test the result with `instanceof DomainError`.
*/
export async function catchDomainError<T>(
fn: () => Promise<T> | T,
): Promise<{ ok: true; value: T } | { ok: false; error: DomainError }> {
export async function catchDomainError<T>(fn: () => Promise<T> | T): Promise<T | DomainError> {
try {
return { ok: true, value: await fn() };
return await fn();
} catch (err) {
if (err instanceof DomainError) return { ok: false, error: err };
if (err instanceof DomainError) return err;
throw err;
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { DomainError } from '../common/domain-error';
import crypto, { randomBytes, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -745,10 +746,10 @@ export class OauthService {
async adminRevokeOAuthSession(id: string) {
const tokenId = toRowId(id);
if (tokenId === null) return { error: 'Session not found', status: 404 };
if (tokenId === null) throw new DomainError(404, 'Session not found');
const row = await this.tokens.findById(tokenId);
if (!row) return { error: 'Session not found', status: 404 };
if (!row) throw new DomainError(404, 'Session not found');
await this.tokens.revokeById(tokenId);
revokeUserSessionsForClient(row.user_id, row.client_id);
return {};
+12 -11
View File
@@ -1,3 +1,4 @@
import { DomainError } from '../common/domain-error';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { randomBytes, createHash } from 'crypto';
@@ -108,13 +109,13 @@ export class TokenService {
return await this.createToken(userId, rawName, 'api', scopes);
}
private async createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): Promise<{ error?: string; status?: number; token?: Record<string, unknown> }> {
private async createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): Promise<{ token?: Record<string, unknown> }> {
const name = rawName as string | undefined;
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
if (!name?.trim()) throw new DomainError(400, 'Token name is required');
if (name.trim().length > 100) throw new DomainError(400, 'Token name must be 100 characters or less');
const tokenCount = await this.tokens.countByUserAndKind(userId, kind);
if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 };
if (tokenCount >= 10) throw new DomainError(400, 'Maximum of 10 tokens per user reached');
const rawToken = 'trek_' + randomBytes(24).toString('hex');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
@@ -162,7 +163,7 @@ export class TokenService {
* happily delete a token the MCP panel manages, and the user would find a key
* missing from a screen they never opened.
*/
private async deleteToken(userId: number, tokenId: string, kind: TokenKind): Promise<{ error?: string; status?: number; success?: boolean }> {
private async deleteToken(userId: number, tokenId: string, kind: TokenKind): Promise<{ success?: boolean }> {
// Convert, VALIDATE, and answer the legacy not-found before any
// repository call (program rule 15): the legacy statement bound
// `tokenId` straight into `WHERE id = ?` and let SQLite's affinity rules
@@ -170,9 +171,9 @@ export class TokenService {
// leniency, so a bare `Number()` turned a 404 into a 500 (Plan 3b Task 2
// review, F1).
const id = toRowId(tokenId);
if (id === null) return { error: 'Token not found', status: 404 };
if (id === null) throw new DomainError(404, 'Token not found');
const token = await this.tokens.findOwnedByKind(id, userId, kind);
if (!token) return { error: 'Token not found', status: 404 };
if (!token) throw new DomainError(404, 'Token not found');
await this.tokens.deleteById(id);
// Best-effort, like the changePassword/resetPassword revocations: a session
// sweep failure must not turn a successful token delete into a 500.
@@ -184,12 +185,12 @@ export class TokenService {
// Ephemeral tokens
// -------------------------------------------------------------------------
async createWsToken(userId: number): Promise<{ error?: string; status?: number; token?: string }> {
async createWsToken(userId: number): Promise<{ token?: string }> {
// Bind the ws-token to the user's current password_version so a token minted
// before a password reset is rejected on connect (defence-in-depth session gate).
const pv = (await this.users.getPasswordVersion(userId)) ?? 0;
const token = this.ephemeral.create(userId, 'ws', { pv });
if (!token) return { error: 'Service unavailable', status: 503 };
if (!token) throw new DomainError(503, 'Service unavailable');
return { token };
}
@@ -222,9 +223,9 @@ export class TokenService {
// Same guard as `deleteToken` above — convert, VALIDATE, answer the
// legacy 404 before any repository call (F1).
const numericId = toRowId(id);
if (numericId === null) return { error: 'Token not found', status: 404 };
if (numericId === null) throw new DomainError(404, 'Token not found');
const token = await this.tokens.findBasic(numericId);
if (!token) return { error: 'Token not found', status: 404 };
if (!token) throw new DomainError(404, 'Token not found');
await this.tokens.deleteById(numericId);
revokeUserSessions(token.user_id);
return {};