diff --git a/graphql/server/src/middleware/__tests__/cookie.test.ts b/graphql/server/src/middleware/__tests__/cookie.test.ts index 034d268d6..1e8348d7a 100644 --- a/graphql/server/src/middleware/__tests__/cookie.test.ts +++ b/graphql/server/src/middleware/__tests__/cookie.test.ts @@ -57,6 +57,23 @@ describe('cookie utilities', () => { expect(config.maxAge).toBe(2592000); }); + it('uses PostgreSQL interval cookieMaxAge values', () => { + const authSettings: AuthSettings = { + cookieMaxAge: { days: 14 }, + }; + const config = getSessionCookieConfig(authSettings); + expect(config.maxAge).toBe(14 * 24 * 60 * 60); + }); + + it('uses PostgreSQL interval rememberMeDuration when rememberMe is true', () => { + const authSettings: AuthSettings = { + cookieMaxAge: { hours: 1 }, + rememberMeDuration: { days: 30 }, + }; + const config = getSessionCookieConfig(authSettings, true); + expect(config.maxAge).toBe(30 * 24 * 60 * 60); + }); + it('uses cookieMaxAge when rememberMe is false', () => { const authSettings: AuthSettings = { cookieMaxAge: '3600', @@ -73,6 +90,15 @@ describe('cookie utilities', () => { const config = getSessionCookieConfig(authSettings, true); expect(config.maxAge).toBe(7200); }); + + it('falls back to cookieMaxAge when rememberMeDuration is invalid', () => { + const authSettings: AuthSettings = { + cookieMaxAge: { hours: 2 }, + rememberMeDuration: 'not-an-interval', + }; + const config = getSessionCookieConfig(authSettings, true); + expect(config.maxAge).toBe(2 * 60 * 60); + }); }); describe('getDeviceTokenCookieConfig', () => { diff --git a/graphql/server/src/middleware/cookie.ts b/graphql/server/src/middleware/cookie.ts index bb9244639..c4fcf1c0d 100644 --- a/graphql/server/src/middleware/cookie.ts +++ b/graphql/server/src/middleware/cookie.ts @@ -1,5 +1,6 @@ import type { Request, Response } from 'express'; import type { AuthSettings } from '../types'; +import { pgIntervalToSeconds } from '../utils/pg-interval'; export const SESSION_COOKIE_NAME = 'constructive_session'; export const DEVICE_TOKEN_COOKIE_NAME = 'constructive_device_token'; @@ -24,12 +25,15 @@ export const getSessionCookieConfig = ( ): CookieConfig => { const DEFAULT_MAX_AGE = 86400; // 24 hours let maxAge = DEFAULT_MAX_AGE; - if (rememberMe && authSettings?.rememberMeDuration) { - const parsed = parseInt(authSettings.rememberMeDuration, 10); - if (!isNaN(parsed)) maxAge = parsed; - } else if (authSettings?.cookieMaxAge) { - const parsed = parseInt(authSettings.cookieMaxAge, 10); - if (!isNaN(parsed)) maxAge = parsed; + + const configuredMaxAge = + rememberMe + ? pgIntervalToSeconds(authSettings?.rememberMeDuration) ?? + pgIntervalToSeconds(authSettings?.cookieMaxAge) + : pgIntervalToSeconds(authSettings?.cookieMaxAge); + + if (configuredMaxAge !== null) { + maxAge = configuredMaxAge; } return { diff --git a/graphql/server/src/types.ts b/graphql/server/src/types.ts index 474ad0fc2..12435f624 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -11,6 +11,7 @@ export type { CorsModuleData, DatabaseSettings, GenericModuleData, + PgInterval, PubkeyChallengeSettings, PublicKeyChallengeData, RlsModule, diff --git a/graphql/server/src/utils/__tests__/pg-interval.test.ts b/graphql/server/src/utils/__tests__/pg-interval.test.ts new file mode 100644 index 000000000..60cb6170d --- /dev/null +++ b/graphql/server/src/utils/__tests__/pg-interval.test.ts @@ -0,0 +1,35 @@ +import { + pgIntervalToMilliseconds, + pgIntervalToSeconds, +} from '../pg-interval'; + +describe('pg interval utilities', () => { + describe('pgIntervalToSeconds', () => { + it('treats numeric strings as seconds', () => { + expect(pgIntervalToSeconds('3600')).toBe(3600); + }); + + it('converts PostgreSQL interval objects to seconds', () => { + expect(pgIntervalToSeconds({ days: 1, hours: 2, minutes: 3, seconds: 4 })).toBe( + 93784, + ); + }); + + it('returns null for missing or invalid values', () => { + expect(pgIntervalToSeconds(null)).toBeNull(); + expect(pgIntervalToSeconds(undefined)).toBeNull(); + expect(pgIntervalToSeconds('not-an-interval')).toBeNull(); + }); + + it('preserves zero-second values', () => { + expect(pgIntervalToSeconds('0')).toBe(0); + expect(pgIntervalToSeconds({ seconds: 0 })).toBe(0); + }); + }); + + describe('pgIntervalToMilliseconds', () => { + it('converts parsed seconds to milliseconds', () => { + expect(pgIntervalToMilliseconds({ minutes: 10 })).toBe(10 * 60 * 1000); + }); + }); +}); diff --git a/graphql/server/src/utils/pg-interval.ts b/graphql/server/src/utils/pg-interval.ts new file mode 100644 index 000000000..6e63b6754 --- /dev/null +++ b/graphql/server/src/utils/pg-interval.ts @@ -0,0 +1,36 @@ +import type { PgInterval } from '@constructive-io/express-context'; + +/** + * Convert a PostgreSQL interval value from auth settings into seconds. + * + * Numeric string values are treated as seconds, matching the existing auth + * settings cookie parser behavior. + */ +export function pgIntervalToSeconds( + interval: string | PgInterval | null | undefined, +): number | null { + if (!interval) return null; + + if (typeof interval === 'string') { + const seconds = parseInt(interval, 10); + return Number.isNaN(seconds) ? null : seconds; + } + + let totalSeconds = 0; + if (interval.years) totalSeconds += interval.years * 365 * 24 * 60 * 60; + if (interval.months) totalSeconds += interval.months * 30 * 24 * 60 * 60; + if (interval.days) totalSeconds += interval.days * 24 * 60 * 60; + if (interval.hours) totalSeconds += interval.hours * 60 * 60; + if (interval.minutes) totalSeconds += interval.minutes * 60; + if (interval.seconds) totalSeconds += interval.seconds; + if (interval.milliseconds) totalSeconds += interval.milliseconds / 1000; + + return totalSeconds; +} + +export function pgIntervalToMilliseconds( + interval: string | PgInterval | null | undefined, +): number | null { + const seconds = pgIntervalToSeconds(interval); + return seconds === null ? null : seconds * 1000; +} diff --git a/packages/express-context/src/index.ts b/packages/express-context/src/index.ts index 02a6465ed..4633099a8 100644 --- a/packages/express-context/src/index.ts +++ b/packages/express-context/src/index.ts @@ -53,6 +53,7 @@ export type { InferenceLogConfig, LlmConfig, PublicKeyChallengeData, + PgInterval, PubkeyChallengeSettings, RlsModule, WebauthnSettings, diff --git a/packages/express-context/src/loaders/auth-settings.ts b/packages/express-context/src/loaders/auth-settings.ts index ff5fcfb93..d2e5e797e 100644 --- a/packages/express-context/src/loaders/auth-settings.ts +++ b/packages/express-context/src/loaders/auth-settings.ts @@ -10,7 +10,7 @@ * database rather than the services database. */ -import type { AuthSettings } from '../types'; +import type { AuthSettings, PgInterval } from '../types'; import type { LoaderContext, ModuleLoader } from './types'; import { createModuleLoader } from './create-loader'; @@ -45,9 +45,9 @@ interface AuthSettingsRow { cookie_samesite: string; cookie_domain: string | null; cookie_httponly: boolean; - cookie_max_age: string | null; + cookie_max_age: string | PgInterval | null; cookie_path: string; - remember_me_duration: string | null; + remember_me_duration: string | PgInterval | null; enable_captcha: boolean; captcha_site_key: string | null; } diff --git a/packages/express-context/src/types.ts b/packages/express-context/src/types.ts index 39e0a79c5..d92131693 100644 --- a/packages/express-context/src/types.ts +++ b/packages/express-context/src/types.ts @@ -88,14 +88,24 @@ export interface RlsModule { currentUserAgent: string; } +export interface PgInterval { + years?: number; + months?: number; + days?: number; + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; +} + export interface AuthSettings { cookieSecure?: boolean; cookieSamesite?: string; cookieDomain?: string | null; cookieHttponly?: boolean; - cookieMaxAge?: string | null; + cookieMaxAge?: string | PgInterval | null; cookiePath?: string; - rememberMeDuration?: string | null; + rememberMeDuration?: string | PgInterval | null; enableCaptcha?: boolean; captchaSiteKey?: string | null; }