LibreChat/api/server/services/OpenIDSessionRefresh.spec.js
2026-08-29 12:51:04 -04:00

1533 lines
59 KiB
JavaScript

jest.mock('openid-client', () => ({
refreshTokenGrant: jest.fn(),
}));
jest.mock('~/strategies/openidStrategy', () => ({
getOpenIdConfig: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
},
DEFAULT_REFRESH_TOKEN_EXPIRY: 1000 * 60 * 60 * 24 * 7,
}));
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(),
math: jest.fn((_value, fallback) => fallback),
createAuthIdentityContext: jest.fn(({ user, requestUser }) => ({
appUserId:
user?._id?.toString?.() ?? user?.id ?? requestUser?._id?.toString?.() ?? requestUser?.id,
openidSubject: user?.openidId ?? requestUser?.openidId,
tenantId: user?.tenantId ?? requestUser?.tenantId,
openidIssuer: user?.openidIssuer ?? requestUser?.openidIssuer,
})),
isOpenIDSessionIdentityMatch: jest.fn((sessionIdentity, expectedIdentity) => {
const normalize = (value) => {
if (value == null) {
return undefined;
}
const normalized = typeof value === 'string' ? value.trim() : value.toString().trim();
return normalized || undefined;
};
const normalizeIssuer = (value) => normalize(value)?.replace(/\/+$/, '');
const session = {
appUserId: normalize(sessionIdentity?.appUserId),
openidSubject: normalize(sessionIdentity?.openidSubject),
tenantId: normalize(sessionIdentity?.tenantId),
openidIssuer: normalizeIssuer(sessionIdentity?.openidIssuer),
};
const expected = {
appUserId: normalize(expectedIdentity?.appUserId),
openidSubject: normalize(expectedIdentity?.openidSubject),
tenantId: normalize(expectedIdentity?.tenantId),
openidIssuer: normalizeIssuer(expectedIdentity?.openidIssuer),
};
return (
Boolean(session.appUserId) &&
Boolean(session.openidSubject) &&
session.appUserId === expected.appUserId &&
session.openidSubject === expected.openidSubject &&
session.tenantId === expected.tenantId &&
session.openidIssuer === expected.openidIssuer
);
}),
createOpenIDRefreshIdentityTuple: jest.fn(({ user, requestUser }) => {
const subject =
user?.openidId ??
user?.id ??
user?._id?.toString?.() ??
requestUser?.openidId ??
requestUser?.id ??
requestUser?._id?.toString?.();
if (!subject) {
return null;
}
return {
subject,
tenantId: user?.tenantId ?? requestUser?.tenantId ?? 'no-tenant',
openidIssuer: user?.openidIssuer ?? requestUser?.openidIssuer ?? 'no-issuer',
};
}),
serializeAuthIdentityTuple: jest.fn(
(tuple) => `${tuple.tenantId}\x1f${tuple.openidIssuer}\x1f${tuple.subject}`,
),
createRefreshTokenBridgeIdentity: jest.fn(
({ user, requestUser, userId, tenantId, openidIssuer }) => {
const normalize = (value) => {
if (value == null) {
return undefined;
}
const normalized = typeof value === 'string' ? value.trim() : value.toString().trim();
return normalized || undefined;
};
const resolvedUserId =
normalize(userId) ??
normalize(user?._id) ??
normalize(user?.id) ??
normalize(requestUser?._id) ??
normalize(requestUser?.id);
if (!resolvedUserId) {
return null;
}
return {
userId: resolvedUserId,
tenantId: tenantId ?? user?.tenantId ?? requestUser?.tenantId,
openidIssuer: openidIssuer ?? user?.openidIssuer ?? requestUser?.openidIssuer,
};
},
),
buildOpenIDRefreshParams: jest.fn(() => ({ scope: 'openid profile' })),
setRefreshTokenCookie: jest.fn((res, refreshToken, expires) => {
res.cookie('refreshToken', refreshToken, { expires });
}),
setOpenIDMarkerCookies: jest.fn((res, { userId, expires }) => {
res.cookie('token_provider', 'openid', { expires });
if (userId) {
res.cookie('openid_user_id', `signed:${userId}`, { expires });
}
}),
normalizeExpiresIn: (value) => {
const normalized = typeof value === 'string' && value.trim() ? Number(value) : value;
return typeof normalized === 'number' && Number.isFinite(normalized) ? normalized : undefined;
},
storeOpenIdSession: jest.fn(),
}));
jest.mock('~/models', () => ({
upsertSession: jest.fn(),
deleteSession: jest.fn(),
}));
jest.mock('./RefreshTokenBridge', () => ({
OPENID_REFRESH_BRIDGE_GRACE_MS: 60 * 1000,
storeRefreshTokenBridge: jest.fn(),
}));
jest.mock('./OpenIDRefreshFlight', () => ({
acquireOpenIDRefreshFlight: jest.fn(),
completeOpenIDRefreshFlight: jest.fn(),
createOpenIDRefreshFlightKey: jest.fn(),
failOpenIDRefreshFlight: jest.fn(),
waitForOpenIDRefreshFlight: jest.fn(),
}));
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const {
isEnabled,
buildOpenIDRefreshParams,
setRefreshTokenCookie,
setOpenIDMarkerCookies,
storeOpenIdSession,
} = require('@librechat/api');
const { upsertSession, deleteSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
const { storeRefreshTokenBridge } = require('./RefreshTokenBridge');
const {
acquireOpenIDRefreshFlight,
completeOpenIDRefreshFlight,
createOpenIDRefreshFlightKey,
failOpenIDRefreshFlight,
waitForOpenIDRefreshFlight,
} = require('./OpenIDRefreshFlight');
const {
createOpenIDSessionTokenProvider,
refreshOpenIDSession,
__internals,
} = require('./OpenIDSessionRefresh');
const SECRET = 'test-secret';
const makeJwt = (exp) => jwt.sign({ sub: 'user-123', exp }, SECRET);
const DEFAULT_SESSION_IDENTITY = {
appUserId: 'local-id-1',
openidSubject: 'oidc-sub-123',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
};
const withSessionIdentity = (sessionTokens) =>
sessionTokens == null ? sessionTokens : { ...DEFAULT_SESSION_IDENTITY, ...sessionTokens };
const buildReq = (sessionTokens, sessionId = 'session-A', { bindIdentity = true } = {}) => ({
sessionID: sessionId,
session: Object.assign(
{
save: jest.fn((cb) => cb(null)),
},
sessionTokens === undefined
? {}
: { openidTokens: bindIdentity ? withSessionIdentity(sessionTokens) : sessionTokens },
),
});
/** Minimal writable Express response stub for cookie-sync assertions. */
const buildRes = ({ headersSent = false } = {}) => ({
headersSent,
cookie: jest.fn(),
});
const makeOpenIdUser = (overrides = {}) => ({
id: 'local-id-1',
openidId: 'oidc-sub-123',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
provider: 'openid',
...overrides,
});
describe('OpenIDSessionRefresh', () => {
beforeEach(() => {
jest.clearAllMocks();
__internals.inFlightRefreshes.clear();
isEnabled.mockReturnValue(true);
getOpenIdConfig.mockReturnValue({ issuer: 'https://issuer.example.com' });
openIdClient.refreshTokenGrant.mockReset();
createOpenIDRefreshFlightKey.mockImplementation(
({ req, refreshToken }) => refreshToken && `flight:${req?.sessionID}:${refreshToken}`,
);
acquireOpenIDRefreshFlight.mockResolvedValue({ acquired: true, ownerId: 'owner-1' });
completeOpenIDRefreshFlight.mockResolvedValue({});
failOpenIDRefreshFlight.mockResolvedValue({});
waitForOpenIDRefreshFlight.mockResolvedValue(null);
});
describe('createOpenIDSessionTokenProvider closure no-op cases', () => {
it('throws when tokenPreference is missing', () => {
expect(() =>
createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: makeOpenIdUser(),
}),
).toThrow(/tokenPreference/);
});
it('throws when tokenPreference is invalid', () => {
expect(() =>
createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: makeOpenIdUser(),
tokenPreference: 'bogus',
}),
).toThrow(/tokenPreference/);
});
it('returns null when OPENID_REUSE_TOKENS is disabled', async () => {
isEnabled.mockReturnValue(false);
const provider = createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('returns null when user is non-OpenID', async () => {
const provider = createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: { id: 'local-1', provider: 'local' },
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('returns null when user is missing entirely', async () => {
const provider = createOpenIDSessionTokenProvider({
req: buildReq({ accessToken: makeJwt(Date.now() / 1000 + 600) }),
user: undefined,
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
});
it('returns null when req.session.openidTokens is missing', async () => {
const provider = createOpenIDSessionTokenProvider({
req: buildReq(undefined),
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('returns null when req is missing entirely', async () => {
const provider = createOpenIDSessionTokenProvider({
req: undefined,
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
await expect(provider()).resolves.toBeNull();
});
});
describe('refreshOpenIDSession live-token reuse', () => {
it('returns live tokens without calling IdP when access_token still valid past skew', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-1',
};
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(result).toEqual({
access_token: sessionTokens.accessToken,
id_token: sessionTokens.idToken,
refresh_token: 'rt-1',
expires_at: farFutureExp,
});
});
it('rejects session tokens that are missing identity metadata', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-unbound',
};
const req = buildReq(sessionTokens, 'session-unbound', { bindIdentity: false });
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('OpenID session token identity mismatch');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
it('rejects session tokens bound to a different OpenID identity', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-other-user',
appUserId: 'other-user',
};
const req = buildReq(sessionTokens);
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('OpenID session token identity mismatch');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
});
/**
* The bug fixed by Codex Finding 1a: id_token can outlive access_token.
* Old behavior would declare "live" because id_token is fresh, sending an
* expired access_token to the OBO IdP. New behavior must trigger a refresh.
*/
it('refreshes when access_token is expired even if id_token is still fresh', async () => {
const accessExp = Math.floor(Date.now() / 1000) - 30;
const idExp = Math.floor(Date.now() / 1000) + 3600;
const sessionTokens = {
accessToken: makeJwt(accessExp),
idToken: makeJwt(idExp),
refreshToken: 'rt-asym',
};
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-asym-2',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(result.access_token).not.toBe(sessionTokens.accessToken);
});
it('falls through to refresh when access_token expires within the skew buffer', async () => {
const veryNearExp = Math.floor(Date.now() / 1000) + 10; // < 30s buffer
const sessionTokens = {
accessToken: makeJwt(veryNearExp),
idToken: makeJwt(veryNearExp),
refreshToken: 'rt-2',
};
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-3',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(buildOpenIDRefreshParams).toHaveBeenCalled();
expect(result.refresh_token).toBe('rt-3');
expect(req.session.openidTokens.refreshToken).toBe('rt-3');
expect(req.session.save).toHaveBeenCalled();
});
});
describe('refreshOpenIDSession refresh path', () => {
it('refreshes when access_token is expired and persists session', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-old',
};
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-new',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(req.session.save).toHaveBeenCalledTimes(1);
expect(typeof result.access_token).toBe('string');
expect(req.session.openidTokens).toEqual(
expect.objectContaining({
refreshToken: 'rt-new',
lastRefreshedAt: expect.any(Number),
}),
);
});
it('preserves prior id_token and refresh_token when IdP omits them on rotation', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const priorIdToken = makeJwt(expiredExp);
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: priorIdToken,
refreshToken: 'rt-keep',
};
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
// id_token and refresh_token both omitted
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(result.id_token).toBe(priorIdToken);
expect(result.refresh_token).toBe('rt-keep');
expect(req.session.openidTokens.idToken).toBe(priorIdToken);
expect(req.session.openidTokens.refreshToken).toBe('rt-keep');
});
/**
* The bug fixed by Codex Finding 1b: when IdP rotates only access_token,
* derive expires_at from the IdP's tokenset.expires_in (authoritative for
* the new access_token) rather than the prior id_token's exp claim. The
* latter would cause `isOpenIDTokenValid` to reject a fresh credential.
*/
it('uses tokenset.expires_in (not prior id_token exp) for expires_at after rotation-omits-id-token refresh', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-rot',
};
// IdP omits id_token; expires_in is the only authoritative expiry source
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(Math.floor(Date.now() / 1000) + 3600),
// id_token omitted
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const beforeSec = Math.floor(Date.now() / 1000);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
// expires_at should be ~now + 3600, NOT the stale prior id_token exp
expect(result.expires_at).toBeGreaterThanOrEqual(beforeSec + 3590);
expect(result.expires_at).toBeLessThanOrEqual(beforeSec + 3610);
});
it('returns null when session lacks a refresh_token (cannot refresh)', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
// no refreshToken
};
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('rethrows when refreshTokenGrant rejects', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-bad',
};
openIdClient.refreshTokenGrant.mockRejectedValueOnce(new Error('invalid_grant'));
const req = buildReq(sessionTokens);
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('invalid_grant');
expect(req.session.save).not.toHaveBeenCalled();
});
it('rethrows when refreshTokenGrant returns no access_token', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-incomplete',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
// access_token absent
expires_in: 3600,
});
const req = buildReq(sessionTokens);
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).rejects.toThrow(/no access_token/i);
});
});
describe('rotated refresh-token cookie sync', () => {
const buildExpiredSession = (refreshToken, browserRefreshToken) => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken,
};
if (browserRefreshToken) {
sessionTokens.browserRefreshToken = browserRefreshToken;
}
return sessionTokens;
};
it('writes the rotated refresh token to the cookie when res is writable', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).toHaveBeenCalledTimes(1);
expect(setRefreshTokenCookie).toHaveBeenCalledWith(res, 'rt-rotated', expect.any(Date));
expect(setOpenIDMarkerCookies).toHaveBeenCalledTimes(1);
expect(setOpenIDMarkerCookies).toHaveBeenCalledWith(res, {
userId: 'local-id-1',
expires: expect.any(Date),
refreshExpiryMs: 1000 * 60 * 60 * 24 * 7,
refreshToken: 'rt-rotated',
});
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-rotated');
});
/** The durable Session record is what authorizes local image access for OpenID users, and it is
* looked up by the refresh token in the browser's cookie — so it has to move with the cookie. */
it('moves the durable session record onto the rotated token alongside the cookie', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(storeOpenIdSession).toHaveBeenCalledWith(
{
userId: 'local-id-1',
refreshToken: 'rt-rotated',
tenantId: 'tenant-1',
previousRefreshToken: 'rt-old',
},
{ upsertSession, deleteSession },
);
});
/** Headers are already sent, so the browser keeps the old cookie: revoking the record it still
* presents would lock the user out of images until the next `/refresh` recovers the bridge. */
it('leaves the durable session alone when the rotation can only be bridged', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: true });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(storeRefreshTokenBridge).toHaveBeenCalled();
expect(storeOpenIdSession).not.toHaveBeenCalled();
});
/** A failed session write must not fail the refresh: the caller still holds a usable token. */
it('survives a durable session write failure', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
storeOpenIdSession.mockRejectedValueOnce(new Error('mongo down'));
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).resolves.toBeTruthy();
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-rotated');
});
it('does not write the cookie when the IdP does not rotate the refresh token', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
// refresh_token omitted → preserved as 'rt-stable'
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-stable'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
});
it('repairs a stale browser cookie when a stable refresh omits refresh_token', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-session-current', 'rt-browser-stale'));
const res = buildRes({ headersSent: false });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).toHaveBeenCalledWith(
res,
'rt-session-current',
expect.any(Date),
);
expect(storeRefreshTokenBridge).not.toHaveBeenCalled();
expect(req.session.openidTokens.refreshToken).toBe('rt-session-current');
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-session-current');
});
it('stores a bridge for stale browser cookies when a stable refresh cannot write cookies', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-session-current', 'rt-browser-stale'));
const res = buildRes({ headersSent: true });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-browser-stale',
newRefreshToken: 'rt-session-current',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-browser-stale');
});
it('resolves bridge identity through the shared helper when identity context is absent', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq({
...buildExpiredSession('rt-old'),
appUserId: 'mongo-id',
});
const res = buildRes({ headersSent: true });
await refreshOpenIDSession(
req,
res,
makeOpenIdUser({
id: 'public-id',
_id: { toString: () => 'mongo-id' },
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
}),
'access_token',
);
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'mongo-id',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
});
it('syncs the rotated cookie and stores a short bridge before surfacing a session save failure', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: false });
req.session.save.mockImplementationOnce((cb) => cb(new Error('session store down')));
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).rejects.toThrow('session store down');
expect(setRefreshTokenCookie).toHaveBeenCalledWith(res, 'rt-rotated', expect.any(Date));
expect(setOpenIDMarkerCookies).toHaveBeenCalledTimes(1);
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
ttl: 60 * 1000,
});
expect(req.session.openidTokens.refreshToken).toBe('rt-rotated');
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-rotated');
});
it('stores a recovery bridge when response headers are already sent (streaming path)', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: true });
const user = makeOpenIdUser({
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
await refreshOpenIDSession(req, res, user, 'access_token');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
/** Session copy remains authoritative even when the cookie can't be set. */
expect(req.session.openidTokens.refreshToken).toBe('rt-rotated');
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-old');
});
it('keeps bridging from the stale browser cookie across repeated rotations', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-second-rotation',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-first-rotation', 'rt-browser-cookie'));
const res = buildRes({ headersSent: true });
await refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token');
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-browser-cookie',
newRefreshToken: 'rt-second-rotation',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
expect(req.session.openidTokens.refreshToken).toBe('rt-second-rotation');
expect(req.session.openidTokens.browserRefreshToken).toBe('rt-browser-cookie');
});
it('stores a recovery bridge when no res is provided', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
await expect(
refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token'),
).resolves.toBeDefined();
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
});
it('stores a recovery bridge when res cannot write cookies', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const req = buildReq(buildExpiredSession('rt-old'));
await refreshOpenIDSession(req, { headersSent: false }, makeOpenIdUser(), 'access_token');
expect(setRefreshTokenCookie).not.toHaveBeenCalled();
expect(setOpenIDMarkerCookies).not.toHaveBeenCalled();
expect(storeRefreshTokenBridge).toHaveBeenCalledWith({
oldRefreshToken: 'rt-old',
newRefreshToken: 'rt-rotated',
userId: 'local-id-1',
tenantId: 'tenant-1',
openidIssuer: 'https://issuer.example.com',
});
});
it('does not fail the refresh when bridge storage fails after headers are sent', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
storeRefreshTokenBridge.mockRejectedValueOnce(new Error('encrypt failed'));
const req = buildReq(buildExpiredSession('rt-old'));
const res = buildRes({ headersSent: true });
await expect(
refreshOpenIDSession(req, res, makeOpenIdUser(), 'access_token'),
).resolves.toBeDefined();
expect(req.session.openidTokens.refreshToken).toBe('rt-rotated');
});
});
describe('single-flight coalescing', () => {
it('scopes the local refresh key by explicit identity context', () => {
const req = buildReq({ refreshToken: 'rt-shared' }, 'session-shared');
const user = makeOpenIdUser({ tenantId: undefined, openidIssuer: undefined });
const keyA = __internals.getSingleFlightKey(req, user, {
openidSubject: 'oidc-sub-123',
tenantId: 'tenant-a',
openidIssuer: 'https://issuer-a.example.com',
});
const keyB = __internals.getSingleFlightKey(req, user, {
openidSubject: 'oidc-sub-123',
tenantId: 'tenant-b',
openidIssuer: 'https://issuer-a.example.com',
});
const keyC = __internals.getSingleFlightKey(req, user, {
openidSubject: 'oidc-sub-123',
tenantId: 'tenant-a',
openidIssuer: 'https://issuer-b.example.com',
});
expect(keyA).not.toBe(keyB);
expect(keyA).not.toBe(keyC);
expect(keyA).not.toContain('rt-shared');
});
it('shares one refreshTokenGrant call across concurrent waiters in the SAME session', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-shared',
};
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
const req = buildReq(sessionTokens, 'session-shared');
const user = makeOpenIdUser();
const p1 = refreshOpenIDSession(req, undefined, user, 'access_token');
const p2 = refreshOpenIDSession(req, undefined, user, 'access_token');
await Promise.resolve();
// Both calls land before the IdP responds
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveGrant({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toBe(r2);
expect(__internals.inFlightRefreshes.size).toBe(0);
});
/**
* Codex Finding 3: distinct sessions for the same OpenID subject must NOT
* share an in-flight refresh, otherwise refresh-token rotation breaks the
* non-winning session silently. Per-sessionID keying isolates them.
*/
it('does NOT share an in-flight refresh across DIFFERENT sessions for the same user', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const reqA = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-A',
},
'session-A',
);
const reqB = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-B',
},
'session-B',
);
let resolveA;
let resolveB;
const promiseA = new Promise((resolve) => {
resolveA = resolve;
});
const promiseB = new Promise((resolve) => {
resolveB = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(promiseA).mockReturnValueOnce(promiseB);
const user = makeOpenIdUser();
const pA = refreshOpenIDSession(reqA, undefined, user, 'access_token');
const pB = refreshOpenIDSession(reqB, undefined, user, 'access_token');
await Promise.resolve();
// Two refreshes started, one per session
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(2);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveA({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-A-rotated',
expires_in: 3600,
});
resolveB({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-B-rotated',
expires_in: 3600,
});
const [rA, rB] = await Promise.all([pA, pB]);
expect(rA).not.toBe(rB);
expect(reqA.session.openidTokens.refreshToken).toBe('rt-A-rotated');
expect(reqB.session.openidTokens.refreshToken).toBe('rt-B-rotated');
});
it('does NOT share an in-flight refresh in the same session when refresh tokens differ', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const reqOld = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-old',
},
'session-rotated',
);
const reqCurrent = buildReq(
{
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-current',
},
'session-rotated',
);
let resolveOld;
let resolveCurrent;
const oldPromise = new Promise((resolve) => {
resolveOld = resolve;
});
const currentPromise = new Promise((resolve) => {
resolveCurrent = resolve;
});
openIdClient.refreshTokenGrant
.mockReturnValueOnce(oldPromise)
.mockReturnValueOnce(currentPromise);
const user = makeOpenIdUser();
const oldRefresh = refreshOpenIDSession(reqOld, undefined, user, 'access_token');
const currentRefresh = refreshOpenIDSession(reqCurrent, undefined, user, 'access_token');
await Promise.resolve();
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(2);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveOld({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-old-rotated',
expires_in: 3600,
});
resolveCurrent({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-current-rotated',
expires_in: 3600,
});
const [oldResult, currentResult] = await Promise.all([oldRefresh, currentRefresh]);
expect(oldResult).not.toBe(currentResult);
expect(reqOld.session.openidTokens.refreshToken).toBe('rt-old-rotated');
expect(reqCurrent.session.openidTokens.refreshToken).toBe('rt-current-rotated');
});
it('clears in-flight slot on rejection so subsequent attempts can retry', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-flaky',
};
openIdClient.refreshTokenGrant.mockRejectedValueOnce(new Error('transient'));
const req = buildReq(sessionTokens);
const user = makeOpenIdUser();
await expect(refreshOpenIDSession(req, undefined, user, 'access_token')).rejects.toThrow(
'transient',
);
expect(__internals.inFlightRefreshes.size).toBe(0);
// Second attempt: succeed
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-recovered',
expires_in: 3600,
});
const result = await refreshOpenIDSession(req, undefined, user, 'access_token');
expect(result.refresh_token).toBe('rt-recovered');
});
it('hydrates a joining request that shares the session id but carries a distinct req', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = (refreshToken) => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken,
});
/** Two concurrent HTTP requests from the same browser session. */
const leaderReq = buildReq(makeExpiredSession('rt-stale'), 'session-joined');
const joinerReq = buildReq(makeExpiredSession('rt-stale'), 'session-joined');
const user = makeOpenIdUser();
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
const leaderPromise = refreshOpenIDSession(leaderReq, undefined, user, 'access_token');
const joinerPromise = refreshOpenIDSession(joinerReq, undefined, user, 'access_token');
await Promise.resolve();
// Only the leader hit the IdP; the joiner coalesced onto it.
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveGrant({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const [leaderTokens, joinerTokens] = await Promise.all([leaderPromise, joinerPromise]);
expect(leaderTokens.refresh_token).toBe('rt-rotated');
expect(joinerTokens.refresh_token).toBe('rt-rotated');
// The joiner's OWN session is hydrated so a later OBO call won't replay rt-stale.
expect(joinerReq.session.openidTokens.refreshToken).toBe('rt-rotated');
expect(joinerReq.session.openidTokens.browserRefreshToken).toBe('rt-stale');
expect(joinerReq.session.save).toHaveBeenCalled();
});
it('hydrates a joining request with the rotated browser marker when the leader wrote cookies', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = () => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-stale',
browserRefreshToken: 'rt-stale',
});
const leaderReq = buildReq(makeExpiredSession(), 'session-cookie-joined');
const joinerReq = buildReq(makeExpiredSession(), 'session-cookie-joined');
const leaderRes = buildRes({ headersSent: false });
const user = makeOpenIdUser();
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
const leaderPromise = refreshOpenIDSession(leaderReq, leaderRes, user, 'access_token');
const joinerPromise = refreshOpenIDSession(joinerReq, undefined, user, 'access_token');
await Promise.resolve();
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
resolveGrant({
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-rotated',
expires_in: 3600,
});
const [leaderTokens, joinerTokens] = await Promise.all([leaderPromise, joinerPromise]);
expect(leaderTokens.refresh_token).toBe('rt-rotated');
expect(joinerTokens.refresh_token).toBe('rt-rotated');
expect(setRefreshTokenCookie).toHaveBeenCalledWith(leaderRes, 'rt-rotated', expect.any(Date));
expect(leaderReq.session.openidTokens.browserRefreshToken).toBe('rt-rotated');
expect(joinerReq.session.openidTokens.refreshToken).toBe('rt-rotated');
expect(joinerReq.session.openidTokens.browserRefreshToken).toBe('rt-rotated');
expect(Object.keys(joinerTokens)).not.toContain('__browserRefreshToken');
expect(joinerReq.session.save).toHaveBeenCalled();
});
it('hydrates a joining request when the refresh token stays stable', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = () => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-stable',
accessTokenExpiresAt: expiredExp,
});
const leaderReq = buildReq(makeExpiredSession(), 'session-stable-joined');
const joinerReq = buildReq(makeExpiredSession(), 'session-stable-joined');
const user = makeOpenIdUser();
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
const leaderPromise = refreshOpenIDSession(leaderReq, undefined, user, 'access_token');
const joinerPromise = refreshOpenIDSession(joinerReq, undefined, user, 'access_token');
await Promise.resolve();
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
const refreshedAccessToken = makeJwt(refreshedExp);
const refreshedIdToken = makeJwt(refreshedExp);
resolveGrant({
access_token: refreshedAccessToken,
id_token: refreshedIdToken,
refresh_token: 'rt-stable',
expires_in: 3600,
});
const [leaderTokens, joinerTokens] = await Promise.all([leaderPromise, joinerPromise]);
expect(leaderTokens.refresh_token).toBe('rt-stable');
expect(joinerTokens.refresh_token).toBe('rt-stable');
expect(joinerReq.session.openidTokens.accessToken).toBe(refreshedAccessToken);
expect(joinerReq.session.openidTokens.idToken).toBe(refreshedIdToken);
expect(joinerReq.session.openidTokens.refreshToken).toBe('rt-stable');
expect(joinerReq.session.openidTokens.accessTokenExpiresAt).toBe(joinerTokens.expires_at);
expect(joinerReq.session.openidTokens.accessTokenExpiresAt).toBeGreaterThan(expiredExp);
expect(joinerReq.session.save).toHaveBeenCalled();
});
it('joins a shared Mongo refresh flight when the local process has no in-flight entry', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const makeExpiredSession = () => ({
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-cross-worker',
});
const leaderReq = buildReq(makeExpiredSession(), 'session-cross-worker');
const joinerReq = buildReq(makeExpiredSession(), 'session-cross-worker');
const user = makeOpenIdUser();
let resolveGrant;
const grantPromise = new Promise((resolve) => {
resolveGrant = resolve;
});
openIdClient.refreshTokenGrant.mockReturnValueOnce(grantPromise);
acquireOpenIDRefreshFlight
.mockResolvedValueOnce({ acquired: true, ownerId: 'owner-leader' })
.mockResolvedValueOnce({ acquired: false, ownerId: 'owner-joiner' });
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
const sharedTokens = {
access_token: makeJwt(refreshedExp),
id_token: makeJwt(refreshedExp),
refresh_token: 'rt-cross-worker-rotated',
expires_at: refreshedExp,
};
waitForOpenIDRefreshFlight.mockResolvedValueOnce(sharedTokens);
const leaderPromise = refreshOpenIDSession(leaderReq, undefined, user, 'access_token');
await Promise.resolve();
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
/**
* Simulate a second worker: it does not see this process-local Map, but
* it does see the Mongo flight for the same browser session/token.
*/
__internals.inFlightRefreshes.clear();
const joinerTokens = await refreshOpenIDSession(joinerReq, undefined, user, 'access_token');
expect(waitForOpenIDRefreshFlight).toHaveBeenCalledWith({
key: 'flight:session-cross-worker:rt-cross-worker',
});
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(joinerTokens).toBe(sharedTokens);
expect(joinerReq.session.openidTokens.refreshToken).toBe('rt-cross-worker-rotated');
expect(joinerReq.session.save).toHaveBeenCalled();
resolveGrant({
access_token: sharedTokens.access_token,
id_token: sharedTokens.id_token,
refresh_token: sharedTokens.refresh_token,
expires_in: 3600,
});
await expect(leaderPromise).resolves.toEqual(
expect.objectContaining({
access_token: sharedTokens.access_token,
id_token: sharedTokens.id_token,
refresh_token: sharedTokens.refresh_token,
expires_at: expect.any(Number),
}),
);
expect(completeOpenIDRefreshFlight).toHaveBeenCalledWith({
key: 'flight:session-cross-worker:rt-cross-worker',
ownerId: 'owner-leader',
tokens: expect.objectContaining({
access_token: sharedTokens.access_token,
id_token: sharedTokens.id_token,
refresh_token: sharedTokens.refresh_token,
expires_at: expect.any(Number),
}),
});
});
});
describe('createOpenIDSessionTokenProvider closure delegation', () => {
it('returns the live OIDCTokens shape from a valid session', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: makeJwt(farFutureExp),
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-1',
};
const provider = createOpenIDSessionTokenProvider({
req: buildReq(sessionTokens),
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
const result = await provider();
expect(result).toEqual({
access_token: sessionTokens.accessToken,
id_token: sessionTokens.idToken,
refresh_token: 'rt-1',
expires_at: farFutureExp,
});
});
it('rejects with the IdP error when refresh fails through the closure', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: makeJwt(expiredExp),
idToken: makeJwt(expiredExp),
refreshToken: 'rt-fail',
};
openIdClient.refreshTokenGrant.mockRejectedValueOnce(new Error('invalid_grant'));
const provider = createOpenIDSessionTokenProvider({
req: buildReq(sessionTokens),
user: makeOpenIdUser(),
tokenPreference: 'access_token',
});
await expect(provider()).rejects.toThrow('invalid_grant');
});
});
/**
* Codex Finding 4: opaque (non-JWT) access tokens make `decodeJwtExp` return
* null, which would force every OBO call to refresh even when the previous
* refresh response advertised a still-valid `expires_in`. The fix persists
* `accessTokenExpiresAt` (unix seconds) on each refresh and uses it as a
* fallback for the freshness check + `expires_at` derivation.
*/
describe('opaque access token support (accessTokenExpiresAt fallback)', () => {
it('reuses live opaque access_token when accessTokenExpiresAt is in the future', async () => {
const farFutureExp = Math.floor(Date.now() / 1000) + 600;
const sessionTokens = {
accessToken: 'opaque-blob-not-a-jwt',
idToken: makeJwt(farFutureExp),
refreshToken: 'rt-opaque',
accessTokenExpiresAt: farFutureExp,
};
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
expect(result).toEqual({
access_token: 'opaque-blob-not-a-jwt',
id_token: sessionTokens.idToken,
refresh_token: 'rt-opaque',
expires_at: farFutureExp,
});
});
it('refreshes opaque access_token when accessTokenExpiresAt has passed', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
const sessionTokens = {
accessToken: 'opaque-stale',
idToken: makeJwt(refreshedExp), // id_token still valid
refreshToken: 'rt-opaque-stale',
accessTokenExpiresAt: expiredExp,
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh',
// IdP omits id_token (Auth0 rotation off / MS personal); we use expires_in
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const result = await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
expect(result.access_token).toBe('opaque-fresh');
});
it('refreshes opaque access_token when no JWT exp and no accessTokenExpiresAt are present', async () => {
const refreshedExp = Math.floor(Date.now() / 1000) + 3600;
const sessionTokens = {
accessToken: 'opaque-no-expiry',
idToken: makeJwt(refreshedExp),
refreshToken: 'rt-no-exp',
// accessTokenExpiresAt deliberately omitted
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledTimes(1);
});
it('persists accessTokenExpiresAt to req.session.openidTokens after a refresh with expires_in', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: 'opaque-stale',
idToken: makeJwt(expiredExp),
refreshToken: 'rt-persist',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh',
expires_in: 3600,
});
const req = buildReq(sessionTokens);
const beforeSec = Math.floor(Date.now() / 1000);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
const persistedExp = req.session.openidTokens.accessTokenExpiresAt;
expect(typeof persistedExp).toBe('number');
expect(persistedExp).toBeGreaterThanOrEqual(beforeSec + 3590);
expect(persistedExp).toBeLessThanOrEqual(beforeSec + 3610);
});
it('persists accessTokenExpiresAt when the refreshed expires_in is a numeric string', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: 'opaque-stale',
idToken: makeJwt(expiredExp),
refreshToken: 'rt-string-expiry',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh',
expires_in: '3600',
});
const req = buildReq(sessionTokens);
const beforeSec = Math.floor(Date.now() / 1000);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(req.session.openidTokens.accessTokenExpiresAt).toBeGreaterThanOrEqual(
beforeSec + 3590,
);
});
it('drops a stale accessTokenExpiresAt when the new tokenset has neither expires_in nor a JWT access_token', async () => {
const expiredExp = Math.floor(Date.now() / 1000) - 60;
const sessionTokens = {
accessToken: 'opaque-old',
idToken: makeJwt(expiredExp),
refreshToken: 'rt-drop',
accessTokenExpiresAt: expiredExp, // stale carry-over
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh-no-meta',
// no expires_in, no JWT access_token, no id_token
});
const req = buildReq(sessionTokens);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
expect(req.session.openidTokens).not.toHaveProperty('accessTokenExpiresAt');
});
it('getAccessTokenExp prefers JWT exp over the persisted accessTokenExpiresAt', () => {
const jwtExp = Math.floor(Date.now() / 1000) + 600;
const persistedExp = Math.floor(Date.now() / 1000) - 60; // stale
const result = __internals.getAccessTokenExp({
accessToken: makeJwt(jwtExp),
accessTokenExpiresAt: persistedExp,
});
expect(result).toBe(jwtExp);
});
it('getAccessTokenExp returns null when neither a decodable JWT nor a persisted expiry is present', () => {
const result = __internals.getAccessTokenExp({
accessToken: 'opaque',
});
expect(result).toBeNull();
});
/**
* Codex Finding 6: id_token TTL is governed by IdP session policy and is
* often longer than access-token TTL. Trusting it as access-token expiry
* would mark an opaque access token reusable past its real lifetime,
* sending an expired credential to the OBO IdP. The fallback chain must
* be expires_in → JWT access_token exp → unset (NOT id_token exp).
*/
it('does NOT fall back to id_token exp for accessTokenExpiresAt when expires_in is missing', async () => {
const longLivedIdTokenExp = Math.floor(Date.now() / 1000) + 86400; // 24h
const sessionTokens = {
accessToken: 'opaque-old',
idToken: makeJwt(Math.floor(Date.now() / 1000) - 60),
refreshToken: 'rt-no-fallback',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: 'opaque-fresh', // opaque, NOT a JWT
id_token: makeJwt(longLivedIdTokenExp), // long-lived id_token
// no expires_in
});
const req = buildReq(sessionTokens);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
// The long-lived id_token exp must NOT have been borrowed for the access token.
expect(req.session.openidTokens).not.toHaveProperty('accessTokenExpiresAt');
});
it('falls back to JWT access_token exp for accessTokenExpiresAt when expires_in is missing', async () => {
const accessExp = Math.floor(Date.now() / 1000) + 1800; // 30min
const sessionTokens = {
accessToken: 'opaque-old',
idToken: makeJwt(Math.floor(Date.now() / 1000) - 60),
refreshToken: 'rt-jwt-access',
};
openIdClient.refreshTokenGrant.mockResolvedValueOnce({
access_token: makeJwt(accessExp), // JWT access token
id_token: makeJwt(Math.floor(Date.now() / 1000) + 86400), // long-lived; should NOT win
// no expires_in
});
const req = buildReq(sessionTokens);
await refreshOpenIDSession(req, undefined, makeOpenIdUser(), 'access_token');
// accessTokenExpiresAt comes from the access token's own JWT exp, not the id_token.
expect(req.session.openidTokens.accessTokenExpiresAt).toBe(accessExp);
});
});
});