From 268097c1584e43e941d93d83574eb4d9b8b88498 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Thu, 1 Oct 2026 20:57:29 +0530 Subject: [PATCH] feat(anonymous): support spa-js v2.28 session transfer ticket and session expiry surfacing --- EXAMPLES.md | 20 ++++++++- __mocks__/@auth0/auth0-spa-js.tsx | 2 + __tests__/anonymous.test.tsx | 72 +++++++++++++++++++++++++++++-- package-lock.json | 12 +++--- package.json | 2 +- src/auth0-context.tsx | 1 + 6 files changed, 97 insertions(+), 12 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 2b7cd872..86f6e2dc 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2195,6 +2195,7 @@ Access anonymous session operations through the `anonymous` property from `useAu - [Getting an access token](#getting-an-anonymous-access-token) - [Explicit session creation with metadata](#explicit-anonymous-session-creation-with-metadata) - [Multiple audiences](#multiple-anonymous-audiences) +- [Linking to an authenticated user](#linking-to-an-authenticated-user) - [Ending the session](#ending-the-anonymous-session) - [Storage modes](#anonymous-session-storage-modes) @@ -2267,9 +2268,24 @@ const { accessToken: tokenB } = await anonymous.getTokenSilently({ }); ``` -### Ending the anonymous session +### Linking to an authenticated user + +When the user logs in, the SDK automatically mints a short-lived transfer ticket from the stored session token and passes it to `/authorize` as `anon_transfer_token`. This works for both `loginWithRedirect()` and `loginWithPopup()` — no extra configuration needed. + +Auth0 delivers the anonymous identity to your Post-Login Action as `event.anonymous_session`: -> **Note:** If you want the anonymous identity to be available for linking during login, call `loginWithRedirect()` before `anonymous.logout()`. Auth0 reads the anonymous session cookie during the login flow. Clearing it first means the identity will not be available in Post-Login Actions. +```js +exports.onExecutePostLogin = async (event, api) => { + if (event.anonymous_session) { + api.idToken.setCustomClaim('https://anon/metadata', event.anonymous_session.metadata); + api.idToken.setCustomClaim('https://anon/user_id', event.anonymous_session.user_id); + } +}; +``` + +The anonymous session is **not** automatically cleared after login. Call `anonymous.logout()` explicitly once you have finished using the session data. + +### Ending the anonymous session ```jsx const { anonymous } = useAuth0(); diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index e6badb87..a70b276b 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -44,6 +44,7 @@ const anonymousGetTokenSilently = jest.fn(() => Promise.resolve({ accessToken: ' const anonymousLogout = jest.fn(() => Promise.resolve()); const anonymousHasSession = jest.fn(() => false); const anonymousGetClaims = jest.fn(() => null); +const anonymousMintTransferToken = jest.fn(() => Promise.resolve('transfer-ticket-jwe')); export const Auth0Client = jest.fn(() => { return { @@ -95,6 +96,7 @@ export const Auth0Client = jest.fn(() => { logout: anonymousLogout, hasSession: anonymousHasSession, getClaims: anonymousGetClaims, + mintTransferToken: anonymousMintTransferToken, }, }; }); diff --git a/__tests__/anonymous.test.tsx b/__tests__/anonymous.test.tsx index 7d8d0bb6..f59136aa 100644 --- a/__tests__/anonymous.test.tsx +++ b/__tests__/anonymous.test.tsx @@ -1,4 +1,4 @@ -import { Auth0Client, AnonymousSession } from '@auth0/auth0-spa-js'; +import { Auth0Client, AnonymousSession, AnonymousSessionError } from '@auth0/auth0-spa-js'; import { act, renderHook, waitFor } from '@testing-library/react'; import useAuth0 from '../src/use-auth0'; import { createWrapper } from './helpers'; @@ -63,7 +63,7 @@ describe('Anonymous Session API', () => { }); it('should rethrow errors from getTokenSilently', async () => { - clientMock.anonymous.getTokenSilently.mockRejectedValueOnce(new Error('session_expired')); + clientMock.anonymous.getTokenSilently.mockRejectedValueOnce(new Error('network_error')); const wrapper = createWrapper(); const { result } = renderHook(() => useAuth0(), { wrapper }); @@ -74,7 +74,32 @@ describe('Anonymous Session API', () => { act(async () => { await result.current.anonymous.getTokenSilently({ audience: 'https://api.example.com' }); }) - ).rejects.toThrow('session_expired'); + ).rejects.toThrow('network_error'); + }); + + it('should surface AnonymousSessionError when the session has expired', async () => { + const expiredError = new AnonymousSessionError( + 'session_expired', + 'The anonymous session has expired' + ); + clientMock.anonymous.getTokenSilently.mockRejectedValueOnce(expiredError); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let caughtError: unknown; + await act(async () => { + try { + await result.current.anonymous.getTokenSilently({ audience: 'https://api.example.com' }); + } catch (e) { + caughtError = e; + } + }); + + expect(caughtError).toBeInstanceOf(AnonymousSessionError); + expect((caughtError as AnonymousSessionError).code).toBe('session_expired'); }); }); @@ -185,4 +210,45 @@ describe('Anonymous Session API', () => { expect(result.current.anonymous.getClaims()).toBeNull(); }); }); + + describe('anonymous.mintTransferToken', () => { + it('should be defined', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => { + expect(result.current.anonymous.mintTransferToken).toBeDefined(); + }); + }); + + it('should return a transfer ticket when a session exists', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let ticket: string | null | undefined; + await act(async () => { + ticket = await result.current.anonymous.mintTransferToken(); + }); + + expect(ticket).toBe('transfer-ticket-jwe'); + }); + + it('should return null when no session exists', async () => { + clientMock.anonymous.mintTransferToken.mockResolvedValueOnce(null); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let ticket: string | null | undefined; + await act(async () => { + ticket = await result.current.anonymous.mintTransferToken(); + }); + + expect(ticket).toBeNull(); + }); + }); }); diff --git a/package-lock.json b/package-lock.json index c2b04571..9ef034e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.27.0", "license": "MIT", "dependencies": { - "@auth0/auth0-spa-js": "^2.27.0" + "@auth0/auth0-spa-js": "^2.28.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.1", @@ -80,8 +80,8 @@ "license": "ISC" }, "node_modules/@auth0/auth0-auth-js": { - "version": "1.15.0", - "integrity": "sha512-obn/Qhxx3eNs2wV6/lLsIDIcEiSJvBEEy15hppB5Me1j2No2toa7KqQcXHBCyGX4mBBdVnxZJBjEX6E4WanYwg==", + "version": "1.16.0", + "integrity": "sha512-Yiz4ZJWrE0cBpqmr2fXOCQZuENVU0tHzi++O1I8D07vbcjRIUmKZC5oYBpPOGDtvfm1DTNTaQcBFvIN/Mwh4lg==", "license": "MIT", "dependencies": { "jose": "^6.0.8", @@ -89,11 +89,11 @@ } }, "node_modules/@auth0/auth0-spa-js": { - "version": "2.27.0", - "integrity": "sha512-E9U4v9IiowT+z26PQYT55ZAU5J1feyEV9umVX+lvnaHl43E3GvGVsTrfukhPLLWnoCRItWa2TxVQ3Svj1S4qyA==", + "version": "2.28.0", + "integrity": "sha512-2bUynidfpFEVb+yTQsr3ZoccmMtzoy46Cvy0fZtIGhbRs40/k975oh9o6j+ePF86Q5OQB5Cz4JAOnSICw4hpJg==", "license": "MIT", "dependencies": { - "@auth0/auth0-auth-js": "^1.15.0", + "@auth0/auth0-auth-js": "^1.16.0", "browser-tabs-lock": "^1.3.0", "dpop": "^2.1.1", "es-cookie": "~1.3.2" diff --git a/package.json b/package.json index 125a49e4..cd3296e6 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,6 @@ "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" }, "dependencies": { - "@auth0/auth0-spa-js": "^2.27.0" + "@auth0/auth0-spa-js": "^2.28.0" } } diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 6eda276d..fa040e4d 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -549,6 +549,7 @@ export const initialContext = { logout: stub, hasSession: stub, getClaims: stub, + mintTransferToken: stub, } as unknown as AnonymousSessionApiClient, };