Skip to content

Commit ec84d51

Browse files
authored
chore(clerk-js): Reorganize cookies code and fix TokenUpdate event (#3362)
* fix(shared): Change createCookieHandler#.set to return void * chore(clerk-js): Centralize cookie code into single module * 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. * chore(clerk-js): Refactor Clerk.#environment modifier to protected to use type assert * chore(repo): Add changeset * chore(clerk-js): Rename SessionCookieService to AuthCookieService for clarity * chore(clerk-js): Add JSDoc for auth services and cookies * chore(clerk-js): Rename urlWithAuth to decorateUrlWithDevBrowserToken method of auth service * fix(clerk-js): Fix isSignedOut() to use both clientUat and clerk.user This change was applied to keep the existing behaviour: - clerk.user is used when clerk is loaded - clientUat cookie is used when clerk is NOT loaded
1 parent 4d3dc00 commit ec84d51

21 files changed

+396
-309
lines changed

.changeset/shaggy-zebras-attend.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/shared': patch
4+
---
5+
6+
Re-organize cookie codebase into a central place, fix TokenUpdate event to be triggered on sign-out and drop duplicate event on refreshing token.

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import type { DevBrowser } from '../auth/devBrowser';
12
import { Clerk } from '../clerk';
2-
import type { DevBrowser } from '../devBrowser';
33
import type { DisplayConfig } from '../resources/internal';
44
import { Client, Environment } from '../resources/internal';
55

@@ -10,7 +10,7 @@ jest.mock('../resources/Client');
1010
jest.mock('../resources/Environment');
1111

1212
// Because Jest, don't ask me why...
13-
jest.mock('../devBrowser', () => ({
13+
jest.mock('../auth/devBrowser', () => ({
1414
createDevBrowser: (): DevBrowser => ({
1515
clear: jest.fn(),
1616
setup: jest.fn(),

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

+19-25
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' });
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { setDevBrowserJWTInURL } from '@clerk/shared/devBrowser';
2+
import { is4xxError, isClerkAPIResponseError, isNetworkError } from '@clerk/shared/error';
3+
import type { Clerk, EnvironmentResource } from '@clerk/types';
4+
5+
import { clerkCoreErrorTokenRefreshFailed, clerkMissingDevBrowserJwt } from '../errors';
6+
import { eventBus, events } from '../events';
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';
14+
import { SessionCookiePoller } from './SessionCookiePoller';
15+
16+
// TODO(@dimkl): make AuthCookieService singleton since it handles updating cookies using a poller
17+
// and we need to avoid updating them concurrently.
18+
/**
19+
* The AuthCookieService class is a service responsible to handle
20+
* all operations and helpers required in a standard browser context
21+
* based on the cookies to remove the dependency between cookies
22+
* and auth from the Clerk instance.
23+
* This service is responsible to:
24+
* - refresh the session cookie using a poller
25+
* - refresh the session cookie on tab visibility change
26+
* - update the related cookies listening to the `token:update` event
27+
* - initialize auth related cookies for development instances (eg __client_uat, __clerk_db_jwt)
28+
* - cookie setup for production / development instances
29+
* It also provides the following helpers:
30+
* - isSignedOut(): check if the current user is signed-out using cookies
31+
* - decorateUrlWithDevBrowserToken(): decorates url with auth related info (eg dev browser jwt)
32+
* - handleUnauthenticatedDevBrowser(): resets dev browser in case of invalid dev browser
33+
* - setEnvironment(): update cookies (eg client_uat) related to environment
34+
*/
35+
export class AuthCookieService {
36+
private environment: EnvironmentResource | undefined;
37+
private poller: SessionCookiePoller | null = null;
38+
private clientUat: ClientUatCookieHandler;
39+
private sessionCookie: SessionCookieHandler;
40+
private devBrowser: DevBrowser;
41+
42+
constructor(private clerk: Clerk, fapiClient: FapiClient) {
43+
// set cookie on token update
44+
eventBus.on(events.TokenUpdate, ({ token }) => {
45+
this.updateSessionCookie(token && token.getRawString());
46+
this.setClientUatCookieForDevelopmentInstances();
47+
});
48+
49+
this.refreshTokenOnVisibilityChange();
50+
this.startPollingForToken();
51+
52+
this.clientUat = createClientUatCookie();
53+
this.sessionCookie = createSessionCookie();
54+
this.devBrowser = createDevBrowser({
55+
frontendApi: clerk.frontendApi,
56+
fapiClient,
57+
});
58+
}
59+
60+
// TODO(@dimkl): Replace this method call with an event listener to decouple Clerk with setEnvironment
61+
public setEnvironment(environment: EnvironmentResource) {
62+
this.environment = environment;
63+
this.setClientUatCookieForDevelopmentInstances();
64+
}
65+
66+
public isSignedOut() {
67+
if (!this.clerk.loaded) {
68+
return this.clientUat.get() <= 0;
69+
}
70+
return !!this.clerk.user;
71+
}
72+
73+
public async setupDevelopment() {
74+
await this.devBrowser.setup();
75+
}
76+
77+
public setupProduction() {
78+
this.devBrowser.clear();
79+
}
80+
81+
public async handleUnauthenticatedDevBrowser() {
82+
this.devBrowser.clear();
83+
await this.devBrowser.setup();
84+
}
85+
86+
public decorateUrlWithDevBrowserToken(url: URL): URL {
87+
const devBrowserJwt = this.devBrowser.getDevBrowserJWT();
88+
if (!devBrowserJwt) {
89+
return clerkMissingDevBrowserJwt();
90+
}
91+
92+
return setDevBrowserJWTInURL(url, devBrowserJwt);
93+
}
94+
95+
private startPollingForToken() {
96+
if (!this.poller) {
97+
this.poller = new SessionCookiePoller();
98+
}
99+
this.poller.startPollingForSessionToken(() => this.refreshSessionToken());
100+
}
101+
102+
private refreshTokenOnVisibilityChange() {
103+
document.addEventListener('visibilitychange', () => {
104+
if (document.visibilityState === 'visible') {
105+
void this.refreshSessionToken();
106+
}
107+
});
108+
}
109+
110+
private async refreshSessionToken(): Promise<void> {
111+
if (!this.clerk.session) {
112+
return;
113+
}
114+
115+
try {
116+
await this.clerk.session.getToken();
117+
} catch (e) {
118+
return this.handleGetTokenError(e);
119+
}
120+
}
121+
122+
private updateSessionCookie(token: string | null) {
123+
return token ? this.sessionCookie.set(token) : this.sessionCookie.remove();
124+
}
125+
126+
private setClientUatCookieForDevelopmentInstances() {
127+
if (this.environment?.isDevelopmentOrStaging() && this.inCustomDevelopmentDomain()) {
128+
this.clientUat.set(this.clerk.client);
129+
}
130+
}
131+
132+
private inCustomDevelopmentDomain() {
133+
const domain = this.clerk.frontendApi.replace('clerk.', '');
134+
return !window.location.host.endsWith(domain);
135+
}
136+
137+
private handleGetTokenError(e: any) {
138+
//throw if not a clerk error
139+
if (!isClerkAPIResponseError(e)) {
140+
clerkCoreErrorTokenRefreshFailed(e.message || e);
141+
}
142+
143+
//sign user out if a 4XX error
144+
if (is4xxError(e)) {
145+
void this.clerk.handleUnauthenticated();
146+
return;
147+
}
148+
149+
if (isNetworkError(e)) {
150+
return;
151+
}
152+
153+
clerkCoreErrorTokenRefreshFailed(e.toString());
154+
}
155+
}

packages/clerk-js/src/core/services/authentication/SessionCookiePoller.ts packages/clerk-js/src/core/auth/SessionCookiePoller.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createWorkerTimers } from '@clerk/shared';
22

3-
import { SafeLock } from '../../../utils';
3+
import { SafeLock } from './safeLock';
44

55
const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
66
const INTERVAL_IN_MS = 5 * 1000;

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import type { FapiClient } from '../../fapiClient';
12
import { createDevBrowser } from '../devBrowser';
2-
import type { FapiClient } from '../fapiClient';
33

44
type RecursivePartial<T> = {
55
[P in keyof T]?: RecursivePartial<T[P]>;

0 commit comments

Comments
 (0)