mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 20:01:35 +00:00
🔒 fix: Apply brutal-review hardening to Google admin refresh
Tighten the Google OAuth refresh flow against all outstanding code review findings: enforce JWT aud claim verification against the configured clientId (ISSUER_MISMATCH on mismatch), reject ambiguous googleId matches (limit:2 in findUsers, USER_ID_MISMATCH when multiple rows match), scope the authInfo refresh-token carrier to the Google provider only, add TOCTOU re-read defense after the admin googleId migration write in socialLogin, deduplicate canAccessAdmin/mintToken closures via buildAdminRefreshClosures shared by both OpenID and Google refresh paths, document rotation semantics on AdminExchangeResponse.refreshToken, standardise all log prefixes to [admin/oauth/refresh], and expand test coverage for all new paths.
This commit is contained in:
parent
0e55d8a1df
commit
fcdb66bb6b
7 changed files with 235 additions and 72 deletions
|
|
@ -41,46 +41,6 @@ const middleware = require('~/server/middleware');
|
|||
|
||||
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
|
||||
|
||||
function buildGoogleAdminRefreshDeps(sessionExpiry) {
|
||||
return {
|
||||
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;
|
||||
}
|
||||
},
|
||||
isEmailAllowed: async (user) => {
|
||||
if (!user?.email) return false;
|
||||
try {
|
||||
const appConfig = user.tenantId
|
||||
? await resolveAppConfigForUser(getAppConfig, user)
|
||||
: await getAppConfig({ baseOnly: true });
|
||||
return isEmailDomainAllowed(user.email, appConfig?.registration?.allowedDomains);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[admin/oauth/refresh] domain allowlist check failed, denying: ${err?.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
mintToken: async (user) => ({
|
||||
token: await generateToken(user, sessionExpiry),
|
||||
expiresAt: Date.now() + sessionExpiry,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const setBalanceConfig = createSetBalanceConfig({
|
||||
getAppConfig,
|
||||
findBalanceByUser,
|
||||
|
|
@ -111,6 +71,52 @@ function resolveRequestOrigin(req) {
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
mintToken: async (user) => ({
|
||||
token: await generateToken(user, sessionExpiry),
|
||||
expiresAt: Date.now() + sessionExpiry,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGoogleAdminRefreshDeps(sessionExpiry) {
|
||||
return {
|
||||
findUsers,
|
||||
getUserById,
|
||||
...buildAdminRefreshClosures(sessionExpiry),
|
||||
isEmailAllowed: async (user) => {
|
||||
if (!user?.email) return false;
|
||||
try {
|
||||
const appConfig = user.tenantId
|
||||
? await resolveAppConfigForUser(getAppConfig, user)
|
||||
: await getAppConfig({ baseOnly: true });
|
||||
return isEmailDomainAllowed(user.email, appConfig?.registration?.allowedDomains);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[admin/oauth/refresh] domain allowlist check failed, denying: ${err?.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
router.post(
|
||||
'/login/local',
|
||||
middleware.logHeaders,
|
||||
|
|
@ -654,27 +660,7 @@ 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: normalizedUserId,
|
||||
|
|
|
|||
|
|
@ -296,7 +296,15 @@ describe('admin auth Google refresh route', () => {
|
|||
expect(response.body).toEqual({
|
||||
token: 'admin-jwt',
|
||||
refreshToken: 'rotated-refresh',
|
||||
user: expect.objectContaining({ provider: 'google' }),
|
||||
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(
|
||||
|
|
@ -316,6 +324,45 @@ describe('admin auth Google refresh route', () => {
|
|||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ const socialLogin =
|
|||
}
|
||||
|
||||
const passResult = (user) =>
|
||||
refreshToken ? cb(null, user, { refreshToken }) : cb(null, user);
|
||||
refreshToken && provider === 'google' ? cb(null, user, { refreshToken }) : cb(null, user);
|
||||
|
||||
if (existingUser?.provider === provider) {
|
||||
if (
|
||||
|
|
@ -74,6 +74,15 @@ const socialLogin =
|
|||
}
|
||||
if (options.existingUsersOnly && id && !existingUser[providerKey]) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -177,6 +177,37 @@ 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';
|
||||
|
|
@ -427,6 +458,36 @@ describe('socialLogin', () => {
|
|||
);
|
||||
});
|
||||
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
|
|
@ -150,6 +151,21 @@ describe('applyGoogleAdminRefresh', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
|
@ -222,6 +238,18 @@ describe('applyGoogleAdminRefresh', () => {
|
|||
).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 USER_NOT_FOUND when no admin user matches the refreshed googleId', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
makeOkJson({ access_token: 'new-access', id_token: makeIdToken() }),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ interface GoogleTokenset {
|
|||
|
||||
interface IdTokenClaims {
|
||||
sub?: string;
|
||||
aud?: string | string[];
|
||||
}
|
||||
|
||||
export interface MintedGoogleAdminToken {
|
||||
|
|
@ -70,7 +71,7 @@ async function resolveSubFromUserinfo(accessToken: string): Promise<string | und
|
|||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn('[adminGoogleRefresh] userinfo fallback returned non-OK', {
|
||||
logger.warn('[admin/oauth/refresh] userinfo fallback returned non-OK', {
|
||||
status: response.status,
|
||||
});
|
||||
return undefined;
|
||||
|
|
@ -79,7 +80,7 @@ async function resolveSubFromUserinfo(accessToken: string): Promise<string | und
|
|||
return typeof body?.sub === 'string' ? body.sub : undefined;
|
||||
} catch (err) {
|
||||
const error = err as { name?: string; message?: string };
|
||||
logger.warn('[adminGoogleRefresh] userinfo fallback failed', {
|
||||
logger.warn('[admin/oauth/refresh] userinfo fallback failed', {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
});
|
||||
|
|
@ -109,7 +110,7 @@ async function fetchGoogleTokenset(
|
|||
});
|
||||
} catch (err) {
|
||||
const error = err as { name?: string; message?: string };
|
||||
logger.warn('[adminGoogleRefresh] token endpoint request failed', {
|
||||
logger.warn('[admin/oauth/refresh] token endpoint request failed', {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
});
|
||||
|
|
@ -117,7 +118,7 @@ async function fetchGoogleTokenset(
|
|||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('[adminGoogleRefresh] Google rejected refresh grant', {
|
||||
logger.warn('[admin/oauth/refresh] Google rejected refresh grant', {
|
||||
status: response.status,
|
||||
});
|
||||
throw new AdminRefreshError('REFRESH_FAILED', 401, 'Refresh failed');
|
||||
|
|
@ -127,7 +128,7 @@ async function fetchGoogleTokenset(
|
|||
return (await response.json()) as GoogleTokenset;
|
||||
} catch (err) {
|
||||
const error = err as { name?: string; message?: string };
|
||||
logger.warn('[adminGoogleRefresh] Google returned non-JSON body', {
|
||||
logger.warn('[admin/oauth/refresh] Google returned non-JSON body', {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
});
|
||||
|
|
@ -135,7 +136,7 @@ async function fetchGoogleTokenset(
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveGoogleSub(tokenset: GoogleTokenset): Promise<string> {
|
||||
async function resolveGoogleSub(tokenset: GoogleTokenset, clientId?: string): Promise<string> {
|
||||
if (typeof tokenset.access_token !== 'string') {
|
||||
throw new AdminRefreshError(
|
||||
'IDP_INCOMPLETE',
|
||||
|
|
@ -147,6 +148,17 @@ async function resolveGoogleSub(tokenset: GoogleTokenset): Promise<string> {
|
|||
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;
|
||||
}
|
||||
|
|
@ -193,10 +205,18 @@ async function resolveAdminUser(
|
|||
const filter = (
|
||||
options.tenantId ? { googleId, tenantId: options.tenantId } : { googleId }
|
||||
) as FilterQuery<IUser>;
|
||||
const [found] = await deps.findUsers(filter, SAFE_USER_PROJECTION, {
|
||||
const matches = await deps.findUsers(filter, SAFE_USER_PROJECTION, {
|
||||
sort: { updatedAt: -1 },
|
||||
limit: 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');
|
||||
}
|
||||
|
|
@ -233,7 +253,7 @@ export async function applyGoogleAdminRefresh(
|
|||
};
|
||||
|
||||
const tokenset = await fetchGoogleTokenset(configured);
|
||||
const googleId = await resolveGoogleSub(tokenset);
|
||||
const googleId = await resolveGoogleSub(tokenset, configured.clientId);
|
||||
const user = await resolveAdminUser(googleId, deps, options);
|
||||
|
||||
if (deps.isEmailAllowed && !(await deps.isEmailAllowed(user))) {
|
||||
|
|
@ -250,6 +270,12 @@ export async function applyGoogleAdminRefresh(
|
|||
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue