Add device attestation to signup flow (#8757)
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {WebView, WebViewNavigation} from 'react-native-webview'
|
||||
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
|
||||
import {useEffect, useMemo, useRef} from 'react'
|
||||
import {WebView, type WebViewNavigation} from 'react-native-webview'
|
||||
import {type ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
|
||||
|
||||
import {SignupState} from '#/screens/Signup/state'
|
||||
import {type SignupState} from '#/screens/Signup/state'
|
||||
|
||||
const ALLOWED_HOSTS = [
|
||||
'bsky.social',
|
||||
'bsky.app',
|
||||
'staging.bsky.app',
|
||||
'staging.bsky.dev',
|
||||
'app.staging.bsky.dev',
|
||||
'js.hcaptcha.com',
|
||||
'newassets.hcaptcha.com',
|
||||
'api2.hcaptcha.com',
|
||||
]
|
||||
|
||||
const MIN_DELAY = 3_500
|
||||
|
||||
export function CaptchaWebView({
|
||||
url,
|
||||
stateParam,
|
||||
@@ -28,49 +30,67 @@ export function CaptchaWebView({
|
||||
onSuccess: (code: string) => void
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const redirectHost = React.useMemo(() => {
|
||||
const startedAt = useRef(Date.now())
|
||||
const successTo = useRef<NodeJS.Timeout>()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (successTo.current) {
|
||||
clearTimeout(successTo.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const redirectHost = useMemo(() => {
|
||||
if (!state?.serviceUrl) return 'bsky.app'
|
||||
|
||||
return state?.serviceUrl &&
|
||||
new URL(state?.serviceUrl).host === 'staging.bsky.dev'
|
||||
? 'staging.bsky.app'
|
||||
? 'app.staging.bsky.dev'
|
||||
: 'bsky.app'
|
||||
}, [state?.serviceUrl])
|
||||
|
||||
const wasSuccessful = React.useRef(false)
|
||||
const wasSuccessful = useRef(false)
|
||||
|
||||
const onShouldStartLoadWithRequest = React.useCallback(
|
||||
(event: ShouldStartLoadRequest) => {
|
||||
const urlp = new URL(event.url)
|
||||
return ALLOWED_HOSTS.includes(urlp.host)
|
||||
},
|
||||
[],
|
||||
)
|
||||
const onShouldStartLoadWithRequest = (event: ShouldStartLoadRequest) => {
|
||||
const urlp = new URL(event.url)
|
||||
return ALLOWED_HOSTS.includes(urlp.host)
|
||||
}
|
||||
|
||||
const onNavigationStateChange = React.useCallback(
|
||||
(e: WebViewNavigation) => {
|
||||
if (wasSuccessful.current) return
|
||||
const onNavigationStateChange = (e: WebViewNavigation) => {
|
||||
if (wasSuccessful.current) return
|
||||
|
||||
const urlp = new URL(e.url)
|
||||
if (urlp.host !== redirectHost) return
|
||||
const urlp = new URL(e.url)
|
||||
if (urlp.host !== redirectHost || urlp.pathname === '/gate/signup') return
|
||||
|
||||
const code = urlp.searchParams.get('code')
|
||||
if (urlp.searchParams.get('state') !== stateParam || !code) {
|
||||
onError({error: 'Invalid state or code'})
|
||||
return
|
||||
}
|
||||
const code = urlp.searchParams.get('code')
|
||||
if (urlp.searchParams.get('state') !== stateParam || !code) {
|
||||
onError({error: 'Invalid state or code'})
|
||||
return
|
||||
}
|
||||
|
||||
wasSuccessful.current = true
|
||||
// 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
|
||||
wasSuccessful.current = true
|
||||
const now = Date.now()
|
||||
const timeTaken = now - startedAt.current
|
||||
if (timeTaken < MIN_DELAY) {
|
||||
successTo.current = setTimeout(() => {
|
||||
onSuccess(code)
|
||||
}, MIN_DELAY - timeTaken)
|
||||
} else {
|
||||
onSuccess(code)
|
||||
},
|
||||
[redirectHost, stateParam, onSuccess, onError],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WebView
|
||||
source={{uri: url}}
|
||||
javaScriptEnabled
|
||||
style={styles.webview}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 10,
|
||||
}}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
onNavigationStateChange={onNavigationStateChange}
|
||||
scrollEnabled={false}
|
||||
@@ -83,11 +103,3 @@ export function CaptchaWebView({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 10,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,21 +1,74 @@
|
||||
import React from 'react'
|
||||
import {ActivityIndicator, View} from 'react-native'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {ActivityIndicator, Platform, View} from 'react-native'
|
||||
import ReactNativeDeviceAttest from 'react-native-device-attest'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {createFullHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
||||
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
|
||||
import {useSignupContext} from '#/screens/Signup/state'
|
||||
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {FormError} from '#/components/forms/FormError'
|
||||
import {GCP_PROJECT_ID} from '#/env'
|
||||
import {BackNextButtons} from '../BackNextButtons'
|
||||
|
||||
const CAPTCHA_PATH = '/gate/signup'
|
||||
const CAPTCHA_PATH =
|
||||
isWeb || GCP_PROJECT_ID === 0 ? '/gate/signup' : '/gate/signup/attempt-attest'
|
||||
|
||||
export function StepCaptcha() {
|
||||
if (isWeb) {
|
||||
return <StepCaptchaInner />
|
||||
} else {
|
||||
return <StepCaptchaNative />
|
||||
}
|
||||
}
|
||||
|
||||
export function StepCaptchaNative() {
|
||||
const [token, setToken] = useState<string>()
|
||||
const [payload, setPayload] = useState<string>()
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
logger.debug('trying to generate attestation token...')
|
||||
try {
|
||||
if (isIOS) {
|
||||
logger.debug('starting to generate devicecheck token...')
|
||||
const token = await ReactNativeDeviceAttest.getDeviceCheckToken()
|
||||
setToken(token)
|
||||
logger.debug(`generated devicecheck token: ${token}`)
|
||||
} else {
|
||||
const {token, payload} =
|
||||
await ReactNativeDeviceAttest.getIntegrityToken('signup')
|
||||
setToken(token)
|
||||
setPayload(base64UrlEncode(payload))
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(e)
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return <View />
|
||||
}
|
||||
|
||||
return <StepCaptchaInner token={token} payload={payload} />
|
||||
}
|
||||
|
||||
function StepCaptchaInner({
|
||||
token,
|
||||
payload,
|
||||
}: {
|
||||
token?: string
|
||||
payload?: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const theme = useTheme()
|
||||
const {state, dispatch} = useSignupContext()
|
||||
@@ -33,8 +86,24 @@ export function StepCaptcha() {
|
||||
newUrl.searchParams.set('state', stateParam)
|
||||
newUrl.searchParams.set('colorScheme', theme.name)
|
||||
|
||||
if (isNative && token) {
|
||||
newUrl.searchParams.set('platform', Platform.OS)
|
||||
newUrl.searchParams.set('token', token)
|
||||
if (isAndroid && payload) {
|
||||
newUrl.searchParams.set('payload', payload)
|
||||
}
|
||||
}
|
||||
|
||||
return newUrl.href
|
||||
}, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name])
|
||||
}, [
|
||||
state.serviceUrl,
|
||||
state.handle,
|
||||
state.userDomain,
|
||||
stateParam,
|
||||
theme.name,
|
||||
token,
|
||||
payload,
|
||||
])
|
||||
|
||||
const onSuccess = React.useCallback(
|
||||
(code: string) => {
|
||||
@@ -105,3 +174,13 @@ export function StepCaptcha() {
|
||||
</ScreenTransition>
|
||||
)
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: string): string {
|
||||
const encoder = new TextEncoder()
|
||||
const bytes = encoder.encode(data)
|
||||
|
||||
const binaryString = String.fromCharCode(...bytes)
|
||||
const base64 = btoa(binaryString)
|
||||
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]/g, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user