diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js index 02da392795..0f222e9a32 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -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) { diff --git a/api/server/routes/admin/auth.refresh.test.js b/api/server/routes/admin/auth.refresh.test.js index e15225ccaf..b9a2c7b68c 100644 --- a/api/server/routes/admin/auth.refresh.test.js +++ b/api/server/routes/admin/auth.refresh.test.js @@ -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(); }); }); diff --git a/packages/api/src/auth/googleRefresh.spec.ts b/packages/api/src/auth/googleRefresh.spec.ts new file mode 100644 index 0000000000..2b797a45a6 --- /dev/null +++ b/packages/api/src/auth/googleRefresh.spec.ts @@ -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 { + 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 = { 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; + 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); + }); +}); diff --git a/packages/api/src/auth/googleRefresh.ts b/packages/api/src/auth/googleRefresh.ts new file mode 100644 index 0000000000..8f92765d06 --- /dev/null +++ b/packages/api/src/auth/googleRefresh.ts @@ -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, + projection: string, + options: { sort: Record; limit: number }, + ) => Promise; + getUserById: (id: string, projection: string) => Promise; + canAccessAdmin: (user: IUser) => Promise; + mintToken: (user: IUser) => Promise; +} + +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 { + 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 { + 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 { + 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 { + 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; + 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 { + 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, + }; +} diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index f51f5c4081..f177002916 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -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';