[105-10][@ant] feat: персистентность токенов CoopID + recovery как потребитель моста подписи (Эпик 7) — чтобы CoopID-сессия переживала перезагрузку не хуже легаси и восстановленный пайщик сразу входил

@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) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-06-15 08:43:50 +00:00
parent d1d7410731
commit 2575e9545d
7 changed files with 176 additions and 17 deletions
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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/` */
+58 -4
View File
@@ -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<boolean> {
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<string> {
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
}
+79 -2
View File
@@ -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<string> {
@@ -16,8 +17,23 @@ function okJson(body: unknown) {
return { ok: true, status: 200, json: async () => body }
}
/** In-memory StorageAdapter для тестов персистентности токенов. */
function memoryStorage(): StorageAdapter & { dump: () => Record<string, string> } {
const m = new Map<string, string>()
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)
})
})
@@ -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);
@@ -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<void> {
await recover(email);
}
/** Шаг 2: подтвердить восстановление (TOTP + новый пароль) и войти новым контуром. */
/**
* Шаг 2: подтвердить восстановление (TOTP + новый пароль) и войти новым контуром
* через МОСТ ПОДПИСИ CoopID (Эпик 7). У восстановленного пайщика легаси-WIF нет —
* ключ живёт в keystore @coopenomics/auth, поэтому сессия строится поверх него
* (`establishCoopIdSession`), а не из `globalStore.wif`.
*/
async function confirmRecovery(input: IConfirmRecoveryInput): Promise<void> {
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 };
@@ -54,7 +54,6 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useCreateUser } from 'src/features/User/CreateUser';
import { useRecoverAccess } from 'src/features/User/RecoverAccess';
import { FailAlert, SuccessAlert } from 'src/shared/api';
@@ -70,7 +69,6 @@ const props = defineProps<{
const MIN_PASSWORD_LENGTH = 8;
const router = useRouter();
const { confirmRecovery } = useRecoverAccess();
const { emailIsValid } = useCreateUser();
@@ -124,8 +122,12 @@ const submit = async (): Promise<void> => {
totp: totp.value,
newPassword: newPassword.value,
});
SuccessAlert('Доступ восстановлен. Войдите с новым паролём.');
void router.push({ name: 'signin', params: { coopname: props.coopname } });
SuccessAlert('Доступ восстановлен. Входим…');
// Сессия CoopID построена, токены и PIN-кэш ключа персистнуты — перезагрузка
// по каноническому boot-пути сама поднимет сессию (токены из IndexedDB + ключ
// из PIN-кэша) и доведёт до рабочего стола; переживает F5 (паритет с легаси).
window.location.hash = `#/${props.coopname}`;
window.location.reload();
} catch (e: any) {
errorMessage.value =
e?.message || 'Не удалось восстановить доступ. Проверьте код и попробуйте снова.';