dsh-email 0.16.1 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/CHANGELOG.en.md +6 -0
- package/docs/USAGE.en.md +1 -1
- package/docs/USAGE.md +1 -1
- package/docs/VALIDATION.md +2 -0
- package/docs/validation/0.16.2-accounts-en.png +0 -0
- package/docs/validation/0.16.2-recovery-en.png +0 -0
- package/docs/validation/0.16.2.md +28 -0
- package/lib/client.js +4 -0
- package/lib/oauth2-storage.d.ts +6 -0
- package/lib/oauth2-storage.js +121 -0
- package/lib/oauth2.d.ts +2 -2
- package/lib/oauth2.js +23 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
[历史英文记录](docs/CHANGELOG.en.md)
|
|
6
6
|
|
|
7
|
+
## 0.16.2 (2026-10-08)
|
|
8
|
+
|
|
9
|
+
- Windows 的 OAuth2 access / refresh token 改用当前用户 DPAPI 加密,旧的有效令牌自动原位迁移;不使用明文备份、命令参数或临时明文文件传输令牌。
|
|
10
|
+
- 加密不可用、文件损坏、未知格式或解锁失败时保留原文件;通过私有临时文件与原子替换保存,POSIX 更新时也修正文件权限为 0600。
|
|
11
|
+
- 复查 [PR #22](https://github.com/STARDUSTLC666/dsh-email/pull/22):原建议仅导入加密函数,未实现存储变更。本次在 TypeScript 源码中完成实际修复。恢复方法及保护范围见 [使用说明](docs/USAGE.md) 和 [验收记录](docs/validation/0.16.2.md)。
|
|
12
|
+
|
|
7
13
|
## 0.16.1 (2026-10-08)
|
|
8
14
|
|
|
9
15
|
- 修复切换会话工作区后相对附件可能读取启动目录同名文件的问题:发信与草稿工具都按当前会话解析路径,绝对路径和无会话调用保持兼容。
|
package/README.en.md
CHANGED
|
@@ -14,7 +14,7 @@ Feedback and contributions are welcome: report [issues](https://github.com/STARD
|
|
|
14
14
|
|
|
15
15
|
- Read, send, reply and forward using IMAP and SMTP.
|
|
16
16
|
- Manage attachments, search results and mailbox organization.
|
|
17
|
-
- Configure multiple accounts, Outlook OAuth2 and send approval.
|
|
17
|
+
- Configure multiple accounts, Outlook OAuth2, encrypted Windows login tokens and send approval.
|
|
18
18
|
- Turn new-mail popups off in settings; the preference survives restarts.
|
|
19
19
|
- Prepare and edit local drafts, review To/Cc, content and attachment bytes, then confirm sending.
|
|
20
20
|
- Manage trusted and blocked recipients per account and preview address, group and Cc matches.
|
package/README.md
CHANGED
package/docs/CHANGELOG.en.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
These English notes preserve the earlier translations. The main changelog contains the consolidated version history.
|
|
6
6
|
|
|
7
|
+
## 0.16.2 (2026-10-08)
|
|
8
|
+
|
|
9
|
+
- Encrypt Windows access / refresh tokens with current-user DPAPI and migrate valid legacy tokens in place, without plaintext backups or secrets in process arguments.
|
|
10
|
+
- Preserve corrupt, unsupported or locked stores and fail closed when encryption is unavailable. Save through private atomic replacement; POSIX writes use 0600 files.
|
|
11
|
+
- Replace the import-only suggestion in [PR #22](https://github.com/STARDUSTLC666/dsh-email/pull/22) with an implementation in the TypeScript source. See the usage guide for recovery and protection boundaries.
|
|
12
|
+
|
|
7
13
|
## 0.16.1 (2026-10-08)
|
|
8
14
|
|
|
9
15
|
- Resolve relative attachments against the current session workspace in both send and draft tools, preserving absolute paths and calls without session context.
|
package/docs/USAGE.en.md
CHANGED
|
@@ -207,7 +207,7 @@ Microsoft has disabled username+password basic auth for Exchange Online: persona
|
|
|
207
207
|
- **Body search**: the server side only searches subject / from / to / cc. Most servers (e.g. QQ) have unreliable IMAP `TEXT` / `HEADER` search, so with no results it falls back to a body scan of the most recent `bodySearchLimit` messages (slower; disable with `bodySearchFallback`).
|
|
208
208
|
- **Attachments**: inline images aren't downloadable separately yet; a failed attachment match errors instead of downloading the wrong file (safe default).
|
|
209
209
|
- **Password storage**: the authorization code saved in the settings page is written in plaintext to the local `settings.yaml` (the secret mark only keeps it out of logs / exports / diagnostics; no disk encryption). Don't hand `settings.yaml` to untrusted people.
|
|
210
|
-
- **OAuth2 token storage**:
|
|
210
|
+
- **OAuth2 token storage**: `$DSH_HOME/data/dsh-email/oauth2-tokens.json` stays outside settings exports. Starting in 0.16.2, **Windows encrypts it with current-user DPAPI**, without an extra password. The first read of valid legacy credentials migrates them in place without a plaintext backup. Unavailable system PowerShell / DPAPI never falls back to plaintext; damaged, unsupported or locked stores are preserved with recovery guidance. macOS / Linux still use plaintext JSON, atomically replaced through a private `0600` file; this is not disk encryption. DPAPI does not isolate other programs running as the same Windows user. A different device / OS account requires login again, and older plugin versions cannot read the encrypted format. For recovery, safely retain and move aside the old file before logging in again. Do not share token files. Tokens remain bound to their application ID; changing `clientId` requires login again, and account deletion removes only that account's tokens.
|
|
211
211
|
- **Local edits are undone by `pnpm install`**: if you deploy by editing files inside `node_modules/dsh-email/`, any `pnpm install` restores the registry version (0.10.7, for example). To keep changes long-term, install from a local path or a Git commit instead.
|
|
212
212
|
|
|
213
213
|
## Development
|
package/docs/USAGE.md
CHANGED
|
@@ -207,7 +207,7 @@ dsh plugin --profile web remove dsh-email
|
|
|
207
207
|
- **正文搜索**:服务器端只搜 subject / from / to / cc;多数服务器(如 QQ)的 IMAP `TEXT` / `HEADER` 搜索不可靠,无结果时回退到最近 `bodySearchLimit` 封的正文扫描(较慢,可用 `bodySearchFallback` 关闭)。
|
|
208
208
|
- **附件**:内嵌图片暂不支持单独下载;附件定位失败会直接报错而不是下载错误文件(安全默认)。
|
|
209
209
|
- **密码落盘**:设置页保存的授权码以明文写在本机 `settings.yaml`(secret 标记只保证它不进日志 / 导出 / 诊断,不做磁盘加密)。请勿把 `settings.yaml` 交给不信任的人。
|
|
210
|
-
- **OAuth2 token
|
|
210
|
+
- **OAuth2 token 落盘**:文件为 `$DSH_HOME/data/dsh-email/oauth2-tokens.json`,不随设置导出。0.16.2 起,**Windows 使用当前用户的 DPAPI 加密**,无需设置额外密码;首次读取旧的有效登录数据时自动原位迁移,不留下明文备份。系统 PowerShell / DPAPI 不可用时不会回退写明文;损坏、未知格式或无法解锁的文件保留,并提示恢复操作。macOS / Linux 仍使用明文 JSON,写入以权限 `0600` 的临时文件原子替换,不把这等同于磁盘加密。DPAPI 不隔离同一 Windows 用户运行的其他程序;换设备 / 系统账号需要重新登录,旧版插件也无法读取新的加密格式。遇到解锁问题时,先妥善保留并移走旧文件,再重新登录;请勿分享令牌文件。token 与应用 ID 绑定,换 `clientId` 需重新登录;删除账号只清理该账号的 token。
|
|
211
211
|
- **本地改动会被 `pnpm install` 还原**:如果你是直接改 `node_modules/dsh-email/` 里的文件做本地部署,任何一次 `pnpm install` 都会把它还原成 registry 上的版本(例如 0.10.7);要长期保留请改成从本地路径或 Git 提交安装。
|
|
212
212
|
|
|
213
213
|
## 开发
|
package/docs/VALIDATION.md
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# 0.16.2 · Windows OAuth2 令牌保护
|
|
2
|
+
|
|
3
|
+
2026-10-08,Windows / Node 24.16.0。PR #22 提到了明文令牌存储问题,但 diff 仅向 `lib/oauth2.js` 增加未使用的加密函数导入,未改源码、读写或迁移;构建还会覆盖这行。此次修复在 TypeScript 源码实现。
|
|
4
|
+
|
|
5
|
+
## 实现与保护范围
|
|
6
|
+
|
|
7
|
+
- Windows 使用系统 PowerShell 调用 .NET `ProtectedData` / DPAPI,绑定 `CurrentUser`,加密整个有效令牌文档。静态脚本不携带凭据;输入输出通过匿名进程管道,隐藏窗口,超时 10 秒,输出有上限,不打印原始子进程异常。
|
|
8
|
+
- 新文件只包含版本、保护机制与密文,没有 access / refresh token 或账号明文。首次读取有效旧文件时先加密成功,再原位替换;不生成明文备份。缓存只复用字节完全相同的已认证文档,每次仍读取文件,不掩盖密文被修改。
|
|
9
|
+
- 私有临时文件写入、同步后再原子替换。加密不可用、未知格式、截断或解锁失败时不覆盖旧数据,不回退 Windows 明文写入。缺失文件仍表示未登录;格式损坏不再被错误地视为可覆盖的空账号集合。
|
|
10
|
+
- POSIX 仍为明文 JSON,更新通过 0600 新文件替换,从而修正旧文件过宽的权限。没有将文件权限描述为加密。
|
|
11
|
+
|
|
12
|
+
DPAPI 使用 Windows 登录身份提供保护,通常要求同一用户及机器;**同一用户运行的其他程序不在隔离范围内**。跨设备、换系统账号或退回旧插件格式时需要重新登录。与本机 `settings.yaml` 中密码账号授权码的保存方式无关,本次没有修改用户配置、密码或系统权限。
|
|
13
|
+
|
|
14
|
+
参考:[微软 ProtectedData](https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.protecteddata?view=netframework-4.8)、[CryptProtectData](https://learn.microsoft.com/en-us/windows/desktop/api/Dpapi/nf-dpapi-cryptprotectdata)、[Node 文件权限 API](https://nodejs.org/api/fs.html#fschmodpath-mode-callback)。
|
|
15
|
+
|
|
16
|
+
## 验证
|
|
17
|
+
|
|
18
|
+
完整邮件回归 346 项通过;最终存储专项 6 项通过,包括真实 Windows DPAPI 加解密、独立 Node 进程解锁、旧数据迁移保留多账号、删除仅影响指定账号、未知格式 / 损坏 / 截断文件保留、缓存后篡改真实密文仍被拒绝、加密不可用时不生成明文或破坏旧文件。设备码、刷新单次请求、撤销登录、SMTP OAuth2 重试与取消等已有协议回归仍通过。
|
|
19
|
+
|
|
20
|
+
专项测试只使用 E 盘仓库下的隔离合成令牌,不读取本机账号令牌,不连接微软或实际邮箱,不泄露真实凭据。macOS / Linux 的原子保存与权限路径由 GitHub Linux CI 另行检查;Windows 专属原生用例在 Linux 明确跳过,不把模拟检查算作其他平台原生加密。
|
|
21
|
+
|
|
22
|
+
隔离官方 Alpha 网页在 1280×720 实际操作通过:首次加载后三个合成 Outlook 账号都保持已登录,磁盘已变为密文;在账号页删除 spare 并确认,剩下 work / personal 的令牌和登录状态不变。人为破坏保护机制标识后,英文设置页显示保留原文件和恢复操作的完整提示,文件 SHA256 没有被页面改写;恢复原密文,再切换回邮件页,两账号重新正常显示。正常页面控制台没有 error / warn;故障步骤产生的拒绝响应属于预期检查。
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
没有 API 密钥或真实凭据进入隔离环境,未调用测试连接或实际邮箱。真实微软账号授权、跨 Windows 用户解锁拒绝及原生 DSH 桌面操作不属于本次验收。
|
package/lib/client.js
CHANGED
|
@@ -18,6 +18,10 @@ function emailError(error) {
|
|
|
18
18
|
if (UI_LANG !== 'en') return message;
|
|
19
19
|
if (error?.status === 401) return 'Your DSH session expired. Reopen DSH and try again.';
|
|
20
20
|
if (!/[\u3400-\u9fff]/.test(message)) return message;
|
|
21
|
+
if (/未写入明文令牌/.test(message)) return 'Windows credential encryption is unavailable. Check system PowerShell / DPAPI and retry. No plaintext tokens were written.';
|
|
22
|
+
if (/无法读取或解锁 OAuth2 登录数据/.test(message)) return 'The OAuth login file could not be read or unlocked. Windows encrypted files require the original Windows user. Preserve and move aside oauth2-tokens.json before signing in again on a new device or recovering damaged data. The original file was not overwritten.';
|
|
23
|
+
if (/无法读取 OAuth2 登录数据/.test(message)) return 'The OAuth login file could not be read. Check file access permissions; the original file was not overwritten.';
|
|
24
|
+
if (/OAuth2 登录数据过大/.test(message)) return 'The OAuth login data exceeds the storage limit. The original file was not overwritten.';
|
|
21
25
|
if (/结果.*未|不要.*重发|不要.*发送|本地保存失败|回执未能保存/.test(message)) return 'The send outcome is uncertain or its receipt could not be saved. Check the mailbox before sending again to avoid duplicate delivery.';
|
|
22
26
|
if (/已提交|不能再次发送/.test(message)) return 'This draft was submitted or its outcome is uncertain. Check the mailbox before copying it to a new draft.';
|
|
23
27
|
if (/预览已过期|draft-preview-expired/.test(message + ' ' + (error?.code || ''))) return 'The send review expired or was already used. Review the current draft again.';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare class OAuth2StorageError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export declare function readOAuth2Document(file: string): unknown;
|
|
4
|
+
/** Encrypt before touching the destination, then replace it in one rename. */
|
|
5
|
+
export declare function writeOAuth2Document(file: string, doc: unknown): void;
|
|
6
|
+
export declare function isLegacyOAuth2Document(doc: unknown): boolean;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Windows DPAPI protects stored OAuth2 credentials with the current user's OS key. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
const MAX_STORE_BYTES = 1024 * 1024;
|
|
7
|
+
const PROTECTION = 'windows-dpapi-current-user';
|
|
8
|
+
const ENTROPY = 'dsh-email/oauth2-tokens/v2';
|
|
9
|
+
const STORAGE_MESSAGE = '无法读取或解锁 OAuth2 登录数据:Windows 加密文件需原 Windows 账号;损坏或换设备时请先保留并移走 oauth2-tokens.json,再重新登录。原文件未被覆盖。';
|
|
10
|
+
const ENCRYPTION_MESSAGE = '无法安全保存 OAuth2 登录数据:Windows 用户加密不可用,请检查系统 PowerShell / DPAPI 后重试。未写入明文令牌。';
|
|
11
|
+
export class OAuth2StorageError extends Error {
|
|
12
|
+
}
|
|
13
|
+
// Cache only one authenticated document. File bytes are checked on every read;
|
|
14
|
+
// callers parse their own copy so deleting an account cannot mutate this cache.
|
|
15
|
+
let cached;
|
|
16
|
+
function dpapi(operation, input) {
|
|
17
|
+
const root = process.env.SystemRoot ?? process.env.WINDIR ?? 'C:\\Windows';
|
|
18
|
+
const executable = join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
19
|
+
// This script is static: credentials go through stdin, never argv, an env var,
|
|
20
|
+
// a temporary plaintext file, a profile script or a generated shell command.
|
|
21
|
+
const script = `$ErrorActionPreference='Stop'; try {
|
|
22
|
+
Add-Type -AssemblyName System.Security
|
|
23
|
+
$bytes=[Convert]::FromBase64String([Console]::In.ReadToEnd())
|
|
24
|
+
$entropy=[Text.Encoding]::UTF8.GetBytes('${ENTROPY}')
|
|
25
|
+
$result=[Security.Cryptography.ProtectedData]::${operation}($bytes,$entropy,[Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
26
|
+
[Console]::Out.Write([Convert]::ToBase64String($result))
|
|
27
|
+
} catch { [Console]::Error.Write('DPAPI failed'); exit 1 }`;
|
|
28
|
+
const result = spawnSync(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], {
|
|
29
|
+
input: input.toString('base64'), encoding: 'utf8', windowsHide: true,
|
|
30
|
+
timeout: 10000, maxBuffer: MAX_STORE_BYTES * 4,
|
|
31
|
+
});
|
|
32
|
+
const output = result.stdout?.trim() ?? '';
|
|
33
|
+
if (result.error !== undefined || result.status !== 0 || output === '' || !/^[A-Za-z0-9+/]+={0,2}$/.test(output)) {
|
|
34
|
+
throw new OAuth2StorageError(operation === 'Protect' ? ENCRYPTION_MESSAGE : STORAGE_MESSAGE);
|
|
35
|
+
}
|
|
36
|
+
return Buffer.from(output, 'base64');
|
|
37
|
+
}
|
|
38
|
+
function isProtected(doc) {
|
|
39
|
+
return doc !== null && typeof doc === 'object' && !Array.isArray(doc)
|
|
40
|
+
&& ((doc.version !== undefined && doc.version !== 1) || 'protection' in doc || 'ciphertext' in doc);
|
|
41
|
+
}
|
|
42
|
+
function parseDocument(raw) {
|
|
43
|
+
if (Buffer.byteLength(raw) > MAX_STORE_BYTES * 2)
|
|
44
|
+
throw new OAuth2StorageError(STORAGE_MESSAGE);
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new OAuth2StorageError(STORAGE_MESSAGE);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function readOAuth2Document(file) {
|
|
53
|
+
const raw = readFileSync(file, 'utf8');
|
|
54
|
+
const doc = parseDocument(raw);
|
|
55
|
+
if (!isProtected(doc))
|
|
56
|
+
return doc;
|
|
57
|
+
if (process.platform !== 'win32' || doc.version !== 2 || doc.protection !== PROTECTION
|
|
58
|
+
|| typeof doc.ciphertext !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(doc.ciphertext)) {
|
|
59
|
+
throw new OAuth2StorageError(STORAGE_MESSAGE);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const plaintext = cached?.file === file && cached.envelope === raw
|
|
63
|
+
? cached.plaintext : dpapi('Unprotect', Buffer.from(doc.ciphertext, 'base64')).toString('utf8');
|
|
64
|
+
const value = JSON.parse(plaintext);
|
|
65
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
66
|
+
|| value.version !== 1)
|
|
67
|
+
throw new Error('Invalid protected document');
|
|
68
|
+
cached = { file, envelope: raw, plaintext };
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new OAuth2StorageError(STORAGE_MESSAGE);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Encrypt before touching the destination, then replace it in one rename. */
|
|
76
|
+
export function writeOAuth2Document(file, doc) {
|
|
77
|
+
// Never replace a locked, foreign-user, unsupported or damaged encrypted
|
|
78
|
+
// store with a new empty store during a login or account deletion.
|
|
79
|
+
let existing;
|
|
80
|
+
try {
|
|
81
|
+
existing = parseDocument(readFileSync(file, 'utf8'));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== 'ENOENT')
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
if (isProtected(existing))
|
|
88
|
+
readOAuth2Document(file);
|
|
89
|
+
const plaintext = JSON.stringify(doc, null, 2) + '\n';
|
|
90
|
+
if (Buffer.byteLength(plaintext) > MAX_STORE_BYTES)
|
|
91
|
+
throw new OAuth2StorageError('OAuth2 登录数据过大,原文件未被覆盖。');
|
|
92
|
+
const raw = process.platform === 'win32'
|
|
93
|
+
? JSON.stringify({ version: 2, protection: PROTECTION, ciphertext: dpapi('Protect', Buffer.from(plaintext)).toString('base64') }, null, 2) + '\n'
|
|
94
|
+
: plaintext;
|
|
95
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
96
|
+
const temp = file + '.' + randomUUID() + '.tmp';
|
|
97
|
+
let fd;
|
|
98
|
+
try {
|
|
99
|
+
fd = openSync(temp, 'wx', 0o600);
|
|
100
|
+
writeFileSync(fd, raw, 'utf8');
|
|
101
|
+
fsyncSync(fd);
|
|
102
|
+
closeSync(fd);
|
|
103
|
+
fd = undefined;
|
|
104
|
+
renameSync(temp, file);
|
|
105
|
+
cached = process.platform === 'win32' ? { file, envelope: raw, plaintext } : undefined;
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
if (fd !== undefined)
|
|
109
|
+
closeSync(fd);
|
|
110
|
+
try {
|
|
111
|
+
unlinkSync(temp);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error.code !== 'ENOENT')
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function isLegacyOAuth2Document(doc) {
|
|
120
|
+
return !isProtected(doc);
|
|
121
|
+
}
|
package/lib/oauth2.d.ts
CHANGED
|
@@ -71,9 +71,9 @@ interface OAuthFailure {
|
|
|
71
71
|
* normal state, not an error: the user simply has not finished in the browser.
|
|
72
72
|
*/
|
|
73
73
|
export declare function classifyOAuthFailure(payload: unknown, httpStatus?: number): OAuthFailure;
|
|
74
|
-
/**
|
|
74
|
+
/** Missing/legacy malformed entries mean no tokens; unreadable or corrupt files fail closed. */
|
|
75
75
|
export declare function readTokenStore(): OAuth2TokenStore;
|
|
76
|
-
/**
|
|
76
|
+
/** Windows: current-user DPAPI. POSIX: private atomic file. Never write plaintext on Windows. */
|
|
77
77
|
export declare function writeTokenStore(store: OAuth2TokenStore): void;
|
|
78
78
|
/** Drop one account's tokens (a dead refresh token, or a mailbox that moved). */
|
|
79
79
|
export declare function clearTokenFor(name: string): boolean;
|
package/lib/oauth2.js
CHANGED
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
* fingerprint and are never logged. Every message that leaves this module is
|
|
19
19
|
* the text the user reads in Chinese, without any token material in it.
|
|
20
20
|
*/
|
|
21
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
22
21
|
import { homedir } from 'node:os';
|
|
23
|
-
import {
|
|
22
|
+
import { join } from 'node:path';
|
|
24
23
|
import { clampInt, OUTLOOK_OAUTH2_CLIENT_ID } from './config.js';
|
|
24
|
+
import { isLegacyOAuth2Document, OAuth2StorageError, readOAuth2Document, writeOAuth2Document } from './oauth2-storage.js';
|
|
25
25
|
/** The authority that serves both endpoints. `common` accepts any work/school/personal tenant. */
|
|
26
26
|
export const OAUTH2_TENANT = 'common';
|
|
27
27
|
export const DEVICE_CODE_URL = 'https://login.microsoftonline.com/' + OAUTH2_TENANT + '/oauth2/v2.0/devicecode';
|
|
@@ -143,14 +143,18 @@ export function classifyOAuthFailure(payload, httpStatus = 0) {
|
|
|
143
143
|
return { ...base, message: '登录失败:' + detail + (aadsts !== '' ? '(AADSTS' + aadsts + ')' : '') };
|
|
144
144
|
}
|
|
145
145
|
// --- token store -------------------------------------------------------------
|
|
146
|
-
/**
|
|
146
|
+
/** Missing/legacy malformed entries mean no tokens; unreadable or corrupt files fail closed. */
|
|
147
147
|
export function readTokenStore() {
|
|
148
148
|
let doc;
|
|
149
149
|
try {
|
|
150
|
-
doc =
|
|
150
|
+
doc = readOAuth2Document(oauth2TokenFile());
|
|
151
151
|
}
|
|
152
|
-
catch {
|
|
153
|
-
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof OAuth2StorageError)
|
|
154
|
+
throw new OAuth2Error(error.message);
|
|
155
|
+
if (error.code === 'ENOENT')
|
|
156
|
+
return { version: 1, accounts: {} };
|
|
157
|
+
throw new OAuth2Error('无法读取 OAuth2 登录数据,请检查文件访问权限。原文件未被覆盖。');
|
|
154
158
|
}
|
|
155
159
|
if (doc === null || typeof doc !== 'object' || Array.isArray(doc))
|
|
156
160
|
return { version: 1, accounts: {} };
|
|
@@ -163,7 +167,10 @@ export function readTokenStore() {
|
|
|
163
167
|
accounts[name] = entry;
|
|
164
168
|
}
|
|
165
169
|
}
|
|
166
|
-
|
|
170
|
+
const store = { version: 1, accounts };
|
|
171
|
+
if (process.platform === 'win32' && isLegacyOAuth2Document(doc) && Object.keys(accounts).length > 0)
|
|
172
|
+
writeTokenStore(store);
|
|
173
|
+
return store;
|
|
167
174
|
}
|
|
168
175
|
/** Keep only well-formed entries: a half-written file must not poison a login. */
|
|
169
176
|
function tokenEntryOf(value) {
|
|
@@ -180,11 +187,16 @@ function tokenEntryOf(value) {
|
|
|
180
187
|
expiresAt: typeof doc.expiresAt === 'number' && Number.isFinite(doc.expiresAt) ? doc.expiresAt : 0,
|
|
181
188
|
};
|
|
182
189
|
}
|
|
183
|
-
/**
|
|
190
|
+
/** Windows: current-user DPAPI. POSIX: private atomic file. Never write plaintext on Windows. */
|
|
184
191
|
export function writeTokenStore(store) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
192
|
+
try {
|
|
193
|
+
writeOAuth2Document(oauth2TokenFile(), store);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (error instanceof OAuth2StorageError)
|
|
197
|
+
throw new OAuth2Error(error.message);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
188
200
|
}
|
|
189
201
|
/** Drop one account's tokens (a dead refresh token, or a mailbox that moved). */
|
|
190
202
|
export function clearTokenFor(name) {
|