Skip to content
6 changes: 6 additions & 0 deletions .changeset/native-session-lifetime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Add `hasMaximumLifetime` to `SessionResource` and `SessionWithActivitiesResource`. It is `false` for sessions without a maximum lifetime, whose `expireAt` and `abandonAt` now stay at Unix epoch zero instead of being replaced with the current time.
8 changes: 6 additions & 2 deletions packages/backend/src/api/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,13 @@ export class Session {
readonly status: string,
/** The time the session was last active on the [`Client`](https://clerk.com/docs/reference/backend/types/backend-client). */
readonly lastActiveAt: number,
/** The Unix timestamp when the `Session` will expire. */
/**
* The Unix timestamp when the `Session` will expire. For an indefinite native session, this is `0`, meaning no expiration deadline. Check `status` to determine whether the session is active.
*/
readonly expireAt: number,
/** The Unix timestamp when the `Session` will be abandoned. */
/**
* The Unix timestamp when the `Session` will be abandoned. For an indefinite native session, this is `0`, meaning no abandonment deadline.
*/
readonly abandonAt: number,
/** The Unix timestamp when the `Session` was first created. */
readonly createdAt: number,
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ export class Session extends BaseResource implements SessionResource {
return !!resource && resource instanceof Session;
}

get hasMaximumLifetime(): boolean {
return this.expireAt.getTime() > 0;
}

constructor(data: SessionJSON | SessionJSONSnapshot) {
super();

Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/resources/SessionWithActivities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc
latestActivity!: SessionActivity;
actor!: ActClaim | null;

get hasMaximumLifetime(): boolean {
return this.expireAt.getTime() > 0;
}

constructor(data: SessionWithActivitiesJSON, pathRoot: string) {
super();
this.pathRoot = pathRoot;
Expand Down
26 changes: 26 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 @@ -193,6 +193,32 @@ describe('Client Singleton', () => {
expect(client.signIn.status).toBe('needs_second_factor');
});

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.hasMaximumLifetime).toBe(false);
expect(restored.status).toBe('active');
});

it('replaces sign up and sign in identity when fromJSON receives new ids', () => {
const user = createUser({ first_name: 'John', last_name: 'Doe', id: 'user_1' });
const session = createSession({ id: 'session_1' }, user);
Expand Down
23 changes: 23 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 @@ -16,6 +16,7 @@ import { eventBus } from '../../events';
import { createFapiClient } from '../../fapiClient';
import { SessionTokenCache } from '../../tokenCache';
import { BaseResource, Client, Organization, Session } from '../internal';
import { SessionWithActivities } from '../SessionWithActivities';

const baseFapiClientOptions = {
frontendApi: 'clerk.example.com',
Expand All @@ -40,6 +41,28 @@ describe('Session', () => {
vi.useRealTimers();
});

it.each([0, 2000000000000])('preserves a session deadline of %s through snapshots', deadline => {
const session = new Session({
id: 'session_lifetime',
status: 'active',
expire_at: deadline,
abandon_at: deadline,
public_user_data: { user_id: 'user_1' },
} as SessionJSON);
expect(session.expireAt.getTime()).toBe(deadline);
expect(session.abandonAt.getTime()).toBe(deadline);
expect(session.hasMaximumLifetime).toBe(deadline > 0);
const restored = new Session(session.__internal_toSnapshot());
expect(restored.expireAt.getTime()).toBe(deadline);
expect(restored.hasMaximumLifetime).toBe(deadline > 0);
const withActivities = new SessionWithActivities(
{ id: session.id, expire_at: deadline, abandon_at: deadline } as any,
'/me/sessions',
);
expect(withActivities.hasMaximumLifetime).toBe(deadline > 0);
expect(withActivities.abandonAt.getTime()).toBe(deadline);
});

describe('getToken()', () => {
let dispatchSpy;

Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/utils/__tests__/date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ describe('date utilities', () => {
});

describe('unixEpochToDate', () => {
it('preserves an unset session deadline', () => {
expect(unixEpochToDate(0).getTime()).toBe(0);
});

it('converts a unix epoch to Date', function () {
const epoch = 1622575087;
const date = unixEpochToDate(epoch);
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/utils/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ function isValidDate(d: Date | unknown) {
}

export function unixEpochToDate(epochInSeconds?: number): Date {
const date = new Date(epochInSeconds || new Date());
const date = new Date(epochInSeconds ?? new Date());
return isValidDate(date) ? date : new Date();
}
12 changes: 10 additions & 2 deletions packages/shared/src/types/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,15 @@ export interface SessionResource extends ClerkResource {
*/
status: SessionStatus;
/**
* The date and time when the session will expire.
* The date and time when the session will expire. For an indefinite native session, this represents Unix epoch zero, meaning no expiration deadline is configured.
*/
expireAt: Date;
/**
* The date and time when the session was abandoned by the user.
* Whether the session has a maximum lifetime. `false` when `expireAt` is Unix epoch zero. A session without a maximum lifetime can still end through sign-out or revocation, so use `status` to check whether it is active. `undefined` in ClerkJS versions that predate this property.
*/
readonly hasMaximumLifetime?: boolean;
/**
* The abandonment deadline. For an indefinite native session, this represents Unix epoch zero, meaning no abandonment deadline is configured.
*/
abandonAt: Date;
/**
Expand Down Expand Up @@ -380,6 +384,10 @@ export interface SessionWithActivitiesResource extends ClerkResource {
id: string;
status: string;
expireAt: Date;
/**
* Whether the session has a maximum lifetime. `false` when `expireAt` is Unix epoch zero. `undefined` in ClerkJS versions that predate this property.
*/
readonly hasMaximumLifetime?: boolean;
abandonAt: Date;
lastActiveAt: Date;
latestActivity: SessionActivity;
Expand Down
Loading