Fix stale cross-tab session writes
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
|||||||
tryStringify,
|
tryStringify,
|
||||||
} from '#/state/persisted/schema'
|
} from '#/state/persisted/schema'
|
||||||
import {device} from '#/storage'
|
import {device} from '#/storage'
|
||||||
import {type PersistedApi} from './types'
|
import {type PersistedApi, type PersistedWriteValue} from './types'
|
||||||
import {normalizeData} from './util'
|
import {normalizeData} from './util'
|
||||||
|
|
||||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||||
@@ -45,16 +45,22 @@ readLatest satisfies PersistedApi['readLatest']
|
|||||||
|
|
||||||
export async function write<K extends keyof Schema>(
|
export async function write<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
value: Schema[K],
|
value: PersistedWriteValue<Schema[K]>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
_state = normalizeData({
|
_state = normalizeData({
|
||||||
..._state,
|
..._state,
|
||||||
[key]: value,
|
[key]: resolveWriteValue(value, _state[key]),
|
||||||
})
|
})
|
||||||
await writeToStorage(_state)
|
await writeToStorage(_state)
|
||||||
}
|
}
|
||||||
write satisfies PersistedApi['write']
|
write satisfies PersistedApi['write']
|
||||||
|
|
||||||
|
function resolveWriteValue<T>(value: PersistedWriteValue<T>, latest: T): T {
|
||||||
|
return typeof value === 'function'
|
||||||
|
? (value as (current: T) => T)(latest)
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
|
||||||
export function onUpdate<K extends keyof Schema>(
|
export function onUpdate<K extends keyof Schema>(
|
||||||
_key: K,
|
_key: K,
|
||||||
_cb: (v: Schema[K]) => void,
|
_cb: (v: Schema[K]) => void,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
tryParse,
|
tryParse,
|
||||||
tryStringify,
|
tryStringify,
|
||||||
} from '#/state/persisted/schema'
|
} from '#/state/persisted/schema'
|
||||||
import {type PersistedApi} from './types'
|
import {type PersistedApi, type PersistedWriteValue} from './types'
|
||||||
import {normalizeData} from './util'
|
import {normalizeData} from './util'
|
||||||
|
|
||||||
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
|
||||||
@@ -65,7 +65,7 @@ readLatest satisfies PersistedApi['readLatest']
|
|||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
export async function write<K extends keyof Schema>(
|
export async function write<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
value: Schema[K],
|
value: PersistedWriteValue<Schema[K]>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const next = readFromStorage()
|
const next = readFromStorage()
|
||||||
if (next) {
|
if (next) {
|
||||||
@@ -75,8 +75,9 @@ export async function write<K extends keyof Schema>(
|
|||||||
// Don't fire the update listeners yet to avoid a loop.
|
// Don't fire the update listeners yet to avoid a loop.
|
||||||
// If there was a change, we'll receive the broadcast event soon enough which will do that.
|
// If there was a change, we'll receive the broadcast event soon enough which will do that.
|
||||||
}
|
}
|
||||||
|
const nextValue = resolveWriteValue(value, _state[key])
|
||||||
try {
|
try {
|
||||||
if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
|
if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: nextValue})) {
|
||||||
// Fast path for updates that are guaranteed to be noops.
|
// Fast path for updates that are guaranteed to be noops.
|
||||||
// This is good mostly because it avoids useless broadcasts to other tabs.
|
// This is good mostly because it avoids useless broadcasts to other tabs.
|
||||||
return
|
return
|
||||||
@@ -86,7 +87,7 @@ export async function write<K extends keyof Schema>(
|
|||||||
}
|
}
|
||||||
_state = normalizeData({
|
_state = normalizeData({
|
||||||
..._state,
|
..._state,
|
||||||
[key]: value,
|
[key]: nextValue,
|
||||||
})
|
})
|
||||||
writeToStorage(_state)
|
writeToStorage(_state)
|
||||||
broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
|
broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
|
||||||
@@ -94,6 +95,12 @@ export async function write<K extends keyof Schema>(
|
|||||||
}
|
}
|
||||||
write satisfies PersistedApi['write']
|
write satisfies PersistedApi['write']
|
||||||
|
|
||||||
|
function resolveWriteValue<T>(value: PersistedWriteValue<T>, latest: T): T {
|
||||||
|
return typeof value === 'function'
|
||||||
|
? (value as (current: T) => T)(latest)
|
||||||
|
: value
|
||||||
|
}
|
||||||
|
|
||||||
export function onUpdate<K extends keyof Schema>(
|
export function onUpdate<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
cb: (v: Schema[K]) => void,
|
cb: (v: Schema[K]) => void,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import {type Schema} from './schema'
|
import {type Schema} from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value to persist, or a function that derives one from the freshest
|
||||||
|
* persisted value while the write operation is in progress.
|
||||||
|
*/
|
||||||
|
export type PersistedWriteValue<T> = T | ((latest: T) => T)
|
||||||
|
|
||||||
export type PersistedApi = {
|
export type PersistedApi = {
|
||||||
init(): Promise<void>
|
init(): Promise<void>
|
||||||
get<K extends keyof Schema>(key: K): Schema[K]
|
get<K extends keyof Schema>(key: K): Schema[K]
|
||||||
@@ -12,7 +18,10 @@ export type PersistedApi = {
|
|||||||
* it is identical to {@link get} (single-instance, no other writer).
|
* it is identical to {@link get} (single-instance, no other writer).
|
||||||
*/
|
*/
|
||||||
readLatest<K extends keyof Schema>(key: K): Schema[K]
|
readLatest<K extends keyof Schema>(key: K): Schema[K]
|
||||||
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
|
write<K extends keyof Schema>(
|
||||||
|
key: K,
|
||||||
|
value: PersistedWriteValue<Schema[K]>,
|
||||||
|
): Promise<void>
|
||||||
onUpdate<K extends keyof Schema>(
|
onUpdate<K extends keyof Schema>(
|
||||||
key: K,
|
key: K,
|
||||||
cb: (v: Schema[K]) => void,
|
cb: (v: Schema[K]) => void,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import {type SessionData} from '@atproto/lex-password-session'
|
import {type SessionData} from '@atproto/lex-password-session'
|
||||||
import {describe, expect, it, jest} from '@jest/globals'
|
import {describe, expect, it, jest} from '@jest/globals'
|
||||||
|
|
||||||
import {type Action, getInitialState, reducer, type State} from '../reducer'
|
import {
|
||||||
|
type Action,
|
||||||
|
getInitialState,
|
||||||
|
rebasePersistedSession,
|
||||||
|
reducer,
|
||||||
|
type State,
|
||||||
|
} from '../reducer'
|
||||||
import {sessionDataToSessionAccount} from '../session-core'
|
import {sessionDataToSessionAccount} from '../session-core'
|
||||||
import {type SessionAccount} from '../types'
|
import {type SessionAccount} from '../types'
|
||||||
|
|
||||||
@@ -1777,8 +1783,233 @@ describe('session', () => {
|
|||||||
expect(state.currentBundleState.did).toBe('alice-did')
|
expect(state.currentBundleState.did).toBe('alice-did')
|
||||||
expect(state.needsPersist).toBe(true)
|
expect(state.needsPersist).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('rebasePersistedSession', () => {
|
||||||
|
it('keeps a newer token generation when a stale metadata update is persisted', () => {
|
||||||
|
const stale = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-1',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-1',
|
||||||
|
emailConfirmed: false,
|
||||||
|
emailAuthFactor: false,
|
||||||
|
})
|
||||||
|
const fresh = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-2',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-2',
|
||||||
|
emailConfirmed: false,
|
||||||
|
emailAuthFactor: false,
|
||||||
|
})
|
||||||
|
const bob = makeAccount('https://bob.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'bob-did',
|
||||||
|
handle: 'bob.test',
|
||||||
|
accessJwt: 'bob-access-jwt-1',
|
||||||
|
refreshJwt: 'bob-refresh-jwt-1',
|
||||||
|
})
|
||||||
|
const desired = snapshot([{...stale, emailConfirmed: true}], 'alice-did')
|
||||||
|
const latest = snapshot([fresh, bob], 'alice-did')
|
||||||
|
|
||||||
|
const rebased = rebasePersistedSession(latest, desired, {
|
||||||
|
type: 'partial-refresh-session',
|
||||||
|
accountDid: 'alice-did',
|
||||||
|
patch: {emailConfirmed: true, emailAuthFactor: false},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rebased.accounts).toEqual([{...fresh, emailConfirmed: true}, bob])
|
||||||
|
expect(rebased.currentAccount?.did).toBe('alice-did')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a fresh token pair delivered by the active session', () => {
|
||||||
|
const stale = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-1',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-1',
|
||||||
|
})
|
||||||
|
const fresh = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-2',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-2',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rebased = rebasePersistedSession(
|
||||||
|
snapshot([stale], 'alice-did'),
|
||||||
|
snapshot([fresh], 'alice-did'),
|
||||||
|
{
|
||||||
|
type: 'received-session-event',
|
||||||
|
bundle: makeBundle('https://alice.com'),
|
||||||
|
accountDid: 'alice-did',
|
||||||
|
refreshedAccount: fresh,
|
||||||
|
sessionEvent: 'update',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(rebased.accounts).toEqual([fresh])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves a concurrent newer generation instead of clearing it on expiry', () => {
|
||||||
|
const dying = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-1',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-1',
|
||||||
|
})
|
||||||
|
const fresh = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-2',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-2',
|
||||||
|
})
|
||||||
|
const expired = {...dying, accessJwt: undefined, refreshJwt: undefined}
|
||||||
|
|
||||||
|
const rebased = rebasePersistedSession(
|
||||||
|
snapshot([fresh], 'alice-did'),
|
||||||
|
snapshot([expired]),
|
||||||
|
{
|
||||||
|
type: 'received-session-event',
|
||||||
|
bundle: makeBundle('https://alice.com'),
|
||||||
|
accountDid: 'alice-did',
|
||||||
|
refreshedAccount: undefined,
|
||||||
|
sessionEvent: 'expired',
|
||||||
|
expiredRefreshJwt: dying.refreshJwt,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(rebased.accounts).toEqual([fresh])
|
||||||
|
expect(rebased.currentAccount?.did).toBe('alice-did')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps explicit logout authoritative over a newer stored token', () => {
|
||||||
|
const fresh = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-2',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-2',
|
||||||
|
})
|
||||||
|
const loggedOut = {...fresh, accessJwt: undefined, refreshJwt: undefined}
|
||||||
|
|
||||||
|
const rebased = rebasePersistedSession(
|
||||||
|
snapshot([fresh], 'alice-did'),
|
||||||
|
snapshot([loggedOut]),
|
||||||
|
{type: 'logged-out-current-account'},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(rebased.accounts).toEqual([loggedOut])
|
||||||
|
expect(rebased.currentAccount).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears only the explicitly logged-out account', () => {
|
||||||
|
const alice = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-2',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-2',
|
||||||
|
})
|
||||||
|
const bobStale = makeAccount('https://bob.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'bob-did',
|
||||||
|
handle: 'bob.test',
|
||||||
|
accessJwt: 'bob-access-jwt-1',
|
||||||
|
refreshJwt: 'bob-refresh-jwt-1',
|
||||||
|
})
|
||||||
|
const bobFresh = {
|
||||||
|
...bobStale,
|
||||||
|
accessJwt: 'bob-access-jwt-2',
|
||||||
|
refreshJwt: 'bob-refresh-jwt-2',
|
||||||
|
}
|
||||||
|
const loggedOutAlice = {
|
||||||
|
...alice,
|
||||||
|
accessJwt: undefined,
|
||||||
|
refreshJwt: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const rebased = rebasePersistedSession(
|
||||||
|
snapshot([alice, bobFresh], 'alice-did'),
|
||||||
|
snapshot([loggedOutAlice, bobStale]),
|
||||||
|
{type: 'logged-out-current-account', accountDid: 'alice-did'},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(rebased.accounts).toEqual([loggedOutAlice, bobFresh])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not restore an account another tab removed', () => {
|
||||||
|
const stale = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-1',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rebased = rebasePersistedSession(
|
||||||
|
snapshot([]),
|
||||||
|
snapshot([{...stale, emailConfirmed: true}], 'alice-did'),
|
||||||
|
{
|
||||||
|
type: 'partial-refresh-session',
|
||||||
|
accountDid: 'alice-did',
|
||||||
|
patch: {emailConfirmed: true, emailAuthFactor: false},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(rebased.accounts).toEqual([])
|
||||||
|
expect(rebased.currentAccount).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears credentials for accounts added before an all-account logout', () => {
|
||||||
|
const alice = makeAccount('https://alice.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'alice-did',
|
||||||
|
handle: 'alice.test',
|
||||||
|
accessJwt: 'alice-access-jwt-2',
|
||||||
|
refreshJwt: 'alice-refresh-jwt-2',
|
||||||
|
})
|
||||||
|
const bob = makeAccount('https://bob.com', {
|
||||||
|
active: true,
|
||||||
|
did: 'bob-did',
|
||||||
|
handle: 'bob.test',
|
||||||
|
accessJwt: 'bob-access-jwt-2',
|
||||||
|
refreshJwt: 'bob-refresh-jwt-2',
|
||||||
|
})
|
||||||
|
const loggedOutAlice = {
|
||||||
|
...alice,
|
||||||
|
accessJwt: undefined,
|
||||||
|
refreshJwt: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const rebased = rebasePersistedSession(
|
||||||
|
snapshot([alice, bob], 'alice-did'),
|
||||||
|
snapshot([loggedOutAlice]),
|
||||||
|
{type: 'logged-out-every-account'},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(rebased.accounts).toEqual([
|
||||||
|
loggedOutAlice,
|
||||||
|
{...bob, accessJwt: undefined, refreshJwt: undefined},
|
||||||
|
])
|
||||||
|
expect(rebased.currentAccount).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function snapshot(accounts: SessionAccount[], currentDid?: string) {
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
currentAccount: accounts.find(account => account.did === currentDid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function run(initialState: State, actions: Action[]): State {
|
function run(initialState: State, actions: Action[]): State {
|
||||||
let state = initialState
|
let state = initialState
|
||||||
for (let action of actions) {
|
for (let action of actions) {
|
||||||
|
|||||||
@@ -22,7 +22,13 @@ import {emitSessionDropped} from '../events'
|
|||||||
import {getPublicAppviewClient} from './clients'
|
import {getPublicAppviewClient} from './clients'
|
||||||
import {createSessionBundleAndCreateAccount} from './create-account'
|
import {createSessionBundleAndCreateAccount} from './create-account'
|
||||||
import {pickExpiryRescueCandidate} from './expiry-rescue'
|
import {pickExpiryRescueCandidate} from './expiry-rescue'
|
||||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
import {
|
||||||
|
type Action,
|
||||||
|
getInitialState,
|
||||||
|
rebasePersistedSession,
|
||||||
|
reducer,
|
||||||
|
type State,
|
||||||
|
} from './reducer'
|
||||||
import {
|
import {
|
||||||
type AtpSessionEvent,
|
type AtpSessionEvent,
|
||||||
createSessionBundleAndLogin,
|
createSessionBundleAndLogin,
|
||||||
@@ -108,17 +114,29 @@ class SessionStore {
|
|||||||
// Persist synchronously without waiting for the React render cycle.
|
// Persist synchronously without waiting for the React render cycle.
|
||||||
if (nextState.needsPersist) {
|
if (nextState.needsPersist) {
|
||||||
nextState.needsPersist = false
|
nextState.needsPersist = false
|
||||||
const persistedData = {
|
const desiredPersistedData = {
|
||||||
accounts: nextState.accounts,
|
accounts: nextState.accounts,
|
||||||
currentAccount: nextState.accounts.find(
|
currentAccount: nextState.accounts.find(
|
||||||
a => a.did === nextState.currentBundleState.did,
|
a => a.did === nextState.currentBundleState.did,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
addSessionDebugLog({
|
/*
|
||||||
type: 'persisted:broadcast',
|
* A background tab can have missed a token-rotation broadcast. Derive the
|
||||||
data: redactPersistedSession(persistedData),
|
* outgoing value inside `write`, after its final web storage re-read, so an
|
||||||
|
* unrelated metadata update cannot restore an older token generation.
|
||||||
|
*/
|
||||||
|
void persisted.write('session', latest => {
|
||||||
|
const persistedData = rebasePersistedSession(
|
||||||
|
latest,
|
||||||
|
desiredPersistedData,
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
addSessionDebugLog({
|
||||||
|
type: 'persisted:broadcast',
|
||||||
|
data: redactPersistedSession(persistedData),
|
||||||
|
})
|
||||||
|
return persistedData
|
||||||
})
|
})
|
||||||
void persisted.write('session', persistedData)
|
|
||||||
}
|
}
|
||||||
this.listeners.forEach(listener => listener())
|
this.listeners.forEach(listener => listener())
|
||||||
}
|
}
|
||||||
@@ -257,6 +275,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
refreshedAccount,
|
refreshedAccount,
|
||||||
accountDid,
|
accountDid,
|
||||||
sessionEvent,
|
sessionEvent,
|
||||||
|
expiredRefreshJwt:
|
||||||
|
sessionEvent === 'expired' ? sessionData?.refreshJwt : undefined,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[store],
|
[store],
|
||||||
@@ -290,6 +310,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newBundle: bundle,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
|
tokenUpdate: 'login',
|
||||||
})
|
})
|
||||||
ax.metric('account:create:success', metrics, {
|
ax.metric('account:create:success', metrics, {
|
||||||
session: utils.accountToSessionMetadata(account),
|
session: utils.accountToSessionMetadata(account),
|
||||||
@@ -321,6 +342,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newBundle: bundle,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
|
tokenUpdate: 'login',
|
||||||
})
|
})
|
||||||
ax.metric(
|
ax.metric(
|
||||||
'account:loggedIn',
|
'account:loggedIn',
|
||||||
@@ -345,6 +367,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const prevState = store.getState()
|
const prevState = store.getState()
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'logged-out-current-account',
|
type: 'logged-out-current-account',
|
||||||
|
accountDid: prevState.currentBundleState.did,
|
||||||
})
|
})
|
||||||
ax.metric(
|
ax.metric(
|
||||||
'account:loggedOut',
|
'account:loggedOut',
|
||||||
@@ -435,6 +458,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
type: 'switched-to-account',
|
type: 'switched-to-account',
|
||||||
newBundle: bundle,
|
newBundle: bundle,
|
||||||
newAccount: account,
|
newAccount: account,
|
||||||
|
tokenUpdate:
|
||||||
|
account.refreshJwt !== storedAccount.refreshJwt
|
||||||
|
? 'refresh'
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
addSessionDebugLog({
|
addSessionDebugLog({
|
||||||
type: 'method:end',
|
type: 'method:end',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {unregisterPushToken} from '#/lib/notifications/notifications'
|
import {unregisterPushToken} from '#/lib/notifications/notifications'
|
||||||
import {logger} from '#/lib/notifications/util'
|
import {logger} from '#/lib/notifications/util'
|
||||||
|
import {type Schema} from '#/state/persisted/schema'
|
||||||
import {wrapSessionReducerForLogging} from './logging'
|
import {wrapSessionReducerForLogging} from './logging'
|
||||||
import {createPublicSessionBundle} from './session-core'
|
import {createPublicSessionBundle} from './session-core'
|
||||||
import {type AtpSessionEvent, type SessionAccount} from './types'
|
import {type AtpSessionEvent, type SessionAccount} from './types'
|
||||||
@@ -28,11 +29,15 @@ export type Action =
|
|||||||
accountDid: string
|
accountDid: string
|
||||||
refreshedAccount: SessionAccount | undefined
|
refreshedAccount: SessionAccount | undefined
|
||||||
sessionEvent: AtpSessionEvent
|
sessionEvent: AtpSessionEvent
|
||||||
|
/** The token that failed, used to preserve a newer concurrent generation. */
|
||||||
|
expiredRefreshJwt?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'switched-to-account'
|
type: 'switched-to-account'
|
||||||
newBundle: OpaqueSessionBundle
|
newBundle: OpaqueSessionBundle
|
||||||
newAccount: SessionAccount
|
newAccount: SessionAccount
|
||||||
|
/** Whether this action obtained a fresh credential generation. */
|
||||||
|
tokenUpdate?: 'login' | 'refresh'
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
// Replace an immutable session from synced or rescued tokens without rebroadcasting.
|
// Replace an immutable session from synced or rescued tokens without rebroadcasting.
|
||||||
@@ -46,6 +51,8 @@ export type Action =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'logged-out-current-account'
|
type: 'logged-out-current-account'
|
||||||
|
/** Omitted only by legacy reducer tests; production always supplies it. */
|
||||||
|
accountDid?: string
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'logged-out-every-account'
|
type: 'logged-out-every-account'
|
||||||
@@ -61,6 +68,164 @@ export type Action =
|
|||||||
patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
|
patch: Pick<SessionAccount, 'emailConfirmed' | 'emailAuthFactor'>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebase a pending session write over the newest persisted snapshot.
|
||||||
|
*
|
||||||
|
* Browser tabs hold independent in-memory session state. A tab which missed a
|
||||||
|
* token-rotation broadcast may still need to persist unrelated metadata. The
|
||||||
|
* persisted layer can protect other root keys, but it cannot tell whether the
|
||||||
|
* complete `session` value it receives contains an older credential generation.
|
||||||
|
* Keep the newest stored credential pair unless this action is known to have
|
||||||
|
* obtained a fresh pair or intentionally cleared it.
|
||||||
|
*/
|
||||||
|
export function rebasePersistedSession(
|
||||||
|
latest: Schema['session'],
|
||||||
|
desired: Schema['session'],
|
||||||
|
action: Action,
|
||||||
|
): Schema['session'] {
|
||||||
|
const latestByDid = new Map(
|
||||||
|
latest.accounts.map(account => [account.did, account]),
|
||||||
|
)
|
||||||
|
const desiredDids = new Set(desired.accounts.map(account => account.did))
|
||||||
|
const removedDid =
|
||||||
|
action.type === 'removed-account' ? action.accountDid : undefined
|
||||||
|
const preservedNewerTokenDids = new Set<string>()
|
||||||
|
|
||||||
|
const accounts = desired.accounts.flatMap(desiredAccount => {
|
||||||
|
const latestAccount = latestByDid.get(desiredAccount.did)
|
||||||
|
if (!latestAccount) {
|
||||||
|
/* Only an explicit fresh login may introduce an account absent from storage. */
|
||||||
|
return shouldAddMissingAccount(action, desiredAccount.did)
|
||||||
|
? [desiredAccount]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenMode = getTokenWriteMode(
|
||||||
|
action,
|
||||||
|
desiredAccount.did,
|
||||||
|
latestAccount,
|
||||||
|
)
|
||||||
|
if (tokenMode === 'keep-latest') {
|
||||||
|
if (latestAccount.refreshJwt !== desiredAccount.refreshJwt) {
|
||||||
|
preservedNewerTokenDids.add(desiredAccount.did)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...desiredAccount,
|
||||||
|
accessJwt: latestAccount.accessJwt,
|
||||||
|
refreshJwt: latestAccount.refreshJwt,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [desiredAccount]
|
||||||
|
})
|
||||||
|
|
||||||
|
/* A stale tab must not discard an account another tab added meanwhile. */
|
||||||
|
for (const latestAccount of latest.accounts) {
|
||||||
|
if (
|
||||||
|
!desiredDids.has(latestAccount.did) &&
|
||||||
|
latestAccount.did !== removedDid
|
||||||
|
) {
|
||||||
|
accounts.push(
|
||||||
|
action.type === 'logged-out-every-account'
|
||||||
|
? {...latestAccount, accessJwt: undefined, refreshJwt: undefined}
|
||||||
|
: latestAccount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredCurrentDid = desired.currentAccount?.did
|
||||||
|
const latestCurrentDid = latest.currentAccount?.did
|
||||||
|
const currentDid = selectCurrentDid({
|
||||||
|
action,
|
||||||
|
desiredCurrentDid,
|
||||||
|
latestCurrentDid,
|
||||||
|
preservedNewerTokenDids,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts,
|
||||||
|
currentAccount: accounts.find(account => account.did === currentDid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAddMissingAccount(action: Action, did: string): boolean {
|
||||||
|
return (
|
||||||
|
action.type === 'switched-to-account' &&
|
||||||
|
action.newAccount.did === did &&
|
||||||
|
action.tokenUpdate === 'login'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenWriteMode(
|
||||||
|
action: Action,
|
||||||
|
did: string,
|
||||||
|
latestAccount: SessionAccount,
|
||||||
|
): 'replace' | 'clear' | 'keep-latest' {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'logged-out-current-account':
|
||||||
|
return action.accountDid === undefined || action.accountDid === did
|
||||||
|
? 'clear'
|
||||||
|
: 'keep-latest'
|
||||||
|
case 'logged-out-every-account':
|
||||||
|
return 'clear'
|
||||||
|
case 'received-session-event':
|
||||||
|
if (action.sessionEvent === 'update' && action.refreshedAccount) {
|
||||||
|
/* A remote logout wins over an in-flight local refresh. */
|
||||||
|
return latestAccount.refreshJwt ? 'replace' : 'keep-latest'
|
||||||
|
}
|
||||||
|
if (action.sessionEvent === 'expired') {
|
||||||
|
return latestAccount.refreshJwt &&
|
||||||
|
latestAccount.refreshJwt !== action.expiredRefreshJwt
|
||||||
|
? 'keep-latest'
|
||||||
|
: 'clear'
|
||||||
|
}
|
||||||
|
return 'keep-latest'
|
||||||
|
case 'switched-to-account':
|
||||||
|
if (action.newAccount.did !== did) return 'keep-latest'
|
||||||
|
if (action.tokenUpdate === 'login') return 'replace'
|
||||||
|
if (action.tokenUpdate === 'refresh' && latestAccount.refreshJwt) {
|
||||||
|
return 'replace'
|
||||||
|
}
|
||||||
|
return 'keep-latest'
|
||||||
|
default:
|
||||||
|
return 'keep-latest'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCurrentDid({
|
||||||
|
action,
|
||||||
|
desiredCurrentDid,
|
||||||
|
latestCurrentDid,
|
||||||
|
preservedNewerTokenDids,
|
||||||
|
}: {
|
||||||
|
action: Action
|
||||||
|
desiredCurrentDid: string | undefined
|
||||||
|
latestCurrentDid: string | undefined
|
||||||
|
preservedNewerTokenDids: Set<string>
|
||||||
|
}): string | undefined {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'switched-to-account':
|
||||||
|
case 'removed-account':
|
||||||
|
case 'logged-out-current-account':
|
||||||
|
case 'logged-out-every-account':
|
||||||
|
return desiredCurrentDid
|
||||||
|
case 'received-session-event':
|
||||||
|
if (
|
||||||
|
action.sessionEvent === 'expired' &&
|
||||||
|
action.accountDid !== undefined &&
|
||||||
|
preservedNewerTokenDids.has(action.accountDid)
|
||||||
|
) {
|
||||||
|
return latestCurrentDid
|
||||||
|
}
|
||||||
|
return action.sessionEvent === 'expired'
|
||||||
|
? desiredCurrentDid
|
||||||
|
: (latestCurrentDid ?? desiredCurrentDid)
|
||||||
|
default:
|
||||||
|
return latestCurrentDid ?? desiredCurrentDid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createPublicBundleState(): BundleState {
|
function createPublicBundleState(): BundleState {
|
||||||
return {
|
return {
|
||||||
bundle: createPublicSessionBundle(),
|
bundle: createPublicSessionBundle(),
|
||||||
|
|||||||
Reference in New Issue
Block a user