LibreChat/packages/api/src/auth/openid.ts
Danny Avila 2ce3921f78
⚖️ feat: Add Operational Prometheus Metrics (#13265)
* add operational prometheus metrics

* fix metrics review findings

* fix metrics edge cases

* gate mongoose metrics instrumentation

* gate metrics setup when unconfigured
2026-05-22 20:47:41 -04:00

299 lines
9.6 KiB
TypeScript

import { logger } from '@librechat/data-schemas';
import { ErrorTypes } from 'librechat-data-provider';
import type { IUser, UserMethods } from '@librechat/data-schemas';
import type { FilterQuery } from 'mongoose';
import { isMetricsConfigured, recordOpenIDUserLookup } from '~/app/metrics';
import type { OpenIDUserLookupResult } from '~/app/metrics';
export type OpenIdEmailClaims = {
email?: unknown;
preferred_username?: unknown;
upn?: unknown;
[claim: string]: unknown;
};
export type OpenIdIssuerSource = {
iss?: string;
issuer?: string;
serverMetadata?: () => { issuer?: string } | undefined;
};
type OpenIdLookupField = 'openidId' | 'idOnTheSource';
type OpenIdUserResolution = { user: IUser | null; error: string | null; migration: boolean };
const OPENID_DISCOVERY_PATH = '/.well-known/openid-configuration';
const LEGACY_ISSUER_FILTERS: Array<FilterQuery<IUser>['openidIssuer']> = [
{ $exists: false },
null,
'',
];
export function normalizeOpenIdIssuer(issuer: string | undefined): string | undefined {
const normalized = issuer?.trim().replace(/\/+$/, '');
if (!normalized) return undefined;
if (!normalized.endsWith(OPENID_DISCOVERY_PATH)) return normalized;
return normalized.slice(0, -OPENID_DISCOVERY_PATH.length) || undefined;
}
function getIssuerFromSource(source: OpenIdIssuerSource | null | undefined): string | undefined {
if (source == null) return undefined;
const issuer = source.iss || source.serverMetadata?.()?.issuer || source.issuer;
return normalizeOpenIdIssuer(issuer);
}
function getStringClaim(claims: OpenIdEmailClaims, claim: string): string | undefined {
const value = claims[claim];
return typeof value === 'string' && value ? value : undefined;
}
export function getOpenIdIssuer(
...sources: Array<OpenIdIssuerSource | null | undefined>
): string | undefined {
for (const source of sources) {
const issuer = getIssuerFromSource(source);
if (issuer) return issuer;
}
return normalizeOpenIdIssuer(process.env.OPENID_ISSUER);
}
function isLegacyOpenIdIssuer(openidIssuer: string | undefined): boolean {
const loginIssuer = normalizeOpenIdIssuer(process.env.OPENID_ISSUER);
return openidIssuer != null && loginIssuer != null && openidIssuer === loginIssuer;
}
function hasOpenIdLookupValue(value: string | undefined): value is string {
return typeof value === 'string' && value.length > 0;
}
function getElapsedSeconds(startedAt: bigint): number {
return Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
}
function getOpenIDUserLookupResult(resolution: OpenIdUserResolution): OpenIDUserLookupResult {
if (resolution.error) return 'auth_failed';
if (resolution.migration) return 'migration';
if (resolution.user) return 'found';
return 'not_found';
}
function getIssuerExactCondition(
field: OpenIdLookupField,
value: string | undefined,
openidIssuer: string | undefined,
): FilterQuery<IUser> | null {
if (!hasOpenIdLookupValue(value) || !openidIssuer) return null;
return { [field]: value, openidIssuer };
}
function getLegacyIssuerConditions(
field: OpenIdLookupField,
value: string | undefined,
openidIssuer: string | undefined,
): FilterQuery<IUser>[] {
if (!hasOpenIdLookupValue(value) || !isLegacyOpenIdIssuer(openidIssuer)) return [];
return LEGACY_ISSUER_FILTERS.map((issuerFilter) => ({
[field]: value,
openidIssuer: issuerFilter,
}));
}
export function getIssuerBoundConditions(
field: OpenIdLookupField,
value: string | undefined,
openidIssuer: string | undefined,
): FilterQuery<IUser>[] {
const exactCondition = getIssuerExactCondition(field, value, openidIssuer);
if (!exactCondition) return [];
return [exactCondition, ...getLegacyIssuerConditions(field, value, openidIssuer)];
}
function getPrimaryLookupConditions(
openidId: string | undefined,
idOnTheSource: string | undefined,
openidIssuer: string | undefined,
): FilterQuery<IUser>[] {
const exactConditions = [
getIssuerExactCondition('openidId', openidId, openidIssuer),
getIssuerExactCondition('idOnTheSource', idOnTheSource, openidIssuer),
].filter((condition): condition is FilterQuery<IUser> => condition != null);
return [
...exactConditions,
...getLegacyIssuerConditions('openidId', openidId, openidIssuer),
...getLegacyIssuerConditions('idOnTheSource', idOnTheSource, openidIssuer),
];
}
async function findFirstOpenIdUser(
findUser: UserMethods['findUser'],
conditions: FilterQuery<IUser>[],
): Promise<IUser | null> {
for (const condition of conditions) {
const user = await findUser(condition);
if (user) return user;
}
return null;
}
export function isUserIssuerAllowed(user: IUser, openidIssuer: string | undefined): boolean {
if (!openidIssuer) return true;
const userIssuer = normalizeOpenIdIssuer(user.openidIssuer);
if (userIssuer) return userIssuer === openidIssuer;
return isLegacyOpenIdIssuer(openidIssuer);
}
function resolveIssuerBoundUser(
user: IUser | null,
normalizedIssuer: string | undefined,
strategyName: string,
context: string,
): OpenIdUserResolution | null {
if (!user?.openidId) return null;
if (!isUserIssuerAllowed(user, normalizedIssuer)) {
logger.warn(
`[${strategyName}] Rejected ${context} for ${user.email}: stored openidIssuer does not match token issuer`,
);
return { user: null, error: ErrorTypes.AUTH_FAILED, migration: false };
}
if (normalizedIssuer && !normalizeOpenIdIssuer(user.openidIssuer)) {
user.openidIssuer = normalizedIssuer;
return { user, error: null, migration: true };
}
return null;
}
/**
* Resolves the OpenID user identifier claim, honoring OPENID_EMAIL_CLAIM before
* email/preferred_username/upn fallbacks.
*/
export function getOpenIdEmail(
claims: OpenIdEmailClaims | null | undefined,
strategyName = 'openidStrategy',
): string | undefined {
if (claims == null) return undefined;
const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim();
if (claimKey) {
const value = claims[claimKey];
if (typeof value === 'string' && value) return value;
if (value != null) {
logger.warn(
`[${strategyName}] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`,
);
} else {
logger.warn(
`[${strategyName}] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`,
);
}
}
return (
getStringClaim(claims, 'email') ??
getStringClaim(claims, 'preferred_username') ??
getStringClaim(claims, 'upn')
);
}
/**
* Finds or migrates a user for OpenID authentication
* @returns user object (with migration fields if needed), error message, and whether migration is needed
*/
export async function findOpenIDUser({
openidId,
findUser,
email,
openidIssuer,
idOnTheSource,
strategyName = 'openid',
}: {
openidId: string;
findUser: UserMethods['findUser'];
email?: string;
openidIssuer?: string;
idOnTheSource?: string;
strategyName?: string;
}): Promise<OpenIdUserResolution> {
const lookupStartedAt = isMetricsConfigured() ? process.hrtime.bigint() : null;
const finish = (resolution: OpenIdUserResolution): OpenIdUserResolution => {
if (lookupStartedAt != null) {
recordOpenIDUserLookup(
getOpenIDUserLookupResult(resolution),
getElapsedSeconds(lookupStartedAt),
);
}
return resolution;
};
try {
const normalizedIssuer = normalizeOpenIdIssuer(openidIssuer);
const primaryConditions = getPrimaryLookupConditions(openidId, idOnTheSource, normalizedIssuer);
let user: IUser | null = null;
if (primaryConditions.length > 0) {
user = await findFirstOpenIdUser(findUser, primaryConditions);
}
const primaryIssuerResolution = resolveIssuerBoundUser(
user,
normalizedIssuer,
strategyName,
'OpenID lookup',
);
if (primaryIssuerResolution) return finish(primaryIssuerResolution);
if (!user && email) {
user = await findUser({ email });
logger.warn(
`[${strategyName}] user ${user ? 'found' : 'not found'} with email: ${email} for openidId: ${openidId}`,
);
// If user found by email, check if they're allowed to use OpenID provider
if (user && user.provider && user.provider !== 'openid') {
logger.warn(
`[${strategyName}] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
);
return finish({ user: null, error: ErrorTypes.AUTH_FAILED, migration: false });
}
if (user?.openidId && user.openidId !== openidId) {
logger.warn(
`[${strategyName}] Rejected email fallback for ${user.email}: stored openidId does not match token sub`,
);
return finish({ user: null, error: ErrorTypes.AUTH_FAILED, migration: false });
}
const emailIssuerResolution = resolveIssuerBoundUser(
user,
normalizedIssuer,
strategyName,
'email fallback',
);
if (emailIssuerResolution) return finish(emailIssuerResolution);
if (user && !user.openidId) {
logger.info(
`[${strategyName}] Preparing user ${user.email} for migration to OpenID with sub: ${openidId}`,
);
user.provider = 'openid';
user.openidId = openidId;
if (normalizedIssuer) user.openidIssuer = normalizedIssuer;
return finish({ user, error: null, migration: true });
}
}
return finish({ user, error: null, migration: false });
} catch (error) {
if (lookupStartedAt != null) {
recordOpenIDUserLookup('error', getElapsedSeconds(lookupStartedAt));
}
throw error;
}
}