🔑 feat: Refresh-Capable Google Admin OAuth Sessions

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.
This commit is contained in:
Dustin Healy 2026-06-18 07:54:30 -07:00
parent 9de3249e9c
commit d40c51616e
6 changed files with 524 additions and 15 deletions

View file

@ -43,11 +43,15 @@ function createOAuthHandler(redirectUri = domains.client) {
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
const token = await generateToken(req.user, sessionExpiry);
/** Get refresh token from tokenset for OpenID users */
const refreshToken =
req.user.provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true
? req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token
: undefined;
let refreshToken;
if (req.user.provider === 'openid') {
if (isEnabled(process.env.OPENID_REUSE_TOKENS) === true) {
refreshToken =
req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token;
}
} else {
refreshToken = req.authInfo?.refreshToken;
}
const expiresAt = Date.now() + sessionExpiry;
const callbackUrl = new URL(redirectUri);

View file

@ -148,4 +148,47 @@ describe('createOAuthHandler', () => {
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),
);
});
});

View file

@ -36,8 +36,147 @@ const getLogStores = require('~/cache/getLogStores');
const { getOpenIdConfig } = require('~/strategies');
const middleware = require('~/server/middleware');
const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
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 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');
}
const tokenset = await tokenResponse.json();
if (!tokenset.access_token || !tokenset.id_token) {
throw new AdminRefreshError(
'IDP_INCOMPLETE',
502,
'Google returned a tokenset missing access_token or id_token',
);
}
const claims = decodeJwtPayload(tokenset.id_token);
if (!claims?.sub) {
throw new AdminRefreshError(
'CLAIMS_INCOMPLETE',
502,
'Google id_token has no readable claims or no sub',
);
}
const googleId = claims.sub;
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;
const responseUser = {
id: user.id ?? user._id?.toString(),
email: user.email,
name: user.name,
role: user.role,
};
return {
token,
refreshToken: tokenset.refresh_token ?? refreshToken,
user: responseUser,
expiresAt,
};
}
const setBalanceConfig = createSetBalanceConfig({
getAppConfig,
findBalanceByUser,
@ -236,6 +375,8 @@ router.get('/oauth/google', async (req, res, next) => {
scope: ['openid', 'profile', 'email'],
session: false,
state,
accessType: 'offline',
prompt: 'consent',
})(req, res, next);
});
@ -491,21 +632,23 @@ router.post('/oauth/exchange', middleware.loginLimiter, async (req, res) => {
* `/api/admin/oauth/exchange`.
*
* POST /api/admin/oauth/refresh
* Body: { refresh_token: string, user_id?: string }
* Body: { refresh_token: string, user_id?: string, provider?: 'openid' | 'google' }
* Response: { token: string, refreshToken?: string, user: object, expiresAt: number }
*
* Errors (all responses are `{ error: string, error_code: string }`):
* 400 MISSING_REFRESH_TOKEN refresh_token absent or empty
* 400 INVALID_PROVIDER provider value not one of 'openid' | 'google'
* 401 REFRESH_FAILED IdP rejected the refresh grant
* 401 USER_NOT_FOUND no LibreChat user matches the refreshed sub
* 401 USER_ID_MISMATCH supplied user_id resolves to a user with a different openidId
* 401 USER_ID_MISMATCH supplied user_id resolves to a different provider id
* 401 ISSUER_MISMATCH refreshed tokenset was issued by an unexpected issuer
* 401 TENANT_MISMATCH resolved user belongs to a different tenant than the request
* 403 FORBIDDEN resolved user no longer holds ACCESS_ADMIN
* 403 TOKEN_REUSE_DISABLED OPENID_REUSE_TOKENS is not enabled on the server
* 502 IDP_INCOMPLETE IdP returned a tokenset missing access_token
* 403 TOKEN_REUSE_DISABLED OPENID_REUSE_TOKENS is not enabled (openid provider only)
* 502 IDP_INCOMPLETE IdP returned a tokenset missing access_token / id_token
* 502 CLAIMS_INCOMPLETE IdP tokenset has no readable claims or no sub
* 503 OPENID_NOT_CONFIGURED OpenID is not configured on this server
* 503 GOOGLE_NOT_CONFIGURED Google admin OAuth is not configured on this server
* 500 INTERNAL_ERROR anything else (logged server-side)
*/
router.post(
@ -514,7 +657,11 @@ router.post(
preAuthTenantMiddleware,
async (req, res) => {
try {
const { refresh_token: refreshToken, user_id: userId } = req.body ?? {};
const {
refresh_token: refreshToken,
user_id: userId,
provider: rawProvider,
} = req.body ?? {};
if (typeof refreshToken !== 'string' || refreshToken.length === 0) {
return res.status(400).json({
error: 'Missing refresh_token',
@ -522,6 +669,36 @@ router.post(
});
}
const provider =
typeof rawProvider === 'string' && rawProvider.length > 0 ? rawProvider : 'openid';
if (provider !== 'openid' && provider !== 'google') {
return res.status(400).json({
error: 'Unsupported provider',
error_code: 'INVALID_PROVIDER',
});
}
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
const normalizedUserId = typeof userId === 'string' && userId.length > 0 ? userId : undefined;
const tenantId = getTenantId();
if (provider === 'google') {
try {
const result = await refreshGoogleAdminSession({
refreshToken,
userId: normalizedUserId,
tenantId,
sessionExpiry,
});
return res.json(result);
} catch (err) {
if (err instanceof AdminRefreshError) {
return res.status(err.status).json({ error: err.message, error_code: err.code });
}
throw err;
}
}
if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
return res.status(403).json({
error: 'OpenID token reuse is not enabled',
@ -564,7 +741,6 @@ router.post(
});
}
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
const expectedIssuer = openIdConfig.serverMetadata?.()?.issuer;
try {
@ -596,10 +772,10 @@ router.post(
}),
},
{
userId: typeof userId === 'string' && userId.length > 0 ? userId : undefined,
userId: normalizedUserId,
previousRefreshToken: refreshToken,
expectedIssuer,
tenantId: getTenantId(),
tenantId,
},
);
return res.json(result);

View file

@ -248,3 +248,254 @@ describe('admin auth OpenID refresh route', () => {
expect(debugOutput).not.toContain('https://api.example.com');
});
});
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,
}),
}),
);
findUsers.mockResolvedValue([
{
_id: { toString: () => 'user-id' },
id: 'user-id',
email: 'admin@example.com',
name: 'Admin',
role: 'ADMIN',
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({
token: 'admin-jwt',
refreshToken: 'incoming-google-refresh',
user: {
id: 'user-id',
email: 'admin@example.com',
name: 'Admin',
role: 'ADMIN',
},
expiresAt: expect.any(Number),
});
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}),
);
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 () => {
isEnabled.mockReturnValue(false);
const response = await request(app)
.post('/api/admin/oauth/refresh')
.send({ refresh_token: 'incoming-google-refresh', provider: 'google' });
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;
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 or id_token', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ access_token: 'only-access' }),
}),
);
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 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 () => {
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();
});
});

View file

@ -55,9 +55,12 @@ const socialLogin =
return cb(error);
}
const passResult = (user) =>
refreshToken ? cb(null, user, { refreshToken }) : cb(null, user);
if (existingUser?.provider === provider) {
await handleExistingUser(existingUser, avatarUrl, appConfig, email);
return cb(null, existingUser);
return passResult(existingUser);
} else if (existingUser) {
logger.info(
`[${provider}Login] User ${email} already exists with provider ${existingUser.provider}`,
@ -97,7 +100,7 @@ const socialLogin =
emailVerified,
appConfig,
});
return cb(null, newUser);
return passResult(newUser);
} catch (err) {
logger.error(`[${provider}Login]`, err);
return cb(err);

View file

@ -358,5 +358,37 @@ describe('socialLogin', () => {
expect.objectContaining({ message: 'Email domain not allowed' }),
);
});
it('passes the IdP refresh token through as authInfo when present', async () => {
const provider = 'google';
const googleId = 'google-with-refresh';
const email = 'admin@example.com';
const existingUser = {
_id: 'userRefresh',
email,
provider: 'google',
googleId,
role: 'ADMIN',
};
findUser.mockResolvedValue(existingUser);
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'Admin', familyName: 'User' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, 'idp-refresh-token', null, mockProfile, callback);
expect(callback).toHaveBeenCalledWith(null, existingUser, {
refreshToken: 'idp-refresh-token',
});
});
});
});