From ec6930fdd6371ed2861a68d882b8a94d7fb1bc52 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 11:17:15 +0300 Subject: [PATCH 01/11] feat(clerk-js,clerk-react): Introduce `sessionClaims` in `useAuth()` --- packages/react/src/contexts/AuthContext.ts | 2 ++ .../src/contexts/ClerkContextProvider.tsx | 5 +++- packages/shared/src/authorization.ts | 25 ++++++++++++++++--- packages/shared/src/deriveState.ts | 7 ++++++ packages/types/src/hooks.ts | 9 ++++++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/react/src/contexts/AuthContext.ts b/packages/react/src/contexts/AuthContext.ts index 7969d505ea3..3691a2a4e2f 100644 --- a/packages/react/src/contexts/AuthContext.ts +++ b/packages/react/src/contexts/AuthContext.ts @@ -1,6 +1,7 @@ import { createContextAndHook } from '@clerk/shared/react'; import type { ActClaim, + JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, SessionStatusClaim, @@ -10,6 +11,7 @@ export type AuthContextValue = { userId: string | null | undefined; sessionId: string | null | undefined; sessionStatus: SessionStatusClaim | null | undefined; + sessionClaims: JwtPayload | null | undefined; actor: ActClaim | null | undefined; orgId: string | null | undefined; orgRole: OrganizationCustomRoleKey | null | undefined; diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index 1374addcab6..6d703a7d53c 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -38,6 +38,7 @@ export function ClerkContextProvider(props: ClerkContextProvider) { const { sessionId, sessionStatus, + sessionClaims, session, userId, user, @@ -54,6 +55,7 @@ export function ClerkContextProvider(props: ClerkContextProvider) { const value = { sessionId, sessionStatus, + sessionClaims, userId, actor, orgId, @@ -63,7 +65,8 @@ export function ClerkContextProvider(props: ClerkContextProvider) { factorVerificationAge, }; return { value }; - }, [sessionId, sessionStatus, userId, actor, orgId, orgRole, orgSlug, factorVerificationAge]); + }, [sessionId, sessionStatus, userId, actor, orgId, orgRole, orgSlug, factorVerificationAge, sessionClaims?.__raw]); + const sessionCtx = React.useMemo(() => ({ value: session }), [sessionId, session]); const userCtx = React.useMemo(() => ({ value: user }), [userId, user]); const organizationCtx = React.useMemo(() => { diff --git a/packages/shared/src/authorization.ts b/packages/shared/src/authorization.ts index a49da464b3c..7d630ac0568 100644 --- a/packages/shared/src/authorization.ts +++ b/packages/shared/src/authorization.ts @@ -2,6 +2,7 @@ import type { ActClaim, CheckAuthorizationWithCustomPermissions, GetToken, + JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, PendingSessionOptions, @@ -170,6 +171,7 @@ type AuthStateOptions = { userId?: string | null; sessionId?: string | null; sessionStatus?: SessionStatusClaim | null; + sessionClaims?: JwtPayload | null; actor?: ActClaim | null; orgId?: string | null; orgRole?: OrganizationCustomRoleKey | null; @@ -188,7 +190,19 @@ type AuthStateOptions = { * @internal */ const resolveAuthState = ({ - authObject: { sessionId, sessionStatus, userId, actor, orgId, orgRole, orgSlug, signOut, getToken, has }, + authObject: { + sessionId, + sessionStatus, + userId, + actor, + orgId, + orgRole, + orgSlug, + signOut, + getToken, + has, + sessionClaims, + }, options: { treatPendingAsSignedOut = true }, }: AuthStateOptions): UseAuthReturn | undefined => { if (sessionId === undefined && userId === undefined) { @@ -196,6 +210,7 @@ const resolveAuthState = ({ isLoaded: false, isSignedIn: undefined, sessionId, + sessionClaims: undefined, userId, actor: undefined, orgId: undefined, @@ -213,6 +228,7 @@ const resolveAuthState = ({ isSignedIn: false, sessionId, userId, + sessionClaims: null, actor: null, orgId: null, orgRole: null, @@ -229,6 +245,7 @@ const resolveAuthState = ({ isSignedIn: false, sessionId: null, userId: null, + sessionClaims: null, actor: null, orgId: null, orgRole: null, @@ -239,11 +256,12 @@ const resolveAuthState = ({ } as const; } - if (!!sessionId && !!userId && !!orgId && !!orgRole) { + if (!!sessionId && !!sessionClaims && !!userId && !!orgId && !!orgRole) { return { isLoaded: true, isSignedIn: true, sessionId, + sessionClaims, userId, actor: actor || null, orgId, @@ -255,11 +273,12 @@ const resolveAuthState = ({ } as const; } - if (!!sessionId && !!userId && !orgId) { + if (!!sessionId && !!sessionClaims && !!userId && !orgId) { return { isLoaded: true, isSignedIn: true, sessionId, + sessionClaims, userId, actor: actor || null, orgId: null, diff --git a/packages/shared/src/deriveState.ts b/packages/shared/src/deriveState.ts index a69d8bb5c89..9317d53c078 100644 --- a/packages/shared/src/deriveState.ts +++ b/packages/shared/src/deriveState.ts @@ -1,5 +1,6 @@ import type { InitialState, + JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, OrganizationResource, @@ -23,6 +24,7 @@ const deriveFromSsrInitialState = (initialState: InitialState) => { const user = initialState.user as UserResource; const sessionId = initialState.sessionId; const sessionStatus = initialState.sessionStatus; + const sessionClaims = initialState.sessionClaims; const session = initialState.session as SignedInSessionResource; const organization = initialState.organization as OrganizationResource; const orgId = initialState.orgId; @@ -38,6 +40,7 @@ const deriveFromSsrInitialState = (initialState: InitialState) => { sessionId, session, sessionStatus, + sessionClaims, organization, orgId, orgRole, @@ -54,6 +57,9 @@ const deriveFromClientSideState = (state: Resources) => { const sessionId: string | null | undefined = state.session ? state.session.id : state.session; const session = state.session; const sessionStatus = state.session?.status; + const sessionClaims: JwtPayload | null | undefined = state.session + ? state.session.lastActiveToken?.jwt?.claims + : null; const factorVerificationAge: [number, number] | null = state.session ? state.session.factorVerificationAge : null; const actor = session?.actor; const organization = state.organization; @@ -71,6 +77,7 @@ const deriveFromClientSideState = (state: Resources) => { sessionId, session, sessionStatus, + sessionClaims, organization, orgId, orgRole, diff --git a/packages/types/src/hooks.ts b/packages/types/src/hooks.ts index 7bd15e369b5..7976167d83c 100644 --- a/packages/types/src/hooks.ts +++ b/packages/types/src/hooks.ts @@ -2,7 +2,7 @@ import type { OrganizationCustomRoleKey } from 'organizationMembership'; import type { SignInResource } from 'signIn'; import type { SetActive, SignOut } from './clerk'; -import type { ActClaim } from './jwtv2'; +import type { ActClaim, JwtPayload } from './jwtv2'; import type { CheckAuthorizationWithCustomPermissions, GetToken, @@ -36,6 +36,10 @@ export type UseAuthReturn = * The ID for the current session. */ sessionId: undefined; + /** + * The JWT claims for the current session. + */ + sessionClaims: undefined; /** * The JWT actor for the session. Holds identifier for the user that is impersonating the current user. Read more about [impersonation](https://clerk.com/docs/users/user-impersonation). */ @@ -70,6 +74,7 @@ export type UseAuthReturn = isSignedIn: false; userId: null; sessionId: null; + sessionClaims: null; actor: null; orgId: null; orgRole: null; @@ -83,6 +88,7 @@ export type UseAuthReturn = isSignedIn: true; userId: string; sessionId: string; + sessionClaims: JwtPayload; actor: ActClaim | null; orgId: null; orgRole: null; @@ -96,6 +102,7 @@ export type UseAuthReturn = isSignedIn: true; userId: string; sessionId: string; + sessionClaims: JwtPayload; actor: ActClaim | null; orgId: string; orgRole: OrganizationCustomRoleKey; From 1670fd02260f1b103b1b1840cb2bf974f274b0c3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 11:20:08 +0300 Subject: [PATCH 02/11] emit to listeners on getToken resolve --- packages/clerk-js/src/core/clerk.ts | 9 ++++++ packages/clerk-js/src/core/events.ts | 2 ++ .../clerk-js/src/core/resources/Session.ts | 12 +++++++ .../src/utils/memoizeStateListenerCallback.ts | 31 +------------------ 4 files changed, 24 insertions(+), 30 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index c6fbc2dbb8f..42e147ba240 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -381,6 +381,15 @@ export class Clerk implements ClerkInterface { this.#options = this.#initOptions(options); + /** + * Listen to `Session.getToken` resolving to emit the updated session + * with the new token to the state listeners. + */ + eventBus.on(events.SessionTokenResolved, () => { + this.#setAccessors(this.session); + this.#emit(); + }); + assertNoLegacyProp(this.#options); if (this.#options.sdkMetadata) { diff --git a/packages/clerk-js/src/core/events.ts b/packages/clerk-js/src/core/events.ts index 42181706221..77c88ff02c9 100644 --- a/packages/clerk-js/src/core/events.ts +++ b/packages/clerk-js/src/core/events.ts @@ -4,6 +4,7 @@ export const events = { TokenUpdate: 'token:update', UserSignOut: 'user:signOut', EnvironmentUpdate: 'environment:update', + SessionTokenResolved: 'session:tokenResolved', } as const; type ClerkEvent = (typeof events)[keyof typeof events]; @@ -15,6 +16,7 @@ type EventPayload = { [events.TokenUpdate]: TokenUpdatePayload; [events.UserSignOut]: null; [events.EnvironmentUpdate]: null; + [events.SessionTokenResolved]: null; }; const createEventBus = () => { diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 85c98c30830..4e1603fcdd6 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -103,6 +103,18 @@ export class Session extends BaseResource implements SessionResource { shouldRetry: (error, iterationsCount) => { return !is4xxError(error) && iterationsCount <= 8; }, + }).then(token => { + if (token) { + this.lastActiveToken = new Token({ + // @ts-expect-error This is safe to ignore. + id: undefined, + object: 'token', + jwt: token, + }); + // Emits the updated session with the new token to the state listeners + eventBus.dispatch(events.SessionTokenResolved, null); + } + return token; }); }; diff --git a/packages/clerk-js/src/utils/memoizeStateListenerCallback.ts b/packages/clerk-js/src/utils/memoizeStateListenerCallback.ts index b3198de8575..8cd337f322c 100644 --- a/packages/clerk-js/src/utils/memoizeStateListenerCallback.ts +++ b/packages/clerk-js/src/utils/memoizeStateListenerCallback.ts @@ -32,8 +32,7 @@ function sessionChanged(prev: SessionResource, next: SessionResource): boolean { return ( prev.id !== next.id || prev.updatedAt.getTime() < next.updatedAt.getTime() || - sessionFVAChanged(prev, next) || - sessionUserMembershipPermissionsChanged(prev, next) || + prev.lastActiveToken?.jwt?.claims?.__raw !== next.lastActiveToken?.jwt?.claims?.__raw || sessionUserChanged(prev, next) ); } @@ -53,15 +52,6 @@ function userMembershipsChanged(prev: UserResource, next: UserResource): boolean ); } -function sessionFVAChanged(prev: SessionResource, next: SessionResource): boolean { - const prevFVA = prev.factorVerificationAge; - const nextFVA = next.factorVerificationAge; - if (prevFVA !== null && nextFVA !== null) { - return prevFVA[0] !== nextFVA[0] || prevFVA[1] !== nextFVA[1]; - } - return prevFVA !== nextFVA; -} - function sessionUserChanged(prev: SessionResource, next: SessionResource): boolean { if (!!prev.user !== !!next.user) { return true; @@ -69,25 +59,6 @@ function sessionUserChanged(prev: SessionResource, next: SessionResource): boole return !!prev.user && !!next.user && userChanged(prev.user, next.user); } -function sessionUserMembershipPermissionsChanged(prev: SessionResource, next: SessionResource): boolean { - if (prev.lastActiveOrganizationId !== next.lastActiveOrganizationId) { - return true; - } - - const prevActiveMembership = prev.user?.organizationMemberships?.find( - mem => mem.organization.id === prev.lastActiveOrganizationId, - ); - - const nextActiveMembership = next.user?.organizationMemberships?.find( - mem => mem.organization.id === prev.lastActiveOrganizationId, - ); - - return ( - prevActiveMembership?.role !== nextActiveMembership?.role || - prevActiveMembership?.permissions?.length !== nextActiveMembership?.permissions?.length - ); -} - // TODO: Decide if this belongs in the resources function resourceChanged(prev: T, next: T): boolean { // support the setSession undefined intermediate state From ce5d718489faec916167297290a7ae186d9c83bb Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 12:51:17 +0300 Subject: [PATCH 03/11] fix tests --- .../src/hooks/__tests__/useAuth.test.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index 420c79fc9dd..9532d4a138a 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -1,6 +1,6 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; import { ClerkInstanceContext } from '@clerk/shared/react'; -import type { LoadedClerk } from '@clerk/types'; +import type { LoadedClerk, UseAuthReturn } from '@clerk/types'; import { render, renderHook } from '@testing-library/react'; import React from 'react'; import { afterAll, beforeAll, beforeEach, describe, expect, expectTypeOf, it, test, vi } from 'vitest'; @@ -34,6 +34,22 @@ const TestComponent = () => { ); }; +const stubSessionClaims = (input: { + sessionId: string; + userId: string; + orgId?: string; +}): NonNullable => ({ + __raw: '', + exp: 1, + iat: 1, + iss: '', + nbf: 1, + sid: input.sessionId, + sub: input.userId, + org_id: input.orgId, + ver: undefined, +}); + describe('useAuth', () => { let consoleErrorSpy: any; @@ -77,6 +93,7 @@ describe('useDerivedAuth', () => { expect(current.isLoaded).toBe(false); expect(current.isSignedIn).toBeUndefined(); expect(current.sessionId).toBeUndefined(); + expect(current.sessionClaims).toBeUndefined(); expect(current.userId).toBeUndefined(); expect(current.actor).toBeUndefined(); expect(current.orgId).toBeUndefined(); @@ -92,6 +109,7 @@ describe('useDerivedAuth', () => { expect(current.isLoaded).toBe(true); expect(current.isSignedIn).toBe(false); expect(current.sessionId).toBeNull(); + expect(current.sessionClaims).toBeNull(); expect(current.userId).toBeNull(); expect(current.actor).toBeNull(); expect(current.orgId).toBeNull(); @@ -134,6 +152,7 @@ describe('useDerivedAuth', () => { const authObject = { sessionId: 'session123', sessionStatus: 'pending', + sessionClaims: stubSessionClaims({ sessionId: 'session123', userId: 'user123', orgId: 'org123' }), userId: 'user123', actor: 'actor123', orgId: 'org123', @@ -163,6 +182,7 @@ describe('useDerivedAuth', () => { const authObject = { sessionId: 'session123', sessionStatus: 'pending', + sessionClaims: stubSessionClaims({ sessionId: 'session123', userId: 'user123', orgId: 'org123' }), userId: 'user123', actor: 'actor123', orgId: 'org123', @@ -192,6 +212,7 @@ describe('useDerivedAuth', () => { it('returns signed in with org context when sessionId, userId, orgId, and orgRole are present', () => { const authObject = { sessionId: 'session123', + sessionClaims: stubSessionClaims({ sessionId: 'session123', userId: 'user123', orgId: 'org123' }), userId: 'user123', actor: 'actor123', orgId: 'org123', @@ -208,6 +229,9 @@ describe('useDerivedAuth', () => { expect(current.isLoaded).toBe(true); expect(current.isSignedIn).toBe(true); expect(current.sessionId).toBe('session123'); + expect(current.sessionClaims?.sid).toBe(current.sessionId); + expect(current.sessionClaims?.sub).toBe(current.userId); + expect(current.sessionClaims?.org_id).toBe(current.orgId); expect(current.userId).toBe('user123'); expect(current.actor).toBe('actor123'); expect(current.orgId).toBe('org123'); @@ -225,6 +249,7 @@ describe('useDerivedAuth', () => { it('returns signed in without org context when sessionId and userId are present but no orgId', () => { const authObject = { sessionId: 'session123', + sessionClaims: stubSessionClaims({ sessionId: 'session123', userId: 'user123' }), userId: 'user123', actor: 'actor123', signOut: vi.fn(), @@ -237,6 +262,8 @@ describe('useDerivedAuth', () => { expect(current.isLoaded).toBe(true); expect(current.isSignedIn).toBe(true); expect(current.sessionId).toBe('session123'); + expect(current.sessionClaims?.sid).toBe(current.sessionId); + expect(current.sessionClaims?.sub).toBe(current.userId); expect(current.userId).toBe('user123'); expect(current.actor).toBe('actor123'); expect(current.orgId).toBeNull(); @@ -253,7 +280,7 @@ describe('useDerivedAuth', () => { it('throws invalid state error if none of the conditions match', () => { const authObject = { - sessionId: true, + sessionId: 'session123', userId: undefined, }; renderHook(() => useDerivedAuth(authObject)); @@ -267,6 +294,7 @@ describe('useDerivedAuth', () => { const authObject = { sessionId: 'session123', userId: 'user123', + sessionClaims: stubSessionClaims({ sessionId: 'session123', userId: 'user123' }), has: mockHas, }; const { From 1dc880b3d1b55c8ce00542ac8ddcd542047bb5c7 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 12:53:30 +0300 Subject: [PATCH 04/11] changeset --- .changeset/cuddly-cars-stop.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/cuddly-cars-stop.md diff --git a/.changeset/cuddly-cars-stop.md b/.changeset/cuddly-cars-stop.md new file mode 100644 index 00000000000..479d3227f00 --- /dev/null +++ b/.changeset/cuddly-cars-stop.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Introduce `sessionClaims` to useAuth(). From e075df6267375d5aede346b2d0344e60874b50fe Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 13:28:26 +0300 Subject: [PATCH 05/11] update memoizeStateListenerCallback.ts --- .../src/utils/memoizeStateListenerCallback.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/clerk-js/src/utils/memoizeStateListenerCallback.ts b/packages/clerk-js/src/utils/memoizeStateListenerCallback.ts index 8cd337f322c..be931447d32 100644 --- a/packages/clerk-js/src/utils/memoizeStateListenerCallback.ts +++ b/packages/clerk-js/src/utils/memoizeStateListenerCallback.ts @@ -32,7 +32,9 @@ function sessionChanged(prev: SessionResource, next: SessionResource): boolean { return ( prev.id !== next.id || prev.updatedAt.getTime() < next.updatedAt.getTime() || + // TODO: Optimize this to once JWT v2 formatting is out. prev.lastActiveToken?.jwt?.claims?.__raw !== next.lastActiveToken?.jwt?.claims?.__raw || + sessionUserMembershipPermissionsChanged(prev, next) || sessionUserChanged(prev, next) ); } @@ -59,6 +61,22 @@ function sessionUserChanged(prev: SessionResource, next: SessionResource): boole return !!prev.user && !!next.user && userChanged(prev.user, next.user); } +function sessionUserMembershipPermissionsChanged(prev: SessionResource, next: SessionResource): boolean { + if (prev.lastActiveOrganizationId !== next.lastActiveOrganizationId) { + return true; + } + + const prevActiveMembership = prev.user?.organizationMemberships?.find( + mem => mem.organization.id === prev.lastActiveOrganizationId, + ); + + const nextActiveMembership = next.user?.organizationMemberships?.find( + mem => mem.organization.id === prev.lastActiveOrganizationId, + ); + + return prevActiveMembership?.permissions?.length !== nextActiveMembership?.permissions?.length; +} + // TODO: Decide if this belongs in the resources function resourceChanged(prev: T, next: T): boolean { // support the setSession undefined intermediate state From e443edd84a5285d28be4419bc700b2450403f2ae Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 13:28:40 +0300 Subject: [PATCH 06/11] fix unit tests --- .../clerk-js/src/core/resources/Session.ts | 18 ++++++------------ .../core/resources/__tests__/Session.test.ts | 8 +++++--- .../__snapshots__/Session.test.ts.snap | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 4e1603fcdd6..044c556c4cf 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -103,18 +103,6 @@ export class Session extends BaseResource implements SessionResource { shouldRetry: (error, iterationsCount) => { return !is4xxError(error) && iterationsCount <= 8; }, - }).then(token => { - if (token) { - this.lastActiveToken = new Token({ - // @ts-expect-error This is safe to ignore. - id: undefined, - object: 'token', - jwt: token, - }); - // Emits the updated session with the new token to the state listeners - eventBus.dispatch(events.SessionTokenResolved, null); - } - return token; }); }; @@ -379,6 +367,12 @@ export class Session extends BaseResource implements SessionResource { return tokenResolver.then(token => { if (shouldDispatchTokenUpdate) { eventBus.dispatch(events.TokenUpdate, { token }); + + if (token.jwt) { + this.lastActiveToken = token; + // Emits the updated session with the new token to the state listeners + eventBus.dispatch(events.SessionTokenResolved, null); + } } // Return null when raw string is empty to indicate that there it's signed-out return token.getRawString() || null; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index 1f267f62ce3..209c22c7f7e 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -46,8 +46,9 @@ describe('Session', () => { await session.getToken(); - expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledTimes(2); expect(dispatchSpy.mock.calls[0]).toMatchSnapshot(); + expect(dispatchSpy.mock.calls[1]).toMatchSnapshot(); }); it('hydrates token cache from lastActiveToken', async () => { @@ -95,8 +96,9 @@ describe('Session', () => { await session.getToken(); - expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledTimes(2); expect(dispatchSpy.mock.calls[0]).toMatchSnapshot(); + expect(dispatchSpy.mock.calls[1]).toMatchSnapshot(); }); it('does not dispatch token:update if template is provided', async () => { @@ -138,7 +140,7 @@ describe('Session', () => { await session.getToken({ organizationId: 'activeOrganization' }); - expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledTimes(2); }); it('does not dispatch token:update when provided organization ID does not match current active organization', async () => { diff --git a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Session.test.ts.snap b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Session.test.ts.snap index ce9e54c2f08..7ad0e5c28bd 100644 --- a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Session.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Session.test.ts.snap @@ -34,6 +34,13 @@ exports[`Session getToken() dispatches token:update event on getToken with activ ] `; +exports[`Session getToken() dispatches token:update event on getToken with active organization 2`] = ` +[ + "session:tokenResolved", + null, +] +`; + exports[`Session getToken() dispatches token:update event on getToken without active organization 1`] = ` [ "token:update", @@ -67,3 +74,10 @@ exports[`Session getToken() dispatches token:update event on getToken without ac }, ] `; + +exports[`Session getToken() dispatches token:update event on getToken without active organization 2`] = ` +[ + "session:tokenResolved", + null, +] +`; From a6662769dfda6d208a4b410086dd5eea7d634657 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 13:38:32 +0300 Subject: [PATCH 07/11] bump bundlewatch.config.json --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 5aaea71c0b2..0e0146b3504 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "590kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "72.7KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "72.8KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "98.2KB" }, { "path": "./dist/vendors*.js", "maxSize": "36KB" }, From 5f983047d8a0cd216c80f82fd781e8ca61067fe6 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 13:53:35 +0300 Subject: [PATCH 08/11] update vue --- packages/vue/src/plugin.ts | 5 +++-- packages/vue/src/types.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/vue/src/plugin.ts b/packages/vue/src/plugin.ts index 7ee439bbd5a..99c46379b98 100644 --- a/packages/vue/src/plugin.ts +++ b/packages/vue/src/plugin.ts @@ -68,8 +68,9 @@ export const clerkPlugin: Plugin<[PluginOptions]> = { const derivedState = computed(() => deriveState(loaded.value, resources.value, initialState)); const authCtx = computed(() => { - const { sessionId, userId, orgId, actor, orgRole, orgSlug, orgPermissions, sessionStatus } = derivedState.value; - return { sessionId, userId, actor, orgId, orgRole, orgSlug, orgPermissions, sessionStatus }; + const { sessionId, userId, orgId, actor, orgRole, orgSlug, orgPermissions, sessionStatus, sessionClaims } = + derivedState.value; + return { sessionId, userId, actor, orgId, orgRole, orgSlug, orgPermissions, sessionStatus, sessionClaims }; }); const clientCtx = computed(() => resources.value.client); const userCtx = computed(() => derivedState.value.user); diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 13767da4855..aef4e97ece1 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -5,6 +5,7 @@ import type { ClientResource, CustomMenuItem, CustomPage, + JwtPayload, OrganizationCustomPermissionKey, OrganizationCustomRoleKey, OrganizationResource, @@ -22,6 +23,7 @@ export interface VueClerkInjectionKeyType { sessionId: string | null | undefined; actor: ActClaim | null | undefined; sessionStatus: SessionStatusClaim | null | undefined; + sessionClaims: JwtPayload | null | undefined; orgId: string | null | undefined; orgRole: OrganizationCustomRoleKey | null | undefined; orgSlug: string | null | undefined; From 0c46c3b1367eac033a2710b95541a5c2c3503bf3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 15:06:25 +0300 Subject: [PATCH 09/11] fixing astro --- packages/astro/src/react/hooks.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/astro/src/react/hooks.ts b/packages/astro/src/react/hooks.ts index c3c49b4b249..02d90adfc0d 100644 --- a/packages/astro/src/react/hooks.ts +++ b/packages/astro/src/react/hooks.ts @@ -1,4 +1,5 @@ import { resolveAuthState } from '@clerk/shared/authorization'; +import { deriveState } from '@clerk/shared/deriveState'; import type { CheckAuthorizationWithCustomPermissions, Clerk, @@ -83,7 +84,7 @@ type UseAuth = (options?: PendingSessionOptions) => UseAuthReturn; * } */ export const useAuth: UseAuth = ({ treatPendingAsSignedOut } = {}) => { - const authContext = useStore($authStore); + const authContext = useAuthStore(); const clerkContext = useStore($clerkStore); const getToken: GetToken = useCallback(createGetToken(), []); @@ -139,14 +140,18 @@ export const useAuth: UseAuth = ({ treatPendingAsSignedOut } = {}) => { return payload; }; +function useStore>(store: T, getServerSnapshot?: () => SV): SV { + const get = store.get.bind(store); + return useSyncExternalStore(store.listen, get, getServerSnapshot || get); +} + /** * This implementation of `useStore` is an alternative solution to the hook exported by nanostores * Reference: https://github.com/nanostores/react/blob/main/index.js */ -function useStore>(store: T): SV { - const get = store.get.bind(store); - - return useSyncExternalStore(store.listen, get, () => { +function useAuthStore() { + const get = $authStore.get.bind($authStore); + return useStore($authStore, () => { // Per react docs /** * optional getServerSnapshot: @@ -160,7 +165,17 @@ function useStore>(store: T): SV { * When this runs on the server we want to grab the content from the async-local-storage. */ if (typeof window === 'undefined') { - return authAsyncStorage.getStore(); + return deriveState( + false, + { + user: null, + session: null, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + client: null!, + organization: null, + }, + authAsyncStorage.getStore() as any, + ); } /** From 7bdcb76d0b3d5cf68f47b084937341cf6db02133 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Apr 2025 16:07:03 +0300 Subject: [PATCH 10/11] Update .changeset/cuddly-cars-stop.md --- .changeset/cuddly-cars-stop.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/cuddly-cars-stop.md b/.changeset/cuddly-cars-stop.md index 479d3227f00..46333058603 100644 --- a/.changeset/cuddly-cars-stop.md +++ b/.changeset/cuddly-cars-stop.md @@ -5,3 +5,4 @@ --- Introduce `sessionClaims` to useAuth(). +- thanks to [@ijxy](https://github.com/ijxy) for the [contribution](https://github.com/clerk/javascript/pull/4823) From 69b138534bc40822f35c1227c0162eaef2667496 Mon Sep 17 00:00:00 2001 From: ijxy <32483798+ijxy@users.noreply.github.com> Date: Wed, 9 Apr 2025 16:29:38 +0300 Subject: [PATCH 11/11] remove old `ver` claim Signed-off-by: panteliselef --- packages/react/src/hooks/__tests__/useAuth.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/hooks/__tests__/useAuth.test.tsx b/packages/react/src/hooks/__tests__/useAuth.test.tsx index 9532d4a138a..29d51063135 100644 --- a/packages/react/src/hooks/__tests__/useAuth.test.tsx +++ b/packages/react/src/hooks/__tests__/useAuth.test.tsx @@ -47,7 +47,6 @@ const stubSessionClaims = (input: { sid: input.sessionId, sub: input.userId, org_id: input.orgId, - ver: undefined, }); describe('useAuth', () => {