🛠️ fix: Bind OpenID session tokens to authenticated identity

Stamp OpenID session token state with the LibreChat user id, OpenID subject,
tenant id, and normalized issuer when tokens are stored.

Fail closed before OBO inline token reuse/refresh when the session token
identity does not match the current authenticated identity, preventing a stale
or mixed Express session from supplying another user's upstream assertion.

Also validate the normal /api/auth/refresh session-token reuse shortcut against
the signed marker-cookie user before returning cached session tokens.

Note: sessions created before this change carry no identity stamp and are
treated as a mismatch. This is self-healing — the reuse path forces a full IdP
refresh (which re-stamps the session) and the OBO path throws, surfacing as a
one-time re-authentication for active OBO users at deploy time. The session
re-stamps within one session lifetime (SESSION_EXPIRY, default 15 min).
This commit is contained in:
J.C. Bartle 2026-06-28 17:53:46 -04:00
parent cc74590864
commit 8b5cea29bc
9 changed files with 490 additions and 22 deletions

View file

@ -1,8 +1,10 @@
import {
createAuthIdentityContext,
createOpenIDOboIdentityTuple,
createOpenIDSessionIdentity,
createOpenIDRefreshIdentityTuple,
createRefreshTokenBridgeIdentity,
isOpenIDSessionIdentityMatch,
resolveAppUserId,
serializeAuthIdentityTuple,
} from './identity';
@ -33,6 +35,65 @@ describe('auth identity helpers', () => {
});
});
it('creates session identity from explicit token metadata before request user fallback', () => {
expect(
createOpenIDSessionIdentity({
user: {
id: 'request-user',
openidId: 'request-sub',
tenantId: 'request-tenant',
openidIssuer: 'https://request.example.com',
},
userId: 'session-user',
openidSubject: 'session-sub',
tenantId: 'session-tenant',
openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration',
}),
).toEqual({
appUserId: 'session-user',
openidSubject: 'session-sub',
tenantId: 'session-tenant',
openidIssuer: 'https://issuer.example.com',
});
});
it('requires stamped OpenID session identity metadata to match exactly', () => {
const expected = {
appUserId: 'user-123',
openidSubject: 'oidc-sub',
tenantId: 'tenant-a',
openidIssuer: 'https://issuer.example.com',
};
expect(
isOpenIDSessionIdentityMatch(
{
...expected,
openidIssuer: 'https://issuer.example.com/',
},
expected,
),
).toBe(true);
expect(
isOpenIDSessionIdentityMatch(
{
...expected,
openidSubject: 'different-sub',
},
expected,
),
).toBe(false);
expect(
isOpenIDSessionIdentityMatch(
{
...expected,
openidIssuer: undefined,
},
expected,
),
).toBe(false);
});
it('allows refresh tuple to fall back to app id when openidId is absent', () => {
expect(
createOpenIDRefreshIdentityTuple({

View file

@ -34,6 +34,13 @@ export type RefreshTokenBridgeIdentity = {
openidIssuer?: string;
};
export type OpenIDSessionIdentitySource = {
appUserId?: string | null;
openidSubject?: string | null;
tenantId?: string | null;
openidIssuer?: string | null;
};
const NO_TENANT = 'no-tenant';
const NO_ISSUER = 'no-issuer';
const IDENTITY_PART_SEPARATOR = '\x1f';
@ -136,6 +143,68 @@ export function createAuthIdentityContext({
};
}
export function createOpenIDSessionIdentity({
user,
requestUser,
userId,
openidSubject,
tenantId,
openidIssuer,
}: {
user?: AuthIdentitySource | null;
requestUser?: AuthIdentitySource | null;
userId?: string | null;
openidSubject?: string | null;
tenantId?: string | null;
openidIssuer?: string | null;
}): AuthIdentityContext {
return createAuthIdentityContext({
user: {
id: userId,
openidId: openidSubject,
tenantId,
openidIssuer,
},
requestUser: user ?? requestUser,
tenantId,
openidIssuer,
});
}
function normalizeOpenIDSessionIdentity(
identity: OpenIDSessionIdentitySource | null | undefined,
): AuthIdentityContext | null {
const appUserId = normalizeIdentityValue(identity?.appUserId);
const openidSubject = normalizeIdentityValue(identity?.openidSubject);
if (!appUserId || !openidSubject) {
return null;
}
return {
appUserId,
openidSubject,
tenantId: normalizeIdentityValue(identity?.tenantId),
openidIssuer: normalizeOpenIdIssuer(identity?.openidIssuer ?? undefined),
};
}
export function isOpenIDSessionIdentityMatch(
sessionIdentity: OpenIDSessionIdentitySource | null | undefined,
expectedIdentity: OpenIDSessionIdentitySource | null | undefined,
): boolean {
const session = normalizeOpenIDSessionIdentity(sessionIdentity);
const expected = normalizeOpenIDSessionIdentity(expectedIdentity);
return (
session != null &&
expected != null &&
session.appUserId === expected.appUserId &&
session.openidSubject === expected.openidSubject &&
session.tenantId === expected.tenantId &&
session.openidIssuer === expected.openidIssuer
);
}
export function createRefreshTokenBridgeIdentity({
user,
requestUser,