Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(clerk-js): Allow token refresh when Client failed to resolve #5345

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
506a6f6
feat(clerk-js): Allow token refresh when Client failed to resolve
panteliselef Mar 12, 2025
7c6a61f
Merge branch 'main' into elef/sdki-915-support-token-refresh-when-cli…
panteliselef Mar 13, 2025
3bf2569
cleanup
panteliselef Mar 13, 2025
a932787
second cleanup
panteliselef Mar 13, 2025
964cdc4
third cleanup
panteliselef Mar 13, 2025
5a6ff31
changeset
panteliselef Mar 13, 2025
8d87d26
reduce status
panteliselef Mar 13, 2025
843ac1c
add defaults to classes instead
panteliselef Mar 13, 2025
c6ffed9
Revert "add defaults to classes instead"
panteliselef Mar 13, 2025
c3451bd
replace (??) with (||) for less bundle size
panteliselef Mar 13, 2025
0fcfedc
add missing type
panteliselef Mar 14, 2025
ffb0146
add e2e test
panteliselef Mar 14, 2025
7839ed2
update bundlewatch.config.json
panteliselef Mar 14, 2025
4c5ccae
Merge branch 'main' into elef/sdki-915-support-token-refresh-when-cli…
panteliselef Mar 14, 2025
2b40678
revert explicit poller start
panteliselef Mar 14, 2025
11bc05b
Merge branch 'main' into elef/sdki-915-support-token-refresh-when-cli…
panteliselef Mar 17, 2025
8ef5751
bundle bump
panteliselef Mar 17, 2025
d5c76c6
cleanup
panteliselef Mar 17, 2025
d5ef0fc
Merge branch 'main' into elef/sdki-915-support-token-refresh-when-cli…
panteliselef Mar 17, 2025
0eba411
attempt to fix flaky test
panteliselef Mar 17, 2025
cdbb27c
typo
panteliselef Mar 17, 2025
b9bd3fc
address PR feedback
panteliselef Mar 17, 2025
ffdee07
wip flaky test
panteliselef Mar 17, 2025
5bca248
rename
panteliselef Mar 18, 2025
7820390
remove flaky part
panteliselef Mar 18, 2025
78cd87c
Merge branch 'refs/heads/main' into elef/sdki-915-support-token-refre…
panteliselef Mar 18, 2025
14af574
bump bundlewatch.config.json
panteliselef Mar 18, 2025
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
6 changes: 6 additions & 0 deletions .changeset/good-penguins-agree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Allow token refresh when Client failed to resolve.
5 changes: 5 additions & 0 deletions integration/testUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ const createExpectPageObject = ({ page }: TestArgs) => {

const createClerkUtils = ({ page }: TestArgs) => {
return {
toBeLoaded: async () => {
return page.waitForFunction(() => {
return !!window.Clerk?.loaded;
});
},
getClientSideUser: () => {
return page.evaluate(() => {
return window.Clerk?.user;
Expand Down
78 changes: 78 additions & 0 deletions integration/tests/resiliency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('resiliency @generic', ({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser();
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('signed in users can get a fresh session token when Client fails to load', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

await u.po.signIn.goTo();

let waitForClientImmediately = page.waitForResponse(response => response.url().includes('/sign_ins'), {
timeout: 3_000,
});
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });

const clientReponse = await waitForClientImmediately;
const d = await clientReponse.json();

await u.po.expect.toBeSignedIn();

// Simulate developer coming back and client fails to load.
await page.route('**/v1/client?**', route => {
return route.fulfill({
status: 500,
body: JSON.stringify({
errors: [
{
message: 'Oops, an unexpected error occurred',
long_message:
"There was an internal error on our servers. We've been notified and are working on fixing it.",
code: 'internal_clerk_error',
},
],
clerk_trace_id: 'some-trace-id',
}),
});
});
await page.reload();

waitForClientImmediately = page.waitForResponse(
response => response.url().includes('/client?') && response.status() === 500,
{ timeout: 3_000 },
);

const waitForTokenImmediately = page.waitForResponse(
response =>
response.url().includes('/tokens?') && response.status() === 200 && response.request().method() === 'POST',
{ timeout: 3_000 },
);

await page.waitForLoadState('domcontentloaded');

await waitForClientImmediately;
await waitForTokenImmediately;

// Wait for the client to be loaded. and the internal `getToken({skipCache: true})` to have been completed.
await u.po.clerk.toBeLoaded();

await u.po.expect.toBeSignedIn();
});
});
2 changes: 1 addition & 1 deletion integration/tests/reverification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withReverification] })(
await expect(u.page.locator('.cl-profileSectionItem__emailAddresses')).not.toContainText(newFakeEmail);
});

test('reverification propmt when deleting account', async ({ page, context }) => {
test('reverification prompt when deleting account', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const delFakeUser = u.services.users.createFakeUser({
withUsername: true,
Expand Down
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "576kB" },
{ "path": "./dist/clerk.js", "maxSize": "577kB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "78kB" },
{ "path": "./dist/clerk.headless.js", "maxSize": "50KB" },
{ "path": "./dist/clerk.headless.js", "maxSize": "51KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "94KB" },
{ "path": "./dist/vendors*.js", "maxSize": "30KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "35.5KB" },
Expand Down
12 changes: 5 additions & 7 deletions packages/clerk-js/src/core/auth/AuthCookieService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createCookieHandler } from '@clerk/shared/cookie';
import { setDevBrowserJWTInURL } from '@clerk/shared/devBrowser';
import { is4xxError, isClerkAPIResponseError, isNetworkError } from '@clerk/shared/error';
import { is4xxError, isClerkAPIResponseError } from '@clerk/shared/error';
import type { Clerk, InstanceType } from '@clerk/types';

import { clerkCoreErrorTokenRefreshFailed, clerkMissingDevBrowserJwt } from '../errors';
Expand Down Expand Up @@ -183,12 +183,6 @@ export class AuthCookieService {
void this.clerk.handleUnauthenticated();
return;
}

if (isNetworkError(e)) {
return;
}

clerkCoreErrorTokenRefreshFailed(e.toString());
Comment on lines -186 to -191
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to stop throwing errors on status 5xx here. These errors are not useful to us, and developers cannot catch them and handle them.

In case of an FAPI outage, the poller would continue to throw errors, that would pollute a customer's Sentry logs

}

/**
Expand Down Expand Up @@ -216,4 +210,8 @@ export class AuthCookieService {

return this.clerk.organization?.id === activeOrganizationId;
}

public getSessionCookie() {
return this.sessionCookie.get();
}
}
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/auth/SessionCookiePoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers';
import { SafeLock } from './safeLock';

const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
const INTERVAL_IN_MS = 5 * 1000;
const INTERVAL_IN_MS = 5 * 1_000;

export class SessionCookiePoller {
private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/core/auth/cookies/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const SESSION_COOKIE_NAME = '__session';
export type SessionCookieHandler = {
set: (token: string) => void;
remove: () => void;
get: () => string | undefined;
};

/**
Expand All @@ -35,8 +36,11 @@ export const createSessionCookie = (cookieSuffix: string): SessionCookieHandler
sessionCookie.set(token, { expires, sameSite, secure });
};

const get = () => suffixedSessionCookie.get() || sessionCookie.get();

return {
set,
remove,
get,
};
};
21 changes: 19 additions & 2 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import {
import { eventBus, events } from './events';
import type { FapiClient, FapiRequestCallback } from './fapiClient';
import { createFapiClient } from './fapiClient';
import { createClientFromJwt } from './jwt-client';
import { __experimental_Commerce } from './modules/commerce';
import {
BaseResource,
Expand Down Expand Up @@ -2052,10 +2053,26 @@ export class Clerk implements ClerkInterface {
this.updateEnvironment(res);
});

const initClient = () => {
const initClient = async () => {
return Client.getOrCreateInstance()
.fetch()
.then(res => this.updateClient(res));
.then(res => this.updateClient(res))
.catch(async e => {
if (isClerkAPIResponseError(e) && e.errors[0].code === 'requires_captcha') {
throw e;
}

const jwtInCookie = this.#authService?.getSessionCookie();
const localClient = createClientFromJwt(jwtInCookie);

this.updateClient(localClient);

// Always grab a fresh token
await this.session?.getToken({ skipCache: true });

// Allows for Clerk to be marked as loaded with the client and session created from the JWT.
return null;
});
Comment on lines +2059 to +2075
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't focus too much on where this code is located, we might refactor once both this and #5287 are merged.

};

const initComponents = () => {
Expand Down
97 changes: 97 additions & 0 deletions packages/clerk-js/src/core/jwt-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type {
ClientJSON,
OrganizationMembershipJSON,
PartialWithClerkResource,
PublicUserDataJSON,
SessionJSON,
TokenJSON,
UserJSON,
} from '@clerk/types';

import { Token } from './resources';
import { Client } from './resources/Client';

/**
* Create a new client instance from a jwt.
* The caller is responsible for reading the jwt from the `__session` cookie.
*/
export function createClientFromJwt(jwt: string | undefined | null): Client {
// Use `Token` class to parse the JWT token
let token: Token | null;

try {
token = new Token({
jwt: jwt || '',
object: 'token',
// @ts-expect-error - ts is not happy about it, but this is allowed
id: undefined,
});
} catch {
// If the token is invalid, return null
token = null;
}

// Clean up singleton instance
Client.clearInstance();

if (!token?.jwt) {
return Client.getOrCreateInstance({
object: 'client',
last_active_session_id: null,
id: 'client_init',
sessions: [],
} as unknown as ClientJSON);
}

const { sid, sub, org_id, org_role, org_permissions, org_slug, fva } = token.jwt.claims;

const defaultClient = {
object: 'client',
last_active_session_id: sid,
id: 'client_init',
sessions: [
{
object: 'session',
id: sid,
status: 'active',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be added in a later PR, but we now have a new claim in the JWT: sts - it's only added when a session is in a pending status

We should extract it here and default to active: sts ?? 'active' -> happy to open up another PR later to update the JWT types and apply it here as well!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing this in another PR sounds proper. TBH i don't think it is that important, because it might force redirect you to the appropriate page, but clerk will be down so you will not be able to proceed.

last_active_organization_id: org_id || null,
// @ts-expect-error - ts is not happy about `id:undefined`, but this is allowed and expected
last_active_token: {
id: undefined,
object: 'token',
jwt,
} as TokenJSON,
factor_verification_age: fva || null,
public_user_data: {
user_id: sub,
} as PublicUserDataJSON,
user: {
object: 'user',
id: sub,
organization_memberships:
org_id && org_slug && org_role
? [
{
object: 'organization_membership',
id: org_id,
role: org_role,
permissions: org_permissions || [],
organization: {
object: 'organization',
id: org_id,
// Use slug as name for the organization, since name is not available in the token.
name: org_slug,
slug: org_slug,
members_count: 1,
max_allowed_memberships: 1,
},
} as PartialWithClerkResource<OrganizationMembershipJSON>,
]
: [],
} as PartialWithClerkResource<UserJSON>,
} as PartialWithClerkResource<SessionJSON>,
],
} as ClientJSON;

return Client.getOrCreateInstance(defaultClient);
}
17 changes: 8 additions & 9 deletions packages/clerk-js/src/core/resources/Client.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {
type ActiveSessionResource,
type ClientJSON,
type ClientJSONSnapshot,
type ClientResource,
type SignedInSessionResource,
type SignInResource,
type SignUpResource,
import type {
ActiveSessionResource,
ClientJSON,
ClientJSONSnapshot,
ClientResource,
SignedInSessionResource,
SignInResource,
SignUpResource,
} from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
Expand Down Expand Up @@ -140,7 +140,6 @@ export class Client extends BaseResource implements ClientResource {
public __internal_toSnapshot(): ClientJSONSnapshot {
return {
object: 'client',
status: null,
id: this.id || '',
sessions: this.sessions.map(s => s.__internal_toSnapshot()),
sign_up: this.signUp.__internal_toSnapshot(),
Expand Down
14 changes: 7 additions & 7 deletions packages/clerk-js/src/core/resources/Organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,13 +269,13 @@ export class Organization extends BaseResource implements OrganizationResource {
this.id = data.id;
this.name = data.name;
this.slug = data.slug;
this.imageUrl = data.image_url;
this.hasImage = data.has_image;
this.publicMetadata = data.public_metadata;
this.membersCount = data.members_count;
this.pendingInvitationsCount = data.pending_invitations_count;
this.maxAllowedMemberships = data.max_allowed_memberships;
this.adminDeleteEnabled = data.admin_delete_enabled;
this.imageUrl = data.image_url || '';
this.hasImage = data.has_image || false;
this.publicMetadata = data.public_metadata || {};
this.membersCount = data.members_count || 0;
this.pendingInvitationsCount = data.pending_invitations_count || 0;
this.maxAllowedMemberships = data.max_allowed_memberships || 0;
this.adminDeleteEnabled = data.admin_delete_enabled || false;
this.createdAt = unixEpochToDate(data.created_at);
this.updatedAt = unixEpochToDate(data.updated_at);
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class OrganizationMembership extends BaseResource implements Organization

this.id = data.id;
this.organization = new Organization(data.organization);
this.publicMetadata = data.public_metadata;
this.publicMetadata = data.public_metadata || {};
if (data.public_user_data) {
this.publicUserData = new PublicUserData(data.public_user_data);
}
Expand Down
12 changes: 5 additions & 7 deletions packages/clerk-js/src/core/resources/PublicUserData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ export class PublicUserData implements IPublicUserData {

protected fromJSON(data: PublicUserDataJSON | PublicUserDataJSONSnapshot | null): this {
if (data) {
this.firstName = data.first_name;
this.lastName = data.last_name;
this.imageUrl = data.image_url;
this.hasImage = data.has_image;
this.identifier = data.identifier;
this.firstName = data.first_name || null;
this.lastName = data.last_name || null;
this.imageUrl = data.image_url || '';
this.hasImage = data.has_image || false;
this.identifier = data.identifier || '';
this.userId = data.user_id;
}

Expand All @@ -27,8 +27,6 @@ export class PublicUserData implements IPublicUserData {

public __internal_toSnapshot(): PublicUserDataJSONSnapshot {
return {
object: 'public_user_data',
id: this.userId || '',
first_name: this.firstName,
last_name: this.lastName,
image_url: this.imageUrl,
Expand Down
Loading