mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
🎭 feat: Support OpenID Audience On Refresh Grants (#13077)
This commit is contained in:
parent
36e95353ed
commit
0a7255b234
7 changed files with 478 additions and 5 deletions
|
|
@ -2,7 +2,12 @@ const cookies = require('cookie');
|
|||
const jwt = require('jsonwebtoken');
|
||||
const openIdClient = require('openid-client');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, findOpenIDUser, getOpenIdIssuer } = require('@librechat/api');
|
||||
const {
|
||||
isEnabled,
|
||||
findOpenIDUser,
|
||||
getOpenIdIssuer,
|
||||
buildOpenIDRefreshParams,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
requestPasswordReset,
|
||||
setOpenIDAuthTokens,
|
||||
|
|
@ -78,12 +83,22 @@ const refreshController = async (req, res) => {
|
|||
|
||||
try {
|
||||
const openIdConfig = getOpenIdConfig();
|
||||
const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {};
|
||||
const refreshParams = buildOpenIDRefreshParams();
|
||||
logger.debug('[refreshController] OpenID refresh params', {
|
||||
has_scope: Boolean(process.env.OPENID_SCOPE),
|
||||
has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE),
|
||||
});
|
||||
const tokenset = await openIdClient.refreshTokenGrant(
|
||||
openIdConfig,
|
||||
refreshToken,
|
||||
refreshParams,
|
||||
);
|
||||
logger.debug('[refreshController] OpenID refresh succeeded', {
|
||||
has_access_token: Boolean(tokenset.access_token),
|
||||
has_id_token: Boolean(tokenset.id_token),
|
||||
has_refresh_token: Boolean(tokenset.refresh_token),
|
||||
expires_in: tokenset.expires_in,
|
||||
});
|
||||
const claims = tokenset.claims();
|
||||
const openidIssuer = getOpenIdIssuer(claims, openIdConfig);
|
||||
const { user, error, migration } = await findOpenIDUser({
|
||||
|
|
|
|||
|
|
@ -24,16 +24,30 @@ jest.mock('@librechat/api', () => ({
|
|||
isEnabled: jest.fn(),
|
||||
findOpenIDUser: jest.fn(),
|
||||
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
|
||||
buildOpenIDRefreshParams: jest.fn(() => {
|
||||
const params = {};
|
||||
if (process.env.OPENID_SCOPE) {
|
||||
params.scope = process.env.OPENID_SCOPE;
|
||||
}
|
||||
if (process.env.OPENID_REFRESH_AUDIENCE) {
|
||||
params.audience = process.env.OPENID_REFRESH_AUDIENCE;
|
||||
}
|
||||
return params;
|
||||
}),
|
||||
}));
|
||||
|
||||
const openIdClient = require('openid-client');
|
||||
const { isEnabled, findOpenIDUser } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, findOpenIDUser, buildOpenIDRefreshParams } = require('@librechat/api');
|
||||
const { graphTokenController, refreshController } = require('./AuthController');
|
||||
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
||||
const { setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
|
||||
const { updateUser } = require('~/models');
|
||||
|
||||
const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
|
||||
const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE;
|
||||
|
||||
describe('graphTokenController', () => {
|
||||
let req, res;
|
||||
|
||||
|
|
@ -155,6 +169,7 @@ describe('refreshController – OpenID path', () => {
|
|||
access_token: 'new-access',
|
||||
id_token: 'new-id',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
const baseClaims = {
|
||||
|
|
@ -179,6 +194,8 @@ describe('refreshController – OpenID path', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.OPENID_SCOPE;
|
||||
delete process.env.OPENID_REFRESH_AUDIENCE;
|
||||
|
||||
isEnabled.mockReturnValue(true);
|
||||
getOpenIdConfig.mockReturnValue({ some: 'config' });
|
||||
|
|
@ -201,9 +218,24 @@ describe('refreshController – OpenID path', () => {
|
|||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_OPENID_SCOPE === undefined) {
|
||||
delete process.env.OPENID_SCOPE;
|
||||
} else {
|
||||
process.env.OPENID_SCOPE = ORIGINAL_OPENID_SCOPE;
|
||||
}
|
||||
|
||||
if (ORIGINAL_OPENID_REFRESH_AUDIENCE === undefined) {
|
||||
delete process.env.OPENID_REFRESH_AUDIENCE;
|
||||
} else {
|
||||
process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE;
|
||||
}
|
||||
});
|
||||
|
||||
it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(buildOpenIDRefreshParams).toHaveBeenCalledTimes(1);
|
||||
expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims);
|
||||
expect(findOpenIDUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
@ -214,6 +246,83 @@ describe('refreshController – OpenID path', () => {
|
|||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should pass scope-only OpenID refresh params when OPENID_SCOPE is set', async () => {
|
||||
process.env.OPENID_SCOPE = 'openid profile email';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||||
{ some: 'config' },
|
||||
'stored-refresh',
|
||||
{ scope: 'openid profile email' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass scope and audience OpenID refresh params when both are set', async () => {
|
||||
process.env.OPENID_SCOPE = 'openid profile email';
|
||||
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||||
{ some: 'config' },
|
||||
'stored-refresh',
|
||||
{
|
||||
scope: 'openid profile email',
|
||||
audience: 'https://api.example.com',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass audience-only OpenID refresh params when scope is unset', async () => {
|
||||
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||||
{ some: 'config' },
|
||||
'stored-refresh',
|
||||
{ audience: 'https://api.example.com' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should omit empty OpenID refresh audience', async () => {
|
||||
process.env.OPENID_SCOPE = 'openid profile email';
|
||||
process.env.OPENID_REFRESH_AUDIENCE = '';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||||
{ some: 'config' },
|
||||
'stored-refresh',
|
||||
{ scope: 'openid profile email' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep OpenID refresh diagnostics free of token and audience values', async () => {
|
||||
process.env.OPENID_SCOPE = 'openid profile email';
|
||||
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh params', {
|
||||
has_scope: true,
|
||||
has_refresh_audience: true,
|
||||
});
|
||||
expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh succeeded', {
|
||||
has_access_token: true,
|
||||
has_id_token: true,
|
||||
has_refresh_token: true,
|
||||
expires_in: 3600,
|
||||
});
|
||||
const debugOutput = JSON.stringify(logger.debug.mock.calls);
|
||||
expect(debugOutput).not.toContain('stored-refresh');
|
||||
expect(debugOutput).not.toContain('new-access');
|
||||
expect(debugOutput).not.toContain('new-id');
|
||||
expect(debugOutput).not.toContain('new-refresh');
|
||||
expect(debugOutput).not.toContain('https://api.example.com');
|
||||
});
|
||||
|
||||
it('should use OPENID_EMAIL_CLAIM-resolved value when claim is present in token', async () => {
|
||||
const claimsWithUpn = { ...baseClaims, upn: 'user@corp.example.com' };
|
||||
mockTokenset.claims.mockReturnValue(claimsWithUpn);
|
||||
|
|
@ -305,6 +414,15 @@ describe('refreshController – OpenID path', () => {
|
|||
expect(res.redirect).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('should preserve invalid OpenID refresh token behavior', async () => {
|
||||
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token');
|
||||
});
|
||||
|
||||
it('should skip OpenID path when token_provider is not openid', async () => {
|
||||
req.headers.cookie = 'token_provider=local; refreshToken=some-token';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue