Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 40 additions & 21 deletions apps/cli/src/auth/command-credentials.layer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Config,
ConfigProvider,
Duration,
Effect,
FileSystem,
Layer,
Expand Down Expand Up @@ -335,30 +336,45 @@ const loadKeyringModule = (
: yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option);
});

interface KeyringToken {
readonly value: string;
readonly account: "profile" | "legacy";
}

// Keyring chain for a given profile account: profile key first, then the legacy `access-token`
// key. Callers must pass the already-reconciled profile name.
const readKeyringForAccount = (
const lookupKeyringForAccount = (
keyringModule: Option.Option<KeyringModule>,
profileAccount: string,
platform: RuntimePlatform,
debugLogger: DebugLoggerShape,
): Effect.Effect<Option.Option<string>> =>
): Effect.Effect<Option.Option<KeyringToken>> =>
Effect.gen(function* () {
if (Option.isNone(keyringModule)) return Option.none<string>();
if (Option.isNone(keyringModule)) return Option.none<KeyringToken>();
const profileResult = yield* tryKeyringRead(keyringModule.value, profileAccount, platform);
if (Option.isSome(profileResult)) {
yield* debugLogger.debug(`Using access token for profile: ${profileAccount}`);
return profileResult;
return Option.some({ value: profileResult.value, account: "profile" });
}
const legacyResult = yield* tryKeyringRead(
keyringModule.value,
LEGACY_KEYRING_ACCOUNT,
platform,
);
if (Option.isSome(legacyResult)) {
yield* debugLogger.debug("Using access token from credentials store...");
}
return legacyResult;
return Option.map(legacyResult, (value) => ({ value, account: "legacy" }));
});

const logKeyringSource = (
token: Option.Option<KeyringToken>,
profileAccount: string,
debugLogger: DebugLoggerShape,
): Effect.Effect<Option.Option<string>> =>
Effect.gen(function* () {
if (Option.isNone(token)) return Option.none<string>();
yield* debugLogger.debug(
token.value.account === "profile"
? `Using access token for profile: ${profileAccount}`
: "Using access token from credentials store...",
);
return Option.some(token.value.value);
});

const readFallbackFile = (
Expand Down Expand Up @@ -403,10 +419,9 @@ export const accessTokenForProfile = Effect.fn("CommandCredentials.accessTokenFo
configProvider,
);
const keyringModule = yield* loadKeyringModule(fs, noKeyring);
const keyringValue = yield* readKeyringForAccount(
keyringModule,
const keyringValue = yield* logKeyringSource(
yield* lookupKeyringForAccount(keyringModule, profileAccount, runtimeInfo.platform),
profileAccount,
runtimeInfo.platform,
debugLogger,
);
if (Option.isSome(keyringValue)) {
Expand Down Expand Up @@ -445,11 +460,13 @@ const makeCommandCredentials = Effect.gen(function* () {
const keyringModule = yield* loadKeyringModule(fs, noKeyring);
yield* Effect.annotateCurrentSpan({ "keyring.enabled": Option.isSome(keyringModule) });

const readKeyring = readKeyringForAccount(
keyringModule,
profileAccount,
runtimeInfo.platform,
debugLogger,
/* Read the keychain once per command: on macOS each read by an untrusted binary prompts. */
Comment thread
7ttp marked this conversation as resolved.
Resolved
const [lookupKeyring, invalidateKeyring] = yield* Effect.cachedInvalidateWithTTL(
lookupKeyringForAccount(keyringModule, profileAccount, runtimeInfo.platform),
Duration.infinity,
);
const readKeyring = lookupKeyring.pipe(
Effect.flatMap((token) => logKeyringSource(token, profileAccount, debugLogger)),
);

const readFile = readFallbackFile(fs, fallbackPath);
Expand Down Expand Up @@ -491,7 +508,7 @@ const makeCommandCredentials = Effect.gen(function* () {
profileAccount,
token,
runtimeInfo.platform,
);
).pipe(Effect.ensuring(invalidateKeyring));
if (ok) {
yield* Effect.annotateCurrentSpan({ "credential.destination": "keyring" });
return;
Expand Down Expand Up @@ -534,15 +551,17 @@ const makeCommandCredentials = Effect.gen(function* () {
keyringModule.value,
profileAccount,
runtimeInfo.platform,
);
).pipe(Effect.ensuring(invalidateKeyring));
if (outcome === "notFound") {
return yield* new NotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE });
}
}).pipe(Effect.withSpan("CommandCredentials.deleteAccessToken")),

deleteAllProjectCredentials: Effect.gen(function* () {
if (Option.isNone(keyringModule)) return;
yield* deleteAllKeyringEntries(keyringModule.value, runtimeInfo.platform);
yield* deleteAllKeyringEntries(keyringModule.value, runtimeInfo.platform).pipe(
Effect.ensuring(invalidateKeyring),
);
}).pipe(Effect.withSpan("CommandCredentials.deleteAllProjectCredentials")),

deleteProjectCredential: Effect.fn("CommandCredentials.deleteProjectCredential")(function* (
Expand Down
27 changes: 27 additions & 0 deletions apps/cli/src/auth/command-credentials.layer.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const throwOnGetPasswordAccounts = new Set<string>();
const throwOnDeleteAccounts = new Set<string>();
const failDeleteAccounts = new Set<string>();
const withTargetCalls: string[] = [];
const getPasswordCalls: string[] = [];
const opaqueAccounts = new Set<string>();
let throwOnFindCredentials = false;

Expand Down Expand Up @@ -75,6 +76,7 @@ vi.mock("@napi-rs/keyring", () => ({
}
getPassword(): string | null {
const key = this.key();
getPasswordCalls.push(key);
if (throwOnGetPasswordAccounts.has(key)) {
throw new Error("Keyring unavailable");
}
Expand Down Expand Up @@ -145,6 +147,7 @@ beforeEach(() => {
throwOnGetPasswordAccounts.clear();
throwOnDeleteAccounts.clear();
withTargetCalls.length = 0;
getPasswordCalls.length = 0;
opaqueAccounts.clear();
failDeleteAccounts.clear();
throwOnFindCredentials = false;
Expand Down Expand Up @@ -233,6 +236,30 @@ describe("commandCredentialsLayer.getAccessToken", () => {
}).pipe(Effect.provide(makeLayer()));
});

it.effect("reads the keychain once per command until the stored token changes", () => {
passwords.set("Supabase CLI/supabase", VALID_TOKEN);
const stderr = captureStderr();
return Effect.gen(function* () {
const { getAccessToken, saveAccessToken, deleteAccessToken, deleteAllProjectCredentials } =
yield* CommandCredentials;
expectSomeToken(yield* getAccessToken, VALID_TOKEN);
expectSomeToken(yield* getAccessToken, VALID_TOKEN);
expect(getPasswordCalls).toEqual(["Supabase CLI/supabase"]);
const stderrText = stderr.mock.calls.map(([chunk]) => String(chunk)).join("");
expect(stderrText.match(/Using access token for profile: supabase\n/g)).toHaveLength(2);

yield* deleteAllProjectCredentials;
expect(Option.isNone(yield* getAccessToken)).toBe(true);
yield* saveAccessToken(VALID_OAUTH_TOKEN);
expectSomeToken(yield* getAccessToken, VALID_OAUTH_TOKEN);
yield* deleteAccessToken;
expect(Option.isNone(yield* getAccessToken)).toBe(true);
}).pipe(
Effect.ensuring(Effect.sync(() => stderr.mockRestore())),
Effect.provide(makeLayer({ debug: true })),
);
});

it.effect("debug logs the access token source", () => {
passwords.set("Supabase CLI/supabase", VALID_TOKEN);
const stderr = captureStderr();
Expand Down
Loading