Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
5 changes: 4 additions & 1 deletion backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
54 changes: 51 additions & 3 deletions backend/docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -208,7 +255,8 @@ Client
| GET /v2/... Authorization: Bearer <access>
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
Expand Down
3 changes: 2 additions & 1 deletion backend/docs/middlewares.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
6 changes: 5 additions & 1 deletion backend/docs/setup-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=
```

Expand Down
30 changes: 29 additions & 1 deletion backend/src/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
Expand Down Expand Up @@ -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: {},
Expand Down
10 changes: 9 additions & 1 deletion backend/src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion backend/src/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` |
Expand Down
22 changes: 18 additions & 4 deletions backend/src/middlewares/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'];
Expand All @@ -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 {
Expand All @@ -81,3 +94,4 @@ const auth =
};

module.exports = auth;
module.exports.sanitizeUser = sanitizeUser;
102 changes: 102 additions & 0 deletions backend/src/services/platformAuth.service.js
Original file line number Diff line number Diff line change
@@ -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<string, string>} 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<import('../models/user.model').User>}
*/
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<string, (req: import('express').Request) => Promise<import('../models/user.model').User>>} */
const providers = {
headers: resolveUserFromHeaders,
};

/**
* @param {import('express').Request} req
* @returns {Promise<import('../models/user.model').User>}
*/
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,
};
Loading