🧹 refactor: Move Google admin refresh into TypeScript @librechat/api helper

Per repo guidance (CLAUDE.md): all new backend code must be TypeScript in
/packages/api, and /api is a thin JS wrapper. The previous commit landed the
Google admin refresh flow as ~120 lines of new JS inside
api/server/routes/admin/auth.js, which violates that. This commit extracts
the flow into a new TS helper at packages/api/src/auth/googleRefresh.ts and
reduces the route handler to a thin dep-wiring wrapper.

The helper exports applyGoogleAdminRefresh(deps, options) with the same
shape as the OpenID applyAdminRefresh: callers pass findUsers, getUserById,
canAccessAdmin, and mintToken as deps so the package stays free of /api
model imports and capability/session helpers. The route handler now builds
those deps from the existing model + capability + token modules and calls
the helper, mapping AdminRefreshError to the documented HTTP responses.

While moving the code, the helper now guards getUserById with
Types.ObjectId.isValid before the direct-lookup branch, matching the
OpenID admin path at packages/api/src/auth/refresh.ts. Without this guard
a malformed user_id from the admin client would hit Mongoose findById's
CastError and surface as a 500 INTERNAL_ERROR instead of falling through
to the documented sub-based lookup.

Tests move with the code: packages/api/src/auth/googleRefresh.spec.ts now
owns the helper's behavior (token endpoint, userinfo fallback, ObjectId
guard, USER_ID_MISMATCH/TENANT_MISMATCH/USER_NOT_FOUND/FORBIDDEN, rotated
refresh-token pass-through, GOOGLE_NOT_CONFIGURED, IDP_INCOMPLETE on
non-JSON body, CLAIMS_INCOMPLETE when both id_token and userinfo miss).
The route-level api/server/routes/admin/auth.refresh.test.js drops the
duplicated end-to-end Google cases and keeps a smaller surface: route
delegates to applyGoogleAdminRefresh with the right deps + options, maps
AdminRefreshError to HTTP status/code, falls through to 500 for unknown
errors, and rejects unknown providers with INVALID_PROVIDER.
This commit is contained in:
Dustin Healy 2026-06-18 12:18:46 -07:00
parent 984f81e257
commit 21922eea78
5 changed files with 587 additions and 447 deletions

View file

@ -18,9 +18,9 @@ const {
tenantContextMiddleware,
preAuthTenantMiddleware,
applyAdminRefresh,
applyGoogleAdminRefresh,
AdminRefreshError,
buildOpenIDRefreshParams,
serializeUserForExchange,
} = require('@librechat/api');
const { loginController } = require('~/server/controllers/auth/LoginController');
const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities');
@ -37,178 +37,31 @@ const getLogStores = require('~/cache/getLogStores');
const { getOpenIdConfig } = require('~/strategies');
const middleware = require('~/server/middleware');
const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
const GOOGLE_USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo';
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
function decodeJwtPayload(token) {
const segments = token.split('.');
if (segments.length !== 3) return undefined;
try {
const payload = Buffer.from(segments[1], 'base64url').toString('utf8');
return JSON.parse(payload);
} catch {
return undefined;
}
}
async function resolveGoogleSubFromUserinfo(accessToken) {
try {
const response = await fetch(GOOGLE_USERINFO_ENDPOINT, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
logger.warn('[admin/oauth/refresh] Google userinfo fallback returned non-OK', {
status: response.status,
});
return undefined;
}
const body = await response.json().catch(() => undefined);
return typeof body?.sub === 'string' ? body.sub : undefined;
} catch (err) {
logger.warn('[admin/oauth/refresh] Google userinfo fallback failed', {
name: err?.name,
message: err?.message,
});
return undefined;
}
}
async function refreshGoogleAdminSession({ refreshToken, userId, tenantId, sessionExpiry }) {
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
throw new AdminRefreshError(
'GOOGLE_NOT_CONFIGURED',
503,
'Google admin OAuth is not configured',
);
}
let tokenResponse;
try {
tokenResponse = await fetch(GOOGLE_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
});
} catch (err) {
logger.warn('[admin/oauth/refresh] Google token endpoint request failed', {
name: err?.name,
message: err?.message,
});
throw new AdminRefreshError('REFRESH_FAILED', 401, 'Refresh failed');
}
if (!tokenResponse.ok) {
logger.warn('[admin/oauth/refresh] Google rejected refresh grant', {
status: tokenResponse.status,
});
throw new AdminRefreshError('REFRESH_FAILED', 401, 'Refresh failed');
}
let tokenset;
try {
tokenset = await tokenResponse.json();
} catch (err) {
logger.warn('[admin/oauth/refresh] Google returned non-JSON body', {
name: err?.name,
message: err?.message,
});
throw new AdminRefreshError('IDP_INCOMPLETE', 502, 'Google returned a non-JSON token response');
}
if (typeof tokenset?.access_token !== 'string') {
throw new AdminRefreshError(
'IDP_INCOMPLETE',
502,
'Google returned a tokenset missing access_token',
);
}
let googleId;
if (typeof tokenset.id_token === 'string') {
const claims = decodeJwtPayload(tokenset.id_token);
if (typeof claims?.sub === 'string') {
googleId = claims.sub;
}
}
if (!googleId) {
googleId = await resolveGoogleSubFromUserinfo(tokenset.access_token);
}
if (!googleId) {
throw new AdminRefreshError(
'CLAIMS_INCOMPLETE',
502,
'Could not resolve google sub from refresh response',
);
}
const SAFE_USER_PROJECTION = '-password -__v -totpSecret -backupCodes';
let user;
if (typeof userId === 'string' && userId.length > 0) {
const direct = await getUserById(userId, SAFE_USER_PROJECTION);
if (direct) {
if (direct.googleId !== googleId) {
throw new AdminRefreshError(
'USER_ID_MISMATCH',
401,
'Provided user_id does not match the refreshed identity',
);
}
if (tenantId && direct.tenantId !== tenantId) {
throw new AdminRefreshError(
'TENANT_MISMATCH',
401,
'Provided user_id resolves outside the request tenant',
);
}
user = direct;
}
}
if (!user) {
const filter = tenantId ? { googleId, tenantId } : { googleId };
const [found] = await findUsers(filter, SAFE_USER_PROJECTION, {
sort: { updatedAt: -1 },
limit: 1,
});
user = found;
}
if (!user) {
throw new AdminRefreshError('USER_NOT_FOUND', 401, 'No user found for the refreshed identity');
}
let canAccess = false;
try {
canAccess = await hasCapability(
{
id: user.id ?? user._id?.toString(),
role: user.role ?? '',
tenantId: user.tenantId,
},
SystemCapabilities.ACCESS_ADMIN,
);
} catch (err) {
logger.warn(`[admin/oauth/refresh] capability check failed, denying: ${err?.message}`);
}
if (!canAccess) {
throw new AdminRefreshError('FORBIDDEN', 403, 'User does not have admin access');
}
const token = await generateToken(user, sessionExpiry);
const expiresAt = Date.now() + sessionExpiry;
function buildGoogleAdminRefreshDeps(sessionExpiry) {
return {
token,
refreshToken: tokenset.refresh_token ?? refreshToken,
user: serializeUserForExchange(user),
expiresAt,
findUsers,
getUserById,
canAccessAdmin: async (user) => {
try {
return await hasCapability(
{
id: user.id ?? user._id?.toString(),
role: user.role ?? '',
tenantId: user.tenantId,
},
SystemCapabilities.ACCESS_ADMIN,
);
} catch (err) {
logger.warn(`[admin/oauth/refresh] capability check failed, denying: ${err?.message}`);
return false;
}
},
mintToken: async (user) => ({
token: await generateToken(user, sessionExpiry),
expiresAt: Date.now() + sessionExpiry,
}),
};
}
@ -719,11 +572,12 @@ router.post(
if (provider === 'google') {
try {
const result = await refreshGoogleAdminSession({
const result = await applyGoogleAdminRefresh(buildGoogleAdminRefreshDeps(sessionExpiry), {
refreshToken,
userId: normalizedUserId,
tenantId,
sessionExpiry,
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
});
return res.json(result);
} catch (err) {

View file

@ -43,6 +43,7 @@ jest.mock('@librechat/api', () => {
tenantContextMiddleware: jest.fn((req, res, next) => next()),
preAuthTenantMiddleware: jest.fn((req, res, next) => next()),
applyAdminRefresh: jest.fn(),
applyGoogleAdminRefresh: jest.fn(),
AdminRefreshError,
buildOpenIDRefreshParams: jest.fn(() => {
const params = {};
@ -54,20 +55,6 @@ jest.mock('@librechat/api', () => {
}
return params;
}),
serializeUserForExchange: jest.fn((user) => {
const userId = String(user._id);
return {
_id: userId,
id: userId,
email: user.email,
name: user.name ?? '',
username: user.username ?? '',
role: user.role ?? 'USER',
avatar: user.avatar,
provider: user.provider,
openidId: user.openidId,
};
}),
};
});
@ -118,7 +105,12 @@ jest.mock('~/server/middleware', () => ({
const openIdClient = require('openid-client');
const { logger } = require('@librechat/data-schemas');
const { isEnabled, applyAdminRefresh, buildOpenIDRefreshParams } = require('@librechat/api');
const {
isEnabled,
applyAdminRefresh,
applyGoogleAdminRefresh,
buildOpenIDRefreshParams,
} = require('@librechat/api');
const { getOpenIdConfig } = require('~/strategies');
const adminAuthRouter = require('./auth');
@ -264,88 +256,22 @@ describe('admin auth OpenID refresh route', () => {
});
describe('admin auth Google refresh route', () => {
const ORIGINAL_GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const ORIGINAL_GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const { findUsers, getUserById, generateToken } = require('~/models');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const validIdToken = () => {
const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'google-admin-id' })).toString('base64url');
return `${header}.${payload}.signature`;
};
let app;
let originalFetch;
beforeEach(() => {
jest.clearAllMocks();
process.env.GOOGLE_CLIENT_ID = 'google-client-id';
process.env.GOOGLE_CLIENT_SECRET = 'google-client-secret';
delete process.env.SESSION_EXPIRY;
app = express();
app.use(express.json());
app.use('/api/admin', adminAuthRouter);
originalFetch = global.fetch;
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: 'google-new-access',
id_token: validIdToken(),
expires_in: 3600,
}),
}),
);
process.env.GOOGLE_CLIENT_ID = 'google-client-id';
process.env.GOOGLE_CLIENT_SECRET = 'google-client-secret';
findUsers.mockResolvedValue([
{
_id: 'user-id',
id: 'user-id',
email: 'admin@example.com',
name: 'Admin',
username: 'admin',
role: 'ADMIN',
provider: 'google',
googleId: 'google-admin-id',
},
]);
getUserById.mockResolvedValue(null);
generateToken.mockResolvedValue('admin-jwt');
hasCapability.mockResolvedValue(true);
});
afterEach(() => {
global.fetch = originalFetch;
});
afterAll(() => {
if (ORIGINAL_GOOGLE_CLIENT_ID === undefined) {
delete process.env.GOOGLE_CLIENT_ID;
} else {
process.env.GOOGLE_CLIENT_ID = ORIGINAL_GOOGLE_CLIENT_ID;
}
if (ORIGINAL_GOOGLE_CLIENT_SECRET === undefined) {
delete process.env.GOOGLE_CLIENT_SECRET;
} else {
process.env.GOOGLE_CLIENT_SECRET = ORIGINAL_GOOGLE_CLIENT_SECRET;
}
});
it('refreshes a google admin session by calling Google with grant_type=refresh_token', async () => {
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(200);
expect(response.body).toEqual({
applyGoogleAdminRefresh.mockResolvedValue({
token: 'admin-jwt',
refreshToken: 'incoming-google-refresh',
refreshToken: 'rotated-refresh',
user: {
_id: 'user-id',
id: 'user-id',
@ -355,20 +281,39 @@ describe('admin auth Google refresh route', () => {
role: 'ADMIN',
provider: 'google',
},
expiresAt: expect.any(Number),
expiresAt: 1234567890,
});
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
});
it('delegates to applyGoogleAdminRefresh with route-supplied deps and options', async () => {
const response = await request(app).post('/api/admin/oauth/refresh').send({
refresh_token: 'incoming-google-refresh',
user_id: '6a343eb8b5025a84b6ca2767',
provider: 'google',
});
expect(response.status).toBe(200);
expect(response.body).toEqual({
token: 'admin-jwt',
refreshToken: 'rotated-refresh',
user: expect.objectContaining({ provider: 'google' }),
expiresAt: 1234567890,
});
expect(applyGoogleAdminRefresh).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
findUsers: expect.any(Function),
getUserById: expect.any(Function),
canAccessAdmin: expect.any(Function),
mintToken: expect.any(Function),
}),
{
refreshToken: 'incoming-google-refresh',
userId: '6a343eb8b5025a84b6ca2767',
tenantId: undefined,
clientId: 'google-client-id',
clientSecret: 'google-client-secret',
},
);
const body = global.fetch.mock.calls[0][1].body.toString();
expect(body).toContain('client_id=google-client-id');
expect(body).toContain('client_secret=google-client-secret');
expect(body).toContain('refresh_token=incoming-google-refresh');
expect(body).toContain('grant_type=refresh_token');
});
it('does not require OPENID_REUSE_TOKENS for the google provider', async () => {
@ -381,205 +326,42 @@ describe('admin auth Google refresh route', () => {
expect(response.status).toBe(200);
});
it('rejects google refresh when GOOGLE_CLIENT_ID/SECRET are not configured', async () => {
delete process.env.GOOGLE_CLIENT_ID;
delete process.env.GOOGLE_CLIENT_SECRET;
it('maps AdminRefreshError thrown by the helper to the documented status and code', async () => {
const { AdminRefreshError } = require('@librechat/api');
applyGoogleAdminRefresh.mockRejectedValueOnce(
new AdminRefreshError('GOOGLE_NOT_CONFIGURED', 503, 'Google admin OAuth is not configured'),
);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(503);
expect(response.body.error_code).toBe('GOOGLE_NOT_CONFIGURED');
expect(global.fetch).not.toHaveBeenCalled();
});
it('maps a 401 from Google to REFRESH_FAILED', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({}) }),
);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(401);
expect(response.body.error_code).toBe('REFRESH_FAILED');
});
it('returns IDP_INCOMPLETE when Google omits access_token', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ id_token: validIdToken() }),
}),
);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(502);
expect(response.body.error_code).toBe('IDP_INCOMPLETE');
});
it('returns IDP_INCOMPLETE when Google returns a non-JSON token body', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON at position 0')),
}),
);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(502);
expect(response.body.error_code).toBe('IDP_INCOMPLETE');
});
it('falls back to Google userinfo when the refresh response omits id_token', async () => {
const tokenCall = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ access_token: 'google-new-access' }),
}),
);
const userinfoCall = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ sub: 'google-admin-id' }),
}),
);
global.fetch = jest.fn((url) =>
url === 'https://openidconnect.googleapis.com/v1/userinfo' ? userinfoCall() : tokenCall(),
);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(200);
expect(response.body.user._id).toBe('user-id');
expect(userinfoCall).toHaveBeenCalled();
});
it('returns CLAIMS_INCOMPLETE when id_token is absent and userinfo also fails', async () => {
global.fetch = jest.fn((url) => {
if (url === 'https://openidconnect.googleapis.com/v1/userinfo') {
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({}) });
}
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ access_token: 'google-new-access' }),
});
expect(response.body).toEqual({
error: 'Google admin OAuth is not configured',
error_code: 'GOOGLE_NOT_CONFIGURED',
});
});
it('returns 500 INTERNAL_ERROR when the helper throws a non-AdminRefreshError', async () => {
applyGoogleAdminRefresh.mockRejectedValueOnce(new Error('boom'));
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(502);
expect(response.body.error_code).toBe('CLAIMS_INCOMPLETE');
expect(response.status).toBe(500);
expect(response.body.error_code).toBe('INTERNAL_ERROR');
});
it('returns CLAIMS_INCOMPLETE when Google id_token has no sub', async () => {
const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({})).toString('base64url');
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: 'google-new-access',
id_token: `${header}.${payload}.signature`,
}),
}),
);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(502);
expect(response.body.error_code).toBe('CLAIMS_INCOMPLETE');
});
it('returns USER_NOT_FOUND when no admin user matches the refreshed googleId', async () => {
findUsers.mockResolvedValue([]);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(401);
expect(response.body.error_code).toBe('USER_NOT_FOUND');
});
it('returns USER_ID_MISMATCH when user_id resolves to a user with a different googleId', async () => {
getUserById.mockResolvedValue({
_id: { toString: () => 'other-user' },
googleId: 'different-google-id',
tenantId: undefined,
});
const response = await request(app).post('/api/admin/oauth/refresh').send({
refresh_token: 'incoming-google-refresh',
user_id: 'other-user',
provider: 'google',
});
expect(response.status).toBe(401);
expect(response.body.error_code).toBe('USER_ID_MISMATCH');
});
it('returns FORBIDDEN when the resolved user no longer holds ACCESS_ADMIN', async () => {
hasCapability.mockResolvedValue(false);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(403);
expect(response.body.error_code).toBe('FORBIDDEN');
});
it('forwards a rotated refresh_token from Google when present', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
access_token: 'google-new-access',
id_token: validIdToken(),
refresh_token: 'rotated-google-refresh',
}),
}),
);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
expect(response.status).toBe(200);
expect(response.body.refreshToken).toBe('rotated-google-refresh');
});
it('rejects unknown provider values with INVALID_PROVIDER', async () => {
it('rejects unknown provider values with INVALID_PROVIDER before calling either helper', async () => {
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-refresh', provider: 'github' });
expect(response.status).toBe(400);
expect(response.body.error_code).toBe('INVALID_PROVIDER');
expect(global.fetch).not.toHaveBeenCalled();
expect(applyGoogleAdminRefresh).not.toHaveBeenCalled();
expect(applyAdminRefresh).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,272 @@
import { Types } from 'mongoose';
import type { IUser } from '@librechat/data-schemas';
import type { GoogleAdminRefreshDeps, GoogleAdminRefreshOptions } from './googleRefresh';
import { applyGoogleAdminRefresh } from './googleRefresh';
import { AdminRefreshError } from './refresh';
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
},
}));
const SUB = 'google-admin-sub';
function makeUser(overrides: Partial<IUser> = {}): IUser {
const _id = overrides._id ?? new Types.ObjectId();
return {
_id,
email: 'admin@example.com',
name: 'Admin User',
username: 'admin',
role: 'ADMIN',
provider: 'google',
googleId: SUB,
avatar: 'https://example.com/avatar.png',
...overrides,
} as IUser;
}
function makeIdToken(claims: Record<string, unknown> = { sub: SUB }): string {
const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url');
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
return `${header}.${payload}.signature`;
}
function makeOkJson(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function makeStatus(status: number, body: unknown = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const baseOptions: GoogleAdminRefreshOptions = {
refreshToken: 'incoming-refresh',
clientId: 'google-client-id',
clientSecret: 'google-client-secret',
};
describe('applyGoogleAdminRefresh', () => {
let deps: jest.Mocked<GoogleAdminRefreshDeps>;
let fetchMock: jest.Mock;
let originalFetch: typeof fetch;
beforeEach(() => {
jest.clearAllMocks();
deps = {
findUsers: jest.fn(),
getUserById: jest.fn(),
canAccessAdmin: jest.fn(),
mintToken: jest.fn(),
};
originalFetch = global.fetch;
fetchMock = jest.fn();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
it('refreshes a Google admin session and returns the exchange-shaped response', async () => {
const user = makeUser();
fetchMock.mockResolvedValueOnce(
makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }),
);
deps.findUsers.mockResolvedValue([user]);
deps.canAccessAdmin.mockResolvedValue(true);
deps.mintToken.mockResolvedValue({ token: 'minted-jwt', expiresAt: 1700000000000 });
const result = await applyGoogleAdminRefresh(deps, baseOptions);
expect(result).toEqual({
token: 'minted-jwt',
refreshToken: 'incoming-refresh',
user: expect.objectContaining({
id: String(user._id),
_id: String(user._id),
email: 'admin@example.com',
provider: 'google',
username: 'admin',
role: 'ADMIN',
}),
expiresAt: 1700000000000,
});
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://oauth2.googleapis.com/token');
const body = (init as { body: URLSearchParams }).body.toString();
expect(body).toContain('client_id=google-client-id');
expect(body).toContain('grant_type=refresh_token');
expect(body).toContain('refresh_token=incoming-refresh');
});
it('throws GOOGLE_NOT_CONFIGURED when credentials are missing', async () => {
await expect(
applyGoogleAdminRefresh(deps, {
...baseOptions,
clientId: undefined,
clientSecret: undefined,
}),
).rejects.toMatchObject({ code: 'GOOGLE_NOT_CONFIGURED', status: 503 });
expect(fetchMock).not.toHaveBeenCalled();
});
it('throws REFRESH_FAILED when Google rejects the grant', async () => {
fetchMock.mockResolvedValueOnce(makeStatus(401));
await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({
code: 'REFRESH_FAILED',
status: 401,
});
});
it('throws IDP_INCOMPLETE when Google returns a non-JSON body', async () => {
fetchMock.mockResolvedValueOnce(
new Response('not json', { status: 200, headers: { 'Content-Type': 'text/plain' } }),
);
await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({
code: 'IDP_INCOMPLETE',
status: 502,
});
});
it('throws IDP_INCOMPLETE when the tokenset is missing access_token', async () => {
fetchMock.mockResolvedValueOnce(makeOkJson({ id_token: makeIdToken() }));
await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({
code: 'IDP_INCOMPLETE',
status: 502,
});
});
it('falls back to the userinfo endpoint when id_token is absent', async () => {
const user = makeUser();
fetchMock
.mockResolvedValueOnce(makeOkJson({ access_token: 'new-access' }))
.mockResolvedValueOnce(makeOkJson({ sub: SUB }));
deps.findUsers.mockResolvedValue([user]);
deps.canAccessAdmin.mockResolvedValue(true);
deps.mintToken.mockResolvedValue({ token: 'minted-jwt', expiresAt: 1 });
const result = await applyGoogleAdminRefresh(deps, baseOptions);
expect(fetchMock.mock.calls[1][0]).toBe('https://openidconnect.googleapis.com/v1/userinfo');
expect(result.user.id).toBe(String(user._id));
});
it('throws CLAIMS_INCOMPLETE when neither id_token nor userinfo yields a sub', async () => {
fetchMock
.mockResolvedValueOnce(makeOkJson({ access_token: 'new-access' }))
.mockResolvedValueOnce(makeStatus(401));
await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({
code: 'CLAIMS_INCOMPLETE',
status: 502,
});
});
it('throws USER_ID_MISMATCH when user_id resolves to a different googleId', async () => {
fetchMock.mockResolvedValueOnce(
makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }),
);
const direct = makeUser({ googleId: 'other-google-id' });
deps.getUserById.mockResolvedValue(direct);
await expect(
applyGoogleAdminRefresh(deps, { ...baseOptions, userId: String(direct._id) }),
).rejects.toMatchObject({ code: 'USER_ID_MISMATCH', status: 401 });
});
it('ignores malformed user_id values that are not valid ObjectIds', async () => {
const user = makeUser();
fetchMock.mockResolvedValueOnce(
makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }),
);
deps.findUsers.mockResolvedValue([user]);
deps.canAccessAdmin.mockResolvedValue(true);
deps.mintToken.mockResolvedValue({ token: 'minted-jwt', expiresAt: 1 });
const result = await applyGoogleAdminRefresh(deps, {
...baseOptions,
userId: 'not-an-objectid',
});
expect(deps.getUserById).not.toHaveBeenCalled();
expect(result.token).toBe('minted-jwt');
});
it('throws TENANT_MISMATCH when the resolved direct user belongs to another tenant', async () => {
fetchMock.mockResolvedValueOnce(
makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }),
);
const direct = makeUser({ tenantId: 'tenant-a' });
deps.getUserById.mockResolvedValue(direct);
await expect(
applyGoogleAdminRefresh(deps, {
...baseOptions,
userId: String(direct._id),
tenantId: 'tenant-b',
}),
).rejects.toMatchObject({ code: 'TENANT_MISMATCH', status: 401 });
});
it('throws USER_NOT_FOUND when no admin user matches the refreshed googleId', async () => {
fetchMock.mockResolvedValueOnce(
makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }),
);
deps.findUsers.mockResolvedValue([]);
await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({
code: 'USER_NOT_FOUND',
status: 401,
});
});
it('throws FORBIDDEN when the resolved user no longer holds ACCESS_ADMIN', async () => {
const user = makeUser();
fetchMock.mockResolvedValueOnce(
makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }),
);
deps.findUsers.mockResolvedValue([user]);
deps.canAccessAdmin.mockResolvedValue(false);
await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({
code: 'FORBIDDEN',
status: 403,
});
});
it('returns the rotated refresh_token when Google supplies one', async () => {
const user = makeUser();
fetchMock.mockResolvedValueOnce(
makeOkJson({
access_token: 'new-access',
id_token: makeIdToken(),
refresh_token: 'rotated-refresh',
}),
);
deps.findUsers.mockResolvedValue([user]);
deps.canAccessAdmin.mockResolvedValue(true);
deps.mintToken.mockResolvedValue({ token: 'minted-jwt', expiresAt: 1 });
const result = await applyGoogleAdminRefresh(deps, baseOptions);
expect(result.refreshToken).toBe('rotated-refresh');
});
it('uses (AdminRefreshError instanceof) for route mapping', () => {
const err = new AdminRefreshError('GOOGLE_NOT_CONFIGURED', 503, 'msg');
expect(err).toBeInstanceOf(AdminRefreshError);
});
});

View file

@ -0,0 +1,231 @@
import { Types } from 'mongoose';
import { logger } from '@librechat/data-schemas';
import type { IUser } from '@librechat/data-schemas';
import type { FilterQuery } from 'mongoose';
import type { AdminExchangeResponse } from '~/auth/exchange';
import { serializeUserForExchange } from '~/auth/exchange';
import { AdminRefreshError } from '~/auth/refresh';
const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
const GOOGLE_USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo';
const SAFE_USER_PROJECTION = '-password -__v -totpSecret -backupCodes';
interface GoogleTokenset {
access_token?: string;
id_token?: string;
refresh_token?: string;
}
interface IdTokenClaims {
sub?: string;
}
export interface MintedGoogleAdminToken {
token: string;
expiresAt: number;
}
export interface GoogleAdminRefreshDeps {
findUsers: (
filter: FilterQuery<IUser>,
projection: string,
options: { sort: Record<string, 1 | -1>; limit: number },
) => Promise<IUser[]>;
getUserById: (id: string, projection: string) => Promise<IUser | null>;
canAccessAdmin: (user: IUser) => Promise<boolean>;
mintToken: (user: IUser) => Promise<MintedGoogleAdminToken>;
}
export interface GoogleAdminRefreshOptions {
refreshToken: string;
userId?: string;
tenantId?: string;
clientId?: string;
clientSecret?: string;
}
function decodeJwtPayload(token: string): IdTokenClaims | undefined {
const segments = token.split('.');
if (segments.length !== 3) return undefined;
try {
const payload = Buffer.from(segments[1], 'base64url').toString('utf8');
return JSON.parse(payload) as IdTokenClaims;
} catch {
return undefined;
}
}
async function resolveSubFromUserinfo(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(GOOGLE_USERINFO_ENDPOINT, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
logger.warn('[adminGoogleRefresh] userinfo fallback returned non-OK', {
status: response.status,
});
return undefined;
}
const body = (await response.json().catch(() => undefined)) as IdTokenClaims | undefined;
return typeof body?.sub === 'string' ? body.sub : undefined;
} catch (err) {
const error = err as { name?: string; message?: string };
logger.warn('[adminGoogleRefresh] userinfo fallback failed', {
name: error?.name,
message: error?.message,
});
return undefined;
}
}
async function fetchGoogleTokenset(options: GoogleAdminRefreshOptions): Promise<GoogleTokenset> {
let response: Response;
try {
response = await fetch(GOOGLE_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: options.clientId ?? '',
client_secret: options.clientSecret ?? '',
refresh_token: options.refreshToken,
grant_type: 'refresh_token',
}),
});
} catch (err) {
const error = err as { name?: string; message?: string };
logger.warn('[adminGoogleRefresh] token endpoint request failed', {
name: error?.name,
message: error?.message,
});
throw new AdminRefreshError('REFRESH_FAILED', 401, 'Refresh failed');
}
if (!response.ok) {
logger.warn('[adminGoogleRefresh] Google rejected refresh grant', {
status: response.status,
});
throw new AdminRefreshError('REFRESH_FAILED', 401, 'Refresh failed');
}
try {
return (await response.json()) as GoogleTokenset;
} catch (err) {
const error = err as { name?: string; message?: string };
logger.warn('[adminGoogleRefresh] Google returned non-JSON body', {
name: error?.name,
message: error?.message,
});
throw new AdminRefreshError('IDP_INCOMPLETE', 502, 'Google returned a non-JSON token response');
}
}
async function resolveGoogleSub(tokenset: GoogleTokenset): Promise<string> {
if (typeof tokenset.access_token !== 'string') {
throw new AdminRefreshError(
'IDP_INCOMPLETE',
502,
'Google returned a tokenset missing access_token',
);
}
let sub: string | undefined;
if (typeof tokenset.id_token === 'string') {
const claims = decodeJwtPayload(tokenset.id_token);
if (typeof claims?.sub === 'string') {
sub = claims.sub;
}
}
if (!sub) {
sub = await resolveSubFromUserinfo(tokenset.access_token);
}
if (!sub) {
throw new AdminRefreshError(
'CLAIMS_INCOMPLETE',
502,
'Could not resolve google sub from refresh response',
);
}
return sub;
}
async function resolveAdminUser(
googleId: string,
deps: GoogleAdminRefreshDeps,
options: GoogleAdminRefreshOptions,
): Promise<IUser> {
if (options.userId && Types.ObjectId.isValid(options.userId)) {
const direct = await deps.getUserById(options.userId, SAFE_USER_PROJECTION);
if (direct) {
if (direct.googleId !== googleId) {
throw new AdminRefreshError(
'USER_ID_MISMATCH',
401,
'Provided user_id does not match the refreshed identity',
);
}
if (options.tenantId && direct.tenantId !== options.tenantId) {
throw new AdminRefreshError(
'TENANT_MISMATCH',
401,
'Provided user_id resolves outside the request tenant',
);
}
return direct;
}
}
const filter = (
options.tenantId ? { googleId, tenantId: options.tenantId } : { googleId }
) as FilterQuery<IUser>;
const [found] = await deps.findUsers(filter, SAFE_USER_PROJECTION, {
sort: { updatedAt: -1 },
limit: 1,
});
if (!found) {
throw new AdminRefreshError('USER_NOT_FOUND', 401, 'No user found for the refreshed identity');
}
return found;
}
/**
* Refresh a Google admin OAuth session.
*
* Mirrors the OpenID admin refresh contract from `applyAdminRefresh` but
* speaks Google's OAuth 2.0 refresh-token grant. Calls Google's token
* endpoint, resolves the user's `sub` (preferring an `id_token` claim, with
* a userinfo-endpoint fallback per Google's documented behavior of returning
* id_token only conditionally on refresh), looks up the admin by `googleId`,
* enforces tenant + `ACCESS_ADMIN`, and mints a fresh LibreChat JWT in the
* same response shape as `/api/admin/oauth/exchange`.
*/
export async function applyGoogleAdminRefresh(
deps: GoogleAdminRefreshDeps,
options: GoogleAdminRefreshOptions,
): Promise<AdminExchangeResponse> {
if (!options.clientId || !options.clientSecret) {
throw new AdminRefreshError(
'GOOGLE_NOT_CONFIGURED',
503,
'Google admin OAuth is not configured',
);
}
const tokenset = await fetchGoogleTokenset(options);
const googleId = await resolveGoogleSub(tokenset);
const user = await resolveAdminUser(googleId, deps, options);
if (!(await deps.canAccessAdmin(user))) {
throw new AdminRefreshError('FORBIDDEN', 403, 'User does not have admin access');
}
const minted = await deps.mintToken(user);
return {
token: minted.token,
refreshToken: tokenset.refresh_token ?? options.refreshToken,
user: serializeUserForExchange(user),
expiresAt: minted.expiresAt,
};
}

View file

@ -3,6 +3,7 @@ export * from './openid';
export * from './proxy';
export * from './exchange';
export * from './refresh';
export * from './googleRefresh';
export * from './agent';
export * from './password';
export * from './invite';