mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 11:53:55 +00:00
🧹 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:
parent
984f81e257
commit
21922eea78
5 changed files with 587 additions and 447 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
272
packages/api/src/auth/googleRefresh.spec.ts
Normal file
272
packages/api/src/auth/googleRefresh.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
231
packages/api/src/auth/googleRefresh.ts
Normal file
231
packages/api/src/auth/googleRefresh.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue