LibreChat/api/server/services/OpenIDRefreshFlight.js
J.C. Bartle 9131186bd1 🛠️ fix: centralize OBO identity scoping
Add shared auth identity helpers for app user ids, OpenID subjects,
tenant ids, and normalized OpenID issuers.

Thread a non-placeholder-visible OBO identity context from the real
request user through MCP connection, tool-call, reinit, and refresh
paths. Keep tenantId and openidIssuer out of createSafeUser so MCP
user placeholders do not expose those fields.

Scope OBO token cache and in-flight exchange keys by tenant, issuer,
OpenID subject, scopes, and a SHA-256 hash of the upstream assertion.
This prevents cross-tenant/cross-issuer collisions and avoids reusing
tokens minted from stale rotated assertions.

Use the shared identity helpers for OpenID refresh-flight keys and
refresh-token bridge recovery records so related OBO refresh paths share
the same identity normalization rules.

The helper is intended for auth-boundary and credential-cache code, not
as a blanket replacement for ordinary app user id ownership checks.
2026-06-28 10:39:04 -04:00

169 lines
4.6 KiB
JavaScript

const crypto = require('node:crypto');
const { setTimeout: delay } = require('node:timers/promises');
const { logger, encryptV2, decryptV2 } = require('@librechat/data-schemas');
const { createOpenIDRefreshIdentityTuple, serializeAuthIdentityTuple } = require('@librechat/api');
const db = require('~/models');
const DEFAULT_FLIGHT_TTL_MS = 2 * 60 * 1000;
const DEFAULT_LOCK_TTL_MS = 30 * 1000;
const DEFAULT_WAIT_TIMEOUT_MS = DEFAULT_LOCK_TTL_MS + 1000;
const DEFAULT_WAIT_INTERVAL_MS = 100;
const INTERNAL_BROWSER_REFRESH_TOKEN_FIELD = '__browserRefreshToken';
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function createOpenIDRefreshFlightKey({ req, user, refreshToken, identityContext }) {
const identitySource = identityContext
? {
id: identityContext.appUserId,
openidId: identityContext.openidSubject,
tenantId: identityContext.tenantId,
openidIssuer: identityContext.openidIssuer,
}
: user;
const tuple = createOpenIDRefreshIdentityTuple({
user: identitySource,
requestUser: req?.user,
});
if (!tuple || !refreshToken) {
return null;
}
const parts = [
serializeAuthIdentityTuple(tuple),
req?.sessionID ?? 'no-session',
sha256(refreshToken),
];
return sha256(parts.join('\x1f'));
}
async function acquireOpenIDRefreshFlight({
key,
ownerId = crypto.randomUUID(),
ttl = DEFAULT_FLIGHT_TTL_MS,
lockTtl = DEFAULT_LOCK_TTL_MS,
}) {
if (!key) {
return { acquired: true, key: null, ownerId, flight: null };
}
const acquired = await db.acquireOpenIDRefreshFlight({
key,
ownerId,
lockExpiresAt: new Date(Date.now() + lockTtl),
expiresAt: new Date(Date.now() + ttl),
});
return {
...acquired,
key,
ownerId,
};
}
async function completeOpenIDRefreshFlight({ key, ownerId, tokens, ttl = DEFAULT_FLIGHT_TTL_MS }) {
if (!key || !ownerId || !tokens) {
return null;
}
const serializedTokens = { ...tokens };
const browserRefreshToken = tokens[INTERNAL_BROWSER_REFRESH_TOKEN_FIELD];
if (typeof browserRefreshToken === 'string' && browserRefreshToken) {
// The marker is non-enumerable in memory; re-add it enumerably so JSON.stringify preserves it.
serializedTokens[INTERNAL_BROWSER_REFRESH_TOKEN_FIELD] = browserRefreshToken;
}
const encryptedResult = await encryptV2(JSON.stringify(serializedTokens));
return db.completeOpenIDRefreshFlight({
key,
ownerId,
encryptedResult,
expiresAt: new Date(Date.now() + ttl),
});
}
async function failOpenIDRefreshFlight({ key, ownerId, error, ttl = DEFAULT_FLIGHT_TTL_MS }) {
if (!key || !ownerId) {
return null;
}
const errorMessage =
typeof error?.message === 'string' && error.message ? error.message : 'OpenID refresh failed';
return db.failOpenIDRefreshFlight({
key,
ownerId,
errorMessage,
expiresAt: new Date(Date.now() + ttl),
});
}
async function readCompletedFlight(flight) {
if (!flight) {
return null;
}
if (flight.status === 'failed') {
throw new Error(flight.errorMessage || 'OpenID refresh failed in another worker');
}
if (flight.status !== 'completed' || !flight.encryptedResult) {
return null;
}
const decrypted = await decryptV2(flight.encryptedResult);
const tokens = JSON.parse(decrypted);
const browserRefreshToken = tokens?.[INTERNAL_BROWSER_REFRESH_TOKEN_FIELD];
if (typeof browserRefreshToken === 'string' && browserRefreshToken) {
delete tokens[INTERNAL_BROWSER_REFRESH_TOKEN_FIELD];
Object.defineProperty(tokens, INTERNAL_BROWSER_REFRESH_TOKEN_FIELD, {
value: browserRefreshToken,
enumerable: false,
configurable: true,
});
}
return tokens;
}
async function waitForOpenIDRefreshFlight({
key,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
intervalMs = DEFAULT_WAIT_INTERVAL_MS,
}) {
if (!key) {
return null;
}
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
const flight = await db.findOpenIDRefreshFlight({ key });
const completed = await readCompletedFlight(flight);
if (completed) {
return completed;
}
if (!flight) {
return null;
}
await delay(intervalMs);
}
logger.warn('[OpenIDRefreshFlight] Timed out waiting for refresh flight', { key });
return null;
}
module.exports = {
acquireOpenIDRefreshFlight,
completeOpenIDRefreshFlight,
createOpenIDRefreshFlightKey,
failOpenIDRefreshFlight,
waitForOpenIDRefreshFlight,
__internals: {
sha256,
readCompletedFlight,
DEFAULT_FLIGHT_TTL_MS,
DEFAULT_LOCK_TTL_MS,
DEFAULT_WAIT_TIMEOUT_MS,
DEFAULT_WAIT_INTERVAL_MS,
},
};