fix(auth): prevent unexpected session logout

This commit is contained in:
shuaiplus
2026-07-13 02:11:12 +08:00
parent e25ec159bb
commit b731a014f1
24 changed files with 489 additions and 191 deletions
+14 -5
View File
@@ -42,6 +42,7 @@ interface RefreshFailure {
ok: false;
transient: boolean;
error: string;
retryAfterMs?: number;
}
interface RefreshSuccess {
@@ -333,8 +334,8 @@ export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskey
return json;
}
function isTransientRefreshStatus(status: number): boolean {
return status === 0 || status === 429 || status >= 500;
function isPermanentRefreshFailure(status: number, errorCode: string | undefined): boolean {
return status === 400 && (errorCode === 'invalid_grant' || errorCode === 'invalid_request');
}
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
@@ -346,6 +347,8 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
try {
const resp = await fetch('/identity/connect/token', {
method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
@@ -354,15 +357,19 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
});
if (!resp.ok) {
const json = await parseJson<TokenError>(resp);
const retryAfterSeconds = Number(resp.headers.get('Retry-After') || 0);
return {
ok: false,
transient: isTransientRefreshStatus(resp.status),
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_failed')),
transient: !isPermanentRefreshFailure(resp.status, json?.error),
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_temporarily_unavailable')),
...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
? { retryAfterMs: retryAfterSeconds * 1000 }
: {}),
};
}
const json = await parseJson<TokenSuccess>(resp);
if (!json?.access_token) {
return { ok: false, transient: false, error: t('txt_session_refresh_failed') };
return { ok: false, transient: true, error: t('txt_session_refresh_temporarily_unavailable') };
}
return { ok: true, token: json };
} catch (error) {
@@ -400,6 +407,8 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
}
await fetch('/identity/connect/revocation', {
method: 'POST',
cache: 'no-store',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
+48 -13
View File
@@ -199,26 +199,43 @@ function decodeJwtExp(accessToken: string | undefined): number | null {
}
}
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
type SessionRefreshOutcome =
| { kind: 'success'; session: SessionState }
| { kind: 'transient'; session: SessionState; message: string; retryAfterMs?: number }
| { kind: 'expired' };
async function maybeRefreshSession(session: SessionState): Promise<SessionRefreshOutcome> {
if (!session.refreshToken && session.authMode !== 'web-cookie') {
return session.accessToken ? { kind: 'success', session } : { kind: 'expired' };
}
const exp = decodeJwtExp(session.accessToken);
const nowSeconds = Math.floor(Date.now() / 1000);
if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
return session;
return { kind: 'success', session };
}
const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) {
if (refreshed.transient) return session;
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
if (refreshed.transient) {
return {
kind: 'transient',
session,
message: refreshed.error || t('txt_session_refresh_temporarily_unavailable'),
retryAfterMs: refreshed.retryAfterMs,
};
}
return { kind: 'expired' };
}
return {
...session,
accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
kind: 'success',
session: {
...session,
accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
},
};
}
@@ -388,25 +405,41 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
export async function hydrateLockedSession(
session: SessionState,
fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> {
): Promise<
| { kind: 'ready'; session: SessionState; profile: Profile | null }
| { kind: 'transient'; session: SessionState; profile: Profile | null; message: string; retryAfterMs?: number }
| { kind: 'expired'; session: null; profile: null }
> {
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
if (hasOfflineUnlock && browserReportsOffline()) {
return {
kind: 'ready',
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
};
}
const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) {
const refreshOutcome = await maybeRefreshSession(session);
if (refreshOutcome.kind === 'expired') {
return { kind: 'expired', session: null, profile: null };
}
if (refreshOutcome.kind === 'transient') {
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return {
kind: 'ready',
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
};
}
return { session: null, profile: null };
return {
kind: 'transient',
session,
profile: fallbackProfile,
message: refreshOutcome.message,
retryAfterMs: refreshOutcome.retryAfterMs,
};
}
const refreshedSession = refreshOutcome.session;
try {
const profile = await getProfile(
createAuthedFetch(
@@ -415,11 +448,13 @@ export async function hydrateLockedSession(
)
);
return {
kind: 'ready',
session: refreshedSession,
profile,
};
} catch {
return {
kind: 'ready',
session: refreshedSession,
profile: fallbackProfile,
};
+1
View File
@@ -981,6 +981,7 @@ const de: Record<string, string> = {
"txt_save_profile_failed": "Fehler beim Speichern des Profils",
"txt_search_sends": "Sendungen suchen...",
"txt_session_refresh_failed": "Sitzungsaktualisierung fehlgeschlagen. Bitte melden Sie sich erneut an.",
"txt_session_refresh_temporarily_unavailable": "Die Sitzung kann vorübergehend nicht geprüft werden. Die Anmeldung bleibt erhalten und wird erneut versucht.",
"txt_search_your_secure_vault": "Ihren sicheren Tresor durchsuchen...",
"txt_search_items_count": "In {count} Einträgen suchen...",
"txt_clear_search": "Suche löschen",
+1
View File
@@ -1004,6 +1004,7 @@ const en: Record<string, string> = {
"txt_save_profile_failed": "Save profile failed",
"txt_search_sends": "Search sends...",
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
"txt_session_refresh_temporarily_unavailable": "Session verification is temporarily unavailable. Your login is preserved and will retry.",
"txt_search_your_secure_vault": "Search your secure vault...",
"txt_search_items_count": "Search within {count} items...",
"txt_clear_search": "Clear search",
+1
View File
@@ -981,6 +981,7 @@ const es: Record<string, string> = {
"txt_save_profile_failed": "Error al guardar perfil",
"txt_search_sends": "Buscar envíos...",
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
"txt_session_refresh_temporarily_unavailable": "La sesión no se puede verificar temporalmente. Tu inicio de sesión se conserva y se volverá a intentar.",
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
"txt_search_items_count": "Buscar entre {count} elementos...",
"txt_clear_search": "Limpiar búsqueda",
+1
View File
@@ -981,6 +981,7 @@ const fi: Record<string, string> = {
"txt_save_profile_failed": "Profiilin tallennus epäonnistui",
"txt_search_sends": "Hae lähetyksiä...",
"txt_session_refresh_failed": "Istunnon päivitys epäonnistui. Kirjaudu sisään uudelleen.",
"txt_session_refresh_temporarily_unavailable": "Istuntoa ei voida tarkistaa juuri nyt. Kirjautuminen säilytetään ja tarkistusta yritetään uudelleen.",
"txt_search_your_secure_vault": "Hae turvallisesta holvistasi...",
"txt_search_items_count": "Hae {count} nimikkeen joukosta...",
"txt_clear_search": "Tyhjennä haku",
+1
View File
@@ -981,6 +981,7 @@ const fr: Record<string, string> = {
"txt_save_profile_failed": "L'enregistrement du profil a échoué",
"txt_search_sends": "Rechercher des envois...",
"txt_session_refresh_failed": "L'actualisation de la session a échoué. Veuillez vous reconnecter.",
"txt_session_refresh_temporarily_unavailable": "La session ne peut pas être vérifiée temporairement. Votre connexion est conservée et une nouvelle tentative sera effectuée.",
"txt_search_your_secure_vault": "Recherchez dans votre coffre-fort sécurisé...",
"txt_search_items_count": "Rechercher parmi {count} éléments...",
"txt_clear_search": "Effacer la recherche",
+1
View File
@@ -981,6 +981,7 @@ const it: Record<string, string> = {
"txt_save_profile_failed": "Salvataggio Profilo fallito",
"txt_search_sends": "Cerca invii...",
"txt_session_refresh_failed": "Aggiornamento della sessione fallito. Per favore, accedi di nuovo.",
"txt_session_refresh_temporarily_unavailable": "La sessione non può essere verificata temporaneamente. L'accesso viene mantenuto e verrà effettuato un nuovo tentativo.",
"txt_search_your_secure_vault": "Cerca nella tua cassaforte sicura...",
"txt_search_items_count": "Cerca in {count} elementi...",
"txt_clear_search": "Cancella Ricerca",
+1
View File
@@ -981,6 +981,7 @@ const ru: Record<string, string> = {
"txt_save_profile_failed": "Сохранить профиль не удалось",
"txt_search_sends": "Поиск отправляет...",
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
"txt_session_refresh_temporarily_unavailable": "Сеанс временно не удаётся проверить. Вход сохранён, проверка будет повторена.",
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
"txt_search_items_count": "Поиск по {count} элементам...",
"txt_clear_search": "Очистить поиск",
+1
View File
@@ -981,6 +981,7 @@ const sv: Record<string, string> = {
"txt_save_profile_failed": "Misslyckades med att spara profil",
"txt_search_sends": "Sök sändningar...",
"txt_session_refresh_failed": "Sessionsuppdatering misslyckades. Vänligen logga in igen.",
"txt_session_refresh_temporarily_unavailable": "Sessionen kan inte verifieras tillfälligt. Inloggningen bevaras och ett nytt försök görs.",
"txt_search_your_secure_vault": "Sök i ditt säkra valv...",
"txt_search_items_count": "Sök bland {count} objekt...",
"txt_clear_search": "Rensa sökning",
+1
View File
@@ -984,6 +984,7 @@ const zhCN: Record<string, string> = {
"txt_save_profile_failed": "保存资料失败",
"txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
"txt_session_refresh_temporarily_unavailable": "暂时无法验证会话,登录状态已保留,稍后会自动重试",
"txt_search_your_secure_vault": "搜索你的密码库...",
"txt_search_items_count": "共 {count} 项中搜索...",
"txt_clear_search": "清空搜索",
+1
View File
@@ -984,6 +984,7 @@ const zhTW: Record<string, string> = {
"txt_save_profile_failed": "保存資料失敗",
"txt_search_sends": "搜索 Send...",
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
"txt_session_refresh_temporarily_unavailable": "暫時無法驗證會話,登入狀態已保留,稍後會自動重試",
"txt_search_your_secure_vault": "搜索你的密碼庫...",
"txt_search_items_count": "在共 {count} 項中搜索...",
"txt_clear_search": "清空搜索",