feat: handle webview proof deeplinks

This commit is contained in:
Justin Hernandez
2025-12-16 19:17:31 -08:00
parent a6194665ec
commit 9c1cb86aad
7 changed files with 276 additions and 9 deletions

View File

@@ -17,6 +17,7 @@ import type { WebViewNavigation } from 'react-native-webview/lib/WebViewTypes';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
charcoal,
slate200,
@@ -27,6 +28,7 @@ import { WebViewNavBar } from '@/components/navbar/WebViewNavBar';
import { WebViewFooter } from '@/components/WebViewFooter';
import { selfUrl } from '@/consts/links';
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
import { handleUrl, parseAndValidateUrlParams } from '@/navigation/deeplinks';
import type { SharedRoutesParamList } from '@/navigation/types';
import {
DISALLOWED_SCHEMES,
@@ -35,6 +37,7 @@ import {
isTrustedDomain,
isUserInitiatedTopFrameNavigation,
shouldAlwaysOpenExternally,
type WebViewRequestWithIosProps,
} from '@/utils/webview';
export interface WebViewScreenParams {
@@ -73,6 +76,7 @@ const styles = StyleSheet.create({
export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
const navigation = useNavigation();
const selfClient = useSelfClient();
const params = route?.params as WebViewScreenParams | undefined;
const safeParams: WebViewScreenParams = params ?? { url: defaultUrl };
const { url, title } = safeParams;
@@ -136,6 +140,37 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
[],
);
const interceptSelfRedirect = useCallback(
(req: WebViewNavigation & WebViewRequestWithIosProps) => {
const validatedParams = parseAndValidateUrlParams(req.url);
const hasSelfParams = Boolean(
validatedParams.selfApp || validatedParams.sessionId,
);
if (!hasSelfParams) {
return false;
}
try {
const hostname = new URL(req.url).hostname;
const isSelfHost =
hostname === 'self.xyz' || hostname.endsWith('.self.xyz');
if (!isSelfHost || !isTrustedDomain(req.url)) {
return false;
}
} catch {
return false;
}
if (!isUserInitiatedTopFrameNavigation(req)) {
return false;
}
handleUrl(selfClient, req.url);
return true;
},
[selfClient],
);
const openUrl = useCallback(async (targetUrl: string) => {
// Block disallowed schemes (blacklist approach)
// Allow everything else - more practical than maintaining a whitelist
@@ -247,6 +282,10 @@ export const WebViewScreen: React.FC<WebViewScreenProps> = ({ route }) => {
onShouldStartLoadWithRequest={req => {
const isHttps = /^https:\/\//i.test(req.url);
if (interceptSelfRedirect(req)) {
return false;
}
// Allow about:blank/srcdoc during trusted sessions (some wallets use this before redirecting)
if (isSessionTrusted && isAllowedAboutUrl(req.url)) {
return true;

View File

@@ -5,7 +5,7 @@
import type { LottieViewProps } from 'lottie-react-native';
import LottieView from 'lottie-react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Linking, StyleSheet, View } from 'react-native';
import { StyleSheet, View } from 'react-native';
import { SystemBars } from 'react-native-edge-to-edge';
import { ScrollView, Spinner } from 'tamagui';
import { useIsFocused, useNavigation } from '@react-navigation/native';
@@ -36,6 +36,7 @@ import type { RootStackParamList } from '@/navigation';
import { getWhiteListedDisclosureAddresses } from '@/services/points/utils';
import { useProofHistoryStore } from '@/stores/proofHistoryStore';
import { ProofStatus } from '@/stores/proofTypes';
import { handleDeeplinkCallbackNavigation } from '@/utils/deeplinkCallbacks';
const SuccessScreen: React.FC = () => {
const selfClient = useSelfClient();
@@ -95,6 +96,21 @@ const SuccessScreen: React.FC = () => {
setCountdown(null);
}
const navigateWithDeeplinkCallback = useCallback(
async (deeplink: string) => {
try {
await handleDeeplinkCallbackNavigation({
deeplinkCallback: deeplink,
navigation,
});
} catch (error) {
console.error('Failed to open deep link:', error);
onOkPress();
}
},
[navigation, onOkPress],
);
useEffect(() => {
if (isFocused) {
}
@@ -191,13 +207,15 @@ const SuccessScreen: React.FC = () => {
} else {
setCountdown(null);
if (selfApp?.deeplinkCallback) {
Linking.openURL(selfApp.deeplinkCallback).catch(err => {
console.error('Failed to open deep link:', err);
onOkPress();
});
navigateWithDeeplinkCallback(selfApp.deeplinkCallback);
}
}
}, [countdown, selfApp?.deeplinkCallback, onOkPress]);
}, [
countdown,
selfApp?.deeplinkCallback,
navigateWithDeeplinkCallback,
onOkPress,
]);
useEffect(() => {
if (!isFocused) {

View File

@@ -6,7 +6,7 @@ import { v4 } from 'uuid';
import { SelfAppBuilder } from '@selfxyz/common/utils/appType';
import { selfLogoReverseUrl } from '@/consts/links';
import { appsUrl, selfLogoReverseUrl } from '@/consts/links';
import { getOrGeneratePointsAddress } from '@/providers/authProvider';
import { POINTS_API_BASE_URL } from '@/services/points/constants';
import type { IncomingPoints } from '@/services/points/types';
@@ -166,18 +166,30 @@ export const hasUserDoneThePointsDisclosure = async (): Promise<boolean> => {
}
};
const buildPointsDisclosureMetadata = (walletAddress: string) => ({
action: 'points-disclosure',
issuedAt: new Date().toISOString(),
nonce: v4(),
wallet: walletAddress.toLowerCase(),
});
export const pointsSelfApp = async () => {
const endpoint = '0x829d183faaa675f8f80e8bb25fb1476cd4f7c1f0';
const pointsAddress = (await getPointsAddress()).toLowerCase();
const disclosureMetadata = buildPointsDisclosureMetadata(pointsAddress);
const builder = new SelfAppBuilder({
appName: '✨ Self Points',
endpoint: endpoint.toLowerCase(),
endpointType: 'celo',
scope: 'minimal-disclosure-quest',
userId: v4(),
userIdType: 'uuid',
userId: pointsAddress,
userIdType: 'hex',
disclosures: {},
logoBase64: selfLogoReverseUrl,
header: '',
deeplinkCallback: appsUrl,
userDefinedData: JSON.stringify(disclosureMetadata),
selfDefinedData: pointsAddress,
});
return builder.build();

View File

@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Linking } from 'react-native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation';
const isSelfHostname = (hostname: string) => {
return hostname === 'self.xyz' || hostname.endsWith('.self.xyz');
};
const isSelfHostedUrl = (deeplink: string): boolean => {
try {
const url = new URL(deeplink);
if (url.protocol !== 'https:') {
return false;
}
return isSelfHostname(url.hostname);
} catch {
return false;
}
};
export const handleDeeplinkCallbackNavigation = async ({
deeplinkCallback,
navigation,
}: {
deeplinkCallback: string;
navigation: NativeStackNavigationProp<RootStackParamList>;
}) => {
if (isSelfHostedUrl(deeplinkCallback)) {
navigation.navigate('WebView', {
url: deeplinkCallback,
title: 'Explore Apps',
});
return;
}
await Linking.openURL(deeplinkCallback);
};
export const isSelfHostedDeeplink = isSelfHostedUrl;

View File

@@ -86,11 +86,29 @@ const mockPlatform = jest.requireMock('react-native').Platform as {
select: (specifics: { ios?: unknown; android?: unknown }) => unknown;
};
const { handleUrl: mockHandleUrl, parseAndValidateUrlParams } =
jest.requireMock('@/navigation/deeplinks') as {
handleUrl: jest.Mock;
parseAndValidateUrlParams: jest.Mock;
};
const { useSelfClient: mockUseSelfClient } = jest.requireMock(
'@selfxyz/mobile-sdk-alpha',
) as { useSelfClient: jest.Mock };
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
useFocusEffect: jest.fn(),
}));
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
useSelfClient: jest.fn(() => ({ getSelfAppState: jest.fn() })),
}));
jest.mock('@/navigation/deeplinks', () => ({
handleUrl: jest.fn(),
parseAndValidateUrlParams: jest.fn(() => ({})),
}));
jest.mock('@/components/navbar/WebViewNavBar', () => ({
WebViewNavBar: ({ children, onBackPress, ...props }: any) => (
<mock-webview-navbar {...props}>
@@ -155,10 +173,13 @@ describe('WebViewScreen URL sanitization and navigation interception', () => {
goBack: mockGoBack,
canGoBack: () => true,
});
mockUseSelfClient.mockReturnValue({ getSelfAppState: jest.fn() });
jest.spyOn(console, 'error').mockImplementation(() => {});
mockAlert.alert.mockClear();
mockLinking.canOpenURL.mockReset();
mockLinking.openURL.mockReset();
parseAndValidateUrlParams.mockReturnValue({});
mockHandleUrl.mockClear();
});
afterEach(() => {
@@ -297,6 +318,24 @@ describe('WebViewScreen URL sanitization and navigation interception', () => {
expect(String(msg)).toContain('Failed to open externally');
expect(String(msg)).not.toMatch(/Failed to open URL externally/);
});
it('intercepts trusted redirect self links carrying selfApp payloads', async () => {
const deeplinkUrl = 'https://redirect.self.xyz?selfApp=%7B%7D';
parseAndValidateUrlParams.mockReturnValue({
selfApp: '{}',
});
render(<WebViewScreen {...createProps('https://apps.self.xyz')} />);
const webview = screen.getByTestId('webview');
const result = await webview.props.onShouldStartLoadWithRequest?.({
url: deeplinkUrl,
isTopFrame: true,
navigationType: 'click',
});
expect(result).toBe(false);
expect(mockHandleUrl).toHaveBeenCalledWith(expect.any(Object), deeplinkUrl);
});
});
describe('WebViewScreen same-origin security', () => {
@@ -321,10 +360,12 @@ describe('WebViewScreen same-origin security', () => {
goBack: jest.fn(),
canGoBack: () => true,
});
mockUseSelfClient.mockReturnValue({ getSelfAppState: jest.fn() });
jest.spyOn(console, 'error').mockImplementation(() => {});
mockAlert.alert.mockClear();
mockLinking.canOpenURL.mockReset();
mockLinking.openURL.mockReset();
parseAndValidateUrlParams.mockReturnValue({});
});
afterEach(() => {

View File

@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { pointsSelfApp } from '@/services/points/utils';
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
jest.mock('@/providers/authProvider', () => ({
getOrGeneratePointsAddress: jest.fn(),
}));
const mockUuid = jest.requireMock('uuid').v4 as jest.Mock;
const mockGetOrGeneratePointsAddress = jest.requireMock(
'@/providers/authProvider',
).getOrGeneratePointsAddress as jest.Mock;
describe('pointsSelfApp', () => {
beforeEach(() => {
mockUuid.mockReset();
mockGetOrGeneratePointsAddress.mockReset();
});
it('builds a SelfApp with wallet-bound metadata and deeplink callback', async () => {
mockUuid
.mockImplementationOnce(() => 'nonce-uuid')
.mockImplementationOnce(() => 'session-uuid');
mockGetOrGeneratePointsAddress.mockResolvedValue(
'0xABCDEF1234567890ABCDEF1234567890ABCDEF12',
);
const selfApp = await pointsSelfApp();
const metadata = JSON.parse(selfApp.userDefinedData);
expect(selfApp.userIdType).toBe('hex');
expect(selfApp.userId).toBe('abcdef1234567890abcdef1234567890abcdef12');
expect(selfApp.deeplinkCallback).toBe('https://apps.self.xyz');
expect(metadata.nonce).toBe('nonce-uuid');
expect(metadata.wallet).toBe('0xabcdef1234567890abcdef1234567890abcdef12');
expect(metadata.action).toBe('points-disclosure');
expect(new Date(metadata.issuedAt).toString()).not.toBe('Invalid Date');
expect(selfApp.selfDefinedData).toBe(
'0xabcdef1234567890abcdef1234567890abcdef12',
);
});
});

View File

@@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { Linking } from 'react-native';
import {
handleDeeplinkCallbackNavigation,
isSelfHostedDeeplink,
} from '@/utils/deeplinkCallbacks';
jest.mock('react-native', () => ({
Linking: {
openURL: jest.fn(),
},
}));
describe('deeplinkCallbacks', () => {
const mockNavigation = {
navigate: jest.fn(),
} as any;
beforeEach(() => {
jest.clearAllMocks();
});
it('routes self-hosted https callbacks through in-app WebView', async () => {
await handleDeeplinkCallbackNavigation({
deeplinkCallback: 'https://apps.self.xyz/proof/done',
navigation: mockNavigation,
});
expect(mockNavigation.navigate).toHaveBeenCalledWith('WebView', {
url: 'https://apps.self.xyz/proof/done',
title: 'Explore Apps',
});
expect(Linking.openURL).not.toHaveBeenCalled();
});
it('opens non-self callbacks externally', async () => {
await handleDeeplinkCallbackNavigation({
deeplinkCallback: 'https://example.com/next',
navigation: mockNavigation,
});
expect(mockNavigation.navigate).not.toHaveBeenCalled();
expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/next');
});
it('treats malformed callbacks as external fallbacks', async () => {
await handleDeeplinkCallbackNavigation({
deeplinkCallback: 'not-a-url',
navigation: mockNavigation,
});
expect(mockNavigation.navigate).not.toHaveBeenCalled();
expect(Linking.openURL).toHaveBeenCalledWith('not-a-url');
});
it('detects self-hosted https deeplinks', () => {
expect(isSelfHostedDeeplink('https://apps.self.xyz/foo')).toBe(true);
expect(isSelfHostedDeeplink('https://malicious.com/foo')).toBe(false);
expect(isSelfHostedDeeplink('ftp://apps.self.xyz/foo')).toBe(false);
});
});