Skip to content

Commit f6a5158

Browse files
committed
chore(clerk-js): Refactor SessionCookieService and auth/cookies handlers for consistency
There is also a fix for duplicate TokenUpdate event and for cleaning the token in sign-out flow.
1 parent 02b00c1 commit f6a5158

File tree

7 files changed

+203
-154
lines changed

7 files changed

+203
-154
lines changed

packages/clerk-js/src/core/__tests__/clerk.test.ts

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ import type { ActiveSessionResource, SignInJSON, SignUpJSON, TokenResource } fro
22
import { waitFor } from '@testing-library/dom';
33

44
import { mockNativeRuntime } from '../../testUtils';
5+
import type { DevBrowser } from '../auth/devBrowser';
56
import { Clerk } from '../clerk';
6-
import type { DevBrowser } from '../devBrowser';
77
import { eventBus, events } from '../events';
88
import type { DisplayConfig, Organization } from '../resources/internal';
99
import { BaseResource, Client, EmailLinkErrorCode, Environment, SignIn, SignUp } from '../resources/internal';
10-
import { SessionCookieService } from '../services';
1110
import { mockJwt } from '../test/fixtures';
1211

1312
const mockClientFetch = jest.fn();
@@ -17,7 +16,7 @@ jest.mock('../resources/Client');
1716
jest.mock('../resources/Environment');
1817

1918
// Because Jest, don't ask me why...
20-
jest.mock('../devBrowser', () => ({
19+
jest.mock('../auth/devBrowser', () => ({
2120
createDevBrowser: (): DevBrowser => ({
2221
clear: jest.fn(),
2322
setup: jest.fn(),
@@ -158,17 +157,17 @@ describe('Clerk singleton', () => {
158157
touch: jest.fn(),
159158
getToken: jest.fn(),
160159
};
161-
let cookieSpy;
160+
let evenBusSpy;
162161

163162
beforeEach(() => {
164-
cookieSpy = jest.spyOn(SessionCookieService.prototype, 'setAuthCookiesFromSession');
163+
evenBusSpy = jest.spyOn(eventBus, 'dispatch');
165164
});
166165

167166
afterEach(() => {
168167
mockSession.remove.mockReset();
169168
mockSession.touch.mockReset();
170169

171-
cookieSpy?.mockRestore();
170+
evenBusSpy?.mockRestore();
172171
// cleanup global window pollution
173172
(window as any).__unstable__onBeforeSetActive = null;
174173
(window as any).__unstable__onAfterSetActive = null;
@@ -183,7 +182,7 @@ describe('Clerk singleton', () => {
183182
await sut.setActive({ session: null });
184183
await waitFor(() => {
185184
expect(mockSession.touch).not.toHaveBeenCalled();
186-
expect(cookieSpy).toBeCalledWith(null);
185+
expect(evenBusSpy).toBeCalledWith('token:update', { token: null });
187186
});
188187
});
189188

@@ -194,22 +193,20 @@ describe('Clerk singleton', () => {
194193
const sut = new Clerk(productionPublishableKey);
195194
await sut.load();
196195
await sut.setActive({ session: mockSession as any as ActiveSessionResource });
197-
await waitFor(() => {
198-
expect(mockSession.touch).toHaveBeenCalled();
199-
expect(cookieSpy).toBeCalledWith(mockSession);
200-
});
196+
expect(mockSession.touch).toHaveBeenCalled();
201197
});
202198

203199
it('does not call session.touch if Clerk was initialised with touchSession set to false', async () => {
204200
mockSession.touch.mockReturnValueOnce(Promise.resolve());
205201
mockClientFetch.mockReturnValue(Promise.resolve({ activeSessions: [mockSession] }));
202+
mockSession.getToken.mockResolvedValue('mocked-token');
206203

207204
const sut = new Clerk(productionPublishableKey);
208205
await sut.load({ touchSession: false });
209206
await sut.setActive({ session: mockSession as any as ActiveSessionResource });
210207
await waitFor(() => {
211208
expect(mockSession.touch).not.toHaveBeenCalled();
212-
expect(cookieSpy).toBeCalledWith(mockSession);
209+
expect(mockSession.getToken).toBeCalled();
213210
});
214211
});
215212

@@ -250,6 +247,7 @@ describe('Clerk singleton', () => {
250247
status: 'active',
251248
user: {},
252249
touch: jest.fn(),
250+
getToken: jest.fn(),
253251
};
254252
mockClientFetch.mockReturnValue(Promise.resolve({ activeSessions: [mockSession, mockSession2] }));
255253

@@ -262,9 +260,9 @@ describe('Clerk singleton', () => {
262260
executionOrder.push('session.touch');
263261
return Promise.resolve();
264262
});
265-
cookieSpy.mockImplementationOnce(() => {
263+
mockSession2.getToken.mockImplementation(() => {
266264
executionOrder.push('set cookie');
267-
return Promise.resolve();
265+
return 'mocked-token-2';
268266
});
269267
const beforeEmitMock = jest.fn().mockImplementationOnce(() => {
270268
executionOrder.push('before emit');
@@ -276,7 +274,7 @@ describe('Clerk singleton', () => {
276274
await waitFor(() => {
277275
expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']);
278276
expect(mockSession2.touch).toHaveBeenCalled();
279-
expect(cookieSpy).toBeCalledWith(mockSession2);
277+
expect(mockSession2.getToken).toHaveBeenCalled();
280278
expect(beforeEmitMock).toBeCalledWith(mockSession2);
281279
expect(sut.session).toMatchObject(mockSession2);
282280
});
@@ -285,7 +283,6 @@ describe('Clerk singleton', () => {
285283
// TODO: @dimkl include set transitive state
286284
it('calls with lastActiveOrganizationId session.touch -> set cookie -> before emit -> set accessors with touched session on organization switch', async () => {
287285
mockClientFetch.mockReturnValue(Promise.resolve({ activeSessions: [mockSession] }));
288-
289286
const sut = new Clerk(productionPublishableKey);
290287
await sut.load();
291288

@@ -295,10 +292,11 @@ describe('Clerk singleton', () => {
295292
executionOrder.push('session.touch');
296293
return Promise.resolve();
297294
});
298-
cookieSpy.mockImplementationOnce(() => {
295+
mockSession.getToken.mockImplementation(() => {
299296
executionOrder.push('set cookie');
300-
return Promise.resolve();
297+
return 'mocked-token';
301298
});
299+
302300
const beforeEmitMock = jest.fn().mockImplementationOnce(() => {
303301
executionOrder.push('before emit');
304302
return Promise.resolve();
@@ -309,8 +307,8 @@ describe('Clerk singleton', () => {
309307
await waitFor(() => {
310308
expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']);
311309
expect(mockSession.touch).toHaveBeenCalled();
310+
expect(mockSession.getToken).toHaveBeenCalled();
312311
expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org-id');
313-
expect(cookieSpy).toBeCalledWith(mockSession);
314312
expect(beforeEmitMock).toBeCalledWith(mockSession);
315313
expect(sut.session).toMatchObject(mockSession);
316314
});
@@ -329,10 +327,6 @@ describe('Clerk singleton', () => {
329327
executionOrder.push('session.touch');
330328
return Promise.resolve();
331329
});
332-
cookieSpy.mockImplementationOnce(() => {
333-
executionOrder.push('set cookie');
334-
return Promise.resolve();
335-
});
336330
const beforeEmitMock = jest.fn().mockImplementationOnce(() => {
337331
executionOrder.push('before emit');
338332
return Promise.resolve();
@@ -343,7 +337,7 @@ describe('Clerk singleton', () => {
343337
expect(executionOrder).toEqual(['session.touch', 'before emit']);
344338
expect(mockSession.touch).toHaveBeenCalled();
345339
expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org-id');
346-
expect(cookieSpy).not.toHaveBeenCalled();
340+
expect(mockSession.getToken).toBeCalled();
347341
expect(beforeEmitMock).toBeCalledWith(mockSession);
348342
expect(sut.session).toMatchObject(mockSession);
349343
});
@@ -523,7 +517,7 @@ describe('Clerk singleton', () => {
523517
);
524518

525519
const sut = new Clerk(productionPublishableKey);
526-
sut.setActive = jest.fn(({ beforeEmit }) => beforeEmit());
520+
sut.setActive = jest.fn(async ({ beforeEmit }) => void (beforeEmit && beforeEmit()));
527521
sut.navigate = jest.fn();
528522
await sut.load();
529523
await sut.signOut({ sessionId: '1', redirectUrl: '/after-sign-out' });

packages/clerk-js/src/core/auth/SessionCookieService.ts

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,74 @@
1+
import { setDevBrowserJWTInURL } from '@clerk/shared/devBrowser';
12
import { is4xxError, isClerkAPIResponseError, isNetworkError } from '@clerk/shared/error';
2-
import type { Clerk, EnvironmentResource, SessionResource, TokenResource } from '@clerk/types';
3+
import type { Clerk, EnvironmentResource } from '@clerk/types';
34

4-
import { inBrowser } from '../../utils';
5-
import { clerkCoreErrorTokenRefreshFailed } from '../errors';
5+
import { clerkCoreErrorTokenRefreshFailed, clerkMissingDevBrowserJwt } from '../errors';
66
import { eventBus, events } from '../events';
7-
import { setClientUatCookie } from './cookies/clientUat';
8-
import { removeSessionCookie, setSessionCookie } from './cookies/session';
7+
import type { FapiClient } from '../fapiClient';
8+
import type { ClientUatCookieHandler } from './cookies/clientUat';
9+
import { createClientUatCookie } from './cookies/clientUat';
10+
import type { SessionCookieHandler } from './cookies/session';
11+
import { createSessionCookie } from './cookies/session';
12+
import type { DevBrowser } from './devBrowser';
13+
import { createDevBrowser } from './devBrowser';
914
import { SessionCookiePoller } from './SessionCookiePoller';
1015

16+
// TODO: make SessionCookieService singleton since it handles updating cookies using a poller
17+
// and we need to avoid updating them concurrently.
1118
export class SessionCookieService {
1219
private environment: EnvironmentResource | undefined;
1320
private poller: SessionCookiePoller | null = null;
21+
private clientUat: ClientUatCookieHandler;
22+
private sessionCookie: SessionCookieHandler;
23+
private devBrowser: DevBrowser;
1424

15-
constructor(private clerk: Clerk) {
25+
constructor(private clerk: Clerk, fapiClient: FapiClient) {
1626
// set cookie on token update
1727
eventBus.on(events.TokenUpdate, ({ token }) => {
18-
this.updateSessionCookie(token?.getRawString());
28+
this.updateSessionCookie(token && token.getRawString());
29+
this.setClientUatCookieForDevelopmentInstances();
1930
});
2031

2132
this.refreshTokenOnVisibilityChange();
2233
this.startPollingForToken();
34+
35+
this.clientUat = createClientUatCookie();
36+
this.sessionCookie = createSessionCookie();
37+
this.devBrowser = createDevBrowser({
38+
frontendApi: clerk.frontendApi,
39+
fapiClient,
40+
});
2341
}
2442

2543
public setEnvironment(environment: EnvironmentResource) {
2644
this.environment = environment;
2745
this.setClientUatCookieForDevelopmentInstances();
2846
}
2947

30-
public async setAuthCookiesFromSession(session: SessionResource | undefined | null): Promise<void> {
31-
this.updateSessionCookie(await session?.getToken());
32-
this.setClientUatCookieForDevelopmentInstances();
48+
public isSignedOut() {
49+
return this.clientUat.get() <= 0;
50+
}
51+
52+
public async setupDevelopment() {
53+
await this.devBrowser.setup();
54+
}
55+
56+
public setupProduction() {
57+
this.devBrowser.clear();
58+
}
59+
60+
public async handleUnauthenticatedDevBrowser() {
61+
this.devBrowser.clear();
62+
await this.devBrowser.setup();
63+
}
64+
65+
public urlWithAuth(url: URL): URL {
66+
const devBrowserJwt = this.devBrowser.getDevBrowserJWT();
67+
if (!devBrowserJwt) {
68+
return clerkMissingDevBrowserJwt();
69+
}
70+
71+
return setDevBrowserJWTInURL(url, devBrowserJwt);
3372
}
3473

3574
private startPollingForToken() {
@@ -40,10 +79,6 @@ export class SessionCookieService {
4079
}
4180

4281
private refreshTokenOnVisibilityChange() {
43-
if (!inBrowser()) {
44-
return;
45-
}
46-
4782
document.addEventListener('visibilitychange', () => {
4883
if (document.visibilityState === 'visible') {
4984
void this.refreshSessionToken();
@@ -52,33 +87,24 @@ export class SessionCookieService {
5287
}
5388

5489
private async refreshSessionToken(): Promise<void> {
55-
if (!inBrowser()) {
56-
return;
57-
}
58-
5990
if (!this.clerk.session) {
6091
return;
6192
}
6293

6394
try {
64-
this.updateSessionCookie(await this.clerk.session?.getToken());
95+
await this.clerk.session.getToken();
6596
} catch (e) {
6697
return this.handleGetTokenError(e);
6798
}
6899
}
69100

70-
private updateSessionCookie(token: TokenResource | string | undefined | null) {
71-
const rawToken = typeof token === 'string' ? token : token?.getRawString();
72-
73-
if (rawToken) {
74-
return setSessionCookie(rawToken);
75-
}
76-
return removeSessionCookie();
101+
private updateSessionCookie(token: string | null) {
102+
return token ? this.sessionCookie.set(token) : this.sessionCookie.remove();
77103
}
78104

79105
private setClientUatCookieForDevelopmentInstances() {
80-
if (this.environment && this.environment.isDevelopmentOrStaging() && this.inCustomDevelopmentDomain()) {
81-
setClientUatCookie(this.clerk.client);
106+
if (this.environment?.isDevelopmentOrStaging() && this.inCustomDevelopmentDomain()) {
107+
this.clientUat.set(this.clerk.client);
82108
}
83109
}
84110

packages/clerk-js/src/core/auth/cookies/clientUat.ts

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,45 @@ import { getCookieDomain } from '../getCookieDomain';
77

88
const CLIENT_UAT_COOKIE_NAME = '__client_uat';
99

10-
export const clientUatCookie = createCookieHandler(CLIENT_UAT_COOKIE_NAME);
11-
12-
export const getClientUatCookie = (): number => {
13-
return parseInt(clientUatCookie.get() || '0', 10);
10+
export type ClientUatCookieHandler = {
11+
set: (client: ClientResource | undefined) => void;
12+
get: () => number;
1413
};
1514

16-
export const setClientUatCookie = (client: ClientResource | undefined) => {
17-
const expires = addYears(Date.now(), 1);
18-
const sameSite = inCrossOriginIframe() ? 'None' : 'Strict';
19-
const secure = window.location.protocol === 'https:';
20-
const domain = getCookieDomain();
21-
22-
// '0' indicates the user is signed out
23-
let val = '0';
24-
25-
if (client && client.updatedAt && client.activeSessions.length > 0) {
26-
// truncate timestamp to seconds, since this is a unix timestamp
27-
val = Math.floor(client.updatedAt.getTime() / 1000).toString();
28-
}
29-
30-
// Removes any existing cookies without a domain specified to ensure the change doesn't break existing sessions.
31-
clientUatCookie.remove();
32-
33-
return clientUatCookie.set(val, {
34-
expires,
35-
sameSite,
36-
domain,
37-
secure,
38-
});
15+
export const createClientUatCookie = (): ClientUatCookieHandler => {
16+
const clientUatCookie = createCookieHandler(CLIENT_UAT_COOKIE_NAME);
17+
18+
const get = (): number => {
19+
return parseInt(clientUatCookie.get() || '0', 10);
20+
};
21+
22+
const set = (client: ClientResource | undefined) => {
23+
const expires = addYears(Date.now(), 1);
24+
const sameSite = inCrossOriginIframe() ? 'None' : 'Strict';
25+
const secure = window.location.protocol === 'https:';
26+
const domain = getCookieDomain();
27+
28+
// '0' indicates the user is signed out
29+
let val = '0';
30+
31+
if (client && client.updatedAt && client.activeSessions.length > 0) {
32+
// truncate timestamp to seconds, since this is a unix timestamp
33+
val = Math.floor(client.updatedAt.getTime() / 1000).toString();
34+
}
35+
36+
// Removes any existing cookies without a domain specified to ensure the change doesn't break existing sessions.
37+
clientUatCookie.remove();
38+
39+
return clientUatCookie.set(val, {
40+
expires,
41+
sameSite,
42+
domain,
43+
secure,
44+
});
45+
};
46+
47+
return {
48+
set,
49+
get,
50+
};
3951
};

0 commit comments

Comments
 (0)