Add device attestation to signup flow (#8757)
This commit is contained in:
@@ -186,6 +186,7 @@
|
|||||||
"react-native": "^0.79.3",
|
"react-native": "^0.79.3",
|
||||||
"react-native-compressor": "^1.11.0",
|
"react-native-compressor": "^1.11.0",
|
||||||
"react-native-date-picker": "^5.0.12",
|
"react-native-date-picker": "^5.0.12",
|
||||||
|
"react-native-device-attest": "^0.1.6",
|
||||||
"react-native-drawer-layout": "^4.1.8",
|
"react-native-drawer-layout": "^4.1.8",
|
||||||
"react-native-edge-to-edge": "^1.6.0",
|
"react-native-edge-to-edge": "^1.6.0",
|
||||||
"react-native-gesture-handler": "2.25.0",
|
"react-native-gesture-handler": "2.25.0",
|
||||||
|
|||||||
Vendored
+7
@@ -78,3 +78,10 @@ export const SENTRY_DSN: string | undefined = process.env.EXPO_PUBLIC_SENTRY_DSN
|
|||||||
*/
|
*/
|
||||||
export const BITDRIFT_API_KEY: string | undefined =
|
export const BITDRIFT_API_KEY: string | undefined =
|
||||||
process.env.EXPO_PUBLIC_BITDRIFT_API_KEY
|
process.env.EXPO_PUBLIC_BITDRIFT_API_KEY
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GCP project ID which is required for device attestation
|
||||||
|
*/
|
||||||
|
export const GCP_PROJECT_ID: number = Number(
|
||||||
|
process.env.EXPO_PUBLIC_GCP_PROJECT_ID,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import React from 'react'
|
import {useEffect, useMemo, useRef} from 'react'
|
||||||
import {StyleSheet} from 'react-native'
|
import {WebView, type WebViewNavigation} from 'react-native-webview'
|
||||||
import {WebView, WebViewNavigation} from 'react-native-webview'
|
import {type ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
|
||||||
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
|
|
||||||
|
|
||||||
import {SignupState} from '#/screens/Signup/state'
|
import {type SignupState} from '#/screens/Signup/state'
|
||||||
|
|
||||||
const ALLOWED_HOSTS = [
|
const ALLOWED_HOSTS = [
|
||||||
'bsky.social',
|
'bsky.social',
|
||||||
'bsky.app',
|
'bsky.app',
|
||||||
'staging.bsky.app',
|
'staging.bsky.app',
|
||||||
'staging.bsky.dev',
|
'staging.bsky.dev',
|
||||||
|
'app.staging.bsky.dev',
|
||||||
'js.hcaptcha.com',
|
'js.hcaptcha.com',
|
||||||
'newassets.hcaptcha.com',
|
'newassets.hcaptcha.com',
|
||||||
'api2.hcaptcha.com',
|
'api2.hcaptcha.com',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const MIN_DELAY = 3_500
|
||||||
|
|
||||||
export function CaptchaWebView({
|
export function CaptchaWebView({
|
||||||
url,
|
url,
|
||||||
stateParam,
|
stateParam,
|
||||||
@@ -28,31 +30,38 @@ export function CaptchaWebView({
|
|||||||
onSuccess: (code: string) => void
|
onSuccess: (code: string) => void
|
||||||
onError: (error: unknown) => 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'
|
if (!state?.serviceUrl) return 'bsky.app'
|
||||||
|
|
||||||
return state?.serviceUrl &&
|
return state?.serviceUrl &&
|
||||||
new URL(state?.serviceUrl).host === 'staging.bsky.dev'
|
new URL(state?.serviceUrl).host === 'staging.bsky.dev'
|
||||||
? 'staging.bsky.app'
|
? 'app.staging.bsky.dev'
|
||||||
: 'bsky.app'
|
: 'bsky.app'
|
||||||
}, [state?.serviceUrl])
|
}, [state?.serviceUrl])
|
||||||
|
|
||||||
const wasSuccessful = React.useRef(false)
|
const wasSuccessful = useRef(false)
|
||||||
|
|
||||||
const onShouldStartLoadWithRequest = React.useCallback(
|
const onShouldStartLoadWithRequest = (event: ShouldStartLoadRequest) => {
|
||||||
(event: ShouldStartLoadRequest) => {
|
|
||||||
const urlp = new URL(event.url)
|
const urlp = new URL(event.url)
|
||||||
return ALLOWED_HOSTS.includes(urlp.host)
|
return ALLOWED_HOSTS.includes(urlp.host)
|
||||||
},
|
}
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onNavigationStateChange = React.useCallback(
|
const onNavigationStateChange = (e: WebViewNavigation) => {
|
||||||
(e: WebViewNavigation) => {
|
|
||||||
if (wasSuccessful.current) return
|
if (wasSuccessful.current) return
|
||||||
|
|
||||||
const urlp = new URL(e.url)
|
const urlp = new URL(e.url)
|
||||||
if (urlp.host !== redirectHost) return
|
if (urlp.host !== redirectHost || urlp.pathname === '/gate/signup') return
|
||||||
|
|
||||||
const code = urlp.searchParams.get('code')
|
const code = urlp.searchParams.get('code')
|
||||||
if (urlp.searchParams.get('state') !== stateParam || !code) {
|
if (urlp.searchParams.get('state') !== stateParam || !code) {
|
||||||
@@ -60,17 +69,28 @@ export function CaptchaWebView({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
wasSuccessful.current = true
|
||||||
|
const now = Date.now()
|
||||||
|
const timeTaken = now - startedAt.current
|
||||||
|
if (timeTaken < MIN_DELAY) {
|
||||||
|
successTo.current = setTimeout(() => {
|
||||||
onSuccess(code)
|
onSuccess(code)
|
||||||
},
|
}, MIN_DELAY - timeTaken)
|
||||||
[redirectHost, stateParam, onSuccess, onError],
|
} else {
|
||||||
)
|
onSuccess(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebView
|
<WebView
|
||||||
source={{uri: url}}
|
source={{uri: url}}
|
||||||
javaScriptEnabled
|
javaScriptEnabled
|
||||||
style={styles.webview}
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||||
onNavigationStateChange={onNavigationStateChange}
|
onNavigationStateChange={onNavigationStateChange}
|
||||||
scrollEnabled={false}
|
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 React, {useEffect, useState} from 'react'
|
||||||
import {ActivityIndicator, View} from 'react-native'
|
import {ActivityIndicator, Platform, View} from 'react-native'
|
||||||
|
import ReactNativeDeviceAttest from 'react-native-device-attest'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import {createFullHandle} from '#/lib/strings/handles'
|
import {createFullHandle} from '#/lib/strings/handles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
||||||
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
|
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
|
||||||
import {useSignupContext} from '#/screens/Signup/state'
|
import {useSignupContext} from '#/screens/Signup/state'
|
||||||
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
|
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {FormError} from '#/components/forms/FormError'
|
import {FormError} from '#/components/forms/FormError'
|
||||||
|
import {GCP_PROJECT_ID} from '#/env'
|
||||||
import {BackNextButtons} from '../BackNextButtons'
|
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() {
|
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 {_} = useLingui()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const {state, dispatch} = useSignupContext()
|
const {state, dispatch} = useSignupContext()
|
||||||
@@ -33,8 +86,24 @@ export function StepCaptcha() {
|
|||||||
newUrl.searchParams.set('state', stateParam)
|
newUrl.searchParams.set('state', stateParam)
|
||||||
newUrl.searchParams.set('colorScheme', theme.name)
|
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
|
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(
|
const onSuccess = React.useCallback(
|
||||||
(code: string) => {
|
(code: string) => {
|
||||||
@@ -105,3 +174,13 @@ export function StepCaptcha() {
|
|||||||
</ScreenTransition>
|
</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, '')
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {useEffect, useReducer, useState} from 'react'
|
import {useEffect, useReducer, useState} from 'react'
|
||||||
import {AppState, type AppStateStatus, View} from 'react-native'
|
import {AppState, type AppStateStatus, View} from 'react-native'
|
||||||
|
import ReactNativeDeviceAttest from 'react-native-device-attest'
|
||||||
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
|
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
|
||||||
import {AppBskyGraphStarterpack} from '@atproto/api'
|
import {AppBskyGraphStarterpack} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {FEEDBACK_FORM_URL} from '#/lib/constants'
|
import {FEEDBACK_FORM_URL} from '#/lib/constants'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {isAndroid} from '#/platform/detection'
|
||||||
import {useServiceQuery} from '#/state/queries/service'
|
import {useServiceQuery} from '#/state/queries/service'
|
||||||
import {useStarterPackQuery} from '#/state/queries/starter-packs'
|
import {useStarterPackQuery} from '#/state/queries/starter-packs'
|
||||||
import {useActiveStarterPack} from '#/state/shell/starter-pack'
|
import {useActiveStarterPack} from '#/state/shell/starter-pack'
|
||||||
@@ -26,6 +29,7 @@ import {Divider} from '#/components/Divider'
|
|||||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {GCP_PROJECT_ID} from '#/env'
|
||||||
import * as bsky from '#/types/bsky'
|
import * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export function Signup({onPressBack}: {onPressBack: () => void}) {
|
export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||||
@@ -101,6 +105,16 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
|||||||
return () => subscription.remove()
|
return () => subscription.remove()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// On Android, warmup the Play Integrity API on the signup screen so it is ready by the time we get to the gate screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAndroid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ReactNativeDeviceAttest.warmupIntegrity(GCP_PROJECT_ID).catch(err =>
|
||||||
|
logger.error(err),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SignupContext.Provider value={{state, dispatch}}>
|
<SignupContext.Provider value={{state, dispatch}}>
|
||||||
<LoggedOutLayout
|
<LoggedOutLayout
|
||||||
|
|||||||
@@ -16787,6 +16787,11 @@ react-native-date-picker@^5.0.12:
|
|||||||
resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-5.0.12.tgz#12540b6a58500811ee7e4fc0244e3accc7cca9c1"
|
resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-5.0.12.tgz#12540b6a58500811ee7e4fc0244e3accc7cca9c1"
|
||||||
integrity sha512-R/mUnCKhcuxbhKPFwYdBQCxQt9HHLqpM4ruRUqlcBjiUZ3N2wdnwOMyc888Ps8qp8e7v29PrDHtUlG8LPuFn9w==
|
integrity sha512-R/mUnCKhcuxbhKPFwYdBQCxQt9HHLqpM4ruRUqlcBjiUZ3N2wdnwOMyc888Ps8qp8e7v29PrDHtUlG8LPuFn9w==
|
||||||
|
|
||||||
|
react-native-device-attest@^0.1.6:
|
||||||
|
version "0.1.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-native-device-attest/-/react-native-device-attest-0.1.6.tgz#51796a92d9199b1d231d4aa62d557019753b30c3"
|
||||||
|
integrity sha512-oTgBu6il+czHIMLs2IVWv2+WZ6a/vUtVLQ40q6/Dgns7NuG69mwmR8lS0e2Sl/yOtpD9YZXc8cYzBDKHAyV5MA==
|
||||||
|
|
||||||
react-native-dotenv@^3.4.11:
|
react-native-dotenv@^3.4.11:
|
||||||
version "3.4.11"
|
version "3.4.11"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-dotenv/-/react-native-dotenv-3.4.11.tgz#2e6c4eabd55d5f1bf109b3dd9141dadf9c55cdd4"
|
resolved "https://registry.yarnpkg.com/react-native-dotenv/-/react-native-dotenv-3.4.11.tgz#2e6c4eabd55d5f1bf109b3dd9141dadf9c55cdd4"
|
||||||
|
|||||||
Reference in New Issue
Block a user