Skip to content

Commit 59fecba

Browse files
committed
Prune old login records + Add debug flag for deployments
1 parent 124a340 commit 59fecba

File tree

6 files changed

+120
-33
lines changed

6 files changed

+120
-33
lines changed

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,12 @@
255255
"title": "Search",
256256
"category": "Coder",
257257
"icon": "$(search)"
258+
},
259+
{
260+
"command": "coder.debug.listDeployments",
261+
"title": "List Stored Deployments",
262+
"category": "Coder Debug",
263+
"when": "coder.devMode"
258264
}
259265
],
260266
"menus": {

src/core/container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class ServiceContainer implements vscode.Disposable {
4141
this.logger,
4242
this.pathResolver,
4343
);
44-
this.contextManager = new ContextManager();
44+
this.contextManager = new ContextManager(context);
4545
this.loginCoordinator = new LoginCoordinator(
4646
this.secretsManager,
4747
this.mementoManager,

src/core/contextManager.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,22 @@ const CONTEXT_DEFAULTS = {
55
"coder.isOwner": false,
66
"coder.loaded": false,
77
"coder.workspace.updatable": false,
8+
"coder.devMode": false,
89
} as const;
910

1011
type CoderContext = keyof typeof CONTEXT_DEFAULTS;
1112

1213
export class ContextManager implements vscode.Disposable {
1314
private readonly context = new Map<CoderContext, boolean>();
1415

15-
public constructor() {
16-
(Object.keys(CONTEXT_DEFAULTS) as CoderContext[]).forEach((key) => {
16+
public constructor(extensionContext: vscode.ExtensionContext) {
17+
for (const key of Object.keys(CONTEXT_DEFAULTS) as CoderContext[]) {
1718
this.set(key, CONTEXT_DEFAULTS[key]);
18-
});
19+
}
20+
this.set(
21+
"coder.devMode",
22+
extensionContext.extensionMode === vscode.ExtensionMode.Development,
23+
);
1924
}
2025

2126
public set(key: CoderContext, value: boolean): void {

src/core/secretsManager.ts

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@ const OAUTH_CLIENT_PREFIX = "coder.oauth.client.";
1313
const CURRENT_DEPLOYMENT_KEY = "coder.currentDeployment";
1414
const OAUTH_CALLBACK_KEY = "coder.oauthCallback";
1515

16-
const KNOWN_LABELS_KEY = "coder.knownLabels";
16+
const DEPLOYMENT_USAGE_KEY = "coder.deploymentUsage";
17+
const DEFAULT_MAX_DEPLOYMENTS = 10;
1718

1819
const LEGACY_SESSION_TOKEN_KEY = "sessionToken";
1920

21+
export interface DeploymentUsage {
22+
label: string;
23+
lastAccessedAt: string;
24+
}
25+
2026
export type StoredOAuthTokens = Omit<TokenResponse, "expires_in"> & {
2127
expiry_timestamp: number;
2228
deployment_url: string;
@@ -179,7 +185,7 @@ export class SecretsManager {
179185
`${SESSION_KEY_PREFIX}${label}`,
180186
JSON.stringify(auth),
181187
);
182-
await this.addKnownLabel(label);
188+
await this.recordDeploymentAccess(label);
183189
}
184190

185191
public async clearSessionAuth(label: string): Promise<void> {
@@ -208,7 +214,7 @@ export class SecretsManager {
208214
`${OAUTH_TOKENS_PREFIX}${label}`,
209215
JSON.stringify(tokens),
210216
);
211-
await this.addKnownLabel(label);
217+
await this.recordDeploymentAccess(label);
212218
}
213219

214220
public async clearOAuthTokens(label: string): Promise<void> {
@@ -237,7 +243,7 @@ export class SecretsManager {
237243
`${OAUTH_CLIENT_PREFIX}${label}`,
238244
JSON.stringify(registration),
239245
);
240-
await this.addKnownLabel(label);
246+
await this.recordDeploymentAccess(label);
241247
}
242248

243249
public async clearOAuthClientRegistration(label: string): Promise<void> {
@@ -252,42 +258,48 @@ export class SecretsManager {
252258
}
253259

254260
/**
255-
* TODO currently it might be used wrong because we can be connected to a remote deployment
256-
* and we log out from the sidebar causing the session to be removed and the auto-refresh disabled.
257-
*
258-
* Potential solutions:
259-
* 1. Keep the last 10 auths and possibly remove entries not used in a while instead.
260-
* We do not remove entries on logout!
261-
* 2. Show the user a warning that their remote deployment might be disconnected.
262-
*
263-
* Update all usages of this after arriving at a decision!
261+
* Record that a deployment was accessed, moving it to the front of the LRU list.
262+
* Prunes deployments beyond maxCount, clearing their auth data.
263+
*/
264+
public async recordDeploymentAccess(
265+
label: string,
266+
maxCount = DEFAULT_MAX_DEPLOYMENTS,
267+
): Promise<void> {
268+
const usage = this.getDeploymentUsage();
269+
const filtered = usage.filter((u) => u.label !== label);
270+
filtered.unshift({ label, lastAccessedAt: new Date().toISOString() });
271+
272+
const toKeep = filtered.slice(0, maxCount);
273+
const toRemove = filtered.slice(maxCount);
274+
275+
await Promise.all(toRemove.map((u) => this.clearAllAuthData(u.label)));
276+
await this.memento.update(DEPLOYMENT_USAGE_KEY, toKeep);
277+
}
278+
279+
/**
280+
* Clear all auth data for a deployment and remove it from the usage list.
264281
*/
265282
public async clearAllAuthData(label: string): Promise<void> {
266283
await Promise.all([
267284
this.clearSessionAuth(label),
268285
this.clearOAuthData(label),
269286
]);
270-
await this.removeKnownLabel(label);
287+
const usage = this.getDeploymentUsage().filter((u) => u.label !== label);
288+
await this.memento.update(DEPLOYMENT_USAGE_KEY, usage);
271289
}
272290

291+
/**
292+
* Get all known deployment labels, ordered by most recently accessed.
293+
*/
273294
public getKnownLabels(): string[] {
274-
return this.memento.get<string[]>(KNOWN_LABELS_KEY) ?? [];
295+
return this.getDeploymentUsage().map((u) => u.label);
275296
}
276297

277-
private async addKnownLabel(label: string): Promise<void> {
278-
const labels = new Set(this.getKnownLabels());
279-
if (!labels.has(label)) {
280-
labels.add(label);
281-
await this.memento.update(KNOWN_LABELS_KEY, Array.from(labels));
282-
}
283-
}
284-
285-
private async removeKnownLabel(label: string): Promise<void> {
286-
const labels = new Set(this.getKnownLabels());
287-
if (labels.has(label)) {
288-
labels.delete(label);
289-
await this.memento.update(KNOWN_LABELS_KEY, Array.from(labels));
290-
}
298+
/**
299+
* Get the full deployment usage list with access timestamps.
300+
*/
301+
private getDeploymentUsage(): DeploymentUsage[] {
302+
return this.memento.get<DeploymentUsage[]>(DEPLOYMENT_USAGE_KEY) ?? [];
291303
}
292304

293305
/**

src/extension.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
319319
vscode.commands.registerCommand("coder.searchAllWorkspaces", async () =>
320320
showTreeViewSearch(ALL_WORKSPACES_TREE_ID),
321321
),
322+
vscode.commands.registerCommand("coder.debug.listDeployments", () =>
323+
listStoredDeployments(secretsManager),
324+
),
322325
);
323326

324327
const remote = new Remote(serviceContainer, commands, ctx);
@@ -560,3 +563,25 @@ async function getToken(
560563
}
561564
return "";
562565
}
566+
567+
async function listStoredDeployments(
568+
secretsManager: SecretsManager,
569+
): Promise<void> {
570+
const labels = secretsManager.getKnownLabels();
571+
if (labels.length === 0) {
572+
vscode.window.showInformationMessage("No deployments stored.");
573+
return;
574+
}
575+
576+
const selected = await vscode.window.showQuickPick(
577+
labels.map((label) => ({ label, description: "Click to forget" })),
578+
{ placeHolder: "Select a deployment to forget" },
579+
);
580+
581+
if (selected) {
582+
await secretsManager.clearAllAuthData(selected.label);
583+
vscode.window.showInformationMessage(
584+
`Cleared auth data for ${selected.label}`,
585+
);
586+
}
587+
}

test/unit/core/secretsManager.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,45 @@ describe("SecretsManager", () => {
9797
await secretsManager.clearAllAuthData("example.com");
9898
expect(secretsManager.getKnownLabels()).not.toContain("example.com");
9999
});
100+
101+
it("should order labels by most recently accessed", async () => {
102+
await secretsManager.setSessionAuth("first.com", {
103+
url: "https://first.com",
104+
token: "token1",
105+
});
106+
await secretsManager.setSessionAuth("second.com", {
107+
url: "https://second.com",
108+
token: "token2",
109+
});
110+
await secretsManager.setSessionAuth("first.com", {
111+
url: "https://first.com",
112+
token: "token1-updated",
113+
});
114+
115+
expect(secretsManager.getKnownLabels()).toEqual([
116+
"first.com",
117+
"second.com",
118+
]);
119+
});
120+
121+
it("should prune old deployments when exceeding maxCount", async () => {
122+
for (let i = 1; i <= 5; i++) {
123+
await secretsManager.setSessionAuth(`host${i}.com`, {
124+
url: `https://host${i}.com`,
125+
token: `token${i}`,
126+
});
127+
}
128+
129+
await secretsManager.recordDeploymentAccess("new.com", 3);
130+
131+
expect(secretsManager.getKnownLabels()).toEqual([
132+
"new.com",
133+
"host5.com",
134+
"host4.com",
135+
]);
136+
expect(await secretsManager.getSessionToken("host1.com")).toBeUndefined();
137+
expect(await secretsManager.getSessionToken("host2.com")).toBeUndefined();
138+
});
100139
});
101140

102141
describe("current deployment", () => {

0 commit comments

Comments
 (0)