mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 11:53:55 +00:00
🔑 feat: Refresh-Capable Google Admin OAuth Sessions
Google admin sessions cannot be refreshed today. Three gaps add up to that:
passport.authenticate('googleAdmin', ...) in api/server/routes/admin/auth.js
never sets access_type=offline, so Google omits the refresh_token from its
token response; createOAuthHandler in api/server/controllers/auth/oauth.js
only forwards a refresh token into the admin exchange payload when the user's
provider is 'openid' AND OPENID_REUSE_TOKENS is enabled; and
/api/admin/oauth/refresh is openid-only, calling openid-client.refreshTokenGrant
against the configured OIDC issuer. OpenID admins refresh transparently
because all three are in place for them.
This PR closes all three. The googleAdmin authenticate call now passes
accessType: 'offline' and prompt: 'consent' so Google issues a refresh token
on consent; the chat-side googleLogin is untouched. The shared socialLogin
verify callback now passes the IdP refreshToken through as passport's third
argument (info), landing on req.authInfo, with the two-argument call shape
preserved when no refresh token is present so existing strategy tests stay
valid. createOAuthHandler reads req.authInfo?.refreshToken for non-OpenID
admin providers and forwards it into the exchange code; the OpenID branch
and its OPENID_REUSE_TOKENS gate are unchanged. /api/admin/oauth/refresh
now accepts an optional provider field ('openid' | 'google', default 'openid').
The new Google branch POSTs grant_type=refresh_token to
https://oauth2.googleapis.com/token, decodes the returned id_token for the sub
claim, looks up the admin user by googleId, enforces tenant scope and
ACCESS_ADMIN, and mints a fresh LibreChat JWT in the same response shape
/oauth/exchange returns. It is gated on GOOGLE_CLIENT_ID and
GOOGLE_CLIENT_SECRET being set (returns 503 GOOGLE_NOT_CONFIGURED otherwise);
unknown provider values return 400 INVALID_PROVIDER.
This commit is contained in:
parent
9de3249e9c
commit
d40c51616e
6 changed files with 524 additions and 15 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue