diff --git a/backend/.env.example b/backend/.env.example index 61e66f9bf..d5f081c5d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -26,3 +26,8 @@ CORS_ORIGINS=localhost:\d+,127\\.0\\.0\\.1:\d+ # Kit server (internal docker-compose service) # KIT_SERVER_URL=http://kit-server:3090 + +# Platform auth (when deploying behind a platform that injects user identity via headers) +# AUTH_PROVIDER=platform +# AUTH_PLATFORM_NAME=Calponia +# AUTH_PLATFORM_HEADERS={"id":"x-calponia-user-id","email":"x-calponia-user-email","firstname":"x-calponia-user-firstname","lastname":"x-calponia-user-lastname"} diff --git a/backend/README.md b/backend/README.md index 7712b41f3..ca93b6494 100644 --- a/backend/README.md +++ b/backend/README.md @@ -95,7 +95,10 @@ The API is available under the `/v2` prefix. Key endpoints include: | `JWT_VERIFY_EMAIL_EXPIRATION_MINUTES` | Minutes after which verify email tokens expire | No | `10` | `10` | | `JWT_COOKIE_NAME` | Name of the cookie storing the refresh token | No | `token` | `refresh-token` | | `JWT_COOKIE_DOMAIN` | Domain for the JWT cookie (used in production) | No | `''` | `yourdomain.com` | -| `AUTH_URL` | URL for the authentication service | No | None | `auth_service_url` | +| `AUTH_PROVIDER` | Authentication provider: `jwt` (default) or `platform` (header-based) | No | `jwt` | `platform` | +| `AUTH_PLATFORM_NAME` | Provider name stored on `user.provider` when using platform auth | No | `Platform` | `Calponia` | +| `AUTH_PLATFORM_HEADERS` | JSON map of identity field to request header name (required when `AUTH_PROVIDER=platform`) | No | None | `{"email":"x-calponia-user-email",...}` | +| `AUTH_URL` | URL for the authentication service (**deprecated**; use `AUTH_PROVIDER=platform`) | No | None | `auth_service_url` | | `CACHE_URL` | URL for the cache service | No | None | `your_cache_url` | | `LOG_URL` | URL for the logging service | No | None | `your_log_url` | | `CLIENT_BASE_URL` | Base URL for the client application | No | `http://localhost:3000` | `your_client_base_url` | diff --git a/backend/docs/authentication.md b/backend/docs/authentication.md index 3c8ee5fc5..885cbd159 100644 --- a/backend/docs/authentication.md +++ b/backend/docs/authentication.md @@ -34,10 +34,55 @@ In short: **access token = short‑lived bearer in response body; refresh token - File: `src/middlewares/auth.js` - Behavior: - - If `AUTH_URL` is configured, the middleware forwards the request to that URL to validate and returns a sanitized `user` on success. + - If `AUTH_PROVIDER=platform`, reads identity from configured request headers via `platformAuth.service`, upserts the user in MongoDB, and sets a sanitized `req.user`. + - Else if `AUTH_URL` is configured (deprecated), forwards the request to that URL to validate and returns a sanitized `user` on success. - Otherwise uses Passport JWT strategy to validate `Authorization: Bearer ...` and sets `req.user`. - Supports `auth({ optional: true })` for public routes; otherwise throws 401. +### Platform auth (header-based) + +When deploying behind a platform that injects authenticated user identity via HTTP headers (e.g. Calponia), set `AUTH_PROVIDER=platform` instead of running a separate auth microservice. + +- File: `src/services/platformAuth.service.js` +- User upsert: `src/services/user.service.js` → `upsertPlatformUser()` +- On each protected request, the middleware: + 1. Reads configured headers from the incoming request. + 2. Requires an `email` header (401 if missing). + 3. Computes display name from `firstname`/`lastname`, else `name`, else `"Anonymous"`. + 4. Upserts the user by email and sets `req.user`. + +**Configuration** + +| Variable | Description | Example | +|---|---|---| +| `AUTH_PROVIDER` | `jwt` (default) or `platform` | `platform` | +| `AUTH_PLATFORM_NAME` | Stored on `user.provider` | `Calponia` | +| `AUTH_PLATFORM_HEADERS` | JSON map of identity field → header name | see below | + +```json +{ + "id": "x-calponia-user-id", + "email": "x-calponia-user-email", + "firstname": "x-calponia-user-firstname", + "lastname": "x-calponia-user-lastname" +} +``` + +Optional key: `name` (single full-name header, used when first/last are absent). + +**Migration from `AUTH_URL`** + +Replace the external auth sidecar with: + +```env +AUTH_PROVIDER=platform +AUTH_PLATFORM_NAME=Calponia +AUTH_PLATFORM_HEADERS={"id":"x-calponia-user-id","email":"x-calponia-user-email","firstname":"x-calponia-user-firstname","lastname":"x-calponia-user-lastname"} +# Remove AUTH_URL +``` + +**Security:** Only enable platform auth when the backend runs behind a trusted reverse proxy that strips client-supplied identity headers and injects trusted ones. Direct public exposure allows header spoofing. + ### Passport JWT - File: `src/config/passport.js` @@ -82,7 +127,9 @@ In short: **access token = short‑lived bearer in response body; refresh token - `JWT_SECRET`, `JWT_ACCESS_EXPIRATION_MINUTES`, `JWT_REFRESH_EXPIRATION_DAYS` - `JWT_RESET_PASSWORD_EXPIRATION_MINUTES` (default 10), `JWT_VERIFY_EMAIL_EXPIRATION_MINUTES` (default 10) - `JWT_COOKIE_NAME`, `JWT_COOKIE_DOMAIN` - - `AUTH_URL` — optional external auth service used by `auth` middleware. + - `AUTH_PROVIDER` — `jwt` (default) or `platform` for header-based platform auth. + - `AUTH_PLATFORM_NAME`, `AUTH_PLATFORM_HEADERS` — platform auth provider name and header map (required when `AUTH_PROVIDER=platform`). + - `AUTH_URL` — deprecated external auth service; prefer built-in platform auth. - Authentication settings (self-registration, public viewing, etc.) are configured via Site Configuration in the database, not environment variables. ### Request Lifecycle (Typical) @@ -208,7 +255,8 @@ Client | GET /v2/... Authorization: Bearer v auth() middleware (/src/middlewares/auth.js) - |-- if AUTH_URL: POST to external auth -> returns user + |-- if AUTH_PROVIDER=platform: read headers, upsert user + |-- else if AUTH_URL (deprecated): POST to external auth -> returns user |-- else: passport-jwt verifies token, loads User/Asset v req.user attached diff --git a/backend/docs/middlewares.md b/backend/docs/middlewares.md index 5fee20ff0..e27e89449 100644 --- a/backend/docs/middlewares.md +++ b/backend/docs/middlewares.md @@ -9,7 +9,8 @@ This document describes the server middlewares, their purpose, configuration, in - Options: - `optional` (boolean, default: false): when true, missing/invalid auth will not block the request; `req.user` may be undefined. - Behavior: - - If `config.services.auth.url` is set, forwards the incoming request (headers and body) to that URL to validate and get the user, sanitizes the user object, and sets `req.user`. + - If `AUTH_PROVIDER=platform`, resolves the user from configured platform headers via `platformAuth.service` and sets `req.user`. + - Else if `config.services.auth.url` is set (deprecated), forwards the incoming request (headers and body) to that URL to validate and get the user, sanitizes the user object, and sets `req.user`. - Otherwise uses `passport.authenticate('jwt')` to validate the bearer token and set `req.user`. - On failure: throws 401 unless `optional=true` (then calls next without user). diff --git a/backend/docs/setup-dev.md b/backend/docs/setup-dev.md index 97183be0f..88de657fc 100644 --- a/backend/docs/setup-dev.md +++ b/backend/docs/setup-dev.md @@ -30,7 +30,11 @@ JWT_COOKIE_NAME=token Optional services (leave empty if not used): ```bash -AUTH_URL= +# Deprecated — use AUTH_PROVIDER=platform instead +# AUTH_URL= +AUTH_PROVIDER=jwt +# AUTH_PLATFORM_NAME=Calponia +# AUTH_PLATFORM_HEADERS={"id":"x-calponia-user-id","email":"x-calponia-user-email","firstname":"x-calponia-user-firstname","lastname":"x-calponia-user-lastname"} EMAIL_URL= ``` diff --git a/backend/src/config/config.js b/backend/src/config/config.js index 4685297c7..d184058e1 100644 --- a/backend/src/config/config.js +++ b/backend/src/config/config.js @@ -43,7 +43,10 @@ const envVarsSchema = Joi.object() // Cache service URL CACHE_URL: Joi.string().description('Cache base url'), // Auth service - AUTH_URL: Joi.string().description('Auth service url'), + AUTH_URL: Joi.string().description('Auth service url (deprecated; use AUTH_PROVIDER=platform)'), + AUTH_PROVIDER: Joi.string().valid('jwt', 'platform').default('jwt').description('Authentication provider'), + AUTH_PLATFORM_NAME: Joi.string().default('Platform').description('Provider name stored on user.provider'), + AUTH_PLATFORM_HEADERS: Joi.string().description('JSON map of identity field to request header name'), // Email URL EMAIL_URL: Joi.string().description('URL to your custom email service'), EMAIL_API_KEY: Joi.string().description('API key for default email service (Brevo)'), @@ -75,10 +78,35 @@ if (error) { throw new Error(`Config validation error: ${error.message}`); } +let platformHeaders = {}; +if (envVars.AUTH_PLATFORM_HEADERS) { + try { + platformHeaders = JSON.parse(envVars.AUTH_PLATFORM_HEADERS); + } catch (e) { + throw new Error('Config validation error: AUTH_PLATFORM_HEADERS must be valid JSON'); + } + if (typeof platformHeaders !== 'object' || platformHeaders === null || Array.isArray(platformHeaders)) { + throw new Error('Config validation error: AUTH_PLATFORM_HEADERS must be a JSON object'); + } +} + +if (envVars.AUTH_PROVIDER === 'platform' && !platformHeaders.email) { + throw new Error( + 'Config validation error: AUTH_PLATFORM_HEADERS must include an "email" key when AUTH_PROVIDER is platform' + ); +} + const config = { env: envVars.NODE_ENV, port: envVars.PORT, strictAuth: envVars.STRICT_AUTH, + auth: { + provider: envVars.AUTH_PROVIDER, + platform: { + name: envVars.AUTH_PLATFORM_NAME, + headers: platformHeaders, + }, + }, mongoose: { url: envVars.MONGODB_URL + (envVars.NODE_ENV === 'test' ? '-test' : ''), options: {}, diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 8819f99d5..63256e734 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -13,6 +13,8 @@ const config = require('../config/config'); const ApiError = require('../utils/ApiError'); const logger = require('../config/logger'); const pick = require('../utils/pick'); +const platformAuthService = require('../services/platformAuth.service'); +const { sanitizeUser } = require('../middlewares/auth'); const authenticate = catchAsync(async (req, res) => { res.status(httpStatus.OK).json({ @@ -91,7 +93,13 @@ const logout = catchAsync(async (req, res) => { }); const refreshTokens = catchAsync(async (req, res) => { - const tokens = await authService.refreshAuth(req.cookies[config.jwt.cookie.name]); + if (config.auth.provider === 'platform') { + const user = await platformAuthService.resolveUser(req); + return res.send({ user: sanitizeUser(user?.toJSON ? user.toJSON() : user) }); + } + + const refreshCookie = req.cookies[config.jwt.cookie.name]; + const tokens = await authService.refreshAuth(refreshCookie); res.cookie(config.jwt.cookie.name, tokens.refresh.token, { expires: tokens.refresh.expires, ...config.jwt.cookie.options, diff --git a/backend/src/docs/configuration.md b/backend/src/docs/configuration.md index 41671f86d..ece046b0b 100644 --- a/backend/src/docs/configuration.md +++ b/backend/src/docs/configuration.md @@ -30,6 +30,9 @@ | `config.jwt.verifyEmailExpirationMinutes` | Minutes until email verification tokens expire | `10` | | `config.jwt.cookie.name` | Name of the JWT cookie | `'token'` | | `config.jwt.cookie.options` | Cookie configuration options | Secure HTTP-only | +| `config.auth.provider` | Authentication provider: `'jwt'` or `'platform'` | `'jwt'` | +| `config.auth.platform.name` | Provider name stored on `user.provider` for platform auth | `'Platform'` | +| `config.auth.platform.headers` | Map of identity field to request header name for platform auth | `{}` | ## Service URLs @@ -40,7 +43,7 @@ | `config.services.log.port` | Logging service port | `9600` | | `config.services.log.url` | Logging service URL | `undefined` | | `config.services.cache.url` | Cache service URL | `undefined` | -| `config.services.auth.url` | Authentication service URL | `undefined` | +| `config.services.auth.url` | Authentication service URL (deprecated) | `undefined` | | `config.services.email.url` | Custom email service URL | `undefined` | | `config.services.email.apiKey` | API key for default email service (Brevo) | `undefined` | | `config.services.email.endpointUrl` | Endpoint URL for default email service | `undefined` | diff --git a/backend/src/middlewares/auth.js b/backend/src/middlewares/auth.js index d75b82998..ecb599bab 100644 --- a/backend/src/middlewares/auth.js +++ b/backend/src/middlewares/auth.js @@ -12,6 +12,10 @@ const config = require('../config/config'); const { default: axios, isAxiosError } = require('axios'); const passport = require('passport'); const logger = require('../config/logger'); +const platformAuthService = require('../services/platformAuth.service'); + +let authUrlDeprecationWarned = false; + /** * * @param {Object} user @@ -33,8 +37,15 @@ const auth = async (req, res, next) => { try { let user; - // If auth service url is provided, use it to authenticate the user - if (config.services.auth.url) { + + if (config.auth.provider === 'platform') { + user = await platformAuthService.resolveUser(req); + user = sanitizeUser(user?.toJSON ? user.toJSON() : user); + } else if (config.services.auth.url) { + if (!authUrlDeprecationWarned) { + logger.warn('AUTH_URL is deprecated; use AUTH_PROVIDER=platform with AUTH_PLATFORM_HEADERS instead'); + authUrlDeprecationWarned = true; + } const forwardHeaders = { ...req.headers }; delete forwardHeaders['content-length']; delete forwardHeaders['keep-alive']; @@ -59,14 +70,16 @@ const auth = }); } - if (!user) throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); + if (!user) { + throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); + } req.user = user; next(); } catch (error) { // Resolve optional parameter - can be boolean or function that receives req const isOptional = typeof optional === 'function' ? optional(req) : optional; - + // If the middleware is optional, call the next middleware if (isOptional) next(); else { @@ -81,3 +94,4 @@ const auth = }; module.exports = auth; +module.exports.sanitizeUser = sanitizeUser; diff --git a/backend/src/services/platformAuth.service.js b/backend/src/services/platformAuth.service.js new file mode 100644 index 000000000..9f81558fc --- /dev/null +++ b/backend/src/services/platformAuth.service.js @@ -0,0 +1,102 @@ +// Copyright (c) 2025 Eclipse Foundation. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License which is available at +// https://opensource.org/licenses/MIT. +// +// SPDX-License-Identifier: MIT + +const httpStatus = require('http-status'); +const ApiError = require('../utils/ApiError'); +const config = require('../config/config'); +const userService = require('./user.service'); +const logger = require('../config/logger'); + +const IDENTITY_FIELDS = ['id', 'email', 'firstname', 'lastname', 'name']; + +/** + * @param {import('express').Request} req + * @param {Record} headerMap + * @returns {{ id?: string, email?: string, firstname?: string, lastname?: string, name?: string }} + */ +const extractIdentity = (req, headerMap) => { + const identity = {}; + for (const field of IDENTITY_FIELDS) { + const headerName = headerMap[field]; + if (headerName) { + const value = req.get(headerName); + if (value) { + identity[field] = value; + } + } + } + return identity; +}; + +/** + * @param {{ firstname?: string, lastname?: string, name?: string }} identity + * @returns {string} + */ +const resolveDisplayName = (identity) => { + const { firstname, lastname, name } = identity; + if (firstname || lastname) { + return `${firstname || ''} ${lastname || ''}`.trim(); + } + if (name) { + return name.trim(); + } + return 'Anonymous'; +}; + +/** + * @param {import('express').Request} req + * @returns {Promise} + */ +const resolveUserFromHeaders = async (req) => { + const headerMap = config.auth.platform.headers; + const identity = extractIdentity(req, headerMap); + + if (!identity.email) { + throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); + } + + const displayName = resolveDisplayName(identity); + + try { + return await userService.upsertPlatformUser({ + email: identity.email, + providerUserId: identity.id, + name: displayName, + provider: config.auth.platform.name, + }); + } catch (error) { + logger.error('Error resolving platform user'); + logger.error(error?.message || error); + throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); + } +}; + +/** @type {Record Promise>} */ +const providers = { + headers: resolveUserFromHeaders, +}; + +/** + * @param {import('express').Request} req + * @returns {Promise} + */ +const resolveUser = async (req) => { + const provider = providers.headers; + if (!provider) { + throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, 'Platform auth provider not configured'); + } + return provider(req); +}; + +module.exports = { + extractIdentity, + resolveDisplayName, + resolveUser, + resolveUserFromHeaders, + providers, +}; diff --git a/backend/src/services/user.service.js b/backend/src/services/user.service.js index 8947ddcdf..d934fc828 100644 --- a/backend/src/services/user.service.js +++ b/backend/src/services/user.service.js @@ -212,6 +212,30 @@ const createSSOUser = async (graphData) => { return createUser(userBody); }; +/** + * Upsert a user from platform-injected identity headers. + * @param {{ email: string, providerUserId?: string, name: string, provider: string }} identity + * @returns {Promise} + */ +const upsertPlatformUser = async ({ email, providerUserId, name, provider }) => { + const update = { + $set: { + name, + provider, + ...(providerUserId && { provider_user_id: providerUserId }), + }, + $setOnInsert: { + email_verified: true, + }, + }; + + return User.findOneAndUpdate({ email: email.toLowerCase() }, update, { + upsert: true, + new: true, + setDefaultsOnInsert: true, + }); +}; + module.exports = { createUser, queryUsers, @@ -221,4 +245,5 @@ module.exports = { deleteUserById, updateSSOUser, createSSOUser, + upsertPlatformUser, }; diff --git a/frontend/src/hooks/usePermissionHook.ts b/frontend/src/hooks/usePermissionHook.ts index 0f714b0dc..5b3e65fb2 100644 --- a/frontend/src/hooks/usePermissionHook.ts +++ b/frontend/src/hooks/usePermissionHook.ts @@ -11,15 +11,16 @@ import { useQuery } from '@tanstack/react-query' import useAuthStore from '@/stores/authStore' const usePermissionHook = (...params: [string, string?][]) => { - const [authBootstrapped, accessToken] = useAuthStore((state) => [ + const [authBootstrapped, accessToken, storeUser] = useAuthStore((state) => [ state.authBootstrapped, state.access?.token, + state.user, ]) const { data } = useQuery({ queryKey: ['permissions', params], queryFn: () => checkPermissionService(params), - enabled: authBootstrapped && !!accessToken && params.length > 0, + enabled: authBootstrapped && (!!accessToken || !!storeUser) && params.length > 0, }) return data || Array(params.length).fill(false) } diff --git a/frontend/src/hooks/useSelfProfile.ts b/frontend/src/hooks/useSelfProfile.ts index 1fdbb88c9..d0a37b3f7 100644 --- a/frontend/src/hooks/useSelfProfile.ts +++ b/frontend/src/hooks/useSelfProfile.ts @@ -11,16 +11,28 @@ import { getSelfService } from '@/services/user.service.ts' import useAuthStore from '@/stores/authStore.ts' const useSelfProfileQuery = () => { - const [authBootstrapped, accessToken] = useAuthStore((state) => [ + const [authBootstrapped, accessToken, storeUser] = useAuthStore((state) => [ state.authBootstrapped, state.access?.token, + state.user, ]) - return useQuery({ + const query = useQuery({ queryKey: ['getSelf'], queryFn: getSelfService, enabled: authBootstrapped && !!accessToken, }) + + if (!accessToken && storeUser) { + return { + ...query, + data: storeUser, + isLoading: false, + isFetching: false, + } + } + + return query } export default useSelfProfileQuery diff --git a/frontend/src/layouts/RootLayout.tsx b/frontend/src/layouts/RootLayout.tsx index e895d1055..2dbfe0f96 100644 --- a/frontend/src/layouts/RootLayout.tsx +++ b/frontend/src/layouts/RootLayout.tsx @@ -62,10 +62,11 @@ const RootLayout = () => { // }, [isChatShowed]) const bootstrappingRef = useRef(false) - const [authBootstrapped, setAuthBootstrapped, setAccess] = useAuthStore((state) => [ + const [authBootstrapped, setAuthBootstrapped, setAccess, setUser] = useAuthStore((state) => [ state.authBootstrapped, state.setAuthBootstrapped, state.setAccess, + state.setUser, ]) useEffect(() => { @@ -84,8 +85,11 @@ const RootLayout = () => { .post('/auth/refresh-tokens', {}) .then((res) => { const access = res?.data?.access + const user = res?.data?.user if (access?.token) { setAccess(access) + } else if (user) { + setUser(user, null) } }) .catch(() => { @@ -95,7 +99,7 @@ const RootLayout = () => { setAuthBootstrapped(true) bootstrappingRef.current = false }) - }, [authBootstrapped, setAccess, setAuthBootstrapped]) + }, [authBootstrapped, setAccess, setAuthBootstrapped, setUser]) const privacyPolicyUrl = useSiteConfig('PRIVACY_POLICY_URL', '') const pathsWithoutBreadcrumb = useMemo( diff --git a/frontend/src/providers/QueryProvider.tsx b/frontend/src/providers/QueryProvider.tsx index 9e7f1a897..3ad4a73e2 100644 --- a/frontend/src/providers/QueryProvider.tsx +++ b/frontend/src/providers/QueryProvider.tsx @@ -48,6 +48,9 @@ const QueryProvider = ({ children }: QueryProviderProps) => { if (res.data?.access?.token) { setAccess(res.data.access) query.invalidate() + } else if (res.data?.user) { + useAuthStore.getState().setUser(res.data.user, null) + query.invalidate() } } catch { logOut() diff --git a/frontend/src/services/base.ts b/frontend/src/services/base.ts index f69409b01..3c2bf10f9 100644 --- a/frontend/src/services/base.ts +++ b/frontend/src/services/base.ts @@ -72,6 +72,7 @@ serverAxios.interceptors.response.use( error.response?.status === 401 && originalRequest && !originalRequest._retry && + useAuthStore.getState().access?.token && !originalRequest.url?.includes('/auth/refresh-tokens') && !originalRequest.url?.includes('/auth/login') && !originalRequest.url?.includes('/auth/logout') diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index 9b78f3b20..c4db48515 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -21,7 +21,7 @@ type AuthState = { type Actions = { setAccess: (_: Token) => void - setUser: (user: any, access: any) => void + setUser: (user: any, access: Token | null) => void logOut: () => void setOpenLoginDialog: (isOpen: boolean) => void setAuthBootstrapped: (bootstrapped: boolean) => void