feat(afos): show live native credential storage state

This commit is contained in:
Jared Lee
2026-10-03 15:28:22 +00:00
parent 1c5c6080a2
commit 680a3b342b
9 changed files with 170 additions and 7 deletions
@@ -0,0 +1,2 @@
ENTERPRISE_CREDENTIAL_STATE.md: dcad32e87a40e3f4a38188458d6b7daee558fa26
ENTERPRISE_CREDENTIAL_STATE.zh.md: 869e5ad5cfacb2bbc87394e33b8ad0df583d959a
@@ -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.
@@ -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 激活。
@@ -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);
});
});
@@ -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 } = {}) {
+7
View File
@@ -124,6 +124,7 @@ export class DesktopAuth {
private readonly config: EnterpriseAuthConfig,
private readonly openBrowser: (url: string) => Promise<void>,
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 {}
}
}
@@ -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')
}
}
+42 -4
View File
@@ -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<string>()
private readonly pendingVaultDeletions = new Set<string>()
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<string, RunAuthorization>()
@@ -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<T>(generation: number, work: () => Promise<T>): Promise<T> {
@@ -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')
+11 -3
View File
@@ -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<unknown>, success: string): Promise<void> => {
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 },