···3535 | 'AgeAssuranceNoAccessScreen'
3636 scope: 'current' | 'every'
3737 }
3838+ // OAuth session lifecycle. These are silent client-side events used to
3939+ // diagnose unexpected sign-outs (refresh races, IndexedDB eviction, DPoP
4040+ // skew, etc.). `account:loggedOut` is reserved for user-initiated logouts.
4141+ 'oauth:sessionDeleted': {
4242+ // Reason the underlying OAuth client deleted the local session.
4343+ // Common values map to oauth-client/session-getter causes:
4444+ // 'session_deleted_by_another_process' — refresh race / cross-tab
4545+ // 'invalid_grant' — refresh token rejected
4646+ // 'database_closed' — IndexedDB unavailable / evicted
4747+ // 'unknown' — unmapped cause
4848+ cause: string
4949+ // Truncated error message for further triage in OpenSearch.
5050+ message?: string
5151+ }
5252+ 'oauth:sessionRefreshed': {}
5353+ // Fired when a refresh round-trip to /oauth/token fails for any reason —
5454+ // network blocked, timeout, 5xx, server-side rejection. The session may or
5555+ // may not survive (a 400 invalid_grant will additionally fire
5656+ // oauth:sessionDeleted; a network blip will not).
5757+ 'oauth:refreshFailed': {
5858+ triggerContext: 'background' | 'debugButton'
5959+ errorCategory:
6060+ | 'sessionDeleted'
6161+ | 'sessionExpired'
6262+ | 'invalidGrant'
6363+ | 'databaseClosed'
6464+ | 'dpopSkew'
6565+ | 'dpopOther'
6666+ | 'refreshExhausted'
6767+ | 'subMismatch'
6868+ | 'timeout'
6969+ | 'network'
7070+ | 'serverError'
7171+ | 'unknown'
7272+ // Truncated for triage in OpenSearch / postgres.
7373+ message?: string
7474+ // HTTP status when the failure was a non-2xx response (vs. a thrown fetch).
7575+ httpStatus?: number
7676+ }
7777+ 'oauth:sessionResumeFailed': {
7878+ // Where in the app the resume attempt happened.
7979+ logContext: 'AppBoot' | 'ChooseAccountForm' | 'SwitchAccount'
8080+ // Coarse error category derived from the thrown error string.
8181+ errorCategory:
8282+ | 'sessionDeleted'
8383+ | 'sessionExpired'
8484+ | 'invalidGrant'
8585+ | 'databaseClosed'
8686+ | 'dpopSkew'
8787+ | 'dpopOther'
8888+ | 'refreshExhausted'
8989+ | 'subMismatch'
9090+ | 'timeout'
9191+ | 'network'
9292+ | 'unknown'
9393+ message?: string
9494+ }
3895 'notifications:openApp': {
3996 reason: NotificationReason
4097 causedBoot: boolean
+28
src/lib/strings/errors.ts
···3939 ) {
4040 return t`This feature is not available with your current session. Please manage your account through your hosting provider's website, or sign out and sign back in to refresh your permissions.`
4141 }
4242+ if (
4343+ str.includes('session was deleted by another process') ||
4444+ str.includes('No refresh token available') ||
4545+ str.includes('The session was revoked')
4646+ ) {
4747+ return t`Your session has expired. Please sign in again.`
4848+ }
4949+ if (
5050+ str.includes('Database closed') ||
5151+ str.includes('Database has been disposed')
5252+ ) {
5353+ return t`Session storage is unavailable. Please sign in again.`
5454+ }
5555+ if (str.includes('invalid_dpop_proof') && str.includes('iat claim')) {
5656+ return t`Your device clock appears to be incorrect. Please check your system time settings and try again.`
5757+ }
5858+ if (str.includes('invalid_dpop_proof')) {
5959+ return t`Authentication error. Please try signing in again.`
6060+ }
6161+ if (str.includes('Session resume timed out')) {
6262+ return t`Sign in is taking too long. Please try again.`
6363+ }
6464+ if (
6565+ str.includes('Token set sub mismatch') ||
6666+ str.includes('Stored session sub mismatch')
6767+ ) {
6868+ return t`Session data is corrupted. Please sign in again.`
6969+ }
4270 if (str.includes('Account has been suspended')) {
4371 return t`Account has been suspended`
4472 }
+3-1
src/screens/Login/ChooseAccountForm.tsx
···33import {msg, Trans} from '@lingui/macro'
44import {useLingui} from '@lingui/react'
5566+import {cleanError} from '#/lib/strings/errors'
67import {logger} from '#/logger'
78import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
89import {useLoggedOutViewControls} from '#/state/shell/logged-out'
···6465 logger.error('choose account: initSession failed', {
6566 message: e.message,
6667 })
6767- Toast.show(_(msg`Sign in failed. Please try again.`))
6868+ const errMsg = cleanError(e.message || e.toString())
6969+ Toast.show(errMsg || _(msg`Sign in failed. Please try again.`))
6870 // Move to login form.
6971 onSelectAccount(account)
7072 } finally {
+30-4
src/screens/Settings/Settings.tsx
···2323import {useModerationOpts} from '#/state/preferences/moderation-opts'
2424import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration'
2525import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
2626-import {useAgent} from '#/state/session'
2726import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
2727+import {
2828+ getWebOAuthClient,
2929+ markNextRefreshContext,
3030+} from '#/state/session/oauth-web-client'
2831import {useOnboardingDispatch} from '#/state/shell'
2932import {useLoggedOutViewControls} from '#/state/shell/logged-out'
3033import {useCloseAllActiveElements} from '#/state/util'
···3336import * as SettingsList from '#/screens/Settings/components/SettingsList'
3437import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
3538import {AvatarStackWithFetch} from '#/components/AvatarStack'
3636-import {Button, ButtonText} from '#/components/Button'
3739import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist'
3840import {useDialogControl} from '#/components/Dialog'
3941import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
···6062import {Loader} from '#/components/Loader'
6163import * as Menu from '#/components/Menu'
6264import * as Prompt from '#/components/Prompt'
6565+import * as ToastV2 from '#/components/Toast'
6366import {Text} from '#/components/Typography'
6467import {useFullVerificationState} from '#/components/verification'
6568import {
···6871} from '#/components/verification/VerificationCheckButton'
6972import {useAnalytics} from '#/analytics'
7073import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env'
7171-import {device, useStorage} from '#/storage'
7274import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
73757476type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
···379381380382function DevOptions() {
381383 const {_} = useLingui()
382382- const agent = useAgent()
384384+ const {currentAccount} = useSession()
383385 const onboardingDispatch = useOnboardingDispatch()
384386 const navigation = useNavigation<NavigationProp>()
385387 const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
···462464 label={_(msg`Open moderation debug page`)}>
463465 <SettingsList.ItemText>
464466 <Trans>Debug Moderation</Trans>
467467+ </SettingsList.ItemText>
468468+ </SettingsList.PressableItem>
469469+ <SettingsList.PressableItem
470470+ onPress={async () => {
471471+ if (!currentAccount?.isOauthSession) {
472472+ ToastV2.show(_(msg`Current account is not an OAuth session`), {
473473+ type: 'warning',
474474+ })
475475+ return
476476+ }
477477+ markNextRefreshContext('debugButton')
478478+ try {
479479+ await getWebOAuthClient().restore(currentAccount.did, true)
480480+ ToastV2.show(_(msg`OAuth refresh succeeded`), {type: 'success'})
481481+ } catch (e) {
482482+ const msgText = e instanceof Error ? e.message : String(e)
483483+ ToastV2.show(_(msg`OAuth refresh failed: ${msgText}`), {
484484+ type: 'error',
485485+ })
486486+ }
487487+ }}
488488+ label={_(msg`Force OAuth token refresh`)}>
489489+ <SettingsList.ItemText>
490490+ <Trans>Force OAuth token refresh</Trans>
465491 </SettingsList.ItemText>
466492 </SettingsList.PressableItem>
467493 <SettingsList.PressableItem