mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🆔 feat: Add OpenID Connect Federated Provider Token Support (#9931)
* feat: Add OpenID Connect federated provider token support
Implements support for passing federated provider tokens (Cognito, Azure AD, Auth0)
as variables in LibreChat's librechat.yaml configuration for both custom endpoints
and MCP servers.
Features:
- New LIBRECHAT_OPENID_* template variables for federated provider tokens
- JWT claims parsing from ID tokens without verification (for claim extraction)
- Token validation with expiration checking
- Support for multiple token storage locations (federatedTokens, openidTokens)
- Integration with existing template variable system
- Comprehensive test suite with Cognito-specific scenarios
- Provider-agnostic design supporting Cognito, Azure AD, Auth0, etc.
Security:
- Server-side only token processing
- Automatic token expiration validation
- Graceful fallbacks for missing/invalid tokens
- No client-side token exposure
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: Add federated token propagation to OIDC authentication strategies
Adds federatedTokens object to user during authentication to enable
federated provider token template variables in LibreChat configuration.
Changes:
- OpenID JWT Strategy: Extract raw JWT from Authorization header and
attach as federatedTokens.access_token to enable {{LIBRECHAT_OPENID_TOKEN}}
placeholder resolution
- OpenID Strategy: Attach tokenset tokens as federatedTokens object to
standardize token access across both authentication strategies
This enables proper token propagation for custom endpoints and MCP
servers that require federated provider tokens for authorization.
Resolves missing token issue reported by @ramden in PR #9931
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Denis Ramic <denis.ramic@nfon.com>
Co-Authored-By: Claude <noreply@anthropic.com>
* test: Add federatedTokens validation tests for OIDC strategies
Adds comprehensive test coverage for the federated token propagation
feature implemented in the authentication strategies.
Tests added:
- Verify federatedTokens object is attached to user with correct structure
(access_token, refresh_token, expires_at)
- Verify both tokenset and federatedTokens are present in user object
- Ensure tokens from OIDC provider are correctly propagated
Also fixes existing test suite by adding missing mocks:
- isEmailDomainAllowed function mock
- findOpenIDUser function mock
These tests validate the fix from commit 5874ba29f that enables
{{LIBRECHAT_OPENID_TOKEN}} template variable functionality.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: Remove implementation documentation file
The PR description already contains all necessary implementation details.
This documentation file is redundant and was requested to be removed.
* fix: skip s256 check
* fix(openid): handle missing refresh token in Cognito token refresh response
When OPENID_REUSE_TOKENS=true, the token refresh flow was failing because
Cognito (and most OAuth providers) don't return a new refresh token in the
refresh grant response - they only return new access and ID tokens.
Changes:
- Modified setOpenIDAuthTokens() to accept optional existingRefreshToken parameter
- Updated validation to only require access_token (refresh_token now optional)
- Added logic to reuse existing refresh token when not provided in tokenset
- Updated refreshController to pass original refresh token as fallback
- Added comments explaining standard OAuth 2.0 refresh token behavior
This fixes the "Token is not present. User is not authenticated." error that
occurred during silent token refresh with Cognito as the OpenID provider.
Fixes: Authentication loop with OPENID_REUSE_TOKENS=true and AWS Cognito
* fix(openid): extract refresh token from cookies for template variable replacement
When OPENID_REUSE_TOKENS=true, the openIdJwtStrategy populates user.federatedTokens
to enable template variable replacement (e.g., {{LIBRECHAT_OPENID_ACCESS_TOKEN}}).
However, the refresh_token field was incorrectly sourced from payload.refresh_token,
which is always undefined because:
1. JWTs don't contain refresh tokens in their payload
2. The JWT itself IS the access token
3. Refresh tokens are separate opaque tokens stored in HTTP-only cookies
This caused extractOpenIDTokenInfo() to receive incomplete federatedTokens,
resulting in template variables remaining unreplaced in headers.
**Root Cause:**
- Line 90: `refresh_token: payload.refresh_token` (always undefined)
- JWTs only contain access token data in their claims
- Refresh tokens are separate, stored securely in cookies
**Solution:**
- Import `cookie` module to parse cookies from request
- Extract refresh token from `refreshToken` cookie
- Populate federatedTokens with both access token (JWT) and refresh token (from cookie)
**Impact:**
- Template variables like {{LIBRECHAT_OPENID_ACCESS_TOKEN}} now work correctly
- Headers in librechat.yaml are properly replaced with actual tokens
- MCP server authentication with federated tokens now functional
**Technical Details:**
- passReqToCallback=true in JWT strategy provides req object access
- Refresh token extracted via cookies.parse(req.headers.cookie).refreshToken
- Falls back gracefully if cookie header or refreshToken is missing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: re-resolve headers on each request to pick up fresh federatedTokens
- OpenAIClient now re-resolves headers in chatCompletion() before each API call
- This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} are replaced
with actual token values from req.user.federatedTokens
- initialize.js now stores original template headers instead of pre-resolved ones
- Fixes template variable replacement when OPENID_REUSE_TOKENS=true
The issue was that headers were only resolved once during client initialization,
before openIdJwtStrategy had populated user.federatedTokens. Now headers are
re-resolved on every request with the current user's fresh tokens.
* debug: add logging to track header resolution in OpenAIClient
* debug: log tokenset structure after refresh to diagnose missing access_token
* fix: set federatedTokens on user object after OAuth refresh
- After successful OAuth token refresh, the user object was not being
updated with federatedTokens
- This caused template variable resolution to fail on subsequent requests
- Now sets user.federatedTokens with access_token, id_token, refresh_token
and expires_at from the refreshed tokenset
- Fixes template variables like {{LIBRECHAT_OPENID_TOKEN}} not being
replaced after token refresh
- Related to PR #9931 (OpenID federated token support)
* fix(openid): pass user object through agent chain for template variable resolution
Root cause: buildAgentContext in agents/run.ts called resolveHeaders without
the user parameter, preventing OpenID federated token template variables from
being resolved in agent runtime parameters.
Changes:
- packages/api/src/agents/run.ts: Add user parameter to createRun signature
- packages/api/src/agents/run.ts: Pass user to resolveHeaders in buildAgentContext
- api/server/controllers/agents/client.js: Pass user when calling createRun
- api/server/services/Endpoints/bedrock/options.js: Add resolveHeaders call with debug logging
- api/server/services/Endpoints/custom/initialize.js: Add debug logging
- packages/api/src/utils/env.ts: Add comprehensive debug logging and stack traces
- packages/api/src/utils/oidc.ts: Fix eslint errors (unused type, explicit any)
This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} and
{{LIBRECHAT_USER_OPENIDID}} are properly resolved in both custom endpoint
headers and Bedrock AgentCore runtime parameters.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: remove debug logging from OpenID token template feature
Removed excessive debug logging that was added during development to make
the PR more suitable for upstream review:
- Removed 7 debug statements from OpenAIClient.js
- Removed all console.log statements from packages/api/src/utils/env.ts
- Removed debug logging from bedrock/options.js
- Removed debug logging from custom/initialize.js
- Removed debug statement from AuthController.js
This reduces the changeset by ~50 lines while maintaining full functionality
of the OpenID federated token template variable feature.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* test(openid): add comprehensive unit tests for template variable substitution
- Add 34 unit tests for OIDC token utilities (oidc.spec.ts)
- Test coverage for token extraction, validation, and placeholder processing
- Integration tests for full OpenID token flow
- All tests pass with comprehensive edge case coverage
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* test: fix OpenID federated tokens test failures
- Add serverMetadata() mock to openid-client mock configuration
* Fixes TypeError in openIdJwtStrategy.js where serverMetadata() was being called
* Mock now returns jwks_uri and end_session_endpoint as expected by the code
- Update outdated initialize.spec.js test
* Remove test expecting resolveHeaders call during initialization
* Header resolution was refactored to be deferred until LLM request time
* Update test to verify options are returned correctly with useLegacyContent flag
Fixes #9931 CI failures for backend unit tests
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: fix package-lock.json conflict
* chore: sync package-log with upstream
* chore: cleanup
* fix: use createSafeUser
* fix: fix createSafeUser signature
* chore: remove comments
* chore: purge comments
* fix: update Jest testPathPattern to testPathPatterns for Jest 30+ compatibility
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Denis Ramic <denis.ramic@nfon.com>
Co-authored-by: kristjanaapro <kristjana@apro.is>
chore: import order and add back JSDoc for OpenID JWT callback
This commit is contained in:
parent
040d083088
commit
ef3bf0a932
19 changed files with 1357 additions and 46 deletions
|
|
@ -785,3 +785,7 @@ OPENWEATHER_API_KEY=
|
||||||
|
|
||||||
# Cache connection status checks for this many milliseconds to avoid expensive verification
|
# Cache connection status checks for this many milliseconds to avoid expensive verification
|
||||||
# MCP_CONNECTION_CHECK_TTL=60000
|
# MCP_CONNECTION_CHECK_TTL=60000
|
||||||
|
|
||||||
|
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
|
||||||
|
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
|
||||||
|
# MCP_SKIP_CODE_CHALLENGE_CHECK=false
|
||||||
|
|
|
||||||
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -138,3 +138,34 @@ helm/**/.values.yaml
|
||||||
/.tabnine/
|
/.tabnine/
|
||||||
/.codeium
|
/.codeium
|
||||||
*.local.md
|
*.local.md
|
||||||
|
|
||||||
|
|
||||||
|
# Removed Windows wrapper files per user request
|
||||||
|
hive-mind-prompt-*.txt
|
||||||
|
|
||||||
|
# Claude Flow generated files
|
||||||
|
.claude/settings.local.json
|
||||||
|
.mcp.json
|
||||||
|
claude-flow.config.json
|
||||||
|
.swarm/
|
||||||
|
.hive-mind/
|
||||||
|
.claude-flow/
|
||||||
|
memory/
|
||||||
|
coordination/
|
||||||
|
memory/claude-flow-data.json
|
||||||
|
memory/sessions/*
|
||||||
|
!memory/sessions/README.md
|
||||||
|
memory/agents/*
|
||||||
|
!memory/agents/README.md
|
||||||
|
coordination/memory_bank/*
|
||||||
|
coordination/subtasks/*
|
||||||
|
coordination/orchestration/*
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
*.sqlite-wal
|
||||||
|
claude-flow
|
||||||
|
# Removed Windows wrapper files per user request
|
||||||
|
hive-mind-prompt-*.txt
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,15 @@ const refreshController = async (req, res) => {
|
||||||
if (error || !user) {
|
if (error || !user) {
|
||||||
return res.status(401).redirect('/login');
|
return res.status(401).redirect('/login');
|
||||||
}
|
}
|
||||||
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString());
|
const token = setOpenIDAuthTokens(tokenset, res, user._id.toString(), refreshToken);
|
||||||
|
|
||||||
|
user.federatedTokens = {
|
||||||
|
access_token: tokenset.access_token,
|
||||||
|
id_token: tokenset.id_token,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
expires_at: claims.exp,
|
||||||
|
};
|
||||||
|
|
||||||
return res.status(200).send({ token, user });
|
return res.status(200).send({ token, user });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[refreshController] OpenID token refresh error', error);
|
logger.error('[refreshController] OpenID token refresh error', error);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const {
|
||||||
logAxiosError,
|
logAxiosError,
|
||||||
sanitizeTitle,
|
sanitizeTitle,
|
||||||
resolveHeaders,
|
resolveHeaders,
|
||||||
|
createSafeUser,
|
||||||
getBalanceConfig,
|
getBalanceConfig,
|
||||||
memoryInstructions,
|
memoryInstructions,
|
||||||
getTransactionsConfig,
|
getTransactionsConfig,
|
||||||
|
|
@ -856,7 +857,7 @@ class AgentClient extends BaseClient {
|
||||||
conversationId: this.conversationId,
|
conversationId: this.conversationId,
|
||||||
parentMessageId: this.parentMessageId,
|
parentMessageId: this.parentMessageId,
|
||||||
},
|
},
|
||||||
user: this.options.req.user,
|
user: createSafeUser(this.options.req.user),
|
||||||
},
|
},
|
||||||
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
|
recursionLimit: agentsEConfig?.recursionLimit ?? 25,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
|
|
@ -932,6 +933,7 @@ class AgentClient extends BaseClient {
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
customHandlers: this.options.eventHandlers,
|
customHandlers: this.options.eventHandlers,
|
||||||
requestBody: config.configurable.requestBody,
|
requestBody: config.configurable.requestBody,
|
||||||
|
user: createSafeUser(this.options.req?.user),
|
||||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1152,6 +1154,7 @@ class AgentClient extends BaseClient {
|
||||||
if (clientOptions?.configuration?.defaultHeaders != null) {
|
if (clientOptions?.configuration?.defaultHeaders != null) {
|
||||||
clientOptions.configuration.defaultHeaders = resolveHeaders({
|
clientOptions.configuration.defaultHeaders = resolveHeaders({
|
||||||
headers: clientOptions.configuration.defaultHeaders,
|
headers: clientOptions.configuration.defaultHeaders,
|
||||||
|
user: createSafeUser(this.options.req?.user),
|
||||||
body: {
|
body: {
|
||||||
messageId: this.responseMessageId,
|
messageId: this.responseMessageId,
|
||||||
conversationId: this.conversationId,
|
conversationId: this.conversationId,
|
||||||
|
|
|
||||||
|
|
@ -412,7 +412,7 @@ const setAuthTokens = async (userId, res, _session = null) => {
|
||||||
* @param {string} [userId] - Optional MongoDB user ID for image path validation
|
* @param {string} [userId] - Optional MongoDB user ID for image path validation
|
||||||
* @returns {String} - access token
|
* @returns {String} - access token
|
||||||
*/
|
*/
|
||||||
const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
const setOpenIDAuthTokens = (tokenset, res, userId, existingRefreshToken) => {
|
||||||
try {
|
try {
|
||||||
if (!tokenset) {
|
if (!tokenset) {
|
||||||
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
||||||
|
|
@ -427,11 +427,25 @@ const setOpenIDAuthTokens = (tokenset, res, userId) => {
|
||||||
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
logger.error('[setOpenIDAuthTokens] No tokenset found in request');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!tokenset.access_token || !tokenset.refresh_token) {
|
if (!tokenset.access_token) {
|
||||||
logger.error('[setOpenIDAuthTokens] No access or refresh token found in tokenset');
|
logger.error('[setOpenIDAuthTokens] No access token found in tokenset');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.cookie('refreshToken', tokenset.refresh_token, {
|
|
||||||
|
const refreshToken = tokenset.refresh_token || existingRefreshToken;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
logger.error('[setOpenIDAuthTokens] No refresh token available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.cookie('refreshToken', refreshToken, {
|
||||||
|
expires: expirationDate,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProduction,
|
||||||
|
sameSite: 'strict',
|
||||||
|
});
|
||||||
|
res.cookie('openid_access_token', tokenset.access_token, {
|
||||||
expires: expirationDate,
|
expires: expirationDate,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: isProduction,
|
secure: isProduction,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
const { resolveHeaders } = require('@librechat/api');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
const {
|
const {
|
||||||
AuthType,
|
AuthType,
|
||||||
|
|
@ -88,6 +89,14 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => {
|
||||||
llmConfig.endpointHost = BEDROCK_REVERSE_PROXY;
|
llmConfig.endpointHost = BEDROCK_REVERSE_PROXY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (llmConfig.additionalModelRequestFields) {
|
||||||
|
llmConfig.additionalModelRequestFields = resolveHeaders({
|
||||||
|
headers: llmConfig.additionalModelRequestFields,
|
||||||
|
user: req.user,
|
||||||
|
body: req.body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** @type {BedrockClientOptions} */
|
/** @type {BedrockClientOptions} */
|
||||||
llmConfig,
|
llmConfig,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
const {
|
const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig } = require('@librechat/api');
|
||||||
resolveHeaders,
|
|
||||||
isUserProvided,
|
|
||||||
getOpenAIConfig,
|
|
||||||
getCustomEndpointConfig,
|
|
||||||
} = require('@librechat/api');
|
|
||||||
const {
|
const {
|
||||||
CacheKeys,
|
CacheKeys,
|
||||||
ErrorTypes,
|
ErrorTypes,
|
||||||
|
|
@ -34,14 +29,6 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||||
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
|
const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey);
|
||||||
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
|
const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL);
|
||||||
|
|
||||||
/** Intentionally excludes passing `body`, i.e. `req.body`, as
|
|
||||||
* values may not be accurate until `AgentClient` is initialized
|
|
||||||
*/
|
|
||||||
let resolvedHeaders = resolveHeaders({
|
|
||||||
headers: endpointConfig.headers,
|
|
||||||
user: req.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (CUSTOM_API_KEY.match(envVarRegex)) {
|
if (CUSTOM_API_KEY.match(envVarRegex)) {
|
||||||
throw new Error(`Missing API Key for ${endpoint}.`);
|
throw new Error(`Missing API Key for ${endpoint}.`);
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +95,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||||
}
|
}
|
||||||
|
|
||||||
const customOptions = {
|
const customOptions = {
|
||||||
headers: resolvedHeaders,
|
headers: endpointConfig.headers,
|
||||||
addParams: endpointConfig.addParams,
|
addParams: endpointConfig.addParams,
|
||||||
dropParams: endpointConfig.dropParams,
|
dropParams: endpointConfig.dropParams,
|
||||||
customParams: endpointConfig.customParams,
|
customParams: endpointConfig.customParams,
|
||||||
|
|
|
||||||
|
|
@ -69,17 +69,21 @@ describe('custom/initializeClient', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => {
|
it('stores original template headers for deferred resolution', async () => {
|
||||||
const { resolveHeaders } = require('@librechat/api');
|
/**
|
||||||
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
|
* Note: Request-based Header Resolution is deferred until right before LLM request is made
|
||||||
expect(resolveHeaders).toHaveBeenCalledWith({
|
* in the OpenAIClient or AgentClient, not during initialization.
|
||||||
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
|
* This test verifies that the initialize function completes successfully with optionsOnly flag,
|
||||||
user: { id: 'user-123', email: 'test@example.com', role: 'user' },
|
* and that headers are passed through to be resolved later during the actual LLM request.
|
||||||
/**
|
*/
|
||||||
* Note: Request-based Header Resolution is deferred until right before LLM request is made
|
const result = await initializeClient({
|
||||||
body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders
|
req: mockRequest,
|
||||||
*/
|
res: mockResponse,
|
||||||
|
optionsOnly: true,
|
||||||
});
|
});
|
||||||
|
// Verify that options are returned for later use
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result).toHaveProperty('useLegacyContent', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws if endpoint config is missing', async () => {
|
it('throws if endpoint config is missing', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
const cookies = require('cookie');
|
||||||
const jwksRsa = require('jwks-rsa');
|
const jwksRsa = require('jwks-rsa');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||||
|
|
@ -40,13 +41,18 @@ const openIdJwtLogin = (openIdConfig) => {
|
||||||
{
|
{
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
|
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
|
||||||
|
passReqToCallback: true,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
* @param {import('@librechat/api').ServerRequest} req
|
||||||
* @param {import('openid-client').IDToken} payload
|
* @param {import('openid-client').IDToken} payload
|
||||||
* @param {import('passport-jwt').VerifyCallback} done
|
* @param {import('passport-jwt').VerifyCallback} done
|
||||||
*/
|
*/
|
||||||
async (payload, done) => {
|
async (req, payload, done) => {
|
||||||
try {
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const rawToken = authHeader?.replace('Bearer ', '');
|
||||||
|
|
||||||
const { user, error, migration } = await findOpenIDUser({
|
const { user, error, migration } = await findOpenIDUser({
|
||||||
findUser,
|
findUser,
|
||||||
email: payload?.email,
|
email: payload?.email,
|
||||||
|
|
@ -77,6 +83,18 @@ const openIdJwtLogin = (openIdConfig) => {
|
||||||
await updateUser(user.id, updateData);
|
await updateUser(user.id, updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cookieHeader = req.headers.cookie;
|
||||||
|
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
|
||||||
|
const accessToken = parsedCookies.openid_access_token;
|
||||||
|
const refreshToken = parsedCookies.refreshToken;
|
||||||
|
|
||||||
|
user.federatedTokens = {
|
||||||
|
access_token: accessToken || rawToken,
|
||||||
|
id_token: rawToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
expires_at: payload.exp,
|
||||||
|
};
|
||||||
|
|
||||||
done(null, user);
|
done(null, user);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,15 @@ async function setupOpenId() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
done(null, { ...user, tokenset });
|
done(null, {
|
||||||
|
...user,
|
||||||
|
tokenset,
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: tokenset.access_token,
|
||||||
|
refresh_token: tokenset.refresh_token,
|
||||||
|
expires_at: tokenset.expires_at,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[openidStrategy] login failed', err);
|
logger.error('[openidStrategy] login failed', err);
|
||||||
done(err);
|
done(err);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ jest.mock('~/server/services/Config', () => ({
|
||||||
jest.mock('@librechat/api', () => ({
|
jest.mock('@librechat/api', () => ({
|
||||||
...jest.requireActual('@librechat/api'),
|
...jest.requireActual('@librechat/api'),
|
||||||
isEnabled: jest.fn(() => false),
|
isEnabled: jest.fn(() => false),
|
||||||
|
isEmailDomainAllowed: jest.fn(() => true),
|
||||||
|
findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser,
|
||||||
getBalanceConfig: jest.fn(() => ({
|
getBalanceConfig: jest.fn(() => ({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
})),
|
})),
|
||||||
|
|
@ -446,6 +448,46 @@ describe('setupOpenId', () => {
|
||||||
expect(callOptions.params?.code_challenge_method).toBeUndefined();
|
expect(callOptions.params?.code_challenge_method).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should attach federatedTokens to user object for token propagation', async () => {
|
||||||
|
// Arrange - setup tokenset with access token, refresh token, and expiration
|
||||||
|
const tokensetWithTokens = {
|
||||||
|
...tokenset,
|
||||||
|
access_token: 'mock_access_token_abc123',
|
||||||
|
refresh_token: 'mock_refresh_token_xyz789',
|
||||||
|
expires_at: 1234567890,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act - validate with the tokenset containing tokens
|
||||||
|
const { user } = await validate(tokensetWithTokens);
|
||||||
|
|
||||||
|
// Assert - verify federatedTokens object is attached with correct values
|
||||||
|
expect(user.federatedTokens).toBeDefined();
|
||||||
|
expect(user.federatedTokens).toEqual({
|
||||||
|
access_token: 'mock_access_token_abc123',
|
||||||
|
refresh_token: 'mock_refresh_token_xyz789',
|
||||||
|
expires_at: 1234567890,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include tokenset along with federatedTokens', async () => {
|
||||||
|
// Arrange
|
||||||
|
const tokensetWithTokens = {
|
||||||
|
...tokenset,
|
||||||
|
access_token: 'test_access_token',
|
||||||
|
refresh_token: 'test_refresh_token',
|
||||||
|
expires_at: 9999999999,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { user } = await validate(tokensetWithTokens);
|
||||||
|
|
||||||
|
// Assert - both tokenset and federatedTokens should be present
|
||||||
|
expect(user.tokenset).toBeDefined();
|
||||||
|
expect(user.federatedTokens).toBeDefined();
|
||||||
|
expect(user.tokenset.access_token).toBe('test_access_token');
|
||||||
|
expect(user.federatedTokens.access_token).toBe('test_access_token');
|
||||||
|
});
|
||||||
|
|
||||||
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
|
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
|
||||||
// Act
|
// Act
|
||||||
const { user } = await validate(tokenset);
|
const { user } = await validate(tokenset);
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ module.exports = {
|
||||||
clientId: 'fake_client_id',
|
clientId: 'fake_client_id',
|
||||||
clientSecret: 'fake_client_secret',
|
clientSecret: 'fake_client_secret',
|
||||||
issuer: 'https://fake-issuer.com',
|
issuer: 'https://fake-issuer.com',
|
||||||
|
serverMetadata: jest.fn().mockReturnValue({
|
||||||
|
jwks_uri: 'https://fake-issuer.com/.well-known/jwks.json',
|
||||||
|
end_session_endpoint: 'https://fake-issuer.com/logout',
|
||||||
|
}),
|
||||||
Client: jest.fn().mockImplementation(() => ({
|
Client: jest.fn().mockImplementation(() => ({
|
||||||
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
|
authorizationUrl: jest.fn().mockReturnValue('mock_auth_url'),
|
||||||
callback: jest.fn().mockResolvedValue({
|
callback: jest.fn().mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import type {
|
||||||
} from '@librechat/agents';
|
} from '@librechat/agents';
|
||||||
import type { Agent } from 'librechat-data-provider';
|
import type { Agent } from 'librechat-data-provider';
|
||||||
import type * as t from '~/types';
|
import type * as t from '~/types';
|
||||||
import { resolveHeaders } from '~/utils/env';
|
import { resolveHeaders, createSafeUser } from '~/utils/env';
|
||||||
|
|
||||||
const customProviders = new Set([
|
const customProviders = new Set([
|
||||||
Providers.XAI,
|
Providers.XAI,
|
||||||
|
|
@ -66,6 +66,7 @@ export async function createRun({
|
||||||
signal,
|
signal,
|
||||||
agents,
|
agents,
|
||||||
requestBody,
|
requestBody,
|
||||||
|
user,
|
||||||
tokenCounter,
|
tokenCounter,
|
||||||
customHandlers,
|
customHandlers,
|
||||||
indexTokenCountMap,
|
indexTokenCountMap,
|
||||||
|
|
@ -78,6 +79,7 @@ export async function createRun({
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
streamUsage?: boolean;
|
streamUsage?: boolean;
|
||||||
requestBody?: t.RequestBody;
|
requestBody?: t.RequestBody;
|
||||||
|
user?: t.TUser;
|
||||||
} & Pick<RunConfig, 'tokenCounter' | 'customHandlers' | 'indexTokenCountMap'>): Promise<
|
} & Pick<RunConfig, 'tokenCounter' | 'customHandlers' | 'indexTokenCountMap'>): Promise<
|
||||||
Run<IState>
|
Run<IState>
|
||||||
> {
|
> {
|
||||||
|
|
@ -118,6 +120,7 @@ export async function createRun({
|
||||||
if (llmConfig?.configuration?.defaultHeaders != null) {
|
if (llmConfig?.configuration?.defaultHeaders != null) {
|
||||||
llmConfig.configuration.defaultHeaders = resolveHeaders({
|
llmConfig.configuration.defaultHeaders = resolveHeaders({
|
||||||
headers: llmConfig.configuration.defaultHeaders as Record<string, string>,
|
headers: llmConfig.configuration.defaultHeaders as Record<string, string>,
|
||||||
|
user: createSafeUser(user),
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,23 @@ export class MCPOAuthHandler {
|
||||||
// Check if we have pre-configured OAuth settings
|
// Check if we have pre-configured OAuth settings
|
||||||
if (config?.authorization_url && config?.token_url && config?.client_id) {
|
if (config?.authorization_url && config?.token_url && config?.client_id) {
|
||||||
logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for ${serverName}`);
|
logger.debug(`[MCPOAuth] Using pre-configured OAuth settings for ${serverName}`);
|
||||||
|
|
||||||
|
const skipCodeChallengeCheck =
|
||||||
|
config?.skip_code_challenge_check === true ||
|
||||||
|
process.env.MCP_SKIP_CODE_CHALLENGE_CHECK === 'true';
|
||||||
|
let codeChallengeMethodsSupported: string[];
|
||||||
|
|
||||||
|
if (config?.code_challenge_methods_supported !== undefined) {
|
||||||
|
codeChallengeMethodsSupported = config.code_challenge_methods_supported;
|
||||||
|
} else if (skipCodeChallengeCheck) {
|
||||||
|
codeChallengeMethodsSupported = ['S256', 'plain'];
|
||||||
|
logger.debug(
|
||||||
|
`[MCPOAuth] Code challenge check skip enabled, forcing S256 support for ${serverName}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
codeChallengeMethodsSupported = ['S256', 'plain'];
|
||||||
|
}
|
||||||
|
|
||||||
/** Metadata based on pre-configured settings */
|
/** Metadata based on pre-configured settings */
|
||||||
const metadata: OAuthMetadata = {
|
const metadata: OAuthMetadata = {
|
||||||
authorization_endpoint: config.authorization_url,
|
authorization_endpoint: config.authorization_url,
|
||||||
|
|
@ -238,10 +255,7 @@ export class MCPOAuthHandler {
|
||||||
'client_secret_post',
|
'client_secret_post',
|
||||||
],
|
],
|
||||||
response_types_supported: config?.response_types_supported ?? ['code'],
|
response_types_supported: config?.response_types_supported ?? ['code'],
|
||||||
code_challenge_methods_supported: config?.code_challenge_methods_supported ?? [
|
code_challenge_methods_supported: codeChallengeMethodsSupported,
|
||||||
'S256',
|
|
||||||
'plain',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
logger.debug(`[MCPOAuth] metadata for "${serverName}": ${JSON.stringify(metadata)}`);
|
logger.debug(`[MCPOAuth] metadata for "${serverName}": ${JSON.stringify(metadata)}`);
|
||||||
const clientInfo: OAuthClientInformation = {
|
const clientInfo: OAuthClientInformation = {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { extractEnvVariable } from 'librechat-data-provider';
|
||||||
import type { TUser, MCPOptions } from 'librechat-data-provider';
|
import type { TUser, MCPOptions } from 'librechat-data-provider';
|
||||||
import type { IUser } from '@librechat/data-schemas';
|
import type { IUser } from '@librechat/data-schemas';
|
||||||
import type { RequestBody } from '~/types';
|
import type { RequestBody } from '~/types';
|
||||||
|
import { extractOpenIDTokenInfo, processOpenIDPlaceholders, isOpenIDTokenValid } from './oidc';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of allowed user fields that can be used in MCP environment variables.
|
* List of allowed user fields that can be used in MCP environment variables.
|
||||||
|
|
@ -32,23 +33,29 @@ type SafeUser = Pick<IUser, AllowedUserField>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a safe user object containing only allowed fields.
|
* Creates a safe user object containing only allowed fields.
|
||||||
* Optimized for performance while maintaining type safety.
|
* Preserves federatedTokens for OpenID token template variable resolution.
|
||||||
*
|
*
|
||||||
* @param user - The user object to extract safe fields from
|
* @param user - The user object to extract safe fields from
|
||||||
* @returns A new object containing only allowed fields
|
* @returns A new object containing only allowed fields plus federatedTokens if present
|
||||||
*/
|
*/
|
||||||
export function createSafeUser(user: IUser | null | undefined): Partial<SafeUser> {
|
export function createSafeUser(
|
||||||
|
user: IUser | null | undefined,
|
||||||
|
): Partial<SafeUser> & { federatedTokens?: unknown } {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeUser: Partial<SafeUser> = {};
|
const safeUser: Partial<SafeUser> & { federatedTokens?: unknown } = {};
|
||||||
for (const field of ALLOWED_USER_FIELDS) {
|
for (const field of ALLOWED_USER_FIELDS) {
|
||||||
if (field in user) {
|
if (field in user) {
|
||||||
safeUser[field] = user[field];
|
safeUser[field] = user[field];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('federatedTokens' in user) {
|
||||||
|
safeUser.federatedTokens = user.federatedTokens;
|
||||||
|
}
|
||||||
|
|
||||||
return safeUser;
|
return safeUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,7 +146,6 @@ function processSingleValue({
|
||||||
}): string {
|
}): string {
|
||||||
let value = originalValue;
|
let value = originalValue;
|
||||||
|
|
||||||
// 1. Replace custom user variables
|
|
||||||
if (customUserVars) {
|
if (customUserVars) {
|
||||||
for (const [varName, varVal] of Object.entries(customUserVars)) {
|
for (const [varName, varVal] of Object.entries(customUserVars)) {
|
||||||
/** Escaped varName for use in regex to avoid issues with special characters */
|
/** Escaped varName for use in regex to avoid issues with special characters */
|
||||||
|
|
@ -149,15 +155,17 @@ function processSingleValue({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Replace user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}, {{LIBRECHAT_USER_ID}})
|
|
||||||
value = processUserPlaceholders(value, user);
|
value = processUserPlaceholders(value, user);
|
||||||
|
|
||||||
// 3. Replace body field placeholders (e.g., {{LIBRECHAT_BODY_CONVERSATIONID}}, {{LIBRECHAT_BODY_PARENTMESSAGEID}})
|
const openidTokenInfo = extractOpenIDTokenInfo(user);
|
||||||
|
if (openidTokenInfo && isOpenIDTokenValid(openidTokenInfo)) {
|
||||||
|
value = processOpenIDPlaceholders(value, openidTokenInfo);
|
||||||
|
}
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
value = processBodyPlaceholders(value, body);
|
value = processBodyPlaceholders(value, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Replace system environment variables
|
|
||||||
value = extractEnvVariable(value);
|
value = extractEnvVariable(value);
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
|
|
||||||
482
packages/api/src/utils/oidc.spec.ts
Normal file
482
packages/api/src/utils/oidc.spec.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
import { extractOpenIDTokenInfo, isOpenIDTokenValid, processOpenIDPlaceholders } from './oidc';
|
||||||
|
import type { TUser } from 'librechat-data-provider';
|
||||||
|
|
||||||
|
describe('OpenID Token Utilities', () => {
|
||||||
|
describe('extractOpenIDTokenInfo', () => {
|
||||||
|
it('should extract token info from user with federatedTokens', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'oidc-sub-456',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'access-token-value',
|
||||||
|
id_token: 'id-token-value',
|
||||||
|
refresh_token: 'refresh-token-value',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractOpenIDTokenInfo(user);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
idToken: 'id-token-value',
|
||||||
|
userId: expect.any(String),
|
||||||
|
userEmail: 'test@example.com',
|
||||||
|
userName: 'Test User',
|
||||||
|
});
|
||||||
|
expect(result?.expiresAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when user is undefined', () => {
|
||||||
|
const result = extractOpenIDTokenInfo(undefined);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when user is not OpenID provider', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'email',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractOpenIDTokenInfo(user);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return token info when user has no federatedTokens but is OpenID provider', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'oidc-sub-456',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractOpenIDTokenInfo(user);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
userEmail: 'test@example.com',
|
||||||
|
userName: 'Test User',
|
||||||
|
});
|
||||||
|
expect(result?.accessToken).toBeUndefined();
|
||||||
|
expect(result?.idToken).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract partial token info when some tokens are missing', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'oidc-sub-456',
|
||||||
|
email: 'test@example.com',
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'access-token-value',
|
||||||
|
id_token: undefined,
|
||||||
|
refresh_token: undefined,
|
||||||
|
expires_at: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractOpenIDTokenInfo(user);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
userEmail: 'test@example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize openidId over regular id', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'oidc-sub-456',
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'access-token-value',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractOpenIDTokenInfo(user);
|
||||||
|
|
||||||
|
expect(result?.userId).toBe('oidc-sub-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to regular id when openidId is not available', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'openid',
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'access-token-value',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = extractOpenIDTokenInfo(user);
|
||||||
|
|
||||||
|
expect(result?.userId).toBe('user-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isOpenIDTokenValid', () => {
|
||||||
|
it('should return false when tokenInfo is null', () => {
|
||||||
|
expect(isOpenIDTokenValid(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when tokenInfo has no accessToken', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when token has access token and no expiresAt', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when token has not expired', () => {
|
||||||
|
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
expiresAt: futureTimestamp,
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when token has expired', () => {
|
||||||
|
const pastTimestamp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
expiresAt: pastTimestamp,
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when token expires exactly now', () => {
|
||||||
|
const nowTimestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
expiresAt: nowTimestamp,
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when token is just about to expire (within 1 second)', () => {
|
||||||
|
const almostExpiredTimestamp = Math.floor(Date.now() / 1000) + 1;
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
expiresAt: almostExpiredTimestamp,
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processOpenIDPlaceholders', () => {
|
||||||
|
it('should replace LIBRECHAT_OPENID_TOKEN with access token', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
idToken: 'id-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('Authorization: Bearer access-token-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace LIBRECHAT_OPENID_ACCESS_TOKEN with access token', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = 'Token: {{LIBRECHAT_OPENID_ACCESS_TOKEN}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('Token: access-token-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace LIBRECHAT_OPENID_ID_TOKEN with id token', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
idToken: 'id-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = 'ID Token: {{LIBRECHAT_OPENID_ID_TOKEN}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('ID Token: id-token-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace LIBRECHAT_OPENID_USER_ID with user id', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = 'User: {{LIBRECHAT_OPENID_USER_ID}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('User: oidc-sub-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace LIBRECHAT_OPENID_USER_EMAIL with user email', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
userEmail: 'test@example.com',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = 'Email: {{LIBRECHAT_OPENID_USER_EMAIL}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('Email: test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace LIBRECHAT_OPENID_USER_NAME with user name', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
userName: 'Test User',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = 'Name: {{LIBRECHAT_OPENID_USER_NAME}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('Name: Test User');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace multiple placeholders in a single string', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
idToken: 'id-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
userEmail: 'test@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input =
|
||||||
|
'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
'Authorization: Bearer access-token-value, ID: id-token-value, User: oidc-sub-456',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace empty string when token field is undefined', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input =
|
||||||
|
'Access: {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('Access: , ID: , User: oidc-sub-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all placeholder types in one value', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
idToken: 'id-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
userEmail: 'test@example.com',
|
||||||
|
userName: 'Test User',
|
||||||
|
expiresAt: 1234567890,
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = `
|
||||||
|
Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}
|
||||||
|
ID Token: {{LIBRECHAT_OPENID_ID_TOKEN}}
|
||||||
|
Access Token (alt): {{LIBRECHAT_OPENID_ACCESS_TOKEN}}
|
||||||
|
User ID: {{LIBRECHAT_OPENID_USER_ID}}
|
||||||
|
User Email: {{LIBRECHAT_OPENID_USER_EMAIL}}
|
||||||
|
User Name: {{LIBRECHAT_OPENID_USER_NAME}}
|
||||||
|
Expires: {{LIBRECHAT_OPENID_EXPIRES_AT}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toContain('Bearer access-token-value');
|
||||||
|
expect(result).toContain('ID Token: id-token-value');
|
||||||
|
expect(result).toContain('Access Token (alt): access-token-value');
|
||||||
|
expect(result).toContain('User ID: oidc-sub-456');
|
||||||
|
expect(result).toContain('User Email: test@example.com');
|
||||||
|
expect(result).toContain('User Name: Test User');
|
||||||
|
expect(result).toContain('Expires: 1234567890');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify string when no placeholders are present', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = 'Authorization: Bearer static-token';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('Authorization: Bearer static-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case-sensitive placeholders', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrong case should NOT be replaced
|
||||||
|
const input = 'Token: {{librechat_openid_token}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('Token: {{librechat_openid_token}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple occurrences of the same placeholder', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const input =
|
||||||
|
'Primary: {{LIBRECHAT_OPENID_TOKEN}}, Secondary: {{LIBRECHAT_OPENID_TOKEN}}, Backup: {{LIBRECHAT_OPENID_TOKEN}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
'Primary: access-token-value, Secondary: access-token-value, Backup: access-token-value',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle token info with all fields undefined except userId', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: undefined,
|
||||||
|
idToken: undefined,
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
userEmail: undefined,
|
||||||
|
userName: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const input =
|
||||||
|
'Access: {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('Access: , ID: , User: oidc-sub-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original value when tokenInfo is null', () => {
|
||||||
|
const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, null);
|
||||||
|
|
||||||
|
expect(result).toBe('Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original value when value is not a string', () => {
|
||||||
|
const tokenInfo = {
|
||||||
|
accessToken: 'access-token-value',
|
||||||
|
userId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processOpenIDPlaceholders(123 as unknown as string, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe(123);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration: Full OpenID Token Flow', () => {
|
||||||
|
it('should extract, validate, and process tokens correctly', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'oidc-sub-456',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'access-token-value',
|
||||||
|
id_token: 'id-token-value',
|
||||||
|
refresh_token: 'refresh-token-value',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: Extract token info
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
||||||
|
expect(tokenInfo).not.toBeNull();
|
||||||
|
|
||||||
|
// Step 2: Validate token
|
||||||
|
const isValid = isOpenIDTokenValid(tokenInfo!);
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
|
||||||
|
// Step 3: Process placeholders
|
||||||
|
const input =
|
||||||
|
'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo!);
|
||||||
|
expect(result).toContain('Authorization: Bearer access-token-value');
|
||||||
|
expect(result).toContain('User:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle expired tokens correctly', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'oidc-sub-456',
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'access-token-value',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
||||||
|
expect(tokenInfo).not.toBeNull();
|
||||||
|
|
||||||
|
const isValid = isOpenIDTokenValid(tokenInfo!);
|
||||||
|
expect(isValid).toBe(false); // Token is expired
|
||||||
|
|
||||||
|
// Even if expired, processOpenIDPlaceholders should still work
|
||||||
|
// (validation is checked separately by the caller)
|
||||||
|
const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}';
|
||||||
|
const result = processOpenIDPlaceholders(input, tokenInfo!);
|
||||||
|
expect(result).toBe('Authorization: Bearer access-token-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle user with no federatedTokens but still has OpenID provider', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'oidc-sub-456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
||||||
|
expect(tokenInfo).not.toBeNull();
|
||||||
|
expect(tokenInfo?.userId).toBe('oidc-sub-456');
|
||||||
|
expect(tokenInfo?.accessToken).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing user', () => {
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(undefined);
|
||||||
|
expect(tokenInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-OpenID users', () => {
|
||||||
|
const user: Partial<TUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'email',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
||||||
|
expect(tokenInfo).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
197
packages/api/src/utils/oidc.ts
Normal file
197
packages/api/src/utils/oidc.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { logger } from '@librechat/data-schemas';
|
||||||
|
import type { TUser } from 'librechat-data-provider';
|
||||||
|
import type { IUser } from '@librechat/data-schemas';
|
||||||
|
|
||||||
|
export interface OpenIDTokenInfo {
|
||||||
|
accessToken?: string;
|
||||||
|
idToken?: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
userId?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
userName?: string;
|
||||||
|
claims?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FederatedTokens {
|
||||||
|
access_token?: string;
|
||||||
|
id_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFederatedTokens(obj: unknown): obj is FederatedTokens {
|
||||||
|
if (!obj || typeof obj !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return 'access_token' in obj || 'id_token' in obj || 'expires_at' in obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENID_TOKEN_FIELDS = [
|
||||||
|
'ACCESS_TOKEN',
|
||||||
|
'ID_TOKEN',
|
||||||
|
'USER_ID',
|
||||||
|
'USER_EMAIL',
|
||||||
|
'USER_NAME',
|
||||||
|
'EXPIRES_AT',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function extractOpenIDTokenInfo(
|
||||||
|
user: IUser | TUser | null | undefined,
|
||||||
|
): OpenIDTokenInfo | null {
|
||||||
|
if (!user) {
|
||||||
|
logger.debug('[extractOpenIDTokenInfo] No user provided');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug(
|
||||||
|
'[extractOpenIDTokenInfo] User provider:',
|
||||||
|
user.provider,
|
||||||
|
'openidId:',
|
||||||
|
user.openidId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user.provider !== 'openid' && !user.openidId) {
|
||||||
|
logger.debug('[extractOpenIDTokenInfo] User not authenticated via OpenID');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenInfo: OpenIDTokenInfo = {};
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'[extractOpenIDTokenInfo] Checking for federatedTokens in user object:',
|
||||||
|
'federatedTokens' in user,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ('federatedTokens' in user && isFederatedTokens(user.federatedTokens)) {
|
||||||
|
const tokens = user.federatedTokens;
|
||||||
|
logger.debug('[extractOpenIDTokenInfo] Found federatedTokens:', {
|
||||||
|
has_access_token: !!tokens.access_token,
|
||||||
|
has_id_token: !!tokens.id_token,
|
||||||
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
|
expires_at: tokens.expires_at,
|
||||||
|
});
|
||||||
|
tokenInfo.accessToken = tokens.access_token;
|
||||||
|
tokenInfo.idToken = tokens.id_token;
|
||||||
|
tokenInfo.expiresAt = tokens.expires_at;
|
||||||
|
} else if ('openidTokens' in user && isFederatedTokens(user.openidTokens)) {
|
||||||
|
const tokens = user.openidTokens;
|
||||||
|
logger.debug('[extractOpenIDTokenInfo] Found openidTokens');
|
||||||
|
tokenInfo.accessToken = tokens.access_token;
|
||||||
|
tokenInfo.idToken = tokens.id_token;
|
||||||
|
tokenInfo.expiresAt = tokens.expires_at;
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
'[extractOpenIDTokenInfo] No federatedTokens or openidTokens found in user object',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenInfo.userId = user.openidId || user.id;
|
||||||
|
tokenInfo.userEmail = user.email;
|
||||||
|
tokenInfo.userName = user.name || user.username;
|
||||||
|
|
||||||
|
if (tokenInfo.idToken) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(tokenInfo.idToken.split('.')[1], 'base64').toString(),
|
||||||
|
);
|
||||||
|
tokenInfo.claims = payload;
|
||||||
|
|
||||||
|
if (payload.sub) tokenInfo.userId = payload.sub;
|
||||||
|
if (payload.email) tokenInfo.userEmail = payload.email;
|
||||||
|
if (payload.name) tokenInfo.userName = payload.name;
|
||||||
|
if (payload.exp) tokenInfo.expiresAt = payload.exp;
|
||||||
|
} catch (jwtError) {
|
||||||
|
logger.warn('Could not parse ID token claims:', jwtError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenInfo;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error extracting OpenID token info:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpenIDTokenValid(tokenInfo: OpenIDTokenInfo | null): boolean {
|
||||||
|
if (!tokenInfo || !tokenInfo.accessToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenInfo.expiresAt) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (now >= tokenInfo.expiresAt) {
|
||||||
|
logger.warn('OpenID token has expired');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processOpenIDPlaceholders(
|
||||||
|
value: string,
|
||||||
|
tokenInfo: OpenIDTokenInfo | null,
|
||||||
|
): string {
|
||||||
|
if (!tokenInfo || typeof value !== 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedValue = value;
|
||||||
|
|
||||||
|
for (const field of OPENID_TOKEN_FIELDS) {
|
||||||
|
const placeholder = `{{LIBRECHAT_OPENID_${field}}}`;
|
||||||
|
if (!processedValue.includes(placeholder)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let replacementValue = '';
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case 'ACCESS_TOKEN':
|
||||||
|
replacementValue = tokenInfo.accessToken || '';
|
||||||
|
break;
|
||||||
|
case 'ID_TOKEN':
|
||||||
|
replacementValue = tokenInfo.idToken || '';
|
||||||
|
break;
|
||||||
|
case 'USER_ID':
|
||||||
|
replacementValue = tokenInfo.userId || '';
|
||||||
|
break;
|
||||||
|
case 'USER_EMAIL':
|
||||||
|
replacementValue = tokenInfo.userEmail || '';
|
||||||
|
break;
|
||||||
|
case 'USER_NAME':
|
||||||
|
replacementValue = tokenInfo.userName || '';
|
||||||
|
break;
|
||||||
|
case 'EXPIRES_AT':
|
||||||
|
replacementValue = tokenInfo.expiresAt ? String(tokenInfo.expiresAt) : '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedValue = processedValue.replace(new RegExp(placeholder, 'g'), replacementValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const genericPlaceholder = '{{LIBRECHAT_OPENID_TOKEN}}';
|
||||||
|
if (processedValue.includes(genericPlaceholder)) {
|
||||||
|
const replacementValue = tokenInfo.accessToken || '';
|
||||||
|
processedValue = processedValue.replace(new RegExp(genericPlaceholder, 'g'), replacementValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBearerAuthHeader(tokenInfo: OpenIDTokenInfo | null): string {
|
||||||
|
if (!tokenInfo || !tokenInfo.accessToken) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Bearer ${tokenInfo.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpenIDAvailable(): boolean {
|
||||||
|
const openidClientId = process.env.OPENID_CLIENT_ID;
|
||||||
|
const openidClientSecret = process.env.OPENID_CLIENT_SECRET;
|
||||||
|
const openidIssuer = process.env.OPENID_ISSUER;
|
||||||
|
|
||||||
|
return !!(openidClientId && openidClientSecret && openidIssuer);
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,8 @@ const BaseOptionsSchema = z.object({
|
||||||
response_types_supported: z.array(z.string()).optional(),
|
response_types_supported: z.array(z.string()).optional(),
|
||||||
/** Supported code challenge methods (defaults to ['S256', 'plain']) */
|
/** Supported code challenge methods (defaults to ['S256', 'plain']) */
|
||||||
code_challenge_methods_supported: z.array(z.string()).optional(),
|
code_challenge_methods_supported: z.array(z.string()).optional(),
|
||||||
|
/** Skip code challenge validation and force S256 (useful for providers like AWS Cognito that support S256 but don't advertise it) */
|
||||||
|
skip_code_challenge_check: z.boolean().optional(),
|
||||||
/** OAuth revocation endpoint (optional - can be auto-discovered) */
|
/** OAuth revocation endpoint (optional - can be auto-discovered) */
|
||||||
revocation_endpoint: z.string().url().optional(),
|
revocation_endpoint: z.string().url().optional(),
|
||||||
/** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */
|
/** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */
|
||||||
|
|
|
||||||
473
src/tests/oidc-integration.test.ts
Normal file
473
src/tests/oidc-integration.test.ts
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||||
|
import {
|
||||||
|
extractOpenIDTokenInfo,
|
||||||
|
processOpenIDPlaceholders,
|
||||||
|
isOpenIDTokenValid,
|
||||||
|
createBearerAuthHeader,
|
||||||
|
isOpenIDAvailable,
|
||||||
|
type OpenIDTokenInfo,
|
||||||
|
} from '../packages/api/src/utils/oidc';
|
||||||
|
import { processMCPEnv, resolveHeaders } from '../packages/api/src/utils/env';
|
||||||
|
import type { TUser } from 'librechat-data-provider';
|
||||||
|
import type { IUser } from '@librechat/data-schemas';
|
||||||
|
|
||||||
|
// Mock logger to avoid console output during tests
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
logger: {
|
||||||
|
error: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('OpenID Connect Federated Provider Token Integration', () => {
|
||||||
|
// Mock user with Cognito tokens
|
||||||
|
const mockCognitoUser: Partial<IUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'cognito-user-123',
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'cognito-access-token-123',
|
||||||
|
id_token: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjb2duaXRvLXVzZXItMTIzIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsImV4cCI6MTcwMDAwMDAwMH0.fake-signature',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600, // Expires in 1 hour
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockExpiredCognitoUser: Partial<IUser> = {
|
||||||
|
...mockCognitoUser,
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'expired-cognito-token',
|
||||||
|
id_token: 'expired-cognito-id-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock user with tokens in alternative location
|
||||||
|
const mockOpenIDTokensUser: Partial<IUser> = {
|
||||||
|
id: 'user-456',
|
||||||
|
email: 'alt@example.com',
|
||||||
|
name: 'Alt User',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'alt-user-456',
|
||||||
|
openidTokens: {
|
||||||
|
access_token: 'alt-access-token-456',
|
||||||
|
id_token: 'alt-id-token-789',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractOpenIDTokenInfo', () => {
|
||||||
|
it('should extract federated provider token info from Cognito user', () => {
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(mockCognitoUser as IUser);
|
||||||
|
|
||||||
|
expect(tokenInfo).toEqual({
|
||||||
|
accessToken: 'cognito-access-token-123',
|
||||||
|
idToken: expect.stringContaining('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'),
|
||||||
|
expiresAt: expect.any(Number),
|
||||||
|
userId: 'cognito-user-123',
|
||||||
|
userEmail: 'test@example.com',
|
||||||
|
userName: 'Test User',
|
||||||
|
claims: expect.objectContaining({
|
||||||
|
sub: 'cognito-user-123',
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'Test User',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract tokens from alternative storage location', () => {
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(mockOpenIDTokensUser as IUser);
|
||||||
|
|
||||||
|
expect(tokenInfo).toEqual({
|
||||||
|
accessToken: 'alt-access-token-456',
|
||||||
|
idToken: 'alt-id-token-789',
|
||||||
|
expiresAt: expect.any(Number),
|
||||||
|
userId: 'alt-user-456',
|
||||||
|
userEmail: 'alt@example.com',
|
||||||
|
userName: 'Alt User',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-OpenID user', () => {
|
||||||
|
const nonOpenIDUser: Partial<IUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'google',
|
||||||
|
email: 'test@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(nonOpenIDUser as IUser);
|
||||||
|
expect(tokenInfo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for null/undefined user', () => {
|
||||||
|
expect(extractOpenIDTokenInfo(null)).toBeNull();
|
||||||
|
expect(extractOpenIDTokenInfo(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JWT parsing errors gracefully', () => {
|
||||||
|
const userWithMalformedJWT: Partial<IUser> = {
|
||||||
|
...mockCognitoUser,
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'valid-access-token',
|
||||||
|
id_token: 'malformed.jwt.token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(userWithMalformedJWT as IUser);
|
||||||
|
|
||||||
|
expect(tokenInfo).toBeDefined();
|
||||||
|
expect(tokenInfo?.accessToken).toBe('valid-access-token');
|
||||||
|
expect(tokenInfo?.claims).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isOpenIDTokenValid', () => {
|
||||||
|
it('should return true for valid Cognito token', () => {
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(mockCognitoUser as IUser);
|
||||||
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for expired Cognito token', () => {
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(mockExpiredCognitoUser as IUser);
|
||||||
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for null token info', () => {
|
||||||
|
expect(isOpenIDTokenValid(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for token info without access token', () => {
|
||||||
|
const tokenInfo: OpenIDTokenInfo = {
|
||||||
|
userId: 'user-123',
|
||||||
|
userEmail: 'test@example.com',
|
||||||
|
};
|
||||||
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processOpenIDPlaceholders', () => {
|
||||||
|
const tokenInfo: OpenIDTokenInfo = {
|
||||||
|
accessToken: 'cognito-access-token-123',
|
||||||
|
idToken: 'cognito-id-token-456',
|
||||||
|
userId: 'cognito-user-789',
|
||||||
|
userEmail: 'cognito@example.com',
|
||||||
|
userName: 'Cognito User',
|
||||||
|
expiresAt: 1700000000,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should replace OpenID Connect token placeholders', () => {
|
||||||
|
const template = 'Bearer {{LIBRECHAT_OPENID_TOKEN}}';
|
||||||
|
const result = processOpenIDPlaceholders(template, tokenInfo);
|
||||||
|
expect(result).toBe('Bearer cognito-access-token-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace specific OpenID Connect placeholders', () => {
|
||||||
|
const template = `
|
||||||
|
Access: {{LIBRECHAT_OPENID_ACCESS_TOKEN}}
|
||||||
|
ID: {{LIBRECHAT_OPENID_ID_TOKEN}}
|
||||||
|
User: {{LIBRECHAT_OPENID_USER_ID}}
|
||||||
|
Email: {{LIBRECHAT_OPENID_USER_EMAIL}}
|
||||||
|
Name: {{LIBRECHAT_OPENID_USER_NAME}}
|
||||||
|
Expires: {{LIBRECHAT_OPENID_EXPIRES_AT}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = processOpenIDPlaceholders(template, tokenInfo);
|
||||||
|
|
||||||
|
expect(result).toContain('Access: cognito-access-token-123');
|
||||||
|
expect(result).toContain('ID: cognito-id-token-456');
|
||||||
|
expect(result).toContain('User: cognito-user-789');
|
||||||
|
expect(result).toContain('Email: cognito@example.com');
|
||||||
|
expect(result).toContain('Name: Cognito User');
|
||||||
|
expect(result).toContain('Expires: 1700000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing token fields gracefully', () => {
|
||||||
|
const partialTokenInfo: OpenIDTokenInfo = {
|
||||||
|
accessToken: 'partial-cognito-token',
|
||||||
|
userId: 'user-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const template = 'Token: {{LIBRECHAT_OPENID_TOKEN}}, Email: {{LIBRECHAT_OPENID_USER_EMAIL}}';
|
||||||
|
const result = processOpenIDPlaceholders(template, partialTokenInfo);
|
||||||
|
|
||||||
|
expect(result).toBe('Token: partial-cognito-token, Email: ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original value for null token info', () => {
|
||||||
|
const template = 'Bearer {{LIBRECHAT_OPENID_TOKEN}}';
|
||||||
|
const result = processOpenIDPlaceholders(template, null);
|
||||||
|
expect(result).toBe(template);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createBearerAuthHeader', () => {
|
||||||
|
it('should create proper Bearer header with Cognito token', () => {
|
||||||
|
const tokenInfo: OpenIDTokenInfo = {
|
||||||
|
accessToken: 'cognito-test-token-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = createBearerAuthHeader(tokenInfo);
|
||||||
|
expect(header).toBe('Bearer cognito-test-token-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string for null token info', () => {
|
||||||
|
const header = createBearerAuthHeader(null);
|
||||||
|
expect(header).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string for token info without access token', () => {
|
||||||
|
const tokenInfo: OpenIDTokenInfo = {
|
||||||
|
userId: 'user-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = createBearerAuthHeader(tokenInfo);
|
||||||
|
expect(header).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isOpenIDAvailable', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when OpenID Connect is properly configured for Cognito', () => {
|
||||||
|
process.env.OPENID_CLIENT_ID = 'cognito-client-id';
|
||||||
|
process.env.OPENID_CLIENT_SECRET = 'cognito-client-secret';
|
||||||
|
process.env.OPENID_ISSUER = 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123';
|
||||||
|
|
||||||
|
expect(isOpenIDAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when OpenID Connect is not configured', () => {
|
||||||
|
delete process.env.OPENID_CLIENT_ID;
|
||||||
|
delete process.env.OPENID_CLIENT_SECRET;
|
||||||
|
delete process.env.OPENID_ISSUER;
|
||||||
|
|
||||||
|
expect(isOpenIDAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when OpenID Connect is partially configured', () => {
|
||||||
|
process.env.OPENID_CLIENT_ID = 'cognito-client-id';
|
||||||
|
delete process.env.OPENID_CLIENT_SECRET;
|
||||||
|
delete process.env.OPENID_ISSUER;
|
||||||
|
|
||||||
|
expect(isOpenIDAvailable()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration with resolveHeaders', () => {
|
||||||
|
it('should resolve OpenID Connect placeholders in headers for Cognito', () => {
|
||||||
|
const headers = {
|
||||||
|
'Authorization': '{{LIBRECHAT_OPENID_TOKEN}}',
|
||||||
|
'X-User-ID': '{{LIBRECHAT_OPENID_USER_ID}}',
|
||||||
|
'X-User-Email': '{{LIBRECHAT_OPENID_USER_EMAIL}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedHeaders = resolveHeaders({
|
||||||
|
headers,
|
||||||
|
user: mockCognitoUser as TUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolvedHeaders['Authorization']).toBe('cognito-access-token-123');
|
||||||
|
expect(resolvedHeaders['X-User-ID']).toBe('cognito-user-123');
|
||||||
|
expect(resolvedHeaders['X-User-Email']).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with Bearer token format for Cognito', () => {
|
||||||
|
const headers = {
|
||||||
|
'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedHeaders = resolveHeaders({
|
||||||
|
headers,
|
||||||
|
user: mockCognitoUser as TUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolvedHeaders['Authorization']).toBe('Bearer cognito-access-token-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with specific access token placeholder', () => {
|
||||||
|
const headers = {
|
||||||
|
'Authorization': 'Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}',
|
||||||
|
'X-Cognito-ID-Token': '{{LIBRECHAT_OPENID_ID_TOKEN}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedHeaders = resolveHeaders({
|
||||||
|
headers,
|
||||||
|
user: mockCognitoUser as TUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolvedHeaders['Authorization']).toBe('Bearer cognito-access-token-123');
|
||||||
|
expect(resolvedHeaders['X-Cognito-ID-Token']).toContain('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration with processMCPEnv', () => {
|
||||||
|
it('should process OpenID Connect placeholders in MCP environment variables for Cognito', () => {
|
||||||
|
const mcpOptions = {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
env: {
|
||||||
|
'COGNITO_ACCESS_TOKEN': '{{LIBRECHAT_OPENID_TOKEN}}',
|
||||||
|
'USER_ID': '{{LIBRECHAT_OPENID_USER_ID}}',
|
||||||
|
'USER_EMAIL': '{{LIBRECHAT_OPENID_USER_EMAIL}}',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const processedOptions = processMCPEnv({
|
||||||
|
options: mcpOptions,
|
||||||
|
user: mockCognitoUser as TUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(processedOptions.env?.['COGNITO_ACCESS_TOKEN']).toBe('cognito-access-token-123');
|
||||||
|
expect(processedOptions.env?.['USER_ID']).toBe('cognito-user-123');
|
||||||
|
expect(processedOptions.env?.['USER_EMAIL']).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process OpenID Connect placeholders in MCP headers for HTTP transport', () => {
|
||||||
|
const mcpOptions = {
|
||||||
|
type: 'sse' as const,
|
||||||
|
url: 'https://api.example.com/mcp',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}',
|
||||||
|
'X-Cognito-User-Info': '{{LIBRECHAT_OPENID_USER_EMAIL}}',
|
||||||
|
'X-Cognito-ID-Token': '{{LIBRECHAT_OPENID_ID_TOKEN}}',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const processedOptions = processMCPEnv({
|
||||||
|
options: mcpOptions,
|
||||||
|
user: mockCognitoUser as TUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(processedOptions.headers?.['Authorization']).toBe('Bearer cognito-access-token-123');
|
||||||
|
expect(processedOptions.headers?.['X-Cognito-User-Info']).toBe('test@example.com');
|
||||||
|
expect(processedOptions.headers?.['X-Cognito-ID-Token']).toContain('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle AWS-specific MCP server configuration', () => {
|
||||||
|
const awsMcpOptions = {
|
||||||
|
command: 'node',
|
||||||
|
args: ['aws-mcp-server.js'],
|
||||||
|
env: {
|
||||||
|
'AWS_COGNITO_TOKEN': '{{LIBRECHAT_OPENID_ACCESS_TOKEN}}',
|
||||||
|
'AWS_COGNITO_ID_TOKEN': '{{LIBRECHAT_OPENID_ID_TOKEN}}',
|
||||||
|
'COGNITO_USER_SUB': '{{LIBRECHAT_OPENID_USER_ID}}',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const processedOptions = processMCPEnv({
|
||||||
|
options: awsMcpOptions,
|
||||||
|
user: mockCognitoUser as TUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(processedOptions.env?.['AWS_COGNITO_TOKEN']).toBe('cognito-access-token-123');
|
||||||
|
expect(processedOptions.env?.['AWS_COGNITO_ID_TOKEN']).toContain('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9');
|
||||||
|
expect(processedOptions.env?.['COGNITO_USER_SUB']).toBe('cognito-user-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Security and Edge Cases', () => {
|
||||||
|
it('should not process OpenID Connect placeholders for expired tokens', () => {
|
||||||
|
const headers = {
|
||||||
|
'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedHeaders = resolveHeaders({
|
||||||
|
headers,
|
||||||
|
user: mockExpiredCognitoUser as TUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not replace placeholder if token is expired
|
||||||
|
expect(resolvedHeaders['Authorization']).toBe('Bearer {{LIBRECHAT_OPENID_TOKEN}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed federated token data gracefully', () => {
|
||||||
|
const malformedUser: Partial<IUser> = {
|
||||||
|
id: 'user-123',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'cognito-user',
|
||||||
|
federatedTokens: null, // Malformed tokens
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedHeaders = resolveHeaders({
|
||||||
|
headers,
|
||||||
|
user: malformedUser as TUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not replace placeholder if token extraction fails
|
||||||
|
expect(resolvedHeaders['Authorization']).toBe('Bearer {{LIBRECHAT_OPENID_TOKEN}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple placeholder instances in same string', () => {
|
||||||
|
const template = '{{LIBRECHAT_OPENID_TOKEN}}-{{LIBRECHAT_OPENID_TOKEN}}-{{LIBRECHAT_OPENID_USER_ID}}';
|
||||||
|
|
||||||
|
const tokenInfo: OpenIDTokenInfo = {
|
||||||
|
accessToken: 'cognito-token123',
|
||||||
|
userId: 'cognito-user456',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processOpenIDPlaceholders(template, tokenInfo);
|
||||||
|
expect(result).toBe('cognito-token123-cognito-token123-cognito-user456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle users without federated tokens storage', () => {
|
||||||
|
const userWithoutTokens: Partial<IUser> = {
|
||||||
|
id: 'user-789',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'user-without-tokens',
|
||||||
|
email: 'no-tokens@example.com',
|
||||||
|
// No federatedTokens or openidTokens
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedHeaders = resolveHeaders({
|
||||||
|
headers,
|
||||||
|
user: userWithoutTokens as TUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not replace placeholder if no tokens available
|
||||||
|
expect(resolvedHeaders['Authorization']).toBe('Bearer {{LIBRECHAT_OPENID_TOKEN}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize federatedTokens over openidTokens', () => {
|
||||||
|
const userWithBothTokens: Partial<IUser> = {
|
||||||
|
id: 'user-priority',
|
||||||
|
provider: 'openid',
|
||||||
|
openidId: 'priority-user',
|
||||||
|
federatedTokens: {
|
||||||
|
access_token: 'federated-priority-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
},
|
||||||
|
openidTokens: {
|
||||||
|
access_token: 'openid-fallback-token',
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenInfo = extractOpenIDTokenInfo(userWithBothTokens as IUser);
|
||||||
|
expect(tokenInfo?.accessToken).toBe('federated-priority-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue