🎭 feat: Support OpenID Audience On Refresh Grants (#13077)

This commit is contained in:
Danny Avila 2026-05-11 17:40:30 -04:00 committed by GitHub
parent 36e95353ed
commit 0a7255b234
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 478 additions and 5 deletions

View file

@ -527,6 +527,10 @@ OPENID_NAME_CLAIM=
OPENID_EMAIL_CLAIM=
# Optional audience parameter for OpenID authorization requests
OPENID_AUDIENCE=
# Optional audience parameter for OpenID refresh token requests.
# Some providers, such as Auth0 custom APIs, require this to preserve
# the intended access-token audience during refresh. Usually matches OPENID_AUDIENCE.
OPENID_REFRESH_AUDIENCE=
OPENID_BUTTON_LABEL=
OPENID_IMAGE_URL=

View file

@ -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({

View file

@ -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';

View file

@ -19,6 +19,7 @@ const {
preAuthTenantMiddleware,
applyAdminRefresh,
AdminRefreshError,
buildOpenIDRefreshParams,
} = require('@librechat/api');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities');
@ -538,10 +539,20 @@ router.post(
});
}
const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {};
const refreshParams = buildOpenIDRefreshParams();
logger.debug('[admin/oauth/refresh] OpenID refresh params', {
has_scope: Boolean(process.env.OPENID_SCOPE),
has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE),
});
let tokenset;
try {
tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken, refreshParams);
logger.debug('[admin/oauth/refresh] 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,
});
} catch (err) {
logger.warn('[admin/oauth/refresh] IdP refresh grant failed', {
code: err?.code,

View file

@ -0,0 +1,250 @@
const express = require('express');
const request = require('supertest');
jest.mock('passport', () => ({
authenticate: jest.fn(() => (req, res, next) => next()),
}));
jest.mock('openid-client', () => ({
refreshTokenGrant: jest.fn(),
}));
jest.mock('librechat-data-provider', () => ({
CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
},
DEFAULT_SESSION_EXPIRY: 60000,
SystemCapabilities: { ACCESS_ADMIN: 'ACCESS_ADMIN' },
getTenantId: jest.fn(() => undefined),
}));
jest.mock('@librechat/api', () => {
class AdminRefreshError extends Error {
constructor(code, status, message) {
super(message);
this.name = 'AdminRefreshError';
this.code = code;
this.status = status;
}
}
return {
isEnabled: jest.fn(),
getAdminPanelUrl: jest.fn(() => 'http://admin.example.com'),
exchangeAdminCode: jest.fn(),
createSetBalanceConfig: jest.fn(() => (req, res, next) => next()),
storeAndStripChallenge: jest.fn(),
tenantContextMiddleware: jest.fn((req, res, next) => next()),
preAuthTenantMiddleware: jest.fn((req, res, next) => next()),
applyAdminRefresh: jest.fn(),
AdminRefreshError,
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;
}),
};
});
jest.mock('~/server/controllers/auth/LoginController', () => ({
loginController: jest.fn((req, res) => res.status(200).end()),
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: jest.fn(() => Promise.resolve(true)),
requireCapability: jest.fn(() => (req, res, next) => next()),
}));
jest.mock('~/server/controllers/auth/oauth', () => ({
createOAuthHandler: jest.fn(() => (req, res) => res.status(200).end()),
}));
jest.mock('~/models', () => ({
findBalanceByUser: jest.fn(),
findUsers: jest.fn(),
generateToken: jest.fn(() => Promise.resolve('minted-token')),
getUserById: jest.fn(),
upsertBalanceFields: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn(() => ({
get: jest.fn(),
delete: jest.fn(),
})),
);
jest.mock('~/strategies', () => ({
getOpenIdConfig: jest.fn(),
}));
jest.mock('~/server/middleware', () => ({
logHeaders: jest.fn((req, res, next) => next()),
loginLimiter: jest.fn((req, res, next) => next()),
checkBan: jest.fn((req, res, next) => next()),
requireLocalAuth: jest.fn((req, res, next) => next()),
requireJwtAuth: jest.fn((req, res, next) => next()),
checkDomainAllowed: jest.fn((req, res, next) => next()),
}));
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, applyAdminRefresh, buildOpenIDRefreshParams } = require('@librechat/api');
const { getOpenIdConfig } = require('~/strategies');
const adminAuthRouter = require('./auth');
const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE;
const ORIGINAL_SESSION_EXPIRY = process.env.SESSION_EXPIRY;
describe('admin auth OpenID refresh route', () => {
const openIdConfig = {
serverMetadata: jest.fn(() => ({ issuer: 'https://issuer.example.com' })),
};
const tokenset = {
access_token: 'new-admin-access',
id_token: 'new-admin-id',
refresh_token: 'new-admin-refresh',
expires_in: 3600,
claims: jest.fn(() => ({ sub: 'admin-openid-id' })),
};
let app;
beforeEach(() => {
jest.clearAllMocks();
delete process.env.OPENID_SCOPE;
delete process.env.OPENID_REFRESH_AUDIENCE;
delete process.env.SESSION_EXPIRY;
app = express();
app.use(express.json());
app.use('/api/admin', adminAuthRouter);
isEnabled.mockReturnValue(true);
getOpenIdConfig.mockReturnValue(openIdConfig);
openIdClient.refreshTokenGrant.mockResolvedValue(tokenset);
applyAdminRefresh.mockResolvedValue({
token: 'admin-jwt',
refreshToken: 'new-admin-refresh',
user: { id: 'user-id', email: 'admin@example.com' },
expiresAt: 1234567890,
});
});
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;
}
if (ORIGINAL_SESSION_EXPIRY === undefined) {
delete process.env.SESSION_EXPIRY;
} else {
process.env.SESSION_EXPIRY = ORIGINAL_SESSION_EXPIRY;
}
});
it.each([
['scope-only', { OPENID_SCOPE: 'openid profile email' }, { scope: 'openid profile email' }],
[
'scope and audience',
{
OPENID_SCOPE: 'openid profile email',
OPENID_REFRESH_AUDIENCE: 'https://api.example.com',
},
{ scope: 'openid profile email', audience: 'https://api.example.com' },
],
[
'audience-only',
{ OPENID_REFRESH_AUDIENCE: 'https://api.example.com' },
{ audience: 'https://api.example.com' },
],
['empty audience', { OPENID_REFRESH_AUDIENCE: '' }, {}],
])('passes %s params to the OpenID refresh grant', async (_label, env, expectedParams) => {
Object.assign(process.env, env);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-refresh-token' });
expect(response.status).toBe(200);
expect(buildOpenIDRefreshParams).toHaveBeenCalledTimes(1);
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
openIdConfig,
'incoming-refresh-token',
expectedParams,
);
expect(applyAdminRefresh).toHaveBeenCalledWith(
tokenset,
expect.any(Object),
expect.objectContaining({ previousRefreshToken: 'incoming-refresh-token' }),
);
});
it('returns the existing refresh failure response when the IdP rejects the grant', async () => {
openIdClient.refreshTokenGrant.mockRejectedValue({
code: 'invalid_grant',
name: 'OAuthError',
});
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-refresh-token' });
expect(response.status).toBe(401);
expect(response.body).toEqual({
error: 'Refresh failed',
error_code: 'REFRESH_FAILED',
});
expect(applyAdminRefresh).not.toHaveBeenCalled();
});
it('keeps admin 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 request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-refresh-token' });
expect(logger.debug).toHaveBeenCalledWith('[admin/oauth/refresh] OpenID refresh params', {
has_scope: true,
has_refresh_audience: true,
});
expect(logger.debug).toHaveBeenCalledWith('[admin/oauth/refresh] 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('incoming-refresh-token');
expect(debugOutput).not.toContain('new-admin-access');
expect(debugOutput).not.toContain('new-admin-id');
expect(debugOutput).not.toContain('new-admin-refresh');
expect(debugOutput).not.toContain('https://api.example.com');
});
});

View file

@ -4,7 +4,7 @@ import { logger } from '@librechat/data-schemas';
import type { IUser } from '@librechat/data-schemas';
import type { AdminRefreshDeps, RefreshTokenset } from './refresh';
import { applyAdminRefresh, AdminRefreshError } from './refresh';
import { applyAdminRefresh, AdminRefreshError, buildOpenIDRefreshParams } from './refresh';
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
@ -17,6 +17,9 @@ jest.mock('@librechat/data-schemas', () => ({
const SUB = 'idp-sub-12345';
const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE;
function makeUser(overrides: Partial<IUser> = {}): IUser {
const _id = overrides._id ?? new Types.ObjectId();
return {
@ -54,6 +57,59 @@ function makeDeps(user: IUser | undefined, overrides: Partial<AdminRefreshDeps>
};
}
describe('buildOpenIDRefreshParams', () => {
beforeEach(() => {
delete process.env.OPENID_SCOPE;
delete process.env.OPENID_REFRESH_AUDIENCE;
});
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('returns scope-only params when OPENID_SCOPE is set', () => {
process.env.OPENID_SCOPE = 'openid profile email';
expect(buildOpenIDRefreshParams()).toEqual({ scope: 'openid profile email' });
});
it('returns scope and audience params when both refresh settings are set', () => {
process.env.OPENID_SCOPE = 'openid profile email';
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
expect(buildOpenIDRefreshParams()).toEqual({
scope: 'openid profile email',
audience: 'https://api.example.com',
});
});
it('returns audience-only params when OPENID_SCOPE is unset', () => {
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
expect(buildOpenIDRefreshParams()).toEqual({ audience: 'https://api.example.com' });
});
it('omits an empty refresh audience', () => {
process.env.OPENID_REFRESH_AUDIENCE = '';
expect(buildOpenIDRefreshParams()).toEqual({});
});
it('returns no params when scope and refresh audience are unset', () => {
expect(buildOpenIDRefreshParams()).toEqual({});
});
});
describe('applyAdminRefresh', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -29,6 +29,11 @@ export interface RefreshTokenset {
claims: () => AdminRefreshClaims;
}
export interface OpenIDRefreshParams {
scope?: string;
audience?: string;
}
export interface MintedToken {
/** Bearer the admin panel will send on subsequent requests. */
token: string;
@ -114,6 +119,20 @@ export class AdminRefreshError extends Error {
}
}
export function buildOpenIDRefreshParams(): OpenIDRefreshParams {
const params: OpenIDRefreshParams = {};
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;
}
async function resolveAdminUser(
deps: AdminRefreshDeps,
openidId: string,