Skip to content
2 changes: 2 additions & 0 deletions .changeset/clear-active-devices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 5 additions & 4 deletions packages/clerk-js/src/core/resources/SessionWithActivities.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ActClaim,
GetSessionsParams,
SessionActivity,
SessionActivityJSON,
SessionWithActivitiesJSON,
Expand Down Expand Up @@ -36,10 +37,10 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc
this.fromJSON(data);
}

static retrieve(): Promise<SessionWithActivities[]> {
static retrieve(params?: Pick<GetSessionsParams, 'throwOnError'>): Promise<SessionWithActivities[]> {
const sessionId = BaseResource.clerk.session?.id;

return this.clerk
const request = this.clerk
.getFapiClient()
.request<SessionWithActivitiesJSON[]>({
method: 'GET',
Expand All @@ -50,8 +51,8 @@ export class SessionWithActivities extends BaseResource implements SessionWithAc
// https://www.notion.so/clerkdev/Align-SessionWithActivities-retrieval-with-the-rest-of-Client-API-a043f72f6b9d4344bd2f21dc1d3f79de
const sessionWithActivitiesJSON = res.payload as unknown as SessionWithActivitiesJSON[];
return sessionWithActivitiesJSON.map(sa => new SessionWithActivities(sa, '/me/sessions'));
})
.catch(() => []);
});
return params?.throwOnError ? request : request.catch(() => []);
}

revoke(): Promise<this> {
Expand Down
7 changes: 4 additions & 3 deletions packages/clerk-js/src/core/resources/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
ExternalAccountResource,
GetEnterpriseConnectionsParams,
GetOrganizationMemberships,
GetSessionsParams,
GetUserOrganizationInvitationsParams,
GetUserOrganizationSuggestionsParams,
ImageResource,
Expand Down Expand Up @@ -330,11 +331,11 @@ export class User extends BaseResource implements UserResource {
});
};

getSessions = async (): Promise<SessionWithActivities[]> => {
if (this.cachedSessionsWithActivities) {
getSessions = async (params?: GetSessionsParams): Promise<SessionWithActivities[]> => {
if (!params?.forceRefresh && this.cachedSessionsWithActivities) {
return this.cachedSessionsWithActivities;
}
const res = await SessionWithActivities.retrieve();
const res = await SessionWithActivities.retrieve(params);
this.cachedSessionsWithActivities = res;
return res;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { createSession, createUser } from '@/test/core-fixtures';

import { Clerk } from '../../clerk';
import { BaseResource } from '../internal';
import { User } from '../User';

function response(id: string) {
const { user: _user, ...session } = createSession({ id, status: 'active' });
return Object.assign(new Response(), {
payload: [{ ...session, user: null, latest_activity: { object: 'session_activity', id: `activity_${id}` } }],
});
}

describe('User session retrieval', () => {
const clerk = new Clerk('pk_test_Y2xlcmsuZXhhbXBsZS5jb20k');
const previousClerk = BaseResource.clerk;

beforeEach(() => {
BaseResource.clerk = clerk;
});

afterEach(() => {
vi.restoreAllMocks();
BaseResource.clerk = previousClerk;
});

it('preserves cached successes for callers without options', async () => {
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockResolvedValue(response('sess_first'));
const user = new User(createUser());
expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']);
request.mockResolvedValue(response('sess_next'));
expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']);
expect(request).toHaveBeenCalledOnce();
});

it('preserves the cached empty result after failure for callers without options', async () => {
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure'));
const user = new User(createUser());
await expect(user.getSessions()).resolves.toEqual([]);
request.mockResolvedValue(response('sess_next'));
await expect(user.getSessions()).resolves.toEqual([]);
expect(request).toHaveBeenCalledOnce();
});

it('makes a fresh read after a legacy caller cached a failed request', async () => {
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure'));
const user = new User(createUser());
await expect(user.getSessions()).resolves.toEqual([]);
request.mockResolvedValue(response('sess_next'));
const sessions = await user.getSessions({ forceRefresh: true, throwOnError: true });
expect(sessions.map(session => session.id)).toEqual(['sess_next']);
expect(request).toHaveBeenCalledTimes(2);
});

it('propagates a strict failure without overwriting a successful cache', async () => {
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockResolvedValue(response('sess_first'));
const user = new User(createUser());
await user.getSessions();
const failure = new Error('Network failure');
request.mockRejectedValue(failure);
await expect(user.getSessions({ forceRefresh: true, throwOnError: true })).rejects.toBe(failure);
expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_first']);
});

it('retries a strict failed request and refreshes the cached sessions on success', async () => {
const request = vi.spyOn(clerk.getFapiClient(), 'request').mockRejectedValue(new Error('Network failure'));
const user = new User(createUser());
await expect(user.getSessions({ forceRefresh: true, throwOnError: true })).rejects.toThrow('Network failure');
request.mockResolvedValue(response('sess_next'));
expect((await user.getSessions({ forceRefresh: true, throwOnError: true })).map(session => session.id)).toEqual([
'sess_next',
]);
expect((await user.getSessions()).map(session => session.id)).toEqual(['sess_next']);
expect(request).toHaveBeenCalledTimes(2);
});
});
89 changes: 89 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
SessionJSON,
SessionWithActivitiesJSON,
UserJSON,
UserOrganizationInvitationJSON,
} from '@clerk/shared/types';
Expand Down Expand Up @@ -49,6 +50,16 @@ export interface FakeFapiState {
passwordUpdates: URLSearchParams[];
enterpriseConnections: EnterpriseConnectionJSON[];
enterpriseLinking: FakeEnterpriseLinking;
activeDevices?: ActiveDeviceRecord[];
deviceTrackingEnabled: boolean;
}

export interface ActiveDeviceRecord extends SessionWithActivitiesJSON {
ownerUserId: string;
replacementSessionId?: string;
inactivityTimeoutSeconds?: number;
touchedAt?: number;
tokenIssuedAt?: number;
}

export type FakeFapiSeed = Partial<Omit<FakeFapiState, 'verification' | 'enterpriseLinking'>> & {
Expand Down Expand Up @@ -109,10 +120,40 @@ function updateUser(state: FakeFapiState, user: UserJSON): void {
};
}

function requestingSession(state: FakeFapiState, request: Request): SessionJSON | undefined {
const sessionId = new URL(request.url).searchParams.get('_clerk_session_id') ?? state.client.last_active_session_id;
return findSession(state, sessionId);
}

function missing() {
return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
}

function canRevokeDevice(record: ActiveDeviceRecord): boolean {
const now = Date.now();
const lastActivity = Math.max(record.touchedAt ?? record.last_active_at, record.tokenIssuedAt ?? 0);
return (
record.status === 'active' &&
!record.replacementSessionId &&
record.expire_at > now &&
record.abandon_at > now &&
(!record.inactivityTimeoutSeconds || now - lastActivity <= record.inactivityTimeoutSeconds * 1000)
);
}

function deviceSessionPayload(record: ActiveDeviceRecord) {
const {
ownerUserId: _ownerUserId,
replacementSessionId: _replacementSessionId,
inactivityTimeoutSeconds: _inactivityTimeoutSeconds,
touchedAt: _touchedAt,
tokenIssuedAt: _tokenIssuedAt,
latest_activity: _latestActivity,
...session
} = record;
return session;
}

export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
const { verification, enterpriseLinking, ...rest } = seed;
const state: FakeFapiState = {
Expand All @@ -124,6 +165,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
apiKeys: [],
passwordUpdates: [],
enterpriseConnections: [],
deviceTrackingEnabled: true,
...rest,
verification: createVerificationState(verification),
enterpriseLinking: {
Expand Down Expand Up @@ -248,6 +290,53 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
state.client = { ...state.client, sessions, last_active_session_id: sessions[0]?.id ?? null };
return envelope({ ...session, status: 'removed' }, state.client);
}),
http.get(fapiUrl('/v1/me/sessions/active'), ({ request }) => {
if (!state.activeDevices) {
return undefined;
}
const requester = requestingSession(state, request);
if (!requester) {
return missing();
}
if (!state.deviceTrackingEnabled) {
const { user: _user, ...session } = requester;
return HttpResponse.json([{ ...session, user: null }]);
}
const records = state.activeDevices.filter(
item => item.ownerUserId === requester.user.id && canRevokeDevice(item) && (!item.actor || requester.actor),
);
return HttpResponse.json(
records.map(item => ({
...deviceSessionPayload(item),
status: item.tasks?.length ? 'pending' : item.status,
latest_activity: item.latest_activity,
})),
);
}),
http.post(fapiUrl('/v1/me/sessions/:id/revoke'), ({ params, request }) => {
if (!state.activeDevices) {
return undefined;
}
const requester = requestingSession(state, request);
if (!requester) {
return missing();
}
const target = state.activeDevices.find(item => item.id === params.id);
if (target?.id === requester.id) {
const code = 'invalid_action_for_session';
return HttpResponse.json({ errors: [{ code, message: code }] }, { status: 400 });
}
if (!target || target.ownerUserId !== requester.user.id) {
const code = 'action_for_session_not_authorized';
return HttpResponse.json({ errors: [{ code, message: code }] }, { status: 401 });
}
if (!canRevokeDevice(target)) {
const code = 'invalid_action_for_session';
return HttpResponse.json({ errors: [{ code, message: code }] }, { status: 400 });
}
target.status = 'revoked';
return envelope({ ...deviceSessionPayload(target), user: requester.user, status: 'revoked' }, state.client);
}),
http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => {
const session = findSession(state, state.client.last_active_session_id);
if (!session) {
Expand Down
Loading
Loading