-
Notifications
You must be signed in to change notification settings - Fork 446
Expand file tree
/
Copy pathAuthCookieService.ts
More file actions
303 lines (263 loc) · 10.6 KB
/
AuthCookieService.ts
File metadata and controls
303 lines (263 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
import { isValidBrowserOnline } from '@clerk/shared/browser';
import type { createClerkEventBus } from '@clerk/shared/clerkEventBus';
import { clerkEvents } from '@clerk/shared/clerkEventBus';
import type { createCookieHandler } from '@clerk/shared/cookie';
import { setDevBrowserInURL } from '@clerk/shared/devBrowser';
import {
isClerkAPIResponseError,
isClerkRuntimeError,
isNetworkError,
isUnauthenticatedError,
} from '@clerk/shared/error';
import type { Clerk, InstanceType } from '@clerk/shared/types';
import { noop } from '@clerk/shared/utils';
import { debugLogger } from '@/utils/debug';
import { decode } from '@/utils/jwt';
import { claimFreshness } from '../tokenFreshness';
import { clerkMissingDevBrowser } from '../errors';
import { eventBus, events } from '../events';
import type { FapiClient } from '../fapiClient';
import { Environment } from '../resources/Environment';
import { createActiveContextCookie } from './cookies/activeContext';
import type { ClientUatCookieHandler } from './cookies/clientUat';
import { createClientUatCookie } from './cookies/clientUat';
import type { SessionCookieHandler } from './cookies/session';
import { createSessionCookie } from './cookies/session';
import { getCookieSuffix } from './cookieSuffix';
import type { DevBrowser } from './devBrowser';
import { createDevBrowser } from './devBrowser';
import { SessionCookiePoller } from './SessionCookiePoller';
// TODO(@dimkl): make AuthCookieService singleton since it handles updating cookies using a poller
// and we need to avoid updating them concurrently.
/**
* The AuthCookieService class is a service responsible to handle
* all operations and helpers required in a standard browser context
* based on the cookies to remove the dependency between cookies
* and auth from the Clerk instance.
* This service is responsible to:
* - refresh the session cookie using a poller
* - refresh the session cookie on tab visibility change
* - update the related cookies listening to the `token:update` event
* - initialize auth related cookies for development instances (eg __client_uat, __clerk_db_jwt)
* - cookie setup for production / development instances
* It also provides the following helpers:
* - isSignedOut(): check if the current user is signed-out using cookies
* - decorateUrlWithDevBrowserToken(): decorates url with auth related info (eg dev browser)
* - handleUnauthenticatedDevBrowser(): resets dev browser in case of invalid dev browser
*/
export class AuthCookieService {
private poller: SessionCookiePoller | null = null;
private clientUat: ClientUatCookieHandler;
private sessionCookie: SessionCookieHandler;
private activeCookie: ReturnType<typeof createCookieHandler>;
private devBrowser: DevBrowser;
public static async create(
clerk: Clerk,
fapiClient: FapiClient,
instanceType: InstanceType,
clerkEventBus: ReturnType<typeof createClerkEventBus>,
) {
const cookieSuffix = await getCookieSuffix(clerk.publishableKey);
const service = new AuthCookieService(clerk, fapiClient, cookieSuffix, instanceType, clerkEventBus);
await service.setup();
return service;
}
private constructor(
private clerk: Clerk,
fapiClient: FapiClient,
cookieSuffix: string,
private instanceType: InstanceType,
private clerkEventBus: ReturnType<typeof createClerkEventBus>,
) {
// set cookie on token update
eventBus.on(events.TokenUpdate, ({ token }) => {
this.updateSessionCookie(token && token.getRawString());
this.setClientUatCookieForDevelopmentInstances();
});
eventBus.on(events.UserSignOut, () => this.handleSignOut());
// After Environment resolves, re-write dev browser cookies with correct
// partitioned attributes. Dev browser cookies are initially written before
// Environment is fetched, so they may have stale attributes.
eventBus.on(events.EnvironmentUpdate, () => {
this.devBrowser.refreshCookies();
});
this.refreshTokenOnFocus();
this.startPollingForToken();
const cookieOptions = {
usePartitionedCookies: () => Environment.getInstance().partitionedCookies,
};
this.clientUat = createClientUatCookie(cookieSuffix, cookieOptions);
this.sessionCookie = createSessionCookie(cookieSuffix, cookieOptions);
this.activeCookie = createActiveContextCookie();
this.devBrowser = createDevBrowser({
frontendApi: clerk.frontendApi,
fapiClient,
cookieSuffix,
cookieOptions,
});
}
public async setup() {
if (this.instanceType === 'production') {
return this.setupProduction();
} else {
return this.setupDevelopment();
}
}
public isSignedOut() {
if (!this.clerk.loaded) {
return this.clientUat.get() <= 0;
}
return !!this.clerk.user;
}
public async handleUnauthenticatedDevBrowser() {
this.devBrowser.clear();
await this.devBrowser.setup();
}
public decorateUrlWithDevBrowserToken(url: URL): URL {
const devBrowser = this.devBrowser.getDevBrowser();
if (!devBrowser) {
return clerkMissingDevBrowser();
}
return setDevBrowserInURL(url, devBrowser);
}
private async setupDevelopment() {
await this.devBrowser.setup();
}
private setupProduction() {
this.devBrowser.clear();
}
public startPollingForToken() {
if (!this.poller) {
this.poller = new SessionCookiePoller();
this.poller.startPollingForSessionToken(() => this.refreshSessionToken());
}
}
public stopPollingForToken() {
if (this.poller) {
this.poller.stopPollingForSessionToken();
this.poller = null;
}
}
private refreshTokenOnFocus() {
window.addEventListener('focus', () => {
if (document.visibilityState === 'visible') {
// Certain data-fetching libraries that refetch on focus use setTimeout(cb, 0) to schedule a task on the event loop.
// This gives us an opportunity to ensure the session cookie is updated with a fresh token before the fetch occurs, but it needs to
// be done with a microtask. Promises schedule microtasks, and so by using `updateCookieImmediately: true`, we ensure that the cookie
// is updated as part of the scheduled microtask. Our existing event-based mechanism to update the cookie schedules a task, and so the cookie
// is updated too late and not guaranteed to be fresh before the refetch occurs.
// While online `.schedule()` executes synchronously and immediately, ensuring the above mechanism will not break.
void this.refreshSessionToken({ updateCookieImmediately: true });
}
});
}
private async refreshSessionToken({
updateCookieImmediately = false,
}: {
updateCookieImmediately?: boolean;
} = {}): Promise<void> {
if (!this.clerk.session) {
return;
}
try {
const token = await this.clerk.session.getToken();
if (updateCookieImmediately) {
this.updateSessionCookie(token);
}
} catch (e) {
return this.handleGetTokenError(e);
}
}
private updateSessionCookie(token: string | null) {
// Only allow background tabs to update if both session and organization match
if (!document.hasFocus() && !this.isCurrentContextActive()) {
return;
}
// Monotonic freshness guard: don't regress the cookie within the same session
if (token) {
const currentRaw = this.sessionCookie.get();
if (currentRaw) {
try {
const current = decode(currentRaw);
const incoming = decode(token);
const currentSid = current.claims.sid;
const incomingSid = incoming.claims.sid;
// Only apply within the same session. Different sessions always allowed.
if (currentSid && incomingSid && currentSid === incomingSid) {
const currentFresh = claimFreshness(current);
const incomingFresh = claimFreshness(incoming);
if (currentFresh != null && incomingFresh != null && currentFresh > incomingFresh) {
return;
}
}
} catch {
// If decode fails, allow the write (don't block on malformed tokens)
}
}
}
if (!token && !isValidBrowserOnline()) {
debugLogger.warn('Removing session cookie (offline)', { sessionId: this.clerk.session?.id }, 'authCookieService');
}
this.setActiveContextInStorage();
return token ? this.sessionCookie.set(token) : this.sessionCookie.remove();
}
public setClientUatCookieForDevelopmentInstances() {
if (this.instanceType !== 'production' && this.inCustomDevelopmentDomain()) {
this.clientUat.set(this.clerk.client);
}
}
private inCustomDevelopmentDomain() {
const domain = this.clerk.frontendApi.replace('clerk.', '');
return !window.location.host.endsWith(domain);
}
private handleGetTokenError(e: any) {
//early return if not a clerk api error (aka fapi error) and not a network error
if (!isClerkAPIResponseError(e) && !isClerkRuntimeError(e) && !isNetworkError(e)) {
return;
}
if (isUnauthenticatedError(e)) {
void this.clerk.handleUnauthenticated().catch(noop);
return;
}
// The poller failed to fetch a fresh session token, update status to `degraded`.
this.clerkEventBus.emit(clerkEvents.Status, 'degraded');
// --------
// Treat any other error as a noop
// TODO(debug-logs): Once debug logs is available log this error.
// --------
}
private handleSignOut() {
this.activeCookie.remove();
this.sessionCookie.remove();
this.setClientUatCookieForDevelopmentInstances();
}
/**
* The below methods handle active context tracking (session and organization) to ensure
* only tabs with matching context can update the session cookie.
* The format of the cookie value is "<session id>:<org id>" where either part can be empty.
*/
public setActiveContextInStorage() {
const sessionId = this.clerk.session?.id || '';
const orgId = this.clerk.organization?.id || '';
const contextValue = `${sessionId}:${orgId}`;
if (contextValue !== ':') {
this.activeCookie.set(contextValue);
} else {
this.activeCookie.remove();
}
}
private isCurrentContextActive() {
const activeContext = this.activeCookie.get();
if (!activeContext) {
// we should always have an active context, so return true if there isn't one and treat the current context as active
return true;
}
const [activeSessionId, activeOrgId] = activeContext.split(':');
const currentSessionId = this.clerk.session?.id || '';
const currentOrgId = this.clerk.organization?.id || '';
return activeSessionId === currentSessionId && activeOrgId === currentOrgId;
}
public getSessionCookie() {
return this.sessionCookie.get();
}
}