diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js index ede02febb2..2f265ba227 100644 --- a/api/server/controllers/auth/oauth.js +++ b/api/server/controllers/auth/oauth.js @@ -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); diff --git a/api/server/controllers/auth/oauth.spec.js b/api/server/controllers/auth/oauth.spec.js index 4a20442d4f..d579e7524e 100644 --- a/api/server/controllers/auth/oauth.spec.js +++ b/api/server/controllers/auth/oauth.spec.js @@ -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), + ); + }); }); diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js index 47081232de..a8b6775ce6 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -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); diff --git a/api/server/routes/admin/auth.refresh.test.js b/api/server/routes/admin/auth.refresh.test.js index d4fb59c569..292789dcc7 100644 --- a/api/server/routes/admin/auth.refresh.test.js +++ b/api/server/routes/admin/auth.refresh.test.js @@ -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(); + }); +}); diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js index 580e4f3d7e..9eac62fb9a 100644 --- a/api/strategies/socialLogin.js +++ b/api/strategies/socialLogin.js @@ -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); diff --git a/api/strategies/socialLogin.test.js b/api/strategies/socialLogin.test.js index 4fde397d55..a24f13e511 100644 --- a/api/strategies/socialLogin.test.js +++ b/api/strategies/socialLogin.test.js @@ -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', + }); + }); }); });