From 680a3b342b92eb5fb37fd20eb792db883c8ca685 Mon Sep 17 00:00:00 2001 From: Jared Lee Date: Sat, 3 Oct 2026 15:28:22 +0000 Subject: [PATCH] feat(afos): show live native credential storage state --- .../ENTERPRISE_CREDENTIAL_STATE.i18n.yaml | 2 + .../ENTERPRISE_CREDENTIAL_STATE.md | 11 +++ .../ENTERPRISE_CREDENTIAL_STATE.zh.md | 11 +++ .../enterprise-credential-state.test.mjs | 71 +++++++++++++++++++ .../scripts/enterprise-local-main.test.mjs | 1 + dsh-plugin-desktop/src/desktop-auth.ts | 7 ++ .../src/enterprise-credential-state.ts | 14 ++++ dsh-plugin-desktop/src/enterprise-main.ts | 46 ++++++++++-- dsh-plugin-desktop/src/workdsh-main.ts | 14 +++- 9 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.i18n.yaml create mode 100644 dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.md create mode 100644 dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.zh.md create mode 100644 dsh-plugin-desktop/scripts/enterprise-credential-state.test.mjs create mode 100644 dsh-plugin-desktop/src/enterprise-credential-state.ts diff --git a/dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.i18n.yaml b/dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.i18n.yaml new file mode 100644 index 0000000000..b788d6a2db --- /dev/null +++ b/dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.i18n.yaml @@ -0,0 +1,2 @@ +ENTERPRISE_CREDENTIAL_STATE.md: dcad32e87a40e3f4a38188458d6b7daee558fa26 +ENTERPRISE_CREDENTIAL_STATE.zh.md: 869e5ad5cfacb2bbc87394e33b8ad0df583d959a diff --git a/dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.md b/dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.md new file mode 100644 index 0000000000..dcad32e87a --- /dev/null +++ b/dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.md @@ -0,0 +1,11 @@ +# Enterprise credential storage state + +D04 and AT-11 require an explicit memory-only sign-in state when operating-system encryption is unavailable, including Linux `basic_text`. Main keeps credentials private and records the current refresh-token persistence result only after its account generation is checked. Sign-in and project changes clear the old persistence state; stale writes cannot update a newer account. + +The native account menu displays one finite state: signed out, credentials in memory for this session, or an encrypted refresh credential saved. Successful account actions also explain that memory-only sign-in requires signing in again after restarting WorkDSH. The presentation contains no token, account identifier, namespace, filesystem path or backend details, and adds no Renderer or Agent credential IPC. + +Main privately observes authentication changes and persistence results so that background refresh or revocation also updates the native menu. A rotated token clears the old saved state before publication. Native observer exceptions cannot alter authentication or business authority; the menu subscription ends at application quit. + +Controlled Main tests cover refusal without a vault file, refresh rotation into memory-only state, and a late old-account write. They do not establish real IdP or Windows/macOS acceptance. The final carrier AT-11 must also consume the real Electron unavailable/basic_text evidence and inspect its owned disk. P1 G3 remains unpassed until the original acceptance requirements are met. + +Rollback: revert this slice's Main state and native menu presentation together; preserve the existing encrypted vault and local drafts. No data migration, dependency change, permission expansion or Host activation is part of this change. diff --git a/dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.zh.md b/dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.zh.md new file mode 100644 index 0000000000..869e5ad5cf --- /dev/null +++ b/dsh-plugin-desktop/ENTERPRISE_CREDENTIAL_STATE.zh.md @@ -0,0 +1,11 @@ +# 企业登录凭据存储状态 + +D04 与 AT-11 要求在操作系统加密不可用(含 Linux `basic_text`)时明确显示仅内存登录状态。Main 独占凭据,在核对账号 generation 后才记录当前 refresh token 的持久化结果。登录与项目切换会清除旧持久化状态;迟到的旧写入不能更新新账号。 + +原生账号菜单显示三个有限状态之一:未登录、登录凭据仅保留在本次会话内存中、refresh 凭据已加密保存。账号操作成功时,内存模式还明确提示重启 WorkDSH 后需重新登录。展示信息不包含 token、账号标识、namespace、文件路径或加密后端细节;不增加 Renderer 或 Agent 的凭据 IPC。 + +Main 在私有边界观察认证变化与持久化结果,因此后台刷新或撤权也会更新原生菜单。token 轮换时先清除旧的已保存状态,再公布新的持久化结果。原生观察回调抛错不会改变认证或业务权限;应用退出时解除菜单订阅。 + +受控 Main 测试覆盖拒绝持久化且不产生 Vault 文件、refresh 轮换后转为内存模式、迟到的旧账号写入。它们不代表真实 IdP 或 Windows/macOS 验收。最终载体 AT-11 还需消费真实 Electron unavailable/basic_text 证据并检查测试自有磁盘。原始验收要求满足前,P1 G3 仍未通过。 + +回滚时一并还原本切片的 Main 状态与原生菜单展示,保留已有加密 Vault 与本地草稿。本变更不含数据迁移、依赖更新、权限扩展或 Host 激活。 diff --git a/dsh-plugin-desktop/scripts/enterprise-credential-state.test.mjs b/dsh-plugin-desktop/scripts/enterprise-credential-state.test.mjs new file mode 100644 index 0000000000..68d6a6c717 --- /dev/null +++ b/dsh-plugin-desktop/scripts/enterprise-credential-state.test.mjs @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fixture } from '../../enterprise/plugin-agent-bridge/src/server/__tests__/desktop-fixture.mjs'; +import { credentialStorageUi } from '../src/enterprise-credential-state.ts'; + +// Real Main/Auth/Broker with the existing controlled HTTPS/OS fixture; not IdP or target-OS acceptance. +test('refused credential persistence leaves a clear native memory-only state and no vault file', async t => { + const f = await fixture(); t.after(() => f.cleanup()); + f.vault.save = async () => false; + assert.equal(f.main.credentialStorageState(), 'signed-out'); + await f.main.signIn(); + assert.equal(f.main.credentialStorageState(), 'memory-only'); + await f.main.selectProject('p1'); + const ui = credentialStorageUi(f.main.credentialStorageState()); + assert.match(ui.label, /仅本次内存/u); assert.match(ui.detail, /重启 WorkDSH 后需重新登录/u); + for (const secret of ['fixture-access', 'fixture-refresh', f.base, 'device1', 'alice']) assert.ok(!JSON.stringify(ui).includes(secret)); + await assert.rejects(readdir(join(f.base, 'vault')), { code: 'ENOENT' }); + await f.main.logout(); assert.equal(f.main.credentialStorageState(), 'signed-out'); +}); + +test('a rotated refresh token that cannot be persisted updates the current account state', async t => { + const f = await fixture({ shortTokens: true }); t.after(() => f.cleanup()); + const observed = [], unobserve = f.main.observeCredentialStorage(() => observed.push(f.main.credentialStorageState())); + t.after(unobserve); + const failedObserver = f.main.observeCredentialStorage(() => { throw new Error('fixture/native-presentation-failed'); }); + t.after(failedObserver); + await f.ready(); assert.equal(f.main.credentialStorageState(), 'encrypted'); + assert.equal(observed.at(-1), 'encrypted'); + assert.equal(credentialStorageUi(f.main.credentialStorageState()).detail, undefined); + f.vault.save = async () => false; + await f.main.currentAuthority(); assert.equal(f.main.credentialStorageState(), 'memory-only'); + assert.equal(observed.at(-1), 'memory-only'); + unobserve(); const before = observed.length; + await f.main.logout(); assert.equal(observed.length, before); +}); + +test('late persistence from an old account operation cannot publish its state into a new sign-in', async t => { + const f = await fixture(); t.after(() => f.cleanup()); await f.main.signIn(); + const gate = Promise.withResolvers(), entered = Promise.withResolvers(); + const save = f.vault.save.bind(f.vault); + f.vault.save = async () => { entered.resolve(); await gate.promise; return true; }; + const old = f.main.selectProject('p1'); old.catch(() => {}); await entered.promise; + assert.equal(f.main.credentialStorageState(), 'memory-only'); + const fresh = f.main.signIn(); + assert.equal(f.main.credentialStorageState(), 'signed-out'); + gate.resolve(); await assert.rejects(old, /account-changed/u); await fresh; + assert.equal(f.main.credentialStorageState(), 'memory-only'); + f.vault.save = save; +}); + +test('logout during a credential-state notification cannot publish old account readiness', async t => { + for (const operation of ['select', 'restore']) await t.test(operation, async child => { + const f = await fixture(); child.after(() => f.cleanup()); + let binding; + if (operation === 'restore') { binding = (await f.ready()).binding; await f.main.suspend(); } + else await f.main.signIn(); + let logout, started = false; + const unobserve = f.main.observeCredentialStorage(() => { + if (f.main.credentialStorageState() === 'encrypted' && !started) { + started = true; logout = f.main.logout(); + } + }); + child.after(unobserve); + const pending = operation === 'restore' ? f.main.restoreAccount(binding, 'p1') : f.main.selectProject('p1'); + await assert.rejects(pending, /account-changed/u); await logout; + assert.equal(f.main.credentialStorageState(), 'signed-out'); + await assert.rejects(f.main.currentAuthority(), /select-project-required/u); + }); +}); diff --git a/dsh-plugin-desktop/scripts/enterprise-local-main.test.mjs b/dsh-plugin-desktop/scripts/enterprise-local-main.test.mjs index 57c1cba7ee..eb21a2573a 100644 --- a/dsh-plugin-desktop/scripts/enterprise-local-main.test.mjs +++ b/dsh-plugin-desktop/scripts/enterprise-local-main.test.mjs @@ -6,6 +6,7 @@ import { fixture } from '../../enterprise/plugin-agent-bridge/src/server/__tests import { installLocalDraftIpc, LOCAL_DRAFT_CHANNELS, LOCAL_DRAFT_DOCUMENT } from '../src/enterprise-local-ipc.ts'; import { runtimeDraftProjection } from '../src/enterprise-electron.ts'; import './enterprise-cold-history.test.mjs'; +import './enterprise-credential-state.test.mjs'; const runtimeProfile = fileURLToPath(new URL('../../workdsh-web/packages/providers/enterprise-profile/', import.meta.url)); async function seeded(t, { stop = true, projection } = {}) { diff --git a/dsh-plugin-desktop/src/desktop-auth.ts b/dsh-plugin-desktop/src/desktop-auth.ts index a7237f3a77..b3a898ae9f 100644 --- a/dsh-plugin-desktop/src/desktop-auth.ts +++ b/dsh-plugin-desktop/src/desktop-auth.ts @@ -124,6 +124,7 @@ export class DesktopAuth { private readonly config: EnterpriseAuthConfig, private readonly openBrowser: (url: string) => Promise, private readonly fetcher: typeof fetch = fetch, + private readonly onStateChanged: () => void = () => {}, ) { endpoint(config.issuer) endpoint(config.resource) @@ -217,6 +218,7 @@ export class DesktopAuth { || body.refresh_token !== undefined && !secret(body.refresh_token)) throw new DesktopAuthError('auth/invalid-token-response') this.tokens = body as unknown as Tokens this.expiresAt = Date.now() + body.expires_in * 1_000 + this.notifyStateChanged() return body.access_token } /** Trusted Broker caller only; must never be registered as renderer/Agent API. */ @@ -292,6 +294,11 @@ export class DesktopAuth { this.expiresAt = 0 this.refreshFlight = undefined this.persistedRefresh = undefined + this.notifyStateChanged() + } + private notifyStateChanged(): void { + // Native presentation cannot change the authentication outcome. + try { this.onStateChanged() } catch {} } } diff --git a/dsh-plugin-desktop/src/enterprise-credential-state.ts b/dsh-plugin-desktop/src/enterprise-credential-state.ts new file mode 100644 index 0000000000..9a31a13b6f --- /dev/null +++ b/dsh-plugin-desktop/src/enterprise-credential-state.ts @@ -0,0 +1,14 @@ +/** Main-native presentation only; contains no account, namespace or credential. */ +export type EnterpriseCredentialStorageState = 'signed-out' | 'memory-only' | 'encrypted' + +export function credentialStorageUi(state: EnterpriseCredentialStorageState): { readonly label: string; readonly detail?: string } { + switch (state) { + case 'signed-out': return Object.freeze({ label: 'Signed out / 未登录' }) + case 'encrypted': return Object.freeze({ label: 'Credentials: encrypted / 登录凭据:已加密保存' }) + case 'memory-only': return Object.freeze({ + label: 'Credentials: this session only / 登录凭据:仅本次内存', + detail: 'Sign-in credentials remain in memory for this session. Sign in again after restarting WorkDSH. / 登录凭据仅在本次会话内存中保留,重启 WorkDSH 后需重新登录。', + }) + default: throw new Error('enterprise/credential-state-refused') + } +} diff --git a/dsh-plugin-desktop/src/enterprise-main.ts b/dsh-plugin-desktop/src/enterprise-main.ts index c2da1a11fb..3f0101df90 100644 --- a/dsh-plugin-desktop/src/enterprise-main.ts +++ b/dsh-plugin-desktop/src/enterprise-main.ts @@ -1,6 +1,7 @@ import { accountNamespace, DesktopAuth, RefreshTokenVault, type EnterpriseAuthConfig } from './desktop-auth.ts' import { EnterpriseBroker, EnterpriseBrokerError, type CurrentAuthoritySnapshot, type EnterpriseProject, type RunAuthorization, type SourceRef } from './enterprise-broker.ts' import { EnterpriseRecoveryStore, isOwnedSessionId, type DraftRecord, type OfficialSessionSummaryEvent, type RecoveryLayout, type RecoveryStoreOptions, type RecoveryTransport } from './enterprise-recovery.ts' +import type { EnterpriseCredentialStorageState } from './enterprise-credential-state.ts' /** Private trusted Host callback. Only official Agent setup receives a publication lease. */ export interface EnterpriseSessionBindingLease { commit(): void; dispose(): void } @@ -81,6 +82,9 @@ export class EnterpriseMainSession { private namespace: string | undefined private readonly currentLoginVaults = new Set() private readonly pendingVaultDeletions = new Set() + private refreshPersisted = false + private readonly credentialStorageObservers = new Set<() => void>() + private notifiedCredentialStorage: EnterpriseCredentialStorageState = 'signed-out' private authorizing = false private ready = false private readonly runs = new Map() @@ -108,7 +112,10 @@ export class EnterpriseMainSession { const issuer = new URL(config.auth.issuer) issuer.pathname = `${issuer.pathname.replace(/\/$/u, '')}/afos` if (config.auth.resource !== issuer.href || !/^afos(?: offline_access)?$/u.test(config.auth.scope)) fail('restricted-resource-required') - this.auth = new DesktopAuth(Object.freeze({ ...config.auth }), openBrowser, fetcher) + this.auth = new DesktopAuth(Object.freeze({ ...config.auth }), openBrowser, fetcher, () => { + this.refreshPersisted = false + this.notifyCredentialStorage() + }) this.broker = new EnterpriseBroker(config.auth.issuer, this.auth, fetcher) } private cutoff(): void { @@ -128,6 +135,7 @@ export class EnterpriseMainSession { this.offline = false this.selectedProject = undefined this.namespace = undefined + this.refreshPersisted = false this.runs.clear() this.activeRunId = undefined this.authorizing = false @@ -137,6 +145,25 @@ export class EnterpriseMainSession { this.sessionDrafts.clear() this.historicalSessionDrafts.clear() this.sessionLeases.clear() + this.notifyCredentialStorage() + } + /** Finite native account-menu state; never export it as credential or Agent IPC. */ + credentialStorageState(): EnterpriseCredentialStorageState { + return !this.auth.signedIn ? 'signed-out' : this.refreshPersisted ? 'encrypted' : 'memory-only' + } + /** Main-native observation only; subscriber failures cannot change authority or credential writes. */ + observeCredentialStorage(callback: () => void): () => void { + this.credentialStorageObservers.add(callback) + try { callback() } catch {} + return () => { this.credentialStorageObservers.delete(callback) } + } + private notifyCredentialStorage(): void { + const state = this.credentialStorageState() + if (state === this.notifiedCredentialStorage) return + this.notifiedCredentialStorage = state + for (const callback of [...this.credentialStorageObservers]) { + try { callback() } catch {} + } } private current(generation: number): void { if (generation !== this.generation) fail('account-changed') } private mutate(generation: number, work: () => Promise): Promise { @@ -212,7 +239,10 @@ export class EnterpriseMainSession { this.currentLoginVaults.add(this.namespace) this.recovery = EnterpriseRecoveryStore.open(this.base, authority.binding, this.recoveryTransport(), this.recoveryOptions) this.restoreHistoricalSessionDrafts(project.id) - await this.auth.persistRefresh(this.vault, this.namespace) + const persisted = await this.auth.persistRefresh(this.vault, this.namespace) + this.current(generation) + this.refreshPersisted = persisted + this.notifyCredentialStorage() this.current(generation) this.authoritySyncedAt = new Date().toISOString() this.ready = true @@ -245,7 +275,10 @@ export class EnterpriseMainSession { this.currentLoginVaults.add(namespace) this.recovery = EnterpriseRecoveryStore.open(this.base, binding, this.recoveryTransport(), this.recoveryOptions) this.restoreHistoricalSessionDrafts(project.id) - await this.auth.persistRefresh(this.vault, namespace) + const persisted = await this.auth.persistRefresh(this.vault, namespace) + this.current(generation) + this.refreshPersisted = persisted + this.notifyCredentialStorage() this.current(generation) this.authoritySyncedAt = new Date().toISOString() this.ready = true @@ -268,7 +301,12 @@ export class EnterpriseMainSession { const authority = await this.broker.currentAuthority(this.authority.binding, combined) this.current(generation); combined.throwIfAborted() if (!restart) this.enterprise() - if (this.namespace) await this.auth.persistRefresh(this.vault, this.namespace) + if (this.namespace) { + const persisted = await this.auth.persistRefresh(this.vault, this.namespace) + this.current(generation) + this.refreshPersisted = persisted + this.notifyCredentialStorage() + } this.current(generation); combined.throwIfAborted() if (!restart) this.enterprise() if (Date.parse(authority.expiresAt) <= Date.now()) fail('authority-expired') diff --git a/dsh-plugin-desktop/src/workdsh-main.ts b/dsh-plugin-desktop/src/workdsh-main.ts index 66d7e7ce2a..67defc211c 100644 --- a/dsh-plugin-desktop/src/workdsh-main.ts +++ b/dsh-plugin-desktop/src/workdsh-main.ts @@ -24,6 +24,7 @@ import type { EnterpriseProject } from './enterprise-broker.ts' import { createEnterpriseElectronHost, runtimeDraftProjection, enterpriseRuntimePaths } from './enterprise-electron.ts' import type { installLocalDraftIpc } from './enterprise-local-ipc.ts' import { createEnterpriseWindowOwner } from './enterprise-window.ts' +import { credentialStorageUi } from './enterprise-credential-state.ts' const PROFILE_NAME = 'workdsh' const READY_PATTERN = /dsh web:\s+(http:\/\/127\.0\.0\.1:\d+\/?\?token=[^\s]+)/u @@ -270,14 +271,20 @@ function installEnterpriseEntry(): boolean { const localSession = session let projects: readonly EnterpriseProject[] = [] const report = async (work: () => Promise, success: string): Promise => { - try { const operation = work(); localDraftOwner?.changed(); await operation; await dialog.showMessageBox({ type: 'info', title: 'AFOS', message: success }) } + try { + const operation = work(); localDraftOwner?.changed(); await operation + refreshMenu() + const storage = credentialStorageUi(session.credentialStorageState()) + await dialog.showMessageBox({ type: 'info', title: 'AFOS', message: success, ...(storage.detail ? { detail: storage.detail } : {}) }) + } catch (error) { const code = error instanceof Error && /^(?:enterprise|auth|broker|afos)\/[a-z-]{1,80}$/u.test(error.message) ? error.message : 'enterprise/action-failed' await dialog.showMessageBox({ type: 'error', title: 'AFOS', message: code }) - } finally { localDraftOwner?.changed() } + } finally { refreshMenu(); localDraftOwner?.changed() } } const refreshMenu = (): void => { const account: MenuItemConstructorOptions[] = [ + { label: credentialStorageUi(session.credentialStorageState()).label, enabled: false }, { label: 'Sign in / 登录', click: () => { enterpriseWindows?.cancelStart(); void report(async () => { projects = []; refreshMenu(); projects = await session.signIn(); refreshMenu() }, 'Select an allowed project from the account menu. / 请在账号菜单选择已授权项目。') } }, { label: 'Projects / 项目', submenu: projects.map(project => ({ label: project.name.replaceAll('&', '').slice(0, 80), click: () => { enterpriseWindows?.cancelStart(); void report(() => session.selectProject(project.id), 'Account and project verified. / 账号与项目已核实。') } })) }, { label: 'Start enterprise Host / 启动企业 Host', click: () => { void report(async () => { if (!enterpriseWindows) throw new Error('enterprise/window-owner-refused'); await enterpriseWindows.startHost() }, 'Enterprise Host started. / 企业 Host 已启动。') } }, @@ -287,7 +294,8 @@ function installEnterpriseEntry(): boolean { ] Menu.setApplicationMenu(Menu.buildFromTemplate([{ label: 'AFOS', submenu: account }])) } - refreshMenu() + const unobserveCredentials = session.observeCredentialStorage(refreshMenu) + app.once('will-quit', unobserveCredentials) enterpriseWindows = createEnterpriseWindowOwner({ createWindow: options => new BrowserWindow(options), ipc: ipcMain, appPath: app.getAppPath(), capabilities: () => localSession.localDraftCapabilities(), projectDraft: runtimeDraftProjection(enterprisePaths.runtimeProfile), activated: face => { window = face.window; localDraftOwner = face.owner },