From 60c1faffc41ea462048311d6a8bb42b6e0170dad Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:02:18 +0530 Subject: [PATCH 1/7] fix(cli): delete Windows keyring credentials with undecodable blobs on logout Go writes the Windows CredentialBlob as raw bytes that aren't always valid UTF-8/UTF-16. napi-rs/keyring's getPassword/findCredentials marshal that blob into a JS string and throw on those bytes, so the legacy TS port treated a present-but-opaque credential as absent and skipped deletion, leaving it behind after `supabase logout` reported success. Check existence via findCredentials with an exact target instead: a genuinely absent credential yields an empty result, while a throw means the target was found but couldn't be decoded, i.e. it exists. This deliberately avoids Entry.withTarget() for the probe itself, since constructing it writes an empty placeholder credential as a side effect on Windows (napi-rs/keyring), which would otherwise fabricate the very credential being checked for. --- .../legacy/auth/legacy-credentials.layer.ts | 34 ++++++++--- .../legacy-credentials.layer.unit.test.ts | 59 ++++++++++++++----- 2 files changed, 69 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 2d78ea2ec8..ed534fd807 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -92,7 +92,7 @@ const tryKeyringDelete = ( deleted = true; } - if (platform === "win32" && readGoWindowsTarget(module, account)) { + if (platform === "win32" && windowsTargetExists(module, account)) { deleted = deleteGoWindowsTarget(module, account) || deleted; } @@ -123,6 +123,23 @@ function readGoWindowsTarget(module: KeyringModule, account: string): string | n } } +// Existence check for the Windows target-shaped credential. Deliberately +// does NOT use `Entry.withTarget` here: constructing it writes an empty +// placeholder credential as a side effect on Windows (napi-rs/keyring +// entry.rs), which would fabricate the very credential being probed for. +// `findCredentials` has no such side effect — it's a plain `CredEnumerateW` +// read. Scoped to an exact target, a genuinely absent credential yields an +// empty result (not a throw); a throw means the target was found but its +// blob isn't valid UTF-16 (Go writes raw UTF-8 bytes), i.e. it exists. +function windowsTargetExists(module: KeyringModule, account: string): boolean { + try { + const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); + return credentials.some((item) => item.account === account); + } catch { + return true; + } +} + function normalizeGoWindowsPassword(value: string): string { const direct = normalizeKeyringToken(value); if (LEGACY_ACCESS_TOKEN_PATTERN.test(direct)) return direct; @@ -172,11 +189,10 @@ function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean // (backend unavailable) and only surfaces other errors (`unlink.go:36-40`). // // The plain `Entry(service, projectRef)` is the macOS/Linux form and the Windows -// default. On Windows, Go also writes a separate target-shaped credential; it is -// detected via `findCredentials` (a plain `getPassword` does not read the Go -// target reliably) and deleted through the `withTarget` entry. The `withTarget` -// entry is only constructed on Windows — on macOS its first argument is an -// invalid keychain domain and throws. +// default. On Windows, Go also writes a separate target-shaped credential, +// deleted through the `withTarget` entry. The `withTarget` entry is only +// constructed on Windows — on macOS its first argument is an invalid keychain +// domain and throws. // // Each entry is probed before `deleteCredential()`: on macOS deleting an absent // entry blocks on a Keychain authorization prompt, and an absent read means @@ -204,7 +220,7 @@ const deleteKeyringEntryStrict = ( deleted = true; } - if (platform === "win32" && readGoWindowsTarget(module, account)) { + if (platform === "win32" && windowsTargetExists(module, account)) { const target = module.Entry.withTarget( goWindowsCredentialTarget(account), KEYRING_SERVICE, @@ -256,7 +272,7 @@ const deleteProfileKeyringEntry = ( found = true; } - if (platform === "win32" && readGoWindowsTarget(module, account)) { + if (platform === "win32" && windowsTargetExists(module, account)) { const target = module.Entry.withTarget( goWindowsCredentialTarget(account), KEYRING_SERVICE, @@ -305,7 +321,7 @@ const deleteAllKeyringEntries = ( } catch { // best-effort per entry } - if (platform === "win32" && readGoWindowsTarget(module, account)) { + if (platform === "win32" && windowsTargetExists(module, account)) { deleteGoWindowsTarget(module, account); } } diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 06dad317de..48831a66bb 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -33,23 +33,25 @@ let throwOnSetSecret = false; const throwOnGetPasswordAccounts = new Set(); const throwOnDeleteAccounts = new Set(); const withTargetCalls: string[] = []; +const opaqueAccounts = new Set(); vi.mock("@napi-rs/keyring", () => ({ - findCredentials: (service: string, target?: string) => - Array.from(passwords.entries()) - .filter(([key]) => - // No target → model the Windows `CredEnumerate("*")` sweep, - // which matches both the plain (`/…`) and the Go target-shaped - // (`:/…`) entries by the leading segment. With a - // target → narrow to that specific Go target (used by readGoWindowsTarget). - target === undefined - ? key.split("/")[0]!.startsWith(service) - : key.startsWith(`${target}/`), - ) - .map(([key, password]) => ({ - account: key.split("/").at(-1)!, - password, - })), + findCredentials: (service: string, target?: string) => { + const matches = Array.from(passwords.entries()).filter(([key]) => + // No target → model the Windows `CredEnumerate("*")` sweep, + // which matches both the plain (`/…`) and the Go target-shaped + // (`:/…`) entries by the leading segment. With a + // target → narrow to that specific Go target (used by readGoWindowsTarget). + target === undefined ? key.split("/")[0]!.startsWith(service) : key.startsWith(`${target}/`), + ); + if (matches.some(([key]) => opaqueAccounts.has(key))) { + throw new Error("BadEncoding: invalid UTF-8 data in secure storage"); + } + return matches.map(([key, password]) => ({ + account: key.split("/").at(-1)!, + password, + })); + }, Entry: class Entry { service: string; account: string; @@ -140,6 +142,7 @@ beforeEach(() => { throwOnGetPasswordAccounts.clear(); throwOnDeleteAccounts.clear(); withTargetCalls.length = 0; + opaqueAccounts.clear(); tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-")); }); @@ -423,6 +426,19 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { }, ); + it.effect("win32: no Go target credential → LegacyNotLoggedInError, nothing fabricated", () => { + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + } + expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(withTargetCalls).toEqual([]); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + it.effect( "keyring unavailable (SUPABASE_NO_KEYRING) with token in file → removes file, still NotLoggedIn", () => { @@ -521,6 +537,19 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); + it.effect( + "win32: deletes a Go target whose blob getPassword/findCredentials can't decode", + () => { + passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + opaqueAccounts.add(goWindowsKey("supabase")); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + yield* deleteAccessToken; + expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); + it.effect("legacy-keyring delete error is swallowed and does not change the outcome", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); From f3f48836f2ef5342987b125a5dc786f2ef8fbad1 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:43:31 +0530 Subject: [PATCH 2/7] Nits --- apps/cli/src/legacy/auth/legacy-credentials.layer.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index ed534fd807..a1529c863d 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -123,14 +123,9 @@ function readGoWindowsTarget(module: KeyringModule, account: string): string | n } } -// Existence check for the Windows target-shaped credential. Deliberately -// does NOT use `Entry.withTarget` here: constructing it writes an empty -// placeholder credential as a side effect on Windows (napi-rs/keyring -// entry.rs), which would fabricate the very credential being probed for. -// `findCredentials` has no such side effect — it's a plain `CredEnumerateW` -// read. Scoped to an exact target, a genuinely absent credential yields an -// empty result (not a throw); a throw means the target was found but its -// blob isn't valid UTF-16 (Go writes raw UTF-8 bytes), i.e. it exists. +// Avoid `Entry.withTarget` here — constructing it writes an empty placeholder +// credential on Windows, fabricating the thing being probed for. A throw from +// `findCredentials` on an exact target means found-but-undecodable, not absent. function windowsTargetExists(module: KeyringModule, account: string): boolean { try { const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); From 12e8b3fa60c9512a51623f2cec0d9ccd21020a18 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:57:43 +0530 Subject: [PATCH 3/7] fix(cli): harden Windows credential probing and project sweep on logout Three Windows-only correctness gaps in the credential layer, all matching Go's behavior: - Probe existence with a side-effect-free `findCredentials` read instead of `Entry.withTarget`, whose constructor writes an empty placeholder credential and would fabricate the target being probed for. - Distinguish a confirmed-absent target from an ambiguous probe failure (undecodable blob or enumeration error): only a confirmed-present target whose delete does not succeed becomes a hard error, and `deleteCredential`'s boolean is now checked rather than ignored, so a failed delete can no longer report a successful logout. - Sweep project credentials by enumerating Go's `Supabase CLI:` namespace, mirroring Go's `DeleteAll` prefix match. napi's default enumeration filters the `.` form Go never writes on Windows, so the previous sweep left Go-written project credentials behind. --- .../legacy/auth/legacy-credentials.layer.ts | 134 +++++++++++------- .../legacy-credentials.layer.unit.test.ts | 78 +++++++++- 2 files changed, 151 insertions(+), 61 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index a1529c863d..ceea8164bf 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Redacted, Result } from "effect"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts"; @@ -92,7 +92,7 @@ const tryKeyringDelete = ( deleted = true; } - if (platform === "win32" && windowsTargetExists(module, account)) { + if (platform === "win32" && probeWindowsTarget(module, account) !== "absent") { deleted = deleteGoWindowsTarget(module, account) || deleted; } @@ -123,18 +123,42 @@ function readGoWindowsTarget(module: KeyringModule, account: string): string | n } } -// Avoid `Entry.withTarget` here — constructing it writes an empty placeholder -// credential on Windows, fabricating the thing being probed for. A throw from -// `findCredentials` on an exact target means found-but-undecodable, not absent. -function windowsTargetExists(module: KeyringModule, account: string): boolean { +// `Entry.withTarget` is avoided as a probe — its constructor writes an empty +// placeholder. A `findCredentials` throw is ambiguous (an undecodable Go blob or +// a real enumeration failure), so reported as `"unknown"`, never assumed present. +type WindowsTargetProbe = "present" | "absent" | "unknown"; + +function probeWindowsTarget(module: KeyringModule, account: string): WindowsTargetProbe { try { const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); - return credentials.some((item) => item.account === account); + return credentials.some((item) => item.account === account) ? "present" : "absent"; } catch { - return true; + return "unknown"; } } +const deleteProbedWindowsTarget = ( + module: KeyringModule, + account: string, + probe: Exclude, + onFailure: (cause: unknown) => E, +): Effect.Effect => + Effect.gen(function* () { + const result = yield* Effect.try(() => + module.Entry.withTarget( + goWindowsCredentialTarget(account), + KEYRING_SERVICE, + account, + ).deleteCredential(), + ).pipe(Effect.result); + if (Result.isSuccess(result) && result.success) return true; + if (probe === "present") { + const cause = Result.isFailure(result) ? result.failure : "credential was not removed"; + return yield* Effect.fail(onFailure(cause)); + } + return false; + }); + function normalizeGoWindowsPassword(value: string): string { const direct = normalizeKeyringToken(value); if (LEGACY_ACCESS_TOKEN_PATTERN.test(direct)) return direct; @@ -215,22 +239,20 @@ const deleteKeyringEntryStrict = ( deleted = true; } - if (platform === "win32" && windowsTargetExists(module, account)) { - const target = module.Entry.withTarget( - goWindowsCredentialTarget(account), - KEYRING_SERVICE, - account, - ); - yield* Effect.try({ - try: () => { - target.deleteCredential(); - }, - catch: (cause) => - new LegacyCredentialDeleteError({ - message: `failed to delete project credential: ${String(cause)}`, - }), - }); - deleted = true; + if (platform === "win32") { + const probe = probeWindowsTarget(module, account); + if (probe !== "absent") { + const removed = yield* deleteProbedWindowsTarget( + module, + account, + probe, + (cause) => + new LegacyCredentialDeleteError({ + message: `failed to delete project credential: ${String(cause)}`, + }), + ); + deleted ||= removed; + } } return deleted; @@ -267,43 +289,50 @@ const deleteProfileKeyringEntry = ( found = true; } - if (platform === "win32" && windowsTargetExists(module, account)) { - const target = module.Entry.withTarget( - goWindowsCredentialTarget(account), - KEYRING_SERVICE, - account, - ); - yield* Effect.try({ - try: () => { - target.deleteCredential(); - }, - catch: (cause) => - new LegacyDeleteTokenError({ - message: `failed to delete access token from keyring: ${String(cause)}`, - }), - }); - found = true; + if (platform === "win32") { + const probe = probeWindowsTarget(module, account); + if (probe !== "absent") { + const removed = yield* deleteProbedWindowsTarget( + module, + account, + probe, + (cause) => + new LegacyDeleteTokenError({ + message: `failed to delete access token from keyring: ${String(cause)}`, + }), + ); + found ||= removed; + } } return found ? "deleted" : "notFound"; }); -// Best-effort wipe of every entry in the `"Supabase CLI"` keyring namespace — -// the project database-password credentials `link` writes. Mirrors Go's -// `keyring.DeleteAll(namespace)` (`store.go:71`). Never fails: per-entry delete -// errors are swallowed so a single stuck credential can't abort logout. -// -// On Windows, Go stores credentials under the target-shaped name -// `Supabase CLI:` rather than the plain `Entry(service, account)` form -// (see `writeGoWindowsTarget`). So each discovered account is deleted in BOTH -// forms — the plain entry and, on win32, the Go target entry — mirroring the -// individual deletes in `deleteProfileKeyringEntry`. Without this, a Go-written -// project credential would survive `logout` on Windows. +// Best-effort wipe of every stored project database-password credential. +// Mirrors Go's `keyring.DeleteAll(namespace)` (`store.go:71`), which prefix-matches +// `Supabase CLI:` and swallows per-entry errors so a single stuck credential +// can't abort logout. On Windows, Go writes every credential as +// `Supabase CLI:`; napi's default enumeration filter is the plain +// `.` form Go never writes there, so the Go namespace is +// enumerated explicitly by its `Supabase CLI:` prefix. const deleteAllKeyringEntries = ( module: KeyringModule, platform: RuntimePlatform, ): Effect.Effect => Effect.sync(() => { + if (platform === "win32") { + let entries: ReadonlyArray<{ account: string }>; + try { + entries = module.findCredentials(KEYRING_SERVICE, `${goWindowsCredentialTarget("")}*`); + } catch { + return; + } + for (const { account } of entries) { + deleteGoWindowsTarget(module, account); + } + return; + } + let entries: ReadonlyArray<{ account: string }>; try { entries = module.findCredentials(KEYRING_SERVICE); @@ -316,9 +345,6 @@ const deleteAllKeyringEntries = ( } catch { // best-effort per entry } - if (platform === "win32" && windowsTargetExists(module, account)) { - deleteGoWindowsTarget(module, account); - } } }); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 48831a66bb..0b6c3450b6 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -32,18 +32,21 @@ let throwOnSetPassword = false; let throwOnSetSecret = false; const throwOnGetPasswordAccounts = new Set(); const throwOnDeleteAccounts = new Set(); +const failDeleteAccounts = new Set(); const withTargetCalls: string[] = []; const opaqueAccounts = new Set(); +let throwOnFindCredentials = false; vi.mock("@napi-rs/keyring", () => ({ findCredentials: (service: string, target?: string) => { - const matches = Array.from(passwords.entries()).filter(([key]) => - // No target → model the Windows `CredEnumerate("*")` sweep, - // which matches both the plain (`/…`) and the Go target-shaped - // (`:/…`) entries by the leading segment. With a - // target → narrow to that specific Go target (used by readGoWindowsTarget). - target === undefined ? key.split("/")[0]!.startsWith(service) : key.startsWith(`${target}/`), - ); + if (throwOnFindCredentials) throw new Error("CredEnumerate failed"); + const targetName = (key: string) => key.split("/")[0]!; + const matchesFilter = (key: string): boolean => { + if (target === undefined) return targetName(key) === service; + if (target.endsWith("*")) return targetName(key).startsWith(target.slice(0, -1)); + return targetName(key) === target; + }; + const matches = Array.from(passwords.entries()).filter(([key]) => matchesFilter(key)); if (matches.some(([key]) => opaqueAccounts.has(key))) { throw new Error("BadEncoding: invalid UTF-8 data in secure storage"); } @@ -88,6 +91,7 @@ vi.mock("@napi-rs/keyring", () => ({ deleteCredential(): boolean { const key = this.key(); if (throwOnDeleteAccounts.has(key)) throw new Error("Keyring delete failed"); + if (failDeleteAccounts.has(key)) return false; if (!passwords.has(key)) throw new Error("not found"); passwords.delete(key); return true; @@ -143,6 +147,8 @@ beforeEach(() => { throwOnDeleteAccounts.clear(); withTargetCalls.length = 0; opaqueAccounts.clear(); + failDeleteAccounts.clear(); + throwOnFindCredentials = false; tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-")); }); @@ -550,6 +556,36 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { }, ); + it.effect( + "win32: a confirmed Go target whose delete returns false → LegacyDeleteTokenError", + () => { + passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + failDeleteAccounts.add(goWindowsKey("supabase")); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + } + expect(passwords.has(goWindowsKey("supabase"))).toBe(true); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); + + it.effect("win32: a Windows enumeration failure is not treated as a deletable target", () => { + throwOnFindCredentials = true; + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + expect(JSON.stringify(exit.cause)).not.toContain("LegacyDeleteTokenError"); + } + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + it.effect("legacy-keyring delete error is swallowed and does not change the outcome", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); @@ -605,6 +641,34 @@ describe("legacyCredentialsLayer.deleteAllProjectCredentials", () => { }); }); +describe("legacyCredentialsLayer.deleteProjectCredential", () => { + it.effect("win32: deletes a Go target-shaped project credential", () => { + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "db-password"); + return Effect.gen(function* () { + const { deleteProjectCredential } = yield* LegacyCredentials; + const deleted = yield* deleteProjectCredential("abcdefghijklmnopqrs1"); + expect(deleted).toBe(true); + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + + it.effect( + "win32: a confirmed project credential whose delete fails → LegacyCredentialDeleteError", + () => { + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "db-password"); + throwOnDeleteAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); + return Effect.gen(function* () { + const { deleteProjectCredential } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteProjectCredential("abcdefghijklmnopqrs1")); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyCredentialDeleteError"); + } + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); +}); + // Suppress unused-import nag — referenced in JSDoc / used in assertions above. void LegacyInvalidAccessTokenError; void LegacyDeleteTokenError; From 20420bfa37a78535506ff30d5b863ced5f92015a Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:54:19 +0530 Subject: [PATCH 4/7] fix(cli): stop decoding blobs in the Windows project-credential sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The win32 sweep enumerated Supabase CLI:* to catch project database passwords, but findCredentials decodes every matched blob and throws for the whole call on one undecodable entry (the same failure mode this patch fixes for single-target lookups), aborting the sweep entirely. Verified against apps/cli-go: the only production keyring write is the access token (access_token.go:77, keyed by profile name) — link and every other command never write a project-ref-keyed credential. deleteAccessToken already removes both token forms before this sweep runs, so there is nothing in the Windows target form left to find. The sweep reverts to a plain, decode-only-what-you-write enumeration. --- .../legacy/auth/legacy-credentials.layer.ts | 33 +++++-------------- .../legacy-credentials.layer.unit.test.ts | 11 ------- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index ceea8164bf..ffde1fd71f 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -308,31 +308,14 @@ const deleteProfileKeyringEntry = ( return found ? "deleted" : "notFound"; }); -// Best-effort wipe of every stored project database-password credential. -// Mirrors Go's `keyring.DeleteAll(namespace)` (`store.go:71`), which prefix-matches -// `Supabase CLI:` and swallows per-entry errors so a single stuck credential -// can't abort logout. On Windows, Go writes every credential as -// `Supabase CLI:`; napi's default enumeration filter is the plain -// `.` form Go never writes there, so the Go namespace is -// enumerated explicitly by its `Supabase CLI:` prefix. -const deleteAllKeyringEntries = ( - module: KeyringModule, - platform: RuntimePlatform, -): Effect.Effect => +// Best-effort wipe of the `"Supabase CLI"` keyring namespace, mirroring Go's +// `keyring.DeleteAll` (`store.go:71`); per-entry errors are swallowed so one +// stuck credential can't abort logout. Go stores only the access token here +// (`access_token.go:77`, keyed by profile name), never project database +// passwords, and `deleteAccessToken` already removed it before this runs — so +// there is nothing left to enumerate or decode. +const deleteAllKeyringEntries = (module: KeyringModule): Effect.Effect => Effect.sync(() => { - if (platform === "win32") { - let entries: ReadonlyArray<{ account: string }>; - try { - entries = module.findCredentials(KEYRING_SERVICE, `${goWindowsCredentialTarget("")}*`); - } catch { - return; - } - for (const { account } of entries) { - deleteGoWindowsTarget(module, account); - } - return; - } - let entries: ReadonlyArray<{ account: string }>; try { entries = module.findCredentials(KEYRING_SERVICE); @@ -488,7 +471,7 @@ const makeLegacyCredentials = Effect.gen(function* () { deleteAllProjectCredentials: Effect.gen(function* () { if (Option.isNone(keyringModule)) return; - yield* deleteAllKeyringEntries(keyringModule.value, runtimeInfo.platform); + yield* deleteAllKeyringEntries(keyringModule.value); }), deleteProjectCredential: (projectRef: string) => diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 0b6c3450b6..00780ff0f2 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -619,17 +619,6 @@ describe("legacyCredentialsLayer.deleteAllProjectCredentials", () => { }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_NO_KEYRING: "1" } }))); }); - it.effect("win32: deletes Go target-shaped project credentials", () => { - // Go stores Windows project credentials under `Supabase CLI:`, not the - // plain `Supabase CLI/` form. The sweep must delete the target form too. - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); - return Effect.gen(function* () { - const { deleteAllProjectCredentials } = yield* LegacyCredentials; - yield* deleteAllProjectCredentials; - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); - }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); - }); - it.effect("never fails even when an individual delete throws", () => { passwords.set("Supabase CLI/abcdefghijklmnopqrs1", "secret-1"); throwOnDeleteAccounts.add("Supabase CLI/abcdefghijklmnopqrs1"); From 5b69a47a83f38169976bcbd49af25ce18ef363fb Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:15:53 +0530 Subject: [PATCH 5/7] fix(cli): surface failed Windows target deletes regardless of probe state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entry.withTarget's constructor unconditionally writes an empty placeholder credential on Windows (verified in napi-rs/keyring's entry.rs), so by the time deleteCredential() runs, something always exists at that target — gating the failure path on a confirmed-present probe was withholding a real error signal whenever the probe was merely ambiguous. Any non-success delete outcome is now surfaced, since construction itself already resolved the existence question. Corrected two tests built on the same inaccurate assumption: an enumeration hiccup alone no longer implies not-logged-in (construction leaves something deletable regardless), and a failed Windows target write can leave an empty placeholder behind rather than nothing. --- .../legacy/auth/legacy-credentials.layer.ts | 59 ++++++++----------- .../legacy-credentials.layer.unit.test.ts | 30 +++++++--- 2 files changed, 47 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index ffde1fd71f..a7f8b0dca4 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -137,10 +137,12 @@ function probeWindowsTarget(module: KeyringModule, account: string): WindowsTarg } } +// Constructing `withTarget` (see above) always leaves something at that target +// — the real credential, or the placeholder — so a delete that follows never +// has a legitimate "nothing to delete" outcome: any non-success is surfaced. const deleteProbedWindowsTarget = ( module: KeyringModule, account: string, - probe: Exclude, onFailure: (cause: unknown) => E, ): Effect.Effect => Effect.gen(function* () { @@ -152,11 +154,8 @@ const deleteProbedWindowsTarget = ( ).deleteCredential(), ).pipe(Effect.result); if (Result.isSuccess(result) && result.success) return true; - if (probe === "present") { - const cause = Result.isFailure(result) ? result.failure : "credential was not removed"; - return yield* Effect.fail(onFailure(cause)); - } - return false; + const cause = Result.isFailure(result) ? result.failure : "credential was not removed"; + return yield* Effect.fail(onFailure(cause)); }); function normalizeGoWindowsPassword(value: string): string { @@ -239,20 +238,16 @@ const deleteKeyringEntryStrict = ( deleted = true; } - if (platform === "win32") { - const probe = probeWindowsTarget(module, account); - if (probe !== "absent") { - const removed = yield* deleteProbedWindowsTarget( - module, - account, - probe, - (cause) => - new LegacyCredentialDeleteError({ - message: `failed to delete project credential: ${String(cause)}`, - }), - ); - deleted ||= removed; - } + if (platform === "win32" && probeWindowsTarget(module, account) !== "absent") { + const removed = yield* deleteProbedWindowsTarget( + module, + account, + (cause) => + new LegacyCredentialDeleteError({ + message: `failed to delete project credential: ${String(cause)}`, + }), + ); + deleted ||= removed; } return deleted; @@ -289,20 +284,16 @@ const deleteProfileKeyringEntry = ( found = true; } - if (platform === "win32") { - const probe = probeWindowsTarget(module, account); - if (probe !== "absent") { - const removed = yield* deleteProbedWindowsTarget( - module, - account, - probe, - (cause) => - new LegacyDeleteTokenError({ - message: `failed to delete access token from keyring: ${String(cause)}`, - }), - ); - found ||= removed; - } + if (platform === "win32" && probeWindowsTarget(module, account) !== "absent") { + const removed = yield* deleteProbedWindowsTarget( + module, + account, + (cause) => + new LegacyDeleteTokenError({ + message: `failed to delete access token from keyring: ${String(cause)}`, + }), + ); + found ||= removed; } return found ? "deleted" : "notFound"; diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 00780ff0f2..998e327a04 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -66,7 +66,9 @@ vi.mock("@napi-rs/keyring", () => ({ } static withTarget(target: string, service: string, account: string) { withTargetCalls.push(`${target}/${service}/${account}`); - return new this(service, account, target); + const entry = new this(service, account, target); + if (!passwords.has(entry.key())) passwords.set(entry.key(), ""); + return entry; } key(): string { return this.target === undefined @@ -346,7 +348,7 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { return Effect.gen(function* () { const { saveAccessToken } = yield* LegacyCredentials; yield* saveAccessToken(VALID_TOKEN); - expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.get(goWindowsKey("supabase")) ?? "").toBe(""); expect(passwords.has("Supabase CLI/supabase")).toBe(false); const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); expect(content).toBe(VALID_TOKEN); @@ -573,19 +575,31 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { }, ); - it.effect("win32: a Windows enumeration failure is not treated as a deletable target", () => { + it.effect("win32: an enumeration hiccup alone does not block logout", () => { throwOnFindCredentials = true; return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); - expect(JSON.stringify(exit.cause)).not.toContain("LegacyDeleteTokenError"); - } + expect(exit._tag).toBe("Success"); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); + it.effect( + "win32: an enumeration failure whose target also fails to delete → LegacyDeleteTokenError", + () => { + throwOnFindCredentials = true; + failDeleteAccounts.add(goWindowsKey("supabase")); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + } + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); + it.effect("legacy-keyring delete error is swallowed and does not change the outcome", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); From e751666cef891dcf784d1f0d9dd1f3b9b77735e1 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:37:04 +0530 Subject: [PATCH 6/7] fix(cli): sweep Windows project credentials and reject empty placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections, verified against apps/cli-go and the napi-rs/keyring source: - Restore the Windows-specific project-credential sweep in logout. Go's windowsKeychain.DeleteAll prefix-matches Supabase CLI: unconditionally (logout_test.go's "removes all Supabase CLI credentials" seeds and expects removal of project-keyed entries) — removing this sweep entirely broke that contract. findCredentials still decodes every matched blob and can abort the whole call on one undecodable entry; that's an upstream library constraint with no available workaround, so it stays best-effort. - probeWindowsTarget now requires a non-empty password before treating a match as present. Entry.withTarget's placeholder write means a prior failed save can leave an empty credential behind; matching on account name alone treated that placeholder as a real session and reported a fabricated successful logout. --- .../legacy/auth/legacy-credentials.layer.ts | 45 ++++++++++++++----- .../legacy-credentials.layer.unit.test.ts | 33 ++++++++++++++ 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index a7f8b0dca4..ffddf8b017 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -131,15 +131,19 @@ type WindowsTargetProbe = "present" | "absent" | "unknown"; function probeWindowsTarget(module: KeyringModule, account: string): WindowsTargetProbe { try { const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); - return credentials.some((item) => item.account === account) ? "present" : "absent"; + // An empty password is an orphaned placeholder, not a real credential. + const credential = credentials.find( + (item) => item.account === account && item.password.length > 0, + ); + return credential ? "present" : "absent"; } catch { return "unknown"; } } -// Constructing `withTarget` (see above) always leaves something at that target -// — the real credential, or the placeholder — so a delete that follows never -// has a legitimate "nothing to delete" outcome: any non-success is surfaced. +// Constructing `withTarget` always leaves something at that target — the real +// credential, or the placeholder — so a delete that follows is never a +// legitimate "nothing to delete": any non-success is surfaced. const deleteProbedWindowsTarget = ( module: KeyringModule, account: string, @@ -300,13 +304,32 @@ const deleteProfileKeyringEntry = ( }); // Best-effort wipe of the `"Supabase CLI"` keyring namespace, mirroring Go's -// `keyring.DeleteAll` (`store.go:71`); per-entry errors are swallowed so one -// stuck credential can't abort logout. Go stores only the access token here -// (`access_token.go:77`, keyed by profile name), never project database -// passwords, and `deleteAccessToken` already removed it before this runs — so -// there is nothing left to enumerate or decode. -const deleteAllKeyringEntries = (module: KeyringModule): Effect.Effect => +// `keyring.DeleteAll` (`store.go:71`); errors are swallowed so one stuck +// credential can't abort logout, matching Go's own handling (`logout.go:35`). +// +// Go writes Windows credentials under the target-shaped form +// (`Supabase CLI:`), invisible to the plain enumeration below, so +// it's swept separately. `findCredentials` decodes every matched blob and +// aborts entirely on one undecodable entry, unlike Go's raw-byte `wincred.List`. +const deleteAllKeyringEntries = ( + module: KeyringModule, + platform: RuntimePlatform, +): Effect.Effect => Effect.sync(() => { + if (platform === "win32") { + try { + const entries = module.findCredentials( + KEYRING_SERVICE, + `${goWindowsCredentialTarget("")}*`, + ); + for (const { account } of entries) { + deleteGoWindowsTarget(module, account); + } + } catch { + // best-effort + } + } + let entries: ReadonlyArray<{ account: string }>; try { entries = module.findCredentials(KEYRING_SERVICE); @@ -462,7 +485,7 @@ const makeLegacyCredentials = Effect.gen(function* () { deleteAllProjectCredentials: Effect.gen(function* () { if (Option.isNone(keyringModule)) return; - yield* deleteAllKeyringEntries(keyringModule.value); + yield* deleteAllKeyringEntries(keyringModule.value, runtimeInfo.platform); }), deleteProjectCredential: (projectRef: string) => diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 998e327a04..d63dc4c661 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -447,6 +447,18 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); + it.effect("win32: an empty placeholder Go target → LegacyNotLoggedInError", () => { + passwords.set(goWindowsKey("supabase"), ""); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + } + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + it.effect( "keyring unavailable (SUPABASE_NO_KEYRING) with token in file → removes file, still NotLoggedIn", () => { @@ -642,6 +654,27 @@ describe("legacyCredentialsLayer.deleteAllProjectCredentials", () => { expect(exit._tag).toBe("Success"); }).pipe(Effect.provide(makeLayer())); }); + + it.effect("win32: deletes Go target-shaped project credentials", () => { + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); + passwords.set(goWindowsKey("abcdefghijklmnopqrs2"), "secret-2"); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + yield* deleteAllProjectCredentials; + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(false); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); + + it.effect("win32: never fails even when the Go target sweep can't enumerate", () => { + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); + opaqueAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAllProjectCredentials); + expect(exit._tag).toBe("Success"); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }); }); describe("legacyCredentialsLayer.deleteProjectCredential", () => { From c2e0d4042699898644b5e1892c337e9b65d34a2f Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:48:13 +0530 Subject: [PATCH 7/7] test(cli): assert the actual outcome of an undecodable Windows sweep entry The prior test only checked that logout doesn't fail; it didn't assert whether the credentials actually survive. Now it does, making the known findCredentials all-or-nothing decode limitation explicit rather than implied. Verified there's no fix available to route around: diffed napi-rs/keyring's published 1.3.0 against its unreleased main branch (entry.rs is byte-identical) and confirmed no version exposes a decode-free bulk enumeration. The decode happens inside the Rust FFI boundary before results ever reach JS, unlike the raw CredEnumerateW Win32 API itself, which returns target names and blob bytes with no decode step at all. --- .../legacy-credentials.layer.unit.test.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index d63dc4c661..f5320dba28 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -666,15 +666,22 @@ describe("legacyCredentialsLayer.deleteAllProjectCredentials", () => { }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); - it.effect("win32: never fails even when the Go target sweep can't enumerate", () => { - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); - opaqueAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); - return Effect.gen(function* () { - const { deleteAllProjectCredentials } = yield* LegacyCredentials; - const exit = yield* Effect.exit(deleteAllProjectCredentials); - expect(exit._tag).toBe("Success"); - }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); - }); + it.effect( + "win32: a single undecodable entry aborts the Go target sweep without failing logout", + () => { + passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); + passwords.set(goWindowsKey("abcdefghijklmnopqrs2"), "secret-2"); + opaqueAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); + return Effect.gen(function* () { + const { deleteAllProjectCredentials } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAllProjectCredentials); + expect(exit._tag).toBe("Success"); + // One undecodable entry aborts the whole findCredentials call. + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(true); + expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(true); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); }); describe("legacyCredentialsLayer.deleteProjectCredential", () => {