LibreChat/api/server/services/RefreshTokenBridge.js
J.C. Bartle d23aea1591 🛠️ fix: Harden OBO refresh-token bridge lookup and indexing
Reuse getValidOpenIDReuseUserId for the bridge-recovery user lookup in
refreshController instead of re-verifying openid_user_id inline. The shared
helper enforces the JWT_REFRESH_SECRET presence check and a strict
typeof payload.id === 'string' guard, rejecting tokens whose id claim is
present but not a string (e.g. a numeric id) that the inline check accepted.

Fail closed on issuer mismatch in getRefreshTokenBridge. Both the stored and
the expected issuer are now normalized and compared for equality, so a bridge
is recovered only when both sides agree (both absent, or both present and
equal after normalization). Previously the check was skipped whenever the
stored issuer was absent, allowing recovery across mismatched issuer context.

Drop the unused {oldRefreshTokenHash, userId, tenantId, openidIssuer} index
and the openidIssuer field on RefreshTokenBridgeQuery. The data-layer filter
only queries the 3-field {oldRefreshTokenHash, userId, tenantId} index; the
issuer is verified in application code, not the query. Hoist the repeated
model accessor into getRefreshTokenBridgeModel.

Note: issuer is now load-bearing for recovery. A bridge stored with an issuer
recovers only when the lookup supplies a matching issuer; the recovery lookup
reads user.openidIssuer via AUTH_REFRESH_USER_PROJECTION (an exclusion
projection that retains the field). If a user's persisted openidIssuer is
empty while the stored bridge has one, recovery fails closed (falls through to
normal re-authentication) until the bridge TTLs out — no security regression.

Tests cover invalid signed-cookie payloads bypassing the bridge, both
asymmetric issuer-presence cases, issuer normalization before comparison, and
an index-alignment assertion guarding against re-adding the dropped index.
2026-06-28 19:57:50 -04:00

159 lines
5.6 KiB
JavaScript

const crypto = require('node:crypto');
const {
logger,
encryptV2,
decryptV2,
DEFAULT_REFRESH_TOKEN_EXPIRY,
} = require('@librechat/data-schemas');
const { math, createRefreshTokenBridgeIdentity } = require('@librechat/api');
const db = require('~/models');
/** Short stale-cookie recovery window after bridged refresh succeeds. */
const OPENID_REFRESH_BRIDGE_GRACE_MS = math(process.env.OPENID_REFRESH_BRIDGE_GRACE_MS, 60 * 1000);
/**
* Server-side recovery bridge for refresh tokens rotated during SSE streaming.
*
* When an OBO call during SSE streaming rotates a refresh token but cannot sync the
* browser cookie (headers already sent), this bridge stores a temporary Mongo mapping so
* that if the express-session expires and the next /api/auth/refresh uses the stale
* cookie, we can look up and use the rotated token instead.
*
* Bridge key: hash(oldRefreshToken) uniquely identifies which token rotation this is.
* Bridge value: encrypted newRefreshToken, userId, tenantId, openidIssuer for verification.
* Bridge TTL: matches the refresh-token cookie lifetime and is enforced by Mongo TTL.
*/
const getBridgeTtlMs = () => math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY);
function resolveBridgeIdentity({ userId, tenantId, openidIssuer }) {
return createRefreshTokenBridgeIdentity({
userId,
tenantId,
openidIssuer,
});
}
/**
* Hashes a refresh token for use as a bridge key. Does NOT encrypt; hash is for
* lookup only, safe to expose. Uses full SHA-256 because this value is persisted
* and participates in a unique index.
*
* @param {string} refreshToken
* @returns {string} hex-encoded SHA-256 hash
*/
function hashRefreshToken(refreshToken) {
return crypto.createHash('sha256').update(refreshToken).digest('hex');
}
/**
* Stores a bridge mapping old (stale) refresh token to new (rotated) token for later
* recovery. Called only when an inline OBO refresh rotates the token but cannot set
* the browser cookie (headers already sent, SSE streaming).
*
* @param {object} args
* @param {string} args.oldRefreshToken — the token before IdP refresh
* @param {string} args.newRefreshToken — the token returned by IdP
* @param {string} args.userId — user._id (for verification on lookup)
* @param {string} [args.tenantId] — user.tenantId (for multi-tenant deployments)
* @param {string} [args.openidIssuer] — user.openidIssuer (for issuer-specific validation)
* @param {number} [args.ttl] — bridge TTL in ms (defaults to REFRESH_TOKEN_EXPIRY)
*/
async function storeRefreshTokenBridge({
oldRefreshToken,
newRefreshToken,
userId,
tenantId,
openidIssuer,
ttl,
}) {
const identity = resolveBridgeIdentity({ userId, tenantId, openidIssuer });
if (!oldRefreshToken || !newRefreshToken || !identity) {
logger.warn('[RefreshTokenBridge] Attempted to store bridge with missing required fields');
return;
}
const oldRefreshTokenHash = hashRefreshToken(oldRefreshToken);
const bridgeTtl = ttl ?? getBridgeTtlMs();
const encryptedNewToken = await encryptV2(newRefreshToken);
await db.upsertRefreshTokenBridge({
oldRefreshTokenHash,
encryptedNewRefreshToken: encryptedNewToken,
userId: identity.userId,
tenantId: identity.tenantId,
openidIssuer: identity.openidIssuer,
expiresAt: new Date(Date.now() + bridgeTtl),
});
logger.debug('[RefreshTokenBridge] Stored recovery bridge', {
tokenHash: oldRefreshTokenHash,
userId: identity.userId,
ttl: bridgeTtl,
});
}
/**
* Looks up and retrieves a stored bridge, verifying it matches the user context
* and hasn't expired. Returns the decrypted rotated token on success, null
* otherwise. Does NOT consume the bridge; the recovery path relies on TTL expiry
* and may re-store a short grace bridge after a successful bridged refresh.
*
* @param {object} args
* @param {string} args.oldRefreshToken — the token to look up (hashed for key)
* @param {string} args.userId — current user._id (must match the bridged context)
* @param {string} [args.tenantId] — current user.tenantId (optional but verified if present)
* @param {string} [args.openidIssuer] — current user.openidIssuer (must match stored issuer after normalization)
* @returns {Promise<string | null>} the rotated refresh token if found and valid, null otherwise
*/
async function getRefreshTokenBridge({ oldRefreshToken, userId, tenantId, openidIssuer }) {
const identity = resolveBridgeIdentity({ userId, tenantId, openidIssuer });
if (!oldRefreshToken || !identity) {
return null;
}
const oldRefreshTokenHash = hashRefreshToken(oldRefreshToken);
const bridge = await db.findRefreshTokenBridge({
oldRefreshTokenHash,
userId: identity.userId,
tenantId: identity.tenantId,
});
if (!bridge) {
return null;
}
const bridgeIdentity = resolveBridgeIdentity({
userId: bridge.userId,
tenantId: bridge.tenantId,
openidIssuer: bridge.openidIssuer,
});
if (!bridgeIdentity || bridgeIdentity.openidIssuer !== identity.openidIssuer) {
logger.warn('[RefreshTokenBridge] Bridge lookup failed: issuer mismatch', {
tokenHash: oldRefreshTokenHash,
});
return null;
}
const age = Date.now() - new Date(bridge.createdAt).getTime();
logger.info('[RefreshTokenBridge] Successfully resolved recovery bridge', {
tokenHash: oldRefreshTokenHash,
userId: identity.userId,
age,
});
return decryptV2(bridge.encryptedNewRefreshToken);
}
module.exports = {
OPENID_REFRESH_BRIDGE_GRACE_MS,
storeRefreshTokenBridge,
getRefreshTokenBridge,
__internals: {
hashRefreshToken,
getBridgeTtlMs,
resolveBridgeIdentity,
},
};