Move session locking into persistence
This commit is contained in:
@@ -77,31 +77,21 @@ No action. This whole-root localStorage read-modify-write race predates the vers
|
|||||||
|
|
||||||
Compatibility reference: [MDN `Navigator.locks`](https://developer.mozilla.org/docs/Web/API/Navigator/locks).
|
Compatibility reference: [MDN `Navigator.locks`](https://developer.mozilla.org/docs/Web/API/Navigator/locks).
|
||||||
|
|
||||||
## 3. Enforce the session lock invariant
|
## 3. Keep lock ownership inside persistence
|
||||||
|
|
||||||
### Problem
|
### Problem
|
||||||
|
|
||||||
`writeSession()` performs the conditional session read-modify-write but does not acquire the persisted-storage lock itself. Current session persistence callsites run it inside `runWithCredentialLock()`, which aliases the root storage lock, but this is a convention rather than an enforced API invariant.
|
`writeSession()` previously relied on every caller to enter the persisted-storage lock before dispatching a session action. The lock ownership was transitive and invisible at the write API, so a future direct caller could silently bypass serialization.
|
||||||
|
|
||||||
The lock cannot simply be added inside `writeSession()` while callers retain the outer lock because Web Locks are not reentrant. A nested request for the same exclusive lock would deadlock.
|
### Resolution
|
||||||
|
|
||||||
A future caller could accidentally do this:
|
`writeSession()` now owns the persisted-storage lock on web. Its lock callback contains exactly the shared-state commit:
|
||||||
|
|
||||||
```ts
|
1. read the authoritative root from localStorage;
|
||||||
await persisted.writeSession({
|
2. conditionally merge the session mutation;
|
||||||
nextSession,
|
3. write the updated root; and
|
||||||
credentialMutations,
|
4. broadcast the committed update.
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
without first entering the root lock.
|
Session callsites no longer acquire the lock. Network requests, reducer work, expiry rescue, and local bundle reconciliation remain outside it. Native retains its existing serialized AsyncStorage queue inside `writeSession()`.
|
||||||
|
|
||||||
### Possible direction
|
This makes every call to `writeSession()` safe by construction, avoids non-reentrant nested Web Locks, and keeps the lock scoped to the operation that actually needs cross-tab exclusion.
|
||||||
|
|
||||||
Potential enforcement options include:
|
|
||||||
|
|
||||||
1. Add a development assertion tracking whether the current realm is inside `runWithPersistedStorageLock()`.
|
|
||||||
2. Expose the unlocked commit only through a capability passed to the lock callback.
|
|
||||||
3. Move lock ownership into a higher-level session transaction API that performs the authoritative read, reconciliation, and write together.
|
|
||||||
|
|
||||||
Any enforcement should preserve the existing requirement that network refreshes happen outside the lock.
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ Tab A sends refresh token A to the PDS
|
|||||||
Tab A receives successor generation B
|
Tab A receives successor generation B
|
||||||
```
|
```
|
||||||
|
|
||||||
Before committing the response, Tab A acquires the credential lock and synchronously rereads localStorage. The source of truth may have changed while its network request was in flight.
|
Before committing the response, `writeSession()` acquires the persisted-storage lock and synchronously rereads localStorage. The source of truth may have changed while the network request was in flight.
|
||||||
|
|
||||||
### Case 1: nothing else changed
|
### Case 1: nothing else changed
|
||||||
|
|
||||||
@@ -276,16 +276,14 @@ Tab A plans: write version 9
|
|||||||
Tab B plans: write version 9
|
Tab B plans: write version 9
|
||||||
```
|
```
|
||||||
|
|
||||||
Network refreshes do not run while holding a Web Lock. A refresh captures the generation it uses, performs the network request, and acquires the lock only to reconcile and commit its result:
|
Network refreshes do not run while holding a Web Lock. A refresh captures the generation it uses and performs the network request first. The resulting session mutation is then passed to `writeSession()`, which owns the lock around reconciliation and persistence:
|
||||||
|
|
||||||
```ts
|
```text
|
||||||
const baseRefreshJti = getRefreshJti(session.refreshJwt)
|
Capture refresh generation A
|
||||||
const refreshed = await refreshSession()
|
Perform network refresh A -> B
|
||||||
|
Call writeSession with base A and result B
|
||||||
await navigator.locks.request('bsky-persisted-storage', async () => {
|
writeSession acquires the persisted-storage lock
|
||||||
const latest = readAccountFromLocalStorage(did)
|
writeSession rereads, conditionally merges, and persists
|
||||||
// Commit only if latest is active and still has baseRefreshJti.
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This avoids holding a cross-tab lock over network I/O. Multiple refresh requests may be in flight concurrently; server-side convergence and the generation-specific conditional commit make their results safe.
|
This avoids holding a cross-tab lock over network I/O. Multiple refresh requests may be in flight concurrently; server-side convergence and the generation-specific conditional commit make their results safe.
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ import {type PersistedApi} from './types'
|
|||||||
import {normalizeData} from './util'
|
import {normalizeData} from './util'
|
||||||
|
|
||||||
export type {SessionCredentialMutation} from './session-merge'
|
export type {SessionCredentialMutation} from './session-merge'
|
||||||
export {
|
|
||||||
runWithPersistedStorageLock as runWithCredentialLock,
|
|
||||||
runWithPersistedStorageLock,
|
|
||||||
} from './storage-lock'
|
|
||||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||||
export {defaults} from '#/state/persisted/schema'
|
export {defaults} from '#/state/persisted/schema'
|
||||||
|
|
||||||
@@ -78,6 +74,7 @@ export function write<K extends keyof Schema>(
|
|||||||
}
|
}
|
||||||
write satisfies PersistedApi['write']
|
write satisfies PersistedApi['write']
|
||||||
|
|
||||||
|
/** Queue a conditional session merge with other root storage writes. */
|
||||||
export function writeSession({
|
export function writeSession({
|
||||||
nextSession,
|
nextSession,
|
||||||
credentialMutations,
|
credentialMutations,
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ import {type PersistedApi} from './types'
|
|||||||
import {normalizeData} from './util'
|
import {normalizeData} from './util'
|
||||||
|
|
||||||
export type {SessionCredentialMutation} from './session-merge'
|
export type {SessionCredentialMutation} from './session-merge'
|
||||||
export {
|
|
||||||
runWithPersistedStorageLock as runWithCredentialLock,
|
|
||||||
runWithPersistedStorageLock,
|
|
||||||
} from './storage-lock'
|
|
||||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||||
export {defaults} from '#/state/persisted/schema'
|
export {defaults} from '#/state/persisted/schema'
|
||||||
|
|
||||||
@@ -112,8 +108,8 @@ export function write<K extends keyof Schema>(
|
|||||||
}
|
}
|
||||||
write satisfies PersistedApi['write']
|
write satisfies PersistedApi['write']
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
/** Commit a conditional session merge while holding the root storage lock. */
|
||||||
export async function writeSession({
|
export function writeSession({
|
||||||
nextSession,
|
nextSession,
|
||||||
credentialMutations,
|
credentialMutations,
|
||||||
currentAccountDid,
|
currentAccountDid,
|
||||||
@@ -122,19 +118,23 @@ export async function writeSession({
|
|||||||
credentialMutations: SessionCredentialMutation[]
|
credentialMutations: SessionCredentialMutation[]
|
||||||
currentAccountDid?: string
|
currentAccountDid?: string
|
||||||
}): Promise<Schema['session']> {
|
}): Promise<Schema['session']> {
|
||||||
const stored = readFromStorage() ?? _state
|
return runWithPersistedStorageLock({
|
||||||
const session = applySessionUpdate({
|
operation: () => {
|
||||||
storedSession: stored.session,
|
const stored = readFromStorage() ?? _state
|
||||||
nextSession,
|
const session = applySessionUpdate({
|
||||||
credentialMutations,
|
storedSession: stored.session,
|
||||||
currentAccountDid,
|
nextSession,
|
||||||
})
|
credentialMutations,
|
||||||
const updated = normalizeData({...stored, session})
|
currentAccountDid,
|
||||||
writeToStorage(updated)
|
})
|
||||||
_state = updated
|
const updated = normalizeData({...stored, session})
|
||||||
|
writeToStorage(updated)
|
||||||
|
_state = updated
|
||||||
|
|
||||||
broadcastUpdate({key: 'session'})
|
broadcastUpdate({key: 'session'})
|
||||||
return session
|
return session
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
writeSession satisfies PersistedApi['writeSession']
|
writeSession satisfies PersistedApi['writeSession']
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import {type PersistedApi} from './types'
|
|
||||||
|
|
||||||
export function runWithPersistedStorageLock<T>({
|
export function runWithPersistedStorageLock<T>({
|
||||||
operation,
|
operation,
|
||||||
}: {
|
}: {
|
||||||
@@ -15,4 +13,3 @@ export function runWithPersistedStorageLock<T>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import {type PersistedApi} from './types'
|
|
||||||
|
|
||||||
const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
|
const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
|
||||||
|
|
||||||
export function runWithPersistedStorageLock<T>({
|
export function runWithPersistedStorageLock<T>({
|
||||||
@@ -22,8 +20,6 @@ export function runWithPersistedStorageLock<T>({
|
|||||||
|
|
||||||
return lockManager.request(PERSISTED_STORAGE_LOCK, operation)
|
return lockManager.request(PERSISTED_STORAGE_LOCK, operation)
|
||||||
}
|
}
|
||||||
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
|
|
||||||
|
|
||||||
function getLockManager(): LockManager | undefined {
|
function getLockManager(): LockManager | undefined {
|
||||||
if (typeof navigator === 'undefined' || !('locks' in navigator)) {
|
if (typeof navigator === 'undefined' || !('locks' in navigator)) {
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ export type PersistedApi = {
|
|||||||
/** Omit to preserve the current account read from persisted storage. */
|
/** Omit to preserve the current account read from persisted storage. */
|
||||||
currentAccountDid?: string
|
currentAccountDid?: string
|
||||||
}): Promise<Schema['session']>
|
}): Promise<Schema['session']>
|
||||||
runWithPersistedStorageLock<T>(args: {
|
|
||||||
operation: () => T | Promise<T>
|
|
||||||
}): Promise<T>
|
|
||||||
onUpdate<K extends keyof Schema>(
|
onUpdate<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
cb: (v: Schema[K]) => void,
|
cb: (v: Schema[K]) => void,
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ jest.mock('#/state/persisted', () => {
|
|||||||
readLatest: () => defaults.session,
|
readLatest: () => defaults.session,
|
||||||
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
||||||
Promise.resolve(nextSession),
|
Promise.resolve(nextSession),
|
||||||
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
|
|
||||||
Promise.resolve(operation()),
|
|
||||||
onUpdate: () => () => {},
|
onUpdate: () => () => {},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ jest.mock('#/state/persisted', () => {
|
|||||||
readLatest: () => defaults.session,
|
readLatest: () => defaults.session,
|
||||||
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
||||||
Promise.resolve(nextSession),
|
Promise.resolve(nextSession),
|
||||||
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
|
|
||||||
Promise.resolve(operation()),
|
|
||||||
onUpdate: () => () => {},
|
onUpdate: () => () => {},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ jest.mock('#/state/persisted', () => {
|
|||||||
readLatest: () => defaults.session,
|
readLatest: () => defaults.session,
|
||||||
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
|
||||||
Promise.resolve(nextSession),
|
Promise.resolve(nextSession),
|
||||||
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
|
|
||||||
Promise.resolve(operation()),
|
|
||||||
onUpdate: () => () => {},
|
onUpdate: () => () => {},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ jest.mock('#/state/persisted', () => ({
|
|||||||
mockPersisted.latest = committed
|
mockPersisted.latest = committed
|
||||||
return Promise.resolve(committed)
|
return Promise.resolve(committed)
|
||||||
},
|
},
|
||||||
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
|
|
||||||
Promise.resolve(operation()),
|
|
||||||
onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => {
|
onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => {
|
||||||
mockPersistedListeners.push(callback)
|
mockPersistedListeners.push(callback)
|
||||||
return () => {}
|
return () => {}
|
||||||
|
|||||||
+222
-248
@@ -161,7 +161,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const onSessionChangeRef = useRef<OnSessionChange | null>(null)
|
const onSessionChangeRef = useRef<OnSessionChange | null>(null)
|
||||||
|
|
||||||
const onSessionChange = useCallback(
|
const onSessionChange = useCallback(
|
||||||
(
|
async (
|
||||||
bundle: SessionBundle,
|
bundle: SessionBundle,
|
||||||
accountDid: string,
|
accountDid: string,
|
||||||
sessionEvent: AtpSessionEvent,
|
sessionEvent: AtpSessionEvent,
|
||||||
@@ -172,164 +172,159 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
? bundle.session.session.refreshJwt
|
? bundle.session.session.refreshJwt
|
||||||
: sessionData?.refreshJwt
|
: sessionData?.refreshJwt
|
||||||
|
|
||||||
return persistedSession.runWithCredentialLock({
|
/*
|
||||||
operation: async () => {
|
* Only the live bundle may reset the expiry-rescue bookkeeping: a stale
|
||||||
/*
|
* bundle's late update would otherwise clear the failed-generation set
|
||||||
* Only the live bundle may reset the expiry-rescue bookkeeping: a stale
|
* that bounds the rescue loop. (Its dispatch below is separately dropped
|
||||||
* bundle's late update would otherwise clear the failed-generation set
|
* by the reducer's identity guard.)
|
||||||
* that bounds the rescue loop. (Its dispatch below is separately dropped
|
*/
|
||||||
* by the reducer's identity guard.)
|
if (
|
||||||
*/
|
sessionEvent === 'update' &&
|
||||||
if (
|
sessionData &&
|
||||||
sessionEvent === 'update' &&
|
(store.getState().currentBundleState.bundle as unknown as
|
||||||
sessionData &&
|
SessionBundle | PublicSessionBundle) === bundle
|
||||||
(store.getState().currentBundleState.bundle as unknown as
|
) {
|
||||||
SessionBundle | PublicSessionBundle) === bundle
|
failedExpiryTokensRef.current.get(accountDid)?.clear()
|
||||||
) {
|
}
|
||||||
failedExpiryTokensRef.current.get(accountDid)?.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* PasswordSession invokes its hooks before updating its live getter. Use
|
* PasswordSession invokes its hooks before updating its live getter. Use
|
||||||
* the delivered payload so a refresh persists the newly rotated tokens.
|
* the delivered payload so a refresh persists the newly rotated tokens.
|
||||||
*
|
*
|
||||||
* A refresh payload carries no didDoc unless the server sends one, so the
|
* A refresh payload carries no didDoc unless the server sends one, so the
|
||||||
* stored account's `pdsUrl` is threaded in as the fallback. Without it the
|
* stored account's `pdsUrl` is threaded in as the fallback. Without it the
|
||||||
* refresh would persist `pdsUrl: undefined` and the next cold start would
|
* refresh would persist `pdsUrl: undefined` and the next cold start would
|
||||||
* route pre-refresh requests to the entryway instead of the PDS.
|
* route pre-refresh requests to the entryway instead of the PDS.
|
||||||
*/
|
*/
|
||||||
const refreshedAccount =
|
const refreshedAccount =
|
||||||
sessionEvent === 'update' && sessionData
|
sessionEvent === 'update' && sessionData
|
||||||
? sessionDataToSessionAccount(
|
? sessionDataToSessionAccount(
|
||||||
sessionData,
|
sessionData,
|
||||||
sessionData.service,
|
sessionData.service,
|
||||||
store.getState().accounts.find(a => a.did === accountDid)
|
store.getState().accounts.find(a => a.did === accountDid)?.pdsUrl,
|
||||||
?.pdsUrl,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
/*
|
|
||||||
* A stale tab may expire a token after another tab has already rotated it.
|
|
||||||
* Prefer a newer persisted or reducer generation over logging every tab
|
|
||||||
* out. Failed generations are recorded and bounded to guarantee that a
|
|
||||||
* repeatedly expiring session eventually falls through to logout.
|
|
||||||
*/
|
|
||||||
if (sessionEvent === 'expired') {
|
|
||||||
const current = store.getState()
|
|
||||||
const currentBundle = current.currentBundleState
|
|
||||||
.bundle as unknown as SessionBundle | PublicSessionBundle
|
|
||||||
const dyingRefreshJwt = sessionData?.refreshJwt
|
|
||||||
// Stale bundle events are handled by the reducer's identity guard.
|
|
||||||
if (
|
|
||||||
currentBundle === bundle &&
|
|
||||||
current.currentBundleState.did === accountDid &&
|
|
||||||
dyingRefreshJwt
|
|
||||||
) {
|
|
||||||
let failedSet = failedExpiryTokensRef.current.get(accountDid)
|
|
||||||
if (!failedSet) {
|
|
||||||
failedSet = new Set()
|
|
||||||
failedExpiryTokensRef.current.set(accountDid, failedSet)
|
|
||||||
}
|
|
||||||
failedSet.add(dyingRefreshJwt)
|
|
||||||
|
|
||||||
const persistedCandidate = persistedSession
|
|
||||||
.readLatest('session')
|
|
||||||
.accounts.find(a => a.did === accountDid)
|
|
||||||
const reducerCandidate = current.accounts.find(
|
|
||||||
a => a.did === accountDid,
|
|
||||||
)
|
|
||||||
const candidate = pickExpiryRescueCandidate({
|
|
||||||
dyingRefreshJwt,
|
|
||||||
candidates: [persistedCandidate, reducerCandidate],
|
|
||||||
failedRefreshJwts: failedSet,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (candidate) {
|
|
||||||
const rebuilt = createSessionBundleFromStoredAccount(
|
|
||||||
candidate,
|
|
||||||
onSessionChangeRef.current!,
|
|
||||||
)
|
|
||||||
if (rebuilt) {
|
|
||||||
await store.dispatch({
|
|
||||||
type: 'replaced-current-bundle',
|
|
||||||
newBundle: rebuilt.bundle,
|
|
||||||
newAccount: rebuilt.account,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the current bundle may report that its session was dropped.
|
|
||||||
if (
|
|
||||||
sessionEvent === 'expired' &&
|
|
||||||
store.getState().currentBundleState.bundle === bundle
|
|
||||||
) {
|
|
||||||
emitSessionDropped()
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentialMutations: SessionCredentialMutation[] = []
|
|
||||||
if (sessionEvent === 'update' && refreshedAccount) {
|
|
||||||
credentialMutations.push({
|
|
||||||
type: 'refresh',
|
|
||||||
accountDid,
|
|
||||||
baseRefreshJwt,
|
|
||||||
resultRefreshJwt: refreshedAccount.refreshJwt,
|
|
||||||
})
|
|
||||||
} else if (sessionEvent === 'expired') {
|
|
||||||
credentialMutations.push({
|
|
||||||
type: 'expire',
|
|
||||||
accountDid,
|
|
||||||
baseRefreshJwt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bundle identity prevents stale sessions from changing the active account.
|
|
||||||
const committedSession = await store.dispatch(
|
|
||||||
{
|
|
||||||
type: 'received-session-event',
|
|
||||||
bundle,
|
|
||||||
refreshedAccount,
|
|
||||||
accountDid,
|
|
||||||
sessionEvent,
|
|
||||||
},
|
|
||||||
credentialMutations,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (sessionEvent === 'update' && committedSession) {
|
|
||||||
const committedAccount = committedSession.accounts.find(
|
|
||||||
account => account.did === accountDid,
|
|
||||||
)
|
)
|
||||||
if (
|
: undefined
|
||||||
committedAccount?.refreshJwt &&
|
|
||||||
committedAccount.refreshJwt !== refreshedAccount?.refreshJwt &&
|
/*
|
||||||
store.getState().currentBundleState.bundle === bundle
|
* A stale tab may expire a token after another tab has already rotated it.
|
||||||
) {
|
* Prefer a newer persisted or reducer generation over logging every tab
|
||||||
const rebuilt = createSessionBundleFromStoredAccount(
|
* out. Failed generations are recorded and bounded to guarantee that a
|
||||||
committedAccount,
|
* repeatedly expiring session eventually falls through to logout.
|
||||||
onSessionChangeRef.current!,
|
*/
|
||||||
)
|
if (sessionEvent === 'expired') {
|
||||||
if (rebuilt) {
|
const current = store.getState()
|
||||||
await store.dispatch({
|
const currentBundle = current.currentBundleState.bundle as unknown as
|
||||||
type: 'replaced-current-bundle',
|
SessionBundle | PublicSessionBundle
|
||||||
newBundle: rebuilt.bundle,
|
const dyingRefreshJwt = sessionData?.refreshJwt
|
||||||
newAccount: rebuilt.account,
|
// Stale bundle events are handled by the reducer's identity guard.
|
||||||
})
|
if (
|
||||||
}
|
currentBundle === bundle &&
|
||||||
} else if (
|
current.currentBundleState.did === accountDid &&
|
||||||
!committedAccount?.refreshJwt &&
|
dyingRefreshJwt
|
||||||
store.getState().currentBundleState.bundle === bundle
|
) {
|
||||||
) {
|
let failedSet = failedExpiryTokensRef.current.get(accountDid)
|
||||||
|
if (!failedSet) {
|
||||||
|
failedSet = new Set()
|
||||||
|
failedExpiryTokensRef.current.set(accountDid, failedSet)
|
||||||
|
}
|
||||||
|
failedSet.add(dyingRefreshJwt)
|
||||||
|
|
||||||
|
const persistedCandidate = persistedSession
|
||||||
|
.readLatest('session')
|
||||||
|
.accounts.find(a => a.did === accountDid)
|
||||||
|
const reducerCandidate = current.accounts.find(
|
||||||
|
a => a.did === accountDid,
|
||||||
|
)
|
||||||
|
const candidate = pickExpiryRescueCandidate({
|
||||||
|
dyingRefreshJwt,
|
||||||
|
candidates: [persistedCandidate, reducerCandidate],
|
||||||
|
failedRefreshJwts: failedSet,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (candidate) {
|
||||||
|
const rebuilt = createSessionBundleFromStoredAccount(
|
||||||
|
candidate,
|
||||||
|
onSessionChangeRef.current!,
|
||||||
|
)
|
||||||
|
if (rebuilt) {
|
||||||
await store.dispatch({
|
await store.dispatch({
|
||||||
type: 'synced-accounts',
|
type: 'replaced-current-bundle',
|
||||||
syncedAccounts: committedSession.accounts,
|
newBundle: rebuilt.bundle,
|
||||||
syncedCurrentDid: committedSession.currentAccount?.did,
|
newAccount: rebuilt.account,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the current bundle may report that its session was dropped.
|
||||||
|
if (
|
||||||
|
sessionEvent === 'expired' &&
|
||||||
|
store.getState().currentBundleState.bundle === bundle
|
||||||
|
) {
|
||||||
|
emitSessionDropped()
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialMutations: SessionCredentialMutation[] = []
|
||||||
|
if (sessionEvent === 'update' && refreshedAccount) {
|
||||||
|
credentialMutations.push({
|
||||||
|
type: 'refresh',
|
||||||
|
accountDid,
|
||||||
|
baseRefreshJwt,
|
||||||
|
resultRefreshJwt: refreshedAccount.refreshJwt,
|
||||||
|
})
|
||||||
|
} else if (sessionEvent === 'expired') {
|
||||||
|
credentialMutations.push({
|
||||||
|
type: 'expire',
|
||||||
|
accountDid,
|
||||||
|
baseRefreshJwt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundle identity prevents stale sessions from changing the active account.
|
||||||
|
const committedSession = await store.dispatch(
|
||||||
|
{
|
||||||
|
type: 'received-session-event',
|
||||||
|
bundle,
|
||||||
|
refreshedAccount,
|
||||||
|
accountDid,
|
||||||
|
sessionEvent,
|
||||||
},
|
},
|
||||||
})
|
credentialMutations,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sessionEvent === 'update' && committedSession) {
|
||||||
|
const committedAccount = committedSession.accounts.find(
|
||||||
|
account => account.did === accountDid,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
committedAccount?.refreshJwt &&
|
||||||
|
committedAccount.refreshJwt !== refreshedAccount?.refreshJwt &&
|
||||||
|
store.getState().currentBundleState.bundle === bundle
|
||||||
|
) {
|
||||||
|
const rebuilt = createSessionBundleFromStoredAccount(
|
||||||
|
committedAccount,
|
||||||
|
onSessionChangeRef.current!,
|
||||||
|
)
|
||||||
|
if (rebuilt) {
|
||||||
|
await store.dispatch({
|
||||||
|
type: 'replaced-current-bundle',
|
||||||
|
newBundle: rebuilt.bundle,
|
||||||
|
newAccount: rebuilt.account,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
!committedAccount?.refreshJwt &&
|
||||||
|
store.getState().currentBundleState.bundle === bundle
|
||||||
|
) {
|
||||||
|
await store.dispatch({
|
||||||
|
type: 'synced-accounts',
|
||||||
|
syncedAccounts: committedSession.accounts,
|
||||||
|
syncedCurrentDid: committedSession.currentAccount?.did,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[store],
|
[store],
|
||||||
)
|
)
|
||||||
@@ -358,23 +353,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
disposeBundle(bundle)
|
disposeBundle(bundle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await persistedSession.runWithCredentialLock({
|
await store.dispatch(
|
||||||
operation: () =>
|
{
|
||||||
store.dispatch(
|
type: 'switched-to-account',
|
||||||
{
|
newBundle: bundle,
|
||||||
type: 'switched-to-account',
|
newAccount: account,
|
||||||
newBundle: bundle,
|
},
|
||||||
newAccount: account,
|
[
|
||||||
},
|
{
|
||||||
[
|
type: 'login',
|
||||||
{
|
accountDid: account.did,
|
||||||
type: 'login',
|
resultRefreshJwt: account.refreshJwt,
|
||||||
accountDid: account.did,
|
},
|
||||||
resultRefreshJwt: account.refreshJwt,
|
],
|
||||||
},
|
)
|
||||||
],
|
|
||||||
),
|
|
||||||
})
|
|
||||||
ax.metric('account:create:success', metrics, {
|
ax.metric('account:create:success', metrics, {
|
||||||
session: utils.accountToSessionMetadata(account),
|
session: utils.accountToSessionMetadata(account),
|
||||||
})
|
})
|
||||||
@@ -401,23 +393,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
disposeBundle(bundle)
|
disposeBundle(bundle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await persistedSession.runWithCredentialLock({
|
await store.dispatch(
|
||||||
operation: () =>
|
{
|
||||||
store.dispatch(
|
type: 'switched-to-account',
|
||||||
{
|
newBundle: bundle,
|
||||||
type: 'switched-to-account',
|
newAccount: account,
|
||||||
newBundle: bundle,
|
},
|
||||||
newAccount: account,
|
[
|
||||||
},
|
{
|
||||||
[
|
type: 'login',
|
||||||
{
|
accountDid: account.did,
|
||||||
type: 'login',
|
resultRefreshJwt: account.refreshJwt,
|
||||||
accountDid: account.did,
|
},
|
||||||
resultRefreshJwt: account.refreshJwt,
|
],
|
||||||
},
|
)
|
||||||
],
|
|
||||||
),
|
|
||||||
})
|
|
||||||
ax.metric(
|
ax.metric(
|
||||||
'account:loggedIn',
|
'account:loggedIn',
|
||||||
{logContext, withPassword: true},
|
{logContext, withPassword: true},
|
||||||
@@ -441,13 +430,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const prevState = store.getState()
|
const prevState = store.getState()
|
||||||
const accountDid = prevState.currentBundleState.did
|
const accountDid = prevState.currentBundleState.did
|
||||||
if (accountDid) {
|
if (accountDid) {
|
||||||
void persistedSession
|
void store
|
||||||
.runWithCredentialLock({
|
.dispatch({type: 'logged-out-current-account', accountDid}, [
|
||||||
operation: () =>
|
{type: 'logout', accountDid},
|
||||||
store.dispatch({type: 'logged-out-current-account', accountDid}, [
|
])
|
||||||
{type: 'logout', accountDid},
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
ax.metric(
|
ax.metric(
|
||||||
@@ -489,17 +475,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
.accounts.map(account => account.did),
|
.accounts.map(account => account.did),
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
void persistedSession
|
void store
|
||||||
.runWithCredentialLock({
|
.dispatch(
|
||||||
operation: () =>
|
{type: 'logged-out-every-account'},
|
||||||
store.dispatch(
|
accountDids.map(accountDid => ({
|
||||||
{type: 'logged-out-every-account'},
|
type: 'logout' as const,
|
||||||
accountDids.map(accountDid => ({
|
accountDid,
|
||||||
type: 'logout' as const,
|
})),
|
||||||
accountDid,
|
)
|
||||||
})),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
ax.metric(
|
ax.metric(
|
||||||
'account:loggedOut',
|
'account:loggedOut',
|
||||||
@@ -556,24 +539,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
disposeBundle(bundle)
|
disposeBundle(bundle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const committedSession = await persistedSession.runWithCredentialLock({
|
const committedSession = await store.dispatch(
|
||||||
operation: () =>
|
{
|
||||||
store.dispatch(
|
type: 'switched-to-account',
|
||||||
{
|
newBundle: bundle,
|
||||||
type: 'switched-to-account',
|
newAccount: account,
|
||||||
newBundle: bundle,
|
},
|
||||||
newAccount: account,
|
[
|
||||||
},
|
{
|
||||||
[
|
type: 'refresh',
|
||||||
{
|
accountDid: account.did,
|
||||||
type: 'refresh',
|
baseRefreshJwt: latestStoredAccount.refreshJwt,
|
||||||
accountDid: account.did,
|
resultRefreshJwt: account.refreshJwt,
|
||||||
baseRefreshJwt: latestStoredAccount.refreshJwt,
|
},
|
||||||
resultRefreshJwt: account.refreshJwt,
|
],
|
||||||
},
|
)
|
||||||
],
|
|
||||||
),
|
|
||||||
})
|
|
||||||
const committedAccount = committedSession?.accounts.find(
|
const committedAccount = committedSession?.accounts.find(
|
||||||
candidate => candidate.did === account.did,
|
candidate => candidate.did === account.did,
|
||||||
)
|
)
|
||||||
@@ -625,21 +605,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
/* getSession targets the PDS; only the persisted account fields are patched. */
|
/* getSession targets the PDS; only the persisted account fields are patched. */
|
||||||
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
|
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
|
||||||
if (signal.aborted) return
|
if (signal.aborted) return
|
||||||
await persistedSession.runWithCredentialLock({
|
await store.dispatch({
|
||||||
operation: () =>
|
type: 'partial-refresh-session',
|
||||||
store.dispatch({
|
/*
|
||||||
type: 'partial-refresh-session',
|
* Read the did off the response rather than the session: the bundle may
|
||||||
/*
|
* have been disposed while the request was in flight, and the live
|
||||||
* Read the did off the response rather than the session: the bundle may
|
* getters throw in that state.
|
||||||
* have been disposed while the request was in flight, and the live
|
*/
|
||||||
* getters throw in that state.
|
accountDid: data.did,
|
||||||
*/
|
patch: {
|
||||||
accountDid: data.did,
|
emailConfirmed: data.emailConfirmed,
|
||||||
patch: {
|
emailAuthFactor: data.emailAuthFactor,
|
||||||
emailConfirmed: data.emailConfirmed,
|
},
|
||||||
emailAuthFactor: data.emailAuthFactor,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}, [store, cancelPendingTask])
|
}, [store, cancelPendingTask])
|
||||||
|
|
||||||
@@ -711,17 +688,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
account: redactAccount(account),
|
account: redactAccount(account),
|
||||||
})
|
})
|
||||||
cancelPendingTask()
|
cancelPendingTask()
|
||||||
void persistedSession
|
void store
|
||||||
.runWithCredentialLock({
|
.dispatch(
|
||||||
operation: () =>
|
{
|
||||||
store.dispatch(
|
type: 'removed-account',
|
||||||
{
|
accountDid: account.did,
|
||||||
type: 'removed-account',
|
},
|
||||||
accountDid: account.did,
|
[{type: 'remove', accountDid: account.did}],
|
||||||
},
|
)
|
||||||
[{type: 'remove', accountDid: account.did}],
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
addSessionDebugLog({
|
addSessionDebugLog({
|
||||||
type: 'method:end',
|
type: 'method:end',
|
||||||
|
|||||||
Reference in New Issue
Block a user