Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/native-session-lifetime-core-2.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions packages/clerk-js/src/core/resources/SessionWithActivities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
27 changes: 27 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;

Expand Down
21 changes: 20 additions & 1 deletion packages/clerk-js/src/utils/__tests__/date.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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());
});
});
});
5 changes: 5 additions & 0 deletions packages/clerk-js/src/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading