From 11be7562134d26127def4f29b265597ad3cb96e6 Mon Sep 17 00:00:00 2001 From: NickGhignatti Date: Thu, 26 Mar 2026 21:32:07 +0100 Subject: [PATCH 1/4] feat: errors openapi spec --- landing-page/api/auth.yaml | 14 ++-- landing-page/api/error.yaml | 101 +++++++++++++++++++++++++++++ landing-page/api/notification.yaml | 18 +---- landing-page/api/twin.yaml | 36 ++-------- 4 files changed, 115 insertions(+), 54 deletions(-) create mode 100644 landing-page/api/error.yaml diff --git a/landing-page/api/auth.yaml b/landing-page/api/auth.yaml index 7504da0..337be33 100644 --- a/landing-page/api/auth.yaml +++ b/landing-page/api/auth.yaml @@ -118,7 +118,7 @@ paths: "201": description: Account created successfully "400": - description: Bad request (e.g., account already exists) + $ref: "error.yaml#/components/responses/BadRequest" /login: post: @@ -158,7 +158,7 @@ paths: account: $ref: "#/components/schemas/Account" "401": - description: Invalid credentials + $ref: "error.yaml#/components/responses/Unauthorized" /business/register: post: @@ -196,7 +196,9 @@ paths: "201": description: Enterprise account created "401": - description: Unauthorized / Invalid HMAC signature + $ref: "error.yaml#/components/responses/Unauthorized" + "403": + $ref: "error.yaml#/components/responses/Forbidden" /me: get: @@ -211,7 +213,7 @@ paths: schema: $ref: "#/components/schemas/Account" "401": - description: Unauthorized + $ref: "error.yaml#/components/responses/Unauthorized" /logout: post: @@ -252,7 +254,7 @@ paths: "201": description: Domain created successfully "403": - description: Forbidden (insufficient role) + $ref: "error.yaml#/components/responses/Forbidden" /domains/{accountName}: get: @@ -317,7 +319,7 @@ paths: "201": description: Subdomain created successfully "403": - description: Forbidden + $ref: "error.yaml#/components/responses/Forbidden" /domains/{accountName}/subscribe: post: diff --git a/landing-page/api/error.yaml b/landing-page/api/error.yaml new file mode 100644 index 0000000..ba67c9d --- /dev/null +++ b/landing-page/api/error.yaml @@ -0,0 +1,101 @@ +openapi: 3.1.0 +info: + title: Common Error Responses + version: 1.0.0 + description: Shared error contracts for Crowd Vision microservices. + +components: + schemas: + Error: + type: object + required: + - type + - code + - message + properties: + type: + type: string + description: The category or domain of the error (e.g., 'AuthenticationError', 'ValidationError'). + code: + type: string + description: A specific, machine-readable error code to be parsed by the client. + message: + type: string + description: A human-readable message describing the exact issue. + + responses: + BadRequest: + description: "Bad Request (400) - Missing fields, validation errors, or missing request bodies." + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + generalError: + summary: General Bad Request + value: + type: "ValidationError" + code: "BAD_REQUEST" + message: "Raw request body unavailable for signature verification" + + Unauthorized: + description: "Unauthorized (401) - Authentication failed, or token is missing/expired." + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + missingToken: + summary: Missing Token + value: + type: "AuthenticationError" + code: "MISSING_TOKEN" + message: "Authentication token missing" + invalidToken: + summary: Invalid Token + value: + type: "AuthenticationError" + code: "INVALID_TOKEN" + message: "Invalid or expired token" + + Forbidden: + description: "Forbidden (403) - The client lacks required permissions or signature." + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + invalidSignature: + summary: Invalid HMAC Signature + value: + type: "AuthorizationError" + code: "INVALID_SIGNATURE" + message: "Forbidden: Invalid signature" + + NotFound: + description: "Not Found (404) - The requested resource does not exist." + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + buildingNotFound: + summary: Building Not Found + value: + type: "ResourceNotFoundError" + code: "NOT_FOUND" + message: "Building with ID {id} not found" + + InternalServerError: + description: "Internal Server Error (500) - An unexpected condition was encountered on the server." + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + examples: + serverConfigError: + summary: Missing Admin Secret + value: + type: "ServerError" + code: "INTERNAL_ERROR" + message: "Server configuration error" \ No newline at end of file diff --git a/landing-page/api/notification.yaml b/landing-page/api/notification.yaml index d988d7a..8c71047 100644 --- a/landing-page/api/notification.yaml +++ b/landing-page/api/notification.yaml @@ -63,23 +63,9 @@ paths: type: boolean example: true '400': - description: Invalid subscription payload - content: - application/json: - schema: - type: object - properties: - error: - type: string + $ref: 'error.yaml#/components/responses/BadRequest' '500': - description: Server error - content: - application/json: - schema: - type: object - properties: - error: - type: string + $ref: 'error.yaml#/components/responses/InternalServerError' /public-key: get: diff --git a/landing-page/api/twin.yaml b/landing-page/api/twin.yaml index a548cb1..0bb04d7 100644 --- a/landing-page/api/twin.yaml +++ b/landing-page/api/twin.yaml @@ -26,14 +26,7 @@ paths: schema: $ref: "#/components/schemas/Building" "400": - description: Bad request - content: - application/json: - schema: - type: object - properties: - error: - type: string + $ref: "error.yaml#/components/responses/BadRequest" /building/{id}: get: @@ -55,14 +48,7 @@ paths: schema: $ref: "#/components/schemas/Building" "404": - description: Building not found - content: - application/json: - schema: - type: object - properties: - error: - type: string + $ref: "error.yaml#/components/responses/NotFound" /buildings/{domain}: get: @@ -86,14 +72,7 @@ paths: items: $ref: "#/components/schemas/Building" "404": - description: Error retrieving buildings - content: - application/json: - schema: - type: object - properties: - error: - type: string + $ref: "error.yaml#/components/responses/NotFound" /building/{buildingId}/room/{roomId}: patch: @@ -133,14 +112,7 @@ paths: schema: $ref: "#/components/schemas/Room" "400": - description: Error updating room - content: - application/json: - schema: - type: object - properties: - error: - type: string + $ref: "error.yaml#/components/responses/BadRequest" components: schemas: From 971c1f83c61e8130e0cd0515f2ddf51b0686c9bd Mon Sep 17 00:00:00 2001 From: NickGhignatti Date: Fri, 27 Mar 2026 18:28:49 +0100 Subject: [PATCH 2/4] feat: error handler --- .../cards/__tests__/DomainCard.spec.ts | 2 +- client/src/stores/authentication.ts | 11 +- .../controller/administrationController.ts | 121 ++++----- .../controller/authenticationController.ts | 115 ++++---- .../controller/authenticationMiddleware.ts | 33 +-- .../src/controller/domainController.ts | 254 ++++++++---------- server/auth-service/src/index.ts | 2 + .../src/middlewares/errorsMiddleware.ts | 21 ++ server/auth-service/src/models/domain.ts | 4 +- server/auth-service/src/models/error.ts | 48 ++++ .../src/models/{roles.ts => role.ts} | 0 server/auth-service/src/router.ts | 2 +- .../src/services/authenticationService.ts | 44 +-- .../src/services/domainService.ts | 46 +++- .../auth-service/src/services/tokenService.ts | 17 +- .../auth-service/src/services/totpService.ts | 38 +-- 16 files changed, 409 insertions(+), 349 deletions(-) create mode 100644 server/auth-service/src/middlewares/errorsMiddleware.ts create mode 100644 server/auth-service/src/models/error.ts rename server/auth-service/src/models/{roles.ts => role.ts} (100%) diff --git a/client/src/components/cards/__tests__/DomainCard.spec.ts b/client/src/components/cards/__tests__/DomainCard.spec.ts index af6ef13..df8ea17 100644 --- a/client/src/components/cards/__tests__/DomainCard.spec.ts +++ b/client/src/components/cards/__tests__/DomainCard.spec.ts @@ -7,7 +7,7 @@ vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }), })) -vi.mock('@/helpers/roles.ts', () => ({ +vi.mock('@/helpers/role.ts', () => ({ getRoleMeta: vi.fn((role: string) => ({ i18nKey: `domains.roles.${role}` })), })) diff --git a/client/src/stores/authentication.ts b/client/src/stores/authentication.ts index 97ba1ce..a2317c8 100644 --- a/client/src/stores/authentication.ts +++ b/client/src/stores/authentication.ts @@ -15,8 +15,11 @@ export const useAuthStore = defineStore('authentication', { const res = await makeRequest('/auth/login', 'POST', { body: JSON.stringify({ accountName, password }), }) - if (!res.ok) throw new Error('Login failed') const data = await res.json() + if (!res.ok) { + console.log(`Failed to login: ${data.type} - ${data.message}`) + return; + } this.accountName = data.account.accountName this.isAuthenticated = true }, @@ -28,8 +31,12 @@ export const useAuthStore = defineStore('authentication', { const res = await makeRequest('/auth/register', 'POST', { body: JSON.stringify(payload), }) - if (!res.ok) throw new Error('Registration failed') const data = await res.json() + + if (!res.ok) { + console.log(`Failed to register: ${data.type} - ${data.message}`) + return + } this.accountName = data.account.accountName this.isAuthenticated = true }, diff --git a/server/auth-service/src/controller/administrationController.ts b/server/auth-service/src/controller/administrationController.ts index 4b68ba7..afab55e 100644 --- a/server/auth-service/src/controller/administrationController.ts +++ b/server/auth-service/src/controller/administrationController.ts @@ -4,85 +4,82 @@ import * as DomainService from "../services/domainService.js"; import { generateStandardToken } from "../services/tokenService.js"; import type { StandardTokenPayload } from "../models/token.js"; import { Account } from "../models/account.js"; +import { + type ConflictError, + InternalError, + NotFoundError, +} from "../models/error.js"; export const provideEnterpriseAccount = async (req: Request, res: Response) => { - try { - const { companyName, companyAdminMail, companyPassword, companyDomain } = - req.body; + const { companyName, companyAdminMail, companyPassword, companyDomain } = + req.body; - const accountCreated = await AuthService.registerAccount( - `${companyName}-admin`, - companyAdminMail, - companyPassword, - ); + const accountCreated = await AuthService.registerAccount( + `${companyName}-admin`, + companyAdminMail, + companyPassword, + ); - // TODO - // const totpSecret = deriveTotpSecret( - // accountCreated.name, - // accountCreated.password, - // ); + // TODO + // const totpSecret = deriveTotpSecret( + // accountCreated.name, + // accountCreated.password, + // ); - // await Account.findOneAndUpdate( - // { name: accountCreated.name }, - // { $addToSet: { totpSecret: totpSecret } }, - // ); + // await Account.findOneAndUpdate( + // { name: accountCreated.name }, + // { $addToSet: { totpSecret: totpSecret } }, + // ); - await DomainService.createDomain( - companyDomain as string, - [], - accountCreated.name, - "internal", - ); + const createdDomain = await DomainService.createDomain( + companyDomain as string, + [], + accountCreated.name, + "internal", + ); - const token = await generateStandardToken({ - accountId: accountCreated._id.toString(), - accountName: accountCreated.name, - } as StandardTokenPayload); + const token = await generateStandardToken({ + accountId: accountCreated._id.toString(), + accountName: accountCreated.name, + } as StandardTokenPayload); - res.status(201).json({ - message: `Administrator account for company ${companyName} created successfully`, - token, - account: { - accountName: accountCreated.name, - }, - }); - } catch (error: any) { - res.status(500).json({ message: "Error creating enterprise account", error: error.message }); - } + res.status(201).json({ + message: `Administrator account for company ${companyName} created successfully`, + token, + account: { + accountName: accountCreated.name, + }, + }); }; export const provideEnterpriseAdministratorAccount = async ( req: Request, res: Response, ) => { - try { - const { - companyName, - accountSecretCode, - accountName, - accountEmail, - accountPassword, - } = req.body; + const { + companyName, + accountSecretCode, + accountName, + accountEmail, + accountPassword, + } = req.body; - // TODO - // if (!(await verifyTotpSecret(accountSecretCode, companyName))) { - // throw new Error( - // "Invalid secret code provided for administrator account creation", - // ); - // } + // TODO + // if (!(await verifyTotpSecret(accountSecretCode, companyName))) { + // throw new Error( + // "Invalid secret code provided for administrator account creation", + // ); + // } - const createdAccount = await AuthService.registerAccount( - accountName, - accountEmail, - accountPassword, - ); + const createdAccount = await AuthService.registerAccount( + accountName, + accountEmail, + accountPassword, + ); - const enterpriseDomain = await DomainService.getDomainByName(companyName); + const enterpriseDomain = await DomainService.getDomainByName(companyName); - if (!enterpriseDomain) { - throw new Error("domain of the organization not found"); - } - } catch (error: any) { - res.status(500).json({ message: "Error creating administrator account", error: error.message }); + if (!enterpriseDomain) { + throw new NotFoundError(`Enterprise domain with name "${companyName}" not found`); } }; diff --git a/server/auth-service/src/controller/authenticationController.ts b/server/auth-service/src/controller/authenticationController.ts index 663f1c4..639621f 100644 --- a/server/auth-service/src/controller/authenticationController.ts +++ b/server/auth-service/src/controller/authenticationController.ts @@ -5,94 +5,69 @@ import type { StandardTokenPayload } from "../models/token.js"; import { grantTOTPRoles, resolveRoleFromOTP } from "../services/totpService.js"; import { COOKIE_NAME, getCookieOptions } from "../config/config.js"; - export const createAccount = async (req: Request, res: Response) => { - try { - const { accountName, email, password, otp } = req.body; + const { accountName, email, password, otp } = req.body; - const account = await AuthService.registerAccount( - accountName, - email, - password, - ); + const account = await AuthService.registerAccount( + accountName, + email, + password, + ); - if (otp) { - await grantTOTPRoles(otp, account._id); - } + if (otp) { + await grantTOTPRoles(otp, account._id); + } - const token = await generateStandardToken({ - accountId: account._id.toString(), - accountName: account.name, - } as StandardTokenPayload); + const token = await generateStandardToken({ + accountId: account._id.toString(), + accountName: account.name, + } as StandardTokenPayload); - // Set the token as an httpOnly cookie — never exposed to JS - res.cookie(COOKIE_NAME, token, getCookieOptions()); + // Set the token as an httpOnly cookie — never exposed to JS + res.cookie(COOKIE_NAME, token, getCookieOptions()); - res.status(201).json({ - message: "Account creation successful", - account: { - accountName, - }, - }); - } catch (error: any) { - res.status(400).json({ error: error.message }); - } + res.status(201).json({ + message: "Account creation successful", + account: { + accountName, + }, + }); }; export const authenticateAccount = async (req: Request, res: Response) => { - try { - const { accountName, password } = req.body; - const account = await AuthService.authenticateAccount( - accountName, - password, - ); - const token = await generateStandardToken({ - accountId: account._id.toString(), - accountName: account.name, - } as StandardTokenPayload); + const { accountName, password } = req.body; + const account = await AuthService.authenticateAccount(accountName, password); + const token = await generateStandardToken({ + accountId: account._id.toString(), + accountName: account.name, + } as StandardTokenPayload); - res.cookie(COOKIE_NAME, token, getCookieOptions()); + res.cookie(COOKIE_NAME, token, getCookieOptions()); - res.status(200).json({ - message: "Login successful", - account: { - accountName: account.name, - }, - }); - } catch (error: any) { - res.status(401).json({ error: error.message }); - } + res.status(200).json({ + message: "Login successful", + account: { + accountName: account.name, + }, + }); }; export const startSSOLogin = async (req: Request, res: Response) => { - try { - const { domainName } = req.params; - const { accountName } = req.query; + const { domainName } = req.params; + const { accountName } = req.query; - const redirectUrl = await AuthService.generateSSOLoginUrl( - domainName as string, - accountName as string, - ); + const redirectUrl = await AuthService.generateSSOLoginUrl( + domainName as string, + accountName as string, + ); - res.json({ redirectUrl }); - } catch (error: any) { - res.status(400).json({ error: error.message }); - } + res.json({ redirectUrl }); }; export const handleSSOCallback = async (req: Request, res: Response) => { - try { - // Pass full URL to service to handle state/code extraction - const clientRedirect = await AuthService.processSSOCallback( - req.originalUrl, - ); - res.redirect(clientRedirect); - } catch (error: any) { - console.error("SSO Error:", error); - res.redirect( - `${process.env.CLIENT_URL || "http://localhost:8080"}/domains?error=sso_failed`, - ); - } + // Pass full URL to service to handle state/code extraction + const clientRedirect = await AuthService.processSSOCallback(req.originalUrl); + res.redirect(clientRedirect); }; export const getMe = async (req: Request, res: Response) => { @@ -104,4 +79,4 @@ export const getMe = async (req: Request, res: Response) => { export const logout = (_req: Request, res: Response) => { res.clearCookie(COOKIE_NAME, getCookieOptions()); res.status(200).json({ message: "Logged out" }); -}; \ No newline at end of file +}; diff --git a/server/auth-service/src/controller/authenticationMiddleware.ts b/server/auth-service/src/controller/authenticationMiddleware.ts index fda57e5..9670d69 100644 --- a/server/auth-service/src/controller/authenticationMiddleware.ts +++ b/server/auth-service/src/controller/authenticationMiddleware.ts @@ -2,6 +2,11 @@ import type { NextFunction, Request, Response } from "express"; import * as TokenService from "../services/tokenService.js"; import crypto from "crypto"; import { COOKIE_NAME, getAdminSecret } from "../config/config.js"; +import { + ForbiddenError, + InternalError, + NotFoundError, ValidationError, +} from "../models/error.js"; declare global { namespace Express { @@ -16,18 +21,14 @@ export const requireAuthentication = ( res: Response, next: NextFunction, ) => { - try { - const token = req.cookies?.[COOKIE_NAME]; - - if (!token) { - return res.status(401).json({ error: "Authentication token missing" }); - } + const token = req.cookies?.[COOKIE_NAME]; - req.account = TokenService.verifyToken(token); - next(); - } catch (error) { - return res.status(401).json({ error: "Invalid or expired token" }); + if (!token) { + throw new NotFoundError(`Could not find token for the cookie`); } + + req.account = TokenService.verifyToken(token); + next(); }; export const requireHmacSignature = ( @@ -39,17 +40,19 @@ export const requireHmacSignature = ( const secret = getAdminSecret(); if (!secret) { - return res.status(500).json({ error: "Server configuration error" }); + throw new InternalError("Server configuration error, missing administration secrets"); } if (!signature || typeof signature !== "string") { - return res.status(401).json({ error: "Forbidden: missing signature" }); + throw new ForbiddenError(`Invalid signature`); } const rawBody: Buffer | undefined = (req as any).rawBody; if (!rawBody) { - return res.status(400).json({ error: "Raw request body unavailable for signature verification" }); + throw new ValidationError( + "Raw request body unavailable for signature verification", + ); } const expectedSignature = crypto @@ -63,14 +66,14 @@ export const requireHmacSignature = ( signatureBuffer = Buffer.from(signature, "hex"); expectedBuffer = Buffer.from(expectedSignature, "hex"); } catch { - return res.status(403).json({ error: "Forbidden: Invalid signature" }); + throw new ForbiddenError("Invalid signature"); } if ( signatureBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedBuffer) ) { - return res.status(403).json({ error: "Forbidden: Invalid signature" }); + throw new ForbiddenError("Invalid signature"); } next(); diff --git a/server/auth-service/src/controller/domainController.ts b/server/auth-service/src/controller/domainController.ts index c2475b1..83570f6 100644 --- a/server/auth-service/src/controller/domainController.ts +++ b/server/auth-service/src/controller/domainController.ts @@ -1,170 +1,144 @@ -import type {Request, Response} from "express"; +import type { Request, Response } from "express"; import * as DomainService from "../services/domainService.js"; -import {Domain, type IDomain} from "../models/domain.js"; +import { Domain, type IDomain } from "../models/domain.js"; import * as OTPAuth from "otpauth"; import { Account } from "../models/account.js"; -import { hasRequiredRole, type Role } from "../models/roles.js"; +import { hasRequiredRole, type Role } from "../models/role.js"; +import { NotFoundError, UnauthorizedError } from "../models/error.js"; export const createDomain = async (req: Request, res: Response) => { - try { - const {name, subdomains, authStrategy, ssoConfig, isVisibleFromOutside} = - req.body; - - const creatorAccountName: string | undefined = (req.account as { accountName?: string })?.accountName; - - if (!creatorAccountName) { - return res.status(401).json({error: "Authenticated account name missing"}); - } - - const domain = await DomainService.createDomain( - name, - [], - creatorAccountName, - authStrategy, - ssoConfig, - isVisibleFromOutside - ); - - res.status(201).json(domain); - } catch (error: any) { - res.status(400).json({error: error.message}); - } + const { name, subdomains, authStrategy, ssoConfig, isVisibleFromOutside } = + req.body; + + const creatorAccountName: string | undefined = ( + req.account as { accountName?: string } + )?.accountName; + + if (!creatorAccountName) { + throw new UnauthorizedError("Invalid creator account name"); + } + + const domain = await DomainService.createDomain( + name, + [], + creatorAccountName, + authStrategy, + ssoConfig, + isVisibleFromOutside, + ); + + res.status(201).json(domain); }; export const getAllDomains = async (req: Request, res: Response) => { - try { - const domains = await DomainService.getAllDomains(); - res.json({domains}); - } catch (error: any) { - res.status(400).json({error: error.message}); - } + const domains = await DomainService.getAllDomains(); + res.json({ domains }); }; export const getAllAllowedDomains = async (req: Request, res: Response) => { - try { - const domains = await DomainService.getAllAllowedDomains(); - res.json({domains}); - } catch (error: any) { - res.status(400).json({error: error.message}); - } -} + const domains = await DomainService.getAllAllowedDomains(); + res.json({ domains }); +}; export const getDomainsByAccount = async (req: Request, res: Response) => { - const {accountName} = req.params; - try { - const memberships = await DomainService.getAccountMemberships( - accountName as string, - ); - res.json({domains: memberships}); - } catch (error: any) { - res.status(404).json({error: error.message}); - } + const { accountName } = req.params; + const memberships = await DomainService.getAccountMemberships( + accountName as string, + ); + res.json({ domains: memberships }); }; export const subscribeAccountToDomain = async (req: Request, res: Response) => { - try { - const {accountName} = req.params; - const {domainName} = req.body; - await DomainService.subscribeInternal(accountName as string, domainName); - res.status(200).send(); - } catch (error: any) { - res.status(400).json({error: error.message}); - } + const { accountName } = req.params; + const { domainName } = req.body; + await DomainService.subscribeInternal(accountName as string, domainName); + res.status(200).send(); }; export const unsubscribeAccountFromDomain = async ( - req: Request, - res: Response, + req: Request, + res: Response, ) => { - try { - const {accountName} = req.params; - const {domainName} = req.body; - await DomainService.unsubscribe(accountName as string, domainName); - res.status(200).send(); - } catch (error: any) { - res.status(400).json({error: error.message}); - } + const { accountName } = req.params; + const { domainName } = req.body; + await DomainService.unsubscribe(accountName as string, domainName); + res.status(200).send(); }; export const getSubdomainsFromDomain = async (req: Request, res: Response) => { - try { - const {domainName} = req.params; - const subdomains = await DomainService.getDomainSubdomains( - domainName as string, - ); - - res.status(200).json(subdomains); - } catch (error: any) { - res.status(400).json({error: error.message}); - } + const { domainName } = req.params; + const subdomains = await DomainService.getDomainSubdomains( + domainName as string, + ); + + res.status(200).json(subdomains); }; export const createSubdomain = async (req: Request, res: Response) => { - try { - const {domainName} = req.params; - const {name, authStrategy, ssoConfig, isVisibleFromOutside} = req.body; - - const creatorAccountName: string | undefined = ( - req.account as { accountName?: string } - )?.accountName; - - if (!creatorAccountName) { - return res - .status(401) - .json({error: "Authenticated account name missing"}); - } - - const subdomain = await DomainService.createDomain( - name, - [], - creatorAccountName, - authStrategy, - ssoConfig, - isVisibleFromOutside - ); - - await DomainService.attachSubdomainToDomain( - domainName as string, - subdomain, - ); - - res.status(200).send(); - } catch (error: any) { - res.status(400).json({error: error.message}); - } + const { domainName } = req.params; + const { name, authStrategy, ssoConfig, isVisibleFromOutside } = req.body; + + const creatorAccountName: string | undefined = ( + req.account as { accountName?: string } + )?.accountName; + + if (!creatorAccountName) { + throw new UnauthorizedError("Missing creator account name"); + } + + const subdomain = await DomainService.createDomain( + name, + [], + creatorAccountName, + authStrategy, + ssoConfig, + isVisibleFromOutside, + ); + + await DomainService.attachSubdomainToDomain(domainName as string, subdomain); + + res.status(200).send(); }; export const getDomainTOTPQr = async (req: Request, res: Response) => { - try { - const {domainName, accountName} = req.params; - const domain = await Domain.findOne({name: domainName as string}).select( - "+totpSecrets", - ); - - if (!domain) return res.status(404).json({error: "Domain not found"}); - - const account = await Account.findOne({name: accountName as string}); - - if (!account) return res.status(404).json({error: "Account not found"}); - - const qrCodes: Record = {}; - const totpSecrets = domain.toObject().totpSecrets; - const accountRolePerDomain = account.memberships.filter(d => d.domainName == domain.name) - - if (accountRolePerDomain.length <= 0 || accountRolePerDomain.length >= 2 || !accountRolePerDomain[0]) - return res.status(404).json({ error: "Account invalid" }); - - for (const [role, secret] of Object.entries(totpSecrets)) { - if (!secret || !hasRequiredRole(accountRolePerDomain[0].role, role as Role)) continue; - qrCodes[role] = new OTPAuth.TOTP({ - issuer: "CrowdVision", - label: `${domainName} (${role})`, - secret: OTPAuth.Secret.fromBase32(secret), - }).toString(); - } - - res.json({qrCodes}); - } catch (error: any) { - res.status(500).json({error: error.message}); - } + const { domainName, accountName } = req.params; + const domain = await Domain.findOne({ name: domainName as string }).select( + "+totpSecrets", + ); + + if (!domain) { + throw new NotFoundError("Domain not found"); + } + + const account = await Account.findOne({ name: accountName as string }); + + if (!account) { + throw new NotFoundError("Account not found"); + } + + const qrCodes: Record = {}; + const totpSecrets = domain.toObject().totpSecrets; + const accountRolePerDomain = account.memberships.filter( + (d) => d.domainName == domain.name, + ); + + if ( + accountRolePerDomain.length <= 0 || + accountRolePerDomain.length >= 2 || + !accountRolePerDomain[0] + ) { + throw new NotFoundError("Account role per domain missing"); + } + + for (const [role, secret] of Object.entries(totpSecrets)) { + if (!secret || !hasRequiredRole(accountRolePerDomain[0].role, role as Role)) + continue; + qrCodes[role] = new OTPAuth.TOTP({ + issuer: "CrowdVision", + label: `${domainName} (${role})`, + secret: OTPAuth.Secret.fromBase32(secret), + }).toString(); + } + + res.json({ qrCodes }); }; diff --git a/server/auth-service/src/index.ts b/server/auth-service/src/index.ts index 579b780..090ab33 100644 --- a/server/auth-service/src/index.ts +++ b/server/auth-service/src/index.ts @@ -6,6 +6,7 @@ import router from "./router.js"; import { connectMongo } from "./config/db.js"; import { getClientUrl } from "./config/config.js"; import cookieParser from "cookie-parser"; +import { globalErrorHandler } from "./middlewares/errorsMiddleware.js"; const PORT = 3000; export const app = express(); @@ -31,6 +32,7 @@ app.use( app.use("/", router); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); +app.use(globalErrorHandler); if (process.env.NODE_ENV !== "test") { connectMongo().then(() => { diff --git a/server/auth-service/src/middlewares/errorsMiddleware.ts b/server/auth-service/src/middlewares/errorsMiddleware.ts new file mode 100644 index 0000000..06ff0e5 --- /dev/null +++ b/server/auth-service/src/middlewares/errorsMiddleware.ts @@ -0,0 +1,21 @@ +import type { Request, Response, NextFunction } from "express"; +import { BaseError } from "../models/error.js"; + +export const globalErrorHandler = ( + err: unknown, + _req: Request, + res: Response, + _next: NextFunction, +) => { + if (err instanceof BaseError) { + return res.status(err.code).json({ + type: err.type, + message: err.message, + }); + } + + return res.status(500).json({ + type: "Internal Server Error", + message: "An unexpected error occurred. Please try again later.", + }); +}; diff --git a/server/auth-service/src/models/domain.ts b/server/auth-service/src/models/domain.ts index a603bbc..14081c6 100644 --- a/server/auth-service/src/models/domain.ts +++ b/server/auth-service/src/models/domain.ts @@ -1,6 +1,6 @@ import { Schema, model, Document, Types } from "mongoose"; -import type { Role } from "./roles.js"; -import { ROLE_WEIGHTS } from "./roles.js"; +import type { Role } from "./role.js"; +import { ROLE_WEIGHTS } from "./role.js"; export interface ISSOConfig { issuerUrl: string; diff --git a/server/auth-service/src/models/error.ts b/server/auth-service/src/models/error.ts new file mode 100644 index 0000000..7c433c7 --- /dev/null +++ b/server/auth-service/src/models/error.ts @@ -0,0 +1,48 @@ +export class BaseError extends Error { + type: string; + code: number; + message: string; + + constructor(type: string, code: number, message: string) { + super(message); + this.type = type; + this.code = code; + this.message = message; + } +} + +export class ValidationError extends BaseError { + constructor(message: string) { + super("Validation Error", 400 , message); + } +} + +export class NotFoundError extends BaseError { + constructor(message: string) { + super("Not Found Error", 404, message); + } +} + +export class InternalError extends BaseError { + constructor(message: string) { + super("Internal Error", 500, message); + } +} + +export class UnauthorizedError extends BaseError { + constructor(message: string) { + super("Unauthorized Error", 401, message); + } +} + +export class ForbiddenError extends BaseError { + constructor(message: string) { + super("Forbidden Error", 403, message); + } +} + +export class ConflictError extends BaseError { + constructor(message: string) { + super("Conflict Error", 409, message); + } +} \ No newline at end of file diff --git a/server/auth-service/src/models/roles.ts b/server/auth-service/src/models/role.ts similarity index 100% rename from server/auth-service/src/models/roles.ts rename to server/auth-service/src/models/role.ts diff --git a/server/auth-service/src/router.ts b/server/auth-service/src/router.ts index f2b0cdb..2451ccc 100644 --- a/server/auth-service/src/router.ts +++ b/server/auth-service/src/router.ts @@ -21,7 +21,7 @@ import { requireHmacSignature, } from "./controller/authenticationMiddleware.js"; import { provideEnterpriseAccount } from "./controller/administrationController.js"; -import { requireAuthorization } from "./models/roles.js"; +import { requireAuthorization } from "./models/role.js"; const router = Router(); diff --git a/server/auth-service/src/services/authenticationService.ts b/server/auth-service/src/services/authenticationService.ts index c8e0f71..a2ac6ed 100644 --- a/server/auth-service/src/services/authenticationService.ts +++ b/server/auth-service/src/services/authenticationService.ts @@ -3,7 +3,12 @@ import * as client from "openid-client"; import { Account } from "../models/account.js"; import { Domain } from "../models/domain.js"; import { getClientUrl, getServerUrl } from "../config/config.js"; -import type { Role } from "../models/roles.js"; +import type { Role } from "../models/role.js"; +import { + ConflictError, + NotFoundError, + ValidationError, +} from "../models/error.js"; export const registerAccount = async ( accountName: string, @@ -13,14 +18,15 @@ export const registerAccount = async ( const account = await Account.findOne({ $or: [{ email }, { name: accountName }], }); + if (account) { - throw new Error("user already exists"); + throw new ConflictError("Account with this email or name already exists"); } const salt = await bcrypt.genSalt(10); const passwordHash = await bcrypt.hash(password, salt); - return await Account.create({ + return Account.create({ name: accountName, email, password: passwordHash, @@ -33,13 +39,14 @@ export const authenticateAccount = async ( password: string, ) => { const account = await Account.findOne({ name: accountName }); + if (!account) { - throw new Error("wrong account name"); + throw new NotFoundError("Account with this name doesn't exist"); } const match = await bcrypt.compare(password, account.password); if (!match) { - throw new Error("wrong password"); + throw new ValidationError("Invalid password"); } return account; @@ -55,7 +62,7 @@ export const generateSSOLoginUrl = async ( ); if (!domain || domain.authStrategy !== "oidc" || !domain.ssoConfig) { - throw new Error("SSO not configured for this domain"); + throw new NotFoundError("SSO not configured for this domain"); } // Discover IdP @@ -91,11 +98,14 @@ export const generateSSOLoginUrl = async ( return redirectUrl.href; }; -export const processSSOCallback = async (fullUrl: string) => { +export const processSSOCallback = async ( + fullUrl: string, +) => { const currentUrl = new URL(fullUrl, getServerUrl()); const stateParam = currentUrl.searchParams.get("state"); + if (!stateParam) { - throw new Error("No state returned from provider"); + throw new ValidationError("Missing state parameter"); } // Decode State @@ -108,7 +118,7 @@ export const processSSOCallback = async (fullUrl: string) => { "+ssoConfig.clientSecret", ); if (!domainDoc || !domainDoc.ssoConfig) { - throw new Error("Invalid domain config"); + throw new NotFoundError("Domain or SSO configuration not found"); } const serverUrl = new URL(domainDoc.ssoConfig.issuerUrl); @@ -127,13 +137,15 @@ export const processSSOCallback = async (fullUrl: string) => { // Map Roles const userGroups = claims ? (claims.groups as string[]) || [] : []; - const role: Role = userGroups.includes("admin") || userGroups.includes("platform-admin") - ? "admin" - : userGroups.includes("business-admin") || userGroups.includes("business_admin") - ? "business_admin" - : userGroups.includes("staff") || userGroups.includes("business-staff") - ? "business_staff" - : "standard_customer"; + const role: Role = + userGroups.includes("admin") || userGroups.includes("platform-admin") + ? "admin" + : userGroups.includes("business-admin") || + userGroups.includes("business_admin") + ? "business_admin" + : userGroups.includes("staff") || userGroups.includes("business-staff") + ? "business_staff" + : "standard_customer"; // Update Account Membership (Upsert) await Account.findOneAndUpdate( diff --git a/server/auth-service/src/services/domainService.ts b/server/auth-service/src/services/domainService.ts index 7e8b56b..ddbbf6c 100644 --- a/server/auth-service/src/services/domainService.ts +++ b/server/auth-service/src/services/domainService.ts @@ -1,6 +1,11 @@ -import { Domain, type IDomain, type ISSOConfig } from "../models/domain.js"; +import { + Domain, + type IDomain, + type ISSOConfig, +} from "../models/domain.js"; import { Account } from "../models/account.js"; import { createTOTPForAuthorizedRoles } from "./totpService.js"; +import { ConflictError, NotFoundError } from "../models/error.js"; export const createDomain = async ( domainName: string, @@ -13,10 +18,10 @@ export const createDomain = async ( const domain = await Domain.findOne({ name: domainName }); if (domain) { - throw new Error("domain already exists"); + throw new ConflictError(`Domain with name "${domainName}" already exists`); } - const totpSecrets = await createTOTPForAuthorizedRoles("business_staff") + const totpSecrets = await createTOTPForAuthorizedRoles("business_staff"); const createdDomain = await Domain.create({ name: domainName, @@ -24,12 +29,16 @@ export const createDomain = async ( authStrategy: authenticationStrategy, ...(authenticationStrategy === "oidc" && ssoConfig ? { ssoConfig } : {}), totpSecrets, - isVisibleFromOutside + isVisibleFromOutside, }); await Account.findOneAndUpdate( { name: creatorAccountName }, - { $push: { memberships: { domainName: domainName, role: "business_admin" } } }, + { + $push: { + memberships: { domainName: domainName, role: "business_admin" }, + }, + }, ); return createdDomain; @@ -40,14 +49,16 @@ export const getAllDomains = async () => { }; export const getAllAllowedDomains = async () => { - return Domain.find({ isVisibleFromOutside: true }).select("-ssoConfig.clientSecret"); -} + return Domain.find({ isVisibleFromOutside: true }).select( + "-ssoConfig.clientSecret", + ); +}; export const getDomainByName = async (domainName: string) => { return Domain.findOne({ name: domainName }).select("-ssoConfig.clientSecret"); }; -export const getDomainSubdomains = async (name: string): Promise => { +export const getDomainSubdomains = async (name: string) => { const result = await Domain.aggregate([ { $match: { name } }, { @@ -67,24 +78,29 @@ export const getDomainSubdomains = async (name: string): Promise => { }, ]); - if (!result[0]) throw new Error(`Domain "${name}" not found`); + if (!result[0]) { + throw new NotFoundError(`Domain with name "${name}" not found`); + } return result[0].names ?? []; }; -export const attachSubdomainToDomain = async (domainName: string, subDomain: IDomain) => { +export const attachSubdomainToDomain = async ( + domainName: string, + subDomain: IDomain, +) => { const parent = await Domain.findOneAndUpdate( { name: domainName }, { $addToSet: { subdomains: subDomain._id } }, ); -} +}; // --- Memberships --- export const getAccountMemberships = async (accountName: string) => { const account = await Account.findOne({ name: accountName }); if (!account) { - throw new Error("account not found"); + throw new NotFoundError(`Account with name "${accountName}" not found`); } return account.memberships; @@ -97,11 +113,13 @@ export const subscribeInternal = async ( const domain = await Domain.findOne({ name: domainName }); if (!domain) { - throw new Error("Domain not found"); + return new NotFoundError(`Domain with name "${domainName}" not found`); } if (domain.authStrategy === "oidc") { - throw new Error("this domain requires SSO login"); + throw new NotFoundError( + `Domain with name "${domainName}" does not support internal subscription`, + ); } await Account.findOneAndUpdate( diff --git a/server/auth-service/src/services/tokenService.ts b/server/auth-service/src/services/tokenService.ts index 447a7e0..0cf2dd7 100644 --- a/server/auth-service/src/services/tokenService.ts +++ b/server/auth-service/src/services/tokenService.ts @@ -1,4 +1,4 @@ -import jwt from "jsonwebtoken"; +import jwt, { type JwtPayload } from "jsonwebtoken"; import type { DeviceTokenPayload, @@ -6,18 +6,21 @@ import type { } from "../models/token.js"; import { getTokenSecret } from "../config/config.js"; import { Account } from "../models/account.js"; +import { InternalError, NotFoundError } from "../models/error.js"; -export const generateStandardToken = async (payload: StandardTokenPayload) => { +export const generateStandardToken = async ( + payload: StandardTokenPayload, +) => { const secret = getTokenSecret(); if (!secret) { - throw new Error("secret configuration error"); + throw new InternalError("Missing token secrets configuration"); } - const account = await Account.findOne({name: payload.accountName}); + const account = await Account.findOne({ name: payload.accountName }); if (!account) { - throw new Error(`Account not found: ${payload.accountName}`); + throw new NotFoundError(`Account with name "${payload.accountName}" does not exist`); } return jwt.sign( @@ -35,7 +38,7 @@ export const generateDeviceToken = (payload: DeviceTokenPayload) => { const secret = getTokenSecret(); if (!secret) { - throw new Error("secret configuration error"); + throw new InternalError("Missing token secrets configuration"); } return jwt.sign( @@ -55,7 +58,7 @@ export const verifyToken = (token: string) => { const secret = getTokenSecret(); if (!secret) { - throw new Error("secret configuration error"); + throw new InternalError("Missing token secrets configuration"); } return jwt.verify(token, secret); diff --git a/server/auth-service/src/services/totpService.ts b/server/auth-service/src/services/totpService.ts index fc1bcce..50550ad 100644 --- a/server/auth-service/src/services/totpService.ts +++ b/server/auth-service/src/services/totpService.ts @@ -1,32 +1,32 @@ -import { hasRequiredRole, type Role, ROLE_WEIGHTS } from "../models/roles.js"; +import { hasRequiredRole, type Role, ROLE_WEIGHTS } from "../models/role.js"; import * as OTPAuth from "otpauth"; import { Domain, type IDomainMembership } from "../models/domain.js"; import { Account } from "../models/account.js"; import { Types } from "mongoose"; +import { NotFoundError } from "../models/error.js"; -export const resolveRoleFromOTP = async ( - otp: string, -) => { - try { - const domains = await Domain.find().select("+totpSecrets"); +export const resolveRoleFromOTP = async (otp: string) => { + const domains = await Domain.find().select("+totpSecrets"); - if (!domains || domains.length <= 0) { - return null; - } + if (!domains || domains.length <= 0) { + throw new NotFoundError("Domain or SSO configuration not found"); + } - for (const domain of domains) { - for (const [role, secret] of Object.entries(domain.totpSecrets)) { - const totp = new OTPAuth.TOTP({ - secret: OTPAuth.Secret.fromBase32(secret), - }); - if (totp.validate({ token: otp, window: 1 }) !== null) { - return {domainName: domain.name, role: role as Role} as IDomainMembership; - } + for (const domain of domains) { + for (const [role, secret] of Object.entries(domain.totpSecrets)) { + const totp = new OTPAuth.TOTP({ + secret: OTPAuth.Secret.fromBase32(secret), + }); + if (totp.validate({ token: otp, window: 1 }) !== null) { + return { + domainName: domain.name, + role: role as Role, + } as IDomainMembership } } - } catch (error) { - return null; } + + throw new NotFoundError("Invalid OTP provided"); }; export const createTOTPForAuthorizedRoles = async (minimumRole: Role) => { From 52467df8abea3178fd86e0bfbe34e050135615e2 Mon Sep 17 00:00:00 2001 From: NickGhignatti Date: Sat, 28 Mar 2026 16:05:28 +0100 Subject: [PATCH 3/4] fix: remove fetch for using the useApi composable --- .../src/components/PushNotificationToast.vue | 4 +-- .../__tests__/PushNotificationToast.spec.ts | 2 +- ...pec.ts => useWebPushNotifications.spec.ts} | 17 ++++++----- client/src/composables/useBuildingModel.ts | 1 - ...{usePush.ts => useWebPushNotifications.ts} | 29 +++++++++++-------- client/src/stores/domain.ts | 4 +-- 6 files changed, 31 insertions(+), 26 deletions(-) rename client/src/composables/__tests__/{usePush.spec.ts => useWebPushNotifications.spec.ts} (89%) rename client/src/composables/{usePush.ts => useWebPushNotifications.ts} (67%) diff --git a/client/src/components/PushNotificationToast.vue b/client/src/components/PushNotificationToast.vue index d2dafee..969dce8 100644 --- a/client/src/components/PushNotificationToast.vue +++ b/client/src/components/PushNotificationToast.vue @@ -1,10 +1,10 @@