diff --git a/packages/shared/src/types/jwtv2.ts b/packages/shared/src/types/jwtv2.ts index 1d8af24d979..cf00256763d 100644 --- a/packages/shared/src/types/jwtv2.ts +++ b/packages/shared/src/types/jwtv2.ts @@ -97,6 +97,13 @@ type JWTPayloadBase = { */ sts?: SessionStatusClaim; + /** + * Reserved session claim. The claim shape is opaque and may change + * without notice. Do not depend on its presence or absence for + * authorization checks. + */ + ams?: AmsClaim; + /** * Any other JWT Claim Set member. */ @@ -213,3 +220,11 @@ export type AgentActClaim = ActClaim & { type: 'agent' }; * The current state of the session which can only be `active` or `pending`. */ export type SessionStatusClaim = Extract; + +/** + * Opaque optional `ams` session claim. Consumers must not inspect its value + * or depend on its presence or absence for authorization checks. + * + * @inline + */ +export type AmsClaim = unknown; diff --git a/packages/ui/src/components/UserButton/SessionActions.tsx b/packages/ui/src/components/UserButton/SessionActions.tsx index 2947759741e..8c9e02b5482 100644 --- a/packages/ui/src/components/UserButton/SessionActions.tsx +++ b/packages/ui/src/components/UserButton/SessionActions.tsx @@ -10,6 +10,7 @@ import { USER_BUTTON_ITEM_ID } from '../../constants'; import { useUserButtonContext } from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { descriptors, Flex, localizationKeys } from '../../customizables'; +import { useAms } from '../../hooks/useAms'; import { Add, CogFilled, SignOut, SwitchArrowRight } from '../../icons'; import type { ThemableCssProp } from '../../styledSystem'; import type { DefaultItemIds, MenuItem } from '../../utils/createCustomMenuItems'; @@ -138,6 +139,7 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => { } = props; const { menutItems } = useUserButtonContext(); + const ams = useAms(); const handleActionClick = async (route: MenuItem) => { if (route?.path) { @@ -172,18 +174,20 @@ export const MultiSessionActions = (props: MultiSessionActionsProps) => { justify='between' sx={t => ({ marginInlineStart: t.space.$12, padding: `0 ${t.space.$5} ${t.space.$4}`, gap: t.space.$2 })} > - + {!ams.isActive && ( + + )} { return createUserButtonCustomMenuItems(customMenuItems || [], clerk); }, []); + // When the active session carries the `ams` claim, the "Manage account" + // entry would launch a modal whose underlying writes are + // rejected by the issuer. Strip it from the menu while the claim is + // active so users aren't presented with actions that can never succeed. + const ams = useAms(); + const visibleMenuItems = ams.isActive + ? menuItems.filter((item: MenuItem) => item.id !== USER_BUTTON_ITEM_ID.MANAGE_ACCOUNT) + : menuItems; + return { ...ctx, componentName, @@ -50,6 +61,6 @@ export const useUserButtonContext = () => { afterSignOutUrl, afterSwitchSessionUrl, userProfileMode: userProfileMode || 'modal', - menutItems: menuItems, + menutItems: visibleMenuItems, }; }; diff --git a/packages/ui/src/hooks/__tests__/useAms.test.ts b/packages/ui/src/hooks/__tests__/useAms.test.ts new file mode 100644 index 00000000000..b8716f88310 --- /dev/null +++ b/packages/ui/src/hooks/__tests__/useAms.test.ts @@ -0,0 +1,54 @@ +import type { JwtPayload } from '@clerk/shared/types'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAms } from '../useAms'; + +const { mockUseSession } = vi.hoisted(() => ({ + mockUseSession: vi.fn(), +})); + +vi.mock('@clerk/shared/react', () => ({ + useSession: mockUseSession, +})); + +const mockClaims = (claims: JwtPayload | undefined) => { + mockUseSession.mockReturnValue({ + session: claims + ? { + lastActiveToken: { + jwt: { claims }, + }, + } + : null, + }); +}; + +describe('useAms', () => { + beforeEach(() => { + mockUseSession.mockReset(); + }); + + it('returns inactive when the ams claim is absent', () => { + mockClaims({ __raw: 'token' } as JwtPayload); + + const { result } = renderHook(() => useAms()); + + expect(result.current).toEqual({ isActive: false }); + }); + + it('returns active when the ams claim is present without reading its value', () => { + const claims = { __raw: 'token' } as JwtPayload; + Object.defineProperty(claims, 'ams', { + enumerable: true, + get: () => { + throw new Error('ams should be opaque'); + }, + }); + mockClaims(claims); + + const { result } = renderHook(() => useAms()); + + expect(result.current).toEqual({ isActive: true }); + }); +}); diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 796773dd1d4..5f7d7e223b1 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useAms'; export * from './useClerkModalStateParams'; export * from './useClipboard'; export * from './useDebounce'; diff --git a/packages/ui/src/hooks/useAms.ts b/packages/ui/src/hooks/useAms.ts new file mode 100644 index 00000000000..f17a5ce8e07 --- /dev/null +++ b/packages/ui/src/hooks/useAms.ts @@ -0,0 +1,32 @@ +import { useSession } from '@clerk/shared/react'; + +/** + * Return shape for {@link useAms}. The claim is opaque, so callers should + * only branch on whether it is present. + */ +export type UseAmsReturn = { + isActive: boolean; +}; + +const INACTIVE: UseAmsReturn = { + isActive: false, +}; + +/** + * Returns information about the optional `ams` claim on the active + * session. The claim shape is intentionally opaque. + * + * Reactive — re-renders when the session token rotates. + */ +export const useAms = (): UseAmsReturn => { + const { session } = useSession(); + const claims = session?.lastActiveToken?.jwt?.claims; + + if (!claims || !Object.prototype.hasOwnProperty.call(claims, 'ams')) { + return INACTIVE; + } + + return { + isActive: true, + }; +};