Skip to content

Cost auth - #35

Open
louistrue wants to merge 2 commits into
mainfrom
cost-auth
Open

Cost auth#35
louistrue wants to merge 2 commits into
mainfrom
cost-auth

Conversation

@louistrue

@louistrue louistrue commented Aug 29, 2025

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Single sign-on with auto-login and role-based access.
    • Project-level permissions for reads/writes/deletes.
    • Guarded routing with protected /plugin-cost and unauthorized screen.
    • Loading screen during authentication.
    • Authenticated API client with token-based requests and exports/imports support.
  • Changes

    • All backend routes require authentication; health is public.
    • /projects and key endpoints now permission-gated; admins see all projects.
    • Standalone app now uses authenticated entry point.
  • Configuration

    • New settings for identity provider and project service URL.
  • Chores

    • Added auth-related dependencies (OIDC/JWT/JWKS, axios).

…hentication. Added support for Keycloak integration and improved project service URL handling. Refactored API context to utilize shared authentication and streamlined project element fetching logic.
…d-auth path and update various package versions. Removed extraneous dependencies and improved dependency resolution for better project structure.
@coderabbitai

coderabbitai Bot commented Aug 29, 2025

Copy link
Copy Markdown

Walkthrough

Adds JWT-based auth/authorization middleware with Keycloak JWKS verification, extends backend config, updates dependencies, guards backend routes, introduces a frontend authenticated app shell with role-based routing, creates an authenticated API client, and refactors ApiContext to a minimal, token-enabled client provider.

Changes

Cohort / File(s) Summary
Backend auth middleware
backend/auth-middleware.ts
New JWT/JWKS auth module: Request typing, User/UserProject interfaces, middleware factory, role guards (read/write/delete), project-access checker via project service, and project-access middleware.
Backend config
backend/config.ts
Adds keycloak (authority, clientId, clientSecret) and projectService (url) to exported config, sourced from env with defaults.
Backend deps
backend/package.json
Adds runtime deps: axios, jsonwebtoken, jwks-rsa; devDep: @types/jsonwebtoken.
Server route guards
backend/server.ts
Wires global auth, optional on /health. Adds permission checks to routes, admin handling for /projects, project-level access for /project-elements/:projectName, write checks on POST routes.
Root deps
package.json
Adds @nhmzh/shared-auth (file:../shared/NHMzh-Shared_Auth), oidc-client-ts, react-oidc-context.
Frontend auth shell
src/AuthenticatedApp.tsx, src/StandaloneApp.tsx
Introduces authenticated app wrapper (auto-login, role-guarded routes, unauthorized screen). StandaloneApp now renders AuthenticatedApp directly.
API client (authenticated)
src/api/AuthenticatedApiClient.ts
New axios client with injected token getter, request/response interceptors, and cost-related endpoints (projects, costs CRUD, templates, EBKP, materials, export/import, health). Exports setter and singleton client.
API context refactor
src/contexts/ApiContext.tsx
Simplifies context to { apiClient }, configures token getter via useAuth, adds guarded useApi hook, removes prior data/state methods.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor U as User
  participant FE as Frontend (AuthenticatedApp)
  participant Auth as Auth Provider
  participant API as AuthenticatedApiClient
  participant BE as Backend (Express)
  participant KC as Keycloak JWKS

  U->>FE: Navigate to app
  FE->>Auth: Initialize (autoLogin)
  Auth-->>FE: isAuthenticated? token?
  FE->>API: setAuthTokenGetter(getAccessToken)
  U->>FE: Request protected route
  FE->>API: GET /projects (with Bearer token)
  API->>BE: /projects
  BE->>BE: authMiddleware verifies JWT
  BE->>KC: Fetch signing key (JWKS) if needed
  KC-->>BE: Public key
  BE->>BE: Validate issuer/audience, extract roles
  BE->>BE: requireReadPermission
  BE-->>API: 200 OK (projects)
  API-->>FE: Projects data
  FE-->>U: Render view
Loading
sequenceDiagram
  autonumber
  actor U as User
  participant FE as Frontend
  participant API as AuthenticatedApiClient
  participant BE as Backend
  participant PS as Project Service

  U->>FE: Open project elements
  FE->>API: GET /project-elements/:projectId
  API->>BE: /project-elements/:projectId (Bearer token)
  BE->>BE: authMiddleware + requireProjectAccess('read')
  alt Non-admin user
    BE->>PS: GET /users/me/projects (Bearer token)
    PS-->>BE: User projects + roles
    BE->>BE: Check action roles for project
    alt Authorized
      BE-->>API: 200 OK
    else Unauthorized
      BE-->>API: 403 Forbidden
    end
  else Admin user
    BE-->>API: 200 OK
  end
  API-->>FE: Response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

A rabbit with keys in a meadow of roles,
Nibbles JWTs, guards backend goals.
Frontend burrow checks who may pass,
Tokens shimmer like dew on grass.
Projects unlock with a gentle hop—
Auth in the pouch, we’re ready to ship! 🐇🔐

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cost-auth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbit in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbit in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbit gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbit read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbit help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbit ignore or @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbit summary or @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbit or @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
backend/server.ts (5)

216-233: Guard get-kennwerte with read + project access.

Currently only global auth applies; users with generic plugin access can fetch any project’s kennwerte. Enforce read permission and project-level read.

 app.get("/get-kennwerte/:projectName", 
   param('projectName').trim().notEmpty().withMessage('Project name is required'),
   handleValidationErrors,
   haltOnTimedout,
+  requireReadPermission,
+  requireProjectAccess('read'),
   async (req: Request, res: Response) => {

235-266: Enforce per-project write access on save-kennwerte.

Without project-level checks, a user with write role could write to any project.

 app.post("/save-kennwerte", 
   strictLimiter, // Apply strict rate limiting for write operations
   body('projectName').trim().notEmpty().withMessage('Project name is required'),
   body('kennwerte').isObject().withMessage('Kennwerte must be an object'),
   handleValidationErrors,
   haltOnTimedout,
-  requireWritePermission,
+  requireWritePermission,
+  requireProjectAccess('write'),
   async (req: Request, res: Response) => {

268-280: Enforce per-project write access on reapply-costs.

Same security gap as above.

 app.post("/reapply-costs", 
   strictLimiter,
   body('projectName').trim().notEmpty().withMessage('Project name is required'),
   handleValidationErrors,
   haltOnTimedout,
-  requireWritePermission,
+  requireWritePermission,
+  requireProjectAccess('write'),
   async (req: Request, res: Response) => {

293-301: Require project and enforce per-project write on confirm-costs.

Project is optional and there is no project access check. This enables sending costs for arbitrary/unknown projects.

 app.post("/confirm-costs", 
   strictLimiter,
   body('data').isArray().withMessage('Data must be an array'),
   body('data.*.id').notEmpty().withMessage('Each element must have an id'),
-  body('project').optional().trim().notEmpty(),
+  body('project').trim().notEmpty().withMessage('Project is required'),
   handleValidationErrors,
   haltOnTimedout,
-  requireWritePermission,
+  requireWritePermission,
+  requireProjectAccess('write'),
   async (req: Request<{}, {}, KafkaMessageBody>, res: Response) => {

159-165: Include projectName in access middleware

In backend/auth-middleware.ts, update the param extraction to also read the route’s projectName:

-    const projectId = req.params.projectId || req.params.project_name;
+    const projectId = req.params.projectId
+      || req.params.project_name
+      || req.params.projectName;
🧹 Nitpick comments (12)
backend/package.json (1)

23-25: Enforce JWKS rate limiting and clock tolerance

JWKS client is configured with caching (cacheMaxEntries/cacheMaxAge) but missing rate limiting—add rateLimit: true and jwksRequestsPerMinute in the jwksClient() options (backend/auth-middleware.ts). jwt.verify already enforces exp/nbf; consider adding a small clockTolerance (e.g., 5s) to accommodate clock skew.

package.json (1)

18-24: Validate auth UI deps and workspace path.

  • @nhmzh/shared-auth via file: path: confirm this resolves in CI and publish workflows; consider using workspaces ("workspace:*") if you’re on npm/pnpm workspaces.
  • oidc-client-ts/react-oidc-context: check peer deps align with React 18 and React Router v7 usage in the app shell.
    If you want, I can add a quick script to verify peer-dep compatibility.
backend/config.ts (1)

21-28: Prod safety: avoid http default authority and require https unless explicitly disabled.

Defaulting to http for KEYCLOAK_AUTHORITY is risky in prod. Recommend:

  • Fail fast (or log a loud warning) if NODE_ENV=production and authority is http.
  • Add KEYCLOAK_REQUIRE_HTTPS (default true) and enforce it.

I can add a small guard if you want.

src/api/AuthenticatedApiClient.ts (5)

36-39: Gate request logging to dev.

Avoid noisy logs in prod and possible URL leakage.

-      console.debug(`Making authenticated request to: ${config.url}`);
+      if (import.meta.env.DEV) console.debug(`[cost-api] -> ${config.method?.toUpperCase()} ${config.url}`);

47-61: Surface 401/403 to the auth layer with a callback.

Consider accepting an optional onAuthError callback (set alongside the token getter) to centralize re-login/refresh and user messaging.

I can add a small hook-based notifier if desired.


121-136: Strongly type payloads and responses.

Replace any with typed DTOs and use Axios generics:

-  async saveCostCalculations(projectId: string, calculations: any): Promise<any> {
+  async saveCostCalculations(projectId: string, calculations: CostCalculationsDto): Promise<SaveResult> {
     const response = await this.axiosInstance.post<SaveResult>(

I can sketch DTOs if you share backend response shapes.


172-190: Type the blob response explicitly.

Small improvement:

-      const response = await this.axiosInstance.get(
+      const response = await this.axiosInstance.get<Blob>(
         `/projects/${encodeURIComponent(projectId)}/costs/export`,

279-281: Consider lazy/factory init for testability and token injection order.

Export a factory or lazy singleton to avoid constructing the client before setAuthTokenGetter is registered and to ease testing.

I can provide a minimal refactor if wanted.

src/AuthenticatedApp.tsx (2)

49-51: Avoid indefinite spinner when auth fails.

If auto-login fails or the session is invalid, this returns LoadingScreen forever. Consider handling an error state from useAuth() (if exposed) to redirect to /unauthorized or surface a retry/login action.


54-70: Optional: add a catch-all route to prevent accidental blank screens.

Redirect unknown paths to /plugin-cost or show a 404.

       <Route path="/unauthorized" element={<UnauthorizedScreen />} />
       <Route path="/" element={<Navigate to="/plugin-cost" replace />} />
+      <Route path="*" element={<Navigate to="/plugin-cost" replace />} />
src/contexts/ApiContext.tsx (1)

1-1: Stabilize context value to avoid unnecessary re-renders.

Memoize the provider value so downstream consumers don’t re-render on every parent render.

-import React, { createContext, useContext, useEffect } from 'react';
+import React, { createContext, useContext, useEffect, useMemo } from 'react';
@@
-  return (
-    <ApiContext.Provider value={{ apiClient: authenticatedCostApiClient }}>
+  const value = useMemo(() => ({ apiClient: authenticatedCostApiClient }), []);
+  return (
+    <ApiContext.Provider value={value}>
       {children}
     </ApiContext.Provider>
   );

Also applies to: 20-24

backend/auth-middleware.ts (1)

38-49: Single source of truth for roles across FE/BE.

These role lists are duplicated in the frontend (ProtectedRoute). Publish shared role constants (e.g., a small shared package or generated types) to prevent drift.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a2ace92 and b7d9602.

⛔ Files ignored due to path filters (2)
  • backend/package-lock.json is excluded by !**/package-lock.json
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (9)
  • backend/auth-middleware.ts (1 hunks)
  • backend/config.ts (1 hunks)
  • backend/package.json (1 hunks)
  • backend/server.ts (7 hunks)
  • package.json (1 hunks)
  • src/AuthenticatedApp.tsx (1 hunks)
  • src/StandaloneApp.tsx (1 hunks)
  • src/api/AuthenticatedApiClient.ts (1 hunks)
  • src/contexts/ApiContext.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
backend/auth-middleware.ts (1)
backend/config.ts (1)
  • config (13-46)
backend/server.ts (2)
backend/auth-middleware.ts (5)
  • authMiddleware (144-202)
  • requireReadPermission (205-220)
  • User (22-29)
  • requireProjectAccess (302-323)
  • requireWritePermission (222-237)
backend/mongodb.ts (1)
  • getAllProjects (1264-1287)
src/contexts/ApiContext.tsx (1)
src/api/AuthenticatedApiClient.ts (1)
  • setAuthTokenGetter (10-12)
🔇 Additional comments (3)
backend/package.json (1)

32-32: Good: add typings for jsonwebtoken.

This prevents ad-hoc any usage around token claims.

src/StandaloneApp.tsx (1)

2-5: LGTM: delegate to AuthenticatedApp.

Clean handoff; reduces routing/auth duplication.

src/contexts/ApiContext.tsx (1)

15-19: Confirm timing: token getter set before first API call.

setAuthTokenGetter runs in useEffect (post-mount). If any child fires a request on initial render, it could miss the getter. Mounting ApiProvider under AuthProvider (as suggested) helps, but please confirm no immediate API calls occur before effects run.

Comment on lines +55 to +67
let jwksClientInstance: jwksClient.JwksClient | null = null;

const getJwksClient = (): jwksClient.JwksClient => {
if (!jwksClientInstance) {
const authority = config.keycloak.authority;
jwksClientInstance = jwksClient({
jwksUri: `${authority}/protocol/openid-connect/certs`,
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 10 * 60 * 1000, // 10 minutes
});
}
return jwksClientInstance;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Harden JWKS client configuration.

Enable rate limiting to protect your JWKS endpoint and rely on the client’s internal cache (you can drop the custom keyCache if desired).

-  jwksClientInstance = jwksClient({
+  jwksClientInstance = jwksClient({
     jwksUri: `${authority}/protocol/openid-connect/certs`,
-    cache: true,
+    cache: true,
+    rateLimit: true,
     cacheMaxEntries: 5,
     cacheMaxAge: 10 * 60 * 1000, // 10 minutes
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let jwksClientInstance: jwksClient.JwksClient | null = null;
const getJwksClient = (): jwksClient.JwksClient => {
if (!jwksClientInstance) {
const authority = config.keycloak.authority;
jwksClientInstance = jwksClient({
jwksUri: `${authority}/protocol/openid-connect/certs`,
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 10 * 60 * 1000, // 10 minutes
});
}
return jwksClientInstance;
let jwksClientInstance: jwksClient.JwksClient | null = null;
const getJwksClient = (): jwksClient.JwksClient => {
if (!jwksClientInstance) {
const authority = config.keycloak.authority;
jwksClientInstance = jwksClient({
jwksUri: `${authority}/protocol/openid-connect/certs`,
cache: true,
rateLimit: true,
cacheMaxEntries: 5,
cacheMaxAge: 10 * 60 * 1000, // 10 minutes
});
}
return jwksClientInstance;
};
🤖 Prompt for AI Agents
In backend/auth-middleware.ts around lines 55 to 67, the jwksClient constructor
should be hardened by enabling the built-in rate limiter and relying on the
client's internal cache instead of any external/custom keyCache; update the
options to include rateLimit: true (and optionally a reasonable
jwksRequestsPerMinute limit if supported by your jwks client version) and remove
or stop using any external keyCache logic so the client uses its own cache and
rate limiting to protect the JWKS endpoint.

Comment on lines +256 to +299
// Project service integration
export const checkProjectAccess = async (
projectId: string,
action: 'read' | 'write' | 'delete',
user: User,
token: string
): Promise<boolean> => {
// Admin users have access to all projects
if (user.roles.includes('Admin')) {
return true;
}

try {
// Fetch user's projects from the project service
const projectServiceUrl = config.projectService?.url || 'http://localhost:3001';
const response = await axios.get(`${projectServiceUrl}/api/projects/my-projects`, {
headers: {
'Authorization': `Bearer ${token}`
}
});

const projects = response.data.projects || [];

// Check if user has access to this project
for (const project of projects) {
if (project.projectId === projectId || project.erzProjectCode === projectId) {
const projectRoles = project.roles || [];

if (action === 'read') {
return projectRoles.some((role: string) => PLUGIN_ROLES.read.includes(role));
} else if (action === 'write') {
return projectRoles.some((role: string) => PLUGIN_ROLES.write.includes(role));
} else if (action === 'delete') {
return projectRoles.some((role: string) => PLUGIN_ROLES.delete.includes(role));
}
}
}

return false;
} catch (error) {
logger.error('Error checking project access:', error);
return false;
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

checkProjectAccess: add timeout and accept additional identifiers.

  • External call lacks a timeout; a slow project service will stall requests.
  • If routes pass projectName today, also match by project.name to avoid false denials (until the API standardizes on projectId/erzProjectCode).
-    const response = await axios.get(`${projectServiceUrl}/api/projects/my-projects`, {
-      headers: {
-        'Authorization': `Bearer ${token}`
-      }
-    });
+    const response = await axios.get(`${projectServiceUrl}/api/projects/my-projects`, {
+      headers: { 'Authorization': `Bearer ${token}` },
+      timeout: 3000,
+    });
@@
-      if (project.projectId === projectId || project.erzProjectCode === projectId) {
+      if (
+        project.projectId === projectId ||
+        project.erzProjectCode === projectId ||
+        project.name === projectId
+      ) {

Longer-term: standardize all backend routes and frontend clients to use a canonical project identifier (projectId or erzProjectCode) and remove the name fallback.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Project service integration
export const checkProjectAccess = async (
projectId: string,
action: 'read' | 'write' | 'delete',
user: User,
token: string
): Promise<boolean> => {
// Admin users have access to all projects
if (user.roles.includes('Admin')) {
return true;
}
try {
// Fetch user's projects from the project service
const projectServiceUrl = config.projectService?.url || 'http://localhost:3001';
const response = await axios.get(`${projectServiceUrl}/api/projects/my-projects`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const projects = response.data.projects || [];
// Check if user has access to this project
for (const project of projects) {
if (project.projectId === projectId || project.erzProjectCode === projectId) {
const projectRoles = project.roles || [];
if (action === 'read') {
return projectRoles.some((role: string) => PLUGIN_ROLES.read.includes(role));
} else if (action === 'write') {
return projectRoles.some((role: string) => PLUGIN_ROLES.write.includes(role));
} else if (action === 'delete') {
return projectRoles.some((role: string) => PLUGIN_ROLES.delete.includes(role));
}
}
}
return false;
} catch (error) {
logger.error('Error checking project access:', error);
return false;
}
};
// Project service integration
export const checkProjectAccess = async (
projectId: string,
action: 'read' | 'write' | 'delete',
user: User,
token: string
): Promise<boolean> => {
// Admin users have access to all projects
if (user.roles.includes('Admin')) {
return true;
}
try {
// Fetch user's projects from the project service
const projectServiceUrl = config.projectService?.url || 'http://localhost:3001';
const response = await axios.get(`${projectServiceUrl}/api/projects/my-projects`, {
headers: { 'Authorization': `Bearer ${token}` },
timeout: 3000,
});
const projects = response.data.projects || [];
// Check if user has access to this project
for (const project of projects) {
if (
project.projectId === projectId ||
project.erzProjectCode === projectId ||
project.name === projectId
) {
const projectRoles = project.roles || [];
if (action === 'read') {
return projectRoles.some((role: string) => PLUGIN_ROLES.read.includes(role));
} else if (action === 'write') {
return projectRoles.some((role: string) => PLUGIN_ROLES.write.includes(role));
} else if (action === 'delete') {
return projectRoles.some((role: string) => PLUGIN_ROLES.delete.includes(role));
}
}
}
return false;
} catch (error) {
logger.error('Error checking project access:', error);
return false;
}
};
🤖 Prompt for AI Agents
In backend/auth-middleware.ts around lines 256 to 299, the external
project-service GET call has no timeout and the project matching only checks
projectId/erzProjectCode; update the axios request to include a reasonable
timeout (e.g., 3–5s) in the options object and keep the Authorization header,
and extend the matching logic so a project also matches when project.name ===
projectId (to accept routes that pass a projectName until the API standardizes);
preserve existing role checks and error handling when applying these changes.

Comment on lines +301 to +323
// Middleware to check project-specific access
export const requireProjectAccess = (action: 'read' | 'write' | 'delete' = 'read') => {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !req.token) {
return res.status(401).json({ error: 'User not authenticated' });
}

const projectId = req.params.projectId || req.params.project_name;
if (!projectId) {
return res.status(400).json({ error: 'Project ID is required' });
}

const hasAccess = await checkProjectAccess(projectId, action, req.user, req.token);

if (!hasAccess) {
return res.status(403).json({
error: `You don't have ${action} permission for project: ${projectId}`
});
}

next();
};
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

requireProjectAccess: read common parameter names and body/query fallbacks.

Routes currently pass projectName; this middleware won’t find it. Until the API is standardized, support multiple keys.

-export const requireProjectAccess = (action: 'read' | 'write' | 'delete' = 'read') => {
+export const requireProjectAccess = (action: 'read' | 'write' | 'delete' = 'read') => {
   return async (req: Request, res: Response, next: NextFunction) => {
@@
-    const projectId = req.params.projectId || req.params.project_name;
-    if (!projectId) {
+    const projectId =
+      (req.params as any).projectId ||
+      (req.params as any).project_name ||
+      (req.params as any).projectName ||
+      (req.body as any)?.project ||
+      (req.body as any)?.projectName ||
+      (typeof (req.query as any)?.project === 'string' ? (req.query as any).project : undefined);
+    if (!projectId) {
       return res.status(400).json({ error: 'Project ID is required' });
     }
 
-    const hasAccess = await checkProjectAccess(projectId, action, req.user, req.token);
+    const hasAccess = await checkProjectAccess(projectId, action, req.user, req.token);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Middleware to check project-specific access
export const requireProjectAccess = (action: 'read' | 'write' | 'delete' = 'read') => {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !req.token) {
return res.status(401).json({ error: 'User not authenticated' });
}
const projectId = req.params.projectId || req.params.project_name;
if (!projectId) {
return res.status(400).json({ error: 'Project ID is required' });
}
const hasAccess = await checkProjectAccess(projectId, action, req.user, req.token);
if (!hasAccess) {
return res.status(403).json({
error: `You don't have ${action} permission for project: ${projectId}`
});
}
next();
};
};
// Middleware to check project-specific access
export const requireProjectAccess = (action: 'read' | 'write' | 'delete' = 'read') => {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !req.token) {
return res.status(401).json({ error: 'User not authenticated' });
}
const projectId =
(req.params as any).projectId ||
(req.params as any).project_name ||
(req.params as any).projectName ||
(req.body as any)?.project ||
(req.body as any)?.projectName ||
(typeof (req.query as any)?.project === 'string' ? (req.query as any).project : undefined);
if (!projectId) {
return res.status(400).json({ error: 'Project ID is required' });
}
const hasAccess = await checkProjectAccess(projectId, action, req.user, req.token);
if (!hasAccess) {
return res.status(403).json({
error: `You don't have ${action} permission for project: ${projectId}`
});
}
next();
};
};
🤖 Prompt for AI Agents
In backend/auth-middleware.ts around lines 301 to 323, the middleware only reads
req.params.projectId or req.params.project_name so it will miss routes that send
projectName or put the identifier in body/query; update the projectId resolution
to check common keys in params, body and query (e.g. projectId, project_name,
projectName, project) and normalize the value into a single projectId variable
before validation, making sure to coerce to string and handle missing/empty
values the same way as now; then pass that normalized projectId to
checkProjectAccess.

Comment thread backend/config.ts
Comment on lines +21 to +28
keycloak: {
authority: process.env.KEYCLOAK_AUTHORITY || 'http://localhost:8080/realms/nhmzh',
clientId: process.env.KEYCLOAK_CLIENT_ID || 'plugin-cost',
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
},
projectService: {
url: process.env.PROJECT_SERVICE_URL || 'http://localhost:3001',
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Add explicit issuer/audience and operational knobs.

Right now only authority/clientId are exposed. For robust verification and ops, include:

  • issuer (often same as authority, but explicit)
  • audience (expected aud/azp)
  • optional jwksUri override
  • small clock tolerance
  • projectService timeout

Apply this patch:

   keycloak: {
-    authority: process.env.KEYCLOAK_AUTHORITY || 'http://localhost:8080/realms/nhmzh',
-    clientId: process.env.KEYCLOAK_CLIENT_ID || 'plugin-cost',
-    clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
+    authority: process.env.KEYCLOAK_AUTHORITY || 'http://localhost:8080/realms/nhmzh',
+    issuer: process.env.KEYCLOAK_ISSUER || process.env.KEYCLOAK_AUTHORITY || 'http://localhost:8080/realms/nhmzh',
+    audience: process.env.KEYCLOAK_AUDIENCE || process.env.KEYCLOAK_CLIENT_ID || 'plugin-cost',
+    jwksUri: process.env.KEYCLOAK_JWKS_URI, // optional override
+    clockToleranceSec: parseInt(process.env.KEYCLOAK_CLOCK_TOLERANCE_SEC || '60'),
+    clientId: process.env.KEYCLOAK_CLIENT_ID || 'plugin-cost',
+    clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, // likely unused for RS256 verify; keep if introspection is needed
   },
   projectService: {
-    url: process.env.PROJECT_SERVICE_URL || 'http://localhost:3001',
+    url: process.env.PROJECT_SERVICE_URL || 'http://localhost:3001',
+    timeoutMs: parseInt(process.env.PROJECT_SERVICE_TIMEOUT_MS || '5000'),
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
keycloak: {
authority: process.env.KEYCLOAK_AUTHORITY || 'http://localhost:8080/realms/nhmzh',
clientId: process.env.KEYCLOAK_CLIENT_ID || 'plugin-cost',
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
},
projectService: {
url: process.env.PROJECT_SERVICE_URL || 'http://localhost:3001',
},
keycloak: {
authority: process.env.KEYCLOAK_AUTHORITY || 'http://localhost:8080/realms/nhmzh',
issuer: process.env.KEYCLOAK_ISSUER || process.env.KEYCLOAK_AUTHORITY || 'http://localhost:8080/realms/nhmzh',
audience: process.env.KEYCLOAK_AUDIENCE || process.env.KEYCLOAK_CLIENT_ID || 'plugin-cost',
jwksUri: process.env.KEYCLOAK_JWKS_URI, // optional override
clockToleranceSec: parseInt(process.env.KEYCLOAK_CLOCK_TOLERANCE_SEC || '60'),
clientId: process.env.KEYCLOAK_CLIENT_ID || 'plugin-cost',
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET, // likely unused for RS256 verify; keep if introspection is needed
},
projectService: {
url: process.env.PROJECT_SERVICE_URL || 'http://localhost:3001',
timeoutMs: parseInt(process.env.PROJECT_SERVICE_TIMEOUT_MS || '5000'),
},
🤖 Prompt for AI Agents
In backend/config.ts around lines 21 to 28, the Keycloak and projectService
config only expose authority/clientId/clientSecret and url; add explicit issuer
(default to authority), audience (default to clientId), an optional jwksUri
override, and a small clockToleranceSeconds (e.g. 5) to support token
verification tolerance, all wired to env vars (KEYCLOAK_ISSUER,
KEYCLOAK_AUDIENCE, KEYCLOAK_JWKS_URI, KEYCLOAK_CLOCK_TOLERANCE). Also add a
projectService.timeoutSecs (env PROJECT_SERVICE_TIMEOUT_SECS with a sensible
default like 5) so callers can use a request timeout. Ensure types/defaults use
existing pattern (process.env... || fallback) and keep keys under keycloak and
projectService objects.

Comment thread backend/server.ts
Comment on lines +100 to +105
// --- Authentication Middleware ---
// Apply authentication to all routes except health check
app.use(authMiddleware({ optional: false }));
// Skip authentication for health endpoint
app.use("/health", authMiddleware({ optional: true }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

/health is still protected due to middleware order.

The global auth middleware runs for /health before the optional one, so health checks return 401/403. Skip /health in the global gate.

-// --- Authentication Middleware ---
-// Apply authentication to all routes except health check
-app.use(authMiddleware({ optional: false }));
-// Skip authentication for health endpoint
-app.use("/health", authMiddleware({ optional: true }));
+// --- Authentication Middleware ---
+// Apply authentication to all routes, but skip /health
+app.use((req, res, next) =>
+  req.path === '/health'
+    ? next()
+    : authMiddleware({ optional: false })(req, res, next)
+);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// --- Authentication Middleware ---
// Apply authentication to all routes except health check
app.use(authMiddleware({ optional: false }));
// Skip authentication for health endpoint
app.use("/health", authMiddleware({ optional: true }));
// --- Authentication Middleware ---
// Apply authentication to all routes, but skip /health
app.use((req, res, next) =>
req.path === '/health'
? next()
: authMiddleware({ optional: false })(req, res, next)
);
🤖 Prompt for AI Agents
In backend/server.ts around lines 100 to 105, the global auth middleware is
registered before the health-specific optional middleware so /health is still
protected; change the order or exclude the /health path from the global gate:
register the /health route (or its authMiddleware({ optional: true })) before
app.use(authMiddleware({ optional: false })) OR update the global middleware to
skip the /health path (e.g., check req.path and call next() for "/health") so
the health endpoint is not authenticated.

Comment on lines +19 to +23
baseURL: process.env.NODE_ENV === 'production'
? '/api'
: 'http://localhost:3004',
timeout: 30000,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix: process.env is not reliable in Vite browser builds.

Vite uses import.meta.env.PROD/DEV. Using process.env.NODE_ENV can throw at runtime in the browser.

Apply:

-    baseURL: process.env.NODE_ENV === 'production' 
+    baseURL: import.meta.env.PROD 
       ? '/api' 
       : 'http://localhost:3004',
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
baseURL: process.env.NODE_ENV === 'production'
? '/api'
: 'http://localhost:3004',
timeout: 30000,
});
baseURL: import.meta.env.PROD
? '/api'
: 'http://localhost:3004',
timeout: 30000,
});
🤖 Prompt for AI Agents
In src/api/AuthenticatedApiClient.ts around lines 19 to 23, the code uses
process.env.NODE_ENV to choose the baseURL which is unreliable in Vite browser
builds; replace that check with Vite's import.meta.env (use import.meta.env.PROD
or import.meta.env.DEV) to determine production vs development and set baseURL
accordingly, ensuring any TypeScript typings for import.meta.env are present in
the project (or cast/guard as needed) so the code won't reference process.env at
runtime in the browser.

Comment on lines +198 to +211
async importCostData(projectId: string, file: File): Promise<any> {
try {
const formData = new FormData();
formData.append('file', file);

const response = await this.axiosInstance.post(
`/projects/${encodeURIComponent(projectId)}/costs/import`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: don’t set multipart Content-Type manually (boundary gets lost).

Browsers must set the boundary; setting Content-Type breaks uploads.

-      const response = await this.axiosInstance.post(
+      const response = await this.axiosInstance.post(
         `/projects/${encodeURIComponent(projectId)}/costs/import`,
         formData,
         {
-          headers: {
-            'Content-Type': 'multipart/form-data'
-          }
+          // Let the browser set multipart boundary automatically
         }
       );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async importCostData(projectId: string, file: File): Promise<any> {
try {
const formData = new FormData();
formData.append('file', file);
const response = await this.axiosInstance.post(
`/projects/${encodeURIComponent(projectId)}/costs/import`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
async importCostData(projectId: string, file: File): Promise<any> {
try {
const formData = new FormData();
formData.append('file', file);
const response = await this.axiosInstance.post(
`/projects/${encodeURIComponent(projectId)}/costs/import`,
formData,
{
// Let the browser set multipart boundary automatically
}
);
🤖 Prompt for AI Agents
In src/api/AuthenticatedApiClient.ts around lines 198 to 211, the code manually
sets 'Content-Type: multipart/form-data' which removes the required boundary and
breaks uploads; remove that manual header so the browser (or axios when provided
FormData) can set the Content-Type with the correct boundary automatically —
simply omit the headers object (or at least the Content-Type entry) when posting
the FormData.

Comment thread src/AuthenticatedApp.tsx
Comment on lines +30 to +38
<Typography variant="h4" color="error">
Access Denied
</Typography>
<Typography variant="body1" sx={{ mt: 2 }}>
You don't have permission to access this application.
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
Required roles: Admin, Fachplanung_Kosten, or Viewer
</Typography>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Fix role list drift between UI text and gate; centralize READ_ROLES.

UnauthorizedScreen shows only three roles, but ProtectedRoute requires eight. This will confuse users and drift over time. Define a single READ_ROLES constant and reuse it for both the text and the gate.

@@
-import { CircularProgress, Box, Typography } from '@mui/material';
+import { CircularProgress, Box, Typography } from '@mui/material';
+
+// Single source of truth for read roles (keep in sync with backend PLUGIN_ROLES.read)
+const READ_ROLES = [
+  'Admin',
+  'Fachplanung_Kosten',
+  'Projektleitung_Architektur',
+  'Projektleitung_Statik',
+  'Projektleitung_Gebaudetechnik',
+  'Fachplanung_Oekobilanz',
+  'Fachplanung_Gebaudetechnik',
+  'Viewer',
+];
@@
-    <Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
-      Required roles: Admin, Fachplanung_Kosten, or Viewer
-    </Typography>
+    <Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
+      Required roles: {READ_ROLES.join(', ')}
+    </Typography>
@@
-          <ProtectedRoute
-            requiredRoles={['Admin', 'Fachplanung_Kosten', 'Projektleitung_Architektur', 
-                           'Projektleitung_Statik', 'Projektleitung_Gebaudetechnik', 
-                           'Fachplanung_Oekobilanz', 'Fachplanung_Gebaudetechnik', 'Viewer']}
+          <ProtectedRoute
+            requiredRoles={READ_ROLES}
             fallbackPath="/unauthorized"
           >

Also applies to: 58-66, 6-7

Comment thread src/AuthenticatedApp.tsx
Comment on lines +76 to +80
<BrowserRouter>
<AuthProvider appName="plugin-cost" autoLogin={true}>
<AuthenticatedContent />
</AuthProvider>
</BrowserRouter>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Mount ApiProvider under AuthProvider so API calls always have a token.

Ensure the authenticated API client is available app-wide and receives the token getter before children run.

@@
-import { FC } from 'react';
+import { FC } from 'react';
 import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
 import { AuthProvider, useAuth, ProtectedRoute } from '@nhmzh/shared-auth';
 import App from './App';
 import { CircularProgress, Box, Typography } from '@mui/material';
+import { ApiProvider } from './contexts/ApiContext';
@@
   return (
     <BrowserRouter>
       <AuthProvider appName="plugin-cost" autoLogin={true}>
-        <AuthenticatedContent />
+        <ApiProvider>
+          <AuthenticatedContent />
+        </ApiProvider>
       </AuthProvider>
     </BrowserRouter>
   );

Also applies to: 1-5

🤖 Prompt for AI Agents
In src/AuthenticatedApp.tsx around lines 76 to 80, the ApiProvider must be
mounted under AuthProvider so that the authenticated API client always has
access to the token getter before any children run; move or insert ApiProvider
as a child of AuthProvider (i.e., BrowserRouter > AuthProvider > ApiProvider >
AuthenticatedContent) and pass the token getter/function provided by
AuthProvider into ApiProvider so all downstream components receive an API client
initialized with the token.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant