From 2575e9545deeb5ef92d148391c204eafdf348918 Mon Sep 17 00:00:00 2001 From: coopops Date: Mon, 15 Jun 2026 08:43:50 +0000 Subject: [PATCH] =?UTF-8?q?[105-10][@ant]=20feat:=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2=20Coo?= =?UTF-8?q?pID=20+=20recovery=20=D0=BA=D0=B0=D0=BA=20=D0=BF=D0=BE=D1=82?= =?UTF-8?q?=D1=80=D0=B5=D0=B1=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=20(=D0=AD=D0=BF=D0=B8=D0=BA=207)=20=E2=80=94=20=D1=87=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=8B=20CoopID-=D1=81=D0=B5=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D1=83=20=D0=BD=D0=B5=20=D1=85=D1=83=D0=B6=D0=B5=20?= =?UTF-8?q?=D0=BB=D0=B5=D0=B3=D0=B0=D1=81=D0=B8=20=D0=B8=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D0=B9=D1=89=D0=B8=D0=BA=20?= =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B7=D1=83=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B8?= =?UTF-8?q?=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @coopenomics/auth: configureTokenStorage + restoreSession — пара токенов сессии персистится в StorageAdapter (frontend — IndexedDB) и восстанавливается на boot; setSession пишет копию, refresh её обновляет, clearSession стирает (logout). Без этого CoopID-сессия теряла токен на F5 и была слабее легаси. Тесты oidc-tokens 10/10 (персист/restore/refresh-update/clear), tsc 0, ESLint 0, dist пересобран. Desktop: session.init восстанавливает токены CoopID (configureTokenStorage+ restoreSession) перед establishCoopIdSession — сессия переживает reload (токены из IndexedDB + ключ из PIN-кэша). Recovery — первый потребитель моста: confirmRecovery строит CoopID-сессию поверх keystore (у восстановленного пайщика легаси-WIF нет) + PIN-кэш; RecoverConfirm ведёт по каноническому boot-пути на рабочий стол вместо тупикового signin (который требует WIF). ESLint 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/auth/src/index.ts | 2 +- components/auth/src/oidc/index.ts | 2 +- components/auth/src/oidc/tokens.ts | 62 +++++++++++++- components/auth/test/oidc-tokens.test.ts | 81 ++++++++++++++++++- .../src/entities/Session/model/store.ts | 11 ++- .../User/RecoverAccess/model/index.ts | 25 +++++- .../Registrator/Recover/ui/RecoverConfirm.vue | 10 ++- 7 files changed, 176 insertions(+), 17 deletions(-) diff --git a/components/auth/src/index.ts b/components/auth/src/index.ts index dc2441d257c..431923623f6 100644 --- a/components/auth/src/index.ts +++ b/components/auth/src/index.ts @@ -37,7 +37,7 @@ export type { MigrateParams } from './migration' export { canonicalMigrationMessage, migrate } from './migration' export type { FlowExecutorParams, HandshakeResult, LoginParams, LoginResult, LogoutParams, SessionTokens } from './oidc' -export { authenticateWithFlowExecutor, configureCoopId, configureOidc, currentTokens, DEFAULT_AUTHENTICATION_FLOW, getAccessToken, getParticipantCertificate, login, loginWithMagicLink, logout, performTimestampHandshake, recover } from './oidc' +export { authenticateWithFlowExecutor, configureCoopId, configureOidc, configureTokenStorage, currentTokens, DEFAULT_AUTHENTICATION_FLOW, getAccessToken, getParticipantCertificate, login, loginWithMagicLink, logout, performTimestampHandshake, recover, restoreSession } from './oidc' export type { SignDocumentParams, TimestampSignature } from './signing' export { canonicalTimestampMessage, signChainDigest, signDocument, signTimestamp } from './signing' diff --git a/components/auth/src/oidc/index.ts b/components/auth/src/oidc/index.ts index 4f507765c9a..93c4293853f 100644 --- a/components/auth/src/oidc/index.ts +++ b/components/auth/src/oidc/index.ts @@ -17,7 +17,7 @@ export type { FlowExecutorParams } from './flow-executor' export type { HandshakeResult } from './handshake' export { performTimestampHandshake } from './handshake' export type { SessionTokens } from './tokens' -export { currentTokens } from './tokens' +export { configureTokenStorage, currentTokens, restoreSession } from './tokens' export interface LoginParams { /** Issuer кооператива, например `https://coop.example/application/o/coopid/` */ diff --git a/components/auth/src/oidc/tokens.ts b/components/auth/src/oidc/tokens.ts index a7c07797e91..e09fe0f23dc 100644 --- a/components/auth/src/oidc/tokens.ts +++ b/components/auth/src/oidc/tokens.ts @@ -1,3 +1,4 @@ +import type { StorageAdapter } from '../wallet/storage-adapter' /** * Lifecycle платформенных токенов сессии CoopID (Эпик 7). Источник истины токена — * этот модуль `@coopenomics/auth`; `@coopenomics/sdk` копирует access в свои @@ -22,16 +23,67 @@ const REFRESH_SKEW_SEC = 30 let tokens: SessionTokens | null = null let apiBase: string | null = null -/** Кладёт токены текущей сессии (вызывается из handshake/login). `apiUrl` — база controller'а для refresh. */ +/** + * Опциональная персистентность токенов между перезагрузками (паритет с легаси, + * у которого токены лежат в IndexedDB). По умолчанию контур RAM-only; приложение + * подключает `StorageAdapter` (frontend — IndexedDB) через `configureTokenStorage`. + * Без этого CoopID-сессия не переживала бы F5 (ключ поднимается из PIN-кэша, а + * токен терялся бы), т.е. была бы СЛАБЕЕ легаси. + */ +const TOKEN_STORAGE_KEY = 'coopid.session.tokens' +let storage: StorageAdapter | null = null + +interface PersistedSession { + apiBase: string + tokens: SessionTokens +} + +/** Подключает (или снимает — `null`) персистентность токенов сессии. */ +export function configureTokenStorage(adapter: StorageAdapter | null): void { + storage = adapter +} + +/** Кладёт токены текущей сессии (вызывается из handshake/login/refresh). `apiUrl` — база controller'а для refresh. */ export function setSession(apiUrl: string, next: SessionTokens): void { apiBase = apiUrl.replace(/\/$/, '') tokens = next + // best-effort персист: сбой записи не должен ронять вход (токены уже в RAM). + if (storage) + void storage.set(TOKEN_STORAGE_KEY, JSON.stringify({ apiBase, tokens: next } satisfies PersistedSession)).catch(() => undefined) } -/** Затирает токены сессии (logout). Идемпотентно. */ +/** + * Восстанавливает токены сессии из персистентного хранилища на старте приложения + * (после reload). `true` — сессия поднята в RAM. Уже активная RAM-сессия не + * перетирается. Без подключённого storage или записи — `false`. + */ +export async function restoreSession(): Promise { + if (tokens && apiBase) + return true + if (!storage) + return false + const raw = await storage.get(TOKEN_STORAGE_KEY) + if (!raw) + return false + try { + const parsed = JSON.parse(raw) as PersistedSession + if (!parsed?.apiBase || !parsed?.tokens?.accessToken || !parsed?.tokens?.refreshToken) + return false + apiBase = parsed.apiBase + tokens = parsed.tokens + return true + } + catch { + return false + } +} + +/** Затирает токены сессии (logout). Идемпотентно. Стирает и персистентную копию. */ export function clearSession(): void { tokens = null apiBase = null + if (storage) + void storage.remove(TOKEN_STORAGE_KEY).catch(() => undefined) } /** Снимок текущих токенов (или null, если сессии нет). */ @@ -88,6 +140,8 @@ export async function getAccessToken(): Promise { return tokens.accessToken const refreshed = await refreshSession(apiBase, tokens.refreshToken) - tokens = refreshed - return tokens.accessToken + // setSession обновляет RAM И персистентную копию — иначе на диске остался бы + // устаревший refresh-токен и следующий reload поднял бы протухшую сессию. + setSession(apiBase, refreshed) + return refreshed.accessToken } diff --git a/components/auth/test/oidc-tokens.test.ts b/components/auth/test/oidc-tokens.test.ts index 5fd1f6b51e1..48bb9c773fb 100644 --- a/components/auth/test/oidc-tokens.test.ts +++ b/components/auth/test/oidc-tokens.test.ts @@ -1,7 +1,8 @@ +import type { StorageAdapter } from '../src/wallet' import { SignJWT } from 'jose' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AuthV2Error } from '../src/errors' -import { clearSession, currentTokens, getAccessToken, setSession } from '../src/oidc/tokens' +import { clearSession, configureTokenStorage, currentTokens, getAccessToken, restoreSession, setSession } from '../src/oidc/tokens' /** JWT с заданным exp (сек от now); содержимое не важно — getAccessToken читает только exp. */ async function jwtExpIn(seconds: number): Promise { @@ -16,8 +17,23 @@ function okJson(body: unknown) { return { ok: true, status: 200, json: async () => body } } +/** In-memory StorageAdapter для тестов персистентности токенов. */ +function memoryStorage(): StorageAdapter & { dump: () => Record } { + const m = new Map() + return { + get: async k => m.get(k) ?? null, + set: async (k, v) => void m.set(k, v), + remove: async k => void m.delete(k), + dump: () => Object.fromEntries(m), + } +} + beforeEach(() => clearSession()) -afterEach(() => vi.unstubAllGlobals()) +afterEach(() => { + vi.unstubAllGlobals() + configureTokenStorage(null) + clearSession() +}) describe('getAccessToken lifecycle (Story 1.7) — токен внутри SDK, авто-refresh', () => { it('нет активной сессии → бросает', async () => { @@ -55,3 +71,64 @@ describe('getAccessToken lifecycle (Story 1.7) — токен внутри SDK, await expect(getAccessToken()).rejects.toMatchObject({ code: 'session_binding_expired' }) }) }) + +describe('персистентность токенов (паритет с легаси — переживание reload)', () => { + it('setSession персистит пару токенов в storage', async () => { + const storage = memoryStorage() + const access = await jwtExpIn(3600) + configureTokenStorage(storage) + setSession('https://coop.example', { accessToken: access, refreshToken: 'r' }) + expect(JSON.stringify(storage.dump())).toContain(access) + }) + + it('restoreSession поднимает пару после «reload» (RAM пуста, storage цел) — сессия переживает F5', async () => { + const access = await jwtExpIn(3600) + // «До reload»: storage с записью, как её оставил setSession в прошлой сессии. + const storage = memoryStorage() + await storage.set('coopid.session.tokens', JSON.stringify({ apiBase: 'https://coop.example', tokens: { accessToken: access, refreshToken: 'r' } })) + // «После reload»: RAM пуста (beforeEach), подключаем storage и восстанавливаем. + configureTokenStorage(storage) + + expect(await restoreSession()).toBe(true) + expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' }) + expect(await getAccessToken()).toBe(access) // токен жив без сети + }) + + it('нет персистентной записи → restoreSession=false (нужен вход)', async () => { + configureTokenStorage(memoryStorage()) + expect(await restoreSession()).toBe(false) + await expect(getAccessToken()).rejects.toBeInstanceOf(AuthV2Error) + }) + + it('активная RAM-сессия не перетирается restoreSession', async () => { + const access = await jwtExpIn(3600) + setSession('https://coop.example', { accessToken: access, refreshToken: 'r' }) + configureTokenStorage(memoryStorage()) // пустой storage + expect(await restoreSession()).toBe(true) // уже в RAM + expect(currentTokens()).toEqual({ accessToken: access, refreshToken: 'r' }) + }) + + it('refresh обновляет персистентную копию (на диске свежий refresh-токен)', async () => { + const storage = memoryStorage() + const expired = await jwtExpIn(-10) + const fresh = await jwtExpIn(3600) + configureTokenStorage(storage) + setSession('https://coop.example', { accessToken: expired, refreshToken: 'r-old' }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okJson({ access_token: fresh, refresh_token: 'r-new' }))) + + expect(await getAccessToken()).toBe(fresh) + const dump = JSON.stringify(storage.dump()) + expect(dump).toContain('r-new') + expect(dump).not.toContain('r-old') + }) + + it('clearSession стирает персистентную копию (logout)', async () => { + const storage = memoryStorage() + const access = await jwtExpIn(3600) + configureTokenStorage(storage) + setSession('https://coop.example', { accessToken: access, refreshToken: 'r' }) + expect(Object.keys(storage.dump())).toHaveLength(1) + clearSession() + expect(Object.keys(storage.dump())).toHaveLength(0) + }) +}) diff --git a/components/desktop/src/entities/Session/model/store.ts b/components/desktop/src/entities/Session/model/store.ts index 7b04622e33d..3cbcef28200 100644 --- a/components/desktop/src/entities/Session/model/store.ts +++ b/components/desktop/src/entities/Session/model/store.ts @@ -5,10 +5,12 @@ import { Session } from '@wharfkit/session'; import { WalletPluginPrivateKey } from '@wharfkit/wallet-plugin-privatekey'; import { clearPinCache, + configureTokenStorage, getWallet, isWalletUnlocked, lockWallet, persistPinCache, + restoreSession, unlockWithPin, } from '@coopenomics/auth'; import { FailAlert } from 'src/shared/api'; @@ -186,10 +188,13 @@ export const useSessionStore = defineStore('session', (): ISessionStore => { return; } - // Легаси-ключа нет — пробуем контур CoopID: keystore уже разблокирован после - // входа, либо поднимаем его из локального PIN-кэша (мост подписи, Эпик 7). - // Полностью аддитивно: если CoopID-кэша нет, ветка — no-op, легаси не задет. + // Легаси-ключа нет — пробуем контур CoopID: токены восстанавливаем из + // персистентного хранилища (паритет с легаси — переживание F5), ключ + // поднимаем из локального PIN-кэша (мост подписи, Эпик 7). Полностью + // аддитивно: если CoopID-артефактов нет, ветка — no-op, легаси не задет. try { + configureTokenStorage(coopStorage()); + await restoreSession(); await establishCoopIdSession(); } catch (e: any) { console.error(e); diff --git a/components/desktop/src/features/User/RecoverAccess/model/index.ts b/components/desktop/src/features/User/RecoverAccess/model/index.ts index 2feeac676cf..2a177886e87 100644 --- a/components/desktop/src/features/User/RecoverAccess/model/index.ts +++ b/components/desktop/src/features/User/RecoverAccess/model/index.ts @@ -1,5 +1,8 @@ -import { loginWithMagicLink, recover } from '@coopenomics/auth'; +import { configureTokenStorage, loginWithMagicLink, recover } from '@coopenomics/auth'; import { env } from 'src/shared/config'; +import { useSessionStore } from 'src/entities/Session'; +import { useSystemStore } from 'src/entities/System/model'; +import { createCoopIdStorage } from 'src/entities/Session/lib/coopidStorage'; export interface IConfirmRecoveryInput { /** Email пайщика — фактор-1 повторного входа через authentik после смены ключа. */ @@ -23,20 +26,38 @@ export interface IConfirmRecoveryInput { * Account заранее не нужен — он приходит в ответе confirm (whoami-by-token не нужен). */ export function useRecoverAccess() { + const session = useSessionStore(); + const systemStore = useSystemStore(); + /** Шаг 1: запросить magic-link на email. Бэкенд всегда отвечает 202 (анти-enumeration). */ async function requestRecovery(email: string): Promise { await recover(email); } - /** Шаг 2: подтвердить восстановление (TOTP + новый пароль) и войти новым контуром. */ + /** + * Шаг 2: подтвердить восстановление (TOTP + новый пароль) и войти новым контуром + * через МОСТ ПОДПИСИ CoopID (Эпик 7). У восстановленного пайщика легаси-WIF нет — + * ключ живёт в keystore @coopenomics/auth, поэтому сессия строится поверх него + * (`establishCoopIdSession`), а не из `globalStore.wif`. + */ async function confirmRecovery(input: IConfirmRecoveryInput): Promise { + const storage = createCoopIdStorage(systemStore.info.coopname); + // Подключаем персистентность токенов ДО входа — чтобы выданная пара легла в + // IndexedDB и сессия пережила перезагрузку (паритет с легаси). + configureTokenStorage(storage); + await loginWithMagicLink({ issuer: env.COOPID_ISSUER as string, email: input.email, token: input.token, totp: input.totp, newPassword: input.newPassword, + storage, }); + + // Строим wharfkit-сессию поверх keystore + пишем локальный PIN-кэш (дефолт + // прозрачный) — дальше подпись он-чейн идёт через WalletPluginCoopId. + await session.establishCoopIdSession({ persistPin: true }); } return { requestRecovery, confirmRecovery }; diff --git a/components/desktop/src/widgets/Registrator/Recover/ui/RecoverConfirm.vue b/components/desktop/src/widgets/Registrator/Recover/ui/RecoverConfirm.vue index acfb4cf45d8..da032b89517 100644 --- a/components/desktop/src/widgets/Registrator/Recover/ui/RecoverConfirm.vue +++ b/components/desktop/src/widgets/Registrator/Recover/ui/RecoverConfirm.vue @@ -54,7 +54,6 @@