mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 07:46:47 +00:00
🔄 feat: Cross-Origin Admin OAuth Refresh (#13007)
* feat(admin-panel): add /api/admin/oauth/refresh endpoint for cross-origin BFF refresh
The cookie-based /api/auth/refresh controller can't be reached cross-origin
from a separately-hosted admin panel because the refresh-token cookie isn't
sent on cross-origin fetches. Add a dedicated POST /api/admin/oauth/refresh
endpoint that accepts the refresh token in the request body, exchanges it
at the IdP via openid-client refreshTokenGrant, and returns the same
response shape as /api/admin/oauth/exchange.
Implementation lives in packages/api/src/auth/refresh.ts as the
applyAdminRefresh helper. It validates the refreshed tokenset, looks up the
admin user by openidId (with optional user_id disambiguation when multiple
user docs share an openidId), mints the bearer via an injected mintToken
hook, and runs an optional onRefreshSuccess hook for downstream forks that
need to update server-side session state.
The default mintToken passed by the OSS route signs an HS256 LibreChat JWT
via generateToken so admin panel callers continue to use the existing local
JWT strategy. Forks that prefer to hand back an IdP-signed token (e.g. for
deployments where the JWT auth gate is JWKS-only) override mintToken
without changing the helper or the route.
Also threads expiresAt through AdminExchangeData and AdminExchangeResponse
so admin panel clients can drive proactive refresh before the bearer
expires. Defaults the OSS exchange flow to Date.now() + sessionExpiry.
* fix(admin-panel): address review feedback on /api/admin/oauth/refresh
mintToken now returns {token, expiresAt} so the minter is authoritative
for the bearer's lifetime instead of deriving it from the IdP `exp` claim.
The refresh response would otherwise lie to the admin panel and trigger
premature or late refresh cycles.
The helper now falls back to the inbound refresh_token when the IdP omits
one on rotation (Auth0 with rotation off, Microsoft personal accounts).
Without this the admin panel loses its refresh capability after one cycle.
Other hardening:
resolveAdminUser validates user_id with Types.ObjectId.isValid before
hitting Mongoose, avoiding a CastError that would surface as a generic
500 with no useful information for the client.
If user_id resolves to a user whose openidId does not match the refreshed
sub, throw USER_ID_MISMATCH (401) instead of silently swapping in a
different user matching the sub.
Wrap tokenset.claims() in readClaims so an IdP that returns a tokenset
without a usable id_token gets mapped to CLAIMS_INCOMPLETE (502) rather
than bubbling a raw exception.
findUsers now uses the same SAFE_USER_PROJECTION as getUserById so the
fallback path no longer pulls password/totpSecret/backupCodes into memory.
Removed dead fields (email on AdminRefreshClaims, id_token on
RefreshTokenset) and fixed import ordering per AGENTS.md.
Adds packages/api/src/auth/refresh.spec.ts: 18 tests covering the happy
path, userId disambiguation (match, invalid ObjectId, null, mismatch),
all error branches (IDP_INCOMPLETE, CLAIMS_INCOMPLETE for both throw and
missing sub, USER_NOT_FOUND, mintToken/onRefreshSuccess propagation), and
refresh-token preservation under rotation/no-rotation.
* chore(admin-panel): polish per re-review on /api/admin/oauth/refresh
readClaims now logs the original error name/message at warn before mapping
to CLAIMS_INCOMPLETE so a programming bug doesn't get silently rebadged
as an IdP problem in production logs.
The route handler's JSDoc now enumerates every error response (status +
error_code) so admin-panel implementors can plan for each branch without
reading the source.
Tightens the helper's surface: removed the now-dead `exp` field from
`AdminRefreshClaims` (only `sub` is read since the v2 mintToken refactor),
and tightened `AdminRefreshDeps.findUsers`'s projection parameter from
`string | null` to `string` so the contract matches actual usage.
Test polish: the userId-resolves-to-null fallthrough test now asserts the
exact `findUsers` and `getUserById` call arguments so a regression in the
fallthrough query shape is caught. The "skips onRefreshSuccess" test now
asserts a populated response shape rather than just `toBeDefined`.
Declined per prior triage and re-confirmed: a role guard inside
`applyAdminRefresh` (downstream `/api/admin/*` already enforces
ACCESS_ADMIN via requireCapability) and moving the IdP grant call out of
the JS route into TypeScript (matches existing oauth.js / openidStrategy
pattern; package-boundary refactor belongs in a separate PR).
* fix(admin-panel): reject /api/admin/oauth/refresh tokensets from foreign issuers
When the route handler can resolve the configured OpenID issuer, it now
threads it into applyAdminRefresh as expectedIssuer. The helper compares
that against the tokenset claims iss (after normalizeOpenIdIssuer on
both sides to absorb trailing-slash differences) and throws
ISSUER_MISMATCH (401) on mismatch.
The check is skipped when either side is unset so behavior is unchanged
for IdPs that don't return iss on a refresh-grant id_token, and for
older deployments where the OpenID config doesn't expose serverMetadata.
This is a defense-in-depth measure for the refresh path only. The
deeper OIDC posture fix (binding IUser lookup to (sub, iss) as a pair)
is pre-existing debt across openidStrategy.js and the regular exchange
flow as well, and belongs in a separate PR with the schema change and
backfill migration.
* fix(admin-panel): bind refresh user lookup to (sub, iss) and handle getOpenIdConfig throw
Two fixes raised on the PR thread that I previously misdescribed:
The user lookup in resolveAdminUser was keyed on openidId alone, so a
tokenset from a different issuer that happened to share the same sub
could resolve to a local user from a different IdP. Now exports
getIssuerBoundConditions and isUserIssuerAllowed from openid.ts (the
helpers findOpenIDUser already uses) and reuses them. The findUsers
filter becomes ($or of getIssuerBoundConditions for openidId) when an
expectedIssuer is provided, with the same legacy backward-compat
clause for users whose openidIssuer field was never populated. The
direct user_id path now also checks isUserIssuerAllowed and throws
USER_ID_MISMATCH if the stored openidIssuer disagrees with the
configured issuer.
The route's getOpenIdConfig() call was previously documented as
returning null when uninitialized; the actual implementation throws.
That made the if (!openIdConfig) guard unreachable, and an unconfigured
server would surface as 500 INTERNAL_ERROR rather than 503
OPENID_NOT_CONFIGURED. Wraps the call in try/catch so the documented
503 response is what callers actually receive.
Adds 4 tests covering the new lookup binding behavior.
* fix(admin-panel): re-check ACCESS_ADMIN on /api/admin/oauth/refresh
The IdP refresh token can outlive a capability/role change, so the
initial requireAdminAccess on the OAuth callback isn't sufficient.
Inject canAccessAdmin via the existing capability model
(hasCapability with SystemCapabilities.ACCESS_ADMIN, matching
requireAdminAccess so custom roles and user grants are honored)
and gate token minting on it. Capability backend errors are
warn-and-denied to keep the bearer-mint path fail-closed.
* fix(admin-panel): scope /api/admin/oauth/refresh to the request tenant
The same (openidId, openidIssuer) pair is allowed across tenants by
the user schema's unique index. The refresh helper was wrapping both
the direct getUserById and the fallback findUsers in runAsSystem,
bypassing tenant isolation, so an IdP identity that exists in two
tenants could resolve to the wrong tenant's user and mint a JWT
bound to that tenant.
Drop the runAsSystem wrappers, add a trusted tenantId option to
applyAdminRefresh, AND it into the fallback findUsers filter, and
assert it against the direct getUserById result. Mount
preAuthTenantMiddleware on the refresh route so the deployment's
X-Tenant-Id header drives the trusted tenant via ALS. Single-tenant
deploys (no header) keep the existing openidId-only behaviour.
Adds TENANT_MISMATCH (401) and a regression covering duplicate
(sub, iss) across tenants plus the direct-userId tenant assertion.
* fix(admin-panel): gate /api/admin/oauth/refresh on OPENID_REUSE_TOKENS
The OSS refreshController only refreshes OpenID tokensets when
OPENID_REUSE_TOKENS is enabled. The body-based admin variant was
unconditionally calling refreshTokenGrant, which made the flag
ineffective for the admin OAuth flow and let admin sessions keep
renewing in deployments that explicitly turned token reuse off.
Add the same isEnabled(process.env.OPENID_REUSE_TOKENS) check up
front and return 403 TOKEN_REUSE_DISABLED so the admin panel BFF
can surface the configuration mismatch instead of silently churning
through retries.
This commit is contained in:
parent
22890771cf
commit
e262219c8f
7 changed files with 958 additions and 6 deletions
|
|
@ -46,6 +46,7 @@ function createOAuthHandler(redirectUri = domains.client) {
|
|||
/** Get refresh token from tokenset for OpenID users */
|
||||
const refreshToken =
|
||||
req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token;
|
||||
const expiresAt = Date.now() + sessionExpiry;
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
const exchangeCode = await generateAdminExchangeCode(
|
||||
|
|
@ -55,6 +56,7 @@ function createOAuthHandler(redirectUri = domains.client) {
|
|||
refreshToken,
|
||||
callbackUrl.origin,
|
||||
req.pkceChallenge,
|
||||
expiresAt,
|
||||
);
|
||||
callbackUrl.searchParams.set('code', exchangeCode);
|
||||
logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,35 @@
|
|||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const crypto = require('node:crypto');
|
||||
const openIdClient = require('openid-client');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const {
|
||||
logger,
|
||||
DEFAULT_SESSION_EXPIRY,
|
||||
SystemCapabilities,
|
||||
getTenantId,
|
||||
} = require('@librechat/data-schemas');
|
||||
const {
|
||||
isEnabled,
|
||||
getAdminPanelUrl,
|
||||
exchangeAdminCode,
|
||||
createSetBalanceConfig,
|
||||
storeAndStripChallenge,
|
||||
tenantContextMiddleware,
|
||||
preAuthTenantMiddleware,
|
||||
applyAdminRefresh,
|
||||
AdminRefreshError,
|
||||
} = require('@librechat/api');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { requireCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
|
||||
const { findBalanceByUser, upsertBalanceFields } = require('~/models');
|
||||
const {
|
||||
findBalanceByUser,
|
||||
findUsers,
|
||||
generateToken,
|
||||
getUserById,
|
||||
upsertBalanceFields,
|
||||
} = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { getOpenIdConfig } = require('~/strategies');
|
||||
|
|
@ -464,4 +480,132 @@ router.post('/oauth/exchange', middleware.loginLimiter, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin-panel-shaped token refresh.
|
||||
*
|
||||
* The standard `/api/auth/refresh` controller reads the refresh token from
|
||||
* cookies, which a cross-origin admin panel can't set. This endpoint accepts
|
||||
* the refresh token in the request body, exchanges it at the IdP, mints a
|
||||
* fresh LibreChat JWT, and returns the same response shape as
|
||||
* `/api/admin/oauth/exchange`.
|
||||
*
|
||||
* POST /api/admin/oauth/refresh
|
||||
* Body: { refresh_token: string, user_id?: string }
|
||||
* 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
|
||||
* 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 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
|
||||
* 502 CLAIMS_INCOMPLETE — IdP tokenset has no readable claims or no sub
|
||||
* 503 OPENID_NOT_CONFIGURED — OpenID is not configured on this server
|
||||
* 500 INTERNAL_ERROR — anything else (logged server-side)
|
||||
*/
|
||||
router.post(
|
||||
'/oauth/refresh',
|
||||
middleware.loginLimiter,
|
||||
preAuthTenantMiddleware,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { refresh_token: refreshToken, user_id: userId } = req.body ?? {};
|
||||
if (typeof refreshToken !== 'string' || refreshToken.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing refresh_token',
|
||||
error_code: 'MISSING_REFRESH_TOKEN',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
return res.status(403).json({
|
||||
error: 'OpenID token reuse is not enabled',
|
||||
error_code: 'TOKEN_REUSE_DISABLED',
|
||||
});
|
||||
}
|
||||
|
||||
let openIdConfig;
|
||||
try {
|
||||
openIdConfig = getOpenIdConfig();
|
||||
} catch {
|
||||
return res.status(503).json({
|
||||
error: 'OpenID is not configured',
|
||||
error_code: 'OPENID_NOT_CONFIGURED',
|
||||
});
|
||||
}
|
||||
|
||||
const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {};
|
||||
let tokenset;
|
||||
try {
|
||||
tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken, refreshParams);
|
||||
} catch (err) {
|
||||
logger.warn('[admin/oauth/refresh] IdP refresh grant failed', {
|
||||
code: err?.code,
|
||||
name: err?.name,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: 'Refresh failed',
|
||||
error_code: 'REFRESH_FAILED',
|
||||
});
|
||||
}
|
||||
|
||||
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
|
||||
const expectedIssuer = openIdConfig.serverMetadata?.()?.issuer;
|
||||
|
||||
try {
|
||||
const result = await applyAdminRefresh(
|
||||
tokenset,
|
||||
{
|
||||
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,
|
||||
}),
|
||||
},
|
||||
{
|
||||
userId: typeof userId === 'string' && userId.length > 0 ? userId : undefined,
|
||||
previousRefreshToken: refreshToken,
|
||||
expectedIssuer,
|
||||
tenantId: getTenantId(),
|
||||
},
|
||||
);
|
||||
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;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[admin/oauth/refresh] Error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
error_code: 'INTERNAL_ERROR',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue