🛠️ fix: type-safety polish on OBO data layer

Replace refresh token bridge query/update Record<string, unknown> usage
with typed Mongoose FilterQuery and UpdateQuery definitions.

Harden OpenID marker cookie JWT expiry handling by converting refresh
expiry milliseconds to integer seconds and rejecting invalid or
non-positive durations.

Add focused CSRF tests for fractional refresh expiry values and invalid
expiry configuration.
This commit is contained in:
J.C. Bartle 2026-06-28 16:31:29 -04:00
parent 5a32d9eb3d
commit cc74590864
3 changed files with 47 additions and 13 deletions

View file

@ -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');
},
);
});

View file

@ -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);
}

View file

@ -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<string, unknown> {
}: RefreshTokenBridgeQuery): FilterQuery<IRefreshTokenBridge> {
return {
oldRefreshTokenHash,
userId,
@ -30,14 +30,11 @@ export function createRefreshTokenBridgeMethods(mongoose: typeof import('mongoos
try {
const RefreshTokenBridge = mongoose.models.RefreshTokenBridge as Model<IRefreshTokenBridge>;
const filter = bridgeFilter(bridgeData);
const update: {
$set: Record<string, unknown>;
$setOnInsert: Record<string, unknown>;
$unset?: Record<string, string>;
} = {
const update: UpdateQuery<IRefreshTokenBridge> = {
$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,