diff --git a/.changeset/good-penguins-agree.md b/.changeset/good-penguins-agree.md new file mode 100644 index 00000000000..9182df66f52 --- /dev/null +++ b/.changeset/good-penguins-agree.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Allow token refresh when Client failed to resolve. diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index f593b29127d..7523027cd72 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -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; diff --git a/integration/tests/resiliency.test.ts b/integration/tests/resiliency.test.ts new file mode 100644 index 00000000000..4fef9b5d163 --- /dev/null +++ b/integration/tests/resiliency.test.ts @@ -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(); + }); +}); diff --git a/integration/tests/reverification.test.ts b/integration/tests/reverification.test.ts index f6b61814643..749b0a67a9b 100644 --- a/integration/tests/reverification.test.ts +++ b/integration/tests/reverification.test.ts @@ -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, diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 9f3ef3f9d76..c885f42820a 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -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" }, diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index c91da643918..0f070cd6860 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -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'; @@ -183,12 +183,6 @@ export class AuthCookieService { void this.clerk.handleUnauthenticated(); return; } - - if (isNetworkError(e)) { - return; - } - - clerkCoreErrorTokenRefreshFailed(e.toString()); } /** @@ -216,4 +210,8 @@ export class AuthCookieService { return this.clerk.organization?.id === activeOrganizationId; } + + public getSessionCookie() { + return this.sessionCookie.get(); + } } diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index dd4ff1f2a05..4497b87c28d 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -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); diff --git a/packages/clerk-js/src/core/auth/cookies/session.ts b/packages/clerk-js/src/core/auth/cookies/session.ts index 653d0c8951a..fbdb4bc70ae 100644 --- a/packages/clerk-js/src/core/auth/cookies/session.ts +++ b/packages/clerk-js/src/core/auth/cookies/session.ts @@ -10,6 +10,7 @@ const SESSION_COOKIE_NAME = '__session'; export type SessionCookieHandler = { set: (token: string) => void; remove: () => void; + get: () => string | undefined; }; /** @@ -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, }; }; diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 32f0fb7ece7..86fe2155823 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -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, @@ -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; + }); }; const initComponents = () => { diff --git a/packages/clerk-js/src/core/jwt-client.ts b/packages/clerk-js/src/core/jwt-client.ts new file mode 100644 index 00000000000..550c44bf87b --- /dev/null +++ b/packages/clerk-js/src/core/jwt-client.ts @@ -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', + 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, + ] + : [], + } as PartialWithClerkResource, + } as PartialWithClerkResource, + ], + } as ClientJSON; + + return Client.getOrCreateInstance(defaultClient); +} diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 00f997e8d85..194de39e86c 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -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'; @@ -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(), diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 314f36c7b6a..3167f0a3a17 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -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; diff --git a/packages/clerk-js/src/core/resources/OrganizationMembership.ts b/packages/clerk-js/src/core/resources/OrganizationMembership.ts index 52a5e936eaf..7c44524aa61 100644 --- a/packages/clerk-js/src/core/resources/OrganizationMembership.ts +++ b/packages/clerk-js/src/core/resources/OrganizationMembership.ts @@ -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); } diff --git a/packages/clerk-js/src/core/resources/PublicUserData.ts b/packages/clerk-js/src/core/resources/PublicUserData.ts index 50253459172..d552dfba32e 100644 --- a/packages/clerk-js/src/core/resources/PublicUserData.ts +++ b/packages/clerk-js/src/core/resources/PublicUserData.ts @@ -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; } @@ -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, diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index ec9da32d516..61c42a2d237 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -282,11 +282,11 @@ export class Session extends BaseResource implements SessionResource { this.factorVerificationAge = data.factor_verification_age; this.lastActiveAt = unixEpochToDate(data.last_active_at || undefined); this.lastActiveOrganizationId = data.last_active_organization_id; - this.actor = data.actor; + this.actor = data.actor || null; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); this.user = new User(data.user); - this.tasks = data.tasks; + this.tasks = data.tasks || null; if (data.public_user_data) { this.publicUserData = new PublicUserData(data.public_user_data); diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index 10d35d5c644..b7f665da870 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -316,32 +316,32 @@ export class User extends BaseResource implements UserResource { } this.id = data.id; - this.externalId = data.external_id; - this.firstName = data.first_name; - this.lastName = data.last_name; + this.externalId = data.external_id || null; + this.firstName = data.first_name || null; + this.lastName = data.last_name || null; if (this.firstName || this.lastName) { this.fullName = getFullName({ firstName: this.firstName, lastName: this.lastName }); } - this.imageUrl = data.image_url; - this.hasImage = data.has_image; - this.username = data.username; - this.passwordEnabled = data.password_enabled; + this.imageUrl = data.image_url || ''; + this.hasImage = data.has_image || false; + this.username = data.username || null; + this.passwordEnabled = data.password_enabled || false; this.emailAddresses = (data.email_addresses || []).map( ea => new EmailAddress(ea, this.path() + '/email_addresses'), ); - this.primaryEmailAddressId = data.primary_email_address_id; + this.primaryEmailAddressId = data.primary_email_address_id || null; this.primaryEmailAddress = this.emailAddresses.find(({ id }) => id === this.primaryEmailAddressId) || null; this.phoneNumbers = (data.phone_numbers || []).map(ph => new PhoneNumber(ph, this.path() + '/phone_numbers')); - this.primaryPhoneNumberId = data.primary_phone_number_id; + this.primaryPhoneNumberId = data.primary_phone_number_id || null; this.primaryPhoneNumber = this.phoneNumbers.find(({ id }) => id === this.primaryPhoneNumberId) || null; this.web3Wallets = (data.web3_wallets || []).map(ph => new Web3Wallet(ph, this.path() + '/web3_wallets')); - this.primaryWeb3WalletId = data.primary_web3_wallet_id; + this.primaryWeb3WalletId = data.primary_web3_wallet_id || null; this.primaryWeb3Wallet = this.web3Wallets.find(({ id }) => id === this.primaryWeb3WalletId) || null; this.externalAccounts = (data.external_accounts || []).map( @@ -358,16 +358,16 @@ export class User extends BaseResource implements UserResource { ea => new EnterpriseAccount(ea, this.path() + '/enterprise_accounts'), ); - this.publicMetadata = data.public_metadata; - this.unsafeMetadata = data.unsafe_metadata; + this.publicMetadata = data.public_metadata || {}; + this.unsafeMetadata = data.unsafe_metadata || {}; - this.totpEnabled = data.totp_enabled; - this.backupCodeEnabled = data.backup_code_enabled; - this.twoFactorEnabled = data.two_factor_enabled; + this.totpEnabled = data.totp_enabled || false; + this.backupCodeEnabled = data.backup_code_enabled || false; + this.twoFactorEnabled = data.two_factor_enabled || false; - this.createOrganizationEnabled = data.create_organization_enabled; - this.createOrganizationsLimit = data.create_organizations_limit; - this.deleteSelfEnabled = data.delete_self_enabled; + this.createOrganizationEnabled = data.create_organization_enabled || false; + this.createOrganizationsLimit = data.create_organizations_limit || null; + this.deleteSelfEnabled = data.delete_self_enabled || false; if (data.last_sign_in_at) { this.lastSignInAt = unixEpochToDate(data.last_sign_in_at); diff --git a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap index bad0cbe3369..e8fbec1216d 100644 --- a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap @@ -200,7 +200,6 @@ exports[`Client Singleton __internal_toSnapshot() 1`] = ` }, "web3_wallet": null, }, - "status": null, "updated_at": 1733924546843, } `; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 62bef917e6f..ce8101f8176 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -28,6 +28,9 @@ export interface ClerkResourceJSON { object: string; } +export type PartialWithClerkResource = Omit, 'id' | 'object'> & + Pick; + export interface DisplayThemeJSON { general: { color: HexColor; @@ -68,7 +71,6 @@ export interface EnvironmentJSON extends ClerkResourceJSON { export interface ClientJSON extends ClerkResourceJSON { object: 'client'; id: string; - status: any; sessions: SessionJSON[]; sign_up: SignUpJSON | null; sign_in: SignInJSON | null; @@ -238,13 +240,13 @@ export interface SamlAccountJSON extends ClerkResourceJSON { export interface UserJSON extends ClerkResourceJSON { object: 'user'; id: string; - external_id: string; - primary_email_address_id: string; - primary_phone_number_id: string; - primary_web3_wallet_id: string; + external_id: string | null; + primary_email_address_id: string | null; + primary_phone_number_id: string | null; + primary_web3_wallet_id: string | null; image_url: string; has_image: boolean; - username: string; + username: string | null; email_addresses: EmailAddressJSON[]; phone_numbers: PhoneNumberJSON[]; web3_wallets: Web3WalletJSON[]; @@ -259,8 +261,8 @@ export interface UserJSON extends ClerkResourceJSON { organization_memberships: OrganizationMembershipJSON[]; password_enabled: boolean; profile_image_id: string; - first_name: string; - last_name: string; + first_name: string | null; + last_name: string | null; totp_enabled: boolean; backup_code_enabled: boolean; two_factor_enabled: boolean; @@ -275,7 +277,7 @@ export interface UserJSON extends ClerkResourceJSON { created_at: number; } -export interface PublicUserDataJSON extends ClerkResourceJSON { +export interface PublicUserDataJSON { first_name: string | null; last_name: string | null; image_url: string;