diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js index cd6a88ac67..b50fc65a47 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 if (req.user.provider === 'google') { + 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..d9984a8dd5 100644 --- a/api/server/controllers/auth/oauth.spec.js +++ b/api/server/controllers/auth/oauth.spec.js @@ -148,4 +148,69 @@ 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), + ); + }); + + it('does not forward refresh tokens for admin providers other than google or openid', async () => { + const handler = createOAuthHandler('http://admin.example.com/auth/discord/callback'); + const req = buildReq({ + user: { _id: 'user-9', email: 'd@example.com', provider: 'discord' }, + authInfo: { refreshToken: 'discord-refresh-token' }, + }); + 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 29e9c8102f..fbc53c0907 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -8,6 +8,7 @@ const { DEFAULT_SESSION_EXPIRY, SystemCapabilities, getTenantId, + tenantStorage, } = require('@librechat/data-schemas'); const { isEnabled, @@ -18,8 +19,10 @@ const { tenantContextMiddleware, preAuthTenantMiddleware, applyAdminRefresh, + applyGoogleAdminRefresh, AdminRefreshError, buildOpenIDRefreshParams, + isEmailDomainAllowed, } = require('@librechat/api'); const { loginController } = require('~/server/controllers/auth/LoginController'); const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities'); @@ -101,6 +104,55 @@ function resolveRequestOrigin(req) { } } +async function isEmailAllowedForUser(user) { + if (!user?.email) return false; + try { + const userId = user.id ?? user._id?.toString(); + const appConfig = user.tenantId + ? await tenantStorage.run({ tenantId: user.tenantId }, () => + getAppConfig({ role: user.role ?? '', userId, tenantId: user.tenantId }), + ) + : await getAppConfig({ role: user.role ?? '', userId }); + return isEmailDomainAllowed(user.email, appConfig?.registration?.allowedDomains); + } catch (err) { + logger.warn(`[admin/oauth/refresh] domain allowlist check failed, denying: ${err?.message}`); + return false; + } +} + +function buildAdminRefreshClosures(sessionExpiry) { + return { + 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; + } + }, + isEmailAllowed: isEmailAllowedForUser, + mintToken: async (user) => ({ + token: await generateToken(user, sessionExpiry), + expiresAt: Date.now() + sessionExpiry, + }), + }; +} + +function buildGoogleAdminRefreshDeps(sessionExpiry) { + return { + findUsers, + getUserById, + ...buildAdminRefreshClosures(sessionExpiry), + }; +} + router.post( '/login/local', middleware.logHeaders, @@ -275,6 +327,8 @@ router.get( scope: ['openid', 'profile', 'email'], session: false, state, + accessType: 'offline', + prompt: 'consent', })(req, res, next); }, ); @@ -548,30 +602,37 @@ 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( '/oauth/refresh', middleware.loginLimiter, + middleware.checkBan, 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', @@ -579,6 +640,40 @@ 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 applyGoogleAdminRefresh(buildGoogleAdminRefreshDeps(sessionExpiry), { + refreshToken, + userId: normalizedUserId, + tenantId, + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }); + req.user = { id: result.user._id }; + await middleware.checkBan(req, res, () => {}); + if (req.banned || res.headersSent) return; + 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', @@ -621,7 +716,6 @@ router.post( }); } - const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; const expectedIssuer = openIdConfig.serverMetadata?.()?.issuer; try { @@ -630,35 +724,18 @@ router.post( { 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, - }), + ...buildAdminRefreshClosures(sessionExpiry), }, { - userId: typeof userId === 'string' && userId.length > 0 ? userId : undefined, + userId: normalizedUserId, previousRefreshToken: refreshToken, expectedIssuer, - tenantId: getTenantId(), + tenantId, }, ); + req.user = { id: result.user._id }; + await middleware.checkBan(req, res, () => {}); + if (req.banned || res.headersSent) return; return res.json(result); } catch (err) { if (err instanceof AdminRefreshError) { diff --git a/api/server/routes/admin/auth.refresh.test.js b/api/server/routes/admin/auth.refresh.test.js index 3e2ce1cff7..3539c2d285 100644 --- a/api/server/routes/admin/auth.refresh.test.js +++ b/api/server/routes/admin/auth.refresh.test.js @@ -23,6 +23,7 @@ jest.mock('@librechat/data-schemas', () => ({ DEFAULT_SESSION_EXPIRY: 60000, SystemCapabilities: { ACCESS_ADMIN: 'ACCESS_ADMIN' }, getTenantId: jest.fn(() => undefined), + tenantStorage: { run: jest.fn((ctx, fn) => fn()) }, })); jest.mock('@librechat/api', () => { @@ -44,6 +45,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 = {}; @@ -110,6 +112,7 @@ const { logger } = require('@librechat/data-schemas'); const { isEnabled, applyAdminRefresh, + applyGoogleAdminRefresh, storeAndStripChallenge, buildOpenIDRefreshParams, } = require('@librechat/api'); @@ -121,146 +124,6 @@ const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE; const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE; const ORIGINAL_SESSION_EXPIRY = process.env.SESSION_EXPIRY; -describe('admin auth OpenID route availability', () => { - let app; - - beforeEach(() => { - jest.clearAllMocks(); - - app = express(); - app.use(express.json()); - app.use('/api/admin', adminAuthRouter); - }); - - it('returns not configured for the OpenID availability check when config lookup throws', async () => { - getOpenIdConfig.mockImplementation(() => { - throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); - }); - - const response = await request(app).get('/api/admin/oauth/openid/check'); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ - error: 'OpenID configuration not found', - error_code: 'OPENID_NOT_CONFIGURED', - }); - }); - - it('does not start OpenID admin login when config lookup throws', async () => { - getOpenIdConfig.mockImplementation(() => { - throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); - }); - - const response = await request(app).get('/api/admin/oauth/openid'); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ - error: 'OpenID configuration not found', - error_code: 'OPENID_NOT_CONFIGURED', - }); - expect(storeAndStripChallenge).not.toHaveBeenCalled(); - expect(passport.authenticate).not.toHaveBeenCalled(); - }); - - it('does not run OpenID admin callback auth when config lookup throws', async () => { - getOpenIdConfig.mockImplementation(() => { - throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); - }); - - const response = await request(app).get('/api/admin/oauth/openid/callback?state=state'); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ - error: 'OpenID configuration not found', - error_code: 'OPENID_NOT_CONFIGURED', - }); - }); -}); - -describe('admin auth social route availability', () => { - let app; - - beforeEach(() => { - jest.clearAllMocks(); - passport._strategy.mockReturnValue(undefined); - - app = express(); - app.use(express.json()); - app.use('/api/admin', adminAuthRouter); - }); - - const startRoutes = [ - ['saml', 'SAML'], - ['google', 'Google'], - ['github', 'GitHub'], - ['discord', 'Discord'], - ['facebook', 'Facebook'], - ['apple', 'Apple'], - ]; - - it.each(startRoutes)( - 'does not start %s admin login when the strategy is not registered', - async (path, provider) => { - const response = await request(app).get(`/api/admin/oauth/${path}`); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ - error: `${provider} configuration not found`, - error_code: `${provider.toUpperCase()}_NOT_CONFIGURED`, - }); - expect(storeAndStripChallenge).not.toHaveBeenCalled(); - expect(passport.authenticate).not.toHaveBeenCalled(); - }, - ); - - const callbackRoutes = [ - [ - 'saml', - 'SAML', - (agent) => agent.post('/api/admin/oauth/saml/callback').send({ RelayState: 'state' }), - ], - ['google', 'Google', (agent) => agent.get('/api/admin/oauth/google/callback?state=state')], - ['github', 'GitHub', (agent) => agent.get('/api/admin/oauth/github/callback?state=state')], - ['discord', 'Discord', (agent) => agent.get('/api/admin/oauth/discord/callback?state=state')], - [ - 'facebook', - 'Facebook', - (agent) => agent.get('/api/admin/oauth/facebook/callback?state=state'), - ], - [ - 'apple', - 'Apple', - (agent) => agent.post('/api/admin/oauth/apple/callback').send({ state: 'state' }), - ], - ]; - - it.each(callbackRoutes)( - 'does not run %s admin callback auth when the strategy is not registered', - async (path, provider, makeRequest) => { - const response = await makeRequest(request(app)); - - expect(response.status).toBe(404); - expect(response.body).toEqual({ - error: `${provider} configuration not found`, - error_code: `${provider.toUpperCase()}_NOT_CONFIGURED`, - }); - }, - ); - - it('starts admin login when the strategy is registered', async () => { - passport._strategy.mockReturnValue({ name: 'googleAdmin' }); - storeAndStripChallenge.mockResolvedValue(true); - - await request(app).get('/api/admin/oauth/google'); - - expect(storeAndStripChallenge).toHaveBeenCalledTimes(1); - expect(passport.authenticate).toHaveBeenCalledWith( - 'googleAdmin', - expect.objectContaining({ session: false }), - ); - }); -}); - describe('admin auth OpenID refresh route', () => { const openIdConfig = { serverMetadata: jest.fn(() => ({ issuer: 'https://issuer.example.com' })), @@ -398,6 +261,326 @@ describe('admin auth OpenID refresh route', () => { }); }); +describe('admin auth Google refresh route', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.SESSION_EXPIRY; + + app = express(); + app.use(express.json()); + app.use('/api/admin', adminAuthRouter); + + process.env.GOOGLE_CLIENT_ID = 'google-client-id'; + process.env.GOOGLE_CLIENT_SECRET = 'google-client-secret'; + + applyGoogleAdminRefresh.mockResolvedValue({ + token: 'admin-jwt', + refreshToken: 'rotated-refresh', + user: { + _id: 'user-id', + id: 'user-id', + email: 'admin@example.com', + name: 'Admin', + username: 'admin', + role: 'ADMIN', + provider: 'google', + }, + expiresAt: 1234567890, + }); + }); + + 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({ + _id: 'user-id', + id: 'user-id', + email: 'admin@example.com', + name: 'Admin', + username: 'admin', + role: 'ADMIN', + provider: 'google', + }), + expiresAt: 1234567890, + }); + expect(applyGoogleAdminRefresh).toHaveBeenCalledWith( + expect.objectContaining({ + 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', + }, + ); + }); + + it('forwards the tenant id from getTenantId() to the helper', async () => { + const { getTenantId } = require('@librechat/data-schemas'); + getTenantId.mockReturnValueOnce('tenant-x'); + + const response = await request(app) + .post('/api/admin/oauth/refresh') + .send({ refresh_token: 'incoming-google-refresh', provider: 'google' }); + + expect(response.status).toBe(200); + expect(applyGoogleAdminRefresh).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ tenantId: 'tenant-x' }), + ); + }); + + it('canAccessAdmin closure calls hasCapability with the normalized user id', async () => { + const { hasCapability } = require('~/server/middleware/roles/capabilities'); + let capturedDeps; + applyGoogleAdminRefresh.mockImplementationOnce(async (deps) => { + capturedDeps = deps; + return { + token: 'jwt', + refreshToken: 'r', + user: { id: 'u', _id: 'u', email: 'e@e.com', name: '', username: '', role: 'ADMIN' }, + expiresAt: 0, + }; + }); + + await request(app) + .post('/api/admin/oauth/refresh') + .send({ refresh_token: 'google-refresh', provider: 'google' }); + + await capturedDeps.canAccessAdmin({ id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' }); + expect(hasCapability).toHaveBeenCalledWith( + { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' }, + 'ACCESS_ADMIN', + ); + }); + + 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('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).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(500); + expect(response.body.error_code).toBe('INTERNAL_ERROR'); + }); + + 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(applyGoogleAdminRefresh).not.toHaveBeenCalled(); + expect(applyAdminRefresh).not.toHaveBeenCalled(); + }); + + it('re-runs checkBan with the resolved user identity and blocks a banned user', async () => { + const middleware = require('~/server/middleware'); + let banCheckCalls = 0; + middleware.checkBan.mockImplementation((req, res, next) => { + banCheckCalls++; + if (banCheckCalls >= 2 && req.user) { + req.banned = true; + return res.status(403).json({ message: 'banned' }); + } + return next(); + }); + + const response = await request(app) + .post('/api/admin/oauth/refresh') + .send({ refresh_token: 'incoming-google-refresh', provider: 'google' }); + + expect(response.status).toBe(403); + expect(middleware.checkBan).toHaveBeenCalledTimes(2); + expect(middleware.checkBan.mock.calls[1][0].user).toEqual({ id: 'user-id' }); + }); +}); + +describe('admin auth OpenID route availability', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + middleware.checkBan.mockImplementation((req, res, next) => next()); + + app = express(); + app.use(express.json()); + app.use('/api/admin', adminAuthRouter); + }); + + it('returns not configured for the OpenID availability check when config lookup throws', async () => { + getOpenIdConfig.mockImplementation(() => { + throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); + }); + + const response = await request(app).get('/api/admin/oauth/openid/check'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: 'OpenID configuration not found', + error_code: 'OPENID_NOT_CONFIGURED', + }); + }); + + it('does not start OpenID admin login when config lookup throws', async () => { + getOpenIdConfig.mockImplementation(() => { + throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); + }); + + const response = await request(app).get('/api/admin/oauth/openid'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: 'OpenID configuration not found', + error_code: 'OPENID_NOT_CONFIGURED', + }); + expect(storeAndStripChallenge).not.toHaveBeenCalled(); + expect(passport.authenticate).not.toHaveBeenCalled(); + }); + + it('does not run OpenID admin callback auth when config lookup throws', async () => { + getOpenIdConfig.mockImplementation(() => { + throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); + }); + + const response = await request(app).get('/api/admin/oauth/openid/callback?state=state'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: 'OpenID configuration not found', + error_code: 'OPENID_NOT_CONFIGURED', + }); + }); +}); + +describe('admin auth social route availability', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + passport._strategy.mockReturnValue(undefined); + + app = express(); + app.use(express.json()); + app.use('/api/admin', adminAuthRouter); + }); + + const startRoutes = [ + ['saml', 'SAML'], + ['google', 'Google'], + ['github', 'GitHub'], + ['discord', 'Discord'], + ['facebook', 'Facebook'], + ['apple', 'Apple'], + ]; + + it.each(startRoutes)( + 'does not start %s admin login when the strategy is not registered', + async (path, provider) => { + const response = await request(app).get(`/api/admin/oauth/${path}`); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: `${provider} configuration not found`, + error_code: `${provider.toUpperCase()}_NOT_CONFIGURED`, + }); + expect(storeAndStripChallenge).not.toHaveBeenCalled(); + expect(passport.authenticate).not.toHaveBeenCalled(); + }, + ); + + const callbackRoutes = [ + [ + 'saml', + 'SAML', + (agent) => agent.post('/api/admin/oauth/saml/callback').send({ RelayState: 'state' }), + ], + ['google', 'Google', (agent) => agent.get('/api/admin/oauth/google/callback?state=state')], + ['github', 'GitHub', (agent) => agent.get('/api/admin/oauth/github/callback?state=state')], + ['discord', 'Discord', (agent) => agent.get('/api/admin/oauth/discord/callback?state=state')], + [ + 'facebook', + 'Facebook', + (agent) => agent.get('/api/admin/oauth/facebook/callback?state=state'), + ], + [ + 'apple', + 'Apple', + (agent) => agent.post('/api/admin/oauth/apple/callback').send({ state: 'state' }), + ], + ]; + + it.each(callbackRoutes)( + 'does not run %s admin callback auth when the strategy is not registered', + async (path, provider, makeRequest) => { + const response = await makeRequest(request(app)); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + error: `${provider} configuration not found`, + error_code: `${provider.toUpperCase()}_NOT_CONFIGURED`, + }); + }, + ); + + it('starts admin login when the strategy is registered', async () => { + passport._strategy.mockReturnValue({ name: 'googleAdmin' }); + storeAndStripChallenge.mockResolvedValue(true); + + await request(app).get('/api/admin/oauth/google'); + + expect(storeAndStripChallenge).toHaveBeenCalledTimes(1); + expect(passport.authenticate).toHaveBeenCalledWith( + 'googleAdmin', + expect.objectContaining({ session: false }), + ); + }); +}); + describe('admin local login route', () => { let app; diff --git a/api/strategies/appleStrategy.test.js b/api/strategies/appleStrategy.test.js index d142d27eac..703a5ff0a4 100644 --- a/api/strategies/appleStrategy.test.js +++ b/api/strategies/appleStrategy.test.js @@ -32,6 +32,7 @@ jest.mock('@librechat/api', () => ({ })); jest.mock('~/models', () => ({ findUser: jest.fn(), + updateUser: jest.fn(), })); jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn().mockResolvedValue({ diff --git a/api/strategies/socialLogin.js b/api/strategies/socialLogin.js index 580e4f3d7e..da751d0c1c 100644 --- a/api/strategies/socialLogin.js +++ b/api/strategies/socialLogin.js @@ -3,7 +3,7 @@ const { ErrorTypes } = require('librechat-data-provider'); const { isEnabled, isEmailDomainAllowed, resolveAppConfigForUser } = require('@librechat/api'); const { createSocialUser, handleExistingUser } = require('./process'); const { getAppConfig } = require('~/server/services/Config'); -const { findUser } = require('~/models'); +const { findUser, updateUser } = require('~/models'); const socialLogin = (provider, getProfileDetails, options = {}) => @@ -55,9 +55,46 @@ const socialLogin = return cb(error); } + const passResult = (user) => + refreshToken && provider === 'google' ? cb(null, user, { refreshToken }) : cb(null, user); + if (existingUser?.provider === provider) { + if ( + options.existingUsersOnly && + id && + existingUser[providerKey] && + existingUser[providerKey] !== id + ) { + logger.warn( + `[${provider}Login] Rejected admin email fallback for ${email}: stored ${providerKey} does not match`, + ); + const error = new Error(ErrorTypes.AUTH_FAILED); + error.code = ErrorTypes.AUTH_FAILED; + return cb(error); + } + if (options.existingUsersOnly && id && !existingUser[providerKey]) { + if (existingUser.tenantId) { + logger.warn( + `[${provider}Login] Admin migrate blocked for tenanted user ${email}: no tenant scope in OAuth callback`, + ); + const tenantError = new Error(ErrorTypes.AUTH_FAILED); + tenantError.code = ErrorTypes.AUTH_FAILED; + return cb(tenantError); + } + await updateUser(existingUser._id, { [providerKey]: id }); + const verified = await findUser({ _id: existingUser._id, [providerKey]: id }); + if (!verified) { + logger.warn( + `[${provider}Login] Admin migrate superseded by concurrent write, denying: ${email}`, + ); + const concurrentError = new Error(ErrorTypes.AUTH_FAILED); + concurrentError.code = ErrorTypes.AUTH_FAILED; + return cb(concurrentError); + } + existingUser[providerKey] = id; + } 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 +134,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..01b8c6f210 100644 --- a/api/strategies/socialLogin.test.js +++ b/api/strategies/socialLogin.test.js @@ -35,6 +35,7 @@ jest.mock('@librechat/api', () => ({ jest.mock('~/models', () => ({ findUser: jest.fn(), + updateUser: jest.fn(), })); jest.mock('~/server/services/Config', () => ({ @@ -176,6 +177,140 @@ describe('socialLogin', () => { expect(callback).toHaveBeenCalledWith(null, existingUser); }); + it('does not migrate the provider id on the chat path (only admin path migrates)', async () => { + const { updateUser } = require('~/models'); + const provider = 'google'; + const googleId = 'google-user-chat'; + const email = 'chat@example.com'; + + const existingUser = { + _id: 'chatUser', + email: email, + provider: 'google', + }; + + findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser); + + const mockProfile = { + id: googleId, + emails: [{ value: email, verified: true }], + photos: [{ value: 'https://example.com/avatar.png' }], + name: { givenName: 'Chat', familyName: 'User' }, + }; + + const loginFn = socialLogin(provider, mockGetProfileDetails); + const callback = jest.fn(); + + await loginFn(null, null, null, mockProfile, callback); + + expect(updateUser).not.toHaveBeenCalled(); + expect(handleExistingUser).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith(null, existingUser); + }); + + it('migrates the missing provider id when finding by email fallback (admin path)', async () => { + const { updateUser } = require('~/models'); + const provider = 'google'; + const googleId = 'google-user-789'; + const email = 'admin@example.com'; + + const existingUser = { + _id: 'admin789', + email: email, + provider: 'google', + }; + + findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(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, { existingUsersOnly: true }); + const callback = jest.fn(); + + await loginFn(null, null, null, mockProfile, callback); + + expect(updateUser).toHaveBeenCalledWith('admin789', { googleId }); + expect(existingUser.googleId).toBe(googleId); + expect(handleExistingUser).toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith(null, existingUser); + }); + + it('blocks migration via email fallback for a tenanted user (no tenant scope in OAuth callback)', async () => { + const { updateUser } = require('~/models'); + const provider = 'google'; + const googleId = 'google-user-cross'; + const email = 'admin@tenantb.example.com'; + + const tenantedUser = { + _id: 'tenant-b-user', + email: email, + provider: 'google', + tenantId: 'tenant-b', + }; + + findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(tenantedUser); + + 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, { existingUsersOnly: true }); + const callback = jest.fn(); + + await loginFn(null, null, null, mockProfile, callback); + + expect(updateUser).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ code: ErrorTypes.AUTH_FAILED }), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Admin migrate blocked for tenanted user'), + ); + }); + + it('rejects the admin email fallback when stored provider id differs from the current sub', async () => { + const provider = 'google'; + const googleId = 'google-user-new'; + const email = 'admin@example.com'; + + const existingUser = { + _id: 'admin789', + email: email, + provider: 'google', + googleId: 'google-user-old', + }; + + findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(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, { existingUsersOnly: true }); + const callback = jest.fn(); + + await loginFn(null, null, null, mockProfile, callback); + + expect(logger.warn).toHaveBeenCalledWith( + `[${provider}Login] Rejected admin email fallback for ${email}: stored ${provider}Id does not match`, + ); + expect(handleExistingUser).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ code: ErrorTypes.AUTH_FAILED }), + ); + }); + it('should create new user if not found by provider ID or email', async () => { const provider = 'google'; const googleId = 'google-new-user'; @@ -358,5 +493,67 @@ describe('socialLogin', () => { expect.objectContaining({ message: 'Email domain not allowed' }), ); }); + + it('does not forward the refresh token as authInfo for non-google providers', async () => { + const provider = 'github'; + const githubId = 'gh-user-123'; + const email = 'user@example.com'; + + const existingUser = { + _id: 'ghUser', + email, + provider: 'github', + githubId, + }; + + findUser.mockResolvedValue(existingUser); + + const mockProfile = { + id: githubId, + emails: [{ value: email, verified: true }], + photos: [{ value: 'https://example.com/avatar.png' }], + name: { givenName: 'GitHub', familyName: 'User' }, + }; + + const loginFn = socialLogin(provider, mockGetProfileDetails); + const callback = jest.fn(); + + await loginFn(null, 'github-refresh-token', null, mockProfile, callback); + + expect(callback).toHaveBeenCalledWith(null, existingUser); + expect(callback).not.toHaveBeenCalledWith(null, existingUser, expect.anything()); + }); + + 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', + }); + }); }); }); diff --git a/packages/api/src/auth/exchange.ts b/packages/api/src/auth/exchange.ts index 424ac20313..bf7fa163d6 100644 --- a/packages/api/src/auth/exchange.ts +++ b/packages/api/src/auth/exchange.ts @@ -50,6 +50,12 @@ export interface AdminExchangeData { */ export interface AdminExchangeResponse { token: string; + /** + * When Google rotates the refresh token on use, this will differ from the + * token the client originally sent. Clients MUST persist this value; failing + * to do so causes future refresh calls to fail once Google's original grant + * expires or is revoked. + */ refreshToken?: string; user: AdminExchangeUser; expiresAt?: number; diff --git a/packages/api/src/auth/googleRefresh.spec.ts b/packages/api/src/auth/googleRefresh.spec.ts new file mode 100644 index 0000000000..2356e91b1f --- /dev/null +++ b/packages/api/src/auth/googleRefresh.spec.ts @@ -0,0 +1,343 @@ +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(), + error: 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(), + isEmailAllowed: jest.fn().mockResolvedValue(true), + 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('throws ISSUER_MISMATCH when the id_token aud does not match the configured clientId', async () => { + fetchMock.mockResolvedValueOnce( + makeOkJson({ + access_token: 'new-access', + id_token: makeIdToken({ sub: SUB, aud: 'wrong-client' }), + }), + ); + + await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({ + code: 'ISSUER_MISMATCH', + status: 401, + }); + expect(deps.findUsers).not.toHaveBeenCalled(); + }); + + 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_ID_MISMATCH when multiple users share the same googleId', async () => { + fetchMock.mockResolvedValueOnce( + makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }), + ); + deps.findUsers.mockResolvedValue([makeUser(), makeUser({ email: 'other@example.com' })]); + + await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({ + code: 'USER_ID_MISMATCH', + status: 401, + }); + }); + + it('throws PROVIDER_MISMATCH when the resolved user is not bound to the google provider (findUsers path)', async () => { + fetchMock.mockResolvedValueOnce( + makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }), + ); + deps.findUsers.mockResolvedValue([makeUser({ provider: 'openid' })]); + + await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({ + code: 'PROVIDER_MISMATCH', + status: 401, + }); + expect(deps.canAccessAdmin).not.toHaveBeenCalled(); + }); + + it('throws PROVIDER_MISMATCH when the direct-lookup user is not bound to the google provider', async () => { + fetchMock.mockResolvedValueOnce( + makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }), + ); + const direct = makeUser({ provider: 'openid' }); + deps.getUserById.mockResolvedValue(direct); + + await expect( + applyGoogleAdminRefresh(deps, { ...baseOptions, userId: String(direct._id) }), + ).rejects.toMatchObject({ code: 'PROVIDER_MISMATCH', status: 401 }); + expect(deps.canAccessAdmin).not.toHaveBeenCalled(); + }); + + 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('throws FORBIDDEN when isEmailAllowed rejects the refreshed identity', async () => { + const user = makeUser(); + fetchMock.mockResolvedValueOnce( + makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }), + ); + deps.findUsers.mockResolvedValue([user]); + (deps.isEmailAllowed as jest.Mock).mockResolvedValue(false); + + await expect(applyGoogleAdminRefresh(deps, baseOptions)).rejects.toMatchObject({ + code: 'FORBIDDEN', + status: 403, + message: expect.stringContaining('domain'), + }); + expect(deps.canAccessAdmin).not.toHaveBeenCalled(); + }); + + 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..dac9016858 --- /dev/null +++ b/packages/api/src/auth/googleRefresh.ts @@ -0,0 +1,299 @@ +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; + aud?: string | 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; + /** + * Re-runs the deployment's `registration.allowedDomains` check against the + * resolved user's email. Returns true to allow refresh, false to reject. + * Mirrors the `isEmailDomainAllowed` call the initial OAuth login enforces + * so a domain removed from the allowlist after issuance can't refresh. + */ + isEmailAllowed?: (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('[admin/oauth/refresh] 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('[admin/oauth/refresh] userinfo fallback failed', { + name: error?.name, + message: error?.message, + }); + return undefined; + } +} + +interface GoogleAdminRefreshConfiguredOptions extends GoogleAdminRefreshOptions { + clientId: string; + clientSecret: string; +} + +async function fetchGoogleTokenset( + options: GoogleAdminRefreshConfiguredOptions, +): 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('[admin/oauth/refresh] token endpoint request failed', { + name: error?.name, + message: error?.message, + }); + throw new AdminRefreshError('REFRESH_FAILED', 401, 'Refresh failed'); + } + + if (!response.ok) { + logger.warn('[admin/oauth/refresh] 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('[admin/oauth/refresh] 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, clientId?: string): 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 (clientId && claims?.aud !== undefined) { + const aud = claims.aud; + const audOk = Array.isArray(aud) ? aud.includes(clientId) : aud === clientId; + if (!audOk) { + throw new AdminRefreshError( + 'ISSUER_MISMATCH', + 401, + 'id_token aud does not match configured client', + ); + } + } + 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', + ); + } + if (direct.provider !== 'google') { + throw new AdminRefreshError( + 'PROVIDER_MISMATCH', + 401, + 'User account is not bound to the Google provider', + ); + } + return direct; + } + } + + const filter = ( + options.tenantId ? { googleId, tenantId: options.tenantId } : { googleId } + ) as FilterQuery; + const matches = await deps.findUsers(filter, SAFE_USER_PROJECTION, { + sort: { updatedAt: -1 }, + limit: 2, + }); + if (matches.length > 1) { + logger.error('[admin/oauth/refresh] ambiguous googleId match', { + googleId, + tenantId: options.tenantId, + }); + throw new AdminRefreshError('USER_ID_MISMATCH', 401, 'Ambiguous identity'); + } + const [found] = matches; + if (!found) { + throw new AdminRefreshError('USER_NOT_FOUND', 401, 'No user found for the refreshed identity'); + } + if (found.provider !== 'google') { + throw new AdminRefreshError( + 'PROVIDER_MISMATCH', + 401, + 'User account is not bound to the Google provider', + ); + } + 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 configured: GoogleAdminRefreshConfiguredOptions = { + ...options, + clientId: options.clientId, + clientSecret: options.clientSecret, + }; + + const tokenset = await fetchGoogleTokenset(configured); + const googleId = await resolveGoogleSub(tokenset, configured.clientId); + const user = await resolveAdminUser(googleId, deps, options); + + if (deps.isEmailAllowed && !(await deps.isEmailAllowed(user))) { + throw new AdminRefreshError( + 'FORBIDDEN', + 403, + 'User email domain is not on the deployment allowlist', + ); + } + + if (!(await deps.canAccessAdmin(user))) { + throw new AdminRefreshError('FORBIDDEN', 403, 'User does not have admin access'); + } + + const minted = await deps.mintToken(user); + + if (tokenset.refresh_token && tokenset.refresh_token !== options.refreshToken) { + logger.info( + '[admin/oauth/refresh] Google rotated the refresh token; client must persist the new value', + ); + } + + 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 0b211cf3b2..1932986e3b 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'; diff --git a/packages/api/src/auth/refresh.ts b/packages/api/src/auth/refresh.ts index 2ae5ca0f0a..ba35c15f4c 100644 --- a/packages/api/src/auth/refresh.ts +++ b/packages/api/src/auth/refresh.ts @@ -65,6 +65,13 @@ export interface AdminRefreshDeps { * bearers should always inject this. */ canAccessAdmin?: (user: IUser) => Promise; + /** + * Re-runs the deployment's `registration.allowedDomains` check against the + * resolved user's email. Returns true to allow refresh, false to reject. + * Mirrors the domain check the initial OAuth callback enforces so a domain + * removed from the allowlist after issuance can't refresh. + */ + isEmailAllowed?: (user: IUser) => Promise; /** * Optional post-success hook for forks that need to do additional work * with the refreshed tokenset and resolved user (e.g. update a server-side @@ -275,6 +282,14 @@ export async function applyAdminRefresh( throw new AdminRefreshError('USER_NOT_FOUND', 401, 'No user found for the refreshed identity'); } + if (deps.isEmailAllowed && !(await deps.isEmailAllowed(user))) { + throw new AdminRefreshError( + 'FORBIDDEN', + 403, + 'User email domain is not on the deployment allowlist', + ); + } + if (deps.canAccessAdmin && !(await deps.canAccessAdmin(user))) { throw new AdminRefreshError('FORBIDDEN', 403, 'User does not have admin access'); }