mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 04:12:36 +00:00
Google admin sessions cannot be refreshed today. Three gaps add up to that:
passport.authenticate('googleAdmin', ...) in api/server/routes/admin/auth.js
never sets access_type=offline, so Google omits the refresh_token from its
token response; createOAuthHandler in api/server/controllers/auth/oauth.js
only forwards a refresh token into the admin exchange payload when the user's
provider is 'openid' AND OPENID_REUSE_TOKENS is enabled; and
/api/admin/oauth/refresh is openid-only, calling openid-client.refreshTokenGrant
against the configured OIDC issuer. OpenID admins refresh transparently
because all three are in place for them.
This PR closes all three. The googleAdmin authenticate call now passes
accessType: 'offline' and prompt: 'consent' so Google issues a refresh token
on consent; the chat-side googleLogin is untouched. The shared socialLogin
verify callback now passes the IdP refreshToken through as passport's third
argument (info), landing on req.authInfo, with the two-argument call shape
preserved when no refresh token is present so existing strategy tests stay
valid. createOAuthHandler reads req.authInfo?.refreshToken for non-OpenID
admin providers and forwards it into the exchange code; the OpenID branch
and its OPENID_REUSE_TOKENS gate are unchanged. /api/admin/oauth/refresh
now accepts an optional provider field ('openid' | 'google', default 'openid').
The new Google branch POSTs grant_type=refresh_token to
https://oauth2.googleapis.com/token, decodes the returned id_token for the sub
claim, looks up the admin user by googleId, enforces tenant scope and
ACCESS_ADMIN, and mints a fresh LibreChat JWT in the same response shape
/oauth/exchange returns. It is gated on GOOGLE_CLIENT_ID and
GOOGLE_CLIENT_SECRET being set (returns 503 GOOGLE_NOT_CONFIGURED otherwise);
unknown provider values return 400 INVALID_PROVIDER.
194 lines
5.8 KiB
JavaScript
194 lines
5.8 KiB
JavaScript
const mockIsEnabled = jest.fn();
|
|
const mockGetAdminPanelUrl = jest.fn();
|
|
const mockIsAdminPanelRedirect = jest.fn();
|
|
const mockGenerateAdminExchangeCode = jest.fn();
|
|
const mockSyncUserEntraGroupMemberships = jest.fn();
|
|
const mockSetAuthTokens = jest.fn();
|
|
const mockSetOpenIDAuthTokens = jest.fn();
|
|
const mockGetLogStores = jest.fn();
|
|
const mockCheckBan = jest.fn();
|
|
const mockGenerateToken = jest.fn();
|
|
const mockLogger = { info: jest.fn(), error: jest.fn() };
|
|
|
|
jest.mock('librechat-data-provider', () => ({
|
|
CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
|
|
}));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: mockLogger,
|
|
DEFAULT_SESSION_EXPIRY: 60000,
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
isEnabled: (...args) => mockIsEnabled(...args),
|
|
getAdminPanelUrl: (...args) => mockGetAdminPanelUrl(...args),
|
|
isAdminPanelRedirect: (...args) => mockIsAdminPanelRedirect(...args),
|
|
generateAdminExchangeCode: (...args) => mockGenerateAdminExchangeCode(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/services/PermissionService', () => ({
|
|
syncUserEntraGroupMemberships: (...args) => mockSyncUserEntraGroupMemberships(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/services/AuthService', () => ({
|
|
setAuthTokens: (...args) => mockSetAuthTokens(...args),
|
|
setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
|
|
}));
|
|
|
|
jest.mock(
|
|
'~/cache/getLogStores',
|
|
() =>
|
|
(...args) =>
|
|
mockGetLogStores(...args),
|
|
);
|
|
|
|
jest.mock('~/server/middleware', () => ({
|
|
checkBan: (...args) => mockCheckBan(...args),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
generateToken: (...args) => mockGenerateToken(...args),
|
|
}));
|
|
|
|
const { createOAuthHandler } = require('./oauth');
|
|
|
|
const ORIGINAL_ENV = process.env;
|
|
|
|
function buildReq(overrides = {}) {
|
|
return {
|
|
user: {
|
|
_id: 'user-123',
|
|
email: 'admin@example.com',
|
|
provider: 'openid',
|
|
tokenset: { refresh_token: 'openid-refresh-token', access_token: 'openid-access-token' },
|
|
federatedTokens: { refresh_token: 'federated-refresh-token' },
|
|
},
|
|
pkceChallenge: 'pkce-challenge',
|
|
banned: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildRes() {
|
|
return {
|
|
headersSent: false,
|
|
redirect: jest.fn(),
|
|
};
|
|
}
|
|
|
|
describe('createOAuthHandler', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
process.env = {
|
|
...ORIGINAL_ENV,
|
|
DOMAIN_CLIENT: 'http://localhost:3080',
|
|
DOMAIN_SERVER: 'http://localhost:3080',
|
|
OPENID_REUSE_TOKENS: 'false',
|
|
};
|
|
mockIsEnabled.mockImplementation((value) => value === 'true' || value === true);
|
|
mockGetAdminPanelUrl.mockReturnValue('http://admin.example.com');
|
|
mockIsAdminPanelRedirect.mockReturnValue(true);
|
|
mockGetLogStores.mockReturnValue({});
|
|
mockCheckBan.mockResolvedValue(undefined);
|
|
mockGenerateToken.mockResolvedValue('jwt-token');
|
|
mockGenerateAdminExchangeCode.mockResolvedValue('exchange-code');
|
|
});
|
|
|
|
afterAll(() => {
|
|
process.env = ORIGINAL_ENV;
|
|
});
|
|
|
|
it('omits refresh token from admin exchange when OPENID_REUSE_TOKENS is disabled', async () => {
|
|
const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
|
|
const req = buildReq();
|
|
const res = buildRes();
|
|
const next = jest.fn();
|
|
|
|
await handler(req, res, next);
|
|
|
|
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
|
|
{},
|
|
req.user,
|
|
'jwt-token',
|
|
undefined,
|
|
'http://admin.example.com',
|
|
'pkce-challenge',
|
|
expect.any(Number),
|
|
);
|
|
expect(res.redirect).toHaveBeenCalledWith(
|
|
'http://admin.example.com/auth/openid/callback?code=exchange-code',
|
|
);
|
|
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('includes refresh token in admin exchange when OPENID_REUSE_TOKENS is enabled', async () => {
|
|
process.env.OPENID_REUSE_TOKENS = 'true';
|
|
const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
|
|
const req = buildReq();
|
|
const res = buildRes();
|
|
const next = jest.fn();
|
|
|
|
await handler(req, res, next);
|
|
|
|
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
|
|
{},
|
|
req.user,
|
|
'jwt-token',
|
|
'openid-refresh-token',
|
|
'http://admin.example.com',
|
|
'pkce-challenge',
|
|
expect.any(Number),
|
|
);
|
|
expect(res.redirect).toHaveBeenCalledWith(
|
|
'http://admin.example.com/auth/openid/callback?code=exchange-code',
|
|
);
|
|
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
|
|
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
|
expect(next).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('forwards the refresh token from req.authInfo for non-openid admin providers', async () => {
|
|
const handler = createOAuthHandler('http://admin.example.com/auth/google/callback');
|
|
const req = buildReq({
|
|
user: { _id: 'user-9', email: 'g@example.com', provider: 'google' },
|
|
authInfo: { refreshToken: 'google-refresh-token' },
|
|
});
|
|
const res = buildRes();
|
|
const next = jest.fn();
|
|
|
|
await handler(req, res, next);
|
|
|
|
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
|
|
{},
|
|
req.user,
|
|
'jwt-token',
|
|
'google-refresh-token',
|
|
'http://admin.example.com',
|
|
'pkce-challenge',
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('omits the refresh token when a non-openid admin login has no authInfo', async () => {
|
|
const handler = createOAuthHandler('http://admin.example.com/auth/google/callback');
|
|
const req = buildReq({
|
|
user: { _id: 'user-9', email: 'g@example.com', provider: 'google' },
|
|
});
|
|
const res = buildRes();
|
|
const next = jest.fn();
|
|
|
|
await handler(req, res, next);
|
|
|
|
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
|
|
{},
|
|
req.user,
|
|
'jwt-token',
|
|
undefined,
|
|
'http://admin.example.com',
|
|
'pkce-challenge',
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
});
|