Integrate on-device age verification into access decision

Wire the expo-age-range device signals (already plumbed) into the age
assurance access decision, gated per-region.

- Model verificationMethods on region config (AgeAssuranceConfigRegion)
  and add a debug US/TX region permitting the 'device' method
- Add region accessors + getAssuredAgeFromDeviceSignals in util
- Surface deviceSignals through the AA context and feed lowerBound in as
  assuredAge, matching the existing IfAssuredOverAge rules
- NoAccessScreen "Verify now" now prompts the OS in device regions and
  falls back to the KWS dialog when unavailable/insufficient
- Keep useDeviceSignalsQuery disabled so we never prompt on load; restore
  from the persisted cache only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eric Bailey
2026-06-22 18:21:47 -05:00
parent 4b7abf0ff2
commit e834167f81
6 changed files with 221 additions and 74 deletions
+47 -20
View File
@@ -4,7 +4,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import * as AgeRange from 'expo-age-range'
import {
SupportCode,
@@ -12,7 +11,7 @@ import {
} from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSessionApi} from '#/state/session'
import {useSession, useSessionApi} from '#/state/session'
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
import {DeleteAccountDialog} from '#/screens/Settings/components/DeleteAccountDialog'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
@@ -33,10 +32,17 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {
getDeviceSignals,
setDeviceSignalsForDid,
useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {
getAssuredAgeFromDeviceSignals,
isLegacyBirthdateBug,
regionAllowsDeviceVerification,
useAgeAssuranceRegionConfig,
} from '#/ageAssurance/util'
import {useAnalytics} from '#/analytics'
@@ -308,6 +314,8 @@ function AccessSection() {
const getTimeAgo = useGetTimeAgo()
const {setDeviceGeolocation} = useDeviceGeolocationApi()
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
const {currentAccount} = useSession()
const region = useAgeAssuranceRegionConfig()
const aa = useAgeAssurance()
const {status, lastInitiatedAt} = aa.state
@@ -320,6 +328,41 @@ function AccessSection() {
? dateDiff(lastInitiatedAt, new Date(), 'down')
: null
const openKwsDialog = useCallback(() => {
control.open()
ax.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}, [control, ax, hasInitiated])
const onPressVerify = useCallback(async () => {
/*
* In regions that permit on-device verification, try the native age API
* first. If it returns a sufficient age, the cached signals flow into the
* AA state recompute and lift the gate. Otherwise we fall back to the KWS
* flow below. `getDeviceSignals` handles its own errors and returns
* undefined on failure, which also routes us to the fallback.
*/
if (region && regionAllowsDeviceVerification(region)) {
const did = currentAccount?.did
const signals = await getDeviceSignals()
if (did) {
setDeviceSignalsForDid({did, signals})
}
const assuredAge = getAssuredAgeFromDeviceSignals(region, signals)
if (assuredAge !== undefined) {
// Sufficient device signals: AA state recomputes from the cache
// write above and unlocks access. Nothing else to do here.
return
}
logger.debug(
`onPressVerify: device signals insufficient, falling back to KWS`,
)
}
openKwsDialog()
}, [region, currentAccount?.did, openKwsDialog])
return (
<>
<AgeAssuranceInitDialog control={control} />
@@ -349,23 +392,7 @@ function AccessSection() {
label={_(msg`Verify now`)}
size="large"
color={hasInitiated ? 'secondary' : 'primary'}
onPress={async () => {
try {
const ageRange = await AgeRange.requestAgeRangeAsync({
threshold1: 10,
threshold2: 13,
threshold3: 18,
});
console.log(ageRange)
} catch (e) {
console.error(e)
}
return
control.open()
ax.metric('ageAssurance:initDialogOpen', {
hasInitiatedPreviously: hasInitiated,
})
}}>
onPress={() => void onPressVerify()}>
<ButtonIcon icon={ShieldIcon} />
<ButtonText>
{hasInitiated ? (
+53 -48
View File
@@ -1,4 +1,5 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetConfig,
@@ -11,7 +12,6 @@ import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persist
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client'
import debounce from 'lodash.debounce'
import * as AgeRange from 'expo-age-range';
import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
@@ -489,15 +489,18 @@ export function useOtherRequiredDataQuery() {
export function createDeviceSignalsQueryKey({did}: {did: string}) {
return ['device-signals', did]
}
export async function getDeviceSignals(): Promise<AgeRange.AgeRangeResponse | undefined> {
export async function getDeviceSignals(): Promise<
AgeRange.AgeRangeResponse | undefined
> {
if (debug.enabled) return debug.resolve(debug.deviceSignals)
try {
return AgeRange.requestAgeRangeAsync({
return await AgeRange.requestAgeRangeAsync({
threshold1: 13,
threshold2: 16,
threshold3: 18,
});
} catch (e: any) {
})
} catch (err) {
const e = err as Error
logger.error(`getDeviceSignals: failed to get device signals`, {
safeMessage: e.message,
})
@@ -508,66 +511,60 @@ export function getDeviceSignalsFromCache({
did,
}: {
did: string
}):
| AgeRange.AgeRangeResponse
| undefined {
}): AgeRange.AgeRangeResponse | undefined {
return qc.getQueryData<AgeRange.AgeRangeResponse>(
createDeviceSignalsQueryKey({did}),
)
}
let deviceSignalsPrefetchPromise: Promise<void> | undefined
export async function prefetchDeviceSignals({
agent,
/**
* Writes freshly granted device signals into the (persisted) cache. Notifies
* the disabled `useDeviceSignalsQuery` observer so the AA state recomputes.
*/
export function setDeviceSignalsForDid({
did,
signals,
}: {
agent: AtpAgent
did: string
signals: AgeRange.AgeRangeResponse | undefined
}) {
qc.setQueryData<AgeRange.AgeRangeResponse | undefined>(
createDeviceSignalsQueryKey({did}),
signals,
)
}
export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
/**
* If we don't have a cache, it's possible the user hasn't granted access.
* We don't want to do this during the prefetch phase, so just exit early,
* the user can potentially enable it later.
* Device signals are restored from the persisted cache only — we never call
* the native age API during prefetch, since that would prompt the OS for
* users who haven't opted in. Awaiting cache hydration ensures any previously
* granted signals are available before the AA state is first computed. The
* user can (re)grant access later via the NoAccessScreen verify flow.
*/
await cacheHydrationPromise
const cached = getDeviceSignalsFromCache({did})
if (!cached) return
if (deviceSignalsPrefetchPromise) {
logger.debug(`prefetchDeviceSignals: already in progress`)
return
}
deviceSignalsPrefetchPromise = new Promise(async resolve => {
await cacheHydrationPromise
const cached = getDeviceSignalsFromCache({did})
if (cached) {
logger.debug(`prefetchDeviceSignals: using cache`)
resolve()
} else {
try {
logger.debug(`prefetchDeviceSignals: resolving...`)
const res = await getDeviceSignals()
qc.setQueryData<AgeRange.AgeRangeResponse>(
createDeviceSignalsQueryKey({did}),
res,
)
} catch (e: any) {
logger.warn(`prefetchDeviceSignals: failed`, {
safeMessage: e.message,
})
} finally {
resolve()
}
}
})
logger.debug(
`prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`,
)
}
export function useDeviceSignalsQuery() {
const agent = useAgent()
const did = getDidFromAgentSession(agent)
return useQuery(
{
enabled: !!did,
/**
* Disabled so we never auto-call the native age API on load — that would
* prompt the OS for every logged-in user. We restore from the persisted
* cache (via `initialData`) and otherwise only update reactively when the
* user explicitly verifies (see `getDeviceSignals` + `setQueryData` in
* the NoAccessScreen verify flow).
*
* A future enhancement could silently refresh here when already cached,
* since the OS returns the granted result without re-prompting.
*/
enabled: false,
initialData: getDeviceSignalsFromCache({did: did!}),
queryKey: createDeviceSignalsQueryKey({did: did!}),
async queryFn() {
@@ -620,6 +617,11 @@ export type AgeAssuranceServerData = {
*/
state: AppBskyAgeassuranceDefs.State | undefined
metadata: AgeAssuranceMetadata | undefined
/**
* The native on-device age signals, if the user has granted access. Only
* consumed for regions that permit device verification.
*/
deviceSignals: AgeRange.AgeRangeResponse | undefined
}
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
config: undefined,
@@ -629,6 +631,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
declaredAge: undefined,
birthdate: undefined,
},
deviceSignals: undefined,
})
export function useAgeAssuranceServerDataContext() {
return useContext(AgeAssuranceServerDataContext)
@@ -642,6 +645,7 @@ export function AgeAssuranceServerDataProvider({
const serverState = useServerStateQuery()
const {state, metadata} = serverState.data || {}
const {data} = useOtherRequiredDataQuery()
const {data: deviceSignals} = useDeviceSignalsQuery()
const ctx = useMemo(
() => ({
config,
@@ -654,8 +658,9 @@ export function AgeAssuranceServerDataProvider({
: undefined,
birthdate: data?.birthdate,
},
deviceSignals,
}),
[config, state, data, metadata],
[config, state, data, metadata, deviceSignals],
)
return (
<AgeAssuranceServerDataContext.Provider value={ctx}>
+33 -3
View File
@@ -1,14 +1,23 @@
import type * as AgeRange from 'expo-age-range'
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetState,
} from '@atproto/api'
import {type OtherRequiredData} from '#/ageAssurance/data'
import {type AgeAssuranceConfigRegion} from '#/ageAssurance/types'
import {IS_DEV, IS_E2E} from '#/env'
import {type Geolocation} from '#/geolocation'
/**
* Debug-only config shape. Mirrors {@link AppBskyAgeassuranceDefs.Config} but
* uses {@link AgeAssuranceConfigRegion}, which carries the not-yet-in-lexicon
* `verificationMethods` field so we can prototype on-device verification.
*/
export type DebugConfig = {
regions: AgeAssuranceConfigRegion[]
}
export const enabled = (IS_DEV && true) || IS_E2E
export const geolocation: Geolocation | undefined = enabled
@@ -46,7 +55,7 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
export const config: DebugConfig = {
regions: [
{
countryCode: 'AA',
@@ -59,6 +68,25 @@ export const config: AppBskyAgeassuranceDefs.Config = {
},
],
},
{
// On-device verification region (e.g. Texas). Set debug.geolocation to
// {countryCode: 'US', regionCode: 'TX'} to exercise the device flow.
countryCode: 'US',
regionCode: 'TX',
minAccessAge: 18,
verificationMethods: ['device'],
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'GB',
minAccessAge: 13,
@@ -262,7 +290,9 @@ const deviceSignalsEnabled = true
export const deviceSignals: AgeRange.AgeRangeResponse | undefined =
deviceSignalsEnabled
? {
lowerBound: null,
// Simulates the OS reporting the user is at least 18. Lower this below
// a region's IfAssuredOverAge threshold to exercise the KWS fallback.
lowerBound: 18,
upperBound: null,
}
: undefined
+17 -2
View File
@@ -1,4 +1,5 @@
import {useEffect, useMemo, useState} from 'react'
import type * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
@@ -8,6 +9,7 @@ import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session'
import {
getConfigFromCache,
getDeviceSignalsFromCache,
getOtherRequiredDataFromCache,
getServerStateFromCache,
useAgeAssuranceServerDataContext,
@@ -24,6 +26,7 @@ import {
import {
computeAgeAssuranceFlags,
getAgeAssuranceRegionConfigWithFallback,
getAssuredAgeFromDeviceSignals,
} from '#/ageAssurance/util'
import {type Geolocation, useGeolocation} from '#/geolocation'
import {device} from '#/storage'
@@ -39,12 +42,14 @@ function computeAgeAssuranceState({
config,
state,
metadata,
deviceSignals,
}: {
hasSession: boolean
geolocation: Geolocation
config?: AppBskyAgeassuranceDefs.Config
state?: AppBskyAgeassuranceDefs.State
metadata?: AgeAssuranceMetadata
deviceSignals?: AgeRange.AgeRangeResponse
}) {
/**
* This is where we control logged-out moderation prefs. It's all
@@ -93,10 +98,16 @@ function computeAgeAssuranceState({
* Otherwise, we need to compute the access based on the latest data. For
* accounts with an accurate birthdate, our default fallback rules should
* ensure correct access.
*
* In regions that permit on-device verification, the OS-provided age range
* is treated as an assured age and fed into the rule engine, where it
* matches `IfAssuredOverAge`/`IfAssuredUnderAge` rules.
*/
const assuredAge = getAssuredAgeFromDeviceSignals(region, deviceSignals)
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
assuredAge,
})
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
@@ -126,6 +137,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
const config = getConfigFromCache()
const state = getServerStateFromCache({did})
const requiredData = getOtherRequiredDataFromCache({did})
const deviceSignals = getDeviceSignalsFromCache({did})
const geolocation = device.get(['mergedGeolocation'])
if (!geolocation || !config || !state || !requiredData) {
@@ -151,6 +163,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
geolocation,
state: state.state,
metadata,
deviceSignals,
})
return {
@@ -166,7 +179,8 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
const {config, state, metadata} = useAgeAssuranceServerDataContext()
const {config, state, metadata, deviceSignals} =
useAgeAssuranceServerDataContext()
return useMemo(
() =>
@@ -176,8 +190,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
geolocation,
state,
metadata,
deviceSignals,
}),
[hasSession, geolocation, config, state, metadata],
[hasSession, geolocation, config, state, metadata, deviceSignals],
)
}
+26 -1
View File
@@ -1,7 +1,32 @@
import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
import {
type AppBskyAgeassuranceDefs,
type computeAgeAssuranceRegionAccess,
} from '@atproto/api'
import {logger} from '#/ageAssurance/logger'
/**
* The ways a user can satisfy age assurance within a given region.
*
* - `kws`: the third-party (KWS) verification flow.
* - `device`: native on-device age APIs (Apple Declared Age Range / Google
* Play Age Signals), surfaced via `expo-age-range`.
*
* NOTE: this is not yet part of the `app.bsky.ageassurance` lexicon. It's
* modeled client-side (see {@link AgeAssuranceConfigRegion}) while we prototype
* the shape. Once the lexicon adds `verificationMethods`, this can be removed in
* favor of the generated type.
*/
export type AgeAssuranceVerificationMethod = 'device' | 'kws'
/**
* A region config extended with the (not-yet-in-lexicon) `verificationMethods`
* field. Regions without the field are treated as KWS-only.
*/
export type AgeAssuranceConfigRegion = AppBskyAgeassuranceDefs.ConfigRegion & {
verificationMethods?: AgeAssuranceVerificationMethod[]
}
export enum AgeAssuranceAccess {
Unknown = 'unknown',
None = 'none',
+45
View File
@@ -1,4 +1,5 @@
import {useMemo} from 'react'
import type * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
getAgeAssuranceRegionConfig,
@@ -11,9 +12,11 @@ import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {
AgeAssuranceAccess,
type AgeAssuranceConfigRegion,
type AgeAssuranceFlags,
type AgeAssuranceMetadata,
type AgeAssuranceState,
type AgeAssuranceVerificationMethod,
} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
@@ -36,6 +39,48 @@ export function getAgeAssuranceRegionConfigWithFallback(
return region || FALLBACK_REGION_CONFIG
}
/**
* Returns the verification methods permitted for a region, defaulting to
* `['kws']` when the region doesn't specify any (the historical behavior).
*
* NOTE: `verificationMethods` is not yet part of the lexicon, so we read it via
* {@link AgeAssuranceConfigRegion}. See that type for the migration note.
*/
export function getRegionVerificationMethods(
region: AppBskyAgeassuranceDefs.ConfigRegion,
): AgeAssuranceVerificationMethod[] {
const methods = (region as AgeAssuranceConfigRegion).verificationMethods
return methods && methods.length > 0 ? methods : ['kws']
}
/**
* Whether a region permits satisfying age assurance via the native on-device
* age APIs (Apple Declared Age Range / Google Play Age Signals).
*/
export function regionAllowsDeviceVerification(
region: AppBskyAgeassuranceDefs.ConfigRegion,
): boolean {
return getRegionVerificationMethods(region).includes('device')
}
/**
* Derives an assured age from native device signals, but only for regions that
* permit device verification. The OS-provided `lowerBound` is the minimum age
* the platform will attest to, which maps directly onto the `assuredAge` input
* of the rule engine (i.e. `IfAssuredOverAge`/`IfAssuredUnderAge` rules).
*
* Returns undefined when the region doesn't allow device verification or when
* the OS didn't provide a usable lower bound.
*/
export function getAssuredAgeFromDeviceSignals(
region: AppBskyAgeassuranceDefs.ConfigRegion,
deviceSignals: AgeRange.AgeRangeResponse | undefined,
): number | undefined {
if (!regionAllowsDeviceVerification(region)) return undefined
const lowerBound = deviceSignals?.lowerBound
return typeof lowerBound === 'number' ? lowerBound : undefined
}
/**
* Hook to get the age assurance region config based on current geolocation.
* Does not fall-back to our app defaults. If no config is found, returns