diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 31522346c8..ec0160cf68 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -7,6 +7,8 @@ const { isEnabled, findOpenIDUser, getOpenIdIssuer, + createAuthIdentityContext, + isOpenIDSessionIdentityMatch, buildOpenIDRefreshParams, } = require('@librechat/api'); const { @@ -144,16 +146,44 @@ const refreshOpenIDUser = async ({ refreshToken, strategyName }) => { return { tokenset, claims, openidIssuer, user, error, migration }; }; -const sendOpenIDAuthResponse = ({ tokenset, user, existingRefreshToken, req, res }) => { +const getAuthIdentitySource = (user) => + typeof user?.toObject === 'function' ? user.toObject() : user; + +const sendOpenIDAuthResponse = ({ + tokenset, + user, + existingRefreshToken, + openidSubject, + openidIssuer, + req, + res, +}) => { const token = setOpenIDAuthTokens(tokenset, req, res, { userId: user._id.toString(), existingRefreshToken, tenantId: user.tenantId, + openidSubject: openidSubject ?? user.openidId, + openidIssuer: openidIssuer ?? user.openidIssuer, }); return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) }); }; +const isReusableOpenIDSessionIdentity = (openidTokens, user) => { + const identitySource = getAuthIdentitySource(user); + const expectedIdentity = createAuthIdentityContext({ user: identitySource }); + const matches = isOpenIDSessionIdentityMatch(openidTokens, expectedIdentity); + if (!matches) { + logger.warn('[refreshController] OpenID session token identity mismatch; forcing refresh', { + userId: expectedIdentity.appUserId, + has_session_user_id: Boolean(openidTokens?.appUserId), + has_session_subject: Boolean(openidTokens?.openidSubject), + has_session_issuer: Boolean(openidTokens?.openidIssuer), + }); + } + return matches; +}; + const getReusableOpenIDSessionToken = (openidTokens) => { if (!isRecentOpenIDSessionRefresh(openidTokens)) { return null; @@ -238,7 +268,7 @@ const refreshController = async (req, res) => { const reuseUserId = reusableSessionToken ? getValidOpenIDReuseUserId(parsedCookies) : null; if (reuseUserId) { const user = await getUserById(reuseUserId, AUTH_REFRESH_USER_PROJECTION); - if (user) { + if (user && isReusableOpenIDSessionIdentity(req.session?.openidTokens, user)) { const cloudFrontCookiesSet = setCloudFrontAuthCookies(req, res, user); logger.debug('[refreshController] OpenID session token reused', { token_type: reusableSessionToken.type, @@ -283,6 +313,8 @@ const refreshController = async (req, res) => { tokenset, user, existingRefreshToken: refreshToken, + openidSubject: claims?.sub, + openidIssuer, req, res, }); @@ -328,6 +360,8 @@ const refreshController = async (req, res) => { try { const { tokenset: retryTokenset, + claims: retryClaims, + openidIssuer: retryOpenidIssuer, user: retryUser, error: retryError, } = await refreshOpenIDUser({ @@ -372,6 +406,8 @@ const refreshController = async (req, res) => { tokenset: retryTokenset, user: retryUser, existingRefreshToken: bridgedRefreshToken, + openidSubject: retryClaims?.sub, + openidIssuer: retryOpenidIssuer, req, res, }); diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index e96fde15b2..9fe518f4bb 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -30,6 +30,31 @@ jest.mock('@librechat/api', () => ({ isEnabled: jest.fn(), findOpenIDUser: jest.fn(), getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'), + createAuthIdentityContext: jest.fn(({ user }) => ({ + appUserId: user?._id?.toString?.() ?? user?.id, + openidSubject: user?.openidId, + tenantId: user?.tenantId, + openidIssuer: user?.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(/\/+$/, ''); + return ( + Boolean(normalize(sessionIdentity?.appUserId)) && + Boolean(normalize(sessionIdentity?.openidSubject)) && + normalize(sessionIdentity?.appUserId) === normalize(expectedIdentity?.appUserId) && + normalize(sessionIdentity?.openidSubject) === normalize(expectedIdentity?.openidSubject) && + normalize(sessionIdentity?.tenantId) === normalize(expectedIdentity?.tenantId) && + normalizeIssuer(sessionIdentity?.openidIssuer) === + normalizeIssuer(expectedIdentity?.openidIssuer) + ); + }), buildOpenIDRefreshParams: jest.fn(() => { const params = {}; if (process.env.OPENID_SCOPE) { @@ -201,6 +226,8 @@ describe('refreshController – OpenID path', () => { _id: 'user-db-id', email: baseClaims.email, openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, password: '$2b$10$hashedpassword', __v: 0, totpSecret: 'encrypted-totp-secret', @@ -251,6 +278,8 @@ describe('refreshController – OpenID path', () => { _id: 'user-db-id', email: baseClaims.email, openidId: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, }); updateUser.mockResolvedValue({}); @@ -296,7 +325,9 @@ describe('refreshController – OpenID path', () => { expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { userId: 'user-db-id', existingRefreshToken: 'stored-refresh', - tenantId: undefined, + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, }); }; @@ -324,6 +355,10 @@ describe('refreshController – OpenID path', () => { idToken: reusableIdToken, refreshToken: 'stored-refresh', lastRefreshedAt: Date.now(), + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, }, }; const user = { @@ -370,6 +405,37 @@ describe('refreshController – OpenID path', () => { expect(debugOutput).not.toContain('session-access-token'); }); + it('falls through to full OpenID refresh when reusable session token identity mismatches', async () => { + setOpenIDReuseCookies(); + req.session = { + openidTokens: { + accessToken: 'session-access-token', + idToken: makeSessionToken(), + refreshToken: 'stored-refresh', + lastRefreshedAt: Date.now(), + appUserId: 'other-user-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, + }, + }; + + await refreshController(req, res); + + expect(getUserById).toHaveBeenCalledWith( + 'user-db-id', + '-password -__v -totpSecret -backupCodes -federatedTokens', + ); + expect(setCloudFrontAuthCookies).not.toHaveBeenCalled(); + expectOpenIDRefreshGrant(); + expect(logger.warn).toHaveBeenCalledWith( + '[refreshController] OpenID session token identity mismatch; forcing refresh', + expect.objectContaining({ + userId: 'user-db-id', + }), + ); + }); + it('falls through to full OpenID refresh when session tokens are expired', async () => { const expiredToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) - 60 }); setOpenIDReuseCookies(); @@ -379,6 +445,10 @@ describe('refreshController – OpenID path', () => { idToken: expiredToken, refreshToken: 'stored-refresh', lastRefreshedAt: Date.now(), + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, }, }; @@ -523,6 +593,10 @@ describe('refreshController – OpenID path', () => { idToken: reusableIdToken, refreshToken: 'stored-refresh', lastRefreshedAt: Date.now(), + appUserId: 'user-db-id', + openidSubject: baseClaims.sub, + tenantId: 'tenant-1', + openidIssuer: baseClaims.iss, }, }; const userDocument = { @@ -775,7 +849,9 @@ describe('refreshController – OpenID path', () => { expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { userId: 'user-db-id', existingRefreshToken: 'bridged-refresh', - tenantId: undefined, + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, }); expect(storeRefreshTokenBridge).toHaveBeenCalledWith({ oldRefreshToken: 'stored-refresh', @@ -873,7 +949,9 @@ describe('refreshController – OpenID path', () => { expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, { userId: 'user-db-id', existingRefreshToken: 'bridged-refresh', - tenantId: undefined, + tenantId: 'tenant-1', + openidSubject: baseClaims.sub, + openidIssuer: baseClaims.iss, }); expect(storeRefreshTokenBridge).toHaveBeenCalledWith({ oldRefreshToken: 'stored-refresh', diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js index ede02febb2..fda6985a33 100644 --- a/api/server/controllers/auth/oauth.js +++ b/api/server/controllers/auth/oauth.js @@ -75,6 +75,8 @@ function createOAuthHandler(redirectUri = domains.client) { setOpenIDAuthTokens(req.user.tokenset, req, res, { userId: req.user._id.toString(), tenantId: req.user.tenantId, + openidSubject: req.user.openidId, + openidIssuer: req.user.openidIssuer, }); } else { await setAuthTokens(req.user._id, res, null, req); diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 94fb1d4526..ad6b0d3839 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -20,6 +20,7 @@ const { shouldUseSecureCookie, setRefreshTokenCookie, setOpenIDMarkerCookies, + createOpenIDSessionIdentity, resolveAppConfigForUser, } = require('@librechat/api'); const { @@ -699,7 +700,9 @@ const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, te if ( 'userId' in optionsOrUserId || 'existingRefreshToken' in optionsOrUserId || - 'tenantId' in optionsOrUserId + 'tenantId' in optionsOrUserId || + 'openidSubject' in optionsOrUserId || + 'openidIssuer' in optionsOrUserId ) { return optionsOrUserId; } @@ -709,6 +712,44 @@ const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, te return { userId: optionsOrUserId, existingRefreshToken, tenantId }; }; +const getOpenIDTokenClaims = (tokenset) => { + if (typeof tokenset?.claims === 'function') { + try { + const claims = tokenset.claims(); + return claims && typeof claims === 'object' ? claims : {}; + } catch (error) { + logger.debug('[setOpenIDAuthTokens] Unable to read tokenset claims', error?.message); + } + } + + if (typeof tokenset?.id_token !== 'string') { + return {}; + } + + const decoded = jwt.decode(tokenset.id_token); + return decoded && typeof decoded === 'object' ? decoded : {}; +}; + +const getStringClaim = (claims, claim) => { + const value = claims?.[claim]; + return typeof value === 'string' && value ? value : undefined; +}; + +const applyOpenIDSessionIdentity = (sessionOpenidTokens, identity) => { + if (identity.appUserId) { + sessionOpenidTokens.appUserId = identity.appUserId; + } + if (identity.openidSubject) { + sessionOpenidTokens.openidSubject = identity.openidSubject; + } + if (identity.tenantId) { + sessionOpenidTokens.tenantId = identity.tenantId; + } + if (identity.openidIssuer) { + sessionOpenidTokens.openidIssuer = identity.openidIssuer; + } +}; + /** * @function setOpenIDAuthTokens * Set OpenID Authentication Tokens @@ -723,6 +764,8 @@ const resolveOpenIDAuthTokenOptions = (optionsOrUserId, existingRefreshToken, te * @param {string} [options.userId] - Optional MongoDB user ID for image path validation * @param {string} [options.existingRefreshToken] - Optional existing refresh token to preserve * @param {string} [options.tenantId] - Optional tenant identifier for CloudFront cookie scoping + * @param {string} [options.openidSubject] - Optional OpenID subject bound to the session tokens + * @param {string} [options.openidIssuer] - Optional OpenID issuer bound to the session tokens * @returns {String} - id_token (preferred) or access_token as the app auth token */ const setOpenIDAuthTokens = ( @@ -734,11 +777,8 @@ const setOpenIDAuthTokens = ( tenantIdArg, ) => { try { - const { userId, existingRefreshToken, tenantId } = resolveOpenIDAuthTokenOptions( - optionsOrUserId, - existingRefreshTokenArg, - tenantIdArg, - ); + const { userId, existingRefreshToken, tenantId, openidSubject, openidIssuer } = + resolveOpenIDAuthTokenOptions(optionsOrUserId, existingRefreshTokenArg, tenantIdArg); if (!tokenset) { logger.error('[setOpenIDAuthTokens] No tokenset found in request'); @@ -774,6 +814,14 @@ const setOpenIDAuthTokens = ( getUnexpiredOpenIDSessionIdToken(sessionIdToken) || tokenset.access_token; const logoutIdToken = tokenset.id_token || sessionIdToken; + const claims = getOpenIDTokenClaims(tokenset); + const sessionIdentity = createOpenIDSessionIdentity({ + user: req?.user, + userId, + openidSubject: openidSubject ?? getStringClaim(claims, 'sub'), + tenantId, + openidIssuer: openidIssuer ?? getStringClaim(claims, 'iss'), + }); /** * Always set refresh token cookie so it survives express session expiry. @@ -796,6 +844,7 @@ const setOpenIDAuthTokens = ( expiresAt: expirationDate.getTime(), lastRefreshedAt: Date.now(), }; + applyOpenIDSessionIdentity(sessionOpenidTokens, sessionIdentity); /** * Capture the access-token's own expiry (unix seconds) when the IdP * advertises one. Lets downstream consumers — notably the OBO inline- diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index 7d0d5b5414..c29abf7f47 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -42,6 +42,27 @@ jest.mock('@librechat/api', () => { } }), resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), + createOpenIDSessionIdentity: jest.fn( + ({ user, userId, openidSubject, tenantId, openidIssuer }) => { + 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(/\/\.well-known\/openid-configuration$/, '') + .replace(/\/+$/, ''); + return { + appUserId: normalize(userId) ?? normalize(user?._id) ?? normalize(user?.id), + openidSubject: normalize(openidSubject) ?? normalize(user?.openidId), + tenantId: normalize(tenantId) ?? normalize(user?.tenantId), + openidIssuer: normalizeIssuer(openidIssuer) ?? normalizeIssuer(user?.openidIssuer), + }; + }, + ), setCloudFrontCookies: jest.fn(() => true), getCloudFrontConfig: jest.fn(() => ({ domain: 'https://cdn.example.com', @@ -284,6 +305,32 @@ describe('setOpenIDAuthTokens', () => { expect(req.session.openidTokens.lastRefreshedAt).toEqual(expect.any(Number)); }); + it('should bind session tokens to the OpenID user identity', () => { + const tokenset = { + id_token: 'the-id-token', + access_token: 'the-access-token', + refresh_token: 'the-refresh-token', + }; + const req = mockRequest(); + const res = mockResponse(); + + setOpenIDAuthTokens(tokenset, req, res, { + userId: 'user-123', + openidSubject: 'oidc-sub-123', + tenantId: 'tenantA', + openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration', + }); + + expect(req.session.openidTokens).toEqual( + expect.objectContaining({ + appUserId: 'user-123', + openidSubject: 'oidc-sub-123', + tenantId: 'tenantA', + openidIssuer: 'https://issuer.example.com', + }), + ); + }); + /** * Codex Finding 5: persist the access-token's expiry (unix seconds) so the * first OBO call after login or SPA refresh can reuse a still-valid OPAQUE diff --git a/api/server/services/OpenIDSessionRefresh.js b/api/server/services/OpenIDSessionRefresh.js index 02ea1a7c11..c48e5a34c7 100644 --- a/api/server/services/OpenIDSessionRefresh.js +++ b/api/server/services/OpenIDSessionRefresh.js @@ -6,6 +6,7 @@ const { isEnabled, math, createAuthIdentityContext, + isOpenIDSessionIdentityMatch, createOpenIDRefreshIdentityTuple, createRefreshTokenBridgeIdentity, serializeAuthIdentityTuple, @@ -43,6 +44,10 @@ const { * the browser cookie. * @property {number} [expiresAt] — SESSION cookie expiry (ms). * @property {number} [lastRefreshedAt] — wall-clock ms of the last server-side rotation. + * @property {string} [appUserId] — LibreChat user id bound to these session tokens. + * @property {string} [openidSubject] — OpenID `sub` bound to these session tokens. + * @property {string} [tenantId] — tenant bound to these session tokens. + * @property {string} [openidIssuer] — normalized issuer bound to these session tokens. * @property {number} [accessTokenExpiresAt] — access token expiry (unix seconds), captured * from the IdP `tokenset.expires_in` so opaque * access tokens can still be reused without @@ -138,6 +143,47 @@ function hashKeyForLogs(key) { return crypto.createHash('sha256').update(key).digest('hex').slice(0, 12); } +function resolveExpectedOpenIDSessionIdentity(req, user, identityContext) { + if (!identityContext) { + return createAuthIdentityContext({ + user, + requestUser: req?.user, + }); + } + + return createAuthIdentityContext({ + user: { + id: identityContext.appUserId, + openidId: identityContext.openidSubject, + tenantId: identityContext.tenantId, + openidIssuer: identityContext.openidIssuer, + }, + requestUser: user ?? req?.user, + tenantId: identityContext.tenantId, + openidIssuer: identityContext.openidIssuer, + }); +} + +function assertOpenIDSessionIdentityMatch(req, user, identityContext) { + const sessionTokens = req?.session?.openidTokens; + if (!sessionTokens) { + return; + } + + const expectedIdentity = resolveExpectedOpenIDSessionIdentity(req, user, identityContext); + if (isOpenIDSessionIdentityMatch(sessionTokens, expectedIdentity)) { + return; + } + + logger.warn('[OpenIDSessionRefresh] OpenID session token identity mismatch; refusing reuse', { + userId: expectedIdentity.appUserId, + has_session_user_id: Boolean(sessionTokens.appUserId), + has_session_subject: Boolean(sessionTokens.openidSubject), + has_session_issuer: Boolean(sessionTokens.openidIssuer), + }); + throw new Error('OpenID session token identity mismatch'); +} + function decodeJwtExp(token) { if (typeof token !== 'string' || token.length === 0) { return null; @@ -625,6 +671,7 @@ async function refreshOrReuseSession(req, res, user, tokenPreference, identityCo * returned `expires_at`. OBO callers pass 'access_token'. */ async function refreshOpenIDSession(req, res, user, tokenPreference, identityContext) { + assertOpenIDSessionIdentityMatch(req, user, identityContext); const key = getSingleFlightKey(req, user, identityContext); if (!key) { return refreshOrReuseSession(req, res, user, tokenPreference, identityContext); @@ -685,6 +732,7 @@ function isOIDCRefreshApplicable(user) { * Closure contract (matches `UpstreamTokenProvider` in obo.ts): * - resolves to non-null OIDCTokens when fresh tokens are available. * - resolves to null when refresh is not applicable / no session. + * - rejects when session identity metadata does not match the current user. * - rejects when refresh was attempted and rejected by the IdP. The MCP * layer wraps the rejection as `session_refresh_failed`. * diff --git a/api/server/services/OpenIDSessionRefresh.spec.js b/api/server/services/OpenIDSessionRefresh.spec.js index dbf400467e..7e1c1f178e 100644 --- a/api/server/services/OpenIDSessionRefresh.spec.js +++ b/api/server/services/OpenIDSessionRefresh.spec.js @@ -23,6 +23,36 @@ jest.mock('@librechat/api', () => ({ 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 ?? @@ -117,13 +147,25 @@ const SECRET = 'test-secret'; const makeJwt = (exp) => jwt.sign({ sub: 'user-123', exp }, SECRET); -const buildReq = (sessionTokens, sessionId = 'session-A') => ({ +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: sessionTokens }, + sessionTokens === undefined + ? {} + : { openidTokens: bindIdentity ? withSessionIdentity(sessionTokens) : sessionTokens }, ), }); @@ -136,6 +178,8 @@ const buildRes = ({ headersSent = false } = {}) => ({ const makeOpenIdUser = (overrides = {}) => ({ id: 'local-id-1', openidId: 'oidc-sub-123', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', provider: 'openid', ...overrides, }); @@ -247,6 +291,37 @@ describe('OpenIDSessionRefresh', () => { }); }); + 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 @@ -530,8 +605,8 @@ describe('OpenIDSessionRefresh', () => { oldRefreshToken: 'rt-browser-stale', newRefreshToken: 'rt-session-current', userId: 'local-id-1', - tenantId: undefined, - openidIssuer: undefined, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', }); expect(req.session.openidTokens.browserRefreshToken).toBe('rt-browser-stale'); }); @@ -544,7 +619,10 @@ describe('OpenIDSessionRefresh', () => { refresh_token: 'rt-rotated', expires_in: 3600, }); - const req = buildReq(buildExpiredSession('rt-old')); + const req = buildReq({ + ...buildExpiredSession('rt-old'), + appUserId: 'mongo-id', + }); const res = buildRes({ headersSent: true }); await refreshOpenIDSession( @@ -639,8 +717,8 @@ describe('OpenIDSessionRefresh', () => { oldRefreshToken: 'rt-browser-cookie', newRefreshToken: 'rt-second-rotation', userId: 'local-id-1', - tenantId: undefined, - openidIssuer: undefined, + 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'); @@ -665,8 +743,8 @@ describe('OpenIDSessionRefresh', () => { oldRefreshToken: 'rt-old', newRefreshToken: 'rt-rotated', userId: 'local-id-1', - tenantId: undefined, - openidIssuer: undefined, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', }); }); @@ -688,8 +766,8 @@ describe('OpenIDSessionRefresh', () => { oldRefreshToken: 'rt-old', newRefreshToken: 'rt-rotated', userId: 'local-id-1', - tenantId: undefined, - openidIssuer: undefined, + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', }); }); diff --git a/packages/api/src/utils/identity.spec.ts b/packages/api/src/utils/identity.spec.ts index 6510d89364..761489351b 100644 --- a/packages/api/src/utils/identity.spec.ts +++ b/packages/api/src/utils/identity.spec.ts @@ -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({ diff --git a/packages/api/src/utils/identity.ts b/packages/api/src/utils/identity.ts index 185fbf5e81..f40928b332 100644 --- a/packages/api/src/utils/identity.ts +++ b/packages/api/src/utils/identity.ts @@ -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,