mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🔑 feat: Refresh-Capable Google Admin OAuth Sessions (#13832)
* 🔑 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. * 🔁 fix: Harden Google admin refresh against bot review findings Five validated findings from the initial bot pass: socialLogin.js: mirror the OpenID migrate-or-reject pattern on the email fallback. When an existing user is found by email and the stored provider id is empty, persist the refreshed sub so the refresh path can later bind to it. When the stored id is present and differs, reject as AUTH_FAILED to prevent identity-swap, matching the existing OpenID behavior in packages/api/src/auth/openid.ts. oauth.js: scope the non-OpenID admin refresh-token forwarding to provider === 'google'. The previous else branch would have forwarded a Discord refresh token (passport-discord supplies one) into the admin exchange payload even though /api/admin/oauth/refresh only accepts openid or google, leaving the admin client with a token it could not refresh. admin/auth.js (refreshGoogleAdminSession): drop id_token from the mandatory-fields check. Google's OAuth refresh response is documented to include id_token only conditionally, so the previous mandatory check broke refresh whenever Google omitted it. Decode id_token when present (fast path); when absent, call Google's userinfo endpoint with the access token to read sub. Wrap tokenResponse.json() in try/catch and return IDP_INCOMPLETE on parse failure instead of a generic 500. Tighten access_token to a typeof string check. admin/auth.js (refreshGoogleAdminSession): reuse serializeUserForExchange for the response user so the Google refresh shape matches /oauth/exchange and the OpenID branch exactly (full _id, id, email, name, username, role, avatar, provider, openidId). The previous Google-specific subset dropped fields the admin client relies on for later provider-specific refreshes and disambiguation. Tests cover each fix: socialLogin's migration and rejection cases, the oauth.js Discord-gating case, the userinfo fallback path on missing id_token, CLAIMS_INCOMPLETE when both id_token and userinfo are absent, IDP_INCOMPLETE on a non-JSON token body, and the full response shape on the happy path. * 🧪 fix: Add updateUser to appleStrategy test mock for socialLogin migration The shared socialLogin verify callback now invokes `updateUser` when the email-fallback path discovers a same-provider user with an empty provider id, persisting the refreshed sub. The Apple strategy test's `~/models` mock did not stub `updateUser`, so the migration path hit `TypeError: updateUser is not a function` and failed the `should handle existing user and update avatarUrl` case in CI shard 1/3. * 🧹 refactor: Move Google admin refresh into TypeScript @librechat/api helper Per repo guidance (CLAUDE.md): all new backend code must be TypeScript in /packages/api, and /api is a thin JS wrapper. The previous commit landed the Google admin refresh flow as ~120 lines of new JS inside api/server/routes/admin/auth.js, which violates that. This commit extracts the flow into a new TS helper at packages/api/src/auth/googleRefresh.ts and reduces the route handler to a thin dep-wiring wrapper. The helper exports applyGoogleAdminRefresh(deps, options) with the same shape as the OpenID applyAdminRefresh: callers pass findUsers, getUserById, canAccessAdmin, and mintToken as deps so the package stays free of /api model imports and capability/session helpers. The route handler now builds those deps from the existing model + capability + token modules and calls the helper, mapping AdminRefreshError to the documented HTTP responses. While moving the code, the helper now guards getUserById with Types.ObjectId.isValid before the direct-lookup branch, matching the OpenID admin path at packages/api/src/auth/refresh.ts. Without this guard a malformed user_id from the admin client would hit Mongoose findById's CastError and surface as a 500 INTERNAL_ERROR instead of falling through to the documented sub-based lookup. Tests move with the code: packages/api/src/auth/googleRefresh.spec.ts now owns the helper's behavior (token endpoint, userinfo fallback, ObjectId guard, USER_ID_MISMATCH/TENANT_MISMATCH/USER_NOT_FOUND/FORBIDDEN, rotated refresh-token pass-through, GOOGLE_NOT_CONFIGURED, IDP_INCOMPLETE on non-JSON body, CLAIMS_INCOMPLETE when both id_token and userinfo miss). The route-level api/server/routes/admin/auth.refresh.test.js drops the duplicated end-to-end Google cases and keeps a smaller surface: route delegates to applyGoogleAdminRefresh with the right deps + options, maps AdminRefreshError to HTTP status/code, falls through to 500 for unknown errors, and rejects unknown providers with INVALID_PROVIDER. * 🔁 fix: Tighten Google admin refresh and limit social-login changes Brutal-review findings on top of the upstream feature work. socialLogin.js: the migrate-or-reject pattern from the previous commit applied to every provider's chat-side verify callback, not just the admin flow. Gate both branches on `options.existingUsersOnly` so the chat-side googleLogin / facebookLogin / etc. keep their pre-existing email-fallback behavior unchanged. Tests follow: restore the original `should fallback to finding user by email` chat-side case and re-add the migration and mismatch-reject cases as admin-only by passing `{ existingUsersOnly: true }` to socialLogin in those tests. googleRefresh.ts: add a defense-in-depth `isEmailAllowed(user)` dep that the helper invokes before `canAccessAdmin`. Mirrors the `isEmailDomainAllowed` check the initial Google admin login already runs, so a deployment that removes a domain from `registration.allowedDomains` after issuance can no longer mint fresh JWTs for that admin via refresh. The route handler wires it up with `resolveAppConfigForUser` + `isEmailDomainAllowed`, falling back to `baseOnly` config for users without a tenantId. googleRefresh.ts: drop the unreachable `?? ''` defensive coalescing in `fetchGoogleTokenset`. The `GOOGLE_NOT_CONFIGURED` guard upstream already narrows `clientId`/`clientSecret` to non-empty strings; the function takes a narrowed `GoogleAdminRefreshConfiguredOptions` shape and `applyGoogleAdminRefresh` constructs that shape after the guard. * 🔒 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. * 🔒 fix: Reject refresh for users migrated off the Google provider The interactive Google admin login path in socialLogin.js already rejects a user whose provider field is not 'google', returning AUTH_FAILED. Without a matching guard in the refresh path, a user migrated to OpenID could use an unexpired Google refresh token to keep minting admin JWTs indefinitely. Add a PROVIDER_MISMATCH check after resolving the user in both the direct getUserById branch and the findUsers fallback branch of resolveAdminUser, mirroring the provider gate the interactive path enforces. * 🔒 fix: Add ban check and fix domain allowlist on admin OAuth refresh Two gaps in the /api/admin/oauth/refresh route: Add middleware.checkBan to the route chain before preAuthTenantMiddleware, matching the gate that /login/local and createOAuthHandler already apply. Without it a banned admin could keep minting JWTs until their IdP refresh token expired. Replace getAppConfig({ baseOnly: true }) in the non-tenant isEmailAllowed closure with getAppConfig({ role: user.role }), which includes DB-layer overrides from the admin panel. baseOnly returns only YAML-derived config, so any allowedDomains list maintained entirely through the admin panel was silently inert on this path. Extract isEmailAllowedForUser as a shared helper, move it into buildAdminRefreshClosures so both Google and OpenID refresh paths enforce domain policy consistently, and add isEmailAllowed to AdminRefreshDeps in the TS package so applyAdminRefresh can invoke it. * 🔒 fix: Harden admin OAuth refresh against user bans, tenant scope gaps, and cross-tenant migration Post-identity-resolution ban check: the initial checkBan middleware fires before the refresh token is exchanged and req.user is populated, so it can only evaluate IP bans. After applyGoogleAdminRefresh/applyAdminRefresh resolves the user identity, we now synthesize req.user and re-run checkBan against the resolved user's id before emitting the JWT, so a user-level ban is enforced even from a fresh IP. Domain allowlist now includes userId: the getAppConfig call in isEmailAllowedForUser was passing only role, missing user and group-level allowedDomains overrides that the initial OAuth callback's checkDomainAllowed enforces via userId. Both branches now pass userId so buildPrincipals takes the full user+group+role resolution path. The tenant branch is also inlined (replacing resolveAppConfigForUser) to accept userId, wrapped in tenantStorage.run for correct Mongoose scoping and cache-key resolution. Cross-tenant email-fallback migration: the Passport verify callback fires before tenantContextMiddleware, so findUser({email}) is unscoped and can return a same-email user from another tenant. Writing googleId onto that document permanently corrupts the other tenant's account. Migration is now blocked for users with a tenantId; single-tenant users are unaffected. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
e3b8e30327
commit
6d2f29266c
12 changed files with 1405 additions and 177 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 if (req.user.provider === 'google') {
|
||||
refreshToken = req.authInfo?.refreshToken;
|
||||
}
|
||||
const expiresAt = Date.now() + sessionExpiry;
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
343
packages/api/src/auth/googleRefresh.spec.ts
Normal file
343
packages/api/src/auth/googleRefresh.spec.ts
Normal file
|
|
@ -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> = {}): 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<string, unknown> = { 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<GoogleAdminRefreshDeps>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
299
packages/api/src/auth/googleRefresh.ts
Normal file
299
packages/api/src/auth/googleRefresh.ts
Normal file
|
|
@ -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<IUser>,
|
||||
projection: string,
|
||||
options: { sort: Record<string, 1 | -1>; limit: number },
|
||||
) => Promise<IUser[]>;
|
||||
getUserById: (id: string, projection: string) => Promise<IUser | null>;
|
||||
canAccessAdmin: (user: IUser) => Promise<boolean>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
mintToken: (user: IUser) => Promise<MintedGoogleAdminToken>;
|
||||
}
|
||||
|
||||
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<string | undefined> {
|
||||
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<GoogleTokenset> {
|
||||
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<string> {
|
||||
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<IUser> {
|
||||
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<IUser>;
|
||||
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<AdminExchangeResponse> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ export interface AdminRefreshDeps {
|
|||
* bearers should always inject this.
|
||||
*/
|
||||
canAccessAdmin?: (user: IUser) => Promise<boolean>;
|
||||
/**
|
||||
* 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<boolean>;
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue