mirror of
https://github.com/selfxyz/self.git
synced 2026-01-08 22:28:11 -05:00
feat: handle webview proof deeplinks
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
44
app/src/utils/deeplinkCallbacks.ts
Normal file
44
app/src/utils/deeplinkCallbacks.ts
Normal 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;
|
||||
@@ -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(() => {
|
||||
|
||||
48
app/tests/src/services/points/utils.test.ts
Normal file
48
app/tests/src/services/points/utils.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
65
app/tests/src/utils/deeplinkCallbacks.test.ts
Normal file
65
app/tests/src/utils/deeplinkCallbacks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user