From e8513444a98ea3be1d8a1c921a7d69111dc5d144 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Sun, 26 Jul 2026 22:52:47 +0400 Subject: [PATCH 1/2] feat(oauth): support recoverable profile rename --- README.md | 6 +- docs/cli.md | 2 +- docs/console-api.md | 4 +- src/cli/exit-codes.ts | 1 + src/cli/migrate-config.ts | 2 +- src/console/console-application-service.ts | 19 +- src/console/console-assets.ts | 4 +- src/console/console-server.ts | 14 + src/oauth/connection-lifecycle.ts | 124 ++- src/oauth/connection-registry.ts | 93 ++ src/oauth/connection-types.ts | 34 + src/oauth/local-state-paths.ts | 21 + src/oauth/profile-rename-transaction.ts | 856 ++++++++++++++++++ src/oauth/remote-oauth-runtime.ts | 42 +- src/oauth/secure-credential-store.ts | 8 +- src/setup/profile-rename-onboarding.ts | 106 ++- src/utils/errors.ts | 1 + tests/cli-exit-codes.test.ts | 1 + tests/console-application-service.test.ts | 68 ++ tests/console-server.test.ts | 70 +- .../profile-rename-oauth-dependencies.ts | 55 ++ tests/oauth-connection-lifecycle.test.ts | 22 +- tests/oauth-connection-registry.test.ts | 15 + tests/oauth-connection-types.test.ts | 11 +- ...console-threat-model-docs-contract.test.ts | 2 + .../oauth-profile-rename-transaction.test.ts | 749 +++++++++++++++ tests/oauth-secure-credential-store.test.ts | 11 + tests/profile-rename-onboarding.test.ts | 11 +- tests/readme-product-contract.test.ts | 8 +- tests/remote-oauth-runtime.test.ts | 117 ++- 30 files changed, 2361 insertions(+), 116 deletions(-) create mode 100644 src/oauth/local-state-paths.ts create mode 100644 src/oauth/profile-rename-transaction.ts create mode 100644 tests/helpers/profile-rename-oauth-dependencies.ts create mode 100644 tests/oauth-profile-rename-transaction.test.ts diff --git a/README.md b/README.md index 02003141..46e06c7c 100644 --- a/README.md +++ b/README.md @@ -528,9 +528,11 @@ Rename a profile without hand-editing every configuration reference: miftah profile rename --config ~/.config/miftah/gsc.json --profile google-personal --new-profile google-studio ``` -The selected profile must already exist and the new name must be distinct and safe. Miftah atomically renames the profile and every configuration-owned reference to it: the durable default, routing rules, routing-plugin bindings, and a configured profile lock. It validates the complete candidate, keeps a recovery backup, and finalizes the configured fail-closed audit lifecycle. +The selected profile must already exist and the new name must be distinct and safe. Miftah atomically renames the profile and every configuration-owned reference to it: the durable default, routing rules, routing-plugin bindings, configured profile lock, and any native OAuth connection binding. It validates the complete candidate, keeps a recovery backup, and finalizes the configured fail-closed audit lifecycle. -It does not read, move, copy, or delete credentials, provider token caches, profile state, identity records, or OS-vault data, and it does not change an active MCP client session. Miftah refuses to rename a profile with a native OAuth binding because moving its OS-vault credential requires one future atomic lifecycle rather than a configuration-only rename. +For a native OAuth connection, Miftah moves the credential only between its two exact OS-vault keys and migrates its non-secret connection metadata in the same recoverable local transaction. It never exposes the credential or copies it to configuration, logs, provider caches, profile state, or identity records. Existing MCP clients keep their current session; restart the client to use the new profile name. + +If a local interruption leaves that native OAuth rename unfinished, retry the same rename from the CLI or Console surface that started it. Miftah completes the recovery journal only after its required audit finalization succeeds, then asks you to reload the configuration. Do not edit the configuration, vault, metadata, or provider cache to recover it. In the browser, run `miftah dashboard --config ~/.config/miftah/gsc.json` and choose **Rename an account profile**. In catalog mode, select the configuration again after the write before making another Console change. diff --git a/docs/cli.md b/docs/cli.md index baad309a..3fb8c3c4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -30,7 +30,7 @@ Documented command names, options, JSON success forms, and exit categories are c | `miftah profile list --config ` | `--config` | `--config ` | Lists the durable default and fixed non-secret metadata for every configured account: name, optional description/tags/policy, and profile-level upstream override names. It only loads and validates configuration; it never resolves secret references, reads credentials, headers, OAuth vault entries, or provider token caches, starts an upstream, or changes configuration. | | `miftah profile set-default --config --profile ` | `--config`, `--profile` | `--config `, `--profile ` | Makes one existing profile the durable default for future Miftah sessions. It performs a guarded replacement with a recovery backup and finalizes a configured fail-closed audit record. Its result omits configuration bytes, profile data, provider paths, and secret references; it never starts an upstream, opens OAuth, or changes profile data, provider caches, or an active client session. | | `miftah profile set-description --config --profile ` | `--config`, `--profile`, exactly one of `--description` or `--clear-description` | `--config `, `--profile `, `--description `, `--clear-description` | Sets or explicitly clears one existing profile's non-secret description. It validates the candidate and performs a guarded replacement with a recovery backup and configured fail-closed audit record. The public result omits configuration bytes and the submitted description. It never starts an upstream, opens OAuth, resolves credentials, changes routing/default selection, or changes provider caches or an active client session. | -| `miftah profile rename --config --profile --new-profile ` | `--config`, `--profile`, `--new-profile` | `--config `, `--profile `, `--new-profile ` | Renames one existing profile and every configuration-owned durable reference: the default profile, routing rules, routing-plugin bindings, and configured profile lock. It validates the candidate and performs a guarded replacement with recovery backup and configured fail-closed audit. It never starts an upstream, resolves, moves, copies, or deletes credentials, provider caches, profile state, identity records, or an active client session. A configured native OAuth binding causes `PROFILE_RENAME_OAUTH_CONNECTION`: Miftah refuses to split configuration rename from OS-vault credential migration. | +| `miftah profile rename --config --profile --new-profile ` | `--config`, `--profile`, `--new-profile` | `--config `, `--profile `, `--new-profile ` | Renames one existing profile and every configuration-owned durable reference: the default profile, routing rules, routing-plugin bindings, configured profile lock, and native OAuth connection bindings. It validates the candidate and performs a guarded replacement with recovery backup and configured fail-closed audit. For native OAuth, it moves the credential only between exact OS-vault keys and migrates non-secret metadata through one recoverable local transaction; it never exposes a credential or copies it to config, logs, provider caches, profile state, identity records, or active client sessions. If a local interruption leaves the transaction unfinished, retry the same rename from the CLI; Miftah completes recovery only after required audit finalization, otherwise returns `OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED` (exit 1). Do not hand-edit the configuration, vault, metadata, or provider cache. | | `miftah profile remove --config --profile ` | `--config`, `--profile`; `--replacement-profile` when the profile has durable references | `--config `, `--profile `, `--replacement-profile ` | Removes one configured profile only when another remains. It requires an explicit different existing replacement before it changes a durable default, routing rule, routing-plugin binding, or configured profile lock; it performs one guarded replacement with recovery backup and fail-closed audit. It never resolves or deletes an underlying secret, provider cache, token cache, or active client session. A configured native OAuth binding causes `PROFILE_REMOVAL_OAUTH_CONNECTION`: configuration and OS-vault cleanup are not split by this generic command. | | `miftah profile test --config --profile ` | `--config`, `--profile` | `--config `, `--profile `, `--upstream ` | Runs only the selected provider adapter's declared read-only readiness check and writes its redacted report. It never accepts an arbitrary tool name, exposes provider output, changes configuration, reads provider token caches, or opens a browser. `--upstream` is required only when the configuration has multiple upstreams. It exits `0` only for `ready`; other bounded readiness results exit `1`. | | `miftah connection add --config ` | `--config`, `--profile`, `--issuer`, `--client-registration` | `--connection `, `--upstream `, repeated `--scope `, `--write` | Plans a v3 OAuth binding by default. `--write` applies the reviewed candidate with a unique recovery backup and configured audit event. It never resolves credentials or starts an upstream. | diff --git a/docs/console-api.md b/docs/console-api.md index 1d330324..41d16bc9 100644 --- a/docs/console-api.md +++ b/docs/console-api.md @@ -33,7 +33,7 @@ Browser sessions have a 15-minute idle limit and a one-hour absolute limit. Rest Every request must use the exact listener `Host`. Browser mutations, including bootstrap, must also use the exact listener `Origin`. Normal same-origin navigation and authenticated `GET` and `HEAD` requests may omit `Origin`, because browsers do not consistently attach it to read requests; hostile or duplicate Origin values are still rejected, cross-site cookies remain blocked by `SameSite=Strict`, and every mutation still requires exact Origin plus CSRF. Except for the bootstrap exchange and static application assets, every API endpoint requires the session cookie. JSON request bodies are capped at 64 KiB and must use `Content-Type: application/json`, except the client-entry onboarding endpoint has a bounded envelope limit of up to 136 KiB so it can carry one separately byte-limited 64 KiB pasted document after JSON escaping. An explicitly configured request-size cap remains authoritative. Headers are capped at 16 KiB. The process admits at most 240 trusted requests per minute and only eight bootstrap attempts per minute; excess requests receive `429` plus `Retry-After`. -`POST /api/v1/sessions`, `POST /api/v1/connections/:ref/connect`, `POST /api/v1/connections/:ref/test`, `POST /api/v1/connections/:ref/reauth`, and `DELETE /api/v1/connections/:ref/credential` must send `Content-Type: application/json` with the JSON body `{}`. `POST /api/v1/connections` instead accepts a strict JSON object with required `profile`, `issuer`, `clientRegistration`, and `scopes` fields plus optional `connectionRef` and `upstream` fields; unknown fields are rejected. `POST /api/v1/profile-readiness` accepts only a selected `profile` plus an optional `upstream`; the Console always selects an exact configured upstream when there is more than one. It can invoke only a provider-declared safe read-only check after audit and policy preflight, binds that operation to the browser request so a disconnected client cancels it, and never accepts a tool name or arguments. `POST /api/v1/profiles/default` accepts only one existing `profile` name. It changes only the durable `defaultProfile` through the guarded replacement and audit transaction; it never starts an upstream, opens OAuth, accepts a credential value, or changes provider-owned token caches. `POST /api/v1/profiles/rename` accepts only an existing `profile` and a distinct bounded `newProfile`; unknown fields are rejected. It atomically renames the profile plus configuration-owned default, routing, routing-plugin, and profile-lock references through the guarded audit transaction. It never reads, moves, copies, or deletes credentials, provider caches, profile state, identity records, or OS-vault data. A profile with a configured native OAuth binding is rejected because Console will not split configuration rename from the required atomic OS-vault credential migration. `POST /api/v1/profiles/remove` accepts only a selected `profile` and optional `replacementProfile`. It refuses the last profile and requires a different existing replacement whenever it must redirect a durable default, routing rule, routing-plugin binding, or configured profile lock. It does not resolve or delete secrets, provider caches, token caches, or OS-vault data. A profile with a configured native OAuth binding is rejected because Console will not split configuration removal from the required atomic OS-vault lifecycle. Native-OAuth first run accepts only non-secret configuration name, profile, description, exact resource/issuer, client-registration mode, and scopes. Reviewed-preset first run accepts only safe catalog metadata; for Google Search Console that can include strict named account objects containing a profile name, optional description, and client-secrets path, plus an explicit default when there is more than one account. The `local-stdio` preset additionally accepts one literal executable, a bounded literal argument array, optional native absolute working directory, optional credential-environment name, and a required `acceptLocalCommand: true` acknowledgement. It never uses a shell or starts that generic local executable during onboarding; shell/wrapper, URL, control, environment-reference, and credential-shaped values are rejected. On Windows, it accepts only a direct absolute `.exe` or `.com` binary, not a bare command or `.cmd`/`.bat` shim, so the direct argv path cannot fall back to a command processor. Client-entry first run accepts a bounded in-memory JSON document plus an explicit entry name; it accepts a selected local stdio entry that fits the finite static launch grammar or one credential-free HTTPS remote entry. A remote entry must be a `url` under `mcpServers` or `servers`, explicitly marked `type: "http"` or `"streamable-http"`; it must be HTTPS without userinfo, query, fragment, or opaque credential-shaped path segments. The Console never persists, returns, or audits the pasted document, never copies `env` or headers, and does not discover OAuth or call the remote endpoint during import. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. Token, password, cookie, raw secret contents, callback, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, opaque credential-shaped URL path segments, and arbitrary extra fields are rejected. +`POST /api/v1/sessions`, `POST /api/v1/connections/:ref/connect`, `POST /api/v1/connections/:ref/test`, `POST /api/v1/connections/:ref/reauth`, and `DELETE /api/v1/connections/:ref/credential` must send `Content-Type: application/json` with the JSON body `{}`. `POST /api/v1/connections` instead accepts a strict JSON object with required `profile`, `issuer`, `clientRegistration`, and `scopes` fields plus optional `connectionRef` and `upstream` fields; unknown fields are rejected. `POST /api/v1/profile-readiness` accepts only a selected `profile` plus an optional `upstream`; the Console always selects an exact configured upstream when there is more than one. It can invoke only a provider-declared safe read-only check after audit and policy preflight, binds that operation to the browser request so a disconnected client cancels it, and never accepts a tool name or arguments. `POST /api/v1/profiles/default` accepts only one existing `profile` name. It changes only the durable `defaultProfile` through the guarded replacement and audit transaction; it never starts an upstream, opens OAuth, accepts a credential value, or changes provider-owned token caches. `POST /api/v1/profiles/rename` accepts only an existing `profile` and a distinct bounded `newProfile`; unknown fields are rejected. It atomically renames the profile plus configuration-owned default, routing, routing-plugin, profile-lock, and native OAuth connection references. For native OAuth it moves the credential only between exact OS-vault keys and migrates non-secret connection metadata through one recoverable local transaction; credentials never enter configuration, HTTP responses, Console audit records, provider caches, profile state, or identity records. Existing MCP clients remain on their current session until restart. `POST /api/v1/profiles/remove` accepts only a selected `profile` and optional `replacementProfile`. It refuses the last profile and requires a different existing replacement whenever it must redirect a durable default, routing rule, routing-plugin binding, or configured profile lock. It does not resolve or delete secrets, provider caches, token caches, or OS-vault data. A profile with a configured native OAuth binding is rejected because Console will not split configuration removal from the required atomic OS-vault lifecycle. Native-OAuth first run accepts only non-secret configuration name, profile, description, exact resource/issuer, client-registration mode, and scopes. Reviewed-preset first run accepts only safe catalog metadata; for Google Search Console that can include strict named account objects containing a profile name, optional description, and client-secrets path, plus an explicit default when there is more than one account. The `local-stdio` preset additionally accepts one literal executable, a bounded literal argument array, optional native absolute working directory, optional credential-environment name, and a required `acceptLocalCommand: true` acknowledgement. It never uses a shell or starts that generic local executable during onboarding; shell/wrapper, URL, control, environment-reference, and credential-shaped values are rejected. On Windows, it accepts only a direct absolute `.exe` or `.com` binary, not a bare command or `.cmd`/`.bat` shim, so the direct argv path cannot fall back to a command processor. Client-entry first run accepts a bounded in-memory JSON document plus an explicit entry name; it accepts a selected local stdio entry that fits the finite static launch grammar or one credential-free HTTPS remote entry. A remote entry must be a `url` under `mcpServers` or `servers`, explicitly marked `type: "http"` or `"streamable-http"`; it must be HTTPS without userinfo, query, fragment, or opaque credential-shaped path segments. The Console never persists, returns, or audits the pasted document, never copies `env` or headers, and does not discover OAuth or call the remote endpoint during import. On Windows, a local import accepts only a direct absolute `.exe` or `.com` executable; bare runners such as `npx` or `node`, and `.cmd`/`.bat` shims, are rejected rather than being dispatched through a command processor. Token, password, cookie, raw secret contents, callback, environment wrappers, inline code, opaque values or assignments, unsupported remote transports, opaque credential-shaped URL path segments, and arbitrary extra fields are rejected. `POST /api/v1/profiles/environment-account` accepts only a profile slug, optional description, an environment-variable name, and optional `makeDefault: true`. It is available only for the currently selected trusted configuration when every existing profile has one matching direct credential environment binding for a single local `stdio` upstream. It rejects remote HTTP, provider-adapter, OAuth, named-upstream, raw credential, duplicate source-variable, and arbitrary profile-override cases. The operation records intent and completion in the fail-closed Console journal, writes via the guarded configuration transaction, never starts the upstream, and returns no credential environment reference or configuration bytes. @@ -49,7 +49,7 @@ Every request must use the exact listener `Host`. Browser mutations, including b | `POST /api/v1/configurations/:id/select` | Select one opaque catalog entry for this Console process. Requires CSRF; it never changes MCP client files or live MCP sessions. | | `POST /api/v1/profile-readiness` | Run one declared safe read-only check for the selected profile/upstream. Requires CSRF, never auto-runs, and returns only bounded status rather than provider output. | | `POST /api/v1/profiles/default` | Make one existing profile the durable default for future Miftah sessions. Requires CSRF, accepts only `{ "profile": "…" }`, and performs no OAuth, upstream, credential, or provider-cache operation. | -| `POST /api/v1/profiles/rename` | Rename one selected profile and configuration-owned default, routing, routing-plugin, and profile-lock references. Requires CSRF and accepts only `{ "profile": "…", "newProfile": "…" }`; it never moves credentials or provider caches and rejects a native OAuth binding rather than splitting OS-vault credential migration. | +| `POST /api/v1/profiles/rename` | Rename one selected profile and configuration-owned default, routing, routing-plugin, and profile-lock references. Requires CSRF and accepts only `{ "profile": "…", "newProfile": "…" }`. For native OAuth it moves the credential only between exact OS-vault keys and migrates non-secret connection metadata through one recoverable local transaction for OS-vault credential migration; it never exposes a credential or touches provider caches. An unfinished prior rename returns `409 oauth_profile_rename_recovery_required`; a completed recovery returns `409 profile_selection_stale`, so reload or reselect the configuration before retrying. | | `POST /api/v1/profiles/remove` | Remove one selected profile through the guarded audited configuration transaction. Requires CSRF and accepts only `{ "profile": "…", "replacementProfile": "…"? }`; a replacement is required for durable references. It never deletes secrets, provider caches, token caches, or OS-vault entries, and it rejects configured native OAuth bindings rather than splitting that lifecycle. | | `POST /api/v1/profiles/environment-account` | Add one simple local environment-backed account. Requires CSRF and a selected trusted configuration. It accepts only a profile, optional description, environment-variable name, and optional durable default; never a credential value. | | `GET /api/v1/profiles` | Return profile names, descriptions, tags, policy names, and named-upstream keys only. | diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index 3370d079..d181b072 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -98,6 +98,7 @@ export const ERROR_EXIT_CODES = { OAUTH_CONNECTION_NOT_FOUND: CLI_EXIT_CODES.config, OAUTH_CONNECTION_BINDING_MISMATCH: CLI_EXIT_CODES.config, OAUTH_CONNECTION_STORE_UNAVAILABLE: CLI_EXIT_CODES.config, + OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED: CLI_EXIT_CODES.operation, OAUTH_SECURE_STORE_UNAVAILABLE: CLI_EXIT_CODES.secret, OAUTH_CREDENTIAL_NOT_FOUND: CLI_EXIT_CODES.secret, OAUTH_CREDENTIAL_INVALID: CLI_EXIT_CODES.secret, diff --git a/src/cli/migrate-config.ts b/src/cli/migrate-config.ts index 99f8b95b..b0483e53 100644 --- a/src/cli/migrate-config.ts +++ b/src/cli/migrate-config.ts @@ -226,7 +226,7 @@ async function writeSyncedExclusive( } /** Creates one synced owner-only configuration file without replacing any existing path. */ -export async function writeNewConfigFile(path: string, content: string): Promise { +export async function writeNewConfigFile(path: string, content: string | Uint8Array): Promise { await writeSyncedExclusive(path, content, 0o600, async (targetPath) => { if (!(await secureWindowsConfigFile(targetPath))) { throw migrationWriteError("could not apply and verify a private Windows security descriptor"); diff --git a/src/console/console-application-service.ts b/src/console/console-application-service.ts index 745bfb2a..47543405 100644 --- a/src/console/console-application-service.ts +++ b/src/console/console-application-service.ts @@ -81,6 +81,7 @@ import { type ProfileRenameAuditSink, type ProfileRenameReport } from "../setup/profile-rename-onboarding.js"; +import type { OAuthProfileRenameDependencies } from "../oauth/profile-rename-transaction.js"; import { createWindowsPrivateDirectory, verifyWindowsConfigPathSecurity @@ -322,7 +323,7 @@ export interface ConsoleControlApplication { ): Promise; /** Available when an initialized configuration has more than one removable profile. */ removeProfile?(request: ConsoleProfileRemovalRequest): Promise; - /** Available when an initialized configuration has a non-OAuth-bound profile to rename. */ + /** Available when an initialized configuration has an existing profile to rename. */ renameProfile?(request: ConsoleProfileRenameRequest): Promise; onboardNativeOAuth(request: ConsoleNativeOAuthOnboardingRequest): Promise; clientSnippets(selection: ClientSelection): Promise; @@ -353,6 +354,8 @@ export interface ConsoleApplicationDependencies { readonly launcher?: ClientLauncher; /** Internal test/runtime seam; endpoint-first discovery is guarded and never persists credentials. */ readonly nativeOAuthFetch?: FetchLike; + /** Internal test/runtime seam; production defaults to the exact OS vault and local OAuth metadata store. */ + readonly oauthProfileRename?: OAuthProfileRenameDependencies; /** A selected dashboard entry that was read through the catalog's verified file handle. */ readonly trustedConfiguration?: ConsoleTrustedConfiguration; } @@ -623,6 +626,15 @@ class ConsoleProfileRenameAuditSink implements ProfileRenameAuditSink { status: event.status }); } + + recordRecovered(event: { readonly profile: string; readonly newProfile: string; readonly status: "success" }): Promise { + return this.trail.writeRequiredLifecycle({ + operation: "console/profile-rename-recovered", + name: event.newProfile, + profile: event.profile, + status: event.status + }); + } } /** Shared, in-process Console application layer. It never invokes the Miftah CLI. */ @@ -634,6 +646,7 @@ export class ConsoleApplicationService implements ConsoleControlApplication { private readonly generateConnectionRef: () => string; private readonly launcher: ClientLauncher | undefined; private readonly nativeOAuthFetch: FetchLike | undefined; + private readonly oauthProfileRename: OAuthProfileRenameDependencies | undefined; private readonly trustedConfiguration: ConsoleTrustedConfiguration | undefined; constructor( @@ -650,6 +663,7 @@ export class ConsoleApplicationService implements ConsoleControlApplication { this.generateConnectionRef = dependencies.generateConnectionRef ?? randomUUID; this.launcher = dependencies.launcher; this.nativeOAuthFetch = dependencies.nativeOAuthFetch; + this.oauthProfileRename = dependencies.oauthProfileRename; } async health(): Promise { @@ -1101,7 +1115,8 @@ export class ConsoleApplicationService implements ConsoleControlApplication { newProfile: request.newProfile }, { trustedSource: source, - audit: new ConsoleProfileRenameAuditSink(this.audit) + audit: new ConsoleProfileRenameAuditSink(this.audit), + ...(this.oauthProfileRename === undefined ? {} : { oauth: this.oauthProfileRename }) }); return { changed: result.changed, diff --git a/src/console/console-assets.ts b/src/console/console-assets.ts index cca32fb1..573c9831 100644 --- a/src/console/console-assets.ts +++ b/src/console/console-assets.ts @@ -233,14 +233,14 @@ const page = ` diff --git a/src/console/console-server.ts b/src/console/console-server.ts index 46c06314..faedfe24 100644 --- a/src/console/console-server.ts +++ b/src/console/console-server.ts @@ -419,6 +419,20 @@ function publicApplicationError(error: unknown): ConsoleHttpError { "This account has a native OAuth binding. Miftah refuses to split the rename from OS-vault credential migration." ); } + if (error.code === "PROFILE_SELECTION_STALE") { + return new ConsoleHttpError( + 409, + "profile_selection_stale", + "The configuration changed during recovery; reload it before retrying." + ); + } + if (error.code === "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED") { + return new ConsoleHttpError( + 409, + "oauth_profile_rename_recovery_required", + "Miftah must complete a prior local OAuth profile-rename recovery before retrying." + ); + } if ( error.code.startsWith("CONFIG_") || error.code.startsWith("OAUTH_CONNECTION_") || diff --git a/src/oauth/connection-lifecycle.ts b/src/oauth/connection-lifecycle.ts index 285d7dac..5cd30932 100644 --- a/src/oauth/connection-lifecycle.ts +++ b/src/oauth/connection-lifecycle.ts @@ -1,4 +1,9 @@ -import { connectionCredentialKey, type OAuthConnectionBinding, type OAuthIdentityState } from "./connection-types.js"; +import { + connectionCredentialKey, + connectionProfileTargetKey, + type OAuthConnectionBinding, + type OAuthIdentityState +} from "./connection-types.js"; import type { OAuthConnectionLifecycleAuditEvent, OAuthConnectionLifecycleAuditSink @@ -14,7 +19,47 @@ const maximumRefreshTimeoutMs = 120_000; const maximumRefreshSkewMs = 300_000; // A holder can legitimately execute the longest permitted refresh before committing its vault and // metadata transaction. This is a lock bound, not a retry-based correctness mechanism. -const connectionTransactionLockWaitMilliseconds = maximumRefreshTimeoutMs + 5_000; +export const oauthConnectionTransactionLockWaitMilliseconds = maximumRefreshTimeoutMs + 5_000; + +/** + * Coordinates exact OAuth binding keys and their unique config/profile/upstream targets in + * deterministic order. Every lifecycle mutation uses this same scope, so a cross-binding + * migration cannot race a refresh, connect, disconnect, status, identity update, or a competing + * connection registration from another Miftah process. + */ +export function oauthConnectionLifecycleLockKeys(bindings: readonly OAuthConnectionBinding[]): readonly string[] { + return [...new Set(bindings.flatMap((binding) => [ + connectionCredentialKey(binding), + connectionProfileTargetKey(binding) + ]))].sort((left, right) => left.localeCompare(right, "en", { sensitivity: "variant" })); +} + +export async function withOAuthConnectionBindingLocks( + bindings: readonly OAuthConnectionBinding[], + operation: () => Promise +): Promise { + const keys = oauthConnectionLifecycleLockKeys(bindings); + const acquire = async (index: number): Promise => { + if (index >= keys.length) return operation(); + return withOAuthLocalLock( + "connection-lifecycle", + keys[index]!, + oauthConnectionTransactionLockWaitMilliseconds, + () => acquire(index + 1) + ); + }; + try { + return await acquire(0); + } catch (error) { + if (error instanceof OAuthLocalLockUnavailableError) { + throw new MiftahError( + "OAUTH_CONNECTION_STORE_UNAVAILABLE", + "OAUTH_CONNECTION_STORE_UNAVAILABLE: OAuth connection lifecycle coordination is unavailable" + ); + } + throw error; + } +} /** * The future authorization engine supplies this port after it has obtained a refresh-capable @@ -106,11 +151,13 @@ export class OAuthConnectionLifecycle { } async register(binding: OAuthConnectionBinding): Promise { - return this.mutateBinding(binding, async () => { - const record = await this.options.registry.create(binding); - this.recordAudit("register", binding, record, "success"); - return record; - }); + return this.withBindingLock(binding, () => + this.mutateBinding(binding, async () => { + const record = await this.options.registry.create(binding); + this.recordAudit("register", binding, record, "success"); + return record; + }) + ); } async connect(binding: OAuthConnectionBinding, credential: OAuthCredential): Promise { @@ -208,30 +255,34 @@ export class OAuthConnectionLifecycle { } async status(binding: OAuthConnectionBinding): Promise { - return this.mutateBinding(binding, async () => { - const record = await this.options.registry.get(binding.connectionRef, binding); - if (isTerminalCredentialState(record.credentialState) || record.expiresAt === undefined) return record; - const expiresAt = Date.parse(record.expiresAt); - if (!Number.isFinite(expiresAt)) invalidLifecycle(); - const state = this.deriveExpiryState(expiresAt, this.currentTime()); - if (state === record.credentialState) return record; - const updated = await this.options.registry.setCredentialState( - binding.connectionRef, - binding, - state, - record.expiresAt - ); - this.recordAudit("status", binding, updated, "success"); - return updated; - }); + return this.withBindingLock(binding, () => + this.mutateBinding(binding, async () => { + const record = await this.options.registry.get(binding.connectionRef, binding); + if (isTerminalCredentialState(record.credentialState) || record.expiresAt === undefined) return record; + const expiresAt = Date.parse(record.expiresAt); + if (!Number.isFinite(expiresAt)) invalidLifecycle(); + const state = this.deriveExpiryState(expiresAt, this.currentTime()); + if (state === record.credentialState) return record; + const updated = await this.options.registry.setCredentialState( + binding.connectionRef, + binding, + state, + record.expiresAt + ); + this.recordAudit("status", binding, updated, "success"); + return updated; + }) + ); } async setIdentityState(binding: OAuthConnectionBinding, state: OAuthIdentityState): Promise { - return this.mutateBinding(binding, async () => { - const record = await this.options.registry.setIdentityState(binding.connectionRef, binding, state); - this.recordAudit("identity", binding, record, "success"); - return record; - }); + return this.withBindingLock(binding, () => + this.mutateBinding(binding, async () => { + const record = await this.options.registry.setIdentityState(binding.connectionRef, binding, state); + this.recordAudit("identity", binding, record, "success"); + return record; + }) + ); } private needsRefresh(credential: OAuthCredential): boolean { @@ -520,22 +571,7 @@ export class OAuthConnectionLifecycle { } private async withBindingLock(binding: OAuthConnectionBinding, operation: () => Promise): Promise { - try { - return await withOAuthLocalLock( - "connection-lifecycle", - connectionCredentialKey(binding), - connectionTransactionLockWaitMilliseconds, - operation - ); - } catch (error) { - if (error instanceof OAuthLocalLockUnavailableError) { - throw new MiftahError( - "OAUTH_CONNECTION_STORE_UNAVAILABLE", - "OAUTH_CONNECTION_STORE_UNAVAILABLE: OAuth connection lifecycle coordination is unavailable" - ); - } - throw error; - } + return withOAuthConnectionBindingLocks([binding], operation); } private currentGeneration(binding: OAuthConnectionBinding): number { diff --git a/src/oauth/connection-registry.ts b/src/oauth/connection-registry.ts index a6af0301..befef4c8 100644 --- a/src/oauth/connection-registry.ts +++ b/src/oauth/connection-registry.ts @@ -2,6 +2,7 @@ import { createOAuthConnectionBinding, parseOAuthConnectionRef, sameOAuthConnectionBinding, + sameOAuthConnectionBindingExceptProfile, type OAuthConnectionBinding, type OAuthConnectionRef, type OAuthCredentialState, @@ -293,6 +294,98 @@ export class OAuthConnectionRegistry { return this.serialized(async () => this.requireExact(await this.records(), connectionRef, expected)); } + /** Returns an exact non-secret metadata snapshot when it exists, without creating a record. */ + async snapshot(expected: OAuthConnectionBinding): Promise { + return this.serialized(async () => { + const record = (await this.records()).find((candidate) => candidate.binding.connectionRef === expected.connectionRef); + if (record === undefined) return undefined; + if (!sameOAuthConnectionBinding(record.binding, expected)) mismatchedConnection(); + return record; + }); + } + + /** + * Rebinds one persisted metadata record after its vault key has been moved. The caller must + * hold both lifecycle binding locks; this registry lock then makes the metadata write atomic. + */ + async migrateProfileBinding( + from: OAuthConnectionBinding, + to: OAuthConnectionBinding + ): Promise { + if (!sameOAuthConnectionBindingExceptProfile(from, to)) invalidConnection(); + return this.serialized(async () => { + const records = await this.records(); + const existing = records.find((record) => record.binding.connectionRef === from.connectionRef); + if (existing === undefined) return undefined; + if (sameOAuthConnectionBinding(existing.binding, to)) return existing; + if (!sameOAuthConnectionBinding(existing.binding, from)) mismatchedConnection(); + if ( + records.some( + (record) => + record.binding.connectionRef !== from.connectionRef && + record.binding.configIdentity === to.configIdentity && + record.binding.profile === to.profile && + record.binding.upstream === to.upstream + ) + ) { + invalidConnection(); + } + const updated: OAuthConnectionRecord = Object.freeze({ + binding: to, + credentialState: existing.credentialState, + identityState: existing.identityState, + ...(existing.expiresAt === undefined ? {} : { expiresAt: existing.expiresAt }), + updatedAt: this.timestamp() + }); + await this.persist(records.map((record) => (record.binding.connectionRef === from.connectionRef ? updated : record))); + return updated; + }); + } + + /** + * Restores the exact pre-rename non-secret record, or removes a record that did not exist + * before the interrupted rename. The caller must hold both lifecycle binding locks. + */ + async restoreProfileBinding( + from: OAuthConnectionBinding, + to: OAuthConnectionBinding, + original: OAuthConnectionRecord | undefined + ): Promise { + if (!sameOAuthConnectionBindingExceptProfile(from, to)) invalidConnection(); + if (original !== undefined && !sameOAuthConnectionBinding(normalizeRecord(original).binding, from)) invalidConnection(); + return this.serialized(async () => { + const records = await this.records(); + const existing = records.find((record) => record.binding.connectionRef === from.connectionRef); + if (original === undefined) { + if (existing === undefined) return; + if (!sameOAuthConnectionBinding(existing.binding, to)) mismatchedConnection(); + await this.persist(records.filter((record) => record.binding.connectionRef !== from.connectionRef)); + return; + } + + const restored = normalizeRecord(original); + if ( + records.some( + (record) => + record.binding.connectionRef !== from.connectionRef && + record.binding.configIdentity === from.configIdentity && + record.binding.profile === from.profile && + record.binding.upstream === from.upstream + ) + ) { + invalidConnection(); + } + if (existing === undefined) { + await this.persist([...records, restored]); + return; + } + if (!sameOAuthConnectionBinding(existing.binding, to) && !sameOAuthConnectionBinding(existing.binding, from)) { + mismatchedConnection(); + } + await this.persist(records.map((record) => (record.binding.connectionRef === from.connectionRef ? restored : record))); + }); + } + async setCredentialState( connectionRef: OAuthConnectionRef | string, expected: OAuthConnectionBinding, diff --git a/src/oauth/connection-types.ts b/src/oauth/connection-types.ts index d1ecd1f8..8d249d31 100644 --- a/src/oauth/connection-types.ts +++ b/src/oauth/connection-types.ts @@ -181,6 +181,21 @@ export function connectionCredentialKey(binding: OAuthConnectionBinding): string return `v1-${digest}`; } +/** Produces a stable opaque lock key for the unique config/profile/upstream connection target. */ +export function connectionProfileTargetKey(binding: OAuthConnectionBinding): string { + const digest = createHash("sha256") + .update( + lengthPrefixed([ + "miftah.oauth.profile-target-key.v1", + binding.configIdentity, + binding.profile, + binding.upstream + ]) + ) + .digest("base64url"); + return `profile-target-v1-${digest}`; +} + /** Performs exact tuple comparison; partial profile/provider matching is intentionally impossible. */ export function sameOAuthConnectionBinding(left: OAuthConnectionBinding, right: OAuthConnectionBinding): boolean { return ( @@ -196,3 +211,22 @@ export function sameOAuthConnectionBinding(left: OAuthConnectionBinding, right: left.scopes.every((scope, index) => scope === right.scopes[index]) ); } + +/** Allows only the profile label to change while preserving one exact OAuth connection identity. */ +export function sameOAuthConnectionBindingExceptProfile( + from: OAuthConnectionBinding, + to: OAuthConnectionBinding +): boolean { + return ( + from.version === to.version && + from.configIdentity === to.configIdentity && + from.connectionRef === to.connectionRef && + from.profile !== to.profile && + from.upstream === to.upstream && + from.canonicalResource === to.canonicalResource && + from.issuer === to.issuer && + from.clientRegistration === to.clientRegistration && + from.scopes.length === to.scopes.length && + from.scopes.every((scope, index) => scope === to.scopes[index]) + ); +} diff --git a/src/oauth/local-state-paths.ts b/src/oauth/local-state-paths.ts new file mode 100644 index 00000000..284a1843 --- /dev/null +++ b/src/oauth/local-state-paths.ts @@ -0,0 +1,21 @@ +import { homedir, platform } from "node:os"; +import { isAbsolute, join } from "node:path"; + +/** Returns the restrictive non-secret OAuth metadata location for the current OS user. */ +export function defaultOAuthConnectionMetadataPath(): string { + if (platform() === "win32") { + const configured = process.env.LOCALAPPDATA; + const root = configured !== undefined && isAbsolute(configured) + ? configured + : join(homedir(), "AppData", "Local"); + return join(root, "Miftah", "oauth-connections.json"); + } + if (platform() === "darwin") { + return join(homedir(), "Library", "Application Support", "Miftah", "oauth-connections.json"); + } + const configured = process.env.XDG_STATE_HOME; + const root = configured !== undefined && isAbsolute(configured) + ? configured + : join(homedir(), ".local", "state"); + return join(root, "miftah", "oauth-connections.json"); +} diff --git a/src/oauth/profile-rename-transaction.ts b/src/oauth/profile-rename-transaction.ts new file mode 100644 index 00000000..cbf0b805 --- /dev/null +++ b/src/oauth/profile-rename-transaction.ts @@ -0,0 +1,856 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, readFile, realpath, rm } from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; +import { + applyConfigReplacement, + readConfigMigrationSource, + restoreConfigReplacementWithoutPublishingBackup, + writeNewConfigFile, + type ConfigMigrationSource +} from "../cli/migrate-config.js"; +import type { MiftahConfig } from "../config/types.js"; +import { MiftahError } from "../utils/errors.js"; +import { withOAuthConnectionBindingLocks, oauthConnectionTransactionLockWaitMilliseconds } from "./connection-lifecycle.js"; +import { + FileOAuthConnectionMetadataStore, + OAuthConnectionRegistry, + type OAuthConnectionRecord +} from "./connection-registry.js"; +import { + connectionCredentialKey, + createOAuthConfigIdentity, + createOAuthConnectionBinding, + sameOAuthConnectionBinding, + sameOAuthConnectionBindingExceptProfile, + type OAuthConnectionBinding +} from "./connection-types.js"; +import { defaultOAuthConnectionMetadataPath } from "./local-state-paths.js"; +import { OAuthLocalLockUnavailableError, withOAuthLocalLock } from "./local-lock.js"; +import { createPlatformOAuthCredentialStore, type OAuthCredential, type OAuthCredentialStore } from "./secure-credential-store.js"; +import { SecretRedactor } from "../secrets/redact.js"; + +const journalVersion = 1; +const journalSuffix = ".miftah-oauth-profile-rename-journal"; +const sourceBackupMarker = ".miftah-oauth-profile-rename-source-"; +const maximumJournalBytes = 256 * 1_024; +const hashPattern = /^[a-f0-9]{64}$/u; +const credentialStates = new Set([ + "connected", + "expiring", + "expired", + "reauth-required", + "disconnected", + "unsupported" +]); +const identityStates = new Set([ + "verified", + "unverified", + "changed", + "expired", + "unavailable", + "unknown", + "unsupported" +]); + +export interface OAuthProfileRenameBindingPair { + readonly from: OAuthConnectionBinding; + readonly to: OAuthConnectionBinding; +} + +export interface OAuthProfileRenameDependencies { + readonly credentialStore: OAuthCredentialStore; + readonly registry: OAuthConnectionRegistry; + readonly journalStore?: OAuthProfileRenameJournalStore; +} + +export interface OAuthProfileRenameJournalStore { + load(configPath: string): Promise; + create(configPath: string, journal: OAuthProfileRenameJournal): Promise; + remove(configPath: string): Promise; +} + +export interface OAuthProfileRenameTransactionRequest { + readonly configPath: string; + readonly source: ConfigMigrationSource; + readonly candidate: MiftahConfig; + readonly bindings: readonly OAuthProfileRenameBindingPair[]; + /** Required configuration audit finalization, when the initiating surface has an audit sink. */ + readonly recordAudit?: () => Promise; +} + +export interface OAuthProfileRenameTransactionReport { + readonly backupPath: string; +} + +/** Non-secret profile labels supplied only to a trusted initiating audit surface during recovery. */ +export interface OAuthProfileRenameRecoveryAuditEvent { + readonly profile: string; + readonly newProfile: string; +} + +export interface OAuthProfileRenameRecoveryOptions { + /** Required to finish a forward recovery for a transaction that recorded a required audit intent. */ + readonly finalizeAudit?: (event: OAuthProfileRenameRecoveryAuditEvent) => Promise; +} + +interface OAuthProfileRenameJournalBinding { + readonly from: OAuthConnectionBinding; + readonly to: OAuthConnectionBinding; + readonly originalCredentialPresent: boolean; + readonly originalMetadata?: OAuthConnectionRecord; +} + +/** This journal contains only binding and lifecycle metadata; OAuth credentials never leave the OS vault. */ +export interface OAuthProfileRenameJournal { + readonly version: 1; + readonly configIdentity: string; + readonly sourceHash: string; + readonly targetHash: string; + /** Whether recovery must retain this transaction until its initiating audit surface finalizes it. */ + readonly auditRequired: boolean; + /** A current-user-only byte-exact source backup retained only while the transaction is in flight. */ + readonly sourceBackupPath: string; + readonly bindings: readonly OAuthProfileRenameJournalBinding[]; +} + +function recoveryRequired(): never { + throw new MiftahError( + "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED", + "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED: OAuth profile rename recovery must complete before another change can proceed" + ); +} + +function invalidJournal(): never { + recoveryRequired(); +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : undefined; +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + const expected = new Set(allowed); + return Object.keys(value).every((key) => expected.has(key)); +} + +function validTimestamp(value: unknown): value is string { + return typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value; +} + +function serializeBinding(binding: OAuthConnectionBinding): Record { + return { + version: 1, + configIdentity: binding.configIdentity, + connectionRef: binding.connectionRef, + profile: binding.profile, + upstream: binding.upstream, + canonicalResource: binding.canonicalResource, + issuer: binding.issuer, + clientRegistration: binding.clientRegistration, + scopes: [...binding.scopes] + }; +} + +function parseBinding(value: unknown): OAuthConnectionBinding { + const record = asRecord(value); + if ( + record === undefined || + !hasOnlyKeys(record, [ + "version", + "configIdentity", + "connectionRef", + "profile", + "upstream", + "canonicalResource", + "issuer", + "clientRegistration", + "scopes" + ]) || + record.version !== 1 || + typeof record.configIdentity !== "string" || + typeof record.connectionRef !== "string" || + typeof record.profile !== "string" || + typeof record.upstream !== "string" || + typeof record.canonicalResource !== "string" || + typeof record.issuer !== "string" || + typeof record.clientRegistration !== "string" || + !Array.isArray(record.scopes) || + !record.scopes.every((scope) => typeof scope === "string") + ) { + invalidJournal(); + } + try { + return createOAuthConnectionBinding({ + configIdentity: record.configIdentity, + connectionRef: record.connectionRef, + profile: record.profile, + upstream: record.upstream, + resource: record.canonicalResource, + issuer: record.issuer, + clientRegistration: record.clientRegistration, + scopes: record.scopes + }); + } catch { + invalidJournal(); + } +} + +function serializeMetadata(record: OAuthConnectionRecord): Record { + return { + binding: serializeBinding(record.binding), + credentialState: record.credentialState, + identityState: record.identityState, + ...(record.expiresAt === undefined ? {} : { expiresAt: record.expiresAt }), + updatedAt: record.updatedAt + }; +} + +function parseMetadata(value: unknown): OAuthConnectionRecord { + const record = asRecord(value); + if ( + record === undefined || + !hasOnlyKeys(record, ["binding", "credentialState", "identityState", "expiresAt", "updatedAt"]) || + !credentialStates.has(record.credentialState as string) || + !identityStates.has(record.identityState as string) || + !validTimestamp(record.updatedAt) || + (record.expiresAt !== undefined && !validTimestamp(record.expiresAt)) + ) { + invalidJournal(); + } + return Object.freeze({ + binding: parseBinding(record.binding), + credentialState: record.credentialState as OAuthConnectionRecord["credentialState"], + identityState: record.identityState as OAuthConnectionRecord["identityState"], + ...(record.expiresAt === undefined ? {} : { expiresAt: record.expiresAt as string }), + updatedAt: record.updatedAt as string + }); +} + +function serializeJournal(journal: OAuthProfileRenameJournal): string { + return JSON.stringify({ + version: journal.version, + configIdentity: journal.configIdentity, + sourceHash: journal.sourceHash, + targetHash: journal.targetHash, + auditRequired: journal.auditRequired, + sourceBackupPath: journal.sourceBackupPath, + bindings: journal.bindings.map((binding) => ({ + from: serializeBinding(binding.from), + to: serializeBinding(binding.to), + originalCredentialPresent: binding.originalCredentialPresent, + ...(binding.originalMetadata === undefined ? {} : { originalMetadata: serializeMetadata(binding.originalMetadata) }) + })) + }); +} + +function journalStorageLimitExceeded(): never { + throw new MiftahError( + "OAUTH_CONNECTION_INVALID", + "OAUTH_CONNECTION_INVALID: OAuth profile rename cannot create a journal that recovery could not safely reload" + ); +} + +function assertJournalStorageBounds(journal: OAuthProfileRenameJournal): void { + if (journal.bindings.length === 0 || journal.bindings.length > 128) journalStorageLimitExceeded(); + if (Buffer.byteLength(serializeJournal(journal), "utf8") > maximumJournalBytes) journalStorageLimitExceeded(); +} + +function parseJournal(value: unknown): OAuthProfileRenameJournal { + const record = asRecord(value); + if ( + record === undefined || + !hasOnlyKeys(record, ["version", "configIdentity", "sourceHash", "targetHash", "auditRequired", "sourceBackupPath", "bindings"]) || + record.version !== journalVersion || + typeof record.configIdentity !== "string" || + !hashPattern.test(record.configIdentity) || + typeof record.sourceHash !== "string" || + !hashPattern.test(record.sourceHash) || + typeof record.targetHash !== "string" || + !hashPattern.test(record.targetHash) || + typeof record.auditRequired !== "boolean" || + typeof record.sourceBackupPath !== "string" || + record.sourceBackupPath.length === 0 || + record.sourceBackupPath.length > 4_096 || + record.sourceBackupPath.includes("\u0000") || + !Array.isArray(record.bindings) || + record.bindings.length === 0 || + record.bindings.length > 128 + ) { + invalidJournal(); + } + const bindings = record.bindings.map((value) => { + const entry = asRecord(value); + if ( + entry === undefined || + !hasOnlyKeys(entry, ["from", "to", "originalCredentialPresent", "originalMetadata"]) || + typeof entry.originalCredentialPresent !== "boolean" + ) { + invalidJournal(); + } + const from = parseBinding(entry.from); + const to = parseBinding(entry.to); + if (!sameOAuthConnectionBindingExceptProfile(from, to)) invalidJournal(); + const originalMetadata = entry.originalMetadata === undefined ? undefined : parseMetadata(entry.originalMetadata); + if (originalMetadata !== undefined && !sameOAuthConnectionBinding(originalMetadata.binding, from)) invalidJournal(); + return Object.freeze({ from, to, originalCredentialPresent: entry.originalCredentialPresent, ...(originalMetadata === undefined ? {} : { originalMetadata }) }); + }); + if (new Set(bindings.map((binding) => connectionCredentialKey(binding.from))).size !== bindings.length) invalidJournal(); + if (bindings.some((binding) => binding.from.configIdentity !== record.configIdentity || binding.to.configIdentity !== record.configIdentity)) { + invalidJournal(); + } + const first = bindings[0]; + if (first === undefined) invalidJournal(); + if (bindings.some((binding) => binding.from.profile !== first.from.profile || binding.to.profile !== first.to.profile)) { + invalidJournal(); + } + return Object.freeze({ + version: 1, + configIdentity: record.configIdentity, + sourceHash: record.sourceHash, + targetHash: record.targetHash, + auditRequired: record.auditRequired, + sourceBackupPath: record.sourceBackupPath, + bindings: Object.freeze(bindings) + }); +} + +function journalPath(configPath: string): string { + return `${resolve(configPath)}${journalSuffix}`; +} + +/** Uses the same filesystem-canonical config identity that the remote OAuth runtime uses for vault keys. */ +export async function canonicalOAuthProfileRenameConfigPath(configPath: string): Promise { + const path = resolve(configPath); + try { + return await realpath(path); + } catch { + // The guarded source read below retains the authoritative configuration-path error. + return path; + } +} + +/** Exposed for diagnostics only; it contains no token or credential data. */ +export function oauthProfileRenameJournalPath(configPath: string): string { + return journalPath(configPath); +} + +/** File-backed immutable intent journal. Updates are deliberately unnecessary: recovery direction is the exact config bytes. */ +export class FileOAuthProfileRenameJournalStore implements OAuthProfileRenameJournalStore { + async load(configPath: string): Promise { + const path = journalPath(configPath); + let stats: Awaited>; + try { + stats = await lstat(path); + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return undefined; + recoveryRequired(); + } + if (!stats.isFile() || stats.isSymbolicLink()) invalidJournal(); + let content: string; + try { + content = await readFile(path, "utf8"); + } catch { + recoveryRequired(); + } + if (Buffer.byteLength(content, "utf8") > maximumJournalBytes) invalidJournal(); + try { + return parseJournal(JSON.parse(content)); + } catch (error) { + if (error instanceof MiftahError) throw error; + invalidJournal(); + } + } + + async create(configPath: string, journal: OAuthProfileRenameJournal): Promise { + try { + assertJournalStorageBounds(journal); + await writeNewConfigFile(journalPath(configPath), serializeJournal(journal)); + } catch (error) { + if (error instanceof MiftahError) throw error; + recoveryRequired(); + } + } + + async remove(configPath: string): Promise { + try { + await rm(journalPath(configPath), { force: true }); + } catch { + recoveryRequired(); + } + } +} + +function journalStoreFor(dependencies: OAuthProfileRenameDependencies): OAuthProfileRenameJournalStore { + return dependencies.journalStore ?? new FileOAuthProfileRenameJournalStore(); +} + +function bytesHash(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function serializedConfig(config: MiftahConfig): Buffer { + return Buffer.from(`${JSON.stringify(config, null, 2)}\n`); +} + +function sourceBackupPath(configPath: string): string { + return resolve(dirname(configPath), `.${basename(configPath)}${sourceBackupMarker}${randomUUID()}`); +} + +function validBackupPath(configPath: string, backupPath: string): boolean { + const resolvedConfig = resolve(configPath); + const resolvedBackup = resolve(backupPath); + return ( + dirname(resolvedBackup) === dirname(resolvedConfig) && + basename(resolvedBackup).startsWith(`.${basename(resolvedConfig)}${sourceBackupMarker}`) && + resolvedBackup !== resolvedConfig + ); +} + +async function createSourceBackup(path: string, source: ConfigMigrationSource): Promise { + try { + await writeNewConfigFile(path, source.originalBytes); + } catch (error) { + if (error instanceof MiftahError) throw error; + recoveryRequired(); + } +} + +async function readSourceBackup(configPath: string, journal: OAuthProfileRenameJournal): Promise { + if (!validBackupPath(configPath, journal.sourceBackupPath)) invalidJournal(); + let source: ConfigMigrationSource; + try { + const metadata = await lstat(journal.sourceBackupPath); + if (!metadata.isFile() || metadata.isSymbolicLink()) recoveryRequired(); + source = await readConfigMigrationSource(journal.sourceBackupPath); + } catch { + recoveryRequired(); + } + if (bytesHash(source.originalBytes) !== journal.sourceHash) recoveryRequired(); + return source; +} + +async function withProfileRenameLock(configPath: string, operation: () => Promise): Promise { + try { + return await withOAuthLocalLock( + "profile-rename", + createOAuthConfigIdentity(configPath), + oauthConnectionTransactionLockWaitMilliseconds, + operation + ); + } catch (error) { + if (error instanceof OAuthLocalLockUnavailableError) { + throw new MiftahError( + "OAUTH_CONNECTION_STORE_UNAVAILABLE", + "OAUTH_CONNECTION_STORE_UNAVAILABLE: OAuth profile rename coordination is unavailable" + ); + } + throw error; + } +} + +function credentialsEqual(left: OAuthCredential, right: OAuthCredential): boolean { + return ( + left.accessToken === right.accessToken && + left.refreshToken === right.refreshToken && + left.expiresAt === right.expiresAt && + left.clientId === right.clientId && + left.clientSecret === right.clientSecret && + left.scopes?.length === right.scopes?.length && + left.scopes?.every((scope, index) => scope === right.scopes?.[index]) !== false + ); +} + +async function captureJournal( + configPath: string, + source: ConfigMigrationSource, + candidate: MiftahConfig, + pairs: readonly OAuthProfileRenameBindingPair[], + auditRequired: boolean, + dependencies: OAuthProfileRenameDependencies +): Promise { + const bindings: OAuthProfileRenameJournalBinding[] = []; + for (const pair of pairs) { + const [credential, metadata] = await Promise.all([ + dependencies.credentialStore.load(pair.from), + dependencies.registry.snapshot(pair.from) + ]); + bindings.push(Object.freeze({ + from: pair.from, + to: pair.to, + originalCredentialPresent: credential !== undefined, + ...(metadata === undefined ? {} : { originalMetadata: metadata }) + })); + } + const backupPath = sourceBackupPath(configPath); + const journal = Object.freeze({ + version: 1, + configIdentity: createOAuthConfigIdentity(configPath), + sourceHash: bytesHash(source.originalBytes), + targetHash: bytesHash(serializedConfig(candidate)), + auditRequired, + sourceBackupPath: backupPath, + bindings: Object.freeze(bindings) + }); + assertJournalStorageBounds(journal); + await createSourceBackup(backupPath, source); + return journal; +} + +async function cleanupJournal( + configPath: string, + journal: OAuthProfileRenameJournal, + store: OAuthProfileRenameJournalStore +): Promise { + // Verify the backup before beginning cleanup. Remove the journal before its backup so a process + // crash can leave at most an owner-only orphaned source copy, never a recoverable journal that + // no longer has the bytes required to roll a failed recovered audit back safely. + await readSourceBackup(configPath, journal); + try { + await store.remove(configPath); + } catch { + recoveryRequired(); + } + try { + await rm(journal.sourceBackupPath); + } catch { + try { + await store.create(configPath, journal); + } catch { + recoveryRequired(); + } + recoveryRequired(); + } +} + +async function prepareTargetCredentials( + journal: OAuthProfileRenameJournal, + store: OAuthCredentialStore +): Promise { + for (const binding of journal.bindings) { + const [from, to] = await Promise.all([store.load(binding.from), store.load(binding.to)]); + if (to !== undefined) recoveryRequired(); + if (binding.originalCredentialPresent) { + if (from === undefined) recoveryRequired(); + await store.save(binding.to, from); + } else if (from !== undefined) { + recoveryRequired(); + } + } +} + +async function completeForward( + journal: OAuthProfileRenameJournal, + dependencies: OAuthProfileRenameDependencies +): Promise { + for (const binding of journal.bindings) { + const [from, to] = await Promise.all([ + dependencies.credentialStore.load(binding.from), + dependencies.credentialStore.load(binding.to) + ]); + if (binding.originalCredentialPresent) { + if (to === undefined) { + if (from === undefined) recoveryRequired(); + await dependencies.credentialStore.save(binding.to, from); + } else if (from !== undefined && !credentialsEqual(from, to)) { + recoveryRequired(); + } + } else if (from !== undefined || to !== undefined) { + recoveryRequired(); + } + const migrated = await dependencies.registry.migrateProfileBinding(binding.from, binding.to); + if (migrated === undefined && binding.originalMetadata !== undefined) recoveryRequired(); + } + + for (const binding of journal.bindings) { + if (!binding.originalCredentialPresent) continue; + const [from, to] = await Promise.all([ + dependencies.credentialStore.load(binding.from), + dependencies.credentialStore.load(binding.to) + ]); + if (to === undefined) recoveryRequired(); + if (from !== undefined) { + if (!credentialsEqual(from, to)) recoveryRequired(); + await dependencies.credentialStore.delete(binding.from); + } + } +} + +async function completeRollback( + journal: OAuthProfileRenameJournal, + dependencies: OAuthProfileRenameDependencies +): Promise { + for (const binding of journal.bindings) { + const [from, to] = await Promise.all([ + dependencies.credentialStore.load(binding.from), + dependencies.credentialStore.load(binding.to) + ]); + if (binding.originalCredentialPresent) { + if (from === undefined) { + if (to === undefined) recoveryRequired(); + await dependencies.credentialStore.save(binding.from, to); + } else if (to !== undefined && !credentialsEqual(from, to)) { + recoveryRequired(); + } + } else if (from !== undefined) { + recoveryRequired(); + } + if (to !== undefined) await dependencies.credentialStore.delete(binding.to); + await dependencies.registry.restoreProfileBinding(binding.from, binding.to, binding.originalMetadata); + } +} + +async function currentDirection(configPath: string, journal: OAuthProfileRenameJournal): Promise<"forward" | "rollback"> { + const current = await readConfigMigrationSource(configPath); + const currentHash = bytesHash(current.originalBytes); + if (currentHash === journal.sourceHash) return "rollback"; + if (currentHash === journal.targetHash) return "forward"; + recoveryRequired(); +} + +async function restoreSourceConfig(configPath: string, journal: OAuthProfileRenameJournal): Promise { + const current = await readConfigMigrationSource(configPath); + const currentHash = bytesHash(current.originalBytes); + if (currentHash === journal.sourceHash) return; + if (currentHash !== journal.targetHash) recoveryRequired(); + const original = await readSourceBackup(configPath, journal); + await restoreConfigReplacementWithoutPublishingBackup(configPath, current, original); + const restored = await readConfigMigrationSource(configPath); + if (bytesHash(restored.originalBytes) !== journal.sourceHash) recoveryRequired(); +} + +async function recoverLocked( + configPath: string, + journal: OAuthProfileRenameJournal, + dependencies: OAuthProfileRenameDependencies, + store: OAuthProfileRenameJournalStore, + options: OAuthProfileRenameRecoveryOptions +): Promise { + if (journal.configIdentity !== createOAuthConfigIdentity(configPath) || !validBackupPath(configPath, journal.sourceBackupPath)) { + invalidJournal(); + } + // Validate the byte-exact source backup before any forward credential or metadata mutation. + // Cleanup revalidates it immediately before removing durable recovery state. + await readSourceBackup(configPath, journal); + await withOAuthConnectionBindingLocks( + journal.bindings.flatMap((binding) => [binding.from, binding.to]), + async () => { + if (await currentDirection(configPath, journal) === "forward") { + const finalizeAudit = options.finalizeAudit; + if (journal.auditRequired) { + if (finalizeAudit === undefined) recoveryRequired(); + await completeForward(journal, dependencies); + const first = journal.bindings[0]; + if (first === undefined) invalidJournal(); + try { + await finalizeAudit({ profile: first.from.profile, newProfile: first.to.profile }); + } catch { + try { + await restoreSourceConfig(configPath, journal); + await completeRollback(journal, dependencies); + await cleanupJournal(configPath, journal, store); + } catch { + recoveryRequired(); + } + throw new MiftahError( + "AUDIT_WRITE_FAILED", + "AUDIT_WRITE_FAILED: required recovered audit finalization failed; configuration and OAuth connection state were restored" + ); + } + } else { + await completeForward(journal, dependencies); + } + } else { + await completeRollback(journal, dependencies); + } + await cleanupJournal(configPath, journal, store); + } + ); +} + +/** Returns whether a durable native OAuth profile-rename journal is present for this config. */ +export async function hasOAuthProfileRenameJournal(configPath: string): Promise { + const path = journalPath(await canonicalOAuthProfileRenameConfigPath(configPath)); + try { + await lstat(path); + return true; + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return false; + recoveryRequired(); + } +} + +/** Completes or reverses an interrupted transaction based solely on the byte-exact selected config. */ +export async function recoverOAuthProfileRename( + configPath: string, + dependencies: OAuthProfileRenameDependencies, + options: OAuthProfileRenameRecoveryOptions = {} +): Promise { + const path = await canonicalOAuthProfileRenameConfigPath(configPath); + const store = journalStoreFor(dependencies); + return withProfileRenameLock(path, async () => { + const journal = await store.load(path); + if (journal === undefined) return false; + await recoverLocked(path, journal, dependencies, store, options); + return true; + }); +} + +function auditFailureAfterRollback(backupPath: string): MiftahError { + return new MiftahError( + "AUDIT_WRITE_FAILED", + `AUDIT_WRITE_FAILED: required audit finalization failed; configuration and OAuth connection state were restored and the original configuration backup was retained at '${backupPath}'`, + { backupPaths: [backupPath] } + ); +} + +/** + * Publishes a native OAuth profile rename as one recoverable transaction. It never serializes a + * credential outside the OS vault: the durable journal contains only binding keys and metadata. + */ +export async function runOAuthProfileRenameTransaction( + request: OAuthProfileRenameTransactionRequest, + dependencies: OAuthProfileRenameDependencies +): Promise { + if (request.bindings.length === 0) { + throw new MiftahError("OAUTH_CONNECTION_INVALID", "OAUTH_CONNECTION_INVALID: OAuth profile rename requires at least one exact binding"); + } + if (request.bindings.length > 128) journalStorageLimitExceeded(); + const configPath = await canonicalOAuthProfileRenameConfigPath(request.configPath); + const store = journalStoreFor(dependencies); + return withProfileRenameLock(configPath, async () => { + const existing = await store.load(configPath); + if (existing !== undefined) { + await recoverLocked(configPath, existing, dependencies, store, {}); + recoveryRequired(); + } + + const expectedIdentity = createOAuthConfigIdentity(configPath); + const first = request.bindings[0]; + if ( + first === undefined || + new Set(request.bindings.map((binding) => connectionCredentialKey(binding.from))).size !== request.bindings.length || + request.bindings.some( + (binding) => + !sameOAuthConnectionBindingExceptProfile(binding.from, binding.to) || + binding.from.configIdentity !== expectedIdentity || + binding.to.configIdentity !== expectedIdentity || + binding.from.profile !== first.from.profile || + binding.to.profile !== first.to.profile + ) + ) { + throw new MiftahError("OAUTH_CONNECTION_INVALID", "OAUTH_CONNECTION_INVALID: OAuth profile rename bindings are invalid"); + } + + return withOAuthConnectionBindingLocks( + request.bindings.flatMap((binding) => [binding.from, binding.to]), + async () => { + const journal = await captureJournal( + configPath, + request.source, + request.candidate, + request.bindings, + request.recordAudit !== undefined, + dependencies + ); + try { + await store.create(configPath, journal); + } catch (error) { + try { + await rm(journal.sourceBackupPath, { force: true }); + } catch { + recoveryRequired(); + } + throw error; + } + + let backupPath: string | undefined; + try { + await prepareTargetCredentials(journal, dependencies.credentialStore); + backupPath = await applyConfigReplacement(configPath, request.source, request.candidate); + await completeForward(journal, dependencies); + } catch (error) { + try { + await restoreSourceConfig(configPath, journal); + await completeRollback(journal, dependencies); + await cleanupJournal(configPath, journal, store); + } catch { + recoveryRequired(); + } + throw error; + } + + try { + await request.recordAudit?.(); + } catch { + try { + await restoreSourceConfig(configPath, journal); + await completeRollback(journal, dependencies); + await cleanupJournal(configPath, journal, store); + } catch { + recoveryRequired(); + } + throw auditFailureAfterRollback(backupPath); + } + + await cleanupJournal(configPath, journal, store); + return { backupPath }; + } + ); + }); +} + +/** Builds the production-only native vault and non-secret metadata dependencies. */ +export async function createPlatformOAuthProfileRenameDependencies( + redactor: SecretRedactor = new SecretRedactor() +): Promise { + return { + credentialStore: await createPlatformOAuthCredentialStore(redactor), + registry: new OAuthConnectionRegistry(new FileOAuthConnectionMetadataStore(defaultOAuthConnectionMetadataPath())) + }; +} + +/** Builds the exact old/new vault bindings for configured connections owned by one renamed profile. */ +export function createOAuthProfileRenameBindingPairs( + configPath: string, + source: MiftahConfig, + candidate: MiftahConfig, + profile: string, + newProfile: string +): readonly OAuthProfileRenameBindingPair[] { + if (source.version !== "3" || candidate.version !== "3") return []; + const configIdentity = createOAuthConfigIdentity(configPath); + const pairs: OAuthProfileRenameBindingPair[] = []; + const renamedConnections = candidate.oauth?.connections ?? {}; + for (const [connectionRef, connection] of Object.entries(source.oauth?.connections ?? {})) { + if (connection.profile !== profile) continue; + const renamed = renamedConnections[connectionRef as keyof typeof renamedConnections]; + if (renamed === undefined || renamed.profile !== newProfile) { + throw new MiftahError("OAUTH_CONNECTION_INVALID", "OAUTH_CONNECTION_INVALID: renamed OAuth configuration is incomplete"); + } + const from = createOAuthConnectionBinding({ + configIdentity, + connectionRef, + profile: connection.profile, + upstream: connection.upstream, + resource: connection.resource, + issuer: connection.issuer, + clientRegistration: connection.clientRegistration, + scopes: connection.scopes + }); + const to = createOAuthConnectionBinding({ + configIdentity, + connectionRef, + profile: renamed.profile, + upstream: renamed.upstream, + resource: renamed.resource, + issuer: renamed.issuer, + clientRegistration: renamed.clientRegistration, + scopes: renamed.scopes + }); + if (!sameOAuthConnectionBindingExceptProfile(from, to)) { + throw new MiftahError("OAUTH_CONNECTION_INVALID", "OAUTH_CONNECTION_INVALID: renamed OAuth configuration changed an immutable binding field"); + } + pairs.push(Object.freeze({ from, to })); + } + return Object.freeze(pairs.sort((left, right) => connectionCredentialKey(left.from).localeCompare(connectionCredentialKey(right.from), "en", { sensitivity: "variant" }))); +} diff --git a/src/oauth/remote-oauth-runtime.ts b/src/oauth/remote-oauth-runtime.ts index e2d07942..08a0eba8 100644 --- a/src/oauth/remote-oauth-runtime.ts +++ b/src/oauth/remote-oauth-runtime.ts @@ -1,6 +1,4 @@ import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { homedir, platform } from "node:os"; -import { isAbsolute, join } from "node:path"; import type { MiftahConfig } from "../config/types.js"; import type { SecretRedactor } from "../secrets/redact.js"; import { MiftahError } from "../utils/errors.js"; @@ -25,6 +23,12 @@ import { type OAuthIdentityState } from "./connection-types.js"; import { createLoopbackOAuthAuthorizationHandoff } from "./loopback-authorization-handoff.js"; +import { defaultOAuthConnectionMetadataPath } from "./local-state-paths.js"; +import { + canonicalOAuthProfileRenameConfigPath, + recoverOAuthProfileRename, + type OAuthProfileRenameJournalStore +} from "./profile-rename-transaction.js"; import { RemoteOAuthClientProvider, type OAuthAuthorizationHandoff @@ -38,6 +42,8 @@ import { export interface RemoteOAuthRuntimeOptions { readonly metadataStore?: OAuthConnectionMetadataStore; readonly credentialStore?: OAuthCredentialStore; + /** Injectable only for bounded recovery tests; production uses the private config-adjacent journal. */ + readonly profileRenameJournalStore?: OAuthProfileRenameJournalStore; readonly fetch?: FetchLike; readonly createHandoff?: () => Promise; readonly now?: () => Date; @@ -81,23 +87,7 @@ class DeferredOAuthAuditSink implements OAuthConnectionLifecycleAuditSink { } /** Returns the restrictive non-secret OAuth metadata location for the current OS user. */ -export function defaultOAuthConnectionMetadataPath(): string { - if (platform() === "win32") { - const configured = process.env.LOCALAPPDATA; - const root = configured !== undefined && isAbsolute(configured) - ? configured - : join(homedir(), "AppData", "Local"); - return join(root, "Miftah", "oauth-connections.json"); - } - if (platform() === "darwin") { - return join(homedir(), "Library", "Application Support", "Miftah", "oauth-connections.json"); - } - const configured = process.env.XDG_STATE_HOME; - const root = configured !== undefined && isAbsolute(configured) - ? configured - : join(homedir(), ".local", "state"); - return join(root, "miftah", "oauth-connections.json"); -} +export { defaultOAuthConnectionMetadataPath } from "./local-state-paths.js"; /** Owns exact profile/upstream OAuth providers for one resolved Miftah configuration. */ export class RemoteOAuthRuntime { @@ -218,6 +208,18 @@ export async function createRemoteOAuthRuntime( options.metadataStore ?? new FileOAuthConnectionMetadataStore(defaultOAuthConnectionMetadataPath()) ); const store = options.credentialStore ?? await createPlatformOAuthCredentialStore(redactor); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const recoveredProfileRename = await recoverOAuthProfileRename(canonicalConfigPath, { + registry, + credentialStore: store, + ...(options.profileRenameJournalStore === undefined ? {} : { journalStore: options.profileRenameJournalStore }) + }); + if (recoveredProfileRename) { + throw new MiftahError( + "PROFILE_SELECTION_STALE", + "PROFILE_SELECTION_STALE: OAuth profile-rename recovery completed; reload configuration before starting the connection" + ); + } const metadataGuard = new OAuthMetadataFetchGuard(options.fetch); const refresher = new RemoteOAuthCredentialRefresher({ fetch: metadataGuard.fetch, @@ -232,7 +234,7 @@ export async function createRemoteOAuthRuntime( audit, ...(options.now === undefined ? {} : { now: options.now }) }); - const configIdentity = createOAuthConfigIdentity(configPath); + const configIdentity = createOAuthConfigIdentity(canonicalConfigPath); const bindings = new Map(); for (const [connectionRef, connection] of Object.entries(config.oauth?.connections ?? {})) { const binding = createOAuthConnectionBinding({ diff --git a/src/oauth/secure-credential-store.ts b/src/oauth/secure-credential-store.ts index 81380f39..433edc9b 100644 --- a/src/oauth/secure-credential-store.ts +++ b/src/oauth/secure-credential-store.ts @@ -159,8 +159,12 @@ export class PlatformOAuthCredentialStore implements OAuthCredentialStore { async load(binding: OAuthConnectionBinding): Promise { const bindingKey = connectionCredentialKey(binding); - const serialized = await this.vault(() => this.keyring.getPassword(keyringService, bindingKey)); - if (serialized === undefined) return undefined; + // @napi-rs/keyring may return null for a missing native vault item at runtime, + // despite its TypeScript declaration advertising undefined. Treat both native + // no-value sentinels as an absent credential before parsing an envelope. + const serialized = await this.vault(() => this.keyring.getPassword(keyringService, bindingKey)); + if (serialized === undefined || serialized === null) return undefined; + if (typeof serialized !== "string") invalidCredential(); const envelope = parseEnvelope(serialized, this.redactor); if (envelope.bindingKey !== bindingKey) invalidCredential(); return { diff --git a/src/setup/profile-rename-onboarding.ts b/src/setup/profile-rename-onboarding.ts index 1893ccc9..37e816be 100644 --- a/src/setup/profile-rename-onboarding.ts +++ b/src/setup/profile-rename-onboarding.ts @@ -12,6 +12,16 @@ import { planConfigMigration } from "../config/migrate-config.js"; import { resolvePath } from "../config/path-resolve.js"; import type { MiftahConfig } from "../config/types.js"; import { validateConfig } from "../config/validate-config.js"; +import { + createOAuthProfileRenameBindingPairs, + canonicalOAuthProfileRenameConfigPath, + createPlatformOAuthProfileRenameDependencies, + hasOAuthProfileRenameJournal, + recoverOAuthProfileRename, + runOAuthProfileRenameTransaction, + type OAuthProfileRenameDependencies +} from "../oauth/profile-rename-transaction.js"; +import { SecretRedactor } from "../secrets/redact.js"; import { MiftahError } from "../utils/errors.js"; export interface ProfileRenameRequest { @@ -43,6 +53,8 @@ export interface ProfileRenameAuditSink { intent(event: { readonly profile: string; readonly newProfile: string }): Promise; /** Records only a completed durable profile rename. */ record(event: { readonly profile: string; readonly newProfile: string; readonly status: "success" }): Promise; + /** Records completion after an interrupted OAuth transaction is recovered. */ + recordRecovered?(event: { readonly profile: string; readonly newProfile: string; readonly status: "success" }): Promise; } export interface ProfileRenameDependencies { @@ -50,6 +62,10 @@ export interface ProfileRenameDependencies { readonly trustedSource?: ConfigMigrationSource; /** Console callers provide their own redacted local lifecycle journal. */ readonly audit?: ProfileRenameAuditSink; + /** Testable boundary for the native OAuth vault and metadata transaction. */ + readonly oauth?: OAuthProfileRenameDependencies; + /** Shared operation redactor for native-vault parsing and lifecycle diagnostics. */ + readonly redactor?: SecretRedactor; } function sourceInput(source: ConfigMigrationSource): unknown { @@ -92,17 +108,6 @@ function profileAlreadyExists(): never { ); } -function nativeOAuthBoundProfile(): never { - throw new MiftahError( - "PROFILE_RENAME_OAUTH_CONNECTION", - "PROFILE_RENAME_OAUTH_CONNECTION: Miftah refuses to rename a profile with a configured native OAuth binding because its OS-vault credential is bound to the current profile name" - ); -} - -function hasNativeOAuthBinding(config: MiftahConfig, profile: string): boolean { - return config.version === "3" && Object.values(config.oauth?.connections ?? {}).some((connection) => connection.profile === profile); -} - function renameDurableReferences(config: MiftahConfig, profile: string, newProfile: string): void { if (config.defaultProfile === profile) config.defaultProfile = newProfile; for (const rule of config.routing?.rules ?? []) { @@ -115,12 +120,17 @@ function renameDurableReferences(config: MiftahConfig, profile: string, newProfi if (target === profile) plugin.bindings[binding] = newProfile; } } + if (config.version === "3") { + for (const connection of Object.values(config.oauth?.connections ?? {})) { + if (connection.profile === profile) connection.profile = newProfile; + } + } } /** * Creates a non-secret plan to rename a profile and every configuration-owned - * profile reference. Native OAuth-bound profiles are refused because their - * credential-vault key is intentionally bound to the current profile name. + * profile reference. It only changes declarative configuration; the runtime + * transaction migrates any matching native OAuth vault and metadata binding. */ export function planProfileRename(input: unknown, options: ProfileRenameRequest): ProfileRenamePlan { assertProfileRenameInput(options); @@ -129,8 +139,6 @@ export function planProfileRename(input: unknown, options: ProfileRenameRequest) const profile = config.profiles[options.profile]; if (profile === undefined) profileNotFound(); if (Object.hasOwn(config.profiles, options.newProfile)) profileAlreadyExists(); - if (hasNativeOAuthBinding(config, options.profile)) nativeOAuthBoundProfile(); - delete config.profiles[options.profile]; config.profiles[options.newProfile] = profile; renameDurableReferences(config, options.profile, options.newProfile); @@ -172,6 +180,15 @@ class ConfiguredProfileRenameAuditSink implements ProfileRenameAuditSink { status: event.status }); } + + recordRecovered(event: { readonly profile: string; readonly newProfile: string; readonly status: "success" }): Promise { + return this.trail.writeRequiredLifecycle({ + operation: "config/profile-rename-recovered", + name: event.newProfile, + profile: event.profile, + status: event.status + }); + } } function configuredAuditSink(config: MiftahConfig, configPath: string): ProfileRenameAuditSink | undefined { @@ -232,21 +249,68 @@ function auditFailureWithUnconfirmedRecovery(backupPath: string): MiftahError { } /** - * Performs one fail-closed guarded profile rename. It modifies only the - * selected configuration file and its configuration-owned references; no - * provider cache, OS-vault credential, identity record, profile state, or - * active client session is read, moved, copied, deleted, or otherwise changed. + * Performs one fail-closed guarded profile rename. A native OAuth binding is + * migrated through its own recoverable vault-plus-metadata transaction. Provider + * caches, persistent profile state, identity evidence, and active clients stay opaque. */ export async function runProfileRename( options: ProfileRenameRequest, dependencies: ProfileRenameDependencies = {} ): Promise { - const configPath = resolvePath(options.configPath); + const configPath = await canonicalOAuthProfileRenameConfigPath(resolvePath(options.configPath)); const source = dependencies.trustedSource ?? await readConfigMigrationSource(configPath); + const sourceConfig = planConfigMigration(sourceInput(source)).config as MiftahConfig; + const audit = dependencies.audit ?? configuredAuditSink(sourceConfig, configPath); + const redactor = dependencies.redactor ?? new SecretRedactor(); + let oauth = dependencies.oauth; + if (await hasOAuthProfileRenameJournal(configPath)) { + oauth ??= await createPlatformOAuthProfileRenameDependencies(redactor); + const recoverAudit = audit?.recordRecovered === undefined + ? undefined + : async (event: { readonly profile: string; readonly newProfile: string }) => { + await audit.recordRecovered?.({ ...event, status: "success" }); + }; + const recovered = await recoverOAuthProfileRename(configPath, oauth, { + ...(recoverAudit === undefined + ? {} + : { + finalizeAudit: recoverAudit + }) + }); + if (recovered) { + throw new MiftahError( + "PROFILE_SELECTION_STALE", + "PROFILE_SELECTION_STALE: OAuth profile-rename recovery completed; reload configuration before retrying" + ); + } + } const plan = planProfileRename(sourceInput(source), { ...options, configPath }); - const audit = dependencies.audit ?? configuredAuditSink(plan.config, configPath); + const oauthBindings = createOAuthProfileRenameBindingPairs( + configPath, + sourceConfig, + plan.config, + plan.profile, + plan.newProfile + ); await audit?.ensureWritable(); await audit?.intent({ profile: plan.profile, newProfile: plan.newProfile }); + if (oauthBindings.length > 0) { + oauth ??= await createPlatformOAuthProfileRenameDependencies(redactor); + const transaction = await runOAuthProfileRenameTransaction({ + configPath, + source, + candidate: plan.config, + bindings: oauthBindings, + ...(audit === undefined + ? {} + : { + recordAudit: async () => { + await audit.record({ profile: plan.profile, newProfile: plan.newProfile, status: "success" }); + } + }) + }, oauth); + return publicReport(plan, transaction.backupPath); + } const backupPath = await applyConfigReplacement(configPath, source, plan.config); try { await audit?.record({ profile: plan.profile, newProfile: plan.newProfile, status: "success" }); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 2dd2e1f6..36ecd1dc 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -103,6 +103,7 @@ export type MiftahErrorCode = | "OAUTH_CONNECTION_NOT_FOUND" | "OAUTH_CONNECTION_BINDING_MISMATCH" | "OAUTH_CONNECTION_STORE_UNAVAILABLE" + | "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED" | "OAUTH_SECURE_STORE_UNAVAILABLE" | "OAUTH_CREDENTIAL_NOT_FOUND" | "OAUTH_CREDENTIAL_INVALID" diff --git a/tests/cli-exit-codes.test.ts b/tests/cli-exit-codes.test.ts index 4fcee147..fc90d256 100644 --- a/tests/cli-exit-codes.test.ts +++ b/tests/cli-exit-codes.test.ts @@ -92,6 +92,7 @@ const expectedErrorExitCodes: Record = { OAUTH_CONNECTION_NOT_FOUND: CLI_EXIT_CODES.config, OAUTH_CONNECTION_BINDING_MISMATCH: CLI_EXIT_CODES.config, OAUTH_CONNECTION_STORE_UNAVAILABLE: CLI_EXIT_CODES.config, + OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED: CLI_EXIT_CODES.operation, OAUTH_SECURE_STORE_UNAVAILABLE: CLI_EXIT_CODES.secret, OAUTH_CREDENTIAL_NOT_FOUND: CLI_EXIT_CODES.secret, OAUTH_CREDENTIAL_INVALID: CLI_EXIT_CODES.secret, diff --git a/tests/console-application-service.test.ts b/tests/console-application-service.test.ts index f9c4efaf..90ecf135 100644 --- a/tests/console-application-service.test.ts +++ b/tests/console-application-service.test.ts @@ -4,6 +4,14 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ConsoleApplicationService } from "../src/console/console-application-service.js"; import { buildPresetConfig } from "../src/config/presets.js"; +import { + canonicalOAuthProfileRenameConfigPath, +} from "../src/oauth/profile-rename-transaction.js"; +import { + createOAuthConfigIdentity, + createOAuthConnectionBinding, + parseOAuthConnectionRef +} from "../src/oauth/connection-types.js"; import { discoverConsoleConfigCatalog, trustedConfigurationFor @@ -12,6 +20,7 @@ import { verifyWindowsConfigPathSecurity } from "../src/cli/windows-config-acl.j import { MiftahError } from "../src/utils/errors.js"; import { createPrivateConsoleDirectory } from "./helpers/private-console-directory.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; +import { createMemoryProfileRenameOAuthDependencies } from "./helpers/profile-rename-oauth-dependencies.js"; import { startOAuthCompatibilityProbe, type OAuthCompatibilityProbe @@ -924,6 +933,65 @@ describe("Console application service", () => { expect(JSON.stringify(audit)).not.toContain("WORK_API_KEY"); }); + it("renames a native OAuth profile through the Console application service", async () => { + const root = await mkdtemp(join(tmpdir(), "miftah-console-native-oauth-profile-rename-")); + temporaryDirectories.push(root); + const configPath = join(root, "analytics.json"); + await writeFile(configPath, `${JSON.stringify({ + version: "3", + name: "analytics", + defaultProfile: "work", + upstream: { transport: "streamable-http", url: "https://mcp.example.com/mcp" }, + profiles: { work: {}, personal: {} }, + oauth: { + connections: { + "oauthconn:11111111-1111-4111-8111-111111111111": { + profile: "work", + upstream: "default", + resource: "https://mcp.example.com/mcp", + issuer: "https://auth.example.com", + clientRegistration: "dynamic", + scopes: ["openid"] + } + } + } + }, null, 2)}\n`, { mode: 0o600 }); + const oauth = createMemoryProfileRenameOAuthDependencies(); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const from = createOAuthConnectionBinding({ + configIdentity: createOAuthConfigIdentity(canonicalConfigPath), + connectionRef: parseOAuthConnectionRef("oauthconn:11111111-1111-4111-8111-111111111111"), + profile: "work", + upstream: "default", + resource: "https://mcp.example.com/mcp", + issuer: "https://auth.example.com", + clientRegistration: "dynamic", + scopes: ["openid"] + }); + await oauth.credentials.save(from, { accessToken: "fixture-console-profile-rename-token" }); + await oauth.registry.create(from); + const service = new ConsoleApplicationService(configPath, { oauthProfileRename: oauth.dependencies }); + + await expect(service.renameProfile({ profile: "work", newProfile: "studio" })).resolves.toMatchObject({ + changed: true, + profile: "work", + newProfile: "studio" + }); + const to = createOAuthConnectionBinding({ + configIdentity: from.configIdentity, + connectionRef: from.connectionRef, + profile: "studio", + upstream: from.upstream, + resource: from.canonicalResource, + issuer: from.issuer, + clientRegistration: from.clientRegistration, + scopes: from.scopes + }); + await expect(oauth.credentials.load(from)).resolves.toBeUndefined(); + await expect(oauth.credentials.load(to)).resolves.toEqual({ accessToken: "fixture-console-profile-rename-token" }); + await expect(oauth.registry.snapshot(to)).resolves.toMatchObject({ binding: to }); + }); + it("returns allowlisted metadata and audit-records each exact OAuth lifecycle mutation", async () => { const calls: string[] = []; const configPath = await writeConfig(); diff --git a/tests/console-server.test.ts b/tests/console-server.test.ts index dc8ec92a..f27ef411 100644 --- a/tests/console-server.test.ts +++ b/tests/console-server.test.ts @@ -17,6 +17,7 @@ import { writePrivateConsoleFile } from "./helpers/private-console-directory.js"; import { environmentProfileConfig } from "./helpers/environment-profile-config.js"; +import { createMemoryProfileRenameOAuthDependencies } from "./helpers/profile-rename-oauth-dependencies.js"; import { startOAuthCompatibilityProbe } from "./helpers/fake-remote-upstream.js"; const temporaryDirectories: string[] = []; @@ -2916,7 +2917,7 @@ describe("local Console control server", () => { } }); - it("returns a safe explicit refusal when a requested rename has a configured native OAuth binding", async () => { + it("renames a configured native OAuth binding through the same strict Console request", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-console-profile-rename-oauth-")); temporaryDirectories.push(directory); const configPath = join(directory, "analytics.json"); @@ -2939,8 +2940,11 @@ describe("local Console control server", () => { } } }, null, 2)}\n`, { mode: 0o600 }); - const original = await readFile(configPath, "utf8"); - const server = await startConsoleServer(configPath, { bootstrapCredential: "test-only-bootstrap-credential" }); + const oauth = createMemoryProfileRenameOAuthDependencies(); + const server = await startConsoleServer(configPath, { + bootstrapCredential: "test-only-bootstrap-credential", + application: new ConsoleApplicationService(configPath, { oauthProfileRename: oauth.dependencies }) + }); try { const session = await bootstrapSession(server); @@ -2954,14 +2958,66 @@ describe("local Console control server", () => { }, body: JSON.stringify({ profile: "work", newProfile: "studio" }) }); - expect(response.status).toBe(422); + const responseBody = await response.json(); + expect(response.status, JSON.stringify(responseBody)).toBe(200); + expect(responseBody).toEqual({ + data: { + changed: true, + write: true, + profile: "work", + newProfile: "studio", + actions: ["Renamed profile 'work' to 'studio'."] + } + }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + defaultProfile: "studio", + profiles: { studio: {}, personal: {} }, + oauth: { + connections: { + "oauthconn:11111111-1111-4111-8111-111111111111": { profile: "studio" } + } + } + }); + } finally { + await server.close(); + } + }); + + it("requires configuration reload after a completed native OAuth rename recovery", async () => { + const configPath = await writeConfig(); + const application = { + async renameProfile() { + throw new MiftahError( + "PROFILE_SELECTION_STALE", + "PROFILE_SELECTION_STALE: OAuth profile-rename recovery completed; reload configuration before retrying" + ); + } + } as unknown as ConsoleControlApplication; + const server = await startConsoleServer(configPath, { + bootstrapCredential: "test-only-bootstrap-credential", + application + }); + + try { + const session = await bootstrapSession(server); + const response = await fetch(new URL("/api/v1/profiles/rename", server.url), { + method: "POST", + headers: { + origin: server.url.origin, + cookie: session.cookie, + "x-miftah-csrf": session.csrfToken, + "content-type": "application/json" + }, + body: JSON.stringify({ profile: "work", newProfile: "studio" }) + }); + + expect(response.status).toBe(409); expect(await response.json()).toEqual({ error: { - code: "profile_rename_oauth_connection", - message: "This account has a native OAuth binding. Miftah refuses to split the rename from OS-vault credential migration." + code: "profile_selection_stale", + message: "The configuration changed during recovery; reload it before retrying." } }); - expect(await readFile(configPath, "utf8")).toBe(original); } finally { await server.close(); } diff --git a/tests/helpers/profile-rename-oauth-dependencies.ts b/tests/helpers/profile-rename-oauth-dependencies.ts new file mode 100644 index 00000000..f17b9653 --- /dev/null +++ b/tests/helpers/profile-rename-oauth-dependencies.ts @@ -0,0 +1,55 @@ +import { + OAuthConnectionRegistry, + type OAuthConnectionMetadataStore, + type OAuthConnectionRecord +} from "../../src/oauth/connection-registry.js"; +import type { OAuthProfileRenameDependencies } from "../../src/oauth/profile-rename-transaction.js"; +import type { OAuthConnectionBinding } from "../../src/oauth/connection-types.js"; +import type { OAuthCredential, OAuthCredentialStore } from "../../src/oauth/secure-credential-store.js"; + +export class MemoryProfileRenameCredentialStore implements OAuthCredentialStore { + readonly entries = new Map(); + deleteFailure?: (binding: OAuthConnectionBinding) => Error | undefined; + + async load(binding: OAuthConnectionBinding): Promise { + const credential = this.entries.get(JSON.stringify(binding)); + return credential === undefined ? undefined : structuredClone(credential); + } + + async save(binding: OAuthConnectionBinding, credential: OAuthCredential): Promise { + this.entries.set(JSON.stringify(binding), structuredClone(credential)); + } + + async delete(binding: OAuthConnectionBinding): Promise { + const failure = this.deleteFailure?.(binding); + if (failure !== undefined) throw failure; + this.entries.delete(JSON.stringify(binding)); + } +} + +export class MemoryProfileRenameMetadataStore implements OAuthConnectionMetadataStore { + records: readonly OAuthConnectionRecord[] = []; + + async load(): Promise { + return structuredClone(this.records); + } + + async save(records: readonly OAuthConnectionRecord[]): Promise { + this.records = structuredClone(records); + } +} + +/** Hermetic test dependencies; production Console construction always uses OS-backed defaults. */ +export function createMemoryProfileRenameOAuthDependencies(): { + readonly credentials: MemoryProfileRenameCredentialStore; + readonly registry: OAuthConnectionRegistry; + readonly dependencies: OAuthProfileRenameDependencies; +} { + const credentials = new MemoryProfileRenameCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryProfileRenameMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + return { + credentials, + registry, + dependencies: { credentialStore: credentials, registry } + }; +} diff --git a/tests/oauth-connection-lifecycle.test.ts b/tests/oauth-connection-lifecycle.test.ts index 2fa6acc5..bade6ccb 100644 --- a/tests/oauth-connection-lifecycle.test.ts +++ b/tests/oauth-connection-lifecycle.test.ts @@ -1,11 +1,17 @@ import { setTimeout as delay } from "node:timers/promises"; import { describe, expect, it } from "vitest"; -import { OAuthConnectionLifecycle, type OAuthCredentialRefresher } from "../src/oauth/connection-lifecycle.js"; +import { + OAuthConnectionLifecycle, + oauthConnectionLifecycleLockKeys, + type OAuthCredentialRefresher +} from "../src/oauth/connection-lifecycle.js"; import type { OAuthConnectionLifecycleAuditEvent, OAuthConnectionLifecycleAuditSink } from "../src/oauth/audit.js"; import { OAuthConnectionRegistry, type OAuthConnectionMetadataStore, type OAuthConnectionRecord } from "../src/oauth/connection-registry.js"; import { createOAuthConfigIdentity, createOAuthConnectionBinding, + connectionCredentialKey, + connectionProfileTargetKey, parseOAuthConnectionRef, type OAuthConnectionBinding } from "../src/oauth/connection-types.js"; @@ -106,6 +112,20 @@ function deferred() { } describe("OAuth connection lifecycle", () => { + it("reserves one lifecycle lock for competing connection refs on the same profile/upstream target", () => { + const first = binding(); + const competing = binding({ connectionRef: "oauthconn:1d915a13-f8a5-45e0-8343-1e82e0939129" }); + const keys = oauthConnectionLifecycleLockKeys([first, competing]); + + expect(connectionProfileTargetKey(first)).toBe(connectionProfileTargetKey(competing)); + expect(keys).toEqual(expect.arrayContaining([ + connectionCredentialKey(first), + connectionCredentialKey(competing), + connectionProfileTargetKey(first) + ])); + expect(keys.filter((key) => key === connectionProfileTargetKey(first))).toHaveLength(1); + }); + it("reports persisted credentials as expired when wall-clock time passes their expiry", async () => { let now = "2026-07-22T00:00:00.000Z"; const metadata = new MemoryMetadataStore(); diff --git a/tests/oauth-connection-registry.test.ts b/tests/oauth-connection-registry.test.ts index 77e934ea..e4430a9b 100644 --- a/tests/oauth-connection-registry.test.ts +++ b/tests/oauth-connection-registry.test.ts @@ -92,6 +92,21 @@ describe("OAuth connection registry", () => { }); }); + it("fails closed rather than restoring a renamed record into a newly claimed profile/upstream target", async () => { + const store = new MemoryMetadataStore(); + const registry = new OAuthConnectionRegistry(store); + const from = binding(); + const to = binding({ profile: "studio" }); + const original = await registry.create(from); + await registry.migrateProfileBinding(from, to); + const competing = binding({ connectionRef: "oauthconn:1d915a13-f8a5-45e0-8343-1e82e0939129" }); + await registry.create(competing); + + await expect(registry.restoreProfileBinding(from, to, original)).rejects.toMatchObject({ code: "OAUTH_CONNECTION_INVALID" }); + expect(store.records.map((record) => record.binding)).toEqual(expect.arrayContaining([to, competing])); + expect(store.records.filter((record) => record.binding.profile === "work" && record.binding.upstream === "analytics")).toHaveLength(1); + }); + it("persists only safe metadata in a restrictive local registry file", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-registry-")); directories.push(directory); diff --git a/tests/oauth-connection-types.test.ts b/tests/oauth-connection-types.test.ts index 10ec79d6..cbea29cc 100644 --- a/tests/oauth-connection-types.test.ts +++ b/tests/oauth-connection-types.test.ts @@ -4,7 +4,8 @@ import { createOAuthConfigIdentity, createOAuthConnectionBinding, parseOAuthConnectionRef, - sameOAuthConnectionBinding + sameOAuthConnectionBinding, + sameOAuthConnectionBindingExceptProfile } from "../src/oauth/connection-types.js"; const ref = "oauthconn:8c08de29-46cc-4a70-8528-11b9da0382c5"; @@ -33,6 +34,14 @@ describe("OAuth connection binding", () => { expect(sameOAuthConnectionBinding(reordered, original)).toBe(true); }); + it("recognizes the exact profile-only binding migration shape", () => { + const original = binding(); + + expect(sameOAuthConnectionBindingExceptProfile(original, binding({ profile: "personal" }))).toBe(true); + expect(sameOAuthConnectionBindingExceptProfile(original, binding({ upstream: "billing" }))).toBe(false); + expect(sameOAuthConnectionBindingExceptProfile(original, binding({ connectionRef: "oauthconn:1d915a13-f8a5-45e0-8343-1e82e0939129" }))).toBe(false); + }); + it.each([ ["profile", "personal"], ["upstream", "billing"], diff --git a/tests/oauth-console-threat-model-docs-contract.test.ts b/tests/oauth-console-threat-model-docs-contract.test.ts index 74b3b433..9c0c3ed1 100644 --- a/tests/oauth-console-threat-model-docs-contract.test.ts +++ b/tests/oauth-console-threat-model-docs-contract.test.ts @@ -39,6 +39,8 @@ describe("OAuth and Console threat-model documentation contract", () => { expect(consoleApi).toContain("`POST /api/v1/profile-readiness`"); expect(consoleApi).toContain("`POST /api/v1/profiles/rename`"); expect(consoleApi).toContain("OS-vault credential migration"); + expect(consoleApi).toContain("moves the credential only between exact OS-vault keys and migrates non-secret connection metadata through one recoverable local transaction"); + expect(consoleApi).toContain("An unfinished prior rename returns `409 oauth_profile_rename_recovery_required`; a completed recovery returns `409 profile_selection_stale`"); expect(consoleApi).toContain("provider-declared safe read-only check"); expect(consoleApi).toContain("GSC accepts only structured account names/descriptions/client-secrets paths"); expect(consoleApi).toContain("`GET /api/v1/client-snippets?client=`"); diff --git a/tests/oauth-profile-rename-transaction.test.ts b/tests/oauth-profile-rename-transaction.test.ts new file mode 100644 index 00000000..1939b1cc --- /dev/null +++ b/tests/oauth-profile-rename-transaction.test.ts @@ -0,0 +1,749 @@ +import { createHash, randomUUID } from "node:crypto"; +import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OAuthConnectionRegistry } from "../src/oauth/connection-registry.js"; +import { + createOAuthConfigIdentity, + createOAuthConnectionBinding, + parseOAuthConnectionRef +} from "../src/oauth/connection-types.js"; +import { + canonicalOAuthProfileRenameConfigPath, + FileOAuthProfileRenameJournalStore, + oauthProfileRenameJournalPath, + recoverOAuthProfileRename, + runOAuthProfileRenameTransaction, + type OAuthProfileRenameJournal +} from "../src/oauth/profile-rename-transaction.js"; +import { readConfigMigrationSource } from "../src/cli/migrate-config.js"; +import { planProfileRename, runProfileRename } from "../src/setup/profile-rename-onboarding.js"; +import { MiftahError } from "../src/utils/errors.js"; +import { + MemoryProfileRenameCredentialStore as MemoryCredentialStore, + MemoryProfileRenameMetadataStore as MemoryMetadataStore +} from "./helpers/profile-rename-oauth-dependencies.js"; + +const directories: string[] = []; +const connectionRef = "oauthconn:11111111-1111-4111-8111-111111111111"; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +function binding(configPath: string, profile: string, reference = connectionRef) { + return createOAuthConnectionBinding({ + configIdentity: createOAuthConfigIdentity(configPath), + connectionRef: parseOAuthConnectionRef(reference), + profile, + upstream: "default", + resource: "https://mcp.example.com/mcp", + issuer: "https://auth.example.com", + clientRegistration: "dynamic", + scopes: ["openid"] + }); +} + +async function writeOAuthConfig(configPath: string): Promise { + await writeFile(configPath, `${JSON.stringify({ + version: "3", + name: "remote-analytics", + defaultProfile: "work", + upstream: { transport: "streamable-http", url: "https://mcp.example.com/mcp" }, + profiles: { work: {}, personal: {} }, + oauth: { + connections: { + [connectionRef]: { + profile: "work", + upstream: "default", + resource: "https://mcp.example.com/mcp", + issuer: "https://auth.example.com", + clientRegistration: "dynamic", + scopes: ["openid"] + } + } + } + }, null, 2)}\n`, { mode: 0o600 }); +} + +function hash(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +describe("native OAuth profile rename transaction", () => { + it("rejects an unrecoverable journal binding count before touching config, vault, or durable state", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-too-many-bindings-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const originalBytes = await readFile(configPath); + const source = await readConfigMigrationSource(configPath); + const candidate = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }).config; + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + const create = vi.fn(async () => undefined); + const journalStore = { load: async () => undefined, create, remove: async () => undefined }; + const bindings = Array.from({ length: 129 }, () => { + const reference = `oauthconn:${randomUUID()}`; + return { + from: binding(canonicalConfigPath, "work", reference), + to: binding(canonicalConfigPath, "studio", reference) + }; + }); + + await expect(runOAuthProfileRenameTransaction( + { configPath, source, candidate, bindings }, + { credentialStore: credentials, registry, journalStore } + )).rejects.toMatchObject({ code: "OAUTH_CONNECTION_INVALID" }); + + expect(create).not.toHaveBeenCalled(); + expect(credentials.entries).toEqual(new Map()); + await expect(readFile(configPath)).resolves.toEqual(originalBytes); + }); + + it("does not persist a journal whose valid bindings exceed the recovery byte limit", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-oversized-journal-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const oversizedScopes = Array.from({ length: 64 }, () => "x".repeat(256)); + const journal: OAuthProfileRenameJournal = { + version: 1, + configIdentity: createOAuthConfigIdentity(canonicalConfigPath), + sourceHash: hash(Buffer.from("source")), + targetHash: hash(Buffer.from("target")), + auditRequired: false, + sourceBackupPath: join(dirname(canonicalConfigPath), ".remote-analytics.json.miftah-oauth-profile-rename-source-oversized"), + bindings: Array.from({ length: 128 }, () => { + const reference = `oauthconn:${randomUUID()}`; + const from = { ...binding(canonicalConfigPath, "work", reference), scopes: oversizedScopes }; + const to = { ...binding(canonicalConfigPath, "studio", reference), scopes: oversizedScopes }; + return { from, to, originalCredentialPresent: false }; + }) + }; + const store = new FileOAuthProfileRenameJournalStore(); + + await expect(store.create(canonicalConfigPath, journal)).rejects.toMatchObject({ code: "OAUTH_CONNECTION_INVALID" }); + await expect(readFile(oauthProfileRenameJournalPath(canonicalConfigPath), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rejects duplicate source credential bindings before touching config, vault, or durable state", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-duplicate-binding-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const originalBytes = await readFile(configPath); + const source = await readConfigMigrationSource(configPath); + const candidate = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }).config; + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + const create = vi.fn(async () => undefined); + const journalStore = { load: async () => undefined, create, remove: async () => undefined }; + const pair = { + from: binding(canonicalConfigPath, "work"), + to: binding(canonicalConfigPath, "studio") + }; + + await expect(runOAuthProfileRenameTransaction( + { configPath, source, candidate, bindings: [pair, pair] }, + { credentialStore: credentials, registry, journalStore } + )).rejects.toMatchObject({ code: "OAUTH_CONNECTION_INVALID" }); + + expect(create).not.toHaveBeenCalled(); + expect(credentials.entries).toEqual(new Map()); + await expect(readFile(configPath)).resolves.toEqual(originalBytes); + }); + + it("rejects mixed source and target profile pairs before touching config, vault, or durable state", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-mixed-profiles-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const originalBytes = await readFile(configPath); + const source = await readConfigMigrationSource(configPath); + const candidate = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }).config; + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + const create = vi.fn(async () => undefined); + const journalStore = { load: async () => undefined, create, remove: async () => undefined }; + const workPair = { + from: binding(canonicalConfigPath, "work"), + to: binding(canonicalConfigPath, "studio") + }; + const personalReference = `oauthconn:${randomUUID()}`; + const personalPair = { + from: binding(canonicalConfigPath, "personal", personalReference), + to: binding(canonicalConfigPath, "studio", personalReference) + }; + + await expect(runOAuthProfileRenameTransaction( + { configPath, source, candidate, bindings: [workPair, personalPair] }, + { credentialStore: credentials, registry, journalStore } + )).rejects.toMatchObject({ code: "OAUTH_CONNECTION_INVALID" }); + + expect(create).not.toHaveBeenCalled(); + expect(credentials.entries).toEqual(new Map()); + await expect(readFile(configPath)).resolves.toEqual(originalBytes); + }); + + it("moves the exact vault key and non-secret metadata only with the guarded config rename", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const credentials = new MemoryCredentialStore(); + const metadata = new MemoryMetadataStore(); + const registry = new OAuthConnectionRegistry(metadata, () => "2030-01-02T03:04:05.000Z"); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token", refreshToken: "fixture-refresh-token" }); + await registry.create(oldBinding); + await registry.setCredentialState(oldBinding.connectionRef, oldBinding, "connected"); + await registry.setIdentityState(oldBinding.connectionRef, oldBinding, "verified"); + const audit = { + ensureWritable: vi.fn().mockResolvedValue(undefined), + intent: vi.fn().mockResolvedValue(undefined), + record: vi.fn().mockResolvedValue(undefined) + }; + + const report = await runProfileRename( + { configPath, profile: "work", newProfile: "studio" }, + { audit, oauth: { credentialStore: credentials, registry } } + ); + + expect(report).toMatchObject({ changed: true, profile: "work", newProfile: "studio", write: true }); + expect(JSON.stringify(report)).not.toContain("fixture-access-token"); + await expect(credentials.load(oldBinding)).resolves.toBeUndefined(); + await expect(credentials.load(newBinding)).resolves.toEqual({ + accessToken: "fixture-access-token", + refreshToken: "fixture-refresh-token" + }); + await expect(registry.get(newBinding.connectionRef, newBinding)).resolves.toMatchObject({ + credentialState: "connected", + identityState: "verified" + }); + await expect(readFile(oauthProfileRenameJournalPath(canonicalConfigPath), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + expect(audit.intent).toHaveBeenCalledWith({ profile: "work", newProfile: "studio" }); + expect(audit.record).toHaveBeenCalledWith({ profile: "work", newProfile: "studio", status: "success" }); + expect(JSON.stringify(audit.record.mock.calls)).not.toContain("fixture-refresh-token"); + const written = JSON.parse(await readFile(configPath, "utf8")); + expect(written.oauth.connections[connectionRef].profile).toBe("studio"); + }); + + it("fails closed when a recorded metadata binding disappears during forward migration", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-missing-metadata-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const credentials = new MemoryCredentialStore(); + const metadata = new MemoryMetadataStore(); + const registry = new OAuthConnectionRegistry(metadata, () => "2030-01-02T03:04:05.000Z"); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token" }); + await registry.create(oldBinding); + vi.spyOn(registry, "migrateProfileBinding").mockResolvedValue(undefined); + + await expect(runProfileRename( + { configPath, profile: "work", newProfile: "studio" }, + { oauth: { credentialStore: credentials, registry } } + )).rejects.toMatchObject({ code: "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED" }); + + await expect(credentials.load(oldBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + await expect(credentials.load(newBinding)).resolves.toBeUndefined(); + await expect(registry.get(oldBinding.connectionRef, oldBinding)).resolves.toMatchObject({ binding: oldBinding }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + profiles: { work: {}, personal: {} }, + oauth: { connections: { [connectionRef]: { profile: "work" } } } + }); + }); + + it("rolls back config, vault, and metadata when old-vault retirement fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-rollback-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const credentials = new MemoryCredentialStore(); + credentials.deleteFailure = (binding) => binding.profile === "work" + ? new Error("forced old-vault deletion failure") + : undefined; + const metadata = new MemoryMetadataStore(); + const registry = new OAuthConnectionRegistry(metadata, () => "2030-01-02T03:04:05.000Z"); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token" }); + await registry.create(oldBinding); + await registry.setIdentityState(oldBinding.connectionRef, oldBinding, "verified"); + const audit = { + ensureWritable: vi.fn().mockResolvedValue(undefined), + intent: vi.fn().mockResolvedValue(undefined), + record: vi.fn().mockResolvedValue(undefined) + }; + + await expect(runProfileRename( + { configPath, profile: "work", newProfile: "studio" }, + { audit, oauth: { credentialStore: credentials, registry } } + )).rejects.toThrow("forced old-vault deletion failure"); + + await expect(credentials.load(oldBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + await expect(credentials.load(newBinding)).resolves.toBeUndefined(); + await expect(registry.get(oldBinding.connectionRef, oldBinding)).resolves.toMatchObject({ identityState: "verified" }); + await expect(readFile(oauthProfileRenameJournalPath(canonicalConfigPath), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + const restored = JSON.parse(await readFile(configPath, "utf8")); + expect(restored.profiles).toHaveProperty("work"); + expect(restored.profiles).not.toHaveProperty("studio"); + expect(restored.oauth.connections[connectionRef].profile).toBe("work"); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it("restores config, vault, and metadata when required audit finalization fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-audit-rollback-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const credentials = new MemoryCredentialStore(); + const metadata = new MemoryMetadataStore(); + const registry = new OAuthConnectionRegistry(metadata, () => "2030-01-02T03:04:05.000Z"); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token", refreshToken: "fixture-refresh-token" }); + await registry.create(oldBinding); + await registry.setIdentityState(oldBinding.connectionRef, oldBinding, "verified"); + const originalMetadata = await registry.snapshot(oldBinding); + const audit = { + ensureWritable: vi.fn().mockResolvedValue(undefined), + intent: vi.fn().mockResolvedValue(undefined), + record: vi.fn().mockRejectedValue(new MiftahError("AUDIT_WRITE_FAILED", "fixture audit finalization failure")) + }; + + await expect(runProfileRename( + { configPath, profile: "work", newProfile: "studio" }, + { audit, oauth: { credentialStore: credentials, registry } } + )).rejects.toMatchObject({ + code: "AUDIT_WRITE_FAILED", + message: expect.stringContaining("configuration and OAuth connection state were restored") + }); + + await expect(credentials.load(oldBinding)).resolves.toEqual({ + accessToken: "fixture-access-token", + refreshToken: "fixture-refresh-token" + }); + await expect(credentials.load(newBinding)).resolves.toBeUndefined(); + await expect(registry.get(oldBinding.connectionRef, oldBinding)).resolves.toEqual(originalMetadata); + const restored = JSON.parse(await readFile(configPath, "utf8")); + expect(restored.oauth.connections[connectionRef].profile).toBe("work"); + expect(audit.record).toHaveBeenCalledTimes(1); + }); + + it("fails closed when journal creation also leaves its private source backup uncleared", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-journal-cleanup-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + const audit = { ensureWritable: vi.fn(async () => undefined), intent: vi.fn(async () => undefined), record: vi.fn(async () => undefined) }; + const journalStore = { + load: async () => undefined, + create: async (_path: string, journal: OAuthProfileRenameJournal) => { + await rm(journal.sourceBackupPath, { force: true }); + await mkdir(journal.sourceBackupPath); + throw new Error("forced journal creation failure"); + }, + remove: async () => undefined + }; + + await expect(runProfileRename( + { configPath, profile: "work", newProfile: "studio" }, + { audit, oauth: { credentialStore: credentials, registry, journalStore } } + )).rejects.toMatchObject({ code: "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED" }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + profiles: { work: {}, personal: {} } + }); + }); + + it("retains the verified source backup when durable journal removal fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-journal-removal-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const originalBytes = await readFile(configPath); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + const oldBinding = binding(canonicalConfigPath, "work"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token" }); + await registry.create(oldBinding); + const durableJournalStore = new FileOAuthProfileRenameJournalStore(); + let failRemoval = true; + let sourceBackupPresentWhenJournalRemovalBegan = false; + const journalStore = { + load: durableJournalStore.load.bind(durableJournalStore), + create: durableJournalStore.create.bind(durableJournalStore), + remove: async (path: string) => { + if (failRemoval) { + failRemoval = false; + const journal = await durableJournalStore.load(path); + sourceBackupPresentWhenJournalRemovalBegan = journal !== undefined && await readFile(journal.sourceBackupPath) + .then(() => true) + .catch(() => false); + throw new Error("forced journal removal failure"); + } + await durableJournalStore.remove(path); + } + }; + const audit = { + ensureWritable: vi.fn().mockResolvedValue(undefined), + intent: vi.fn().mockResolvedValue(undefined), + record: vi.fn().mockResolvedValue(undefined) + }; + + await expect(runProfileRename( + { configPath, profile: "work", newProfile: "studio" }, + { audit, oauth: { credentialStore: credentials, registry, journalStore } } + )).rejects.toMatchObject({ code: "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED" }); + + expect(sourceBackupPresentWhenJournalRemovalBegan).toBe(true); + const journal = await durableJournalStore.load(canonicalConfigPath); + expect(journal).toBeDefined(); + await expect(readFile(journal?.sourceBackupPath ?? "")).resolves.toEqual(originalBytes); + + await expect(recoverOAuthProfileRename( + configPath, + { credentialStore: credentials, registry, journalStore }, + { finalizeAudit: async () => { throw new Error("forced recovered audit failure"); } } + )).rejects.toMatchObject({ code: "AUDIT_WRITE_FAILED" }); + await expect(credentials.load(oldBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ + profiles: { work: {}, personal: {} } + }); + }); + + it("finishes a crash-interrupted target configuration from the durable non-secret journal", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-recovery-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const originalBytes = await readFile(configPath); + const plan = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }); + const targetBytes = Buffer.from(`${JSON.stringify(plan.config, null, 2)}\n`); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + const credentials = new MemoryCredentialStore(); + const metadata = new MemoryMetadataStore(); + const registry = new OAuthConnectionRegistry(metadata, () => "2030-01-02T03:04:05.000Z"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token" }); + await credentials.save(newBinding, { accessToken: "fixture-access-token" }); + await registry.create(oldBinding); + await registry.setIdentityState(oldBinding.connectionRef, oldBinding, "verified"); + const sourceBackupPath = join(dirname(canonicalConfigPath), ".remote-analytics.json.miftah-oauth-profile-rename-source-fixture"); + await writeFile(sourceBackupPath, originalBytes, { mode: 0o600 }); + await writeFile(configPath, targetBytes, { mode: 0o600 }); + const journal: OAuthProfileRenameJournal = { + version: 1, + configIdentity: createOAuthConfigIdentity(canonicalConfigPath), + sourceHash: hash(originalBytes), + targetHash: hash(targetBytes), + auditRequired: false, + sourceBackupPath, + bindings: [{ + from: oldBinding, + to: newBinding, + originalCredentialPresent: true, + originalMetadata: await registry.snapshot(oldBinding) + }] + }; + await new FileOAuthProfileRenameJournalStore().create(canonicalConfigPath, journal); + + await expect(recoverOAuthProfileRename(configPath, { credentialStore: credentials, registry })).resolves.toBe(true); + + await expect(credentials.load(oldBinding)).resolves.toBeUndefined(); + await expect(credentials.load(newBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + await expect(registry.get(newBinding.connectionRef, newBinding)).resolves.toMatchObject({ identityState: "verified" }); + await expect(readFile(oauthProfileRenameJournalPath(canonicalConfigPath), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(sourceBackupPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("fails closed instead of following a symbolic-link source backup during recovery", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-symlink-backup-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const originalBytes = await readFile(configPath); + const plan = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }); + const targetBytes = Buffer.from(`${JSON.stringify(plan.config, null, 2)}\n`); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token" }); + await credentials.save(newBinding, { accessToken: "fixture-access-token" }); + await registry.create(oldBinding); + const actualSourceBackupPath = join(directory, "actual-source-backup.json"); + const sourceBackupPath = join(dirname(canonicalConfigPath), ".remote-analytics.json.miftah-oauth-profile-rename-source-symlink"); + await writeFile(actualSourceBackupPath, originalBytes, { mode: 0o600 }); + await symlink(actualSourceBackupPath, sourceBackupPath, "file"); + await writeFile(configPath, targetBytes, { mode: 0o600 }); + const journal: OAuthProfileRenameJournal = { + version: 1, + configIdentity: createOAuthConfigIdentity(canonicalConfigPath), + sourceHash: hash(originalBytes), + targetHash: hash(targetBytes), + auditRequired: false, + sourceBackupPath, + bindings: [{ + from: oldBinding, + to: newBinding, + originalCredentialPresent: true, + originalMetadata: await registry.snapshot(oldBinding) + }] + }; + await new FileOAuthProfileRenameJournalStore().create(canonicalConfigPath, journal); + + await expect(recoverOAuthProfileRename(configPath, { credentialStore: credentials, registry })) + .rejects.toMatchObject({ code: "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED" }); + + expect((await lstat(sourceBackupPath)).isSymbolicLink()).toBe(true); + await expect(credentials.load(oldBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + await expect(credentials.load(newBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + }); + + it("retains a forward recovery until its required audit finalization can be performed", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-audit-recovery-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const originalBytes = await readFile(configPath); + const plan = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }); + const targetBytes = Buffer.from(`${JSON.stringify(plan.config, null, 2)}\n`); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token" }); + await credentials.save(newBinding, { accessToken: "fixture-access-token" }); + await registry.create(oldBinding); + const sourceBackupPath = join(dirname(canonicalConfigPath), ".remote-analytics.json.miftah-oauth-profile-rename-source-audit-recovery"); + await writeFile(sourceBackupPath, originalBytes, { mode: 0o600 }); + await writeFile(configPath, targetBytes, { mode: 0o600 }); + const journal: OAuthProfileRenameJournal = { + version: 1, + configIdentity: createOAuthConfigIdentity(canonicalConfigPath), + sourceHash: hash(originalBytes), + targetHash: hash(targetBytes), + auditRequired: true, + sourceBackupPath, + bindings: [{ + from: oldBinding, + to: newBinding, + originalCredentialPresent: true, + originalMetadata: await registry.snapshot(oldBinding) + }] + }; + await new FileOAuthProfileRenameJournalStore().create(canonicalConfigPath, journal); + + await expect(recoverOAuthProfileRename(configPath, { credentialStore: credentials, registry })) + .rejects.toMatchObject({ code: "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED" }); + + await expect(readFile(oauthProfileRenameJournalPath(canonicalConfigPath), "utf8")).resolves.toContain('"auditRequired":true'); + await expect(credentials.load(oldBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + await expect(credentials.load(newBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + + const finalizeAudit = vi.fn().mockResolvedValue(undefined); + await expect(recoverOAuthProfileRename( + configPath, + { credentialStore: credentials, registry }, + { finalizeAudit } + )).resolves.toBe(true); + expect(finalizeAudit).toHaveBeenCalledWith({ profile: "work", newProfile: "studio" }); + await expect(credentials.load(oldBinding)).resolves.toBeUndefined(); + await expect(credentials.load(newBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + await expect(readFile(oauthProfileRenameJournalPath(canonicalConfigPath), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("rolls back a forward recovery when its required audit finalization fails", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-audit-recovery-rollback-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const originalBytes = await readFile(configPath); + const plan = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }); + const targetBytes = Buffer.from(`${JSON.stringify(plan.config, null, 2)}\n`); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token" }); + await credentials.save(newBinding, { accessToken: "fixture-access-token" }); + await registry.create(oldBinding); + const sourceBackupPath = join(dirname(canonicalConfigPath), ".remote-analytics.json.miftah-oauth-profile-rename-source-audit-recovery-rollback"); + await writeFile(sourceBackupPath, originalBytes, { mode: 0o600 }); + await writeFile(configPath, targetBytes, { mode: 0o600 }); + const journal: OAuthProfileRenameJournal = { + version: 1, + configIdentity: createOAuthConfigIdentity(canonicalConfigPath), + sourceHash: hash(originalBytes), + targetHash: hash(targetBytes), + auditRequired: true, + sourceBackupPath, + bindings: [{ + from: oldBinding, + to: newBinding, + originalCredentialPresent: true, + originalMetadata: await registry.snapshot(oldBinding) + }] + }; + await new FileOAuthProfileRenameJournalStore().create(canonicalConfigPath, journal); + + await expect(recoverOAuthProfileRename( + configPath, + { credentialStore: credentials, registry }, + { finalizeAudit: async () => { throw new Error("forced recovered audit failure"); } } + )).rejects.toMatchObject({ code: "AUDIT_WRITE_FAILED" }); + + await expect(credentials.load(oldBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + await expect(credentials.load(newBinding)).resolves.toBeUndefined(); + await expect(registry.get(oldBinding.connectionRef, oldBinding)).resolves.toMatchObject({ binding: oldBinding }); + await expect(readFile(configPath)).resolves.toEqual(originalBytes); + await expect(readFile(oauthProfileRenameJournalPath(canonicalConfigPath), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("fails closed rather than touching a journal binding from another configuration identity", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-foreign-binding-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const originalBytes = await readFile(configPath); + const plan = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }); + const targetBytes = Buffer.from(`${JSON.stringify(plan.config, null, 2)}\n`); + const foreignConfigPath = join(directory, "other-config.json"); + const foreignOldBinding = binding(foreignConfigPath, "work"); + const foreignNewBinding = binding(foreignConfigPath, "studio"); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + await credentials.save(foreignOldBinding, { accessToken: "foreign-fixture-access-token" }); + await credentials.save(foreignNewBinding, { accessToken: "foreign-fixture-access-token" }); + await registry.create(foreignOldBinding); + const sourceBackupPath = join(dirname(canonicalConfigPath), ".remote-analytics.json.miftah-oauth-profile-rename-source-foreign-binding"); + await writeFile(sourceBackupPath, originalBytes, { mode: 0o600 }); + await writeFile(configPath, targetBytes, { mode: 0o600 }); + const journal: OAuthProfileRenameJournal = { + version: 1, + configIdentity: createOAuthConfigIdentity(canonicalConfigPath), + sourceHash: hash(originalBytes), + targetHash: hash(targetBytes), + auditRequired: false, + sourceBackupPath, + bindings: [{ + from: foreignOldBinding, + to: foreignNewBinding, + originalCredentialPresent: true, + originalMetadata: await registry.snapshot(foreignOldBinding) + }] + }; + await new FileOAuthProfileRenameJournalStore().create(canonicalConfigPath, journal); + + await expect(recoverOAuthProfileRename(configPath, { credentialStore: credentials, registry })) + .rejects.toMatchObject({ code: "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED" }); + + await expect(credentials.load(foreignOldBinding)).resolves.toEqual({ accessToken: "foreign-fixture-access-token" }); + await expect(credentials.load(foreignNewBinding)).resolves.toEqual({ accessToken: "foreign-fixture-access-token" }); + }); + + it("reverses a crash-interrupted source configuration without losing the original metadata snapshot", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-source-recovery-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const originalBytes = await readFile(configPath); + const plan = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + const credentials = new MemoryCredentialStore(); + const metadata = new MemoryMetadataStore(); + const registry = new OAuthConnectionRegistry(metadata, () => "2030-01-02T03:04:05.000Z"); + await credentials.save(oldBinding, { accessToken: "fixture-access-token" }); + await credentials.save(newBinding, { accessToken: "fixture-access-token" }); + await registry.create(oldBinding); + await registry.setCredentialState(oldBinding.connectionRef, oldBinding, "expiring", "2030-01-03T03:04:05.000Z"); + await registry.setIdentityState(oldBinding.connectionRef, oldBinding, "verified"); + const originalMetadata = await registry.snapshot(oldBinding); + await registry.migrateProfileBinding(oldBinding, newBinding); + const sourceBackupPath = join(dirname(canonicalConfigPath), ".remote-analytics.json.miftah-oauth-profile-rename-source-fixture"); + await writeFile(sourceBackupPath, originalBytes, { mode: 0o600 }); + const targetBytes = Buffer.from(`${JSON.stringify(plan.config, null, 2)}\n`); + const journal: OAuthProfileRenameJournal = { + version: 1, + configIdentity: createOAuthConfigIdentity(canonicalConfigPath), + sourceHash: hash(originalBytes), + targetHash: hash(targetBytes), + auditRequired: false, + sourceBackupPath, + bindings: [{ + from: oldBinding, + to: newBinding, + originalCredentialPresent: true, + originalMetadata + }] + }; + await new FileOAuthProfileRenameJournalStore().create(canonicalConfigPath, journal); + + await expect(recoverOAuthProfileRename(configPath, { credentialStore: credentials, registry })).resolves.toBe(true); + + await expect(credentials.load(oldBinding)).resolves.toEqual({ accessToken: "fixture-access-token" }); + await expect(credentials.load(newBinding)).resolves.toBeUndefined(); + await expect(registry.get(oldBinding.connectionRef, oldBinding)).resolves.toEqual(originalMetadata); + await expect(readFile(oauthProfileRenameJournalPath(canonicalConfigPath), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/tests/oauth-secure-credential-store.test.ts b/tests/oauth-secure-credential-store.test.ts index 412e283f..581f95a9 100644 --- a/tests/oauth-secure-credential-store.test.ts +++ b/tests/oauth-secure-credential-store.test.ts @@ -45,6 +45,17 @@ describe("OAuth secure credential store", () => { await expect(createPlatformOAuthCredentialStore()).resolves.toBeInstanceOf(PlatformOAuthCredentialStore); }); + it("treats the native keyring null missing-item sentinel as an absent credential", async () => { + const adapter = { + getPassword: async () => null, + setPassword: async () => undefined, + deletePassword: async () => undefined + } as unknown as OAuthKeyringAdapter; + const store = new PlatformOAuthCredentialStore(adapter, new SecretRedactor()); + + await expect(store.load(binding())).resolves.toBeUndefined(); + }); + it("stores tokens only under an opaque exact-binding key and registers them for redaction", async () => { const adapter = new MemoryKeyringAdapter(); const redactor = new SecretRedactor(); diff --git a/tests/profile-rename-onboarding.test.ts b/tests/profile-rename-onboarding.test.ts index 6cfbe5a8..d3d695bb 100644 --- a/tests/profile-rename-onboarding.test.ts +++ b/tests/profile-rename-onboarding.test.ts @@ -71,7 +71,7 @@ describe("durable profile rename onboarding", () => { expect(input).toEqual(original); }); - it("refuses an OAuth-bound profile instead of touching a vault-bound credential", () => { + it("plans a renamed native OAuth binding without reading or writing its vault credential", () => { const input = { version: "3", name: "remote-analytics", @@ -93,11 +93,16 @@ describe("durable profile rename onboarding", () => { }; const original = structuredClone(input); - expect(() => planProfileRename(input, { + const plan = planProfileRename(input, { configPath: "/tmp/miftah-profile-rename/remote-analytics.json", profile: "work", newProfile: "studio" - })).toThrowError(expect.objectContaining({ code: "PROFILE_RENAME_OAUTH_CONNECTION" })); + }); + + const connections = plan.config.version === "3" ? plan.config.oauth?.connections : undefined; + expect(connections?.["oauthconn:11111111-1111-4111-8111-111111111111"]?.profile).toBe("studio"); + expect(plan.config.profiles).toHaveProperty("studio"); + expect(plan.config.profiles).not.toHaveProperty("work"); expect(input).toEqual(original); }); diff --git a/tests/readme-product-contract.test.ts b/tests/readme-product-contract.test.ts index 023c5d80..90199b04 100644 --- a/tests/readme-product-contract.test.ts +++ b/tests/readme-product-contract.test.ts @@ -147,7 +147,11 @@ describe("product README", () => { expect(readme).toContain("### Rename an account later"); expect(readme).toContain("miftah profile rename --config ~/.config/miftah/gsc.json --profile google-personal --new-profile google-studio"); expect(readme).toContain("Rename an account profile"); - expect(readme).toContain("Miftah refuses to rename a profile with a native OAuth binding"); + expect(readme).toContain("moves the credential only between its two exact OS-vault keys"); + expect(cliDocs).toContain( + "If a local interruption leaves the transaction unfinished, retry the same rename from the CLI; Miftah completes recovery only after required audit finalization, otherwise returns `OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED` (exit 1). Do not hand-edit the configuration, vault, metadata, or provider cache." + ); + expect(readme).toContain("Do not edit the configuration, vault, metadata, or provider cache to recover it."); expect(readme).toContain("### Remove an account later"); expect(readme).toContain("miftah profile remove --config ~/.config/miftah/gsc.json --profile google-personal --replacement-profile google-work"); expect(readme).toContain("does not resolve or delete an underlying secret"); @@ -168,7 +172,7 @@ describe("product README", () => { expect(readme).not.toContain("oauthconn:"); expect(readme).toContain("uses `~/.config/miftah/miftah.json` by default"); expect(cliDocs).toContain("miftah profile rename --config --profile --new-profile "); - expect(cliDocs).toContain("PROFILE_RENAME_OAUTH_CONNECTION"); + expect(cliDocs).toContain("moves the credential only between exact OS-vault keys"); }); it("documents the explicit no-secret local and remote client-entry import paths", () => { diff --git a/tests/remote-oauth-runtime.test.ts b/tests/remote-oauth-runtime.test.ts index 3a547469..318be5ee 100644 --- a/tests/remote-oauth-runtime.test.ts +++ b/tests/remote-oauth-runtime.test.ts @@ -1,19 +1,27 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { OAuthAuthorizationHandoff } from "../src/oauth/remote-oauth-client-provider.js"; -import type { OAuthConnectionMetadataStore } from "../src/oauth/connection-registry.js"; +import { OAuthConnectionRegistry, type OAuthConnectionMetadataStore } from "../src/oauth/connection-registry.js"; import { connectionCredentialKey, createOAuthConfigIdentity, - createOAuthConnectionBinding + createOAuthConnectionBinding, + parseOAuthConnectionRef } from "../src/oauth/connection-types.js"; +import { + canonicalOAuthProfileRenameConfigPath, + FileOAuthProfileRenameJournalStore, + oauthProfileRenameJournalPath, + type OAuthProfileRenameJournal +} from "../src/oauth/profile-rename-transaction.js"; import type { OAuthCredential, OAuthCredentialStore } from "../src/oauth/secure-credential-store.js"; import { MiftahServer } from "../src/mcp/server/miftah-server.js"; -import { createRuntime } from "../src/runtime/create-runtime.js"; +import { createRuntime, createRuntimeFromLoadedConfig } from "../src/runtime/create-runtime.js"; import { startOAuthCompatibilityProbe, type OAuthCompatibilityProbe @@ -73,6 +81,10 @@ class SimulatedBrowserHandoff implements OAuthAuthorizationHandoff { async close(): Promise {} } +function bytesHash(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + describe("remote OAuth runtime wiring", () => { const upstreams: OAuthCompatibilityProbe[] = []; const directories: string[] = []; @@ -136,6 +148,101 @@ describe("remote OAuth runtime wiring", () => { } ); + it("finishes an interrupted native OAuth profile rename before refusing to use a stale loaded configuration", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-runtime-profile-rename-recovery-")); + directories.push(directory); + const actualDirectory = join(directory, "actual"); + const aliasDirectory = join(directory, "alias"); + await mkdir(actualDirectory); + await symlink(actualDirectory, aliasDirectory, process.platform === "win32" ? "junction" : "dir"); + const configPath = join(aliasDirectory, "miftah.json"); + const connectionRef = "oauthconn:8c08de29-46cc-4a70-8528-11b9da0382c5"; + const sourceConfig = { + version: "3" as const, + name: "oauth-runtime-profile-rename", + defaultProfile: "work", + upstream: { transport: "streamable-http" as const, url: "https://mcp.example.test/mcp" }, + profiles: { work: {} }, + oauth: { + connections: { + [connectionRef]: { + profile: "work", + upstream: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://mcp.example.test", + clientRegistration: "dynamic", + scopes: ["mcp:tools"] + } + } + } + }; + const targetConfig = { + ...sourceConfig, + defaultProfile: "studio", + profiles: { studio: {} }, + oauth: { + connections: { + [connectionRef]: { ...sourceConfig.oauth.connections[connectionRef], profile: "studio" } + } + } + }; + const sourceBytes = Buffer.from(`${JSON.stringify(sourceConfig, null, 2)}\n`); + const targetBytes = Buffer.from(`${JSON.stringify(targetConfig, null, 2)}\n`); + await writeFile(configPath, targetBytes, { mode: 0o600 }); + + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const configIdentity = createOAuthConfigIdentity(canonicalConfigPath); + const from = createOAuthConnectionBinding({ + configIdentity, + connectionRef: parseOAuthConnectionRef(connectionRef), + profile: "work", + upstream: "default", + resource: "https://mcp.example.test/mcp", + issuer: "https://mcp.example.test", + clientRegistration: "dynamic", + scopes: ["mcp:tools"] + }); + const to = createOAuthConnectionBinding({ ...from, profile: "studio", resource: from.canonicalResource }); + const metadataStore = new MemoryMetadataStore(); + const credentialStore = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(metadataStore, () => "2030-01-02T03:04:05.000Z"); + await credentialStore.save(from, { accessToken: "fixture-runtime-recovery-token" }); + await credentialStore.save(to, { accessToken: "fixture-runtime-recovery-token" }); + await registry.create(from); + const sourceBackupPath = join(dirname(canonicalConfigPath), ".miftah.json.miftah-oauth-profile-rename-source-runtime-recovery"); + await writeFile(sourceBackupPath, sourceBytes, { mode: 0o600 }); + const journal: OAuthProfileRenameJournal = { + version: 1, + configIdentity, + sourceHash: bytesHash(sourceBytes), + targetHash: bytesHash(targetBytes), + auditRequired: false, + sourceBackupPath, + bindings: [{ from, to, originalCredentialPresent: true, originalMetadata: await registry.snapshot(from) }] + }; + await new FileOAuthProfileRenameJournalStore().create(canonicalConfigPath, journal); + + await expect(createRuntime(configPath, undefined, { + oauth: { metadataStore, credentialStore } + })).rejects.toMatchObject({ code: "PROFILE_SELECTION_STALE" }); + await expect(credentialStore.load(from)).resolves.toBeUndefined(); + await expect(credentialStore.load(to)).resolves.toEqual({ accessToken: "fixture-runtime-recovery-token" }); + await expect(registry.snapshot(to)).resolves.toMatchObject({ binding: to }); + await expect(readFile(oauthProfileRenameJournalPath(configPath), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(readFile(sourceBackupPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + + const resumed = await createRuntimeFromLoadedConfig(configPath, targetConfig, undefined, { + oauth: { metadataStore, credentialStore } + }); + try { + const oauth = resumed.oauth; + expect(oauth).toBeDefined(); + await expect(oauth?.status("studio", "default")).resolves.toMatchObject({ credentialState: "disconnected" }); + } finally { + await resumed.manager.close(); + } + }); + it("refreshes and reconnects after process restart without another browser authorization", async () => { const upstream = await startOAuthCompatibilityProbe({ publicBaseUrl: "https://mcp.example.test" }); upstreams.push(upstream); From 6543454738ca4fef08f42db1da93894d8eba5421 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Mon, 27 Jul 2026 00:31:29 +0400 Subject: [PATCH 2/2] fix(oauth): protect profile rename destination credentials --- src/oauth/profile-rename-transaction.ts | 19 ++++- .../oauth-profile-rename-transaction.test.ts | 75 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/oauth/profile-rename-transaction.ts b/src/oauth/profile-rename-transaction.ts index cbf0b805..6647ebc8 100644 --- a/src/oauth/profile-rename-transaction.ts +++ b/src/oauth/profile-rename-transaction.ts @@ -538,6 +538,20 @@ async function prepareTargetCredentials( } } +async function assertDestinationCredentialsAbsent( + bindings: readonly OAuthProfileRenameBindingPair[], + store: OAuthCredentialStore +): Promise { + for (const binding of bindings) { + if (await store.load(binding.to) !== undefined) { + throw new MiftahError( + "OAUTH_CONNECTION_INVALID", + "OAUTH_CONNECTION_INVALID: the renamed OAuth connection already has a destination credential" + ); + } + } +} + async function completeForward( journal: OAuthProfileRenameJournal, dependencies: OAuthProfileRenameDependencies @@ -591,10 +605,10 @@ async function completeRollback( } else if (to !== undefined && !credentialsEqual(from, to)) { recoveryRequired(); } - } else if (from !== undefined) { + } else if (from !== undefined || to !== undefined) { recoveryRequired(); } - if (to !== undefined) await dependencies.credentialStore.delete(binding.to); + if (binding.originalCredentialPresent && to !== undefined) await dependencies.credentialStore.delete(binding.to); await dependencies.registry.restoreProfileBinding(binding.from, binding.to, binding.originalMetadata); } } @@ -744,6 +758,7 @@ export async function runOAuthProfileRenameTransaction( return withOAuthConnectionBindingLocks( request.bindings.flatMap((binding) => [binding.from, binding.to]), async () => { + await assertDestinationCredentialsAbsent(request.bindings, dependencies.credentialStore); const journal = await captureJournal( configPath, request.source, diff --git a/tests/oauth-profile-rename-transaction.test.ts b/tests/oauth-profile-rename-transaction.test.ts index 1939b1cc..9a2434d1 100644 --- a/tests/oauth-profile-rename-transaction.test.ts +++ b/tests/oauth-profile-rename-transaction.test.ts @@ -203,6 +203,41 @@ describe("native OAuth profile rename transaction", () => { await expect(readFile(configPath)).resolves.toEqual(originalBytes); }); + it("rejects a pre-existing destination credential before creating recovery state or altering either vault key", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-destination-collision-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const originalBytes = await readFile(configPath); + const source = await readConfigMigrationSource(configPath); + const candidate = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }).config; + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + const credential = { accessToken: "fixture-access-token" }; + const create = vi.fn(async () => undefined); + const journalStore = { load: async () => undefined, create, remove: async () => undefined }; + await credentials.save(oldBinding, credential); + await credentials.save(newBinding, credential); + await registry.create(oldBinding); + + await expect(runOAuthProfileRenameTransaction( + { configPath, source, candidate, bindings: [{ from: oldBinding, to: newBinding }] }, + { credentialStore: credentials, registry, journalStore } + )).rejects.toMatchObject({ code: "OAUTH_CONNECTION_INVALID" }); + + expect(create).not.toHaveBeenCalled(); + await expect(credentials.load(oldBinding)).resolves.toEqual(credential); + await expect(credentials.load(newBinding)).resolves.toEqual(credential); + await expect(readFile(configPath)).resolves.toEqual(originalBytes); + }); + it("moves the exact vault key and non-secret metadata only with the guarded config rename", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-")); directories.push(directory); @@ -490,6 +525,46 @@ describe("native OAuth profile rename transaction", () => { await expect(readFile(sourceBackupPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); }); + it("fails closed and retains a destination credential that a credential-less source transaction could not have created during rollback", async () => { + const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-credential-less-rollback-")); + directories.push(directory); + const configPath = join(directory, "remote-analytics.json"); + await writeOAuthConfig(configPath); + const canonicalConfigPath = await canonicalOAuthProfileRenameConfigPath(configPath); + const originalBytes = await readFile(configPath); + const plan = planProfileRename(JSON.parse(originalBytes.toString("utf8")), { + configPath, + profile: "work", + newProfile: "studio" + }); + const targetBytes = Buffer.from(`${JSON.stringify(plan.config, null, 2)}\n`); + const oldBinding = binding(canonicalConfigPath, "work"); + const newBinding = binding(canonicalConfigPath, "studio"); + const credentials = new MemoryCredentialStore(); + const registry = new OAuthConnectionRegistry(new MemoryMetadataStore(), () => "2030-01-02T03:04:05.000Z"); + await credentials.save(newBinding, { accessToken: "fixture-destination-token" }); + const sourceBackupPath = join(dirname(canonicalConfigPath), ".remote-analytics.json.miftah-oauth-profile-rename-source-credential-less"); + await writeFile(sourceBackupPath, originalBytes, { mode: 0o600 }); + const journal: OAuthProfileRenameJournal = { + version: 1, + configIdentity: createOAuthConfigIdentity(canonicalConfigPath), + sourceHash: hash(originalBytes), + targetHash: hash(targetBytes), + auditRequired: false, + sourceBackupPath, + bindings: [{ from: oldBinding, to: newBinding, originalCredentialPresent: false }] + }; + await new FileOAuthProfileRenameJournalStore().create(canonicalConfigPath, journal); + + await expect(recoverOAuthProfileRename(configPath, { credentialStore: credentials, registry })) + .rejects.toMatchObject({ code: "OAUTH_PROFILE_RENAME_RECOVERY_REQUIRED" }); + + await expect(credentials.load(oldBinding)).resolves.toBeUndefined(); + await expect(credentials.load(newBinding)).resolves.toEqual({ accessToken: "fixture-destination-token" }); + await expect(readFile(oauthProfileRenameJournalPath(canonicalConfigPath), "utf8")).resolves.toContain('"version":1'); + await expect(readFile(sourceBackupPath)).resolves.toEqual(originalBytes); + }); + it("fails closed instead of following a symbolic-link source backup during recovery", async () => { const directory = await mkdtemp(join(tmpdir(), "miftah-oauth-profile-rename-symlink-backup-")); directories.push(directory);