Skip to content

Commit 2e549dd

Browse files
committed
feat(auth): store a browser session and renew it on expiry
1 parent 76c95c7 commit 2e549dd

20 files changed

Lines changed: 1309 additions & 57 deletions

src/commands/auth/logout.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { afterEach, describe, expect, it, mock } from "bun:test";
2+
3+
import type { CommandContext } from "~/shell/commandContext.js";
4+
import type { UI } from "~/shell/ui/types.js";
5+
import { handleLogout } from "./logout.js";
6+
7+
afterEach(() => {
8+
mock.restore();
9+
});
10+
11+
type Task<T> = { message: string; task: () => T | Promise<T> };
12+
13+
function makeCtx(
14+
ui: Partial<UI> & { mode: UI["mode"] },
15+
): CommandContext & { ui: UI } {
16+
return {
17+
ui: {
18+
gap: mock(),
19+
intro: mock(),
20+
info: mock(),
21+
warn: mock(),
22+
cancel: mock(),
23+
outro: mock(),
24+
output: mock(),
25+
// Run every task so the test observes the real deletion calls.
26+
withProgress: mock(
27+
async (tasks: Task<unknown>[], summarise?: unknown) => {
28+
const results = [];
29+
for (const t of tasks) results.push(await t.task());
30+
if (typeof summarise === "function") summarise(results);
31+
return results;
32+
},
33+
),
34+
...ui,
35+
} as unknown as UI,
36+
configDir: "/config",
37+
} as unknown as CommandContext & { ui: UI };
38+
}
39+
40+
function makeDeps(args: {
41+
stored: boolean;
42+
env?: Record<string, string | undefined>;
43+
}) {
44+
return {
45+
hasStoredCredentials: mock(async () => args.stored),
46+
deleteApiKey: mock(async () => ({
47+
keychain: "deleted" as const,
48+
file: "deleted" as const,
49+
})),
50+
deleteTokens: mock(async () => ({
51+
keychain: "deleted" as const,
52+
file: "deleted" as const,
53+
})),
54+
env: args.env ?? {},
55+
};
56+
}
57+
58+
// A factory, not a shared constant: a module-level mock would carry its call
59+
// record from one test into the next.
60+
function confirmed() {
61+
return {
62+
mode: "human" as const,
63+
confirm: mock(async () => ({ ok: true as const, value: true })),
64+
};
65+
}
66+
67+
describe("handleLogout", () => {
68+
it("clears browser tokens as well as the stored API key", async () => {
69+
const ctx = makeCtx(confirmed());
70+
const deps = makeDeps({ stored: true });
71+
72+
await handleLogout(ctx, deps);
73+
74+
expect(deps.deleteApiKey).toHaveBeenCalledTimes(1);
75+
expect(deps.deleteTokens).toHaveBeenCalledTimes(1);
76+
});
77+
78+
// The bug this replaces: deletion used to sit behind resolveApiKey, which
79+
// refreshes a browser session over the network. Offline, or once WorkOS had
80+
// rotated the refresh token away, logout reported "not authenticated" and
81+
// left the credentials on disk.
82+
it("clears credentials that can no longer be resolved", async () => {
83+
const ctx = makeCtx(confirmed());
84+
const deps = makeDeps({ stored: true });
85+
86+
await handleLogout(ctx, deps);
87+
88+
expect(ctx.ui.info).not.toHaveBeenCalled();
89+
expect(deps.deleteApiKey).toHaveBeenCalledTimes(1);
90+
expect(deps.deleteTokens).toHaveBeenCalledTimes(1);
91+
});
92+
93+
it("does not consult the network to decide whether to delete", async () => {
94+
const ctx = makeCtx(confirmed());
95+
const deps = makeDeps({ stored: true });
96+
97+
await handleLogout(ctx, deps);
98+
99+
expect(deps.hasStoredCredentials).toHaveBeenCalledTimes(1);
100+
expect(deps.hasStoredCredentials).toHaveBeenCalledWith(
101+
"/config",
102+
undefined,
103+
);
104+
});
105+
106+
it("deletes nothing when there is nothing stored", async () => {
107+
const ctx = makeCtx({ mode: "human" });
108+
const deps = makeDeps({ stored: false });
109+
110+
await handleLogout(ctx, deps);
111+
112+
expect(deps.deleteApiKey).not.toHaveBeenCalled();
113+
expect(deps.deleteTokens).not.toHaveBeenCalled();
114+
});
115+
116+
it("warns that an environment variable cannot be removed", async () => {
117+
const ctx = makeCtx(confirmed());
118+
const deps = makeDeps({
119+
stored: false,
120+
env: { QAWOLF_API_KEY: "qaw_env" },
121+
});
122+
123+
await handleLogout(ctx, deps);
124+
125+
expect(ctx.ui.warn).toHaveBeenCalled();
126+
});
127+
128+
it("still clears storage when only an environment key is set", async () => {
129+
const ctx = makeCtx(confirmed());
130+
const deps = makeDeps({
131+
stored: false,
132+
env: { QAWOLF_API_KEY: "qaw_env" },
133+
});
134+
135+
await handleLogout(ctx, deps);
136+
137+
expect(deps.deleteTokens).toHaveBeenCalledTimes(1);
138+
});
139+
140+
it("deletes nothing when the confirmation is declined", async () => {
141+
const ctx = makeCtx({
142+
mode: "human",
143+
confirm: mock(async () => ({ ok: true as const, value: false })),
144+
});
145+
const deps = makeDeps({ stored: true });
146+
147+
await handleLogout(ctx, deps);
148+
149+
expect(deps.deleteApiKey).not.toHaveBeenCalled();
150+
expect(deps.deleteTokens).not.toHaveBeenCalled();
151+
});
152+
});

src/commands/auth/logout.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,49 @@
1-
import { deleteApiKey, resolveApiKey } from "~/domains/auth/index.js";
2-
import {
3-
type CommandContext,
4-
type CommandResult,
5-
} from "~/shell/commandContext.js";
1+
import { deleteApiKey as realDeleteApiKey } from "~/domains/auth/index.js";
2+
import { deleteTokens as realDeleteTokens } from "~/domains/auth/store/deleteTokens.js";
3+
import { hasStoredCredentials as realHasStoredCredentials } from "~/domains/auth/store/index.js";
4+
import type { CommandContext, CommandResult } from "~/shell/commandContext.js";
65
import { authMessages } from "~/core/messages/index.js";
76

7+
type LogoutDeps = {
8+
hasStoredCredentials?: (
9+
configDir: string,
10+
fs: CommandContext["fs"],
11+
) => Promise<boolean>;
12+
deleteApiKey?: (
13+
configDir: string,
14+
fs: CommandContext["fs"],
15+
) => Promise<unknown>;
16+
deleteTokens?: (
17+
configDir: string,
18+
fs: CommandContext["fs"],
19+
) => Promise<unknown>;
20+
env?: Record<string, string | undefined>;
21+
};
22+
823
export async function handleLogout(
924
ctx: CommandContext,
25+
deps: LogoutDeps = {},
1026
): Promise<CommandResult> {
11-
const resolved = await resolveApiKey(ctx.configDir, ctx.fs);
27+
const hasStoredCredentials =
28+
deps.hasStoredCredentials ?? realHasStoredCredentials;
29+
const deleteApiKey = deps.deleteApiKey ?? realDeleteApiKey;
30+
const deleteTokens = deps.deleteTokens ?? realDeleteTokens;
31+
const env = deps.env ?? process.env;
32+
33+
// Storage is asked directly rather than through resolveApiKey. Resolving a
34+
// browser session refreshes it over the network, so being offline or holding
35+
// a refresh token WorkOS has already rotated away would report "not
36+
// authenticated" and leave the credentials in place — the one case where
37+
// clearing them matters most.
38+
const envKey = env["QAWOLF_API_KEY"]?.trim();
39+
const stored = await hasStoredCredentials(ctx.configDir, ctx.fs);
1240

13-
if (!resolved) {
41+
if (!envKey && !stored) {
1442
ctx.ui.info(authMessages.logout.notAuthenticated);
1543
return;
1644
}
1745

18-
if (resolved.source === "env") {
46+
if (envKey) {
1947
ctx.ui.warn(authMessages.logout.envVarWarning);
2048
}
2149

@@ -34,7 +62,15 @@ export async function handleLogout(
3462
[
3563
{
3664
message: authMessages.logout.deleting,
37-
task: () => deleteApiKey(ctx.configDir, ctx.fs),
65+
// Both credential kinds go, whichever one is present. Clearing only the
66+
// one in use would leave the other to take over on the next command,
67+
// so "logged out" would not be true.
68+
task: async () => {
69+
await Promise.all([
70+
deleteApiKey(ctx.configDir, ctx.fs),
71+
deleteTokens(ctx.configDir, ctx.fs),
72+
]);
73+
},
3874
},
3975
],
4076
() => authMessages.logout.credentialsRemoved,

0 commit comments

Comments
 (0)