mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-12 17:43:39 +00:00
🛠️ 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:
parent
cc74590864
commit
8b5cea29bc
9 changed files with 490 additions and 22 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue