From 0041e544253769d6a638854f86d010137e2b23ab Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Mon, 13 Jul 2026 13:21:26 -0300 Subject: [PATCH 1/3] feat: trigger the deep-link login handoff via flow=deeplink and a UUID request id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/auth/requests/:requestId` deep-link login handoff (post the signed identity, then open the client via `open?signin=`, skipping the recover/verify flow) is now keyed on the `?flow=deeplink` query param (compared case-insensitively) instead of the `client-login` magic request id. The route id must be a valid UUID v4 — the client-generated id that correlates the login with the instance that requested it — and is forwarded to the client as the deep link's `authRequestId`. A `flow=deeplink` request whose id is not a valid UUID v4 is rejected up front with the error view (no identity post, no recover). This removes the need to retrieve an id from, or request, the auth-server when the flag is set. The `client-login` pseudo request id and the recover-based deep-link sign-in variant (VerifySignIn deep-link copy, DEEP_LINK_SIGN_IN tracking, the now-unused `recover` isDeepLinkFlow argument) are removed, along with their dead translation keys. - Add `isDeepLinkFlowEnabled` (case-insensitive) and `isValidUuidV4` to shared/locations; drop `CLIENT_LOGIN_REQUEST_ID`. - RequestPage keys the handoff off `isDeepLinkFlow`, guards a malformed id with an explanatory error, and forwards the route UUID as the signin authRequestId. - Drop the unreachable `isDeepLinkFlow` param from the auth clients' `recover`. - Update unit and e2e specs to the new contract. --- e2e/tests/request-edge-cases.spec.ts | 131 ++++++------------ .../MobileAuthPage/MobileAuthSuccess.tsx | 2 +- .../Pages/RequestPage/Container/Container.tsx | 4 +- .../Pages/RequestPage/RequestPage.spec.tsx | 102 ++++++++------ .../Pages/RequestPage/RequestPage.tsx | 56 +++++--- .../Views/VerifySignIn/VerifySignIn.tsx | 15 +- .../Views/VerifySignIn/VerifySignIn.types.ts | 1 - src/modules/analytics/types.ts | 1 - src/modules/translations/en.json | 2 - src/modules/translations/es.json | 2 - src/modules/translations/fr.json | 2 - src/modules/translations/it.json | 2 - src/modules/translations/zh.json | 2 - src/shared/auth/httpClient.ts | 4 +- src/shared/auth/wsClient.ts | 4 +- src/shared/locations.spec.ts | 84 +++++++++++ src/shared/locations.ts | 27 +++- 17 files changed, 249 insertions(+), 192 deletions(-) diff --git a/e2e/tests/request-edge-cases.spec.ts b/e2e/tests/request-edge-cases.spec.ts index cf6b313a..a606d0c9 100644 --- a/e2e/tests/request-edge-cases.spec.ts +++ b/e2e/tests/request-edge-cases.spec.ts @@ -6,107 +6,30 @@ import { } from '../helpers/setup' import { recoverRequestDifferentSenderResponse, - recoverRequestExpiredResponse, - recoverRequestResponse + recoverRequestExpiredResponse } from '../fixtures/mock-responses' -test.describe('Deep link flow (flow=deeplink)', () => { - /** - * The deep link flow is used when Explorer opens auth with ?flow=deeplink. - * A dcl_personal_sign request still sends its signature as the outcome (the client is - * waiting on it) and lands on the sign-in completion view; the deep-link flag only changes - * the confirmation UX (no code, "Sign In" wording). It does NOT post an identity — that - * mechanism is only for the client-login pseudo-request (see the client-login suite below). - */ - - test.beforeEach(async ({ context }) => { - await injectMockWallet(context) - }) - - test('deeplink flow: verify screen shows different text (no code, "Confirm" language)', async ({ page }) => { - await mockApiRoutes(page, { hasProfile: true, onboardingToExplorer: true }) - - await page.goto(`/auth/requests/${MOCK_REQUEST_ID}?loginMethod=METAMASK&flow=deeplink`) - - await expect(page.getByText('Verify Sign In')).toBeVisible({ timeout: 15_000 }) - - // Deep link flow hides the verification code and shows different text - // Should NOT show the code number - await expect(page.getByText('1234')).not.toBeVisible() - - // Buttons should use deep link language - // "Sign In" instead of "Yes, they are the same" - await expect(page.locator('[data-testid="verify-sign-in-approve-button"]')).toBeVisible() - // "Cancel" instead of "No, it doesn't" - await expect(page.locator('[data-testid="verify-sign-in-deny-button"]')).toBeVisible() - }) - - test('deeplink flow: approve → sends the signature outcome → shows completion (no identity post, no ContinueInApp)', async ({ page }) => { - await mockApiRoutes(page, { hasProfile: true, onboardingToExplorer: true }) - - // A dcl_personal_sign request must send its signature as the OUTCOME; it must NOT post an - // identity (postIdentity is only for the client-login pseudo-request). - let postedIdentity = false - await page.route('**/identities', async (route, request) => { - if (request.method() === 'POST') postedIdentity = true - return route.continue() - }) - // Record the outcome POST, then defer to the mockApiRoutes handler that fulfills it. - let outcomeSent = false - await page.route('**/v2/requests/**', async (route, request) => { - if (request.method() === 'POST' && request.url().includes('/outcome')) { - outcomeSent = true - } - return route.fallback() - }) +// A valid UUID v4 route id — the client's correlation id required by the deep-link handoff. +const DEEP_LINK_REQUEST_ID = '123e4567-e89b-42d3-a456-426614174000' - await page.goto(`/auth/requests/${MOCK_REQUEST_ID}?loginMethod=METAMASK&flow=deeplink`) - - await expect(page.getByText('Verify Sign In')).toBeVisible({ timeout: 15_000 }) - - // Approve - await page.locator('[data-testid="verify-sign-in-approve-button"]').click() - - // Shows the sign-in completion view (SignInCompletePage), NOT the ContinueInApp countdown. - await expect(page.getByText(/Sign In successful/i)).toBeVisible({ timeout: 15_000 }) - await expect(page.locator('[data-testid="continue-in-app-return-button"]')).not.toBeVisible() - - // The signature outcome was sent and no identity was posted. - expect(outcomeSent).toBe(true) - expect(postedIdentity).toBe(false) - }) - - test('deeplink flow: deny → shows denied view', async ({ page }) => { - await mockApiRoutes(page, { hasProfile: true, onboardingToExplorer: true }) - - await page.goto(`/auth/requests/${MOCK_REQUEST_ID}?loginMethod=METAMASK&flow=deeplink`) - - await expect(page.getByText('Verify Sign In')).toBeVisible({ timeout: 15_000 }) - - // Click deny (Cancel in deeplink flow) - await page.locator('[data-testid="verify-sign-in-deny-button"]').click() - - // Should show denied state - await expect(page.getByText(/not match|not taken by you/i)).toBeVisible({ timeout: 5_000 }) - }) -}) - -test.describe('Client-login pseudo request id (/auth/requests/client-login)', () => { +test.describe('Deep link login handoff (flow=deeplink with a UUID v4 id)', () => { /** - * `/auth/requests/client-login` runs the deep-link login handoff without a backing - * auth-server request: the user logs in, the signed identity is posted to the - * auth server, and the client is opened immediately via the open?signin= - * deep link — same handoff as the standalone mobile flow, without any countdown. + * Opening auth with `?flow=deeplink` and a UUID v4 route id runs the deep-link login handoff + * WITHOUT a backing auth-server request: the user logs in, the signed identity is posted to the + * auth server, and the client is opened immediately via the `open?signin=` deep + * link — the same handoff as the standalone mobile flow, without any countdown. The route UUID + * is forwarded to the client as the deep link's `authRequestId` so it can correlate the login + * with the instance that requested it. It never recovers a request. */ test.beforeEach(async ({ context }) => { await injectMockWallet(context) }) - test('client-login id: auto-connect → posts identity → fires the deep link without recovering a request', async ({ page }) => { + test('valid UUID: auto-connect → posts identity → fires the deep link without recovering a request', async ({ page }) => { await mockApiRoutes(page, { hasProfile: true, onboardingToExplorer: true }) - // Track auth-server request recoveries — the pseudo id must never trigger one. + // Track auth-server request recoveries — the deep-link handoff must never trigger one. // Registered after mockApiRoutes so it runs first; fallback() defers to it. let recoverCalled = false await page.route('**/v2/requests/**', async (route, request) => { @@ -130,7 +53,7 @@ test.describe('Client-login pseudo request id (/auth/requests/client-login)', () return route.continue() }) - await page.goto('/auth/requests/client-login?loginMethod=METAMASK') + await page.goto(`/auth/requests/${DEEP_LINK_REQUEST_ID}?loginMethod=METAMASK&flow=deeplink`) // AutoLoginRedirect connects the mock wallet, generates the identity and returns // here; the identity is posted and the deep link fires immediately. Headless @@ -142,7 +65,33 @@ test.describe('Client-login pseudo request id (/auth/requests/client-login)', () expect(postedIdentity).toBe(true) expect(recoverCalled).toBe(false) - expect(page.url()).toContain('/auth/requests/client-login') + expect(page.url()).toContain(`/auth/requests/${DEEP_LINK_REQUEST_ID}`) + }) + + test('invalid (non-UUID) id: shows the error view without posting an identity or recovering a request', async ({ page }) => { + await mockApiRoutes(page, { hasProfile: true, onboardingToExplorer: true }) + + let recoverCalled = false + await page.route('**/v2/requests/**', async (route, request) => { + if (request.method() === 'GET' && !request.url().includes('/outcome')) { + recoverCalled = true + } + return route.fallback() + }) + + let postedIdentity = false + await page.route('**/identities', async (route, request) => { + if (request.method() === 'POST') postedIdentity = true + return route.continue() + }) + + // MOCK_REQUEST_ID is not a UUID v4 — a malformed deep-link id is rejected up front. + await page.goto(`/auth/requests/${MOCK_REQUEST_ID}?loginMethod=METAMASK&flow=deeplink`) + + await expect(page.locator('[data-testid="client-login-error-try-again-button"]')).toBeVisible({ timeout: 20_000 }) + + expect(postedIdentity).toBe(false) + expect(recoverCalled).toBe(false) }) }) diff --git a/src/components/Pages/MobileAuthPage/MobileAuthSuccess.tsx b/src/components/Pages/MobileAuthPage/MobileAuthSuccess.tsx index b3767985..b1f6468b 100644 --- a/src/components/Pages/MobileAuthPage/MobileAuthSuccess.tsx +++ b/src/components/Pages/MobileAuthPage/MobileAuthSuccess.tsx @@ -22,7 +22,7 @@ export const MobileAuthSuccess = ({ identityId, explorerText, onTryAgain }: Prop const [deepLinkFailed, setDeepLinkFailed] = useState(false) // Reuse the shared builder so the mobile handoff carries dclenv (on non-production) - // like the desktop/client-login deep links, instead of a bare signin URL. + // like the desktop deep-link login handoff, instead of a bare signin URL. const deepLinkUrl = getSigninDeeplink(undefined, identityId) const hasLaunchedRef = useRef(false) diff --git a/src/components/Pages/RequestPage/Container/Container.tsx b/src/components/Pages/RequestPage/Container/Container.tsx index 4b6ced3c..7086f516 100644 --- a/src/components/Pages/RequestPage/Container/Container.tsx +++ b/src/components/Pages/RequestPage/Container/Container.tsx @@ -6,7 +6,7 @@ import { useMobileMediaQuery } from 'decentraland-ui2' import { useNavigateWithSearchParams } from '../../../../hooks/navigation' import { useTargetConfig } from '../../../../hooks/targetConfig' import { useCurrentConnectionData } from '../../../../shared/connection' -import { buildRequestPageUrl, getAuthRequestId, isBridgeOnlyEnabled } from '../../../../shared/locations' +import { buildRequestPageUrl, getAuthRequestId, isBridgeOnlyEnabled, isDeepLinkFlowEnabled } from '../../../../shared/locations' import { AnimatedBackground } from '../../../AnimatedBackground' import { CustomWearablePreview } from '../../../CustomWearablePreview' import styles from './Container.module.css' @@ -20,7 +20,7 @@ export const Container = (props: { children: ReactNode; requestId?: string; canC const navigate = useNavigateWithSearchParams() const isMobile = useMobileMediaQuery() const { account } = useCurrentConnectionData() - const isDeepLinkFlow = searchParams.get('flow') === 'deeplink' + const isDeepLinkFlow = isDeepLinkFlowEnabled(searchParams) const isBridgeOnly = isBridgeOnlyEnabled(searchParams) const authRequestId = getAuthRequestId(searchParams) diff --git a/src/components/Pages/RequestPage/RequestPage.spec.tsx b/src/components/Pages/RequestPage/RequestPage.spec.tsx index 784aa9f0..19cd1d93 100644 --- a/src/components/Pages/RequestPage/RequestPage.spec.tsx +++ b/src/components/Pages/RequestPage/RequestPage.spec.tsx @@ -81,13 +81,18 @@ jest.mock('../../../shared/auth', () => { }) // --- Shared modules --- -jest.mock('../../../shared/locations', () => ({ - extractReferrerFromSearchParameters: jest.fn().mockReturnValue(null), - isBridgeOnlyEnabled: jest.fn().mockReturnValue(false), - getAuthRequestId: jest.fn().mockReturnValue(null), - CLIENT_LOGIN_REQUEST_ID: 'client-login', - buildRequestPageUrl: (requestId: string, targetConfigId: string) => `/auth/requests/${requestId}?targetConfigId=${targetConfigId}` -})) +jest.mock('../../../shared/locations', () => { + // Keep the pure `flow`/UUID parsers real (tests drive the flow through the URL) and only mock the + // helpers a test needs to control. + const actual = jest.requireActual('../../../shared/locations') + return { + ...actual, + extractReferrerFromSearchParameters: jest.fn().mockReturnValue(null), + isBridgeOnlyEnabled: jest.fn().mockReturnValue(false), + getAuthRequestId: jest.fn().mockReturnValue(null), + buildRequestPageUrl: (requestId: string, targetConfigId: string) => `/auth/requests/${requestId}?targetConfigId=${targetConfigId}` + } +}) jest.mock('../../../shared/utils/analytics', () => ({ identifyUser: jest.fn() })) @@ -257,7 +262,9 @@ jest.mock('@dcl/hooks', () => ({ })) const REQUEST_ID = 'test-request-123' -const CLIENT_LOGIN_REQUEST_PATH = '/auth/requests/client-login?targetConfigId=default' +// The deep-link handoff requires a valid UUID v4 route id (the client's correlation id). +const DEEP_LINK_REQUEST_ID = '123e4567-e89b-42d3-a456-426614174000' +const DEEP_LINK_REQUEST_PATH = `/auth/requests/${DEEP_LINK_REQUEST_ID}?targetConfigId=default&flow=deeplink` let mockFlags: Partial> let mockFlagsInitialized: boolean @@ -548,18 +555,6 @@ describe('RequestPage', () => { expect(mockEnsureProfile).not.toHaveBeenCalled() }) - it('should skip auto-sign and show confirmation page for new user in deep link flow', async () => { - jest.mocked(fetchProfile).mockResolvedValue(null) - jest.mocked(isProfileComplete).mockReturnValue(false) - - renderRequestPage(`/auth/requests/${REQUEST_ID}?targetConfigId=default&flow=deeplink`) - await waitFor(() => { - expect(screen.getByTestId('verify-sign-in')).toBeInTheDocument() - }) - expect(mockSignMessage).not.toHaveBeenCalled() - expect(mockSendSuccessfulOutcome).not.toHaveBeenCalled() - }) - it('should fall through to the verify screen for a new user when the auto-sign message is malformed', async () => { // New user (no profile) but the request carries a non-string message: instead of dead-ending // on the loading spinner, the flow falls through to the normal verification screen. @@ -581,7 +576,7 @@ describe('RequestPage', () => { }) }) - describe('and the request id is the client-login pseudo request id', () => { + describe('and the flow is a deep-link handoff (flow=deeplink with a UUID v4 id)', () => { beforeEach(() => { mockEnsureProfile.mockResolvedValue({ avatars: [{ name: 'User' }] }) }) @@ -592,7 +587,7 @@ describe('RequestPage', () => { }) it('should post the identity to the auth server and show the continue in app view', async () => { - renderRequestPage(CLIENT_LOGIN_REQUEST_PATH) + renderRequestPage(DEEP_LINK_REQUEST_PATH) await waitFor(() => { expect(screen.getByTestId('continue-in-app')).toBeInTheDocument() }) @@ -600,19 +595,27 @@ describe('RequestPage', () => { }) it('should not recover any request from the auth server', async () => { - renderRequestPage(CLIENT_LOGIN_REQUEST_PATH) + renderRequestPage(DEEP_LINK_REQUEST_PATH) await waitFor(() => { expect(screen.getByTestId('continue-in-app')).toBeInTheDocument() }) expect(mockRecover).not.toHaveBeenCalled() }) - it('should build the signin deep link without the bridgeOnly flag or an authRequestId', async () => { - renderRequestPage(CLIENT_LOGIN_REQUEST_PATH) + it('should forward the route UUID to the client as the signin deep link authRequestId', async () => { + renderRequestPage(DEEP_LINK_REQUEST_PATH) + await waitFor(() => { + expect(screen.getByTestId('continue-in-app')).toBeInTheDocument() + }) + expect(jest.mocked(getSigninDeeplink)).toHaveBeenCalledWith(undefined, 'anIdentityId', false, DEEP_LINK_REQUEST_ID) + }) + + it('should accept the flow value case-insensitively', async () => { + renderRequestPage(`/auth/requests/${DEEP_LINK_REQUEST_ID}?targetConfigId=default&flow=DeepLink`) await waitFor(() => { expect(screen.getByTestId('continue-in-app')).toBeInTheDocument() }) - expect(jest.mocked(getSigninDeeplink)).toHaveBeenCalledWith(undefined, 'anIdentityId', false, null) + expect(mockPostIdentity).toHaveBeenCalled() }) }) @@ -627,15 +630,15 @@ describe('RequestPage', () => { }) it('should build the signin deep link with the bridgeOnly flag', async () => { - renderRequestPage(CLIENT_LOGIN_REQUEST_PATH) + renderRequestPage(DEEP_LINK_REQUEST_PATH) await waitFor(() => { expect(screen.getByTestId('continue-in-app')).toBeInTheDocument() }) - expect(jest.mocked(getSigninDeeplink)).toHaveBeenCalledWith(undefined, 'anIdentityId', true, null) + expect(jest.mocked(getSigninDeeplink)).toHaveBeenCalledWith(undefined, 'anIdentityId', true, DEEP_LINK_REQUEST_ID) }) }) - describe('and an authRequestId is provided', () => { + describe('and a query authRequestId is also present', () => { beforeEach(() => { jest.mocked(getAuthRequestId).mockReturnValue('auth-req-abc') mockPostIdentity.mockResolvedValueOnce({ identityId: 'anIdentityId' }) @@ -645,12 +648,12 @@ describe('RequestPage', () => { jest.mocked(getAuthRequestId).mockReturnValue(null) }) - it('should build the signin deep link forwarding the authRequestId verbatim', async () => { - renderRequestPage(CLIENT_LOGIN_REQUEST_PATH) + it('should forward the route UUID as the authRequestId, not the query value', async () => { + renderRequestPage(DEEP_LINK_REQUEST_PATH) await waitFor(() => { expect(screen.getByTestId('continue-in-app')).toBeInTheDocument() }) - expect(jest.mocked(getSigninDeeplink)).toHaveBeenCalledWith(undefined, 'anIdentityId', false, 'auth-req-abc') + expect(jest.mocked(getSigninDeeplink)).toHaveBeenCalledWith(undefined, 'anIdentityId', false, DEEP_LINK_REQUEST_ID) }) }) @@ -660,7 +663,7 @@ describe('RequestPage', () => { }) it('should navigate to the login page without posting an identity', async () => { - renderRequestPage(CLIENT_LOGIN_REQUEST_PATH) + renderRequestPage(DEEP_LINK_REQUEST_PATH) await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith(expect.stringContaining('/login?redirectTo=')) }) @@ -674,12 +677,30 @@ describe('RequestPage', () => { }) it('should show the client login error view', async () => { - renderRequestPage(CLIENT_LOGIN_REQUEST_PATH) + renderRequestPage(DEEP_LINK_REQUEST_PATH) await waitFor(() => { expect(screen.getByTestId('client-login-error')).toBeInTheDocument() }) }) }) + + describe('and the route id is not a valid UUID v4', () => { + it('should show the error view without posting an identity or recovering a request', async () => { + renderRequestPage('/auth/requests/not-a-uuid?targetConfigId=default&flow=deeplink') + await waitFor(() => { + expect(screen.getByTestId('client-login-error')).toBeInTheDocument() + }) + expect(mockPostIdentity).not.toHaveBeenCalled() + expect(mockRecover).not.toHaveBeenCalled() + }) + + it('should explain that the sign-in link is invalid', async () => { + renderRequestPage('/auth/requests/not-a-uuid?targetConfigId=default&flow=deeplink') + await waitFor(() => { + expect(screen.getByTestId('client-login-error')).toHaveTextContent('The sign-in link is invalid.') + }) + }) + }) }) describe('and the wallet changes while profile consistency is loading', () => { @@ -764,23 +785,12 @@ describe('RequestPage', () => { expect(await screen.findByTestId('sign-in-complete')).toBeInTheDocument() }) - it('should not post the identity (that mechanism is only for the client-login flow)', async () => { + it('should not post the identity (that mechanism is only for the deep-link handoff)', async () => { renderRequestPage() await userEvent.click(await screen.findByTestId('verify-sign-in-approve')) await waitFor(() => expect(mockSendSuccessfulOutcome).toHaveBeenCalled()) expect(mockPostIdentity).not.toHaveBeenCalled() }) - - describe('and the flow is a deep-link flow', () => { - it('should still send the outcome and not post the identity', async () => { - renderRequestPage(`/auth/requests/${REQUEST_ID}?targetConfigId=default&flow=deeplink`) - await userEvent.click(await screen.findByTestId('verify-sign-in-approve')) - await waitFor(() => { - expect(mockSendSuccessfulOutcome).toHaveBeenCalledWith(REQUEST_ID, '0xabc123', '0xsignature') - }) - expect(mockPostIdentity).not.toHaveBeenCalled() - }) - }) }) describe('when approving a plain signature request', () => { diff --git a/src/components/Pages/RequestPage/RequestPage.tsx b/src/components/Pages/RequestPage/RequestPage.tsx index ac2ee64f..eba02719 100644 --- a/src/components/Pages/RequestPage/RequestPage.tsx +++ b/src/components/Pages/RequestPage/RequestPage.tsx @@ -30,11 +30,12 @@ import { isSocialProviderType, useCurrentConnectionData } from '../../../shared/ import { isSessionMismatch } from '../../../shared/connection/sessionMismatch' import { isErrorWithMessage, isRpcError, isUserRejectedTransaction } from '../../../shared/errors' import { - CLIENT_LOGIN_REQUEST_ID, buildRequestPageUrl, extractReferrerFromSearchParameters, getAuthRequestId, - isBridgeOnlyEnabled + isBridgeOnlyEnabled, + isDeepLinkFlowEnabled, + isValidUuidV4 } from '../../../shared/locations' import { sendTipNotification } from '../../../shared/notifications' import { isProfileComplete } from '../../../shared/profile' @@ -212,11 +213,15 @@ export const RequestPage = () => { // informative UI is not shown for them. const isUserUsingWeb2Wallet = isSocialProviderType(providerType) const authServerClient = useRef(createAuthServerHttpClient()) - const isDeepLinkFlow = searchParams.get('flow') === 'deeplink' - // The `client-login` pseudo request id has no backing auth-server request: skip the - // whole recover/verify flow and hand the signed identity to the client via the deep - // link, the same way the standalone mobile flow does. - const isClientLoginFlow = requestId === CLIENT_LOGIN_REQUEST_ID + // The deep-link flow (opted in via `?flow=deeplink`, compared case-insensitively) has no + // backing auth-server request: skip the whole recover/verify flow and hand the signed identity + // to the client via the `open?signin=` deep link, the same way the standalone mobile + // flow does. Its request id is a client-generated UUID v4 used to correlate the login with the + // instance that requested it — forwarded to the client as the deep link's `authRequestId`. + const isDeepLinkFlow = isDeepLinkFlowEnabled(searchParams) + // A deep-link handoff requires a valid UUID v4 id. A malformed id is a client bug, so reject it + // with the error view instead of posting an identity for it. + const isInvalidDeepLinkId = isDeepLinkFlow && !isValidUuidV4(requestId) // The bridgeOnly flag rides inside redirectTo so it survives logins/callbacks and can be // appended to the client deep link once the flow completes. const isBridgeOnly = isBridgeOnlyEnabled(searchParams) @@ -238,6 +243,9 @@ export const RequestPage = () => { // Navigates to setup if the profile is incomplete or missing. // Sets isProfileReady=true once the profile is confirmed complete (or skipped). useEffect(() => { + // A deep-link handoff with a malformed id short-circuits to the error view (see the load + // effect below); skip the profile work so it can't navigate to setup for a request we reject. + if (isInvalidDeepLinkId) return if (isConnecting || !account || !provider || !providerType) return if (!initializedFlags) return @@ -286,6 +294,7 @@ export const RequestPage = () => { requestId, targetConfigId, isDeepLinkFlow, + isInvalidDeepLinkId, isBridgeOnly, authRequestId, searchParams, @@ -294,6 +303,15 @@ export const RequestPage = () => { // Effect 2: Load the request once the user is connected and the profile is ready. useEffect(() => { + // A deep-link handoff requires a valid UUID v4 id (the client's correlation id). Reject a + // malformed id up front with the error view instead of running the login handoff for it. + // Surface the reason (rendered as the error detail) so the copy matches the cause — a retry + // won't fix a bad id, but the message tells the user the link itself is the problem. + if (isInvalidDeepLinkId) { + setError('The sign-in link is invalid.') + setView(View.CLIENT_LOGIN_ERROR) + return + } if (isConnecting) return if (!account || !provider || !providerType) { @@ -374,8 +392,9 @@ export const RequestPage = () => { try { const [signerAddress] = await walletClientRef.current.getAddresses() identifyUser(signerAddress) - // Recover the request from the auth server. - const request = await authServerClient.current.recover(requestId, signerAddress, isDeepLinkFlow) + // Recover the request from the auth server. Only the non-deep-link flow reaches here — the + // deep-link handoff has no backing request and never recovers. + const request = await authServerClient.current.recover(requestId, signerAddress) if (cancelled) return @@ -473,7 +492,7 @@ export const RequestPage = () => { // showing the verification code screen. This matches the old behavior where // SetupPage signed the request automatically after profile deployment. // Returning users (with profile) still see the verification screen. - if (skipSetup && !isDeepLinkFlow) { + if (skipSetup) { const userProfile = await fetchProfile(signerAddress) if (cancelled) return @@ -774,7 +793,7 @@ export const RequestPage = () => { } } - if (isClientLoginFlow) { + if (isDeepLinkFlow) { completeClientLoginFlow() } else { loadRequest() @@ -795,7 +814,7 @@ export const RequestPage = () => { isProfileReady, requestId, isDeepLinkFlow, - isClientLoginFlow, + isInvalidDeepLinkId, skipSetup ]) @@ -1153,15 +1172,15 @@ export const RequestPage = () => { trackClick(ClickEvents.IDENTITY_DEEP_LINK_OPENED) } - // Client-login flow: the client was opened via the deep link and there is nothing to + // Deep-link flow: the client was opened via the deep link and there is nothing to // navigate to — stay on the ContinueInApp view, which doubles as the retry fallback. - if (isClientLoginFlow) return + if (isDeepLinkFlow) return // The deep link already fired in ContinueInApp — skip the auto-redirect in SignInCompletePage setSkipDeepLinkRedirect(true) // Show completion view setView(View.VERIFY_SIGN_IN_COMPLETE) - }, [identityId, trackClick, isClientLoginFlow]) + }, [identityId, trackClick, isDeepLinkFlow]) const onRetryClientLogin = useCallback(() => { // A fresh mount re-runs completeClientLoginFlow: the view resets to LOADING_REQUEST and @@ -1233,8 +1252,10 @@ export const RequestPage = () => { ) case View.VERIFY_SIGN_IN_DENIED: @@ -1261,7 +1282,6 @@ export const RequestPage = () => { isLoading={isLoading} hasTimedOut={hasTimedOut} explorerText={targetConfig.explorerText} - isDeepLinkFlow={isDeepLinkFlow} onDeny={onDenyVerifySignIn} onApprove={onApproveSignInVerification} /> diff --git a/src/components/Pages/RequestPage/Views/VerifySignIn/VerifySignIn.tsx b/src/components/Pages/RequestPage/Views/VerifySignIn/VerifySignIn.tsx index 25552a62..9ab05d88 100644 --- a/src/components/Pages/RequestPage/Views/VerifySignIn/VerifySignIn.tsx +++ b/src/components/Pages/RequestPage/Views/VerifySignIn/VerifySignIn.tsx @@ -15,7 +15,6 @@ export const VerifySignIn = ({ isLoading = false, hasTimedOut = false, explorerText = 'Explorer', - isDeepLinkFlow = false, onDeny, onApprove }: VerifySignInProps) => { @@ -25,14 +24,8 @@ export const VerifySignIn = ({ {t('request.verify_sign_in')} - {!isDeepLinkFlow && ( - <> - {t('request.verification_match', { explorerText })} - {code !== undefined && {code}} - - )} - - {isDeepLinkFlow && {t('request.deep_link_confirm', { explorerText })}} + {t('request.verification_match', { explorerText })} + {code !== undefined && {code}} } data-testid="verify-sign-in-deny-button" > - {isDeepLinkFlow ? t('common.cancel') : t('request.no_it_doesnt')} + {t('request.no_it_doesnt')} : } data-testid="verify-sign-in-approve-button" > - {isDeepLinkFlow ? t('request.sign_in') : t('request.yes_same')} + {t('request.yes_same')} diff --git a/src/components/Pages/RequestPage/Views/VerifySignIn/VerifySignIn.types.ts b/src/components/Pages/RequestPage/Views/VerifySignIn/VerifySignIn.types.ts index 7dd641ec..7323376d 100644 --- a/src/components/Pages/RequestPage/Views/VerifySignIn/VerifySignIn.types.ts +++ b/src/components/Pages/RequestPage/Views/VerifySignIn/VerifySignIn.types.ts @@ -4,7 +4,6 @@ export interface VerifySignInProps { isLoading?: boolean hasTimedOut?: boolean explorerText?: string - isDeepLinkFlow?: boolean onDeny: () => void onApprove: () => void } diff --git a/src/modules/analytics/types.ts b/src/modules/analytics/types.ts index d6565a3e..8059f7e7 100644 --- a/src/modules/analytics/types.ts +++ b/src/modules/analytics/types.ts @@ -24,7 +24,6 @@ enum TrackingEvents { enum RequestInteractionType { VERIFY_SIGN_IN = 'Verify sign in', - DEEP_LINK_SIGN_IN = 'Deep link sign in', WALLET_INTERACTION = 'Wallet interaction' } diff --git a/src/modules/translations/en.json b/src/modules/translations/en.json index fbf2bbdd..23efda52 100644 --- a/src/modules/translations/en.json +++ b/src/modules/translations/en.json @@ -64,10 +64,8 @@ "request": { "verify_sign_in": "Verify Sign In", "verification_match": "Does the verification number below match the one in the {explorerText}?", - "deep_link_confirm": "Please confirm you want to sign in to {explorerText}", "no_it_doesnt": "No, it doesn't", "yes_same": "Yes, they are the same", - "sign_in": "Sign In", "timeout_logged_out": "You might be logged out of your wallet extension.", "timeout_check_login": "Please check that you're logged in and try again.", "wallet_interaction": { diff --git a/src/modules/translations/es.json b/src/modules/translations/es.json index bc68db9e..a6eae528 100644 --- a/src/modules/translations/es.json +++ b/src/modules/translations/es.json @@ -64,10 +64,8 @@ "request": { "verify_sign_in": "Verificar inicio de sesión", "verification_match": "¿El número de verificación de abajo coincide con el del {explorerText}?", - "deep_link_confirm": "Por favor, confirma que deseas iniciar sesión en {explorerText}", "no_it_doesnt": "No, no coincide", "yes_same": "Sí, son iguales", - "sign_in": "Iniciar sesión", "timeout_logged_out": "Es posible que hayas cerrado sesión en la extensión de tu billetera.", "timeout_check_login": "Por favor, verifica que has iniciado sesión e inténtalo de nuevo.", "wallet_interaction": { diff --git a/src/modules/translations/fr.json b/src/modules/translations/fr.json index 368202bf..490f9c49 100644 --- a/src/modules/translations/fr.json +++ b/src/modules/translations/fr.json @@ -64,10 +64,8 @@ "request": { "verify_sign_in": "Vérifier la connexion", "verification_match": "Le numéro de vérification ci-dessous correspond-il à celui dans le {explorerText} ?", - "deep_link_confirm": "Veuillez confirmer que vous souhaitez vous connecter à {explorerText}", "no_it_doesnt": "Non, il ne correspond pas", "yes_same": "Oui, ils sont identiques", - "sign_in": "Se connecter", "timeout_logged_out": "Vous êtes peut-être déconnecté de votre extension de portefeuille.", "timeout_check_login": "Veuillez vérifier que vous êtes connecté et réessayer.", "wallet_interaction": { diff --git a/src/modules/translations/it.json b/src/modules/translations/it.json index a135e32d..5b5c206e 100644 --- a/src/modules/translations/it.json +++ b/src/modules/translations/it.json @@ -64,10 +64,8 @@ "request": { "verify_sign_in": "Verifica l'accesso", "verification_match": "Il numero di verifica qui sotto corrisponde a quello nel {explorerText}?", - "deep_link_confirm": "Conferma di voler accedere a {explorerText}", "no_it_doesnt": "No, non corrisponde", "yes_same": "Sì, sono uguali", - "sign_in": "Accedi", "timeout_logged_out": "Potresti essere disconnesso dalla tua estensione del portafoglio.", "timeout_check_login": "Verifica di essere connesso e riprova.", "wallet_interaction": { diff --git a/src/modules/translations/zh.json b/src/modules/translations/zh.json index 2b7e7d4b..bbb55469 100644 --- a/src/modules/translations/zh.json +++ b/src/modules/translations/zh.json @@ -64,10 +64,8 @@ "request": { "verify_sign_in": "验证登录", "verification_match": "下方的验证号码是否与 {explorerText} 中的匹配?", - "deep_link_confirm": "请确认您要登录到 {explorerText}", "no_it_doesnt": "不,不匹配", "yes_same": "是的,它们相同", - "sign_in": "登录", "timeout_logged_out": "您可能已从钱包扩展程序中登出。", "timeout_check_login": "请检查您是否已登录后重试。", "wallet_interaction": { diff --git a/src/shared/auth/httpClient.ts b/src/shared/auth/httpClient.ts index 03974a63..deac627b 100644 --- a/src/shared/auth/httpClient.ts +++ b/src/shared/auth/httpClient.ts @@ -134,7 +134,7 @@ export const createAuthServerHttpClient = (authServerUrl?: string) => { } } - const recover = async (requestId: string, signerAddress: string, isDeepLinkFlow = false): Promise => { + const recover = async (requestId: string, signerAddress: string): Promise => { let recoverResponse: RecoverResponse | undefined try { @@ -177,7 +177,7 @@ export const createAuthServerHttpClient = (authServerUrl?: string) => { switch (recoverResponse.method) { case 'dcl_personal_sign': trackEvent(TrackingEvents.REQUEST_INTERACTION, { - type: isDeepLinkFlow ? RequestInteractionType.DEEP_LINK_SIGN_IN : RequestInteractionType.VERIFY_SIGN_IN, + type: RequestInteractionType.VERIFY_SIGN_IN, browserTime: Date.now(), requestType: recoverResponse?.method }) diff --git a/src/shared/auth/wsClient.ts b/src/shared/auth/wsClient.ts index c3a08419..21342225 100644 --- a/src/shared/auth/wsClient.ts +++ b/src/shared/auth/wsClient.ts @@ -106,7 +106,7 @@ export const createAuthServerWsClient = (authServerUrl?: string) => { } } - const recover = async (requestId: string, signerAddress: string, isDeepLinkFlow = false): Promise => { + const recover = async (requestId: string, signerAddress: string): Promise => { let response: RecoverResponse | undefined try { @@ -141,7 +141,7 @@ export const createAuthServerWsClient = (authServerUrl?: string) => { switch (response.method) { case 'dcl_personal_sign': trackEvent(TrackingEvents.REQUEST_INTERACTION, { - type: isDeepLinkFlow ? RequestInteractionType.DEEP_LINK_SIGN_IN : RequestInteractionType.VERIFY_SIGN_IN, + type: RequestInteractionType.VERIFY_SIGN_IN, browserTime: Date.now(), requestTime: new Date(response.expiration).getTime(), requestType: response?.method diff --git a/src/shared/locations.spec.ts b/src/shared/locations.spec.ts index c041205d..e938a9a7 100644 --- a/src/shared/locations.spec.ts +++ b/src/shared/locations.spec.ts @@ -4,6 +4,8 @@ import { extractReferrerFromSearchParameters, getAuthRequestId, isBridgeOnlyEnabled, + isDeepLinkFlowEnabled, + isValidUuidV4, locations } from './locations' @@ -344,6 +346,88 @@ describe('locations', () => { }) }) + describe('when checking if the deep-link flow is enabled', () => { + let searchParams: URLSearchParams + + describe('and the flow param is set to "deeplink"', () => { + beforeEach(() => { + searchParams = new URLSearchParams('flow=deeplink') + }) + + it('should return true', () => { + expect(isDeepLinkFlowEnabled(searchParams)).toBe(true) + }) + }) + + describe('and the flow param is set to "deeplink" with different casing', () => { + beforeEach(() => { + searchParams = new URLSearchParams('flow=DeepLink') + }) + + it('should return true', () => { + expect(isDeepLinkFlowEnabled(searchParams)).toBe(true) + }) + }) + + describe('and the flow param is set to a different value', () => { + beforeEach(() => { + searchParams = new URLSearchParams('flow=other') + }) + + it('should return false', () => { + expect(isDeepLinkFlowEnabled(searchParams)).toBe(false) + }) + }) + + describe('and the flow param is not present', () => { + beforeEach(() => { + searchParams = new URLSearchParams('targetConfigId=default') + }) + + it('should return false', () => { + expect(isDeepLinkFlowEnabled(searchParams)).toBe(false) + }) + }) + }) + + describe('when validating a UUID v4', () => { + describe('and the value is a canonical lowercase UUID v4', () => { + it('should return true', () => { + expect(isValidUuidV4('123e4567-e89b-42d3-a456-426614174000')).toBe(true) + }) + }) + + describe('and the value is an uppercase UUID v4', () => { + it('should return true (case-insensitive)', () => { + expect(isValidUuidV4('123E4567-E89B-42D3-A456-426614174000')).toBe(true) + }) + }) + + describe('and the value is a non-v4 UUID (wrong version digit)', () => { + it('should return false', () => { + expect(isValidUuidV4('123e4567-e89b-12d3-a456-426614174000')).toBe(false) + }) + }) + + describe('and the value has an invalid variant digit', () => { + it('should return false', () => { + expect(isValidUuidV4('123e4567-e89b-42d3-c456-426614174000')).toBe(false) + }) + }) + + describe('and the value is not a UUID at all', () => { + it('should return false', () => { + expect(isValidUuidV4('client-login')).toBe(false) + }) + }) + + describe('and the value is an empty string', () => { + it('should return false', () => { + expect(isValidUuidV4('')).toBe(false) + }) + }) + }) + describe('when building the request page url', () => { describe('and an authRequestId is provided', () => { it('should append it url-encoded alongside the other preserved params', () => { diff --git a/src/shared/locations.ts b/src/shared/locations.ts index 40a6fd0e..729ef01b 100644 --- a/src/shared/locations.ts +++ b/src/shared/locations.ts @@ -174,10 +174,22 @@ const AUTH_REQUEST_ID_PARAM = 'authRequestId' const getAuthRequestId = (searchParams: URLSearchParams): string | null => searchParams.get(AUTH_REQUEST_ID_PARAM) -// Pseudo request id for `/auth/requests/client-login`. It has no backing auth-server -// request: the user just logs in and the signed identity is handed to the client through -// the `open?signin=` deep link, mirroring the standalone mobile flow. -const CLIENT_LOGIN_REQUEST_ID = 'client-login' +// The `flow` query param opts the request page into the deep-link login handoff: instead of +// fulfilling a backing auth-server request, the user logs in and the signed identity is handed to +// the client through the `open?signin=` deep link, mirroring the standalone mobile +// flow. Enabled by `?flow=deeplink`, compared case-insensitively. +const FLOW_PARAM = 'flow' +const DEEP_LINK_FLOW_VALUE = 'deeplink' + +const isDeepLinkFlowEnabled = (searchParams: URLSearchParams): boolean => + (searchParams.get(FLOW_PARAM) ?? '').toLowerCase() === DEEP_LINK_FLOW_VALUE + +// A canonical RFC 4122 version-4 UUID (case-insensitive). The deep-link handoff requires its +// request id to be one: it is the client-generated id that correlates the login with the instance +// that requested it, and it is forwarded to the client as the deep link's `authRequestId`. +const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +const isValidUuidV4 = (value: string): boolean => UUID_V4_REGEX.test(value) // Builds the request-page URL preserving the flags that must survive a login round-trip // (they ride inside `redirectTo`). Shared by RequestPage and the change-account link so @@ -187,8 +199,8 @@ const buildRequestPageUrl = ( targetConfigId: string, options: { isDeepLinkFlow?: boolean; isBridgeOnly?: boolean; authRequestId?: string | null } = {} ): string => { - const flowParam = options.isDeepLinkFlow ? '&flow=deeplink' : '' - const bridgeOnlyParam = options.isBridgeOnly ? '&bridgeOnly=true' : '' + const flowParam = options.isDeepLinkFlow ? `&${FLOW_PARAM}=${DEEP_LINK_FLOW_VALUE}` : '' + const bridgeOnlyParam = options.isBridgeOnly ? `&${BRIDGE_ONLY_PARAM}=true` : '' const authRequestIdParam = options.authRequestId ? `&${AUTH_REQUEST_ID_PARAM}=${encodeURIComponent(options.authRequestId)}` : '' return `/auth/requests/${requestId}?targetConfigId=${targetConfigId}${flowParam}${bridgeOnlyParam}${authRequestIdParam}` } @@ -203,6 +215,7 @@ export { BRIDGE_ONLY_PARAM, getAuthRequestId, AUTH_REQUEST_ID_PARAM, - CLIENT_LOGIN_REQUEST_ID, + isDeepLinkFlowEnabled, + isValidUuidV4, buildRequestPageUrl } From ebe092ee23c736a6b6f4a920a3dd0aa805785b0c Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Mon, 13 Jul 2026 13:28:15 -0300 Subject: [PATCH 2/3] test: cover the flow/UUID gating contract and UUID validation edges - A UUID v4 id without flow=deeplink goes through the normal recover flow (the handoff is gated on the flag, not the id shape) and posts no identity. - A malformed deep-link id skips the profile consistency check. - isDeepLinkFlowEnabled: a bare `?flow` flag (no value) is not the deep-link flow. - isValidUuidV4: reject surrounding whitespace and a trailing newline (guards the regex anchor, since the id is forwarded to the native client). --- .../Pages/RequestPage/RequestPage.spec.tsx | 33 +++++++++++++++++++ src/shared/locations.spec.ts | 24 ++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/components/Pages/RequestPage/RequestPage.spec.tsx b/src/components/Pages/RequestPage/RequestPage.spec.tsx index 19cd1d93..8396431e 100644 --- a/src/components/Pages/RequestPage/RequestPage.spec.tsx +++ b/src/components/Pages/RequestPage/RequestPage.spec.tsx @@ -694,6 +694,14 @@ describe('RequestPage', () => { expect(mockRecover).not.toHaveBeenCalled() }) + it('should not run the profile consistency check', async () => { + renderRequestPage('/auth/requests/not-a-uuid?targetConfigId=default&flow=deeplink') + await waitFor(() => { + expect(screen.getByTestId('client-login-error')).toBeInTheDocument() + }) + expect(mockEnsureProfile).not.toHaveBeenCalled() + }) + it('should explain that the sign-in link is invalid', async () => { renderRequestPage('/auth/requests/not-a-uuid?targetConfigId=default&flow=deeplink') await waitFor(() => { @@ -703,6 +711,31 @@ describe('RequestPage', () => { }) }) + describe('and the request id is a UUID v4 but the flow param is absent', () => { + // The handoff is gated on flow=deeplink, not on the id shape: a UUID alone must NOT trigger + // the identity post — it goes through the normal recover flow like any other request. + beforeEach(() => { + mockEnsureProfile.mockResolvedValue({ avatars: [{ name: 'User' }] }) + mockGetAddresses.mockResolvedValue(['0xabc123']) + mockRecover.mockResolvedValue({ + sender: '0xabc123', + expiration: new Date(Date.now() + 60000).toISOString(), + method: 'dcl_personal_sign', + code: '1234', + params: ['Sign this message'] + }) + }) + + it('should recover the request normally and not post an identity', async () => { + renderRequestPage(`/auth/requests/${DEEP_LINK_REQUEST_ID}?targetConfigId=default`) + await waitFor(() => { + expect(screen.getByTestId('verify-sign-in')).toBeInTheDocument() + }) + expect(mockRecover).toHaveBeenCalledWith(DEEP_LINK_REQUEST_ID, '0xabc123') + expect(mockPostIdentity).not.toHaveBeenCalled() + }) + }) + describe('and the wallet changes while profile consistency is loading', () => { let resolveProfile: (value: any) => void diff --git a/src/shared/locations.spec.ts b/src/shared/locations.spec.ts index e938a9a7..d17e56ea 100644 --- a/src/shared/locations.spec.ts +++ b/src/shared/locations.spec.ts @@ -379,6 +379,16 @@ describe('locations', () => { }) }) + describe('and the flow param is present as a bare flag without a value', () => { + beforeEach(() => { + searchParams = new URLSearchParams('flow') + }) + + it('should return false', () => { + expect(isDeepLinkFlowEnabled(searchParams)).toBe(false) + }) + }) + describe('and the flow param is not present', () => { beforeEach(() => { searchParams = new URLSearchParams('targetConfigId=default') @@ -421,6 +431,20 @@ describe('locations', () => { }) }) + describe('and a valid UUID is surrounded by whitespace', () => { + it('should return false', () => { + expect(isValidUuidV4(' 123e4567-e89b-42d3-a456-426614174000 ')).toBe(false) + }) + }) + + describe('and a valid UUID is followed by a trailing newline', () => { + // Guards against a regex anchor bypass: JS `$` without the multiline flag must not match + // before a trailing "\n". The id is forwarded to the native client, so this must be strict. + it('should return false', () => { + expect(isValidUuidV4('123e4567-e89b-42d3-a456-426614174000\n')).toBe(false) + }) + }) + describe('and the value is an empty string', () => { it('should return false', () => { expect(isValidUuidV4('')).toBe(false) From 01de11e72107f4efd0d76c6dfdbb6e8eb1650b38 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Mon, 13 Jul 2026 13:57:25 -0300 Subject: [PATCH 3/3] feat: track the deep-link login handoff with its correlation id Thread the route UUID (the client's correlation id) onto the deep-link handoff's analytics so a login can be tied to the instance that requested it in Segment, and add a failure event for the paths that previously emitted nothing. - postIdentity accepts an `authRequestId` and includes it on the DEEP_LINK_AUTH_SUCCESS event; the desktop handoff passes the route UUID. - IDENTITY_DEEP_LINK_OPENED carries the authRequestId when the deep link opens. - New DEEP_LINK_AUTH_FAILED event fires on both failure paths: a malformed deep-link id (reason `invalid_request_id`) and a failed identity POST (reason `post_identity_failed`), each carrying the received id. - Extend TrackingData with `authRequestId` / `reason`. Adds unit coverage for the forwarded id (RequestPage + httpClient) and both failure events. --- .../Pages/RequestPage/RequestPage.spec.tsx | 29 +++++++++++++++++-- .../Pages/RequestPage/RequestPage.tsx | 14 ++++++--- src/modules/analytics/types.ts | 1 + src/shared/auth/httpClient.spec.ts | 20 ++++++++++++- src/shared/auth/httpClient.ts | 10 +++++-- src/shared/utils/errorHandler.types.ts | 2 ++ 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/components/Pages/RequestPage/RequestPage.spec.tsx b/src/components/Pages/RequestPage/RequestPage.spec.tsx index 8396431e..fd0855a4 100644 --- a/src/components/Pages/RequestPage/RequestPage.spec.tsx +++ b/src/components/Pages/RequestPage/RequestPage.spec.tsx @@ -14,6 +14,8 @@ import { } from '../../../shared/auth' import { getAuthRequestId, isBridgeOnlyEnabled } from '../../../shared/locations' import { isProfileComplete } from '../../../shared/profile' +import { trackEvent } from '../../../shared/utils/analytics' +import { TrackingEvents } from '../../../modules/analytics/types' import { FeatureFlagsContext } from '../../FeatureFlagsProvider' import { RequestPage } from './RequestPage' import { decodeManaTransferData, decodeNftTransferData, fetchNftMetadata, getSigninDeeplink } from './utils' @@ -94,7 +96,8 @@ jest.mock('../../../shared/locations', () => { } }) jest.mock('../../../shared/utils/analytics', () => ({ - identifyUser: jest.fn() + identifyUser: jest.fn(), + trackEvent: jest.fn() })) jest.mock('../../../shared/utils/errorHandler', () => ({ handleError: jest.fn().mockReturnValue('An error occurred') @@ -586,12 +589,12 @@ describe('RequestPage', () => { mockPostIdentity.mockResolvedValueOnce({ identityId: 'anIdentityId' }) }) - it('should post the identity to the auth server and show the continue in app view', async () => { + it('should post the identity to the auth server (forwarding the route UUID) and show the continue in app view', async () => { renderRequestPage(DEEP_LINK_REQUEST_PATH) await waitFor(() => { expect(screen.getByTestId('continue-in-app')).toBeInTheDocument() }) - expect(mockPostIdentity).toHaveBeenCalledWith(mockConnectionData.identity) + expect(mockPostIdentity).toHaveBeenCalledWith(mockConnectionData.identity, { authRequestId: DEEP_LINK_REQUEST_ID }) }) it('should not recover any request from the auth server', async () => { @@ -682,6 +685,16 @@ describe('RequestPage', () => { expect(screen.getByTestId('client-login-error')).toBeInTheDocument() }) }) + + it('should track the failure with the route UUID and a post_identity_failed reason', async () => { + renderRequestPage(DEEP_LINK_REQUEST_PATH) + await waitFor(() => { + expect(jest.mocked(trackEvent)).toHaveBeenCalledWith(TrackingEvents.DEEP_LINK_AUTH_FAILED, { + authRequestId: DEEP_LINK_REQUEST_ID, + reason: 'post_identity_failed' + }) + }) + }) }) describe('and the route id is not a valid UUID v4', () => { @@ -708,6 +721,16 @@ describe('RequestPage', () => { expect(screen.getByTestId('client-login-error')).toHaveTextContent('The sign-in link is invalid.') }) }) + + it('should track the failure with an invalid_request_id reason', async () => { + renderRequestPage('/auth/requests/not-a-uuid?targetConfigId=default&flow=deeplink') + await waitFor(() => { + expect(jest.mocked(trackEvent)).toHaveBeenCalledWith(TrackingEvents.DEEP_LINK_AUTH_FAILED, { + authRequestId: 'not-a-uuid', + reason: 'invalid_request_id' + }) + }) + }) }) }) diff --git a/src/components/Pages/RequestPage/RequestPage.tsx b/src/components/Pages/RequestPage/RequestPage.tsx index eba02719..60d8d3a9 100644 --- a/src/components/Pages/RequestPage/RequestPage.tsx +++ b/src/components/Pages/RequestPage/RequestPage.tsx @@ -39,7 +39,7 @@ import { } from '../../../shared/locations' import { sendTipNotification } from '../../../shared/notifications' import { isProfileComplete } from '../../../shared/profile' -import { identifyUser } from '../../../shared/utils/analytics' +import { identifyUser, trackEvent } from '../../../shared/utils/analytics' import { handleError } from '../../../shared/utils/errorHandler' import { FeatureFlagsContext, FeatureFlagsKeys } from '../../FeatureFlagsProvider/FeatureFlagsProvider.types' import { MANATransferData, NFTTransferData, SignaturePayload, SimulationState, TransferType } from './types' @@ -308,6 +308,7 @@ export const RequestPage = () => { // Surface the reason (rendered as the error detail) so the copy matches the cause — a retry // won't fix a bad id, but the message tells the user the link itself is the problem. if (isInvalidDeepLinkId) { + trackEvent(TrackingEvents.DEEP_LINK_AUTH_FAILED, { authRequestId: requestId, reason: 'invalid_request_id' }) setError('The sign-in link is invalid.') setView(View.CLIENT_LOGIN_ERROR) return @@ -368,8 +369,10 @@ export const RequestPage = () => { try { // Reuse an in-flight POST if the effect re-runs mid-request so the identity is // created exactly once instead of leaving an orphaned identity on the server. + // Forward the route UUID as the correlation id so the success event (fired inside + // postIdentity) can be tied to the instance that requested the login. if (!clientLoginPromiseRef.current) { - clientLoginPromiseRef.current = authServerClient.current.postIdentity(currentIdentity) + clientLoginPromiseRef.current = authServerClient.current.postIdentity(currentIdentity, { authRequestId: requestId }) } const identityResponse = await clientLoginPromiseRef.current if (cancelled) return @@ -379,6 +382,7 @@ export const RequestPage = () => { // Clear the shared promise so Try Again (a page reload) can post again. clientLoginPromiseRef.current = null if (cancelled) return + trackEvent(TrackingEvents.DEEP_LINK_AUTH_FAILED, { authRequestId: requestId, reason: 'post_identity_failed' }) setError(isErrorWithMessage(e) ? e.message : 'Unknown error') setView(View.CLIENT_LOGIN_ERROR) } @@ -1169,7 +1173,9 @@ export const RequestPage = () => { // link (return button or retry), which must not re-count the event. if (!hasTrackedDeepLinkRef.current) { hasTrackedDeepLinkRef.current = true - trackClick(ClickEvents.IDENTITY_DEEP_LINK_OPENED) + // Carry the route UUID (the client's correlation id) so the open can be tied in analytics + // to the instance that requested the login. + trackClick(ClickEvents.IDENTITY_DEEP_LINK_OPENED, { authRequestId: requestId }) } // Deep-link flow: the client was opened via the deep link and there is nothing to @@ -1180,7 +1186,7 @@ export const RequestPage = () => { setSkipDeepLinkRedirect(true) // Show completion view setView(View.VERIFY_SIGN_IN_COMPLETE) - }, [identityId, trackClick, isDeepLinkFlow]) + }, [identityId, trackClick, isDeepLinkFlow, requestId]) const onRetryClientLogin = useCallback(() => { // A fresh mount re-runs completeClientLoginFlow: the view resets to LOADING_REQUEST and diff --git a/src/modules/analytics/types.ts b/src/modules/analytics/types.ts index 8059f7e7..33fecca6 100644 --- a/src/modules/analytics/types.ts +++ b/src/modules/analytics/types.ts @@ -12,6 +12,7 @@ enum TrackingEvents { REQUEST_OUTCOME_SUCCESS = 'Request outcome sent successfully', REQUEST_OUTCOME_FAILED = 'Request outcome sent with error', DEEP_LINK_AUTH_SUCCESS = 'Deep link auth success', + DEEP_LINK_AUTH_FAILED = 'Deep link auth failed', START_ADDING_NAME = 'Start adding name', START_ADDING_EMAIL = 'Start adding email', CHECK_TERMS_OF_SERVICE = 'Check terms of service', diff --git a/src/shared/auth/httpClient.spec.ts b/src/shared/auth/httpClient.spec.ts index 8e23ee0c..0ab45b7e 100644 --- a/src/shared/auth/httpClient.spec.ts +++ b/src/shared/auth/httpClient.spec.ts @@ -9,6 +9,7 @@ import { RequestNotFoundError, SimulationUnavailableError } from './errors' +import { TrackingEvents } from '../../modules/analytics/types' import { createAuthServerHttpClient } from './httpClient' import { RecoverResponse, SimulationRequestBody, SimulationResponseBody } from './types' // Mock dependencies @@ -30,13 +31,15 @@ describe('createAuthServerClient', () => { // Mock fetch let mockFetch: jest.Mock + // Mock analytics track (module-scoped so tests can assert the events sent) + let mockTrack: jest.Mock beforeEach(() => { // Reset all mocks jest.clearAllMocks() // Mock analytics - const mockTrack = jest.fn() + mockTrack = jest.fn() mockFetch = jest.fn() const mockAnalytics = { track: mockTrack } @@ -435,6 +438,21 @@ describe('createAuthServerClient', () => { }) expect(result).toEqual(mockResponse) }) + + it('should track the success without an authRequestId when none is provided', async () => { + await client.postIdentity(mockIdentity) + + expect(mockTrack).toHaveBeenCalledWith(TrackingEvents.DEEP_LINK_AUTH_SUCCESS, { type: 'success' }) + }) + + it('should forward the authRequestId onto the success tracking event when provided', async () => { + await client.postIdentity(mockIdentity, { authRequestId: 'a-request-uuid' }) + + expect(mockTrack).toHaveBeenCalledWith(TrackingEvents.DEEP_LINK_AUTH_SUCCESS, { + type: 'success', + authRequestId: 'a-request-uuid' + }) + }) }) describe('when the response is not ok', () => { diff --git a/src/shared/auth/httpClient.ts b/src/shared/auth/httpClient.ts index deac627b..751cae3d 100644 --- a/src/shared/auth/httpClient.ts +++ b/src/shared/auth/httpClient.ts @@ -73,7 +73,12 @@ export const createAuthServerHttpClient = (authServerUrl?: string) => { } } - const postIdentity = async (identity: AuthIdentity, opts: { isMobile?: boolean } = { isMobile: false }): Promise => { + const postIdentity = async ( + identity: AuthIdentity, + // `authRequestId` is the deep-link handoff's correlation id (the route UUID); forwarded onto + // the success event so a login can be tied to the instance that requested it in analytics. + opts: { isMobile?: boolean; authRequestId?: string | null } = { isMobile: false } + ): Promise => { try { const response = await signedFetch(baseUrl + '/identities', { method: 'POST', @@ -93,7 +98,8 @@ export const createAuthServerHttpClient = (authServerUrl?: string) => { const data = await response.json() trackEvent(TrackingEvents.DEEP_LINK_AUTH_SUCCESS, { - type: 'success' + type: 'success', + ...(opts.authRequestId ? { authRequestId: opts.authRequestId } : {}) }) return data diff --git a/src/shared/utils/errorHandler.types.ts b/src/shared/utils/errorHandler.types.ts index 27b5e905..10ae6564 100644 --- a/src/shared/utils/errorHandler.types.ts +++ b/src/shared/utils/errorHandler.types.ts @@ -11,6 +11,8 @@ interface TrackingData { ethAddress?: string eth_address?: string type?: string + authRequestId?: string + reason?: string feature?: string account?: string url?: string