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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/cache/banViolation.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const banViolation = async (req, res, errorMessage) => {

res.clearCookie('refreshToken');
res.clearCookie('openid_access_token');
res.clearCookie('openid_id_token');
res.clearCookie('openid_user_id');
res.clearCookie('token_provider');

Expand Down
1 change: 1 addition & 0 deletions api/server/controllers/auth/LogoutController.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const logoutController = async (req, res) => {

res.clearCookie('refreshToken');
res.clearCookie('openid_access_token');
res.clearCookie('openid_id_token');
res.clearCookie('openid_user_id');
res.clearCookie('token_provider');
const response = { message };
Expand Down
9 changes: 9 additions & 0 deletions api/server/services/AuthService.js
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) =
if (req.session) {
req.session.openidTokens = {
accessToken: tokenset.access_token,
idToken: tokenset.id_token,
refreshToken: refreshToken,
Comment thread
busla marked this conversation as resolved.
expiresAt: expirationDate.getTime(),
};
Expand All @@ -469,6 +470,14 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) =
secure: isProduction,
sameSite: 'strict',
});
if (tokenset.id_token) {
res.cookie('openid_id_token', tokenset.id_token, {
expires: expirationDate,
httpOnly: true,
secure: isProduction,
sameSite: 'strict',
});
}
}

/** Small cookie to indicate token provider (required for auth middleware) */
Expand Down
6 changes: 4 additions & 2 deletions api/strategies/openIdJwtStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,21 @@ const openIdJwtLogin = (openIdConfig) => {
/** Read tokens from session (server-side) to avoid large cookie issues */
const sessionTokens = req.session?.openidTokens;
let accessToken = sessionTokens?.accessToken;
let idToken = sessionTokens?.idToken;
let refreshToken = sessionTokens?.refreshToken;

/** Fallback to cookies for backward compatibility */
if (!accessToken || !refreshToken) {
if (!accessToken || !refreshToken || !idToken) {
const cookieHeader = req.headers.cookie;
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
accessToken = accessToken || parsedCookies.openid_access_token;
idToken = idToken || parsedCookies.openid_id_token;
refreshToken = refreshToken || parsedCookies.refreshToken;
}

user.federatedTokens = {
access_token: accessToken || rawToken,
id_token: rawToken,
id_token: idToken,
refresh_token: refreshToken,
expires_at: payload.exp,
};
Comment thread
busla marked this conversation as resolved.
Expand Down
183 changes: 183 additions & 0 deletions api/strategies/openIdJwtStrategy.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
const { SystemRoles } = require('librechat-data-provider');

// --- Capture the verify callback from JwtStrategy ---
let capturedVerifyCallback;
jest.mock('passport-jwt', () => ({
Strategy: jest.fn((_opts, verifyCallback) => {
capturedVerifyCallback = verifyCallback;
return { name: 'jwt' };
}),
ExtractJwt: {
fromAuthHeaderAsBearerToken: jest.fn(() => 'mock-extractor'),
},
}));
jest.mock('jwks-rsa', () => ({
passportJwtSecret: jest.fn(() => 'mock-secret-provider'),
}));
jest.mock('https-proxy-agent', () => ({
HttpsProxyAgent: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
}));
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(() => false),
findOpenIDUser: jest.fn(),
math: jest.fn((val, fallback) => fallback),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
updateUser: jest.fn(),
}));

const { findOpenIDUser } = require('@librechat/api');
const { updateUser } = require('~/models');
const openIdJwtLogin = require('./openIdJwtStrategy');

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

// Helper: invoke the captured verify callback
async function invokeVerify(req, payload) {
return new Promise((resolve, reject) => {
capturedVerifyCallback(req, payload, (err, user, info) => {
if (err) {
return reject(err);
}
resolve({ user, info });
});
});
}

describe('openIdJwtStrategy – token source handling', () => {
const baseUser = {
_id: { toString: () => 'user-abc' },
role: SystemRoles.USER,
provider: 'openid',
};

const payload = { sub: 'oidc-123', email: 'test@example.com', exp: 9999999999 };

beforeEach(() => {
jest.clearAllMocks();
findOpenIDUser.mockResolvedValue({ user: { ...baseUser }, error: null, migration: false });
updateUser.mockResolvedValue({});

// Initialize the strategy so capturedVerifyCallback is set
openIdJwtLogin(mockOpenIdConfig);
});

it('should read all tokens from session when available', async () => {
const req = {
headers: { authorization: 'Bearer raw-bearer-token' },
session: {
openidTokens: {
accessToken: 'session-access',
idToken: 'session-id',
refreshToken: 'session-refresh',
},
},
};

const { user } = await invokeVerify(req, payload);

expect(user.federatedTokens).toEqual({
access_token: 'session-access',
id_token: 'session-id',
refresh_token: 'session-refresh',
expires_at: payload.exp,
});
});

it('should fall back to cookies when session is absent', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie:
'openid_access_token=cookie-access; openid_id_token=cookie-id; refreshToken=cookie-refresh',
},
};

const { user } = await invokeVerify(req, payload);

expect(user.federatedTokens).toEqual({
access_token: 'cookie-access',
id_token: 'cookie-id',
refresh_token: 'cookie-refresh',
expires_at: payload.exp,
});
});

it('should fall back to cookie for idToken only when session lacks it', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie: 'openid_id_token=cookie-id',
},
session: {
openidTokens: {
accessToken: 'session-access',
// idToken intentionally missing
refreshToken: 'session-refresh',
},
},
};

const { user } = await invokeVerify(req, payload);

expect(user.federatedTokens).toEqual({
access_token: 'session-access',
id_token: 'cookie-id',
refresh_token: 'session-refresh',
expires_at: payload.exp,
});
});

it('should use raw Bearer token as access_token fallback when neither session nor cookie has one', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie: 'openid_id_token=cookie-id; refreshToken=cookie-refresh',
},
};

const { user } = await invokeVerify(req, payload);

expect(user.federatedTokens.access_token).toBe('raw-bearer-token');
expect(user.federatedTokens.id_token).toBe('cookie-id');
expect(user.federatedTokens.refresh_token).toBe('cookie-refresh');
});

it('should set id_token to undefined when not available in session or cookies', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie: 'openid_access_token=cookie-access; refreshToken=cookie-refresh',
},
};

const { user } = await invokeVerify(req, payload);

expect(user.federatedTokens.access_token).toBe('cookie-access');
expect(user.federatedTokens.id_token).toBeUndefined();
expect(user.federatedTokens.refresh_token).toBe('cookie-refresh');
});

it('should keep id_token and access_token as distinct values from cookies', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie:
'openid_access_token=the-access-token; openid_id_token=the-id-token; refreshToken=the-refresh',
},
};

const { user } = await invokeVerify(req, payload);

expect(user.federatedTokens.access_token).toBe('the-access-token');
expect(user.federatedTokens.id_token).toBe('the-id-token');
expect(user.federatedTokens.access_token).not.toBe(user.federatedTokens.id_token);
});
});
1 change: 1 addition & 0 deletions api/strategies/openidStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
tokenset,
federatedTokens: {
access_token: tokenset.access_token,
id_token: tokenset.id_token,
refresh_token: tokenset.refresh_token,
expires_at: tokenset.expires_at,
},
Expand Down
26 changes: 25 additions & 1 deletion api/strategies/openidStrategy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -775,10 +775,11 @@ describe('setupOpenId', () => {
});

it('should attach federatedTokens to user object for token propagation', async () => {
// Arrange - setup tokenset with access token, refresh token, and expiration
// Arrange - setup tokenset with access token, id token, refresh token, and expiration
const tokensetWithTokens = {
...tokenset,
access_token: 'mock_access_token_abc123',
id_token: 'mock_id_token_def456',
refresh_token: 'mock_refresh_token_xyz789',
expires_at: 1234567890,
};
Expand All @@ -790,16 +791,37 @@ describe('setupOpenId', () => {
expect(user.federatedTokens).toBeDefined();
expect(user.federatedTokens).toEqual({
access_token: 'mock_access_token_abc123',
id_token: 'mock_id_token_def456',
refresh_token: 'mock_refresh_token_xyz789',
expires_at: 1234567890,
});
});

it('should include id_token in federatedTokens distinct from access_token', async () => {
// Arrange - use different values for access_token and id_token
const tokensetWithTokens = {
...tokenset,
access_token: 'the_access_token',
id_token: 'the_id_token',
refresh_token: 'the_refresh_token',
expires_at: 9999999999,
};

// Act
const { user } = await validate(tokensetWithTokens);

// Assert - id_token and access_token must be different values
expect(user.federatedTokens.access_token).toBe('the_access_token');
expect(user.federatedTokens.id_token).toBe('the_id_token');
expect(user.federatedTokens.id_token).not.toBe(user.federatedTokens.access_token);
});

it('should include tokenset along with federatedTokens', async () => {
// Arrange
const tokensetWithTokens = {
...tokenset,
access_token: 'test_access_token',
id_token: 'test_id_token',
refresh_token: 'test_refresh_token',
expires_at: 9999999999,
};
Expand All @@ -811,7 +833,9 @@ describe('setupOpenId', () => {
expect(user.tokenset).toBeDefined();
expect(user.federatedTokens).toBeDefined();
expect(user.tokenset.access_token).toBe('test_access_token');
expect(user.tokenset.id_token).toBe('test_id_token');
expect(user.federatedTokens.access_token).toBe('test_access_token');
expect(user.federatedTokens.id_token).toBe('test_id_token');
});

it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
Expand Down
29 changes: 29 additions & 0 deletions packages/api/src/utils/oidc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,35 @@ describe('OpenID Token Utilities', () => {
expect(result).toContain('User:');
});

it('should resolve LIBRECHAT_OPENID_ID_TOKEN and LIBRECHAT_OPENID_ACCESS_TOKEN to different values', () => {
const user: Partial<TUser> = {
id: 'user-123',
provider: 'openid',
openidId: 'oidc-sub-456',
email: 'test@example.com',
name: 'Test User',
federatedTokens: {
access_token: 'my-access-token',
id_token: 'my-id-token',
refresh_token: 'my-refresh-token',
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
};

const tokenInfo = extractOpenIDTokenInfo(user);
expect(tokenInfo).not.toBeNull();
expect(tokenInfo!.accessToken).toBe('my-access-token');
expect(tokenInfo!.idToken).toBe('my-id-token');
expect(tokenInfo!.accessToken).not.toBe(tokenInfo!.idToken);

const input = 'ACCESS={{LIBRECHAT_OPENID_ACCESS_TOKEN}}, ID={{LIBRECHAT_OPENID_ID_TOKEN}}';
const result = processOpenIDPlaceholders(input, tokenInfo!);

expect(result).toBe('ACCESS=my-access-token, ID=my-id-token');
// Verify they are not the same value (the reported bug)
expect(result).not.toBe('ACCESS=my-access-token, ID=my-access-token');
});

it('should handle expired tokens correctly', () => {
const user: Partial<TUser> = {
id: 'user-123',
Expand Down
Loading