Skip to content

Commit a1259d6

Browse files
fix(cli): read Go Windows credentials via findCredentials (#5423)
Fixes #5415. The Windows fallback added for Go-written credentials used `Entry.withTarget(...).getPassword()`. On Windows, that does not read the Go-shaped target credential correctly. `findCredentials(service, target)` can read it, so this uses that path for the Go Windows target while preserving the existing default keyring and file fallback behavior. The legacy credentials unit test now covers the target lookup path. Tested with: ```bash npx bun run .\node_modules\vitest\vitest.mjs run src/legacy/auth/legacy-credentials.layer.unit.test.ts --config vitest.config.ts ``` --------- Co-authored-by: Julien Goux <hi@jgoux.dev>
1 parent 17c45ad commit a1259d6

2 files changed

Lines changed: 182 additions & 23 deletions

File tree

apps/cli/src/legacy/auth/legacy-credentials.layer.ts

Lines changed: 109 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const INVALID_TOKEN_MESSAGE = "Invalid access token format. Must be like `sbp_01
1616

1717
type KeyringModule = typeof import("@napi-rs/keyring");
1818
type KeyringEntry = InstanceType<KeyringModule["Entry"]>;
19+
type RuntimePlatform = NodeJS.Platform;
1920

2021
const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect<boolean> =>
2122
Effect.gen(function* () {
@@ -30,16 +31,21 @@ const detectWsl = (fs: FileSystem.FileSystem): Effect.Effect<boolean> =>
3031
const tryKeyringRead = (
3132
module: KeyringModule,
3233
account: string,
34+
platform: RuntimePlatform,
3335
): Effect.Effect<Option.Option<string>> =>
3436
Effect.try({
3537
try: () => {
36-
for (const entry of [
37-
new module.Entry(KEYRING_SERVICE, account),
38-
module.Entry.withTarget(`${KEYRING_SERVICE}:${account}`, KEYRING_SERVICE, account),
39-
]) {
40-
const value = readEntryPassword(entry);
41-
if (value && value.length > 0) return Option.some(normalizeKeyringToken(value));
38+
const entry = new module.Entry(KEYRING_SERVICE, account);
39+
const value = readEntryPassword(entry);
40+
if (value && value.length > 0) return Option.some(normalizeKeyringToken(value));
41+
42+
if (platform === "win32") {
43+
const goWindowsValue = readGoWindowsTarget(module, account);
44+
if (goWindowsValue && goWindowsValue.length > 0) {
45+
return Option.some(normalizeKeyringToken(goWindowsValue));
46+
}
4247
}
48+
4349
return Option.none<string>();
4450
},
4551
catch: () => Option.none<string>(),
@@ -49,29 +55,41 @@ const tryKeyringWrite = (
4955
module: KeyringModule,
5056
account: string,
5157
token: string,
58+
platform: RuntimePlatform,
5259
): Effect.Effect<boolean> =>
5360
Effect.try({
5461
try: () => {
62+
if (platform === "win32") {
63+
return writeGoWindowsTarget(module, account, token);
64+
}
65+
5566
const entry = new module.Entry(KEYRING_SERVICE, account);
5667
entry.setPassword(token);
5768
return true;
5869
},
5970
catch: () => false,
6071
}).pipe(Effect.orElseSucceed(() => false));
6172

62-
const tryKeyringDelete = (module: KeyringModule, account: string): Effect.Effect<boolean> =>
73+
const tryKeyringDelete = (
74+
module: KeyringModule,
75+
account: string,
76+
platform: RuntimePlatform,
77+
): Effect.Effect<boolean> =>
6378
Effect.try({
6479
try: () => {
6580
let deleted = false;
66-
for (const entry of [
67-
new module.Entry(KEYRING_SERVICE, account),
68-
module.Entry.withTarget(`${KEYRING_SERVICE}:${account}`, KEYRING_SERVICE, account),
69-
]) {
70-
const value = readEntryPassword(entry);
71-
if (!value) continue;
81+
82+
const entry = new module.Entry(KEYRING_SERVICE, account);
83+
const value = readEntryPassword(entry);
84+
if (value) {
7285
entry.deleteCredential();
7386
deleted = true;
7487
}
88+
89+
if (platform === "win32" && readGoWindowsTarget(module, account)) {
90+
deleted = deleteGoWindowsTarget(module, account) || deleted;
91+
}
92+
7593
return deleted;
7694
},
7795
catch: () => false,
@@ -85,6 +103,64 @@ function readEntryPassword(entry: KeyringEntry): string | null {
85103
}
86104
}
87105

106+
function goWindowsCredentialTarget(account: string): string {
107+
return `${KEYRING_SERVICE}:${account}`;
108+
}
109+
110+
function readGoWindowsTarget(module: KeyringModule, account: string): string | null {
111+
try {
112+
const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account));
113+
const credential = credentials.find((item) => item.account === account);
114+
return credential ? normalizeGoWindowsPassword(credential.password) : null;
115+
} catch {
116+
return null;
117+
}
118+
}
119+
120+
function normalizeGoWindowsPassword(value: string): string {
121+
const direct = normalizeKeyringToken(value);
122+
if (ACCESS_TOKEN_PATTERN.test(direct)) return direct;
123+
124+
// Go writes Windows CredentialBlob values as raw UTF-8 bytes. The TS keyring
125+
// search API can surface those bytes packed into UTF-16 code units, so unpack
126+
// each code unit back into the original byte sequence before validation.
127+
const bytes: number[] = [];
128+
for (let index = 0; index < value.length; index += 1) {
129+
const code = value.charCodeAt(index);
130+
bytes.push(code & 0xff);
131+
const high = (code >> 8) & 0xff;
132+
if (high !== 0) bytes.push(high);
133+
}
134+
return Buffer.from(bytes).toString("utf8");
135+
}
136+
137+
function writeGoWindowsTarget(module: KeyringModule, account: string, token: string): boolean {
138+
try {
139+
const entry = module.Entry.withTarget(
140+
goWindowsCredentialTarget(account),
141+
KEYRING_SERVICE,
142+
account,
143+
);
144+
entry.setSecret(Buffer.from(token, "utf8"));
145+
return true;
146+
} catch {
147+
return false;
148+
}
149+
}
150+
151+
function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean {
152+
try {
153+
const entry = module.Entry.withTarget(
154+
goWindowsCredentialTarget(account),
155+
KEYRING_SERVICE,
156+
account,
157+
);
158+
return entry.deleteCredential();
159+
} catch {
160+
return false;
161+
}
162+
}
163+
88164
const makeLegacyCredentials = Effect.gen(function* () {
89165
const fs = yield* FileSystem.FileSystem;
90166
const path = yield* Path.Path;
@@ -108,9 +184,13 @@ const makeLegacyCredentials = Effect.gen(function* () {
108184

109185
const readKeyring = Effect.gen(function* () {
110186
if (Option.isNone(keyringModule)) return Option.none<string>();
111-
const profileResult = yield* tryKeyringRead(keyringModule.value, profileAccount);
187+
const profileResult = yield* tryKeyringRead(
188+
keyringModule.value,
189+
profileAccount,
190+
runtimeInfo.platform,
191+
);
112192
if (Option.isSome(profileResult)) return profileResult;
113-
return yield* tryKeyringRead(keyringModule.value, LEGACY_KEYRING_ACCOUNT);
193+
return yield* tryKeyringRead(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform);
114194
});
115195

116196
const readFile = Effect.gen(function* () {
@@ -150,7 +230,12 @@ const makeLegacyCredentials = Effect.gen(function* () {
150230
Effect.gen(function* () {
151231
yield* validate(token);
152232
if (Option.isSome(keyringModule)) {
153-
const ok = yield* tryKeyringWrite(keyringModule.value, profileAccount, token);
233+
const ok = yield* tryKeyringWrite(
234+
keyringModule.value,
235+
profileAccount,
236+
token,
237+
runtimeInfo.platform,
238+
);
154239
if (ok) return;
155240
}
156241
yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o700 }).pipe(Effect.orDie);
@@ -160,8 +245,14 @@ const makeLegacyCredentials = Effect.gen(function* () {
160245
deleteAccessToken: Effect.gen(function* () {
161246
let anyDeleted = false;
162247
if (Option.isSome(keyringModule)) {
163-
if (yield* tryKeyringDelete(keyringModule.value, profileAccount)) anyDeleted = true;
164-
if (yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT)) anyDeleted = true;
248+
if (yield* tryKeyringDelete(keyringModule.value, profileAccount, runtimeInfo.platform)) {
249+
anyDeleted = true;
250+
}
251+
if (
252+
yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform)
253+
) {
254+
anyDeleted = true;
255+
}
165256
}
166257
const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false));
167258
if (exists) {

apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,20 @@ import { LegacyInvalidAccessTokenError } from "./legacy-errors.ts";
2020

2121
const passwords = new Map<string, string>();
2222
let throwOnSetPassword = false;
23+
let throwOnSetSecret = false;
2324
const throwOnGetPasswordAccounts = new Set<string>();
25+
const withTargetCalls: string[] = [];
2426

2527
vi.mock("@napi-rs/keyring", () => ({
28+
findCredentials: (service: string, target?: string) =>
29+
Array.from(passwords.entries())
30+
.filter(([key]) =>
31+
target === undefined ? key.startsWith(`${service}/`) : key.startsWith(`${target}/`),
32+
)
33+
.map(([key, password]) => ({
34+
account: key.split("/").at(-1)!,
35+
password,
36+
})),
2637
Entry: class Entry {
2738
service: string;
2839
account: string;
@@ -33,6 +44,7 @@ vi.mock("@napi-rs/keyring", () => ({
3344
this.target = target;
3445
}
3546
static withTarget(target: string, service: string, account: string) {
47+
withTargetCalls.push(`${target}/${service}/${account}`);
3648
return new this(service, account, target);
3749
}
3850
key(): string {
@@ -51,6 +63,10 @@ vi.mock("@napi-rs/keyring", () => ({
5163
if (throwOnSetPassword) throw new Error("Keyring unavailable");
5264
passwords.set(this.key(), value);
5365
}
66+
setSecret(value: Uint8Array): void {
67+
if (throwOnSetSecret) throw new Error("Keyring unavailable");
68+
passwords.set(this.key(), Buffer.from(value).toString("utf8"));
69+
}
5470
deleteCredential(): boolean {
5571
const key = this.key();
5672
if (!passwords.has(key)) throw new Error("not found");
@@ -66,10 +82,20 @@ vi.mock("@napi-rs/keyring", () => ({
6682

6783
let tempHome: string;
6884

69-
function makeLayer(opts: { env?: Record<string, string | undefined>; home?: string } = {}) {
85+
function makeLayer(
86+
opts: {
87+
env?: Record<string, string | undefined>;
88+
home?: string;
89+
platform?: NodeJS.Platform;
90+
} = {},
91+
) {
7092
const home = opts.home ?? tempHome;
7193
const env = { HOME: home, ...opts.env };
72-
const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home });
94+
const runtimeInfoLayer = mockRuntimeInfo({
95+
homeDir: home,
96+
cwd: home,
97+
platform: opts.platform,
98+
});
7399
const cliConfigLayer = legacyCliConfigLayer.pipe(
74100
Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")),
75101
Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none<string>())),
@@ -88,7 +114,9 @@ function makeLayer(opts: { env?: Record<string, string | undefined>; home?: stri
88114
beforeEach(() => {
89115
passwords.clear();
90116
throwOnSetPassword = false;
117+
throwOnSetSecret = false;
91118
throwOnGetPasswordAccounts.clear();
119+
withTargetCalls.length = 0;
92120
tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-"));
93121
});
94122

@@ -101,6 +129,14 @@ const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40);
101129
const encodeGoKeyringBase64 = (token: string) =>
102130
`go-keyring-base64:${Buffer.from(token).toString("base64")}`;
103131
const goWindowsKey = (account: string) => `Supabase CLI:${account}/Supabase CLI/${account}`;
132+
const encodeGoWindowsPassword = (token: string) => {
133+
const bytes = Buffer.from(token, "utf8");
134+
let encoded = "";
135+
for (let index = 0; index < bytes.length; index += 2) {
136+
encoded += String.fromCharCode(bytes[index]! | ((bytes[index + 1] ?? 0) << 8));
137+
}
138+
return encoded;
139+
};
104140

105141
const expectSomeToken = (token: Option.Option<Redacted.Redacted<string>>, expected: string) => {
106142
expect(Option.isSome(token)).toBe(true);
@@ -138,12 +174,23 @@ describe("legacyCredentialsLayer.getAccessToken", () => {
138174
});
139175

140176
it.effect("reads Windows credentials created by Go keyring", () => {
141-
passwords.set(goWindowsKey("supabase"), VALID_TOKEN);
177+
passwords.set(goWindowsKey("supabase"), encodeGoWindowsPassword(VALID_TOKEN));
142178
return Effect.gen(function* () {
143179
const { getAccessToken } = yield* LegacyCredentials;
144180
const token = yield* getAccessToken;
145181
expectSomeToken(token, VALID_TOKEN);
146-
}).pipe(Effect.provide(makeLayer()));
182+
expect(withTargetCalls).toEqual([]);
183+
}).pipe(Effect.provide(makeLayer({ platform: "win32" })));
184+
});
185+
186+
it.effect("does not search Go Windows targets on other platforms", () => {
187+
passwords.set(goWindowsKey("supabase"), VALID_TOKEN);
188+
return Effect.gen(function* () {
189+
const { getAccessToken } = yield* LegacyCredentials;
190+
const token = yield* getAccessToken;
191+
expect(token).toEqual(Option.none());
192+
expect(withTargetCalls).toEqual([]);
193+
}).pipe(Effect.provide(makeLayer({ platform: "linux" })));
147194
});
148195

149196
it.effect("falls through to the legacy access-token keyring entry", () => {
@@ -222,6 +269,27 @@ describe("legacyCredentialsLayer.saveAccessToken", () => {
222269
}).pipe(Effect.provide(makeLayer())),
223270
);
224271

272+
it.effect("writes Windows credentials where Go keyring reads them", () =>
273+
Effect.gen(function* () {
274+
const { saveAccessToken } = yield* LegacyCredentials;
275+
yield* saveAccessToken(VALID_TOKEN);
276+
expect(passwords.get(goWindowsKey("supabase"))).toBe(VALID_TOKEN);
277+
expect(passwords.has("Supabase CLI/supabase")).toBe(false);
278+
}).pipe(Effect.provide(makeLayer({ platform: "win32" }))),
279+
);
280+
281+
it.effect("falls back to the shared token file when Windows target writes fail", () => {
282+
throwOnSetSecret = true;
283+
return Effect.gen(function* () {
284+
const { saveAccessToken } = yield* LegacyCredentials;
285+
yield* saveAccessToken(VALID_TOKEN);
286+
expect(passwords.has(goWindowsKey("supabase"))).toBe(false);
287+
expect(passwords.has("Supabase CLI/supabase")).toBe(false);
288+
const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8");
289+
expect(content).toBe(VALID_TOKEN);
290+
}).pipe(Effect.provide(makeLayer({ platform: "win32" })));
291+
});
292+
225293
it.effect("falls back to the filesystem when the keyring write throws", () => {
226294
throwOnSetPassword = true;
227295
return Effect.gen(function* () {
@@ -255,7 +323,7 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => {
255323
expect(passwords.has("Supabase CLI/access-token")).toBe(false);
256324
expect(passwords.has(goWindowsKey("supabase"))).toBe(false);
257325
expect(existsSync(join(supaDir, "access-token"))).toBe(false);
258-
}).pipe(Effect.provide(makeLayer()));
326+
}).pipe(Effect.provide(makeLayer({ platform: "win32" })));
259327
});
260328
});
261329

0 commit comments

Comments
 (0)