Add device attestation to signup flow (#8757)

This commit is contained in:
hailey
2025-08-07 12:33:38 -07:00
committed by GitHub
parent 39e775a376
commit c0593e4979
6 changed files with 160 additions and 42 deletions
+1
View File
@@ -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",
+7
View File
@@ -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,
},
})
+83 -4
View File
@@ -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, '')
}
+14
View File
@@ -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
+5
View File
@@ -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"