Bluesky app fork with some witchin' additions 💫

Add device attestation to signup flow (#8757)

authored by hailey.at and committed by

GitHub c0593e49 39e775a3

+160 -42
+1
package.json
··· 186 186 "react-native": "^0.79.3", 187 187 "react-native-compressor": "^1.11.0", 188 188 "react-native-date-picker": "^5.0.12", 189 + "react-native-device-attest": "^0.1.6", 189 190 "react-native-drawer-layout": "^4.1.8", 190 191 "react-native-edge-to-edge": "^1.6.0", 191 192 "react-native-gesture-handler": "2.25.0",
+7
src/env/common.ts
··· 78 78 */ 79 79 export const BITDRIFT_API_KEY: string | undefined = 80 80 process.env.EXPO_PUBLIC_BITDRIFT_API_KEY 81 + 82 + /** 83 + * GCP project ID which is required for device attestation 84 + */ 85 + export const GCP_PROJECT_ID: number = Number( 86 + process.env.EXPO_PUBLIC_GCP_PROJECT_ID, 87 + )
+50 -38
src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet} from 'react-native' 3 - import {WebView, WebViewNavigation} from 'react-native-webview' 4 - import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' 1 + import {useEffect, useMemo, useRef} from 'react' 2 + import {WebView, type WebViewNavigation} from 'react-native-webview' 3 + import {type ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes' 5 4 6 - import {SignupState} from '#/screens/Signup/state' 5 + import {type SignupState} from '#/screens/Signup/state' 7 6 8 7 const ALLOWED_HOSTS = [ 9 8 'bsky.social', 10 9 'bsky.app', 11 10 'staging.bsky.app', 12 11 'staging.bsky.dev', 12 + 'app.staging.bsky.dev', 13 13 'js.hcaptcha.com', 14 14 'newassets.hcaptcha.com', 15 15 'api2.hcaptcha.com', 16 16 ] 17 17 18 + const MIN_DELAY = 3_500 19 + 18 20 export function CaptchaWebView({ 19 21 url, 20 22 stateParam, ··· 28 30 onSuccess: (code: string) => void 29 31 onError: (error: unknown) => void 30 32 }) { 31 - const redirectHost = React.useMemo(() => { 33 + const startedAt = useRef(Date.now()) 34 + const successTo = useRef<NodeJS.Timeout>() 35 + 36 + useEffect(() => { 37 + return () => { 38 + if (successTo.current) { 39 + clearTimeout(successTo.current) 40 + } 41 + } 42 + }, []) 43 + 44 + const redirectHost = useMemo(() => { 32 45 if (!state?.serviceUrl) return 'bsky.app' 33 46 34 47 return state?.serviceUrl && 35 48 new URL(state?.serviceUrl).host === 'staging.bsky.dev' 36 - ? 'staging.bsky.app' 49 + ? 'app.staging.bsky.dev' 37 50 : 'bsky.app' 38 51 }, [state?.serviceUrl]) 39 52 40 - const wasSuccessful = React.useRef(false) 53 + const wasSuccessful = useRef(false) 41 54 42 - const onShouldStartLoadWithRequest = React.useCallback( 43 - (event: ShouldStartLoadRequest) => { 44 - const urlp = new URL(event.url) 45 - return ALLOWED_HOSTS.includes(urlp.host) 46 - }, 47 - [], 48 - ) 55 + const onShouldStartLoadWithRequest = (event: ShouldStartLoadRequest) => { 56 + const urlp = new URL(event.url) 57 + return ALLOWED_HOSTS.includes(urlp.host) 58 + } 49 59 50 - const onNavigationStateChange = React.useCallback( 51 - (e: WebViewNavigation) => { 52 - if (wasSuccessful.current) return 60 + const onNavigationStateChange = (e: WebViewNavigation) => { 61 + if (wasSuccessful.current) return 53 62 54 - const urlp = new URL(e.url) 55 - if (urlp.host !== redirectHost) return 63 + const urlp = new URL(e.url) 64 + if (urlp.host !== redirectHost || urlp.pathname === '/gate/signup') return 56 65 57 - const code = urlp.searchParams.get('code') 58 - if (urlp.searchParams.get('state') !== stateParam || !code) { 59 - onError({error: 'Invalid state or code'}) 60 - return 61 - } 66 + const code = urlp.searchParams.get('code') 67 + if (urlp.searchParams.get('state') !== stateParam || !code) { 68 + onError({error: 'Invalid state or code'}) 69 + return 70 + } 62 71 63 - wasSuccessful.current = true 72 + // We want to delay the completion of this screen ever so slightly so that it doesn't appear to be a glitch if it completes too fast 73 + wasSuccessful.current = true 74 + const now = Date.now() 75 + const timeTaken = now - startedAt.current 76 + if (timeTaken < MIN_DELAY) { 77 + successTo.current = setTimeout(() => { 78 + onSuccess(code) 79 + }, MIN_DELAY - timeTaken) 80 + } else { 64 81 onSuccess(code) 65 - }, 66 - [redirectHost, stateParam, onSuccess, onError], 67 - ) 82 + } 83 + } 68 84 69 85 return ( 70 86 <WebView 71 87 source={{uri: url}} 72 88 javaScriptEnabled 73 - style={styles.webview} 89 + style={{ 90 + flex: 1, 91 + backgroundColor: 'transparent', 92 + borderRadius: 10, 93 + }} 74 94 onShouldStartLoadWithRequest={onShouldStartLoadWithRequest} 75 95 onNavigationStateChange={onNavigationStateChange} 76 96 scrollEnabled={false} ··· 83 103 /> 84 104 ) 85 105 } 86 - 87 - const styles = StyleSheet.create({ 88 - webview: { 89 - flex: 1, 90 - backgroundColor: 'transparent', 91 - borderRadius: 10, 92 - }, 93 - })
+83 -4
src/screens/Signup/StepCaptcha/index.tsx
··· 1 - import React from 'react' 2 - import {ActivityIndicator, View} from 'react-native' 1 + import React, {useEffect, useState} from 'react' 2 + import {ActivityIndicator, Platform, View} from 'react-native' 3 + import ReactNativeDeviceAttest from 'react-native-device-attest' 3 4 import {msg} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 6 import {nanoid} from 'nanoid/non-secure' 6 7 7 8 import {createFullHandle} from '#/lib/strings/handles' 8 9 import {logger} from '#/logger' 10 + import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' 9 11 import {ScreenTransition} from '#/screens/Login/ScreenTransition' 10 12 import {useSignupContext} from '#/screens/Signup/state' 11 13 import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView' 12 14 import {atoms as a, useTheme} from '#/alf' 13 15 import {FormError} from '#/components/forms/FormError' 16 + import {GCP_PROJECT_ID} from '#/env' 14 17 import {BackNextButtons} from '../BackNextButtons' 15 18 16 - const CAPTCHA_PATH = '/gate/signup' 19 + const CAPTCHA_PATH = 20 + isWeb || GCP_PROJECT_ID === 0 ? '/gate/signup' : '/gate/signup/attempt-attest' 17 21 18 22 export function StepCaptcha() { 23 + if (isWeb) { 24 + return <StepCaptchaInner /> 25 + } else { 26 + return <StepCaptchaNative /> 27 + } 28 + } 29 + 30 + export function StepCaptchaNative() { 31 + const [token, setToken] = useState<string>() 32 + const [payload, setPayload] = useState<string>() 33 + const [ready, setReady] = useState(false) 34 + 35 + useEffect(() => { 36 + ;(async () => { 37 + logger.debug('trying to generate attestation token...') 38 + try { 39 + if (isIOS) { 40 + logger.debug('starting to generate devicecheck token...') 41 + const token = await ReactNativeDeviceAttest.getDeviceCheckToken() 42 + setToken(token) 43 + logger.debug(`generated devicecheck token: ${token}`) 44 + } else { 45 + const {token, payload} = 46 + await ReactNativeDeviceAttest.getIntegrityToken('signup') 47 + setToken(token) 48 + setPayload(base64UrlEncode(payload)) 49 + } 50 + } catch (e: any) { 51 + logger.error(e) 52 + } finally { 53 + setReady(true) 54 + } 55 + })() 56 + }, []) 57 + 58 + if (!ready) { 59 + return <View /> 60 + } 61 + 62 + return <StepCaptchaInner token={token} payload={payload} /> 63 + } 64 + 65 + function StepCaptchaInner({ 66 + token, 67 + payload, 68 + }: { 69 + token?: string 70 + payload?: string 71 + }) { 19 72 const {_} = useLingui() 20 73 const theme = useTheme() 21 74 const {state, dispatch} = useSignupContext() ··· 33 86 newUrl.searchParams.set('state', stateParam) 34 87 newUrl.searchParams.set('colorScheme', theme.name) 35 88 89 + if (isNative && token) { 90 + newUrl.searchParams.set('platform', Platform.OS) 91 + newUrl.searchParams.set('token', token) 92 + if (isAndroid && payload) { 93 + newUrl.searchParams.set('payload', payload) 94 + } 95 + } 96 + 36 97 return newUrl.href 37 - }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name]) 98 + }, [ 99 + state.serviceUrl, 100 + state.handle, 101 + state.userDomain, 102 + stateParam, 103 + theme.name, 104 + token, 105 + payload, 106 + ]) 38 107 39 108 const onSuccess = React.useCallback( 40 109 (code: string) => { ··· 105 174 </ScreenTransition> 106 175 ) 107 176 } 177 + 178 + function base64UrlEncode(data: string): string { 179 + const encoder = new TextEncoder() 180 + const bytes = encoder.encode(data) 181 + 182 + const binaryString = String.fromCharCode(...bytes) 183 + const base64 = btoa(binaryString) 184 + 185 + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]/g, '') 186 + }
+14
src/screens/Signup/index.tsx
··· 1 1 import {useEffect, useReducer, useState} from 'react' 2 2 import {AppState, type AppStateStatus, View} from 'react-native' 3 + import ReactNativeDeviceAttest from 'react-native-device-attest' 3 4 import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated' 4 5 import {AppBskyGraphStarterpack} from '@atproto/api' 5 6 import {msg, Trans} from '@lingui/macro' 6 7 import {useLingui} from '@lingui/react' 7 8 8 9 import {FEEDBACK_FORM_URL} from '#/lib/constants' 10 + import {logger} from '#/logger' 11 + import {isAndroid} from '#/platform/detection' 9 12 import {useServiceQuery} from '#/state/queries/service' 10 13 import {useStarterPackQuery} from '#/state/queries/starter-packs' 11 14 import {useActiveStarterPack} from '#/state/shell/starter-pack' ··· 26 29 import {LinearGradientBackground} from '#/components/LinearGradientBackground' 27 30 import {InlineLinkText} from '#/components/Link' 28 31 import {Text} from '#/components/Typography' 32 + import {GCP_PROJECT_ID} from '#/env' 29 33 import * as bsky from '#/types/bsky' 30 34 31 35 export function Signup({onPressBack}: {onPressBack: () => void}) { ··· 99 103 ) 100 104 101 105 return () => subscription.remove() 106 + }, []) 107 + 108 + // On Android, warmup the Play Integrity API on the signup screen so it is ready by the time we get to the gate screen. 109 + useEffect(() => { 110 + if (!isAndroid) { 111 + return 112 + } 113 + ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID).catch(err => 114 + logger.error(err), 115 + ) 102 116 }, []) 103 117 104 118 return (
+5
yarn.lock
··· 16787 16787 resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-5.0.12.tgz#12540b6a58500811ee7e4fc0244e3accc7cca9c1" 16788 16788 integrity sha512-R/mUnCKhcuxbhKPFwYdBQCxQt9HHLqpM4ruRUqlcBjiUZ3N2wdnwOMyc888Ps8qp8e7v29PrDHtUlG8LPuFn9w== 16789 16789 16790 + react-native-device-attest@^0.1.6: 16791 + version "0.1.6" 16792 + resolved "https://registry.yarnpkg.com/react-native-device-attest/-/react-native-device-attest-0.1.6.tgz#51796a92d9199b1d231d4aa62d557019753b30c3" 16793 + integrity sha512-oTgBu6il+czHIMLs2IVWv2+WZ6a/vUtVLQ40q6/Dgns7NuG69mwmR8lS0e2Sl/yOtpD9YZXc8cYzBDKHAyV5MA== 16794 + 16790 16795 react-native-dotenv@^3.4.11: 16791 16796 version "3.4.11" 16792 16797 resolved "https://registry.yarnpkg.com/react-native-dotenv/-/react-native-dotenv-3.4.11.tgz#2e6c4eabd55d5f1bf109b3dd9141dadf9c55cdd4"