mirror of
https://gh-proxy.org/https://github.com/klinsmaya/workdsh.git
synced 2026-11-10 02:17:22 +08:00
feat(afos): show live native credential storage state
This commit is contained in:
@@ -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 } = {}) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user