diff --git a/.changeset/native-session-lifetime-core-2.md b/.changeset/native-session-lifetime-core-2.md new file mode 100644 index 00000000000..e5265cb1b36 --- /dev/null +++ b/.changeset/native-session-lifetime-core-2.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Keep a session's `expireAt` and `abandonAt` at Unix epoch zero when the session has no maximum lifetime, instead of replacing them with the current time. Sessions restored from a client snapshot keep these deadlines too. diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index bcd93eceffe..dd61d292aca 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -28,7 +28,7 @@ import type { } from '@clerk/shared/types'; import { isWebAuthnSupported as isWebAuthnSupportedOnWindow } from '@clerk/shared/webauthn'; -import { unixEpochToDate } from '@/utils/date'; +import { unixEpochToDate, unixEpochToSessionDeadline } from '@/utils/date'; import { debugLogger } from '@/utils/debug'; import { convertJSONToPublicKeyRequestOptions, @@ -309,8 +309,8 @@ export class Session extends BaseResource implements SessionResource { this.id = data.id; this.status = data.status; - this.expireAt = unixEpochToDate(data.expire_at); - this.abandonAt = unixEpochToDate(data.abandon_at); + this.expireAt = unixEpochToSessionDeadline(data.expire_at); + this.abandonAt = unixEpochToSessionDeadline(data.abandon_at); this.factorVerificationAge = data.factor_verification_age; this.lastActiveAt = unixEpochToDate(data.last_active_at || undefined); this.lastActiveOrganizationId = data.last_active_organization_id; diff --git a/packages/clerk-js/src/core/resources/SessionWithActivities.ts b/packages/clerk-js/src/core/resources/SessionWithActivities.ts index 23c8ababed2..edafb16f4f0 100644 --- a/packages/clerk-js/src/core/resources/SessionWithActivities.ts +++ b/packages/clerk-js/src/core/resources/SessionWithActivities.ts @@ -6,7 +6,7 @@ import type { SessionWithActivitiesResource, } from '@clerk/shared/types'; -import { unixEpochToDate } from '../../utils/date'; +import { unixEpochToDate, unixEpochToSessionDeadline } from '../../utils/date'; import { BaseResource } from './internal'; const mapSessionActivityJSONToSessionActivity = (data: SessionActivityJSON): SessionActivity => ({ @@ -65,8 +65,8 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc this.id = data.id; this.status = data.status; - this.expireAt = unixEpochToDate(data.expire_at); - this.abandonAt = unixEpochToDate(data.abandon_at); + this.expireAt = unixEpochToSessionDeadline(data.expire_at); + this.abandonAt = unixEpochToSessionDeadline(data.abandon_at); this.lastActiveAt = unixEpochToDate(data.last_active_at || undefined); this.latestActivity = mapSessionActivityJSONToSessionActivity(data.latest_activity ?? ({} as SessionActivityJSON)); this.actor = data.actor; diff --git a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts index dd024d128ea..1f86440b938 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Client.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Client.test.ts @@ -304,6 +304,31 @@ describe('Client Singleton', () => { }); }); + it('restores a session without a maximum lifetime from a client snapshot', () => { + const user = createUser({ first_name: 'John', last_name: 'Doe', id: 'user_1' }); + const clientJSON: ClientJSON = { + object: 'client', + id: 'test_id', + status: 'active', + last_active_session_id: 'session_1', + sign_in: null, + sign_up: null, + sessions: [{ ...createSession({ id: 'session_1', status: 'active' }, user), expire_at: 0, abandon_at: 0 }], + created_at: Date.now() - 1000, + updated_at: Date.now(), + } as any; + + // @ts-expect-error We cannot mess with the singleton when tests are running in parallel + const snapshot = new Client(clientJSON).__internal_toSnapshot(); + // @ts-expect-error We cannot mess with the singleton when tests are running in parallel + const [restored] = new Client(snapshot).sessions; + + expect(snapshot.sessions[0]).toMatchObject({ expire_at: 0, abandon_at: 0 }); + expect(restored.expireAt.getTime()).toBe(0); + expect(restored.abandonAt.getTime()).toBe(0); + expect(restored.status).toBe('active'); + }); + it('__internal_toSnapshot()', () => { const clientJSON = { object: 'client', diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index a549bbabfb1..b45c8052ac8 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -8,6 +8,7 @@ import { eventBus } from '../../events'; import { createFapiClient } from '../../fapiClient'; import { SessionTokenCache } from '../../tokenCache'; import { BaseResource, Organization, Session } from '../internal'; +import { SessionWithActivities } from '../SessionWithActivities'; const baseFapiClientOptions = { frontendApi: 'clerk.example.com', @@ -31,6 +32,32 @@ describe('Session', () => { vi.useRealTimers(); }); + it.each([ + { expire_at: 0, abandon_at: 0 }, + { expire_at: 2000000000000, abandon_at: 2000000000000 }, + { expire_at: 0, abandon_at: 2000000000000 }, + { expire_at: 2000000000000, abandon_at: 0 }, + ])('preserves session deadlines $expire_at and $abandon_at through snapshots', deadlines => { + const session = new Session({ + id: 'session_lifetime', + status: 'active', + ...deadlines, + public_user_data: { user_id: 'user_1' }, + } as SessionJSON); + expect(session.expireAt.getTime()).toBe(deadlines.expire_at); + expect(session.abandonAt.getTime()).toBe(deadlines.abandon_at); + + const snapshot = session.__internal_toSnapshot(); + expect(snapshot).toMatchObject(deadlines); + const restored = new Session(snapshot); + expect(restored.expireAt.getTime()).toBe(deadlines.expire_at); + expect(restored.abandonAt.getTime()).toBe(deadlines.abandon_at); + + const withActivities = new SessionWithActivities({ id: session.id, ...deadlines } as any, '/me/sessions'); + expect(withActivities.expireAt.getTime()).toBe(deadlines.expire_at); + expect(withActivities.abandonAt.getTime()).toBe(deadlines.abandon_at); + }); + describe('getToken()', () => { let dispatchSpy; diff --git a/packages/clerk-js/src/utils/__tests__/date.test.ts b/packages/clerk-js/src/utils/__tests__/date.test.ts index 3c1ff3a3ca7..a2311dbe636 100644 --- a/packages/clerk-js/src/utils/__tests__/date.test.ts +++ b/packages/clerk-js/src/utils/__tests__/date.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { unixEpochToDate } from '../date'; +import { unixEpochToDate, unixEpochToSessionDeadline } from '../date'; describe('date utilities', () => { beforeAll(() => { @@ -28,5 +28,24 @@ describe('date utilities', () => { const date = unixEpochToDate('a very strange value' as any); expect(date.getTime()).toBe(Date.now()); }); + + it('fallbacks to current date for a zero epoch', function () { + expect(unixEpochToDate(0).getTime()).toBe(Date.now()); + }); + }); + + describe('unixEpochToSessionDeadline', () => { + it('preserves an unset session deadline', () => { + expect(unixEpochToSessionDeadline(0).getTime()).toBe(0); + }); + + it('converts a finite session deadline to Date', () => { + expect(unixEpochToSessionDeadline(2000000000000).getTime()).toBe(2000000000000); + }); + + it('fallbacks to current date for undefined and invalid values', () => { + expect(unixEpochToSessionDeadline(undefined).getTime()).toBe(Date.now()); + expect(unixEpochToSessionDeadline('a very strange value' as any).getTime()).toBe(Date.now()); + }); }); }); diff --git a/packages/clerk-js/src/utils/date.ts b/packages/clerk-js/src/utils/date.ts index 9541ca70bab..3b0233e7d03 100644 --- a/packages/clerk-js/src/utils/date.ts +++ b/packages/clerk-js/src/utils/date.ts @@ -6,3 +6,8 @@ export function unixEpochToDate(epochInSeconds?: number): Date { const date = new Date(epochInSeconds || new Date()); return isValidDate(date) ? date : new Date(); } + +// Session deadlines use Unix epoch zero for "no deadline", so 0 must not fall back to the current time. +export function unixEpochToSessionDeadline(epochInMilliseconds?: number): Date { + return epochInMilliseconds === 0 ? new Date(0) : unixEpochToDate(epochInMilliseconds); +}