diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d9de0d..74631f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [0.15.1] - Unreleased +- Update Rolldown to 1.2.14 for generated CLI bundling, preserving Node 24 support and the 48-hour dependency cooldown. +- Add opt-in OAuth secret encryption with `MCPORTER_VAULT_PASSWORD`, a required policy, persistent encryption markers, migration on write, and vault/token-cache diagnostics. Older binaries must use a separate state directory after opt-in; see the downgrade instructions. Thanks @alexminza (#425, #408). + ## [0.15.0] - 2026-10-16 **Highlights:** Keep healthy MCP servers available when another server fails, accept inline ad-hoc flags, add runnable MCP examples, and rehearse releases without publishing. diff --git a/README.md b/README.md index a936d8f5..ca1d0f0c 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,16 @@ The first command prints the server's TypeScript-style tool signatures. The seco ## Core workflows -| Goal | Command or API | Details | -| ----------------------------------------- | ------------------------------------ | ------------------------------------------ | -| Discover servers and tools | `mcporter list` | [CLI reference](docs/cli-reference.md) | -| Call tools and read resources | `mcporter call`, `mcporter resource` | [Call syntax](docs/call-syntax.md) | -| Connect to a one-off URL or stdio command | `--http-url`, `--stdio` | [Ad-hoc servers](docs/adhoc.md) | -| Complete or seed OAuth | `mcporter auth`, `mcporter vault` | [Configuration](docs/config.md) | -| Generate a focused CLI | `mcporter generate-cli` | [CLI generator](docs/cli-generator.md) | -| Generate TypeScript types or clients | `mcporter emit-ts` | [Typed clients](docs/emit-ts.md) | -| Capture a reproducible MCP session | `mcporter record`, `mcporter replay` | [Record and replay](docs/record-replay.md) | +| Goal | Command or API | Details | +| ----------------------------------------- | ------------------------------------ | --------------------------------------------------- | +| Discover servers and tools | `mcporter list` | [CLI reference](docs/cli-reference.md) | +| Call tools and read resources | `mcporter call`, `mcporter resource` | [Call syntax](docs/call-syntax.md) | +| Connect to a one-off URL or stdio command | `--http-url`, `--stdio` | [Ad-hoc servers](docs/adhoc.md) | +| Complete or seed OAuth | `mcporter auth`, `mcporter vault` | [Configuration](docs/config.md) | +| Encrypt the OAuth vault at rest | `MCPORTER_VAULT_PASSWORD` | [Vault Encryption](docs/config.md#vault-encryption) | +| Generate a focused CLI | `mcporter generate-cli` | [CLI generator](docs/cli-generator.md) | +| Generate TypeScript types or clients | `mcporter emit-ts` | [Typed clients](docs/emit-ts.md) | +| Capture a reproducible MCP session | `mcporter record`, `mcporter replay` | [Record and replay](docs/record-replay.md) | Human-readable output goes to stdout by default. Use JSON output when another program or agent needs a stable result, and consult `mcporter --help` for the flags supported by that command. diff --git a/docs/config.md b/docs/config.md index ba9b802e..5d957cf9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -49,7 +49,7 @@ mcporter honors XDG Base Directory env vars for its own paths when they are expl | Kind | Env var | mcporter path | Legacy fallback | | ------ | ----------------- | ----------------------------------------------- | -------------------- | | config | `XDG_CONFIG_HOME` | `$XDG_CONFIG_HOME/mcporter/mcporter.json[c]` | `~/.mcporter/...` | -| data | `XDG_DATA_HOME` | `$XDG_DATA_HOME/mcporter/credentials.json` | `~/.mcporter/...` | +| data | `XDG_DATA_HOME` | `$XDG_DATA_HOME/mcporter/credentials.json` | `~/.mcporter/...` | — see [Vault Encryption](#vault-encryption) | | cache | `XDG_CACHE_HOME` | `$XDG_CACHE_HOME/mcporter//schema.json` | `~/.mcporter/...` | | state | `XDG_STATE_HOME` | `$XDG_STATE_HOME/mcporter/...` | `~/.mcporter/daemon` | @@ -210,10 +210,11 @@ The schema is auto-generated from the Zod validation schemas using `pnpm generat Top-level structure: -| Key | Type | Description | -| ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------- | -| `mcpServers` | object | Map of server names → definitions. Required even if empty. | -| `imports` | string[] | Optional list of import kinds. Empty array disables imports entirely; omitting the key falls back to the default list. | +| Key | Type | Description | +| ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `mcpServers` | object | Map of server names → definitions. Required even if empty. | +| `imports` | string[] | Optional list of import kinds. Empty array disables imports entirely; omitting the key falls back to the default list. | +| `oauthVaultEncryption` | string | `"optional"` (default) or `"required"`; see [Vault Encryption](#vault-encryption). `MCPORTER_VAULT_ENCRYPTION` overrides it. | Server definition fields (subset of what `RawEntrySchema` accepts): @@ -293,6 +294,28 @@ For keep-alive stdio servers, refresh happens before process start. If that proc - Machine-specific additions can live in `~/.mcporter/local.json` or `$XDG_CONFIG_HOME/mcporter/local.json`; point `mcporter config --config ~/.mcporter/local.json add ...` there when you prefer not to touch the repo. Since the runtime only watches one config at a time, CI jobs should always pass `--config config/mcporter.json` (or run from the repo root) for deterministic behavior. - OAuth tokens, cached server metadata, and generated CLIs should remain outside the repo (`~/.mcporter/...` or the matching `XDG_*_HOME/mcporter/...`, plus `dist/`). +## Vault Encryption + +> **Before opting in:** upgrade and restart every CLI, daemon, and generated CLI that shares the vault or a `tokenCacheDir`. Older binaries do not enforce encryption: re-authentication can silently put plaintext credentials back on disk. A format marker cannot stop already-released code from doing that. Do not downgrade or mix those versions against an encrypted profile; use the isolated rollback procedure below. + +The OAuth vault (`credentials.json`, see the XDG table above) is plaintext JSON by default. Set `MCPORTER_VAULT_PASSWORD` to store each secret value in it (`access_token`, `refresh_token`, `id_token`, `client_secret`, `registration_access_token`, the PKCE verifier and OAuth state) as an RFC 7516 JWE compact string in place: `PBES2-HS512+A256KW`, `A256GCM`, 8192 PBKDF2 iterations. The JSON document retains version 2 and gains an `encryption: { "version": 1 }` marker; server names, URLs, client IDs and timestamps stay readable, and any JOSE library can decrypt a value with the password (`jose` for Node needs `PBES2-HS512+A256KW` in `keyManagementAlgorithms`). The password must be generated, not chosen: with 8192 key-derivation iterations its randomness is the whole protection against an offline guess at a copied file. It must be at least 32 characters; `openssl rand -base64 32` produces a suitable value. + +- Existing plaintext values are read as before and encrypted the next time a login is added or renewed (a token refresh, `mcporter auth`, `mcporter vault set`); until then they stay plain. `mcporter config doctor` shows how many values are still plain, in the vault and in each configured `tokenCacheDir`. +- Once marked, the vault requires the password even after its last entry is cleared. A `tokenCacheDir` records the same choice in `encryption.json`. Supporting versions refuse unknown encryption-marker versions without repairing or overwriting them. Sealed values from unmarked stores are also recognized and refused without the password. +- There is no password rotation: with a new password the stored values no longer open; run `mcporter vault clear ` and log in again. Restart `mcporter daemon` after changing these variables. +- `oauthVaultEncryption: "required"` in `mcporter.json` (or `MCPORTER_VAULT_ENCRYPTION=required`, which overrides the config key) refuses every vault operation while the password is unset, so a cleared environment cannot write plaintext values. Default `optional`. The config key applies to servers defined in config; ad-hoc servers (`mcporter auth `) follow `MCPORTER_VAULT_ENCRYPTION` only. A CLI generated with `mcporter generate-cli` embeds the policy of the config file or definition it was generated from, and generating under `required` needs the password set, because tool discovery goes through the vault. +- Tokens saved by older versions under `~/.mcporter//` are copied into the vault the first time the server is used, as before. With the password set the old plaintext files are removed after the copy; without it they stay. +- Servers with a `tokenCacheDir` get the same treatment: the secret values in `tokens.json`, `client.json`, `code_verifier.txt` and `state.txt` are encrypted, and the first write with the password set (any write, including the discovery metadata) encrypts every one of those files that is still plain; the other files there are not secrets. Without the password, a directory that holds encrypted values refuses every write and delete and is never modified, as the vault is. The `required` policy refuses the directory too, and a plaintext directory never hides a vault that needs the password. +- Stdio MCP servers, whether started directly or by the keep-alive daemon, and the OAuth auth helper are started without `MCPORTER_VAULT_PASSWORD` and `MCPORTER_VAULT_ENCRYPTION` in their environment; everything else they inherit is unchanged, and a value set in a server's own `env` still applies. + +### Downgrade and rollback + +There is no in-place plaintext downgrade or password rotation command. Unsetting the password is not a rollback. The markers protect supporting versions, but old binaries ignore them (and an unknown top-level vault version is treated as corrupt and rewritten by those binaries). Neither the marker nor `required` can impose a policy on code released before this feature. + +To run an older version safely, stop the daemon and all consumers of the encrypted profile, preserve that profile and its password, and run the older binary with a **different `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME`, and `XDG_STATE_HOME`**. Use a fresh config in that profile; remove or change every explicit `tokenCacheDir` so it cannot point back into the encrypted profile. Authenticate again there, accepting that the old version stores those new credentials in plaintext. Do not copy encrypted credentials into the old profile. To roll forward, stop the old consumers, restore the original environment and password, and restart the supporting version against the preserved encrypted profile. + +If an older consumer has already written plaintext into the encrypted profile, stop it, restore the supporting version and password, and trigger a credential write to seal the whole store again. Run `mcporter config doctor` to check for residue. Sealing cannot undo exposure in backups or copies made during that window; revoke affected credentials at their providers if needed. + ## Validation & Troubleshooting - `mcporter list --http-url ...` refuses to auto-run OAuth to keep listing commands quick; use `mcporter config login ...` or `mcporter auth ...` to finish credential setup. diff --git a/docs/install.md b/docs/install.md index 0a9a1140..5ea22b13 100644 --- a/docs/install.md +++ b/docs/install.md @@ -67,4 +67,4 @@ The `list` command prints every MCP server discovered across your configs (Curso - `brew uninstall steipete/tap/mcporter` - Standalone binary: delete the file you copied onto `$PATH`. -OAuth credentials use `~/.mcporter/credentials.json` or `$XDG_DATA_HOME/mcporter/credentials.json`; cached schemas use `~/.mcporter//` or `$XDG_CACHE_HOME/mcporter//`. Custom `tokenCacheDir` settings can place them elsewhere. Run `mcporter config logout ` to clear that server's credentials while preserving unrelated files. See [configuration paths](config.md#config-resolution-order) before removing any remaining config or cache files. +OAuth credentials use `~/.mcporter/credentials.json` or `$XDG_DATA_HOME/mcporter/credentials.json`; cached schemas use `~/.mcporter//` or `$XDG_CACHE_HOME/mcporter//`. Custom `tokenCacheDir` settings can place them elsewhere. With `MCPORTER_VAULT_PASSWORD` set, the secret values in these files are encrypted; see [Vault Encryption](config.md#vault-encryption). Run `mcporter config logout ` to clear that server's credentials while preserving unrelated files. See [configuration paths](config.md#config-resolution-order) before removing any remaining config or cache files. diff --git a/docs/migration.md b/docs/migration.md index db851929..02397ba4 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -28,7 +28,7 @@ npm install mcporter ## 3. OAuth Tokens -- Tokens are saved in the shared vault under `~/.mcporter/credentials.json` by default, or `$XDG_DATA_HOME/mcporter/credentials.json` when `XDG_DATA_HOME` is set. +- Tokens are saved in the shared vault under `~/.mcporter/credentials.json` by default, or `$XDG_DATA_HOME/mcporter/credentials.json` when `XDG_DATA_HOME` is set, with secret values encrypted when `MCPORTER_VAULT_PASSWORD` is set ([Vault Encryption](config.md#vault-encryption)). - To force a fresh login for one server, run `mcporter auth --reset`. This clears its recognized credentials without deleting unrelated files or other servers' vault entries. - Custom `token_cache_dir` entries in `mcporter.json` continue to work as explicit overrides. diff --git a/mcporter.schema.json b/mcporter.schema.json index 32125db4..f2af8097 100644 --- a/mcporter.schema.json +++ b/mcporter.schema.json @@ -409,6 +409,11 @@ "exclusiveMinimum": 0, "maximum": 9007199254740991 }, + "oauthVaultEncryption": { + "description": "Policy for the shared OAuth vault: \"required\" refuses every vault operation unless MCPORTER_VAULT_PASSWORD is set. MCPORTER_VAULT_ENCRYPTION overrides this key.", + "type": "string", + "enum": ["optional", "required"] + }, "imports": { "description": "Editor configurations to import servers from. Omit to use defaults, or set to [] to disable imports", "type": "array", diff --git a/package.json b/package.json index 4d04d1ab..8b9ab227 100644 --- a/package.json +++ b/package.json @@ -77,9 +77,10 @@ "acorn": "^8.19.0", "commander": "^15.0.0", "es-toolkit": "^1.52.0", + "jose": "^6.2.12", "jsonc-parser": "^3.3.1", "ora": "^9.4.1", - "rolldown": "1.2.13", + "rolldown": "1.2.14", "zod": "^4.6.7" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8512cd21..d46a9041 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: es-toolkit: specifier: ^1.52.0 version: 1.52.0 + jose: + specifier: ^6.2.12 + version: 6.2.12 jsonc-parser: specifier: ^3.3.1 version: 3.3.1 @@ -50,8 +53,8 @@ importers: specifier: ^9.4.1 version: 9.4.1 rolldown: - specifier: 1.2.13 - version: 1.2.13 + specifier: 1.2.14 + version: 1.2.14 zod: specifier: ^4.6.7 version: 4.6.7 @@ -331,8 +334,8 @@ packages: resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} engines: {node: '>=20'} - '@oxc-project/types@0.153.0': - resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + '@oxc-project/types@0.154.0': + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} '@oxfmt/binding-android-arm-eabi@0.73.0': resolution: {integrity: sha512-dhunAdMbi/4o0Y66/+Wl6rpsNqpvE4i26Qw6bB77A97L2lJoktWg6m+X9QOon8iOEEqzD9rEZqFi5uZ4oIzhCA==} @@ -608,98 +611,98 @@ packages: cpu: [x64] os: [win32] - '@rolldown/binding-android-arm-eabi@1.2.13': - resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + '@rolldown/binding-android-arm-eabi@1.2.14': + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.13': - resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + '@rolldown/binding-android-arm64@1.2.14': + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.13': - resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + '@rolldown/binding-darwin-arm64@1.2.14': + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.13': - resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + '@rolldown/binding-darwin-x64@1.2.14': + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.13': - resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + '@rolldown/binding-freebsd-x64@1.2.14': + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': - resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.13': - resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + '@rolldown/binding-linux-arm64-gnu@1.2.14': + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.13': - resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + '@rolldown/binding-linux-arm64-musl@1.2.14': + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.13': - resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.13': - resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + '@rolldown/binding-linux-s390x-gnu@1.2.14': + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.13': - resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + '@rolldown/binding-linux-x64-gnu@1.2.14': + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.13': - resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + '@rolldown/binding-linux-x64-musl@1.2.14': + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.13': - resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + '@rolldown/binding-openharmony-arm64@1.2.14': + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.13': - resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + '@rolldown/binding-win32-arm64-msvc@1.2.14': + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.13': - resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + '@rolldown/binding-win32-x64-msvc@1.2.14': + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1464,8 +1467,8 @@ packages: engines: {node: 20 || >=22} hasBin: true - rolldown@1.2.13: - resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + rolldown@1.2.14: + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1857,7 +1860,7 @@ snapshots: '@modelcontextprotocol/core': 2.3.1 zod: 4.6.7 - '@oxc-project/types@0.153.0': {} + '@oxc-project/types@0.154.0': {} '@oxfmt/binding-android-arm-eabi@0.73.0': optional: true @@ -1991,49 +1994,49 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.88.0': optional: true - '@rolldown/binding-android-arm-eabi@1.2.13': + '@rolldown/binding-android-arm-eabi@1.2.14': optional: true - '@rolldown/binding-android-arm64@1.2.13': + '@rolldown/binding-android-arm64@1.2.14': optional: true - '@rolldown/binding-darwin-arm64@1.2.13': + '@rolldown/binding-darwin-arm64@1.2.14': optional: true - '@rolldown/binding-darwin-x64@1.2.13': + '@rolldown/binding-darwin-x64@1.2.14': optional: true - '@rolldown/binding-freebsd-x64@1.2.13': + '@rolldown/binding-freebsd-x64@1.2.14': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.13': + '@rolldown/binding-linux-arm64-gnu@1.2.14': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.13': + '@rolldown/binding-linux-arm64-musl@1.2.14': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.13': + '@rolldown/binding-linux-ppc64-gnu@1.2.14': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.13': + '@rolldown/binding-linux-s390x-gnu@1.2.14': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.13': + '@rolldown/binding-linux-x64-gnu@1.2.14': optional: true - '@rolldown/binding-linux-x64-musl@1.2.13': + '@rolldown/binding-linux-x64-musl@1.2.14': optional: true - '@rolldown/binding-openharmony-arm64@1.2.13': + '@rolldown/binding-openharmony-arm64@1.2.14': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.13': + '@rolldown/binding-win32-arm64-msvc@1.2.14': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.13': + '@rolldown/binding-win32-x64-msvc@1.2.14': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -2735,26 +2738,26 @@ snapshots: glob: 13.0.6 package-json-from-dist: 1.0.1 - rolldown@1.2.13: + rolldown@1.2.14: dependencies: - '@oxc-project/types': 0.153.0 + '@oxc-project/types': 0.154.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.13 - '@rolldown/binding-android-arm64': 1.2.13 - '@rolldown/binding-darwin-arm64': 1.2.13 - '@rolldown/binding-darwin-x64': 1.2.13 - '@rolldown/binding-freebsd-x64': 1.2.13 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 - '@rolldown/binding-linux-arm64-gnu': 1.2.13 - '@rolldown/binding-linux-arm64-musl': 1.2.13 - '@rolldown/binding-linux-ppc64-gnu': 1.2.13 - '@rolldown/binding-linux-s390x-gnu': 1.2.13 - '@rolldown/binding-linux-x64-gnu': 1.2.13 - '@rolldown/binding-linux-x64-musl': 1.2.13 - '@rolldown/binding-openharmony-arm64': 1.2.13 - '@rolldown/binding-win32-arm64-msvc': 1.2.13 - '@rolldown/binding-win32-x64-msvc': 1.2.13 + '@rolldown/binding-android-arm-eabi': 1.2.14 + '@rolldown/binding-android-arm64': 1.2.14 + '@rolldown/binding-darwin-arm64': 1.2.14 + '@rolldown/binding-darwin-x64': 1.2.14 + '@rolldown/binding-freebsd-x64': 1.2.14 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 + '@rolldown/binding-linux-arm64-gnu': 1.2.14 + '@rolldown/binding-linux-arm64-musl': 1.2.14 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14 + '@rolldown/binding-linux-s390x-gnu': 1.2.14 + '@rolldown/binding-linux-x64-gnu': 1.2.14 + '@rolldown/binding-linux-x64-musl': 1.2.14 + '@rolldown/binding-openharmony-arm64': 1.2.14 + '@rolldown/binding-win32-arm64-msvc': 1.2.14 + '@rolldown/binding-win32-x64-msvc': 1.2.14 router@2.2.0: dependencies: @@ -2913,7 +2916,7 @@ snapshots: lightningcss: 1.33.0 picomatch: 4.0.7 postcss: 8.5.30 - rolldown: 1.2.13 + rolldown: 1.2.14 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.6.5 diff --git a/scripts/test-npm-package.sh b/scripts/test-npm-package.sh index 3d678afd..4ccf4638 100644 --- a/scripts/test-npm-package.sh +++ b/scripts/test-npm-package.sh @@ -28,3 +28,12 @@ for (const name of ['callOnce', 'createRuntime', 'createServerProxy']) { NODE ) node "$ROOT/scripts/verify-packaged-oauth.mjs" "$WORK/consumer/node_modules/mcporter" +node "$ROOT/scripts/verify-packaged-vault.mjs" "$WORK/consumer/node_modules/mcporter" + +# Keep the supported rollback procedure honest against the last plaintext release. +mkdir -p "$WORK/previous" +HOME="$WORK/home" NPM_CONFIG_USERCONFIG="$WORK/empty-npmrc" \ + npm install --prefix "$WORK/previous" --ignore-scripts --no-audit --no-fund --package-lock=false \ + --registry=https://registry.npmjs.org/ mcporter@0.15.0 +node "$ROOT/scripts/test-vault-downgrade.mjs" \ + "$WORK/previous/node_modules/mcporter" "$WORK/consumer/node_modules/mcporter" diff --git a/scripts/test-vault-downgrade.mjs b/scripts/test-vault-downgrade.mjs new file mode 100644 index 00000000..85b77adf --- /dev/null +++ b/scripts/test-vault-downgrade.mjs @@ -0,0 +1,129 @@ +// Usage: pnpm build && node scripts/test-vault-downgrade.mjs +// All credentials and homes are synthetic; no provider or account is contacted. +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const current = path.resolve(process.argv[3] ?? fileURLToPath(new URL('..', import.meta.url))); +const previous = path.resolve(process.argv[2] ?? 'missing-previous-package'); +assert.equal(JSON.parse(await fs.readFile(path.join(previous, 'package.json'))).version, '0.15.0'); +const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-downgrade-')); +const password = 'synthetic-downgrade-test-password-only'; +const token = 'synthetic-downgrade-access-token'; +const config = path.join(root, 'config.json'); +await fs.writeFile(config, JSON.stringify({ mcpServers: { demo: { baseUrl: 'https://example.com/mcp' } } })); + +function environment(profile, encrypted) { + const home = path.join(root, profile); + const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, 'config'), + XDG_DATA_HOME: path.join(home, 'data'), + XDG_STATE_HOME: path.join(home, 'state'), + XDG_CACHE_HOME: path.join(home, 'cache'), + MCPORTER_DAEMON_DIR: path.join(home, 'daemon'), + }; + delete env.MCPORTER_VAULT_ENCRYPTION; + delete env.MCPORTER_VAULT_PASSWORD; + if (encrypted) env.MCPORTER_VAULT_PASSWORD = password; + return env; +} +function runCli(pkg, profile, encrypted, args, input) { + const result = spawnSync(process.execPath, [path.join(pkg, 'dist/cli.js'), '--config', config, ...args], { + env: environment(profile, encrypted), + input, + encoding: 'utf8', + timeout: 30_000, + }); + return result; +} +function writeVault(pkg, profile, encrypted) { + const result = runCli( + pkg, + profile, + encrypted, + ['vault', 'set', 'demo', '--stdin'], + JSON.stringify({ + tokens: { access_token: token, refresh_token: 'synthetic-refresh-token', token_type: 'Bearer' }, + }) + ); + assert.equal(result.status, 0, result.stderr); +} +const vaultPath = (profile) => path.join(environment(profile, false).XDG_DATA_HOME, 'mcporter', 'credentials.json'); +const bytes = (profile) => fs.readFile(vaultPath(profile), 'utf8'); +function writeCache(pkg, profile, encrypted) { + const program = ` + import { pathToFileURL } from 'node:url'; + const { DirectoryPersistence } = await import(pathToFileURL(process.argv[1])); + await new DirectoryPersistence(process.argv[2]).saveTokens({ access_token: process.argv[3], token_type: 'Bearer' }); + `; + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + program, + path.join(pkg, 'dist/oauth-persistence-stores.js'), + path.join(root, profile, 'token-cache'), + token, + ], + { + env: environment(profile, encrypted), + encoding: 'utf8', + timeout: 30_000, + } + ); + assert.equal(result.status, 0, result.stderr); +} +const cacheBytes = (profile) => fs.readFile(path.join(root, profile, 'token-cache/tokens.json'), 'utf8'); +try { + writeVault(previous, 'protected', false); + assert.ok((await bytes('protected')).includes(token)); + writeVault(current, 'protected', true); + const sealed = await bytes('protected'); + assert.ok(!sealed.includes(token)); + assert.deepEqual(JSON.parse(sealed).encryption, { version: 1 }); + const refused = runCli( + current, + 'protected', + false, + ['vault', 'set', 'demo', '--stdin'], + JSON.stringify({ tokens: { access_token: token, token_type: 'Bearer' } }) + ); + assert.notEqual(refused.status, 0); + assert.match(refused.stderr, /MCPORTER_VAULT_PASSWORD/); + assert.equal(await bytes('protected'), sealed); + console.log('PASS: released plaintext migrates; missing password preserves encrypted bytes'); + + writeVault(previous, 'rollback', false); + assert.ok((await bytes('rollback')).includes(token)); + assert.equal(await bytes('protected'), sealed); + writeVault(current, 'protected', true); + assert.ok(!(await bytes('protected')).includes(token)); + console.log('PASS: isolated downgrade and return preserve the encrypted profile'); + + writeVault(previous, 'protected', false); + assert.ok((await bytes('protected')).includes(token)); + writeVault(current, 'protected', true); + assert.ok(!(await bytes('protected')).includes(token)); + console.log('PASS: reproduced unsafe shared-profile downgrade; supporting writer re-seals residue'); + + writeCache(previous, 'protected', false); + writeCache(current, 'protected', true); + const sealedCache = await cacheBytes('protected'); + assert.ok(!sealedCache.includes(token)); + writeCache(previous, 'rollback', false); + assert.equal(await cacheBytes('protected'), sealedCache); + writeCache(previous, 'protected', false); + assert.ok((await cacheBytes('protected')).includes(token)); + writeCache(current, 'protected', true); + assert.ok(!(await cacheBytes('protected')).includes(token)); + console.log('PASS: tokenCacheDir migration, isolated rollback, downgrade residue and recovery'); +} finally { + await fs.rm(root, { recursive: true, force: true }); +} diff --git a/scripts/verify-packaged-vault.mjs b/scripts/verify-packaged-vault.mjs new file mode 100644 index 00000000..8a91e669 --- /dev/null +++ b/scripts/verify-packaged-vault.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const codec = await import( + pathToFileURL(path.join(path.resolve(process.argv[2]), 'dist', 'oauth-vault-encryption.js')).href +); +const password = 'packaged-vault-verifier-password-2026'; +const kid = codec.vaultSecretKid('verify|0000000000000000', ['tokens', 'refresh_token']); +const token = await codec.sealVaultSecret('synthetic-refresh-token', kid, password); +assert.equal(codec.isVaultSecretJwe(token), true, 'installed package did not produce a vault JWE'); +assert.equal( + await codec.openVaultSecret(token, kid, password), + 'synthetic-refresh-token', + 'installed package did not round-trip the value' +); +console.log('Installed npm package seals and opens OAuth vault values.'); diff --git a/src/cli-metadata.ts b/src/cli-metadata.ts index 1efb0d9b..f8de8fc7 100644 --- a/src/cli-metadata.ts +++ b/src/cli-metadata.ts @@ -33,6 +33,7 @@ export interface SerializedServerDefinition { readonly oauthClientMetadataUrl?: string; readonly oauthScope?: string; readonly oauthRequestedScope?: string; + readonly oauthVaultEncryption?: ServerDefinition['oauthVaultEncryption']; readonly refresh?: ServerDefinition['refresh']; readonly httpFetch?: ServerDefinition['httpFetch']; readonly lifecycle?: ServerDefinition['lifecycle']; diff --git a/src/cli/auth-command.ts b/src/cli/auth-command.ts index 66887f27..c10f82fd 100644 --- a/src/cli/auth-command.ts +++ b/src/cli/auth-command.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; import type { ServerDefinition } from '../config-schema.js'; +import { childEnvWithoutVaultSecrets } from '../oauth-vault-encryption.js'; import type { OAuthAuthorizationRequest, OAuthSessionOptions } from '../oauth.js'; import { suppressBrowserLaunchFromEnv } from '../oauth-browser-suppression.js'; import { analyzeConnectionError } from '../error-classifier.js'; @@ -145,7 +146,9 @@ async function runStdioAuth(definition: ServerDefinition, options: { noBrowser?: if (definition.oauthCommand) { authArgs.push(...definition.oauthCommand.args); } - const env = options.noBrowser ? { ...process.env, MCPORTER_OAUTH_NO_BROWSER: '1' } : process.env; + const env = childEnvWithoutVaultSecrets( + options.noBrowser ? { ...process.env, MCPORTER_OAUTH_NO_BROWSER: '1' } : process.env + ); return new Promise((resolve, reject) => { const child = spawn(definition.command.kind === 'stdio' ? definition.command.command : '', authArgs, { stdio: 'inherit', diff --git a/src/cli/config/doctor.ts b/src/cli/config/doctor.ts index afe79878..ca43db56 100644 --- a/src/cli/config/doctor.ts +++ b/src/cli/config/doctor.ts @@ -1,27 +1,145 @@ import path from 'node:path'; -import { loadServerDefinitions } from '../../config.js'; +import { loadConfigSnapshot } from '../../config.js'; +import { readJsonFile } from '../../fs-json.js'; +import { describeTokenCacheDir } from '../../oauth-persistence-stores.js'; +import { getOAuthVaultPath } from '../../oauth-vault.js'; +import { + VAULT_ENCRYPTION_POLICY_ENV, + VAULT_PASSWORD_ENV, + VaultEncryptionError, + describeVaultSecrets, + hasVaultEncryptionMarker, + readVaultEncryptionSettings, +} from '../../oauth-vault-encryption.js'; import { MCPORTER_VERSION } from '../../version.js'; import { logConfigLocations, resolveConfigLocations } from './shared.js'; import type { ConfigCliOptions } from './types.js'; +// Mirrors the vault reader's shape check; anything else is repaired on the next write. +function isVaultDocument(document: unknown): document is { entries: Record } { + if (!document || typeof document !== 'object') return false; + const { version, entries } = document as { version?: unknown; entries?: unknown }; + return (version === 1 || version === 2) && !!entries && typeof entries === 'object'; +} + +async function reportVault(issues: string[], configuredPolicy: 'optional' | 'required' | undefined): Promise { + const vaultPath = getOAuthVaultPath(); + let stats: ReturnType | 'absent' | 'unreadable'; + let problem: string | undefined; + let encrypted = false; + try { + const document = await readJsonFile(vaultPath); + encrypted = hasVaultEncryptionMarker( + document && typeof document === 'object' ? (document as { encryption?: unknown }).encryption : undefined, + vaultPath + ); + if (document === undefined) { + stats = 'absent'; + } else if (isVaultDocument(document)) { + stats = describeVaultSecrets(document.entries); + } else { + // Same shape check as the vault reader, which treats this as needing repair. + stats = 'unreadable'; + problem = + 'The OAuth vault is not a valid OAuth vault document; mcporter will rewrite it on the next credential write.'; + } + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof VaultEncryptionError)) throw error; + stats = 'unreadable'; + problem = + error instanceof VaultEncryptionError + ? error.message + : 'The OAuth vault is not valid JSON; mcporter will rewrite it on the next credential write.'; + } + const label = + typeof stats === 'string' + ? stats + : `${stats.entries} entries, ${stats.sealed} sealed values, ${stats.plaintext} plaintext values`; + console.log(`OAuth vault: ${vaultPath} (${label})`); + if (problem) issues.push(problem); + try { + const settings = readVaultEncryptionSettings(process.env, configuredPolicy); + console.log( + `Vault encryption: policy ${settings.policy}, ${VAULT_PASSWORD_ENV} ${settings.password === undefined ? 'unset' : 'set'}` + ); + if (typeof stats === 'string') return; + if ((encrypted || stats.sealed > 0) && settings.password === undefined) { + issues.push( + `The OAuth vault ${encrypted ? 'is marked encrypted' : `holds ${stats.sealed} sealed values`} but ${VAULT_PASSWORD_ENV} is unset; OAuth-backed servers will fail until it is set.` + ); + } + if (stats.plaintext > 0 && settings.password !== undefined) { + const source = process.env[VAULT_ENCRYPTION_POLICY_ENV] + ? `${VAULT_ENCRYPTION_POLICY_ENV}=required` + : 'oauthVaultEncryption "required"'; + issues.push( + `The OAuth vault holds ${stats.plaintext} plaintext secret values${settings.policy === 'required' ? ` under ${source}` : ''}; they are sealed on the next vault write.` + ); + } + } catch (error) { + if (!(error instanceof VaultEncryptionError)) throw error; + issues.push(error.message); + } +} + +// A tokenCacheDir holds the same secrets as the vault; plaintext an older +// binary leaves there must show up here too. +async function reportTokenCaches( + issues: string[], + servers: readonly { name: string; tokenCacheDir?: string }[], + configuredPolicy: 'optional' | 'required' | undefined +): Promise { + let password: string | undefined; + try { + password = readVaultEncryptionSettings(process.env, configuredPolicy).password; + } catch (error) { + if (!(error instanceof VaultEncryptionError)) throw error; + // Already reported by reportVault. + } + for (const server of servers) { + if (!server.tokenCacheDir) continue; + const dir = server.tokenCacheDir; + let stats: Awaited>; + try { + stats = await describeTokenCacheDir(dir); + } catch (error) { + if (!(error instanceof VaultEncryptionError)) throw error; + issues.push(error.message); + continue; + } + const label = stats === 'absent' ? 'absent' : `${stats.sealed} sealed values, ${stats.plaintext} plaintext values`; + console.log(`Token cache ${dir} (${server.name}): ${label}`); + if (stats === 'absent') continue; + if ((stats.encrypted || stats.sealed > 0) && password === undefined) { + issues.push( + `Token cache ${dir} ${stats.encrypted ? 'is marked encrypted' : `holds ${stats.sealed} sealed values`} but ${VAULT_PASSWORD_ENV} is unset; '${server.name}' will fail until it is set.` + ); + } + if (stats.plaintext > 0 && password !== undefined) { + issues.push( + `Token cache ${dir} holds ${stats.plaintext} plaintext secret values; they are sealed on the next write.` + ); + } + } +} + export async function handleDoctorCommand(options: ConfigCliOptions, _args: string[]): Promise { console.log(`MCPorter ${MCPORTER_VERSION}`); - const configLocations = await resolveConfigLocations(options.loadOptions); - logConfigLocations(configLocations, { leadingNewline: false }); + logConfigLocations(await resolveConfigLocations(options.loadOptions), { leadingNewline: false }); console.log(''); - const servers = await loadServerDefinitions(options.loadOptions); const issues: string[] = []; - for (const server of servers) { + const snapshot = await loadConfigSnapshot(options.loadOptions); + for (const server of snapshot.servers) { if (server.command.kind === 'stdio' && !path.isAbsolute(server.command.cwd)) { issues.push(`Server '${server.name}' has a non-absolute working directory.`); } } + await reportVault(issues, snapshot.vaultEncryption); + await reportTokenCaches(issues, snapshot.servers, snapshot.vaultEncryption); if (issues.length === 0) { console.log('Config looks good.'); return; } console.log('Config issues detected:'); - for (const issue of issues) { - console.log(` - ${issue}`); - } + for (const issue of issues) console.log(` - ${issue}`); } diff --git a/src/cli/generate/definition.ts b/src/cli/generate/definition.ts index c72dfa46..ed2d76b7 100644 --- a/src/cli/generate/definition.ts +++ b/src/cli/generate/definition.ts @@ -11,7 +11,12 @@ import { type StdioCommand, } from '../../config.js'; import { normalizeServerEntry } from '../../config-normalize.js'; -import { RawEntrySchema, type ServerSource } from '../../config-schema.js'; +import { + RawEntrySchema, + type ServerSource, + type VaultEncryptionPolicy, + VaultEncryptionPolicySchema, +} from '../../config-schema.js'; import { parseJsonBuffer } from '../../config/imports/shared.js'; import { resolveLifecycle } from '../../lifecycle.js'; import type { Runtime, ServerToolInfo } from '../../runtime.js'; @@ -67,6 +72,7 @@ export async function resolveServerDefinition( const buffer = await fs.readFile(possiblePath, 'utf8'); const parsed = parseJsonBuffer(buffer) as { mcpServers?: Record; + oauthVaultEncryption?: unknown; }; if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') { throw new Error(`Config file ${possiblePath} does not contain mcpServers.`); @@ -81,17 +87,23 @@ export async function resolveServerDefinition( } const [name, value] = first; const raw = value as Record; + // The file's top-level policy applies to every server, as loadServerDefinitions does. + const policy = getVaultEncryptionPolicy(parsed.oauthVaultEncryption); + const withPolicy = (definition: ServerDefinition): ServerDefinition => + policy === undefined ? definition : { ...definition, oauthVaultEncryption: policy }; // Preserve the existing normalized-command and command-as-URL file forms. // Ordinary mcporter config entries use the same parsing/normalization as discovery. if ( (typeof raw.command === 'object' && !Array.isArray(raw.command)) || (typeof raw.command === 'string' && /^https?:\/\//i.test(raw.command)) ) { - return { definition: normalizeDefinition({ name, ...raw }), name }; + return { definition: withPolicy(normalizeDefinition({ name, ...raw })), name }; } const source: ServerSource = { kind: 'local', path: possiblePath }; return { - definition: normalizeServerEntry(name, RawEntrySchema.parse(value), path.dirname(possiblePath), source, [source]), + definition: withPolicy( + normalizeServerEntry(name, RawEntrySchema.parse(value), path.dirname(possiblePath), source, [source]) + ), name, }; } catch (error) { @@ -218,6 +230,7 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition { const oauthClientMetadataUrl = stringFromAliases(record, 'oauthClientMetadataUrl', 'oauth_client_metadata_url'); const oauthScope = stringFromAliases(record, 'oauthScope', 'oauth_scope'); const oauthRequestedScope = stringFromAliases(record, 'oauthRequestedScope', 'oauth_requested_scope'); + const oauthVaultEncryption = getVaultEncryptionPolicy(record.oauthVaultEncryption ?? record.oauth_vault_encryption); const refresh = getRefresh(record.refresh); const httpFetch = normalizeHttpFetch(stringFromAliases(record, 'httpFetch', 'http_fetch')); const headers = toStringRecord((def as Record).headers); @@ -249,6 +262,7 @@ export function normalizeDefinition(def: DefinitionInput): ServerDefinition { oauthCommand, refresh, httpFetch, + ...(oauthVaultEncryption === undefined ? {} : { oauthVaultEncryption }), lifecycle: resolveLifecycle(name, rawLifecycle, command), logging, ...(allowedTools !== undefined ? { allowedTools } : {}), @@ -437,6 +451,15 @@ function getChromeDevtoolsRelayPolicy(value: unknown): ServerDefinition['chromeD return value === 'off' || value === 'prefer' || value === 'require' ? value : undefined; } +// A generated CLI embeds its definition, so the policy must survive here or +// a `required` configuration silently becomes `optional` in the artifact. +function getVaultEncryptionPolicy(value: unknown): VaultEncryptionPolicy | undefined { + if (value === undefined) return undefined; + const parsed = VaultEncryptionPolicySchema.safeParse(value); + if (!parsed.success) throw new Error("oauthVaultEncryption must be 'optional' or 'required'."); + return parsed.data; +} + function stringFromAliases(record: Record, ...keys: string[]): string | undefined { for (const key of keys) { const value = record[key]; diff --git a/src/config-schema.ts b/src/config-schema.ts index ec0ed380..58e716b2 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -209,6 +209,9 @@ export const RawEntrySchema = z }) .describe('MCP server definition supporting both HTTP/SSE and stdio transports'); +export const VaultEncryptionPolicySchema = z.enum(['optional', 'required']); +export type VaultEncryptionPolicy = z.infer; + export const RawConfigSchema = z .object({ mcpServers: z.record(z.string(), RawEntrySchema).describe('Map of server names to their configurations'), @@ -224,6 +227,9 @@ export const RawConfigSchema = z .positive() .optional() .describe('Idle timeout in milliseconds before shutting down an inactive daemon'), + oauthVaultEncryption: VaultEncryptionPolicySchema.optional().describe( + 'Policy for the shared OAuth vault: "required" refuses every vault operation unless MCPORTER_VAULT_PASSWORD is set. MCPORTER_VAULT_ENCRYPTION overrides this key.' + ), imports: z .array(ImportKindSchema) .optional() @@ -287,6 +293,8 @@ export interface ServerDefinition { readonly env?: Record; readonly auth?: string; readonly tokenCacheDir?: string; + /** Vault encryption policy from the top-level config key; the env var overrides it. */ + readonly oauthVaultEncryption?: VaultEncryptionPolicy; readonly clientName?: string; readonly protocolVersion?: ProtocolVersion; readonly chromeDevtoolsRelay?: ChromeDevtoolsRelayPolicy; diff --git a/src/config.ts b/src/config.ts index 52410172..4e138ed2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,7 @@ import { RawEntrySchema, type ServerDefinition, type ServerSource, + type VaultEncryptionPolicy, } from './config-schema.js'; import { expandHome } from './env.js'; import { writeTextFileAtomic } from './fs-json.js'; @@ -36,6 +37,7 @@ export type { ServerLoggingOptions, ServerSource, StdioCommand, + VaultEncryptionPolicy, } from './config-schema.js'; async function buildServerDefinitions(layers: ConfigLayer[], rootDir: string): Promise { @@ -103,6 +105,10 @@ async function buildServerDefinitions(layers: ConfigLayer[], rootDir: string): P } } + const vaultPolicy = resolveVaultEncryptionPolicy(layers); + if (vaultPolicy !== undefined) { + for (const server of servers) Object.assign(server, { oauthVaultEncryption: vaultPolicy }); + } return servers; } @@ -121,9 +127,18 @@ function buildDaemonConfig(layers: ConfigLayer[]): DaemonConfig { return { idleTimeoutMs }; } +// Last layer that sets the key wins, like daemonIdleTimeoutMs. The value was +// validated by RawConfigSchema when the layer was read. +function resolveVaultEncryptionPolicy(layers: ConfigLayer[]): VaultEncryptionPolicy | undefined { + let policy: VaultEncryptionPolicy | undefined; + for (const layer of layers) policy = layer.config.oauthVaultEncryption ?? policy; + return policy; +} + export interface LoadedConfigSnapshot { readonly servers: ServerDefinition[]; readonly daemon: DaemonConfig; + readonly vaultEncryption: VaultEncryptionPolicy | undefined; } export async function loadConfigSnapshot(options: LoadConfigOptions = {}): Promise { @@ -132,6 +147,7 @@ export async function loadConfigSnapshot(options: LoadConfigOptions = {}): Promi return { servers: await buildServerDefinitions(layers, rootDir), daemon: buildDaemonConfig(layers), + vaultEncryption: resolveVaultEncryptionPolicy(layers), }; } diff --git a/src/daemon/connection-identity.ts b/src/daemon/connection-identity.ts index ea168b82..d3df38e4 100644 --- a/src/daemon/connection-identity.ts +++ b/src/daemon/connection-identity.ts @@ -10,6 +10,7 @@ import { import type { ServerDefinition } from '../config.js'; import { stableJsonStringify } from '../stable-json.js'; import { resolveCommandArgument, resolveCommandArguments } from '../runtime/utils.js'; +import { childEnvWithoutVaultSecrets } from '../oauth-vault-encryption.js'; // These are broker controls, never child inputs. Other environment differences remain meaningful. const BROKER_ENV = new Set([ @@ -37,10 +38,12 @@ export async function effectiveDefinition( inherited: NodeJS.ProcessEnv = process.env, purpose: 'connection' | 'view' = 'connection' ): Promise { + // The inherited environment loses the vault password and policy before it + // reaches any daemon-managed child; an explicit `env` entry still wins. const env = Object.fromEntries( - Object.entries(resolveChromeDevtoolsRelayEnvironment(definition.env, inherited)).filter( - ([key, value]) => value !== undefined && !BROKER_ENV.has(key) - ) + Object.entries( + resolveChromeDevtoolsRelayEnvironment(definition.env, childEnvWithoutVaultSecrets(inherited)) + ).filter(([key, value]) => value !== undefined && !BROKER_ENV.has(key)) ) as Record; const configuredEnv = Object.fromEntries( Object.keys(definition.env ?? {}) diff --git a/src/daemon/view-codec.ts b/src/daemon/view-codec.ts index a7f06b89..1a471525 100644 --- a/src/daemon/view-codec.ts +++ b/src/daemon/view-codec.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { RawEntrySchema, type ServerDefinition } from '../config-schema.js'; +import { RawEntrySchema, type ServerDefinition, VaultEncryptionPolicySchema } from '../config-schema.js'; import type { ResolvedServerDefinition } from './connection-identity.js'; const command = z.discriminatedUnion('kind', [ @@ -19,6 +19,8 @@ const definition = z .object({ ...RawEntrySchema.shape, name: z.string().min(1).max(256), + // Top-level config policy carried on each definition (not a RawEntry field). + oauthVaultEncryption: VaultEncryptionPolicySchema.optional(), configuredEnv: z.record(z.string(), z.string()).optional(), launchCommand: z.string().min(1).optional(), command, diff --git a/src/definition-fields.ts b/src/definition-fields.ts index 67ce3e17..847e3277 100644 --- a/src/definition-fields.ts +++ b/src/definition-fields.ts @@ -13,6 +13,7 @@ const SHARED_DEFINITION_FIELDS = [ 'oauthClientMetadataUrl', 'oauthScope', 'oauthRequestedScope', + 'oauthVaultEncryption', 'refresh', 'httpFetch', 'allowedTools', diff --git a/src/index.ts b/src/index.ts index 0e73ea09..2d5bf741 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ export type { OAuthSession, OAuthSessionOptions, } from './oauth.js'; +export { VaultEncryptionError, type VaultEncryptionErrorCode } from './oauth-vault-encryption.js'; export { callOnce, createRuntime } from './runtime.js'; export type { ElicitationContext, diff --git a/src/oauth-persistence-stores.ts b/src/oauth-persistence-stores.ts index e8d2e6e4..31602807 100644 --- a/src/oauth-persistence-stores.ts +++ b/src/oauth-persistence-stores.ts @@ -2,6 +2,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { OAuthClientInformationMixed, OAuthDiscoveryState, OAuthTokens } from '@modelcontextprotocol/client'; import type { ServerDefinition } from './config.js'; +import type { VaultEncryptionPolicy } from './config-schema.js'; import { readJsonFile, withFileLock, writeJsonFile, writeTextFileAtomic } from './fs-json.js'; import type { Logger } from './logging.js'; import { isStoredOAuthClientInformation, isStoredOAuthTokens } from './oauth-credential-validation.js'; @@ -24,6 +25,15 @@ import { reconcileVaultServerUrl, saveVaultEntry, } from './oauth-vault.js'; +import { + VAULT_PASSWORD_ENV, + VaultEncryptionError, + hasVaultEncryptionMarker, + isVaultSecretJwe, + openVaultSecret, + readVaultEncryptionSettings, + sealVaultSecret, +} from './oauth-vault-encryption.js'; import { runtimeHome } from './runtime/environment.js'; type StoredOAuthTokens = OAuthTokens & { @@ -49,6 +59,70 @@ export function prepareStoredTokens(tokens: OAuthTokens): OAuthTokens { return withStoredExpiry(withOAuthTokenGeneration(tokens)); } +const TOKEN_SECRET_FIELDS = ['access_token', 'refresh_token', 'id_token'] as const; +const CLIENT_SECRET_FIELDS = ['client_secret', 'registration_access_token'] as const; + +export interface TokenCacheSecretStats { + readonly sealed: number; + readonly plaintext: number; + readonly encrypted: boolean; +} + +// Counts the secret values in a tokenCacheDir without opening any of them, for +// `config doctor`; 'absent' when the directory does not exist. Unreadable +// files count as nothing, as the store itself treats them. +export async function describeTokenCacheDir(root: string): Promise { + try { + await fs.access(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'absent'; + throw error; + } + const marker = path.join(root, 'encryption.json'); + const stats = { sealed: 0, plaintext: 0, encrypted: await readEncryptionMarker(marker) }; + const count = (value: unknown) => { + if (typeof value !== 'string' || value.length === 0) return; + if (isVaultSecretJwe(value)) stats.sealed += 1; + else stats.plaintext += 1; + }; + const json = async (file: string) => { + try { + return await readJsonFile(path.join(root, file)); + } catch (error) { + if (error instanceof SyntaxError) return undefined; + throw error; + } + }; + for (const [file, fields] of [ + ['tokens.json', TOKEN_SECRET_FIELDS], + ['client.json', CLIENT_SECRET_FIELDS], + ] as const) { + const record = await json(file); + if (record && typeof record === 'object') { + for (const field of fields) count((record as Record)[field]); + } + } + count(await json('state.txt')); + try { + count((await fs.readFile(path.join(root, 'code_verifier.txt'), 'utf8')).trim()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + return stats; +} + +async function readEncryptionMarker(file: string): Promise { + try { + return hasVaultEncryptionMarker(await readJsonFile(file), file); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + throw new VaultEncryptionError( + 'payload_invalid', + `Invalid OAuth encryption marker at ${file}; nothing was changed.` + ); + } +} + export class DirectoryPersistence implements OAuthPersistence { private readonly tokenPath: string; private readonly clientInfoPath: string; @@ -63,7 +137,8 @@ export class DirectoryPersistence implements OAuthPersistence { private readonly root: string, private readonly logger?: Logger, private readonly serverUrl?: string, - private readonly skipUrlMarkerWhenMissing = false + private readonly skipUrlMarkerWhenMissing = false, + private readonly vaultPolicy?: VaultEncryptionPolicy ) { this.tokenPath = path.join(root, 'tokens.json'); this.clientInfoPath = path.join(root, 'client.json'); @@ -83,7 +158,132 @@ export class DirectoryPersistence implements OAuthPersistence { await fs.mkdir(this.root, { recursive: true }); } + private password(): string | undefined { + return readVaultEncryptionSettings(process.env, this.vaultPolicy).password; + } + + private async checkEncryptionMarker(): Promise { + if (await readEncryptionMarker(path.join(this.root, 'encryption.json'))) this.requirePassword(); + } + + private async markEncrypted(): Promise { + await this.checkEncryptionMarker(); + if (this.password() !== undefined) { + await writeJsonFile(path.join(this.root, 'encryption.json'), { version: 1 }); + } + } + + // Seals the named plaintext string fields of a record when a password is set. + private async sealFields(record: T, file: string, fields: readonly string[]): Promise { + const password = this.password(); + if (password === undefined) return record; + const copy: Record = { ...(record as Record) }; + for (const field of fields) { + const value = copy[field]; + if (typeof value === 'string' && value.length > 0 && !isVaultSecretJwe(value)) { + copy[field] = await sealVaultSecret(value, `${file}/${field}`, password); + } + } + return copy as T; + } + + // Every write goes through here, under the tokens.json lock: without a + // password a directory that holds sealed values is refused and left as it + // is, as the vault is; with one, the files this write does not touch are + // sealed too, so the first write seals the whole directory. + private async save(written: string, write: () => Promise): Promise { + await this.reconcileServerUrl(); + await this.ensureDir(); + await withFileLock(this.tokenPath, async () => { + if (this.password() === undefined && (await this.holdsSealedValues())) this.requirePassword(); + await this.markEncrypted(); + await write(); + await this.sealOtherFiles(written); + }); + } + + private async holdsSealedValues(): Promise { + for (const [file, fields] of [ + [this.tokenPath, TOKEN_SECRET_FIELDS], + [this.clientInfoPath, CLIENT_SECRET_FIELDS], + ] as const) { + const record = await this.readJsonOrUndefined>(file); + if (record && typeof record === 'object' && fields.some((field) => isVaultSecretJwe(record[field]))) return true; + } + return ( + isVaultSecretJwe(await this.readTextAfterReconcile(this.codeVerifierPath)) || + isVaultSecretJwe(await this.readJsonOrUndefined(this.statePath)) + ); + } + + // The secret files this save did not write are sealed too. + private async sealOtherFiles(written: string): Promise { + if (this.password() === undefined) return; + if (written !== this.tokenPath) await this.sealJsonFile(this.tokenPath, 'tokens.json', TOKEN_SECRET_FIELDS); + if (written !== this.clientInfoPath) { + await this.sealJsonFile(this.clientInfoPath, 'client.json', CLIENT_SECRET_FIELDS); + } + if (written !== this.codeVerifierPath) { + const verifier = await this.readTextAfterReconcile(this.codeVerifierPath); + if (verifier && !isVaultSecretJwe(verifier)) { + await writeTextFileAtomic(this.codeVerifierPath, await this.sealValue(verifier, 'code_verifier.txt')); + } + } + if (written !== this.statePath) { + const state = await this.readJsonOrUndefined(this.statePath); + if (typeof state === 'string' && state && !isVaultSecretJwe(state)) { + await writeJsonFile(this.statePath, await this.sealValue(state, 'state.txt')); + } + } + } + + private async sealJsonFile(file: string, name: string, fields: readonly string[]): Promise { + const record = await this.readJsonOrUndefined>(file); + if (!record || typeof record !== 'object') return; + const sealed = await this.sealFields(record, name, fields); + if (fields.some((field) => sealed[field] !== record[field])) await writeJsonFile(file, sealed); + } + + // Opens the named fields that are sealed; plaintext fields are left as they are. + private async openFields( + record: T | undefined, + file: string, + fields: readonly string[] + ): Promise { + if (!record) return record; + const copy: Record = { ...(record as Record) }; + for (const field of fields) { + if (!isVaultSecretJwe(copy[field])) continue; + copy[field] = await openVaultSecret(copy[field] as string, `${file}/${field}`, this.requirePassword()); + } + return copy as T; + } + + private async sealValue(value: string, kid: string): Promise { + const password = this.password(); + return password === undefined ? value : await sealVaultSecret(value, kid, password); + } + + private async openValue(value: string | undefined, kid: string): Promise { + return isVaultSecretJwe(value) ? await openVaultSecret(value, kid, this.requirePassword()) : value; + } + + private requirePassword(): string { + const password = this.password(); + if (password === undefined) { + throw new VaultEncryptionError( + 'password_missing', + `${this.root} holds encrypted values; set ${VAULT_PASSWORD_ENV} to use it.` + ); + } + return password; + } + private async reconcileServerUrl(): Promise { + // Every operation starts here: a `required` policy without a password + // refuses the directory, as readVaultState refuses the vault. + readVaultEncryptionSettings(process.env, this.vaultPolicy); + await this.checkEncryptionMarker(); if (!this.serverUrl) { return; } @@ -112,10 +312,13 @@ export class DirectoryPersistence implements OAuthPersistence { return; } // Keep the marker after invalidation so A -> B -> A cannot revive A's - // old directory-backed credentials. + // old directory-backed credentials. Without a password a sealed + // directory is refused instead of wiped, as the vault is. if (previousUrl !== undefined) { + if (this.password() === undefined && (await this.holdsSealedValues())) this.requirePassword(); await this.clearFiles('all'); } + await this.markEncrypted(); await writeTextFileAtomic(this.serverUrlPath, serverUrl); }); } @@ -141,17 +344,22 @@ export class DirectoryPersistence implements OAuthPersistence { } private async readTokensAfterReconcile(): Promise { - const tokens = await this.readJsonOrUndefined(this.tokenPath); + const tokens = await this.openFields( + await this.readJsonOrUndefined(this.tokenPath), + 'tokens.json', + TOKEN_SECRET_FIELDS + ); return isStoredOAuthTokens(tokens) ? withHiddenOAuthTokenGeneration(tokens) : undefined; } async saveTokens(tokens: OAuthTokens): Promise { - await this.reconcileServerUrl(); - await this.ensureDir(); - // Locked so clearRejectedCredentials cannot compare-then-unlink across a - // concurrent write. - await withFileLock(this.tokenPath, async () => { - await writeJsonFile(this.tokenPath, prepareStoredTokens(tokens)); + // Locked (in save) so clearRejectedCredentials cannot compare-then-unlink + // across a concurrent write. + await this.save(this.tokenPath, async () => { + await writeJsonFile( + this.tokenPath, + await this.sealFields(prepareStoredTokens(tokens), 'tokens.json', TOKEN_SECRET_FIELDS) + ); }); this.logger?.debug?.(`Saved tokens to ${this.tokenPath}`); } @@ -162,21 +370,40 @@ export class DirectoryPersistence implements OAuthPersistence { ): Promise { await this.reconcileServerUrl(); await withFileLock(this.tokenPath, async () => { + const files: string[] = []; if (expectedTokens) { - const current = await this.readJsonOrUndefined(this.tokenPath); + const current = await this.openFields( + await this.readJsonOrUndefined(this.tokenPath), + 'tokens.json', + TOKEN_SECRET_FIELDS + ); if (sameOAuthTokenGeneration(current, expectedTokens)) { - await this.unlinkIfPresent(this.tokenPath); + files.push(this.tokenPath); } } if (expectedClientInfo) { - const currentClientInfo = await this.readJsonOrUndefined(this.clientInfoPath); + const currentClientInfo = await this.openFields( + await this.readJsonOrUndefined(this.clientInfoPath), + 'client.json', + CLIENT_SECRET_FIELDS + ); if (sameOAuthClientGeneration(currentClientInfo, expectedClientInfo)) { - await this.unlinkIfPresent(this.clientInfoPath); + files.push(this.clientInfoPath); } } + await this.removeFiles(files); }); } + // Every deletion path calls this while holding the token lock. Persist the + // policy before the last secret disappears, including conditional recovery. + private async removeFiles(files: readonly string[]): Promise { + if (files.length === 0) return; + if (this.password() === undefined && (await this.holdsSealedValues())) this.requirePassword(); + await this.markEncrypted(); + await Promise.all(files.map((file) => this.unlinkIfPresent(file))); + } + private async unlinkIfPresent(file: string): Promise { try { await fs.unlink(file); @@ -193,15 +420,20 @@ export class DirectoryPersistence implements OAuthPersistence { } private async readClientInfoAfterReconcile(): Promise { - const info = await this.readJsonOrUndefined(this.clientInfoPath); + const info = await this.openFields( + await this.readJsonOrUndefined(this.clientInfoPath), + 'client.json', + CLIENT_SECRET_FIELDS + ); return isStoredOAuthClientInformation(info) ? withHiddenOAuthClientGeneration(info) : undefined; } async saveClientInfo(info: OAuthClientInformationMixed): Promise { - await this.reconcileServerUrl(); - await this.ensureDir(); - await withFileLock(this.tokenPath, async () => { - await writeJsonFile(this.clientInfoPath, withOAuthClientGeneration(info)); + await this.save(this.clientInfoPath, async () => { + await writeJsonFile( + this.clientInfoPath, + await this.sealFields(withOAuthClientGeneration(info), 'client.json', CLIENT_SECRET_FIELDS) + ); }); } @@ -212,7 +444,7 @@ export class DirectoryPersistence implements OAuthPersistence { private async readCodeVerifierAfterReconcile(): Promise { try { - return (await fs.readFile(this.codeVerifierPath, 'utf8')).trim(); + return await this.openValue((await fs.readFile(this.codeVerifierPath, 'utf8')).trim(), 'code_verifier.txt'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return undefined; @@ -222,9 +454,9 @@ export class DirectoryPersistence implements OAuthPersistence { } async saveCodeVerifier(value: string): Promise { - await this.reconcileServerUrl(); - await this.ensureDir(); - await writeTextFileAtomic(this.codeVerifierPath, value); + await this.save(this.codeVerifierPath, async () => { + await writeTextFileAtomic(this.codeVerifierPath, await this.sealValue(value, 'code_verifier.txt')); + }); } async readState(): Promise { @@ -237,7 +469,7 @@ export class DirectoryPersistence implements OAuthPersistence { // flow closed. Returning undefined here would skip the CSRF state check on // the authorization callback (see oauth.ts), so only the credential caches // (tokens/client) degrade to re-auth. - return readJsonFile(this.statePath); + return await this.openValue(await readJsonFile(this.statePath), 'state.txt'); } // A present-but-corrupt credential cache (tokens/client) means "no usable @@ -258,9 +490,9 @@ export class DirectoryPersistence implements OAuthPersistence { } async saveState(value: string): Promise { - await this.reconcileServerUrl(); - await this.ensureDir(); - await writeJsonFile(this.statePath, value); + await this.save(this.statePath, async () => { + await writeJsonFile(this.statePath, await this.sealValue(value, 'state.txt')); + }); } async readDiscoveryState(): Promise { @@ -273,9 +505,7 @@ export class DirectoryPersistence implements OAuthPersistence { } async saveDiscoveryState(value: OAuthDiscoveryState): Promise { - await this.reconcileServerUrl(); - await this.ensureDir(); - await writeJsonFile(this.discoveryStatePath, value); + await this.save(this.discoveryStatePath, () => writeJsonFile(this.discoveryStatePath, value)); } async readAuthorizationServerUrl(): Promise { @@ -284,9 +514,7 @@ export class DirectoryPersistence implements OAuthPersistence { } async saveAuthorizationServerUrl(value: string): Promise { - await this.reconcileServerUrl(); - await this.ensureDir(); - await writeTextFileAtomic(this.authorizationServerUrlPath, value); + await this.save(this.authorizationServerUrlPath, () => writeTextFileAtomic(this.authorizationServerUrlPath, value)); } async readResourceUrl(): Promise { @@ -295,9 +523,7 @@ export class DirectoryPersistence implements OAuthPersistence { } async saveResourceUrl(value: string): Promise { - await this.reconcileServerUrl(); - await this.ensureDir(); - await writeTextFileAtomic(this.resourceUrlPath, value); + await this.save(this.resourceUrlPath, () => writeTextFileAtomic(this.resourceUrlPath, value)); } private async readTextAfterReconcile(filePath: string): Promise { @@ -311,7 +537,12 @@ export class DirectoryPersistence implements OAuthPersistence { async clear(scope: OAuthClearScope): Promise { await this.reconcileServerUrl(); - await this.clearFiles(scope); + // Same lock as the saves, so a first-sealing sweep cannot write a file + // back after this unlinked it. Deleting is a modification: without a + // password a sealed directory is refused here too, as the vault is. + await withFileLock(this.tokenPath, async () => { + await this.clearFiles(scope); + }); } private async clearFiles(scope: OAuthClearScope): Promise { @@ -331,7 +562,7 @@ export class DirectoryPersistence implements OAuthPersistence { if (scope === 'all' || scope === 'discovery') { files.push(this.discoveryStatePath, this.authorizationServerUrlPath, this.resourceUrlPath); } - await Promise.all(files.map((file) => this.unlinkIfPresent(file))); + await this.removeFiles(files); } } @@ -556,6 +787,11 @@ export class CompositePersistence implements OAuthPersistence { snapshots: Map ): Promise<{ value: T | undefined; source: OAuthPersistence | undefined }> { const results = await Promise.allSettled(this.stores.map((store) => read(store))); + for (const result of results) { + // A policy or password refusal is about this process, not one store: a + // plaintext primary cache must not hide it. + if (result.status === 'rejected' && result.reason instanceof VaultEncryptionError) throw result.reason; + } snapshots.clear(); let value: T | undefined; let source: OAuthPersistence | undefined; @@ -658,13 +894,15 @@ export async function createOAuthPersistenceStores( const serverUrl = definition.command.kind === 'http' ? definition.command.url.toString() : undefined; if (definition.tokenCacheDir) { - stores.unshift(new DirectoryPersistence(definition.tokenCacheDir, logger, serverUrl)); + stores.unshift( + new DirectoryPersistence(definition.tokenCacheDir, logger, serverUrl, false, definition.oauthVaultEncryption) + ); } // Migrate legacy default per-server cache (~/.mcporter/) into the vault if present. const legacyDir = path.join(path.join(runtimeHome(), '.mcporter'), definition.name); if (!definition.tokenCacheDir) { - const legacy = new DirectoryPersistence(legacyDir, logger, serverUrl, true); + const legacy = new DirectoryPersistence(legacyDir, logger, serverUrl, true, definition.oauthVaultEncryption); const snapshot = await legacy.readSnapshot(); if ( snapshot.tokens || @@ -697,6 +935,11 @@ export async function createOAuthPersistenceStores( await vault.saveResourceUrl(snapshot.resourceUrl); } logger?.info?.(`Migrated legacy OAuth cache for '${definition.name}' into vault.`); + // With a password set the legacy plaintext files are removed once they + // are in the vault; without one they are kept as before. + if (readVaultEncryptionSettings(process.env, definition.oauthVaultEncryption).password !== undefined) { + await legacy.clear('all'); + } } } @@ -712,7 +955,7 @@ export async function clearLegacyOAuthArtifacts( ): Promise { const legacyDir = path.join(path.join(runtimeHome(), '.mcporter'), definition.name); if (!definition.tokenCacheDir || legacyDir !== definition.tokenCacheDir) { - const legacy = new DirectoryPersistence(legacyDir, logger); + const legacy = new DirectoryPersistence(legacyDir, logger, undefined, false, definition.oauthVaultEncryption); await legacy.clear(scope); } diff --git a/src/oauth-token-refresh.ts b/src/oauth-token-refresh.ts index b12c4094..48ad5d17 100644 --- a/src/oauth-token-refresh.ts +++ b/src/oauth-token-refresh.ts @@ -14,6 +14,7 @@ import { resourceUrlFromServerUrl, } from '@modelcontextprotocol/client'; import type { ServerDefinition } from './config.js'; +import { VaultEncryptionError } from './oauth-vault-encryption.js'; import { isFileLockTimeoutError } from './fs-json.js'; import type { Logger } from './logging.js'; import { buildStaticClientInformation, resolveOAuthClientSecret } from './oauth-client-info.js'; @@ -376,6 +377,11 @@ async function refreshCachedOAuthTokenUnderLock( logger?.debug?.(`Refreshed cached OAuth access token for '${definition.name}' (non-interactive).`); return refreshed.access_token; } catch (error) { + if (error instanceof VaultEncryptionError) { + // A refused vault write after a successful refresh would otherwise return + // the stale token and lose the refresh token the server just rotated. + throw error; + } logger?.debug?.( `Failed to refresh cached OAuth token for '${definition.name}' non-interactively: ${ error instanceof Error ? error.message : String(error) @@ -519,6 +525,9 @@ async function refreshBearerTokenUnderLock( logger?.debug?.(`Refreshed bearer access token for '${definition.name}' (non-interactive).`); return refreshed.access_token; } catch (error) { + if (error instanceof VaultEncryptionError) { + throw error; + } logger?.debug?.( `Failed to refresh bearer token for '${definition.name}' non-interactively: ${ error instanceof Error ? error.message : String(error) diff --git a/src/oauth-vault-encryption.ts b/src/oauth-vault-encryption.ts new file mode 100644 index 00000000..5274c687 --- /dev/null +++ b/src/oauth-vault-encryption.ts @@ -0,0 +1,216 @@ +import { CompactEncrypt, compactDecrypt, decodeProtectedHeader, errors as joseErrors } from 'jose'; +import type { VaultEncryptionPolicy } from './config-schema.js'; + +export type { VaultEncryptionPolicy }; + +export const VAULT_PASSWORD_ENV = 'MCPORTER_VAULT_PASSWORD'; +export const VAULT_ENCRYPTION_POLICY_ENV = 'MCPORTER_VAULT_ENCRYPTION'; +export const VAULT_SECRET_ENV_NAMES = [VAULT_PASSWORD_ENV, VAULT_ENCRYPTION_POLICY_ENV] as const; +export const VAULT_JWE_ALG = 'PBES2-HS512+A256KW'; +export const VAULT_JWE_ENC = 'A256GCM'; +// RFC 7516 §4.1.11: tells vault ciphertext apart from any other string. +export const VAULT_JWE_TYP = 'mcporter-vault+jwe'; +// 99designs/keyring default (about 2 ms per value); readers accept up to the ceiling. +export const VAULT_PBES2_ITERATIONS = 8192; +export const VAULT_PBES2_MAX_ITERATIONS = 100_000; +// 32, not a complexity rule: at 8192 PBKDF2 iterations the passphrase's +// randomness is the whole defense, and 32 characters nudges toward a generated +// value (`openssl rand -base64 32` yields 44) rather than a memorable one. +export const MIN_VAULT_PASSWORD_LENGTH = 32; +// The only values ever sealed; a test checks this list against VaultEntry. +// `id_token` carries identity claims in clear and secret scanners flag it; +// `registration_access_token` is the RFC 7592 bearer secret for the client record. +export const VAULT_SECRET_FIELDS: readonly (readonly string[])[] = [ + ['tokens', 'access_token'], + ['tokens', 'refresh_token'], + ['tokens', 'id_token'], + ['clientInfo', 'client_secret'], + ['clientInfo', 'registration_access_token'], + ['codeVerifier'], + ['state'], +]; + +export type VaultEncryptionErrorCode = + | 'password_missing' + | 'password_invalid' + | 'policy_invalid' + | 'decrypt_failed' + | 'payload_invalid'; + +export class VaultEncryptionError extends Error { + constructor( + readonly code: VaultEncryptionErrorCode, + message: string + ) { + super(message); + this.name = 'VaultEncryptionError'; + } +} + +// Separate from the legacy document version: old readers repair unknown vault +// versions, so bumping that number would destroy rather than protect the store. +export function hasVaultEncryptionMarker(marker: unknown, location: string): boolean { + if (marker === undefined) return false; + if (!marker || typeof marker !== 'object' || (marker as { version?: unknown }).version !== 1) { + throw new VaultEncryptionError( + 'payload_invalid', + `Unsupported OAuth encryption format at ${location}; use a compatible mcporter version. Nothing was changed.` + ); + } + return true; +} + +export interface VaultEncryptionSettings { + readonly policy: VaultEncryptionPolicy; + readonly password: string | undefined; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const COMPACT_JWE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; + +function readPassword(env: NodeJS.ProcessEnv, name: string): string | undefined { + const value = env[name]; + if (value === undefined) return undefined; + if (value.length < MIN_VAULT_PASSWORD_LENGTH) { + throw new VaultEncryptionError( + 'password_invalid', + `${name} must be at least ${MIN_VAULT_PASSWORD_LENGTH} characters and should be generated, not chosen; use \`openssl rand -base64 32\`.` + ); + } + return value; +} + +export function readVaultEncryptionSettings( + env: NodeJS.ProcessEnv = process.env, + configuredPolicy?: VaultEncryptionPolicy +): VaultEncryptionSettings { + const raw = (env[VAULT_ENCRYPTION_POLICY_ENV] ?? '').trim().toLowerCase(); + if (raw !== '' && raw !== 'optional' && raw !== 'required') { + throw new VaultEncryptionError( + 'policy_invalid', + `${VAULT_ENCRYPTION_POLICY_ENV} must be 'optional' or 'required'.` + ); + } + // The env var wins when set; otherwise the config key (validated by RawConfigSchema); otherwise optional. + const policy: VaultEncryptionPolicy = raw !== '' ? (raw as VaultEncryptionPolicy) : (configuredPolicy ?? 'optional'); + const password = readPassword(env, VAULT_PASSWORD_ENV); + if (policy === 'required' && password === undefined) { + throw new VaultEncryptionError( + 'password_missing', + `${raw !== '' ? `${VAULT_ENCRYPTION_POLICY_ENV}=required` : 'oauthVaultEncryption is "required"'} but ${VAULT_PASSWORD_ENV} is not set; the OAuth vault cannot be used.` + ); + } + return { policy, password }; +} + +// Spawned children have no use for the vault password. +export function childEnvWithoutVaultSecrets(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const copy: NodeJS.ProcessEnv = { ...env }; + for (const name of VAULT_SECRET_ENV_NAMES) delete copy[name]; + return copy; +} + +export function vaultSecretKid(vaultKey: string, path: readonly string[]): string { + return `${vaultKey}/${path.join('/')}`; +} + +function header(token: unknown): ReturnType | undefined { + if (typeof token !== 'string' || !COMPACT_JWE.test(token)) return undefined; + try { + return decodeProtectedHeader(token); + } catch { + return undefined; + } +} + +export function isVaultSecretJwe(value: unknown): value is string { + return header(value)?.typ === VAULT_JWE_TYP; +} + +export async function sealVaultSecret(plaintext: string, kid: string, password: string): Promise { + const token = await new CompactEncrypt(encoder.encode(plaintext)) + .setProtectedHeader({ alg: VAULT_JWE_ALG, enc: VAULT_JWE_ENC, typ: VAULT_JWE_TYP, kid }) + .setKeyManagementParameters({ p2c: VAULT_PBES2_ITERATIONS }) + .encrypt(encoder.encode(password)); + // Verified write: a codec regression can never persist an unreadable value. + if ((await openVaultSecret(token, kid, password)) !== plaintext) { + throw new VaultEncryptionError('payload_invalid', `OAuth vault self-check failed for ${kid}; nothing was written.`); + } + return token; +} + +async function openWith(token: string, password: string): Promise { + try { + const { plaintext } = await compactDecrypt(token, encoder.encode(password), { + keyManagementAlgorithms: [VAULT_JWE_ALG], + contentEncryptionAlgorithms: [VAULT_JWE_ENC], + maxPBES2Count: VAULT_PBES2_MAX_ITERATIONS, + }); + return decoder.decode(plaintext); + } catch (error) { + if (error instanceof joseErrors.JWEDecryptionFailed) return undefined; + if (error instanceof joseErrors.JOSEError) { + throw new VaultEncryptionError( + 'payload_invalid', + 'An OAuth vault value is not a JWE mcporter can read; refusing to use it.' + ); + } + throw error; + } +} + +export async function openVaultSecret(token: string, kid: string, password: string): Promise { + // Everything about the envelope is decided from the header before a PBKDF2 round. + const h = header(token); + if ( + !h || + h.alg !== VAULT_JWE_ALG || + h.enc !== VAULT_JWE_ENC || + h.typ !== VAULT_JWE_TYP || + typeof h.p2c !== 'number' || + h.p2c < 1 || + h.p2c > VAULT_PBES2_MAX_ITERATIONS + ) { + throw new VaultEncryptionError( + 'payload_invalid', + `OAuth vault value ${kid} is not in the vault JWE profile; refusing to use it.` + ); + } + if (h.kid !== kid) { + throw new VaultEncryptionError( + 'payload_invalid', + `OAuth vault value ${kid} carries another identity; refusing to use it.` + ); + } + const value = await openWith(token, password); + if (value !== undefined) return value; + throw new VaultEncryptionError( + 'decrypt_failed', + `Cannot decrypt OAuth vault value ${kid} with ${VAULT_PASSWORD_ENV}; the file was left untouched.` + ); +} + +export function readPath(node: unknown, path: readonly string[]): unknown { + return path.reduce( + (cur, key) => (cur && typeof cur === 'object' ? (cur as Record)[key] : undefined), + node + ); +} + +export function describeVaultSecrets(entries: unknown): { entries: number; sealed: number; plaintext: number } { + const stats = { entries: 0, sealed: 0, plaintext: 0 }; + if (!entries || typeof entries !== 'object') return stats; + for (const entry of Object.values(entries as Record)) { + if (!entry || typeof entry !== 'object' || typeof (entry as { serverName?: unknown }).serverName !== 'string') + continue; + stats.entries += 1; + for (const path of VAULT_SECRET_FIELDS) { + const value = readPath(entry, path); + if (typeof value !== 'string') continue; + if (isVaultSecretJwe(value)) stats.sealed += 1; + else stats.plaintext += 1; + } + } + return stats; +} diff --git a/src/oauth-vault.ts b/src/oauth-vault.ts index 468c6581..e4a05df7 100644 --- a/src/oauth-vault.ts +++ b/src/oauth-vault.ts @@ -3,6 +3,18 @@ import path from 'node:path'; import type { OAuthClientInformationMixed, OAuthDiscoveryState, OAuthTokens } from '@modelcontextprotocol/client'; import type { ServerDefinition } from './config.js'; import { readJsonFile, withFileLock, writeJsonFile } from './fs-json.js'; +import { + VAULT_PASSWORD_ENV, + VAULT_SECRET_FIELDS, + VaultEncryptionError, + hasVaultEncryptionMarker, + isVaultSecretJwe, + openVaultSecret, + readPath, + readVaultEncryptionSettings, + sealVaultSecret, + vaultSecretKid, +} from './oauth-vault-encryption.js'; import { isStoredOAuthClientInformation, isStoredOAuthTokens } from './oauth-credential-validation.js'; import { sameOAuthClientGeneration, @@ -33,6 +45,7 @@ export interface VaultEntry { interface VaultFile { version: 1 | 2; + encryption?: { version: 1 }; entries: Record; serverUrls?: Record; } @@ -58,19 +71,35 @@ export function getOAuthVaultPath(): string { return path.join(runtimeStateDir('data'), 'credentials.json'); } -async function readVaultState(): Promise { +// Which entries' sealed values a read needs opened: the definition's own +// credentials (its key plus same-URL legacy rename candidates), or none for +// operations that only delete or reconcile metadata. +type VaultOpenScope = 'credentials' | 'none'; + +async function readVaultState( + definition: ServerDefinition, + scope: VaultOpenScope = 'credentials' +): Promise { + // Throws under a `required` policy without a password, even when no vault + // file exists yet: the policy is about this process, not the file. + const settings = readVaultEncryptionSettings(process.env, definition.oauthVaultEncryption); try { const existing = await readJsonFile(getOAuthVaultPath()); + if (hasVaultEncryptionMarker(existing?.encryption, getOAuthVaultPath()) && settings.password === undefined) { + throw new VaultEncryptionError( + 'password_missing', + `The OAuth vault at ${getOAuthVaultPath()} requires ${VAULT_PASSWORD_ENV}, even when empty.` + ); + } if ( existing && (existing.version === 1 || existing.version === 2) && existing.entries && typeof existing.entries === 'object' ) { - return { - vault: { ...existing, version: 2 } as VaultFile, - needsRepair: existing.version !== 2, - }; + const vault = { ...existing, version: 2 } as VaultFile; + await openVaultSecrets(vault, definition, scope); + return { vault, needsRepair: existing.version !== 2 }; } if (existing !== undefined) { return { vault: emptyVault(), needsRepair: true }; @@ -84,16 +113,84 @@ async function readVaultState(): Promise { return { vault: emptyVault(), needsRepair: false }; } -async function readVault(): Promise { - return (await readVaultState()).vault; +async function readVault(definition: ServerDefinition, scope: VaultOpenScope = 'credentials'): Promise { + return (await readVaultState(definition, scope)).vault; } function emptyVault(): VaultFile { return { version: 2, entries: {} }; } -async function writeVault(contents: VaultFile): Promise { - await writeJsonFile(getOAuthVaultPath(), contents); +async function writeVault(contents: VaultFile, definition: ServerDefinition): Promise { + const settings = readVaultEncryptionSettings(process.env, definition.oauthVaultEncryption); + await writeJsonFile( + getOAuthVaultPath(), + settings.password === undefined ? contents : await sealVaultSecrets(contents, settings.password) + ); +} + +function writePath(node: Record, fieldPath: readonly string[], value: string): void { + let current = node; + for (const key of fieldPath.slice(0, -1)) current = current[key] as Record; + current[fieldPath.at(-1) as string] = value; +} + +// Opens the sealed values the caller needs, in place. Other entries keep their +// ciphertext and are written back unchanged, so one value sealed under another +// password cannot block every server. Without a password, any sealed value +// anywhere is a typed error before any decryption; never a reset. +async function openVaultSecrets(vault: VaultFile, definition: ServerDefinition, scope: VaultOpenScope): Promise { + const settings = readVaultEncryptionSettings(process.env, definition.oauthVaultEncryption); + if (settings.password === undefined) { + for (const entry of Object.values(vault.entries)) { + if ( + isVaultEntry(entry) && + VAULT_SECRET_FIELDS.some((fieldPath) => isVaultSecretJwe(readPath(entry, fieldPath))) + ) { + throw new VaultEncryptionError( + 'password_missing', + `The OAuth vault at ${getOAuthVaultPath()} holds encrypted values for '${entry.serverName}'; set ${VAULT_PASSWORD_ENV} to use it.` + ); + } + } + return; + } + if (scope === 'none') return; + const exactKey = vaultKeyForDefinition(definition); + for (const key of new Set([exactKey, ...legacyOAuthRenameKeys(vault, definition, exactKey)])) { + const entry = vault.entries[key]; + if (!isVaultEntry(entry)) continue; + for (const fieldPath of VAULT_SECRET_FIELDS) { + const value = readPath(entry, fieldPath); + if (!isVaultSecretJwe(value)) continue; + writePath( + entry as unknown as Record, + fieldPath, + await openVaultSecret(value, vaultSecretKid(key, fieldPath), settings.password) + ); + } + } +} + +// Seals every plaintext secret string when a password is set. Values that are +// still sealed (entries the read did not open) pass through byte-identical. +async function sealVaultSecrets(vault: VaultFile, password: string): Promise { + const out: VaultFile = { ...vault, encryption: { version: 1 }, entries: {} }; + for (const [key, entry] of Object.entries(vault.entries)) { + if (!isVaultEntry(entry)) { + out.entries[key] = entry; + continue; + } + const copy = structuredClone(entry) as unknown as Record; + for (const fieldPath of VAULT_SECRET_FIELDS) { + const value = readPath(copy, fieldPath); + if (typeof value === 'string' && value.length > 0 && !isVaultSecretJwe(value)) { + writePath(copy, fieldPath, await sealVaultSecret(value, vaultSecretKid(key, fieldPath), password)); + } + } + out.entries[key] = copy as unknown as VaultEntry; + } + return out; } export function vaultKeyForDefinition(definition: ServerDefinition): VaultKey { @@ -118,7 +215,7 @@ export async function reconcileVaultServerUrl(definition: ServerDefinition): Pro } const serverUrl = definition.command.url.toString(); await withFileLock(getOAuthVaultPath(), async () => { - const { vault, needsRepair } = await readVaultState(); + const { vault, needsRepair } = await readVaultState(definition, 'none'); const serverUrls = stringRecord(vault.serverUrls); const previousUrl = serverUrls[definition.name]; const namedEntryUrls = Object.values(vault.entries) @@ -146,7 +243,7 @@ export async function reconcileVaultServerUrl(definition: ServerDefinition): Pro return; } vault.serverUrls = { ...serverUrls, [definition.name]: serverUrl }; - await writeVault(vault); + await writeVault(vault, definition); }); } @@ -175,17 +272,17 @@ export async function vaultCredentialKeys(definition: ServerDefinition): Promise if (definition.command.kind !== 'http') { return [key]; } - const vault = await readVault(); + const vault = await readVault(definition, 'none'); return [...new Set([key, ...legacyOAuthRenameKeys(vault, definition, key)])]; } export async function loadVaultEntry(definition: ServerDefinition): Promise { - const vault = await readVault(); + const vault = await readVault(definition); return externalVaultEntry(resolveVaultEntry(vault, definition)); } export async function loadVaultEntryForRecovery(definition: ServerDefinition): Promise { - const vault = await readVault(); + const vault = await readVault(definition); const key = vaultKeyForDefinition(definition); const resolved = resolveVaultEntry(vault, definition); const tokenSnapshots = new Map(); @@ -326,7 +423,7 @@ function clientIdFromEntry(entry: VaultEntry | undefined): string | undefined { export async function saveVaultEntry(definition: ServerDefinition, patch: Partial): Promise { await withFileLock(getOAuthVaultPath(), async () => { - const vault = await readVault(); + const vault = await readVault(definition); const key = vaultKeyForDefinition(definition); const existing = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined; const fallback = findSameUrlCredentials(vault, definition, key, existing); @@ -345,7 +442,7 @@ export async function saveVaultEntry(definition: ServerDefinition, patch: Partia (patch.tokens && !current.tokens ? fallback.clientInfo : undefined), updatedAt: new Date().toISOString(), }; - await writeVault(vault); + await writeVault(vault, definition); }); } @@ -371,7 +468,7 @@ export async function clearVaultTokensIfMatching( ): Promise { const key = vaultKeyForDefinition(definition); await withFileLock(getOAuthVaultPath(), async () => { - const { vault, needsRepair } = await readVaultState(); + const { vault, needsRepair } = await readVaultState(definition); const exact = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined; const fallbackKeys = findSameUrlCredentials(vault, definition, key, exact).sourceKeys; const targetKeys = @@ -405,7 +502,7 @@ export async function clearVaultTokensIfMatching( mutated = true; } if (mutated || needsRepair) { - await writeVault(vault); + await writeVault(vault, definition); } }); } @@ -416,13 +513,15 @@ export async function clearVaultEntry( ): Promise { const key = vaultKeyForDefinition(definition); await withFileLock(getOAuthVaultPath(), async () => { - const { vault, needsRepair } = await readVaultState(); + // Deleting needs no plaintext, so a value this password cannot open can + // still be cleared: the documented recovery after a password change. + const { vault, needsRepair } = await readVaultState(definition, 'none'); const existing = isVaultEntry(vault.entries[key]) ? vault.entries[key] : undefined; const fallback = findSameUrlCredentials(vault, definition, key, existing); const inheritedKeys = scope === 'all' ? legacyOAuthRenameKeys(vault, definition, key) : fallback.sourceKeys; if (!existing && inheritedKeys.length === 0) { if (needsRepair) { - await writeVault(vault); + await writeVault(vault, definition); } return; } @@ -469,6 +568,6 @@ export async function clearVaultEntry( updated.updatedAt = new Date().toISOString(); vault.entries[fallbackKey] = updated; } - await writeVault(vault); + await writeVault(vault, definition); }); } diff --git a/src/runtime/cached-auth.ts b/src/runtime/cached-auth.ts index 21471ef2..01e528b6 100644 --- a/src/runtime/cached-auth.ts +++ b/src/runtime/cached-auth.ts @@ -1,6 +1,7 @@ import type { ServerDefinition } from '../config.js'; import type { Logger } from '../logging.js'; import { readCachedAccessToken } from '../oauth-persistence.js'; +import { VaultEncryptionError } from '../oauth-vault-encryption.js'; function hasAuthorizationHeader(headers: Record | undefined): boolean { return Boolean(headers && Object.keys(headers).some((key) => key.toLowerCase() === 'authorization')); @@ -69,7 +70,9 @@ export async function applyCachedAuthIfAvailable( }, }; } catch (error) { - if (definition.auth === 'refreshable_bearer') { + // Vault refusals are configuration errors the operator must see; swallowing + // them would start an interactive auth flow a headless caller cannot finish. + if (definition.auth === 'refreshable_bearer' || error instanceof VaultEncryptionError) { throw error; } logger.debug?.( diff --git a/src/runtime/transport.ts b/src/runtime/transport.ts index b456352a..e1103d6d 100644 --- a/src/runtime/transport.ts +++ b/src/runtime/transport.ts @@ -1,4 +1,5 @@ import { DEFAULT_INHERITED_ENV_VARS } from '@modelcontextprotocol/client/stdio'; +import { childEnvWithoutVaultSecrets } from '../oauth-vault-encryption.js'; import { withRuntimeEnvironment } from './environment.js'; import { isExistingChromeDefinition } from '../daemon/connection-identity.js'; import { @@ -295,7 +296,13 @@ export async function createClientContext( return createReplayClientContext(definition, options.replayPath, clientInfo, options); } assertChromeBrokerAuthority(definition); - const env = isBrokerDefinition(definition) ? definition.env : resolveChromeDevtoolsRelayEnvironment(definition.env); + // A stdio server inherits the ambient environment, minus the vault password + // and policy: they protect every vault entry and no child has a use for + // them. The parent keeps them, so its own OAuth access is unaffected; a + // definition that sets them explicitly in `env` still wins. + const env = isBrokerDefinition(definition) + ? definition.env + : resolveChromeDevtoolsRelayEnvironment(definition.env, childEnvWithoutVaultSecrets(process.env)); return withRuntimeEnvironment(env ?? {}, async () => { const activeDefinition = await applyCachedAuthIfAvailable( { ...definition, env: env as Record }, diff --git a/tests/cached-auth-vault.test.ts b/tests/cached-auth-vault.test.ts new file mode 100644 index 00000000..84e7b5c5 --- /dev/null +++ b/tests/cached-auth-vault.test.ts @@ -0,0 +1,55 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import type { ServerDefinition } from '../src/config.js'; +import { getOAuthVaultPath, vaultKeyForDefinition } from '../src/oauth-vault.js'; +import { sealVaultSecret, vaultSecretKid } from '../src/oauth-vault-encryption.js'; +import { applyCachedAuthIfAvailable } from '../src/runtime/cached-auth.js'; + +const definition: ServerDefinition = { + name: 'enc', + command: { kind: 'http', url: new URL('https://example.com/mcp') }, + auth: 'oauth', +}; +const logger = { info() {}, warn() {}, error() {}, debug: vi.fn() }; +let dir: string; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cached-auth-')); + vi.stubEnv('XDG_DATA_HOME', path.join(dir, 'data')); + const key = vaultKeyForDefinition(definition); + const sealed = await sealVaultSecret( + 'access-enc', + vaultSecretKid(key, ['tokens', 'access_token']), + 'cached-auth-password-0123456789ab' + ); + await fs.mkdir(path.dirname(getOAuthVaultPath()), { recursive: true }); + await fs.writeFile( + getOAuthVaultPath(), + JSON.stringify({ + version: 2, + entries: { + [key]: { + serverName: 'enc', + serverUrl: 'https://example.com/mcp', + updatedAt: 'now', + tokens: { access_token: sealed, token_type: 'Bearer' }, + }, + }, + }), + 'utf8' + ); +}); +afterEach(async () => { + vi.unstubAllEnvs(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +it('surfaces the vault error instead of silently starting an auth flow', async () => { + await expect(applyCachedAuthIfAvailable(definition, logger, true)).rejects.toMatchObject({ + name: 'VaultEncryptionError', + code: 'password_missing', + }); + expect(logger.debug).not.toHaveBeenCalledWith(expect.stringContaining('Failed to read cached OAuth token')); +}); diff --git a/tests/cli-config-command.test.ts b/tests/cli-config-command.test.ts index 2e9e4200..36155a46 100644 --- a/tests/cli-config-command.test.ts +++ b/tests/cli-config-command.test.ts @@ -201,7 +201,9 @@ describe('mcporter config CLI', () => { expect(logs[2]).toMatch(/^Project config:/); expect(logs[3]).toMatch(/^System config:/); expect(logs[4]).toBe(''); - expect(logs[5]).toBe('Config looks good.'); + expect(logs[5]).toMatch(/^OAuth vault: /); + expect(logs[6]).toMatch(/^Vault encryption: policy optional, MCPORTER_VAULT_PASSWORD unset$/); + expect(logs[7]).toBe('Config looks good.'); }); it('prints config locations before doctor issues', async () => { @@ -216,8 +218,10 @@ describe('mcporter config CLI', () => { expect(logs[1]).toBe(`Selected config: ${configPath}`); expect(logs[2]).toMatch(/^Project config:/); expect(logs[3]).toMatch(/^System config:/); - expect(logs[5]).toBe('Config issues detected:'); - expect(logs[6]).toMatch(/non-absolute working directory/); + expect(logs[5]).toMatch(/^OAuth vault: /); + expect(logs[6]).toMatch(/^Vault encryption: /); + expect(logs[7]).toBe('Config issues detected:'); + expect(logs[8]).toMatch(/non-absolute working directory/); }); it('prints inline help for subcommands via --help', async () => { diff --git a/tests/config-doctor.test.ts b/tests/config-doctor.test.ts index 8935e384..51fc2259 100644 --- a/tests/config-doctor.test.ts +++ b/tests/config-doctor.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleDoctorCommand } from '../src/cli/config/doctor.js'; import type { LoadConfigOptions } from '../src/config.js'; import * as configModule from '../src/config.js'; +import { getOAuthVaultPath } from '../src/oauth-vault.js'; +import { sealVaultSecret, vaultSecretKid } from '../src/oauth-vault-encryption.js'; let tempDir: string; let loadOptions: LoadConfigOptions; @@ -40,18 +42,22 @@ describe('config doctor', () => { it('reports issues for stdio cwd and missing oauth token cache', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([ - { - name: 'bad-stdio', - command: { kind: 'stdio', command: 'node', args: [], cwd: 'relative/path' }, - }, - { - name: 'oauth-missing-cache', - command: { kind: 'http', url: new URL('https://example.com/mcp'), headers: {} }, - auth: 'oauth', - tokenCacheDir: undefined, - }, - ]); + vi.spyOn(configModule, 'loadConfigSnapshot').mockResolvedValue({ + servers: [ + { + name: 'bad-stdio', + command: { kind: 'stdio', command: 'node', args: [], cwd: 'relative/path' }, + }, + { + name: 'oauth-missing-cache', + command: { kind: 'http', url: new URL('https://example.com/mcp'), headers: {} }, + auth: 'oauth', + tokenCacheDir: undefined, + }, + ], + daemon: {}, + vaultEncryption: undefined, + }); await handleDoctorCommand({ loadOptions } as never, []); @@ -61,3 +67,123 @@ describe('config doctor', () => { expect(output).toContain('has a non-absolute working directory'); }); }); + +const runDoctor = async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await handleDoctorCommand({ loadOptions } as never, []); + return spy.mock.calls.flat().join('\n'); +}; +const writeVaultFile = async (text: string) => { + await fs.mkdir(path.dirname(getOAuthVaultPath()), { recursive: true }); + await fs.writeFile(getOAuthVaultPath(), text, 'utf8'); +}; + +describe('config doctor vault state', () => { + const PASSWORD = 'doctor-vault-password-0123456789'; + const KEY = 'api|0123456789abcdef'; + const vault = (refresh: string) => + JSON.stringify({ + version: 2, + entries: { + [KEY]: { + serverName: 'api', + updatedAt: 'now', + tokens: { access_token: 'plain', refresh_token: refresh, token_type: 'Bearer' }, + }, + }, + }); + afterEach(async () => { + await fs.rm(getOAuthVaultPath(), { force: true }); + }); + + it('reports an absent vault', async () => { + const out = await runDoctor(); + expect(out).toContain(`OAuth vault: ${getOAuthVaultPath()} (absent)`); + expect(out).toContain('Vault encryption: policy optional, MCPORTER_VAULT_PASSWORD unset'); + expect(out).toContain('Config looks good.'); + }); + it('counts values without the password and flags the missing password', async () => { + await writeVaultFile( + vault(await sealVaultSecret('rt', vaultSecretKid(KEY, ['tokens', 'refresh_token']), PASSWORD)) + ); + const out = await runDoctor(); + expect(out).toContain('(1 entries, 1 sealed values, 1 plaintext values)'); + expect(out).toContain('MCPORTER_VAULT_PASSWORD is unset'); + }); + it('flags plaintext values when a password is set', async () => { + await writeVaultFile(vault('plain-refresh')); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', PASSWORD); + expect(await runDoctor()).toContain('sealed on the next vault write'); + }); + it('applies the configured policy', async () => { + await writeVaultFile(vault('plain-refresh')); + vi.spyOn(configModule, 'loadConfigSnapshot').mockResolvedValue({ + servers: [], + daemon: {}, + vaultEncryption: 'required', + }); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', PASSWORD); + const out = await runDoctor(); + expect(out).toContain('policy required'); + expect(out).toContain('under oauthVaultEncryption'); + }); + it('reports a vault that is valid JSON but not a vault document as unreadable', async () => { + for (const body of ['null', '[]', '"text"', '{"version":2,"entries":7}']) { + await writeVaultFile(body); + const out = await runDoctor(); + expect(out, body).toContain('(unreadable)'); + expect(out, body).toContain('not a valid OAuth vault'); + } + }); + it('reports sealed and plaintext values in each configured tokenCacheDir', async () => { + const cacheDir = path.join(tempDir, 'cache'); + vi.spyOn(configModule, 'loadConfigSnapshot').mockResolvedValue({ + servers: [ + { + name: 'cached', + command: { kind: 'http', url: new URL('https://example.com/mcp'), headers: {} }, + auth: 'oauth', + tokenCacheDir: cacheDir, + }, + ], + daemon: {}, + vaultEncryption: undefined, + }); + expect(await runDoctor()).toContain(`Token cache ${cacheDir} (cached): absent`); + + await fs.mkdir(cacheDir, { recursive: true }); + await fs.writeFile( + path.join(cacheDir, 'tokens.json'), + JSON.stringify({ access_token: 'plain', refresh_token: 'plain-r', token_type: 'Bearer' }) + ); + await fs.writeFile( + path.join(cacheDir, 'client.json'), + JSON.stringify({ client_id: 'c', client_secret: 'plain-s' }) + ); + const plain = await runDoctor(); + expect(plain).toContain(`Token cache ${cacheDir} (cached): 0 sealed values, 3 plaintext values`); + expect(plain).not.toContain('plaintext secret values'); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', PASSWORD); + expect(await runDoctor()).toContain(`Token cache ${cacheDir} holds 3 plaintext secret values`); + + vi.unstubAllEnvs(); + await fs.writeFile( + path.join(cacheDir, 'tokens.json'), + JSON.stringify({ + access_token: await sealVaultSecret('a', 'tokens.json/access_token', PASSWORD), + refresh_token: await sealVaultSecret('r', 'tokens.json/refresh_token', PASSWORD), + token_type: 'Bearer', + }) + ); + const mixed = await runDoctor(); + expect(mixed).toContain(`Token cache ${cacheDir} (cached): 2 sealed values, 1 plaintext values`); + expect(mixed).toContain(`Token cache ${cacheDir} holds 2 sealed values but MCPORTER_VAULT_PASSWORD is unset`); + }); + + it('reports unreadable vaults and settings errors as issues', async () => { + await writeVaultFile('{"version":1,"entries": { bad'); + expect(await runDoctor()).toContain('(unreadable)'); + vi.stubEnv('MCPORTER_VAULT_ENCRYPTION', 'required'); + expect(await runDoctor()).toContain('MCPORTER_VAULT_ENCRYPTION=required but MCPORTER_VAULT_PASSWORD is not set'); + }); +}); diff --git a/tests/config-vault-encryption.test.ts b/tests/config-vault-encryption.test.ts new file mode 100644 index 00000000..726ac358 --- /dev/null +++ b/tests/config-vault-encryption.test.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; +import { loadConfigSnapshot } from '../src/config.js'; +import { RawConfigSchema } from '../src/config-schema.js'; +import { privateFixtureDirectory } from './helpers/private-directory.js'; + +const server = { url: 'https://example.com/mcp', auth: 'oauth' }; +async function snapshotOf(config: unknown) { + const root = await privateFixtureDirectory('mcp-vault-policy-'); + try { + await fs.writeFile(`${root}/config.json`, JSON.stringify(config)); + return await loadConfigSnapshot({ configPath: `${root}/config.json` }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +describe('oauthVaultEncryption', () => { + it('is absent when the config does not set it', async () => { + const snapshot = await snapshotOf({ imports: [], mcpServers: { api: server } }); + expect(snapshot.vaultEncryption).toBeUndefined(); + expect('oauthVaultEncryption' in (snapshot.servers[0] ?? {})).toBe(false); + }); + it('is carried on every definition and on the snapshot', async () => { + const snapshot = await snapshotOf({ + imports: [], + oauthVaultEncryption: 'required', + mcpServers: { api: server, other: server }, + }); + expect(snapshot.vaultEncryption).toBe('required'); + expect(snapshot.servers.map((d) => d.oauthVaultEncryption)).toEqual(['required', 'required']); + }); + it('rejects unknown values', () => { + expect(() => RawConfigSchema.parse({ mcpServers: {}, oauthVaultEncryption: 'always' })).toThrow(); + }); +}); diff --git a/tests/daemon-connection-identity-vault-env.test.ts b/tests/daemon-connection-identity-vault-env.test.ts new file mode 100644 index 00000000..86853770 --- /dev/null +++ b/tests/daemon-connection-identity-vault-env.test.ts @@ -0,0 +1,37 @@ +import os from 'node:os'; +import { describe, expect, it } from 'vitest'; +import type { ServerDefinition } from '../src/config.js'; +import { effectiveDefinition } from '../src/daemon/connection-identity.js'; + +const stdio = (env?: Record): ServerDefinition => ({ + name: 'keep-alive-probe', + command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: os.tmpdir() }, + env, +}); + +describe('effectiveDefinition and the vault variables', () => { + const inherited = { + ...process.env, + MCPORTER_VAULT_PASSWORD: 'ambient-vault-password-0123456789', + MCPORTER_VAULT_ENCRYPTION: 'optional', + UNRELATED_AMBIENT_VAR: 'still-inherited', + }; + + it('does not carry the vault variables into a daemon-managed child environment', async () => { + const resolved = await effectiveDefinition(stdio({ STATIC_ENV: '1' }), inherited, 'view'); + expect(resolved.env).toEqual( + expect.objectContaining({ STATIC_ENV: '1', UNRELATED_AMBIENT_VAR: 'still-inherited' }) + ); + expect(resolved.env).not.toHaveProperty('MCPORTER_VAULT_PASSWORD'); + expect(resolved.env).not.toHaveProperty('MCPORTER_VAULT_ENCRYPTION'); + }); + + it('keeps a vault password the server definition sets explicitly', async () => { + const resolved = await effectiveDefinition( + stdio({ MCPORTER_VAULT_PASSWORD: 'explicit-vault-password-0123456789' }), + inherited, + 'view' + ); + expect(resolved.env?.MCPORTER_VAULT_PASSWORD).toBe('explicit-vault-password-0123456789'); + }); +}); diff --git a/tests/daemon-view-codec.test.ts b/tests/daemon-view-codec.test.ts new file mode 100644 index 00000000..df94fd14 --- /dev/null +++ b/tests/daemon-view-codec.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from 'vitest'; +import { decodeView } from '../src/daemon/view-codec.js'; + +it('keeps the vault encryption policy on definitions sent to the daemon', () => { + const { definitions } = decodeView({ + definitions: [ + { + name: 'api', + command: { kind: 'http', url: 'https://example.com/mcp' }, + auth: 'oauth', + oauthVaultEncryption: 'required', + }, + ], + }); + expect(definitions[0]?.oauthVaultEncryption).toBe('required'); +}); diff --git a/tests/generate-cli.test.ts b/tests/generate-cli.test.ts index 4648d794..770ea6e0 100644 --- a/tests/generate-cli.test.ts +++ b/tests/generate-cli.test.ts @@ -332,12 +332,21 @@ describeGenerateCli('generateCli', () => { description: 'Test integration server', command: derivedUrl.toString(), tokenCacheDir: path.join(tmpDir, 'schema-cache'), + oauthVaultEncryption: 'required', }); await new Promise((resolve, reject) => { exec.execFile( 'node', ['dist/cli.js', 'generate-cli', '--server', inlineServerDefinition, '--output', altOutput], - execOptions(), + // Tool discovery goes through the vault, and `required` refuses it without a password. + { + ...execOptions(), + env: { + ...execOptions().env, + XDG_DATA_HOME: path.join(tmpDir, 'encrypted-data'), + MCPORTER_VAULT_PASSWORD: 'generate-cli-test-password-01234', + }, + }, (error) => { if (error) { reject(error); @@ -350,8 +359,12 @@ describeGenerateCli('generateCli', () => { const altContent = await fs.readFile(altOutput, 'utf8'); expect(altContent).toContain('const embeddedServer ='); expect(altContent).toContain('const embeddedDescription = "Test integration server"'); + // The vault encryption policy must reach the artifact, or a `required` + // configuration silently becomes `optional` in the generated CLI. + expect(altContent).toContain('"oauthVaultEncryption": "required"'); const altMetadata = await readCliMetadata(altOutput); + expect(altMetadata.server.definition.oauthVaultEncryption).toBe('required'); expect(altMetadata.artifact.kind).toBe('template'); expect(altMetadata.invocation.outputPath).toBe(altOutput); expect(['node', 'bun']).toContain(altMetadata.invocation.runtime); diff --git a/tests/generate-definition.test.ts b/tests/generate-definition.test.ts index e998e696..90e01fd0 100644 --- a/tests/generate-definition.test.ts +++ b/tests/generate-definition.test.ts @@ -72,6 +72,56 @@ describe('resolveServerDefinition HTTP selectors', () => { } }); + it('carries a config-file oauthVaultEncryption policy into generated definitions', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-generate-definition-')); + const configPath = path.join(tempDir, 'mcporter.json'); + await fs.writeFile( + configPath, + JSON.stringify({ + oauthVaultEncryption: 'required', + mcpServers: { sealed: { command: 'https://sealed.example.com/mcp', auth: 'oauth' } }, + }) + ); + const stdioConfigPath = path.join(tempDir, 'stdio.json'); + await fs.writeFile( + stdioConfigPath, + JSON.stringify({ + oauthVaultEncryption: 'required', + mcpServers: { local: { command: 'node', args: ['server.js'] } }, + }) + ); + try { + const { definition } = await resolveServerDefinition(configPath); + expect(definition.oauthVaultEncryption).toBe('required'); + // Regeneration passes the serialized definition back inline; the policy must survive that round trip. + const { definition: again } = await resolveServerDefinition(JSON.stringify(serializeDefinition(definition))); + expect(again.oauthVaultEncryption).toBe('required'); + // Ordinary entries take the shared config normalization path; the policy applies there too. + const { definition: local } = await resolveServerDefinition(stdioConfigPath); + expect(local.command.kind).toBe('stdio'); + expect(local.oauthVaultEncryption).toBe('required'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects an invalid oauthVaultEncryption instead of silently weakening policy', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-generate-definition-')); + const configPath = path.join(tempDir, 'mcporter.json'); + await fs.writeFile( + configPath, + JSON.stringify({ oauthVaultEncryption: 'sometimes', mcpServers: { s: { command: 'https://s.example.com/mcp' } } }) + ); + try { + await expect(resolveServerDefinition(configPath)).rejects.toThrow('oauthVaultEncryption'); + expect(() => normalizeDefinition({ name: 'bad', command: 'node', oauth_vault_encryption: 'sometimes' })).toThrow( + 'oauthVaultEncryption' + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('preserves a snake-case protocol version pin in inline generated definitions', async () => { const inline = JSON.stringify({ name: 'modern-inline', @@ -83,6 +133,17 @@ describe('resolveServerDefinition HTTP selectors', () => { expect(definition.protocolVersion).toBe('2026-07-28'); expect(serializeDefinition(definition).protocolVersion).toBe('2026-07-28'); }); + + it('accepts the snake-case oauthVaultEncryption alias in inline generated definitions', async () => { + const { definition } = await resolveServerDefinition( + JSON.stringify({ + name: 'sealed-inline', + command: 'https://sealed.example.com/mcp', + oauth_vault_encryption: 'required', + }) + ); + expect(definition.oauthVaultEncryption).toBe('required'); + }); }); describe('normalizeDefinition', () => { diff --git a/tests/oauth-persistence.test.ts b/tests/oauth-persistence.test.ts index 3750871e..cdc3cc72 100644 --- a/tests/oauth-persistence.test.ts +++ b/tests/oauth-persistence.test.ts @@ -5,16 +5,26 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { OAuthTokens } from '@modelcontextprotocol/client'; import type { ServerDefinition } from '../src/config.js'; -import { readJsonFile } from '../src/fs-json.js'; +import { readJsonFile, withFileLock } from '../src/fs-json.js'; import { buildOAuthPersistence, clearOAuthCaches, readCachedAccessToken } from '../src/oauth-persistence.js'; import { withRefreshLock } from '../src/oauth-refresh-lock.js'; import { clearVaultEntry, loadVaultEntry, saveVaultEntry, vaultKeyForDefinition } from '../src/oauth-vault.js'; +import { getOAuthVaultPath } from '../src/oauth-vault.js'; +import { isVaultSecretJwe, openVaultSecret, sealVaultSecret, vaultSecretKid } from '../src/oauth-vault-encryption.js'; +import { DirectoryPersistence, clearLegacyOAuthArtifacts } from '../src/oauth-persistence-stores.js'; const authMocks = vi.hoisted(() => ({ discoverOAuthServerInfo: vi.fn(), refreshAuthorization: vi.fn(), })); +const codecMocks = vi.hoisted(() => ({ sealVaultSecret: vi.fn() })); +vi.mock('../src/oauth-vault-encryption.js', async (importOriginal) => { + const original = await importOriginal(); + codecMocks.sealVaultSecret.mockImplementation(original.sealVaultSecret); + return { ...original, sealVaultSecret: codecMocks.sealVaultSecret }; +}); + vi.mock('@modelcontextprotocol/client', async (importOriginal) => ({ ...(await importOriginal()), discoverOAuthServerInfo: authMocks.discoverOAuthServerInfo, @@ -1108,6 +1118,8 @@ describe('oauth persistence', () => { ); await expect(readCachedAccessToken(definition)).resolves.toBeUndefined(); + // Without a vault password the legacy copy is kept as before; only the + // rejected tokens are cleared. await expect(readJsonFile(path.join(legacyDir, 'tokens.json'))).resolves.toBeUndefined(); await expect(readJsonFile(path.join(legacyDir, 'client.json'))).resolves.toEqual({ client_id: 'client-123' }); @@ -2413,3 +2425,518 @@ describe('oauth persistence', () => { }); }); }); + +const vaultPlainEntry = (d: ServerDefinition) => ({ + serverName: d.name, + serverUrl: 'https://example.com/mcp', + tokens: { access_token: 'access-enc', refresh_token: 'refresh-enc', token_type: 'Bearer' }, + clientInfo: { + client_id: 'client-enc', + client_secret: 'secret-enc', + token_endpoint_auth_method: 'client_secret_post', + }, + updatedAt: new Date().toISOString(), +}); +const vaultSealedEntry = async (d: ServerDefinition, password: string) => { + const key = vaultKeyForDefinition(d); + const e = vaultPlainEntry(d); + const seal = (p: readonly string[], v: string) => sealVaultSecret(v, vaultSecretKid(key, p), password); + return { + ...e, + tokens: { + ...e.tokens, + access_token: await seal(['tokens', 'access_token'], 'access-enc'), + refresh_token: await seal(['tokens', 'refresh_token'], 'refresh-enc'), + }, + clientInfo: { ...e.clientInfo, client_secret: await seal(['clientInfo', 'client_secret'], 'secret-enc') }, + }; +}; + +describe('vault encryption at rest', () => { + const PASSWORD = 'vault-password-for-tests-0123456789'; + let dir: string; + let vaultPath: string; + + // The suite above replaces process.env with a plain object, which neither + // the env stubbing restore nor os.homedir() follow; set and clear keys directly. + let savedDataHome: string | undefined; + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-vault-enc-')); + savedDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = path.join(dir, 'data'); + delete process.env.MCPORTER_VAULT_PASSWORD; + delete process.env.MCPORTER_VAULT_ENCRYPTION; + vaultPath = getOAuthVaultPath(); + }); + afterEach(async () => { + delete process.env.MCPORTER_VAULT_PASSWORD; + delete process.env.MCPORTER_VAULT_ENCRYPTION; + if (savedDataHome === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = savedDataHome; + vi.restoreAllMocks(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + const seed = async (entries: Record) => { + await fs.mkdir(path.dirname(vaultPath), { recursive: true }); + await fs.writeFile(vaultPath, JSON.stringify({ version: 2, entries }), 'utf8'); + }; + type StoredEntry = { serverName: string; tokens: Record; clientInfo: Record }; + const stored = async () => + (await readJsonFile<{ version: number; entries: Record }>(vaultPath))!; + + it('opens sealed values and keeps metadata', async () => { + const d = mkDef('enc-read'); + await seed({ [vaultKeyForDefinition(d)]: await vaultSealedEntry(d, PASSWORD) }); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await expect(loadVaultEntry(d)).resolves.toMatchObject({ + serverUrl: 'https://example.com/mcp', + tokens: { access_token: 'access-enc', refresh_token: 'refresh-enc', token_type: 'Bearer' }, + clientInfo: { client_id: 'client-enc', client_secret: 'secret-enc' }, + }); + }); + + it('fails closed with the password unset and leaves the file untouched', async () => { + const d = mkDef('enc-fail'); + await seed({ [vaultKeyForDefinition(d)]: await vaultSealedEntry(d, PASSWORD) }); + const before = await fs.readFile(vaultPath, 'utf8'); + await expect(loadVaultEntry(d)).rejects.toMatchObject({ name: 'VaultEncryptionError', code: 'password_missing' }); + await expect(clearVaultEntry(d, 'all')).rejects.toMatchObject({ code: 'password_missing' }); + await expect(saveVaultEntry(d, { state: 'x' })).rejects.toMatchObject({ code: 'password_missing' }); + expect(await fs.readFile(vaultPath, 'utf8')).toBe(before); + }); + + it('fails closed on a wrong password for reads and saves, but clear still removes the entry', async () => { + const d = mkDef('enc-wrong'); + await seed({ [vaultKeyForDefinition(d)]: await vaultSealedEntry(d, PASSWORD) }); + const before = await fs.readFile(vaultPath, 'utf8'); + process.env.MCPORTER_VAULT_PASSWORD = 'another-vault-password-9876543210'; + await expect(loadVaultEntry(d)).rejects.toMatchObject({ code: 'decrypt_failed' }); + await expect(saveVaultEntry(d, { state: 'x' })).rejects.toMatchObject({ code: 'decrypt_failed' }); + expect(await fs.readFile(vaultPath, 'utf8')).toBe(before); + // The documented recovery after a password change: clear needs no decryption. + await clearVaultEntry(d, 'all'); + await expect(loadVaultEntry(d)).resolves.toBeUndefined(); + }); + + it('isolates entries: a value sealed under another password does not block other servers', async () => { + const a = mkDef('enc-other-password'); + const b = mkDef('enc-current-password'); + await seed({ + [vaultKeyForDefinition(a)]: await vaultSealedEntry(a, 'another-vault-password-9876543210'), + [vaultKeyForDefinition(b)]: await vaultSealedEntry(b, PASSWORD), + }); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await expect(loadVaultEntry(b)).resolves.toMatchObject({ tokens: { access_token: 'access-enc' } }); + await expect(loadVaultEntry(a)).rejects.toMatchObject({ code: 'decrypt_failed' }); + const sealedA = (await stored()).entries[vaultKeyForDefinition(a)]!.tokens.refresh_token; + await saveVaultEntry(b, { state: 'updated' }); + // Untouched sealed values are written back byte-identical, not re-sealed. + expect((await stored()).entries[vaultKeyForDefinition(a)]!.tokens.refresh_token).toBe(sealedA); + await clearVaultEntry(a, 'all'); + await expect(loadVaultEntry(a)).resolves.toBeUndefined(); + await expect(loadVaultEntry(b)).resolves.toMatchObject({ state: 'updated' }); + }); + + it('refuses a value moved to another entry', async () => { + const a = mkDef('enc-a'); + const b = mkDef('enc-b'); + const sa = await vaultSealedEntry(a, PASSWORD); + await seed({ + [vaultKeyForDefinition(a)]: sa, + [vaultKeyForDefinition(b)]: { ...vaultPlainEntry(b), tokens: { ...sa.tokens } }, + }); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await expect(loadVaultEntry(b)).rejects.toMatchObject({ code: 'payload_invalid' }); + }); + + it('reads mixed sealed and plaintext values, and plaintext with a password set', async () => { + const d = mkDef('enc-mixed'); + const key = vaultKeyForDefinition(d); + const e = vaultPlainEntry(d); + await seed({ + [key]: { + ...e, + tokens: { + ...e.tokens, + refresh_token: await sealVaultSecret( + 'refresh-enc', + vaultSecretKid(key, ['tokens', 'refresh_token']), + PASSWORD + ), + }, + }, + }); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await expect(loadVaultEntry(d)).resolves.toMatchObject({ + tokens: { access_token: 'access-enc', refresh_token: 'refresh-enc' }, + }); + }); + + it('refuses every access under required without a password, from env or config', async () => { + process.env.MCPORTER_VAULT_ENCRYPTION = 'required'; + await expect(loadVaultEntry(mkDef('req'))).rejects.toMatchObject({ code: 'password_missing' }); + await expect(saveVaultEntry(mkDef('req'), { state: 'x' })).rejects.toMatchObject({ code: 'password_missing' }); + delete process.env.MCPORTER_VAULT_ENCRYPTION; + const configured = { ...mkDef('req-config'), oauthVaultEncryption: 'required' as const }; + await expect(loadVaultEntry(configured)).rejects.toMatchObject({ code: 'password_missing' }); + await expect(saveVaultEntry(configured, { state: 'x' })).rejects.toMatchObject({ code: 'password_missing' }); + }); + + it('seals the secret values and nothing else on write', async () => { + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + const d = mkDef('enc-write'); + const key = vaultKeyForDefinition(d); + await saveVaultEntry(d, { + tokens: { access_token: 'written', refresh_token: 'r', id_token: 'idt', token_type: 'Bearer', expires_in: 3600 }, + clientInfo: { + client_id: 'cid', + client_secret: 'cs', + registration_access_token: 'rat', + token_endpoint_auth_method: 'client_secret_post', + } as Parameters[1]['clientInfo'], + }); + const entry = (await stored()).entries[key]!; + expect(entry.serverName).toBe('enc-write'); + expect(entry.tokens.token_type).toBe('Bearer'); + expect(entry.tokens.expires_in).toBe(3600); + expect(entry.clientInfo.client_id).toBe('cid'); + for (const [p, v] of [ + [['tokens', 'access_token'], 'written'], + [['tokens', 'refresh_token'], 'r'], + [['tokens', 'id_token'], 'idt'], + [['clientInfo', 'client_secret'], 'cs'], + [['clientInfo', 'registration_access_token'], 'rat'], + ] as const) { + const value = entry[p[0]]![p[1]] as string; + expect(isVaultSecretJwe(value), p.join('/')).toBe(true); + await expect(openVaultSecret(value, vaultSecretKid(key, p), PASSWORD)).resolves.toBe(v); + } + }); + + it('seals plaintext values on the next write', async () => { + const legacy = mkDef('legacy'); + await seed({ [vaultKeyForDefinition(legacy)]: vaultPlainEntry(legacy) }); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await saveVaultEntry(mkDef('third'), { state: 'pending' }); + const entry = (await stored()).entries[vaultKeyForDefinition(legacy)]!; + expect(isVaultSecretJwe(entry.tokens.refresh_token)).toBe(true); + expect(isVaultSecretJwe(entry.clientInfo.client_secret)).toBe(true); + await expect(loadVaultEntry(legacy)).resolves.toMatchObject({ tokens: { access_token: 'access-enc' } }); + }); + + it('keeps writing plaintext without a password and skips missing fields with one', async () => { + const d = mkDef('plain-write'); + await saveVaultEntry(d, { tokens: { access_token: 'plain', token_type: 'Bearer' } }); + expect((await stored()).entries[vaultKeyForDefinition(d)]!.tokens.access_token).toBe('plain'); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await saveVaultEntry(d, { state: 'x' }); + const entry = (await stored()).entries[vaultKeyForDefinition(d)]!; + expect(isVaultSecretJwe(entry.tokens.access_token)).toBe(true); + expect('refresh_token' in entry.tokens).toBe(false); + expect(entry.clientInfo).toBeUndefined(); + }); + + it('seals a tokenCacheDir the same way', async () => { + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + const cacheDir = path.join(dir, 'cache'); + const d = mkDef('cached', cacheDir); + const persistence = await buildOAuthPersistence(d); + await persistence.saveTokens({ + access_token: 'dir-access', + refresh_token: 'dir-refresh', + id_token: 'dir-id', + token_type: 'Bearer', + }); + await persistence.saveClientInfo({ + client_id: 'dir-client', + client_secret: 'dir-secret', + registration_access_token: 'dir-rat', + } as Parameters[0]); + const tokens = (await readJsonFile<{ + access_token: string; + refresh_token: string; + id_token: string; + token_type: string; + }>(path.join(cacheDir, 'tokens.json')))!; + const client = (await readJsonFile<{ client_id: string; client_secret: string; registration_access_token: string }>( + path.join(cacheDir, 'client.json') + ))!; + expect(tokens.token_type).toBe('Bearer'); + expect(client.client_id).toBe('dir-client'); + expect(isVaultSecretJwe(tokens.access_token)).toBe(true); + expect(isVaultSecretJwe(tokens.refresh_token)).toBe(true); + expect(isVaultSecretJwe(tokens.id_token)).toBe(true); + expect(isVaultSecretJwe(client.client_secret)).toBe(true); + expect(isVaultSecretJwe(client.registration_access_token)).toBe(true); + await expect(openVaultSecret(tokens.refresh_token, 'tokens.json/refresh_token', PASSWORD)).resolves.toBe( + 'dir-refresh' + ); + await expect(persistence.readSnapshot()).resolves.toMatchObject({ + tokens: { access_token: 'dir-access', refresh_token: 'dir-refresh' }, + clientInfo: { client_secret: 'dir-secret' }, + }); + }); + + it('clears rejected credentials in a tokenCacheDir when the password is set', async () => { + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + const cacheDir = path.join(dir, 'cache-clear'); + const store = new DirectoryPersistence(cacheDir, undefined, 'https://example.com/mcp'); + await store.saveTokens({ access_token: 'dir-access', refresh_token: 'dir-refresh', token_type: 'Bearer' }); + await store.saveClientInfo({ client_id: 'dir-client', client_secret: 'dir-secret' }); + const tokens = await store.readTokens(); + const info = await store.readClientInfo(); + await store.clearRejectedCredentials(tokens, info); + await expect(fs.access(path.join(cacheDir, 'tokens.json'))).rejects.toThrow(); + await expect(fs.access(path.join(cacheDir, 'client.json'))).rejects.toThrow(); + }); + + it('refuses a plaintext tokenCacheDir under required without a password', async () => { + const cacheDir = path.join(dir, 'cache-required'); + const d = mkDef('cached-required', cacheDir); + const store = new DirectoryPersistence(cacheDir, undefined, 'https://example.com/mcp'); + await store.saveTokens({ access_token: 'dir-access', token_type: 'Bearer' }); + process.env.MCPORTER_VAULT_ENCRYPTION = 'required'; + const persistence = await buildOAuthPersistence(d); + await expect(persistence.readTokens()).rejects.toMatchObject({ code: 'password_missing' }); + await expect(persistence.readSnapshot()).rejects.toMatchObject({ code: 'password_missing' }); + await expect(persistence.saveState('x')).rejects.toMatchObject({ code: 'password_missing' }); + await expect(readCachedAccessToken(d)).rejects.toMatchObject({ code: 'password_missing' }); + }); + + it('refuses every tokenCacheDir operation under a config required policy without a password', async () => { + const cacheDir = path.join(dir, 'cache-required-direct'); + const store = new DirectoryPersistence(cacheDir, undefined, 'https://example.com/mcp', false, 'required'); + await expect(store.readTokens()).rejects.toMatchObject({ code: 'password_missing' }); + await expect(store.saveState('x')).rejects.toMatchObject({ code: 'password_missing' }); + await expect(store.clear('all')).rejects.toMatchObject({ code: 'password_missing' }); + await expect(fs.access(cacheDir)).rejects.toThrow(); + // The composite's first-value reads never reach the vault, so the directory must refuse on its own. + const d = { ...mkDef('cached-required-reads', cacheDir), oauthVaultEncryption: 'required' as const }; + const persistence = await buildOAuthPersistence(d); + await expect(persistence.readCodeVerifier()).rejects.toMatchObject({ code: 'password_missing' }); + await expect(persistence.readState()).rejects.toMatchObject({ code: 'password_missing' }); + }); + + it('refuses to clear legacy artifacts under a config required policy without a password', async () => { + vi.spyOn(os, 'homedir').mockReturnValue(dir); + const d = { ...mkDef('req-legacy-clear'), oauthVaultEncryption: 'required' as const }; + const legacyTokens = path.join(dir, '.mcporter', d.name, 'tokens.json'); + await fs.mkdir(path.dirname(legacyTokens), { recursive: true }); + await fs.writeFile(legacyTokens, JSON.stringify({ access_token: 'legacy', token_type: 'Bearer' }), 'utf8'); + await expect(clearLegacyOAuthArtifacts(d, undefined, 'all')).rejects.toMatchObject({ code: 'password_missing' }); + await expect(fs.access(legacyTokens)).resolves.toBeUndefined(); + }); + + it('clears a tokenCacheDir under the lock the sealing writes hold', async () => { + // Without the lock a concurrent first-sealing save could read a file, lose + // the race to clear's unlink, and write the sealed copy back. + const cacheDir = path.join(dir, 'cache-clear-lock'); + const tokensPath = path.join(cacheDir, 'tokens.json'); + const store = new DirectoryPersistence(cacheDir); + await store.saveTokens({ access_token: 'dir-access', token_type: 'Bearer' }); + const gate = Promise.withResolvers(); + const acquired = Promise.withResolvers(); + const lockHeld = withFileLock(tokensPath, async () => { + acquired.resolve(); + await gate.promise; + }); + await acquired.promise; + const cleared = store.clear('all'); + await new Promise((resolve) => setTimeout(resolve, 50)); + await expect(fs.access(tokensPath)).resolves.toBeUndefined(); + gate.resolve(); + await lockHeld; + await cleared; + await expect(fs.access(tokensPath)).rejects.toThrow(); + }); + + it('refuses every write to a sealed tokenCacheDir without a password and leaves it untouched', async () => { + const cacheDir = path.join(dir, 'cache-downgrade'); + const d = mkDef('cached-downgrade', cacheDir); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + const store = new DirectoryPersistence(cacheDir, undefined, 'https://example.com/mcp'); + await store.saveTokens({ access_token: 'dir-access', refresh_token: 'dir-refresh', token_type: 'Bearer' }); + await store.saveClientInfo({ client_id: 'dir-client', client_secret: 'dir-secret' }); + const snapshot = async () => + Object.fromEntries( + await Promise.all( + (await fs.readdir(cacheDir)) + .toSorted() + .map(async (f) => [f, await fs.readFile(path.join(cacheDir, f), 'utf8')]) + ) + ); + const before = await snapshot(); + delete process.env.MCPORTER_VAULT_PASSWORD; + await expect(store.saveTokens({ access_token: 'plain', token_type: 'Bearer' })).rejects.toMatchObject({ + code: 'password_missing', + }); + await expect( + store.saveDiscoveryState({ authorizationServerUrl: 'https://auth.example.com' }) + ).rejects.toMatchObject({ code: 'password_missing' }); + // The composite path the SDK uses: the directory refuses on its own, before the vault answers. + const persistence = await buildOAuthPersistence(d); + await expect(persistence.saveTokens({ access_token: 'plain', token_type: 'Bearer' })).rejects.toMatchObject({ + code: 'password_missing', + }); + // Deleting is a modification too; the vault refuses it the same way. + await expect(store.clear('all')).rejects.toMatchObject({ code: 'password_missing' }); + expect(await snapshot()).toEqual(before); + }); + + it('refuses to invalidate a sealed tokenCacheDir on a server URL change without a password', async () => { + const cacheDir = path.join(dir, 'cache-url-change'); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await new DirectoryPersistence(cacheDir, undefined, 'https://a.example.com/mcp').saveTokens({ + access_token: 'dir-access', + token_type: 'Bearer', + }); + delete process.env.MCPORTER_VAULT_PASSWORD; + const moved = new DirectoryPersistence(cacheDir, undefined, 'https://b.example.com/mcp'); + await expect(moved.readTokens()).rejects.toMatchObject({ code: 'password_missing' }); + await expect(fs.access(path.join(cacheDir, 'tokens.json'))).resolves.toBeUndefined(); + expect((await fs.readFile(path.join(cacheDir, 'server_url.txt'), 'utf8')).trim()).toBe('https://a.example.com/mcp'); + }); + + it('seals a plaintext tokenCacheDir on a metadata save with a password', async () => { + const cacheDir = path.join(dir, 'cache-metadata-sweep'); + const store = new DirectoryPersistence(cacheDir, undefined, 'https://example.com/mcp'); + await store.saveTokens({ access_token: 'dir-access', refresh_token: 'dir-refresh', token_type: 'Bearer' }); + await store.saveClientInfo({ client_id: 'dir-client', client_secret: 'dir-secret' }); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await store.saveResourceUrl('https://example.com/mcp'); + const tokens = (await readJsonFile<{ access_token: string; refresh_token: string }>( + path.join(cacheDir, 'tokens.json') + ))!; + const client = (await readJsonFile<{ client_secret: string }>(path.join(cacheDir, 'client.json')))!; + expect(isVaultSecretJwe(tokens.access_token)).toBe(true); + expect(isVaultSecretJwe(tokens.refresh_token)).toBe(true); + expect(isVaultSecretJwe(client.client_secret)).toBe(true); + await expect(store.readSnapshot()).resolves.toMatchObject({ + tokens: { access_token: 'dir-access' }, + clientInfo: { client_secret: 'dir-secret' }, + resourceUrl: 'https://example.com/mcp', + }); + }); + + it('does not let a plaintext tokenCacheDir hide a sealed vault without a password', async () => { + const cacheDir = path.join(dir, 'cache-hides-vault'); + const d = mkDef('cached-hides-vault', cacheDir); + await seed({ [vaultKeyForDefinition(d)]: await vaultSealedEntry(d, PASSWORD) }); + const store = new DirectoryPersistence(cacheDir, undefined, 'https://example.com/mcp'); + await store.saveTokens({ access_token: 'dir-access', token_type: 'Bearer' }); + await store.saveClientInfo({ client_id: 'dir-client' }); + const persistence = await buildOAuthPersistence(d); + await expect(persistence.readTokens()).rejects.toMatchObject({ code: 'password_missing' }); + await expect(persistence.readClientInfo()).rejects.toMatchObject({ code: 'password_missing' }); + await expect(readCachedAccessToken(d)).rejects.toMatchObject({ code: 'password_missing' }); + }); + + it('seals every plaintext secret file in a tokenCacheDir on the first write with a password', async () => { + const cacheDir = path.join(dir, 'cache-sweep'); + const store = new DirectoryPersistence(cacheDir, undefined, 'https://example.com/mcp'); + await store.saveTokens({ access_token: 'dir-access', refresh_token: 'dir-refresh', token_type: 'Bearer' }); + await store.saveClientInfo({ client_id: 'dir-client', client_secret: 'dir-secret' }); + await store.saveCodeVerifier('verifier-plain'); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await store.saveState('state-plain'); + const readFiles = async () => ({ + tokens: (await readJsonFile<{ access_token: string; refresh_token: string }>( + path.join(cacheDir, 'tokens.json') + ))!, + client: (await readJsonFile<{ client_id: string; client_secret: string }>(path.join(cacheDir, 'client.json')))!, + verifier: (await fs.readFile(path.join(cacheDir, 'code_verifier.txt'), 'utf8')).trim(), + state: (await readJsonFile(path.join(cacheDir, 'state.txt')))!, + }); + const first = await readFiles(); + expect(first.client.client_id).toBe('dir-client'); + for (const [label, value] of [ + ['tokens.json/access_token', first.tokens.access_token], + ['tokens.json/refresh_token', first.tokens.refresh_token], + ['client.json/client_secret', first.client.client_secret], + ['code_verifier.txt', first.verifier], + ['state.txt', first.state], + ] as const) { + expect(isVaultSecretJwe(value), label).toBe(true); + } + await expect(store.readSnapshot()).resolves.toMatchObject({ + tokens: { access_token: 'dir-access', refresh_token: 'dir-refresh' }, + clientInfo: { client_id: 'dir-client', client_secret: 'dir-secret' }, + codeVerifier: 'verifier-plain', + state: 'state-plain', + }); + // Already sealed files are left byte-identical by later writes. + await store.saveState('state-next'); + const second = await readFiles(); + expect(second.tokens).toEqual(first.tokens); + expect(second.client).toEqual(first.client); + expect(second.verifier).toBe(first.verifier); + }); + + it('moves a legacy per-server cache into the vault and removes it when a password is set', async () => { + const d = mkDef('legacy-dir'); + const legacyDir = path.join(dir, '.mcporter', 'legacy-dir'); + vi.spyOn(os, 'homedir').mockReturnValue(dir); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile( + path.join(legacyDir, 'tokens.json'), + JSON.stringify({ access_token: 'legacy-access', refresh_token: 'legacy-refresh', token_type: 'Bearer' }), + 'utf8' + ); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await buildOAuthPersistence(d); + await expect(loadVaultEntry(d)).resolves.toMatchObject({ tokens: { access_token: 'legacy-access' } }); + expect(isVaultSecretJwe((await stored()).entries[vaultKeyForDefinition(d)]!.tokens.refresh_token)).toBe(true); + await expect(fs.access(path.join(legacyDir, 'tokens.json'))).rejects.toThrow(); + }); + + it('keeps the legacy per-server cache without a password, as before', async () => { + const d = mkDef('legacy-dir-plain'); + const legacyDir = path.join(dir, '.mcporter', 'legacy-dir-plain'); + vi.spyOn(os, 'homedir').mockReturnValue(dir); + await fs.mkdir(legacyDir, { recursive: true }); + const tokens = { access_token: 'legacy-access', refresh_token: 'legacy-refresh', token_type: 'Bearer' }; + await fs.writeFile(path.join(legacyDir, 'tokens.json'), JSON.stringify(tokens), 'utf8'); + await buildOAuthPersistence(d); + await expect(loadVaultEntry(d)).resolves.toMatchObject({ tokens: { access_token: 'legacy-access' } }); + await expect(readJsonFile(path.join(legacyDir, 'tokens.json'))).resolves.toEqual(tokens); + }); + + it('rejects a refresh whose vault write fails instead of returning the stale token', async () => { + const d = mkDef('refresh-write-fails'); + const e = vaultPlainEntry(d); + // serverUrls is pre-populated so the URL reconciliation on read does not + // write; the first vault write is then the save after the refresh. + await fs.mkdir(path.dirname(vaultPath), { recursive: true }); + await fs.writeFile( + vaultPath, + JSON.stringify({ + version: 2, + entries: { + [vaultKeyForDefinition(d)]: { ...e, tokens: { ...e.tokens, expires_at: Math.floor(Date.now() / 1000) - 30 } }, + }, + serverUrls: { [d.name]: 'https://example.com/mcp' }, + }), + 'utf8' + ); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + authMocks.discoverOAuthServerInfo.mockResolvedValue({ authorizationServerUrl: 'https://auth.example.com' }); + authMocks.refreshAuthorization.mockResolvedValue({ + access_token: 'fresh-access', + refresh_token: 'fresh-refresh', + token_type: 'Bearer', + expires_in: 3600, + }); + const { VaultEncryptionError } = await import('../src/oauth-vault-encryption.js'); + codecMocks.sealVaultSecret.mockRejectedValueOnce(new VaultEncryptionError('payload_invalid', 'self-check failed')); + await expect(readCachedAccessToken(d)).rejects.toMatchObject({ code: 'payload_invalid' }); + }); + + it('repairs a corrupt vault as today when the password is set', async () => { + await fs.mkdir(path.dirname(vaultPath), { recursive: true }); + await fs.writeFile(vaultPath, '{"version":1,"entries": { bad', 'utf8'); + process.env.MCPORTER_VAULT_PASSWORD = PASSWORD; + await clearVaultEntry(mkDef('missing'), 'all'); + expect(await readJsonFile(vaultPath)).toEqual({ version: 2, encryption: { version: 1 }, entries: {} }); + }); +}); diff --git a/tests/oauth-vault-encryption.test.ts b/tests/oauth-vault-encryption.test.ts new file mode 100644 index 00000000..21ec4de1 --- /dev/null +++ b/tests/oauth-vault-encryption.test.ts @@ -0,0 +1,197 @@ +import crypto from 'node:crypto'; +import { CompactEncrypt, SignJWT, decodeProtectedHeader } from 'jose'; +import { describe, expect, it } from 'vitest'; +import type { VaultEntry } from '../src/oauth-vault.js'; +import { + MIN_VAULT_PASSWORD_LENGTH, + VAULT_JWE_ALG, + VAULT_JWE_ENC, + VAULT_JWE_TYP, + VAULT_PBES2_ITERATIONS, + VAULT_PBES2_MAX_ITERATIONS, + VAULT_SECRET_FIELDS, + VaultEncryptionError, + describeVaultSecrets, + isVaultSecretJwe, + openVaultSecret, + readVaultEncryptionSettings, + sealVaultSecret, + vaultSecretKid, +} from '../src/oauth-vault-encryption.js'; + +const PASSWORD = 'correct-horse-battery-staple-2026'; +const OTHER = 'another-password-that-is-long-enough'; +const KID = vaultSecretKid('linear|3f9c2a7d1b4e8f60', ['tokens', 'refresh_token']); + +// Flip a tag character, not the last ciphertext character: that one can carry +// padding bits decoders ignore (11 of 300 tampered tokens decrypted in a trial). +const tamper = (token: string) => { + const p = token.split('.'); + p[4] = `${p[4]?.[0] === 'A' ? 'B' : 'A'}${p[4]?.slice(1) ?? ''}`; + return p.join('.'); +}; +const foreign = async (header: Record, key: Uint8Array, p2c?: number) => { + const b = new CompactEncrypt(new TextEncoder().encode('lin_rt_EXAMPLE')).setProtectedHeader(header as never); + if (p2c !== undefined) b.setKeyManagementParameters({ p2c }); + return await b.encrypt(key); +}; + +describe('readVaultEncryptionSettings', () => { + it('defaults to optional', () => { + expect(readVaultEncryptionSettings({})).toEqual({ policy: 'optional', password: undefined }); + }); + it('normalizes the policy and rejects unknown values', () => { + expect( + readVaultEncryptionSettings({ MCPORTER_VAULT_ENCRYPTION: ' Required ', MCPORTER_VAULT_PASSWORD: PASSWORD }).policy + ).toBe('required'); + expect(() => readVaultEncryptionSettings({ MCPORTER_VAULT_ENCRYPTION: 'always' })).toThrowError( + expect.objectContaining({ + name: 'VaultEncryptionError', + code: 'policy_invalid', + message: expect.stringContaining('optional'), + }) + ); + }); + it('requires a password under required, from env or config', () => { + expect(() => readVaultEncryptionSettings({ MCPORTER_VAULT_ENCRYPTION: 'required' })).toThrowError( + expect.objectContaining({ code: 'password_missing', message: expect.stringContaining('MCPORTER_VAULT_PASSWORD') }) + ); + expect(() => readVaultEncryptionSettings({}, 'required')).toThrowError( + expect.objectContaining({ code: 'password_missing' }) + ); + }); + it('lets the env var override the configured policy', () => { + expect(readVaultEncryptionSettings({ MCPORTER_VAULT_ENCRYPTION: 'optional' }, 'required').policy).toBe('optional'); + expect(readVaultEncryptionSettings({ MCPORTER_VAULT_PASSWORD: PASSWORD }, 'required').policy).toBe('required'); + }); + it.each(['', 'short'])('rejects password %j without echoing it', (value) => { + expect(() => readVaultEncryptionSettings({ MCPORTER_VAULT_PASSWORD: value })).toThrowError( + expect.objectContaining({ + code: 'password_invalid', + message: expect.stringContaining(String(MIN_VAULT_PASSWORD_LENGTH)), + }) + ); + expect(() => readVaultEncryptionSettings({ MCPORTER_VAULT_PASSWORD: value })).toThrowError( + expect.objectContaining({ message: expect.not.stringContaining(value || 'never') }) + ); + }); +}); + +describe('seal / open', () => { + it('round-trips with the expected header', async () => { + const token = await sealVaultSecret('lin_rt_EXAMPLE', KID, PASSWORD); + expect(isVaultSecretJwe(token)).toBe(true); + expect(decodeProtectedHeader(token)).toMatchObject({ + alg: VAULT_JWE_ALG, + enc: VAULT_JWE_ENC, + typ: VAULT_JWE_TYP, + kid: KID, + p2c: VAULT_PBES2_ITERATIONS, + }); + await expect(openVaultSecret(token, KID, PASSWORD)).resolves.toBe('lin_rt_EXAMPLE'); + expect(await sealVaultSecret('x'.repeat(8), KID, PASSWORD)).not.toBe( + await sealVaultSecret('x'.repeat(8), KID, PASSWORD) + ); + }); + it('fails closed on a wrong password and refuses tampering', async () => { + const token = await sealVaultSecret('lin_rt_EXAMPLE', KID, PASSWORD); + const err = await openVaultSecret(token, KID, OTHER).then( + () => { + throw new Error('expected rejection'); + }, + (e: unknown) => e as VaultEncryptionError + ); + expect(err).toBeInstanceOf(VaultEncryptionError); + expect(err.code).toBe('decrypt_failed'); + for (const s of [PASSWORD, OTHER, 'lin_rt_EXAMPLE']) expect(err.message).not.toContain(s); + await expect(openVaultSecret(tamper(token), KID, PASSWORD)).rejects.toMatchObject({ code: 'decrypt_failed' }); + }); + it('refuses relocated and foreign tokens from the header, before key derivation', async () => { + const pw = new TextEncoder().encode(PASSWORD); + const moved = await sealVaultSecret('lin_rt_EXAMPLE', KID, PASSWORD); + const otherKid = vaultSecretKid('linear|3f9c2a7d1b4e8f60', ['tokens', 'access_token']); + for (const token of [ + moved, + await foreign({ alg: 'dir', enc: 'A256GCM', typ: VAULT_JWE_TYP, kid: otherKid }, crypto.randomBytes(32)), + await foreign({ alg: VAULT_JWE_ALG, enc: VAULT_JWE_ENC, kid: otherKid }, pw, VAULT_PBES2_ITERATIONS), + await foreign( + { alg: VAULT_JWE_ALG, enc: VAULT_JWE_ENC, typ: VAULT_JWE_TYP, kid: otherKid }, + pw, + VAULT_PBES2_MAX_ITERATIONS + 1 + ), + ]) { + await expect(openVaultSecret(token, otherKid, OTHER)).rejects.toMatchObject({ code: 'payload_invalid' }); + } + }); + it('round-trips non-ASCII', async () => { + const pw = 'пароль-très-sécurisé-日本語-2026'; + await expect(openVaultSecret(await sealVaultSecret('секрет-🔑', KID, pw), KID, pw)).resolves.toBe('секрет-🔑'); + }); +}); + +describe('isVaultSecretJwe', () => { + it('accepts sealed values only', async () => { + expect(isVaultSecretJwe(await sealVaultSecret('v', KID, PASSWORD))).toBe(true); + expect(isVaultSecretJwe('lin_rt_EXAMPLE')).toBe(false); + expect(isVaultSecretJwe(undefined)).toBe(false); + expect( + isVaultSecretJwe( + await new SignJWT({ sub: 'x' }).setProtectedHeader({ alg: 'HS256' }).sign(crypto.randomBytes(32)) + ) + ).toBe(false); + expect(isVaultSecretJwe(await foreign({ alg: 'dir', enc: 'A256GCM', typ: 'at+jwe' }, crypto.randomBytes(32)))).toBe( + false + ); + }); +}); + +describe('password floor', () => { + it('rejects 31 characters and accepts 32, so a memorable passphrase does not pass', () => { + const thirtyOne = 'x'.repeat(31); + const thirtyTwo = 'x'.repeat(32); + expect(MIN_VAULT_PASSWORD_LENGTH).toBe(32); + expect(() => readVaultEncryptionSettings({ MCPORTER_VAULT_PASSWORD: thirtyOne })).toThrow( + expect.objectContaining({ code: 'password_invalid' }) + ); + expect(readVaultEncryptionSettings({ MCPORTER_VAULT_PASSWORD: thirtyTwo }).password).toBe(thirtyTwo); + }); +}); + +describe('VAULT_SECRET_FIELDS and describeVaultSecrets', () => { + it('names string fields of VaultEntry and counts them', async () => { + const entry: VaultEntry = { + serverName: 'x', + updatedAt: 'now', + tokens: { access_token: 'a', refresh_token: 'r', id_token: 'i', token_type: 'Bearer' }, + clientInfo: { client_id: 'c', client_secret: 's', registration_access_token: 'rat' } as VaultEntry['clientInfo'], + codeVerifier: 'v', + state: 's', + }; + // An OpenID Connect ID token carries identity claims and scanners flag it; + // an RFC 7592 registration access token is a bearer secret for the client record. + expect(VAULT_SECRET_FIELDS).toEqual( + expect.arrayContaining([ + ['tokens', 'id_token'], + ['clientInfo', 'registration_access_token'], + ]) + ); + for (const path of VAULT_SECRET_FIELDS) { + expect( + typeof path.reduce((n, k) => (n as Record | undefined)?.[k], entry), + path.join('/') + ).toBe('string'); + } + const sealed = await sealVaultSecret('r', KID, PASSWORD); + expect( + describeVaultSecrets({ + a: { + serverName: 'a', + updatedAt: 'now', + tokens: { access_token: 'plain', refresh_token: sealed, token_type: 'Bearer' }, + }, + b: { serverName: 'b', updatedAt: 'now' }, + c: 'not an entry', + }) + ).toEqual({ entries: 2, sealed: 1, plaintext: 1 }); + }); +}); diff --git a/tests/oauth-vault-format.test.ts b/tests/oauth-vault-format.test.ts new file mode 100644 index 00000000..f2edeac5 --- /dev/null +++ b/tests/oauth-vault-format.test.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ServerDefinition } from '../src/config.js'; +import { DirectoryPersistence } from '../src/oauth-persistence-stores.js'; +import { clearVaultEntry, getOAuthVaultPath, loadVaultEntry, saveVaultEntry } from '../src/oauth-vault.js'; + +const PASSWORD = 'synthetic-vault-format-test-password'; +const definition: ServerDefinition = { + name: 'format-test', + command: { kind: 'http', url: new URL('https://example.com/mcp') }, +}; +const tokens = { access_token: 'synthetic-access-token', token_type: 'Bearer' }; + +// A cleared encrypted store must not silently become plaintext when the next +// process loses its environment. The marker records that choice independently +// of whether there are currently any secret values to detect. +describe('persistent encryption format', () => { + let root: string; + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-vault-format-')); + vi.stubEnv('XDG_DATA_HOME', root); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', PASSWORD); + }); + afterEach(async () => { + vi.unstubAllEnvs(); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('keeps the vault encrypted after clearing its last entry', async () => { + await saveVaultEntry(definition, { tokens }); + await clearVaultEntry(definition, 'all'); + const before = await fs.readFile(getOAuthVaultPath(), 'utf8'); + expect(JSON.parse(before).encryption).toEqual({ version: 1 }); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', undefined); + await expect(saveVaultEntry(definition, { tokens })).rejects.toMatchObject({ code: 'password_missing' }); + expect(await fs.readFile(getOAuthVaultPath(), 'utf8')).toBe(before); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', PASSWORD); + await saveVaultEntry(definition, { tokens }); + expect((await loadVaultEntry(definition))?.tokens?.access_token).toBe(tokens.access_token); + }); + + it('refuses an unknown vault encryption version without repairing it', async () => { + await saveVaultEntry(definition, { tokens }); + const document = JSON.parse(await fs.readFile(getOAuthVaultPath(), 'utf8')); + document.encryption = { version: 2 }; + const before = JSON.stringify(document); + await fs.writeFile(getOAuthVaultPath(), before); + await expect(loadVaultEntry(definition)).rejects.toMatchObject({ code: 'payload_invalid' }); + await expect(saveVaultEntry(definition, { tokens })).rejects.toMatchObject({ code: 'payload_invalid' }); + expect(await fs.readFile(getOAuthVaultPath(), 'utf8')).toBe(before); + }); + + it('keeps an empty token cache encrypted across process environments', async () => { + const cache = path.join(root, 'cache'); + const store = new DirectoryPersistence(cache); + await store.saveTokens(tokens); + await store.clear('all'); + const marker = path.join(cache, 'encryption.json'); + expect(JSON.parse(await fs.readFile(marker, 'utf8'))).toEqual({ version: 1 }); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', undefined); + await expect(new DirectoryPersistence(cache).saveTokens(tokens)).rejects.toMatchObject({ + code: 'password_missing', + }); + await expect(fs.access(path.join(cache, 'tokens.json'))).rejects.toMatchObject({ code: 'ENOENT' }); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', PASSWORD); + await new DirectoryPersistence(cache).saveTokens(tokens); + expect((await store.readTokens())?.access_token).toBe(tokens.access_token); + }); + + it.each(['all', 'rejected'] as const)( + 'remembers opt-in when first clearing plaintext credentials (%s)', + async (scope) => { + const cache = path.join(root, 'plaintext-cache'); + const store = new DirectoryPersistence(cache); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', undefined); + await store.saveTokens(tokens); + await store.saveClientInfo({ client_id: 'synthetic-client', client_secret: 'synthetic-client-secret' }); + const cached = await store.readSnapshot(); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', PASSWORD); + if (scope === 'all') await store.clear('all'); + else await store.clearRejectedCredentials(cached.tokens, cached.clientInfo); + expect(JSON.parse(await fs.readFile(path.join(cache, 'encryption.json'), 'utf8'))).toEqual({ version: 1 }); + vi.stubEnv('MCPORTER_VAULT_PASSWORD', undefined); + await expect(store.saveTokens(tokens)).rejects.toMatchObject({ code: 'password_missing' }); + await expect(fs.access(path.join(cache, 'tokens.json'))).rejects.toMatchObject({ code: 'ENOENT' }); + } + ); + + it('refuses an unknown token-cache marker without overwriting it', async () => { + const cache = path.join(root, 'cache'); + await fs.mkdir(cache); + const marker = path.join(cache, 'encryption.json'); + await fs.writeFile(marker, '{"version":2}'); + const store = new DirectoryPersistence(cache); + await expect(store.readTokens()).rejects.toMatchObject({ code: 'payload_invalid' }); + await expect(store.saveTokens(tokens)).rejects.toMatchObject({ code: 'payload_invalid' }); + expect(await fs.readFile(marker, 'utf8')).toBe('{"version":2}'); + }); +}); diff --git a/tests/runtime-transport.test.ts b/tests/runtime-transport.test.ts index e586610d..9b10c96d 100644 --- a/tests/runtime-transport.test.ts +++ b/tests/runtime-transport.test.ts @@ -507,6 +507,40 @@ describe('createClientContext (HTTP)', () => { }); }); + it('does not forward the vault variables to stdio servers, but keeps the rest of the environment', async () => { + vi.stubEnv('MCPORTER_VAULT_PASSWORD', 'ambient-vault-password-0123456789'); + vi.stubEnv('MCPORTER_VAULT_ENCRYPTION', 'optional'); + vi.stubEnv('UNRELATED_AMBIENT_VAR', 'still-inherited'); + const definition: ServerDefinition = { + name: 'stdio-vault-env', + command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' }, + env: { STATIC_ENV: '1' }, + }; + vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => { + const params = (transport as { _serverParams?: { env?: Record } })._serverParams; + expect(params?.env).toEqual( + expect.objectContaining({ STATIC_ENV: '1', UNRELATED_AMBIENT_VAR: 'still-inherited' }) + ); + expect(params?.env).not.toHaveProperty('MCPORTER_VAULT_PASSWORD'); + expect(params?.env).not.toHaveProperty('MCPORTER_VAULT_ENCRYPTION'); + }); + await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 }); + }); + + it('keeps a vault password the server definition sets explicitly', async () => { + vi.stubEnv('MCPORTER_VAULT_PASSWORD', 'ambient-vault-password-0123456789'); + const definition: ServerDefinition = { + name: 'stdio-vault-env-explicit', + command: { kind: 'stdio', command: 'node', args: ['server.js'], cwd: '/tmp' }, + env: { MCPORTER_VAULT_PASSWORD: 'explicit-vault-password-0123456789' }, + }; + vi.spyOn(Client.prototype, 'connect').mockImplementationOnce(async (transport) => { + const params = (transport as { _serverParams?: { env?: Record } })._serverParams; + expect(params?.env?.MCPORTER_VAULT_PASSWORD).toBe('explicit-vault-password-0123456789'); + }); + await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 }); + }); + it('fails refreshable bearer stdio configs that do not name the token env var', async () => { const definition: ServerDefinition = { name: 'stdio-refresh', diff --git a/tests/setup/isolate-home.ts b/tests/setup/isolate-home.ts index 473a5ce6..391abb60 100644 --- a/tests/setup/isolate-home.ts +++ b/tests/setup/isolate-home.ts @@ -41,3 +41,8 @@ process.env.TSX_DISABLE_CACHE = '1'; afterAll(() => { fs.rmSync(root, { recursive: true, force: true }); }); + +// Vault encryption reads these from the environment. An operator's shell must +// never turn the suite's plaintext fixtures into sealed ones. +delete process.env.MCPORTER_VAULT_PASSWORD; +delete process.env.MCPORTER_VAULT_ENCRYPTION; diff --git a/tests/vault-command.test.ts b/tests/vault-command.test.ts index 83b46e46..4933e6f5 100644 --- a/tests/vault-command.test.ts +++ b/tests/vault-command.test.ts @@ -4,7 +4,9 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ServerDefinition } from '../src/config.js'; import { handleVault } from '../src/cli/vault-command.js'; -import { loadVaultEntry } from '../src/oauth-vault.js'; +import { readJsonFile } from '../src/fs-json.js'; +import { getOAuthVaultPath, loadVaultEntry, vaultKeyForDefinition } from '../src/oauth-vault.js'; +import { isVaultSecretJwe, openVaultSecret, vaultSecretKid } from '../src/oauth-vault-encryption.js'; const definition: ServerDefinition = { name: 'calendar', @@ -35,6 +37,31 @@ describe('vault command', () => { vi.restoreAllMocks(); }); + it('seeds sealed values when MCPORTER_VAULT_PASSWORD is set', async () => { + const password = 'vault-command-password-0123456789'; + process.env.MCPORTER_VAULT_PASSWORD = password; + const payloadPath = path.join(tempDir, 'tokens.json'); + await fs.writeFile( + payloadPath, + JSON.stringify({ tokens: { access_token: 'access-enc', refresh_token: 'refresh-enc', token_type: 'Bearer' } }), + 'utf8' + ); + await handleVault(runtimeFor(definition), ['set', 'calendar', '--tokens-file', payloadPath]); + const key = vaultKeyForDefinition(definition); + const stored = (await readJsonFile<{ + entries: Record; + }>(getOAuthVaultPath()))!; + const tokens = stored.entries[key]!.tokens; + expect(tokens.token_type).toBe('Bearer'); + expect(isVaultSecretJwe(tokens.access_token)).toBe(true); + await expect( + openVaultSecret(tokens.access_token, vaultSecretKid(key, ['tokens', 'access_token']), password) + ).resolves.toBe('access-enc'); + await expect(loadVaultEntry(definition)).resolves.toMatchObject({ tokens: { access_token: 'access-enc' } }); + await handleVault(runtimeFor(definition), ['clear', 'calendar']); + await expect(loadVaultEntry(definition)).resolves.toBeUndefined(); + }); + it('keeps accepting string-only client info from a file', async () => { const payloadPath = path.join(tempDir, 'tokens.json'); await fs.writeFile(