From 02af0057179f6339b4440cc55011aa47ef1ef150 Mon Sep 17 00:00:00 2001 From: 7inh-bosch Date: Wed, 5 Aug 2026 08:59:59 +0700 Subject: [PATCH 1/3] feat(auth): add built-in configurable platform header auth Replace the external AUTH_URL sidecar with in-process platform auth (AUTH_PROVIDER=platform + AUTH_PLATFORM_HEADERS). Supports any deployment platform that injects user identity via headers, upserts users on each protected request, and keeps AUTH_URL as a deprecated fallback. --- backend/.env.example | 5 + backend/README.md | 5 +- backend/docs/authentication.md | 54 +++++++++- backend/docs/middlewares.md | 3 +- backend/docs/setup-dev.md | 6 +- backend/src/config/config.js | 30 +++++- backend/src/docs/configuration.md | 5 +- backend/src/middlewares/auth.js | 13 ++- backend/src/services/platformAuth.service.js | 102 +++++++++++++++++++ backend/src/services/user.service.js | 25 +++++ 10 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 backend/src/services/platformAuth.service.js 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/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..21d8bc219 100644 --- a/backend/src/middlewares/auth.js +++ b/backend/src/middlewares/auth.js @@ -12,6 +12,9 @@ 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 +36,14 @@ 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']; 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, }; From da65728ae70105da97c9212ec206f4070a121ead Mon Sep 17 00:00:00 2001 From: 7inh-bosch Date: Wed, 5 Aug 2026 09:06:49 +0700 Subject: [PATCH 2/3] add trace log --- backend/src/controllers/auth.controller.js | 10 +++- backend/src/middlewares/auth.js | 53 ++++++++++++++++++- backend/src/services/auth.service.js | 4 ++ backend/src/services/platformAuth.service.js | 55 +++++++++++++++++++- 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 8819f99d5..c315608af 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -91,7 +91,15 @@ const logout = catchAsync(async (req, res) => { }); const refreshTokens = catchAsync(async (req, res) => { - const tokens = await authService.refreshAuth(req.cookies[config.jwt.cookie.name]); + const refreshCookie = req.cookies[config.jwt.cookie.name]; + logger.debug( + 'Refresh tokens request: path=%s hasRefreshCookie=%s cookieName=%s origin=%s', + req.originalUrl, + Boolean(refreshCookie), + config.jwt.cookie.name, + req.get('origin') || '(none)' + ); + 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/middlewares/auth.js b/backend/src/middlewares/auth.js index 21d8bc219..a84bd8550 100644 --- a/backend/src/middlewares/auth.js +++ b/backend/src/middlewares/auth.js @@ -15,6 +15,13 @@ const logger = require('../config/logger'); const platformAuthService = require('../services/platformAuth.service'); let authUrlDeprecationWarned = false; + +const getAuthMode = () => { + if (config.auth.provider === 'platform') return 'platform'; + if (config.services.auth.url) return 'auth_url'; + return 'jwt'; +}; + /** * * @param {Object} user @@ -34,8 +41,11 @@ const sanitizeUser = (user) => { const auth = ({ optional = false } = {}) => async (req, res, next) => { + const authMode = getAuthMode(); try { let user; + logger.debug('Auth attempt: mode=%s method=%s path=%s', authMode, req.method, req.originalUrl); + if (config.auth.provider === 'platform') { user = await platformAuthService.resolveUser(req); user = sanitizeUser(user?.toJSON ? user.toJSON() : user); @@ -44,6 +54,7 @@ const auth = logger.warn('AUTH_URL is deprecated; use AUTH_PROVIDER=platform with AUTH_PLATFORM_HEADERS instead'); authUrlDeprecationWarned = true; } + logger.debug('Auth delegating to AUTH_URL: url=%s path=%s', config.services.auth.url, req.originalUrl); const forwardHeaders = { ...req.headers }; delete forwardHeaders['content-length']; delete forwardHeaders['keep-alive']; @@ -56,31 +67,69 @@ const auth = }); user = response?.data?.user; user = sanitizeUser(user); + logger.debug( + 'Auth via AUTH_URL succeeded: path=%s userId=%s', + req.originalUrl, + user?.id || user?._id || '(none)' + ); } else { // If auth service url is not provided, use passport to authenticate the user user = await new Promise((resolve, reject) => { passport.authenticate('jwt', { session: false }, (err, user, info) => { if (err || info || !user) { + const hasBearer = Boolean(req.headers.authorization?.startsWith('Bearer ')); + logger.debug( + 'JWT auth failed: method=%s path=%s hasBearer=%s err=%s info=%s', + req.method, + req.originalUrl, + hasBearer, + err?.message || err || '(none)', + info?.message || info?.name || info || '(none)' + ); return reject(new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')); } + logger.debug( + 'JWT auth succeeded: userId=%s method=%s path=%s', + user.id || user._id, + req.method, + req.originalUrl + ); resolve(user); })(req, res, next); }); } - if (!user) throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); + if (!user) { + logger.debug('Auth failed: mode=%s path=%s reason=no user returned', authMode, req.originalUrl); + 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; - + + logger.debug( + 'Auth failed: mode=%s method=%s path=%s optional=%s reason=%s', + authMode, + req.method, + req.originalUrl, + isOptional, + error?.message || error + ); + // If the middleware is optional, call the next middleware if (isOptional) next(); else { logger.error(`Failed to authenticate user: %o`, error?.message || error); if (isAxiosError(error)) { + logger.debug( + 'Auth via AUTH_URL failed: path=%s status=%s response=%o', + req.originalUrl, + error.response?.status, + error.response?.data + ); next(new ApiError(error.response?.status || 401, error.response?.data?.message || 'Please authenticate')); } else { next(error); diff --git a/backend/src/services/auth.service.js b/backend/src/services/auth.service.js index 466cf8ec0..3712a228f 100644 --- a/backend/src/services/auth.service.js +++ b/backend/src/services/auth.service.js @@ -89,17 +89,21 @@ const logout = async (refreshToken) => { */ const refreshAuth = async (refreshToken) => { if (!refreshToken) { + logger.debug('Refresh auth failed: refresh cookie missing (cookieName=%s)', config.jwt.cookie.name); throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } try { const refreshTokenDoc = await tokenService.verifyToken(refreshToken, tokenTypes.REFRESH); const user = await userService.getUserById(refreshTokenDoc.user); if (!user) { + logger.debug('Refresh auth failed: user not found for token userId=%s', refreshTokenDoc.user); throw new Error(); } await refreshTokenDoc.deleteOne(); + logger.debug('Refresh auth succeeded: userId=%s', user.id || user._id); return tokenService.generateAuthTokens(user); } catch (error) { + logger.debug('Refresh auth failed: %s', error?.message || error); throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } }; diff --git a/backend/src/services/platformAuth.service.js b/backend/src/services/platformAuth.service.js index 9f81558fc..5180bdc23 100644 --- a/backend/src/services/platformAuth.service.js +++ b/backend/src/services/platformAuth.service.js @@ -14,6 +14,31 @@ const logger = require('../config/logger'); const IDENTITY_FIELDS = ['id', 'email', 'firstname', 'lastname', 'name']; +/** + * @param {string} email + * @returns {string} + */ +const maskEmail = (email) => { + if (!email || !email.includes('@')) return '(missing)'; + const [local, domain] = email.split('@'); + const maskedLocal = local.length <= 1 ? '*' : `${local[0]}***`; + return `${maskedLocal}@${domain}`; +}; + +/** + * @param {import('express').Request} req + * @param {Record} headerMap + * @returns {Record} + */ +const describeHeaderPresence = (req, headerMap) => { + return Object.fromEntries( + Object.entries(headerMap).map(([field, headerName]) => [ + field, + { header: headerName, present: Boolean(req.get(headerName)) }, + ]) + ); +}; + /** * @param {import('express').Request} req * @param {Record} headerMap @@ -56,22 +81,48 @@ const resolveUserFromHeaders = async (req) => { const headerMap = config.auth.platform.headers; const identity = extractIdentity(req, headerMap); + logger.debug( + 'Platform auth attempt: method=%s path=%s platform=%s headers=%o', + req.method, + req.originalUrl, + config.auth.platform.name, + describeHeaderPresence(req, headerMap) + ); + if (!identity.email) { + logger.debug( + 'Platform auth failed: missing email header; method=%s path=%s', + req.method, + req.originalUrl + ); throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } const displayName = resolveDisplayName(identity); try { - return await userService.upsertPlatformUser({ + const user = await userService.upsertPlatformUser({ email: identity.email, providerUserId: identity.id, name: displayName, provider: config.auth.platform.name, }); + logger.debug( + 'Platform auth succeeded: email=%s userId=%s path=%s', + maskEmail(identity.email), + user.id || user._id, + req.originalUrl + ); + return user; } catch (error) { logger.error('Error resolving platform user'); logger.error(error?.message || error); + logger.debug( + 'Platform auth failed during upsert: email=%s path=%s reason=%s', + maskEmail(identity.email), + req.originalUrl, + error?.message || error + ); throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } }; @@ -98,5 +149,7 @@ module.exports = { resolveDisplayName, resolveUser, resolveUserFromHeaders, + describeHeaderPresence, + maskEmail, providers, }; From a43ad034f5b69ab2a976f6a5a77f1bdebecc62c4 Mon Sep 17 00:00:00 2001 From: 7inh-bosch Date: Thu, 6 Aug 2026 16:11:13 +0700 Subject: [PATCH 3/3] work but changes too many --- backend/src/controllers/auth.controller.js | 14 ++--- backend/src/middlewares/auth.js | 46 +--------------- backend/src/services/auth.service.js | 4 -- backend/src/services/platformAuth.service.js | 55 +------------------- frontend/src/hooks/usePermissionHook.ts | 5 +- frontend/src/hooks/useSelfProfile.ts | 16 +++++- frontend/src/layouts/RootLayout.tsx | 8 ++- frontend/src/providers/QueryProvider.tsx | 3 ++ frontend/src/services/base.ts | 1 + frontend/src/stores/authStore.ts | 2 +- 10 files changed, 37 insertions(+), 117 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index c315608af..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,14 +93,12 @@ const logout = catchAsync(async (req, res) => { }); const refreshTokens = catchAsync(async (req, res) => { + 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]; - logger.debug( - 'Refresh tokens request: path=%s hasRefreshCookie=%s cookieName=%s origin=%s', - req.originalUrl, - Boolean(refreshCookie), - config.jwt.cookie.name, - req.get('origin') || '(none)' - ); const tokens = await authService.refreshAuth(refreshCookie); res.cookie(config.jwt.cookie.name, tokens.refresh.token, { expires: tokens.refresh.expires, diff --git a/backend/src/middlewares/auth.js b/backend/src/middlewares/auth.js index a84bd8550..ecb599bab 100644 --- a/backend/src/middlewares/auth.js +++ b/backend/src/middlewares/auth.js @@ -16,12 +16,6 @@ const platformAuthService = require('../services/platformAuth.service'); let authUrlDeprecationWarned = false; -const getAuthMode = () => { - if (config.auth.provider === 'platform') return 'platform'; - if (config.services.auth.url) return 'auth_url'; - return 'jwt'; -}; - /** * * @param {Object} user @@ -41,10 +35,8 @@ const sanitizeUser = (user) => { const auth = ({ optional = false } = {}) => async (req, res, next) => { - const authMode = getAuthMode(); try { let user; - logger.debug('Auth attempt: mode=%s method=%s path=%s', authMode, req.method, req.originalUrl); if (config.auth.provider === 'platform') { user = await platformAuthService.resolveUser(req); @@ -54,7 +46,6 @@ const auth = logger.warn('AUTH_URL is deprecated; use AUTH_PROVIDER=platform with AUTH_PLATFORM_HEADERS instead'); authUrlDeprecationWarned = true; } - logger.debug('Auth delegating to AUTH_URL: url=%s path=%s', config.services.auth.url, req.originalUrl); const forwardHeaders = { ...req.headers }; delete forwardHeaders['content-length']; delete forwardHeaders['keep-alive']; @@ -67,40 +58,19 @@ const auth = }); user = response?.data?.user; user = sanitizeUser(user); - logger.debug( - 'Auth via AUTH_URL succeeded: path=%s userId=%s', - req.originalUrl, - user?.id || user?._id || '(none)' - ); } else { // If auth service url is not provided, use passport to authenticate the user user = await new Promise((resolve, reject) => { passport.authenticate('jwt', { session: false }, (err, user, info) => { if (err || info || !user) { - const hasBearer = Boolean(req.headers.authorization?.startsWith('Bearer ')); - logger.debug( - 'JWT auth failed: method=%s path=%s hasBearer=%s err=%s info=%s', - req.method, - req.originalUrl, - hasBearer, - err?.message || err || '(none)', - info?.message || info?.name || info || '(none)' - ); return reject(new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate')); } - logger.debug( - 'JWT auth succeeded: userId=%s method=%s path=%s', - user.id || user._id, - req.method, - req.originalUrl - ); resolve(user); })(req, res, next); }); } if (!user) { - logger.debug('Auth failed: mode=%s path=%s reason=no user returned', authMode, req.originalUrl); throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } @@ -110,26 +80,11 @@ const auth = // Resolve optional parameter - can be boolean or function that receives req const isOptional = typeof optional === 'function' ? optional(req) : optional; - logger.debug( - 'Auth failed: mode=%s method=%s path=%s optional=%s reason=%s', - authMode, - req.method, - req.originalUrl, - isOptional, - error?.message || error - ); - // If the middleware is optional, call the next middleware if (isOptional) next(); else { logger.error(`Failed to authenticate user: %o`, error?.message || error); if (isAxiosError(error)) { - logger.debug( - 'Auth via AUTH_URL failed: path=%s status=%s response=%o', - req.originalUrl, - error.response?.status, - error.response?.data - ); next(new ApiError(error.response?.status || 401, error.response?.data?.message || 'Please authenticate')); } else { next(error); @@ -139,3 +94,4 @@ const auth = }; module.exports = auth; +module.exports.sanitizeUser = sanitizeUser; diff --git a/backend/src/services/auth.service.js b/backend/src/services/auth.service.js index 3712a228f..466cf8ec0 100644 --- a/backend/src/services/auth.service.js +++ b/backend/src/services/auth.service.js @@ -89,21 +89,17 @@ const logout = async (refreshToken) => { */ const refreshAuth = async (refreshToken) => { if (!refreshToken) { - logger.debug('Refresh auth failed: refresh cookie missing (cookieName=%s)', config.jwt.cookie.name); throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } try { const refreshTokenDoc = await tokenService.verifyToken(refreshToken, tokenTypes.REFRESH); const user = await userService.getUserById(refreshTokenDoc.user); if (!user) { - logger.debug('Refresh auth failed: user not found for token userId=%s', refreshTokenDoc.user); throw new Error(); } await refreshTokenDoc.deleteOne(); - logger.debug('Refresh auth succeeded: userId=%s', user.id || user._id); return tokenService.generateAuthTokens(user); } catch (error) { - logger.debug('Refresh auth failed: %s', error?.message || error); throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } }; diff --git a/backend/src/services/platformAuth.service.js b/backend/src/services/platformAuth.service.js index 5180bdc23..9f81558fc 100644 --- a/backend/src/services/platformAuth.service.js +++ b/backend/src/services/platformAuth.service.js @@ -14,31 +14,6 @@ const logger = require('../config/logger'); const IDENTITY_FIELDS = ['id', 'email', 'firstname', 'lastname', 'name']; -/** - * @param {string} email - * @returns {string} - */ -const maskEmail = (email) => { - if (!email || !email.includes('@')) return '(missing)'; - const [local, domain] = email.split('@'); - const maskedLocal = local.length <= 1 ? '*' : `${local[0]}***`; - return `${maskedLocal}@${domain}`; -}; - -/** - * @param {import('express').Request} req - * @param {Record} headerMap - * @returns {Record} - */ -const describeHeaderPresence = (req, headerMap) => { - return Object.fromEntries( - Object.entries(headerMap).map(([field, headerName]) => [ - field, - { header: headerName, present: Boolean(req.get(headerName)) }, - ]) - ); -}; - /** * @param {import('express').Request} req * @param {Record} headerMap @@ -81,48 +56,22 @@ const resolveUserFromHeaders = async (req) => { const headerMap = config.auth.platform.headers; const identity = extractIdentity(req, headerMap); - logger.debug( - 'Platform auth attempt: method=%s path=%s platform=%s headers=%o', - req.method, - req.originalUrl, - config.auth.platform.name, - describeHeaderPresence(req, headerMap) - ); - if (!identity.email) { - logger.debug( - 'Platform auth failed: missing email header; method=%s path=%s', - req.method, - req.originalUrl - ); throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } const displayName = resolveDisplayName(identity); try { - const user = await userService.upsertPlatformUser({ + return await userService.upsertPlatformUser({ email: identity.email, providerUserId: identity.id, name: displayName, provider: config.auth.platform.name, }); - logger.debug( - 'Platform auth succeeded: email=%s userId=%s path=%s', - maskEmail(identity.email), - user.id || user._id, - req.originalUrl - ); - return user; } catch (error) { logger.error('Error resolving platform user'); logger.error(error?.message || error); - logger.debug( - 'Platform auth failed during upsert: email=%s path=%s reason=%s', - maskEmail(identity.email), - req.originalUrl, - error?.message || error - ); throw new ApiError(httpStatus.UNAUTHORIZED, 'Please authenticate'); } }; @@ -149,7 +98,5 @@ module.exports = { resolveDisplayName, resolveUser, resolveUserFromHeaders, - describeHeaderPresence, - maskEmail, providers, }; 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