Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
61ee3bf
Remote Agent Auth middleware
SpectralOne Mar 18, 2026
612358d
consider migration and update user
SpectralOne Mar 30, 2026
56c89da
fix eslint errors
SpectralOne Mar 30, 2026
bdc43fd
add scope validation
SpectralOne Mar 31, 2026
6ef10b1
fix codex review errors
SpectralOne Apr 13, 2026
446f4ea
add filter for use: sig
SpectralOne Apr 15, 2026
4fa6864
add jwks-rsa deps
SpectralOne Apr 15, 2026
d97d1f8
Fix remote agent OIDC auth review findings
danny-avila Apr 29, 2026
83463e6
Polish remote agent OIDC timeout coverage
danny-avila Apr 29, 2026
9d290cc
Reject remote OIDC tokens without subject
danny-avila Apr 29, 2026
e8d935b
Use tenant context for remote agent auth config
danny-avila Apr 29, 2026
c60e89b
Harden remote agent OIDC scope handling
danny-avila Apr 29, 2026
7448285
Polish remote agent OIDC cache and scope tests
danny-avila Apr 30, 2026
b260e94
Resolve remote agent auth review comments
danny-avila Apr 30, 2026
cedf3f0
Reuse OpenID email claim resolver for remote auth
danny-avila Apr 30, 2026
4fcdf5e
Skip empty OpenID email fallback claims
danny-avila Apr 30, 2026
b6acb4d
Use pre-auth tenant context for remote auth config
danny-avila Apr 30, 2026
f9e154d
Downgrade expected OIDC fallback logging
danny-avila May 1, 2026
5a15339
Require secure remote OIDC endpoints
danny-avila May 1, 2026
b6da8bd
Polish remote agent auth edge cases
danny-avila May 1, 2026
6ed2ea8
Enforce unique balance records
danny-avila May 1, 2026
354a6a2
Bind remote OpenID users to issuer
danny-avila May 1, 2026
942acb9
Fix issuer-scoped OpenID indexes
danny-avila May 1, 2026
da7d2c8
Avoid unique balance index requirement
danny-avila May 1, 2026
e5846f5
Fix remote OpenID issuer normalization boundaries
danny-avila May 1, 2026
2598f0e
Require issuer-bound OpenID lookups
danny-avila May 1, 2026
9091312
Enforce tenant API key policy after auth
danny-avila May 2, 2026
bfb9350
Fix remote auth tenant policy types
danny-avila May 2, 2026
a854795
Normalize remote OIDC discovery issuer
danny-avila May 4, 2026
340d0a3
Allow normalized remote OIDC issuer validation
danny-avila May 4, 2026
cd11673
Enforce resolved tenant OIDC policy
danny-avila May 4, 2026
ea78ed3
Polish OpenID issuer and scope validation
danny-avila May 4, 2026
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: 4 additions & 1 deletion api/server/controllers/AuthController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, findOpenIDUser } = require('@librechat/api');
const { isEnabled, findOpenIDUser, getOpenIdIssuer } = require('@librechat/api');
const {
requestPasswordReset,
setOpenIDAuthTokens,
Expand Down Expand Up @@ -85,10 +85,12 @@ const refreshController = async (req, res) => {
refreshParams,
);
const claims = tokenset.claims();
const openidIssuer = getOpenIdIssuer(claims, openIdConfig);
const { user, error, migration } = await findOpenIDUser({
findUser,
email: getOpenIdEmail(claims),
openidId: claims.sub,
openidIssuer,
idOnTheSource: claims.oid,
strategyName: 'refreshController',
});
Expand All @@ -111,6 +113,7 @@ const refreshController = async (req, res) => {
await updateUser(user._id.toString(), {
provider: 'openid',
openidId: claims.sub,
...(openidIssuer ? { openidIssuer } : {}),
});
logger.info(
`[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`,
Expand Down
23 changes: 19 additions & 4 deletions api/server/controllers/AuthController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jest.mock('~/models', () => ({
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(),
findOpenIDUser: jest.fn(),
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
}));

const openIdClient = require('openid-client');
Expand Down Expand Up @@ -157,6 +158,7 @@ describe('refreshController – OpenID path', () => {
};

const baseClaims = {
iss: 'https://issuer.example.com',
sub: 'oidc-sub-123',
oid: 'oid-456',
email: 'user@example.com',
Expand Down Expand Up @@ -204,7 +206,10 @@ describe('refreshController – OpenID path', () => {

expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({ email: baseClaims.email }),
expect.objectContaining({
email: baseClaims.email,
openidIssuer: baseClaims.iss,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
Expand All @@ -225,7 +230,10 @@ describe('refreshController – OpenID path', () => {

expect(getOpenIdEmail).toHaveBeenCalledWith(claimsWithUpn);
expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({ email: 'user@corp.example.com' }),
expect.objectContaining({
email: 'user@corp.example.com',
openidIssuer: baseClaims.iss,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
Expand All @@ -236,7 +244,10 @@ describe('refreshController – OpenID path', () => {
await refreshController(req, res);

expect(findOpenIDUser).toHaveBeenCalledWith(
expect.objectContaining({ email: baseClaims.email }),
expect.objectContaining({
email: baseClaims.email,
openidIssuer: baseClaims.iss,
}),
);
});

Expand Down Expand Up @@ -267,7 +278,11 @@ describe('refreshController – OpenID path', () => {

expect(updateUser).toHaveBeenCalledWith(
'user-db-id',
expect.objectContaining({ provider: 'openid', openidId: baseClaims.sub }),
expect.objectContaining({
provider: 'openid',
openidId: baseClaims.sub,
openidIssuer: baseClaims.iss,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
});
Expand Down
41 changes: 41 additions & 0 deletions api/server/routes/agents/middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
generateCheckAccess,
preAuthTenantMiddleware,
createRequireApiKeyAuth,
createRemoteAgentAuth,
createCheckRemoteAgentAccess,
} = require('@librechat/api');
const { getEffectivePermissions } = require('~/server/services/PermissionService');
const { getAppConfig } = require('~/server/services/Config');
const db = require('~/models');

const apiKeyMiddleware = createRequireApiKeyAuth({
validateAgentApiKey: db.validateAgentApiKey,
findUser: db.findUser,
});

const requireRemoteAgentAuth = createRemoteAgentAuth({
apiKeyMiddleware,
findUser: db.findUser,
updateUser: db.updateUser,
getAppConfig,
});

const checkRemoteAgentsFeature = generateCheckAccess({
permissionType: PermissionTypes.REMOTE_AGENTS,
permissions: [Permissions.USE],
getRoleByName: db.getRoleByName,
});

const checkAgentPermission = createCheckRemoteAgentAccess({
getAgent: db.getAgent,
getEffectivePermissions,
});

module.exports = {
checkAgentPermission,
preAuthTenantMiddleware,
requireRemoteAgentAuth,
checkRemoteAgentsFeature,
};
33 changes: 8 additions & 25 deletions api/server/routes/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,40 +17,23 @@
* }
*/
const express = require('express');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
generateCheckAccess,
createRequireApiKeyAuth,
createCheckRemoteAgentAccess,
} = require('@librechat/api');
const {
OpenAIChatCompletionController,
ListModelsController,
GetModelController,
} = require('~/server/controllers/agents/openai');
const { getEffectivePermissions } = require('~/server/services/PermissionService');
const { configMiddleware } = require('~/server/middleware');
const db = require('~/models');
const {
checkAgentPermission,
preAuthTenantMiddleware,
requireRemoteAgentAuth,
checkRemoteAgentsFeature,
} = require('./middleware');

const router = express.Router();

const requireApiKeyAuth = createRequireApiKeyAuth({
validateAgentApiKey: db.validateAgentApiKey,
findUser: db.findUser,
});

const checkRemoteAgentsFeature = generateCheckAccess({
permissionType: PermissionTypes.REMOTE_AGENTS,
permissions: [Permissions.USE],
getRoleByName: db.getRoleByName,
});

const checkAgentPermission = createCheckRemoteAgentAccess({
getAgent: db.getAgent,
getEffectivePermissions,
});

router.use(requireApiKeyAuth);
router.use(preAuthTenantMiddleware);
router.use(requireRemoteAgentAuth);
router.use(configMiddleware);
router.use(checkRemoteAgentsFeature);

Expand Down
33 changes: 8 additions & 25 deletions api/server/routes/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,23 @@
* @see https://openresponses.org/specification
*/
const express = require('express');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
generateCheckAccess,
createRequireApiKeyAuth,
createCheckRemoteAgentAccess,
} = require('@librechat/api');
const {
createResponse,
getResponse,
listModels,
} = require('~/server/controllers/agents/responses');
const { getEffectivePermissions } = require('~/server/services/PermissionService');
const { configMiddleware } = require('~/server/middleware');
const db = require('~/models');
const {
checkAgentPermission,
preAuthTenantMiddleware,
requireRemoteAgentAuth,
checkRemoteAgentsFeature,
} = require('./middleware');

const router = express.Router();

const requireApiKeyAuth = createRequireApiKeyAuth({
validateAgentApiKey: db.validateAgentApiKey,
findUser: db.findUser,
});

const checkRemoteAgentsFeature = generateCheckAccess({
permissionType: PermissionTypes.REMOTE_AGENTS,
permissions: [Permissions.USE],
getRoleByName: db.getRoleByName,
});

const checkAgentPermission = createCheckRemoteAgentAccess({
getAgent: db.getAgent,
getEffectivePermissions,
});

router.use(requireApiKeyAuth);
router.use(preAuthTenantMiddleware);
router.use(requireRemoteAgentAuth);
router.use(configMiddleware);
router.use(checkRemoteAgentsFeature);

Expand Down
18 changes: 15 additions & 3 deletions api/strategies/openIdJwtStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@ const jwksRsa = require('jwks-rsa');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { SystemRoles } = require('librechat-data-provider');
const { isEnabled, findOpenIDUser, math } = require('@librechat/api');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { getOpenIdEmail } = require('./openidStrategy');
const {
isEnabled,
findOpenIDUser,
getOpenIdEmail,
getOpenIdIssuer,
math,
} = require('@librechat/api');
const { updateUser, findUser } = require('~/models');

/**
Expand All @@ -27,7 +32,9 @@ const { updateUser, findUser } = require('~/models');
*/
const openIdJwtLogin = (openIdConfig) => {
let jwksRsaOptions = {
cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true,
cache: process.env.OPENID_JWKS_URL_CACHE_ENABLED
? isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED)
: true,
cacheMaxAge: math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000),
jwksUri: openIdConfig.serverMetadata().jwks_uri,
};
Expand All @@ -51,11 +58,13 @@ const openIdJwtLogin = (openIdConfig) => {
try {
const authHeader = req.headers.authorization;
const rawToken = authHeader?.replace('Bearer ', '');
const openidIssuer = getOpenIdIssuer(payload, openIdConfig);

const { user, error, migration } = await findOpenIDUser({
findUser,
email: payload ? getOpenIdEmail(payload) : undefined,
openidId: payload?.sub,
openidIssuer,
idOnTheSource: payload?.oid,
strategyName: 'openIdJwtLogin',
});
Expand All @@ -72,6 +81,9 @@ const openIdJwtLogin = (openIdConfig) => {
if (migration) {
updateData.provider = 'openid';
updateData.openidId = payload?.sub;
if (openidIssuer) {
updateData.openidIssuer = openidIssuer;
}
}
if (!user.role) {
user.role = SystemRoles.USER;
Expand Down
22 changes: 18 additions & 4 deletions api/strategies/openIdJwtStrategy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jest.mock('@librechat/data-schemas', () => ({
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(() => false),
findOpenIDUser: jest.fn(),
getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail,
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
math: jest.fn((val, fallback) => fallback),
}));
jest.mock('~/models', () => ({
Expand All @@ -47,7 +49,10 @@ const { findUser, updateUser } = require('~/models');

// Helper: build a mock openIdConfig
const mockOpenIdConfig = {
serverMetadata: () => ({ jwks_uri: 'https://example.com/.well-known/jwks.json' }),
serverMetadata: () => ({
issuer: 'https://issuer.example.com',
jwks_uri: 'https://example.com/.well-known/jwks.json',
}),
};

// Helper: invoke the captured verify callback
Expand Down Expand Up @@ -225,6 +230,7 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => {
_id: 'user-id-1',
provider: 'openid',
openidId: payload.sub,
openidIssuer: 'https://issuer.example.com',
email: payload.email,
role: SystemRoles.USER,
};
Expand All @@ -240,7 +246,9 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => {

expect(findUser).toHaveBeenCalledWith(
expect.objectContaining({
$or: expect.arrayContaining([{ openidId: payload.sub }]),
$or: expect.arrayContaining([
{ openidId: payload.sub, openidIssuer: 'https://issuer.example.com' },
]),
}),
);
});
Expand All @@ -254,7 +262,9 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => {

expect(findUser).toHaveBeenCalledTimes(2);
expect(findUser.mock.calls[0][0]).toMatchObject({
$or: expect.arrayContaining([{ openidId: payload.sub }]),
$or: expect.arrayContaining([
{ openidId: payload.sub, openidIssuer: 'https://issuer.example.com' },
]),
});
expect(findUser.mock.calls[1][0]).toEqual({ email: 'test@corp.example.com' });
expect(user).toBe(false);
Expand Down Expand Up @@ -367,7 +377,11 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => {
expect(user).toBeTruthy();
expect(updateUser).toHaveBeenCalledWith(
'legacy-db-id',
expect.objectContaining({ provider: 'openid', openidId: payloadNoEmail.sub }),
expect.objectContaining({
provider: 'openid',
openidId: payloadNoEmail.sub,
openidIssuer: 'https://issuer.example.com',
}),
);
});
});
Loading
Loading