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
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 @@ -137,10 +148,40 @@ function deleteOrganization(state: FakeFapiState, organizationId: string): 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 @@ -152,6 +193,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
apiKeys: [],
passwordUpdates: [],
enterpriseConnections: [],
deviceTrackingEnabled: true,
...rest,
verification: createVerificationState(verification),
enterpriseLinking: {
Expand Down Expand Up @@ -276,6 +318,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