LibreChat/api/server/services/RefreshTokenBridge.js
J.C. Bartle 164ebaf09d 🌉 Persist OIDC refresh-token recovery bridges in MongoDB
Store SSE OBO refresh-token recovery bridges in MongoDB instead of
process-local memory so /api/auth/refresh can recover after worker
restarts or cross-worker routing.

Derive bridge expiry from REFRESH_TOKEN_EXPIRY so the recovery window
matches the stale refreshToken cookie it repairs, and delete bridges
after successful recovery.
2026-06-23 17:19:57 -04:00

156 lines
5.2 KiB
JavaScript

const crypto = require('node:crypto');
const {
logger,
encryptV2,
decryptV2,
DEFAULT_REFRESH_TOKEN_EXPIRY,
} = require('@librechat/data-schemas');
const { math } = require('@librechat/api');
const db = require('~/models');
/**
* 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);
/**
* 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,
}) {
if (!oldRefreshToken || !newRefreshToken || !userId) {
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,
tenantId,
openidIssuer,
expiresAt: new Date(Date.now() + bridgeTtl),
});
logger.debug('[RefreshTokenBridge] Stored recovery bridge', {
tokenHash: oldRefreshTokenHash,
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; callers delete it only 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 (optional but verified if present)
* @returns {Promise<string | null>} the rotated refresh token if found and valid, null otherwise
*/
async function getRefreshTokenBridge({ oldRefreshToken, userId, tenantId, openidIssuer }) {
if (!oldRefreshToken || !userId) {
return null;
}
const oldRefreshTokenHash = hashRefreshToken(oldRefreshToken);
const bridge = await db.findRefreshTokenBridge({
oldRefreshTokenHash,
userId,
tenantId,
});
if (!bridge) {
return null;
}
if (bridge.openidIssuer && bridge.openidIssuer !== 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,
age,
});
return decryptV2(bridge.encryptedNewRefreshToken);
}
/**
* Deletes a bridge after the bridged refresh has succeeded.
*
* @param {object} args
* @param {string} args.oldRefreshToken
* @returns {Promise<boolean>}
*/
async function deleteRefreshTokenBridge({ oldRefreshToken, userId, tenantId }) {
if (!oldRefreshToken) {
return false;
}
if (!userId) {
return false;
}
const oldRefreshTokenHash = hashRefreshToken(oldRefreshToken);
const result = await db.deleteRefreshTokenBridge({ oldRefreshTokenHash, userId, tenantId });
return (result.deletedCount ?? 0) > 0;
}
module.exports = {
storeRefreshTokenBridge,
getRefreshTokenBridge,
deleteRefreshTokenBridge,
__internals: {
hashRefreshToken,
getBridgeTtlMs,
},
};