mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 07:46:47 +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
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
250
api/server/routes/admin/auth.refresh.test.js
Normal file
250
api/server/routes/admin/auth.refresh.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue