diff --git a/packages/api/src/oauth/csrf.spec.ts b/packages/api/src/oauth/csrf.spec.ts index 143dda61c8..592861d4ed 100644 --- a/packages/api/src/oauth/csrf.spec.ts +++ b/packages/api/src/oauth/csrf.spec.ts @@ -241,4 +241,40 @@ describe('setOpenIDMarkerCookies', () => { expect.objectContaining({ expires }), ); }); + + it('uses integer seconds for fractional refresh expiry durations', () => { + const res = { cookie: jest.fn() } as unknown as import('express').Response; + const expires = new Date(Date.now() + 604800999); + + setOpenIDMarkerCookies(res, { + userId: 'user-123', + expires, + refreshExpiryMs: 604800999, + }); + + const signedUserId = (res.cookie as jest.Mock).mock.calls.find( + ([name]) => name === OPENID_USER_ID_COOKIE, + )?.[1]; + const payload = jwt.verify(signedUserId, 'marker-secret') as jwt.JwtPayload; + if (typeof payload.exp !== 'number' || typeof payload.iat !== 'number') { + throw new Error('Expected signed marker JWT to include numeric exp and iat'); + } + expect(payload.exp - payload.iat).toBe(604800); + }); + + it.each([0, -1000, 999, Number.NaN, Number.POSITIVE_INFINITY])( + 'throws when the refresh expiry duration is invalid: %p', + (refreshExpiryMs) => { + const res = { cookie: jest.fn() } as unknown as import('express').Response; + const expires = new Date(Date.now() + 999); + + expect(() => + setOpenIDMarkerCookies(res, { + userId: 'user-123', + expires, + refreshExpiryMs, + }), + ).toThrow('refreshExpiryMs must be a positive duration for OpenID marker cookies'); + }, + ); }); diff --git a/packages/api/src/oauth/csrf.ts b/packages/api/src/oauth/csrf.ts index ce61882496..e21e587b51 100644 --- a/packages/api/src/oauth/csrf.ts +++ b/packages/api/src/oauth/csrf.ts @@ -104,8 +104,13 @@ export function setOpenIDMarkerCookies( throw new Error('JWT_REFRESH_SECRET is required for OpenID marker cookies'); } + const refreshExpirySeconds = Math.floor(refreshExpiryMs / 1000); + if (!Number.isFinite(refreshExpirySeconds) || refreshExpirySeconds <= 0) { + throw new Error('refreshExpiryMs must be a positive duration for OpenID marker cookies'); + } + const signedUserId = jwt.sign({ id: userId }, secret, { - expiresIn: refreshExpiryMs / 1000, + expiresIn: refreshExpirySeconds, }); res.cookie(OPENID_USER_ID_COOKIE, signedUserId, cookieOptions); } diff --git a/packages/data-schemas/src/methods/refreshTokenBridge.ts b/packages/data-schemas/src/methods/refreshTokenBridge.ts index 04e1251f6b..b9fbab992a 100644 --- a/packages/data-schemas/src/methods/refreshTokenBridge.ts +++ b/packages/data-schemas/src/methods/refreshTokenBridge.ts @@ -1,4 +1,4 @@ -import type { Model } from 'mongoose'; +import type { FilterQuery, Model, UpdateQuery } from 'mongoose'; import type { IRefreshTokenBridge, RefreshTokenBridgeCreateData, @@ -10,7 +10,7 @@ function bridgeFilter({ oldRefreshTokenHash, userId, tenantId, -}: RefreshTokenBridgeQuery): Record { +}: RefreshTokenBridgeQuery): FilterQuery { return { oldRefreshTokenHash, userId, @@ -30,14 +30,11 @@ export function createRefreshTokenBridgeMethods(mongoose: typeof import('mongoos try { const RefreshTokenBridge = mongoose.models.RefreshTokenBridge as Model; const filter = bridgeFilter(bridgeData); - const update: { - $set: Record; - $setOnInsert: Record; - $unset?: Record; - } = { + const update: UpdateQuery = { $set: { encryptedNewRefreshToken: bridgeData.encryptedNewRefreshToken, expiresAt: bridgeData.expiresAt, + ...(bridgeData.openidIssuer != null && { openidIssuer: bridgeData.openidIssuer }), }, $setOnInsert: { oldRefreshTokenHash: bridgeData.oldRefreshTokenHash, @@ -45,12 +42,8 @@ export function createRefreshTokenBridgeMethods(mongoose: typeof import('mongoos ...(bridgeData.tenantId != null && { tenantId: bridgeData.tenantId }), createdAt: new Date(), }, + ...(bridgeData.openidIssuer == null && { $unset: { openidIssuer: '' } }), }; - if (bridgeData.openidIssuer != null) { - update.$set.openidIssuer = bridgeData.openidIssuer; - } else { - update.$unset = { openidIssuer: '' }; - } return await RefreshTokenBridge.findOneAndUpdate(filter, update, { upsert: true, new: true,