-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathcliCredentialManager.ts
More file actions
267 lines (244 loc) · 7.24 KB
/
cliCredentialManager.ts
File metadata and controls
267 lines (244 loc) · 7.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import * as semver from "semver";
import { isKeyringEnabled } from "../cliConfig";
import { featureSetForVersion } from "../featureSet";
import { getHeaderArgs } from "../headers";
import { renameWithRetry, tempFilePath, toSafeHost } from "../util";
import * as cliUtils from "./cliUtils";
import type { WorkspaceConfiguration } from "vscode";
import type { Logger } from "../logging/logger";
import type { PathResolver } from "./pathResolver";
const execFileAsync = promisify(execFile);
type KeyringFeature = "keyringAuth" | "keyringTokenRead";
const EXEC_TIMEOUT_MS = 60_000;
const EXEC_LOG_INTERVAL_MS = 5_000;
/**
* Resolves a CLI binary path for a given deployment URL, fetching/downloading
* if needed. Returns the path or throws if unavailable.
*/
export type BinaryResolver = (deploymentUrl: string) => Promise<string>;
/**
* Returns true on platforms where the OS keyring is supported (macOS, Windows).
*/
export function isKeyringSupported(): boolean {
return process.platform === "darwin" || process.platform === "win32";
}
/**
* Delegates credential storage to the Coder CLI. Owns all credential
* persistence: keyring-backed (via CLI) and file-based (plaintext).
*/
export class CliCredentialManager {
constructor(
private readonly logger: Logger,
private readonly resolveBinary: BinaryResolver,
private readonly pathResolver: PathResolver,
) {}
/**
* Store credentials for a deployment URL. Uses the OS keyring when the
* setting is enabled and the CLI supports it; otherwise writes plaintext
* files under --global-config.
*
* Keyring and files are mutually exclusive — never both.
*/
public async storeToken(
url: string,
token: string,
configs: Pick<WorkspaceConfiguration, "get">,
signal?: AbortSignal,
): Promise<void> {
const binPath = await this.resolveKeyringBinary(
url,
configs,
"keyringAuth",
);
if (!binPath) {
await this.writeCredentialFiles(url, token);
return;
}
const args = [
...getHeaderArgs(configs),
"login",
"--use-token-as-session",
url,
];
try {
await this.execWithTimeout(binPath, args, {
env: { ...process.env, CODER_SESSION_TOKEN: token },
signal,
});
this.logger.info("Stored token via CLI for", url);
} catch (error) {
this.logger.warn("Failed to store token via CLI:", error);
throw error;
}
}
/**
* Read a token via `coder login token --url`. Returns trimmed stdout,
* or undefined on any failure (resolver, CLI, empty output).
*/
public async readToken(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
): Promise<string | undefined> {
let binPath: string | undefined;
try {
binPath = await this.resolveKeyringBinary(
url,
configs,
"keyringTokenRead",
);
} catch (error) {
this.logger.warn("Could not resolve CLI binary for token read:", error);
return undefined;
}
if (!binPath) {
return undefined;
}
const args = [...getHeaderArgs(configs), "login", "token", "--url", url];
try {
const { stdout } = await this.execWithTimeout(binPath, args);
const token = stdout.trim();
return token || undefined;
} catch (error) {
this.logger.warn("Failed to read token via CLI:", error);
return undefined;
}
}
/**
* Delete credentials for a deployment. Runs file deletion and keyring
* deletion in parallel, both best-effort (never throws).
*/
public async deleteToken(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
signal?: AbortSignal,
): Promise<void> {
await Promise.all([
this.deleteCredentialFiles(url),
this.deleteKeyringToken(url, configs, signal),
]);
}
/**
* Resolve a CLI binary for keyring operations. Returns the binary path
* when keyring is enabled in settings and the CLI version supports the
* requested feature, or undefined to fall back to file-based storage.
*
* Throws on binary resolution or version-check failure (caller decides
* whether to catch or propagate).
*/
private async resolveKeyringBinary(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
feature: KeyringFeature,
): Promise<string | undefined> {
if (!isKeyringEnabled(configs)) {
return undefined;
}
const binPath = await this.resolveBinary(url);
const version = semver.parse(await cliUtils.version(binPath));
return featureSetForVersion(version)[feature] ? binPath : undefined;
}
/**
* Wrap execFileAsync with a 60s timeout and periodic debug logging.
*/
private async execWithTimeout(
binPath: string,
args: string[],
options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal } = {},
): Promise<{ stdout: string; stderr: string }> {
const { signal, ...execOptions } = options;
const timer = setInterval(() => {
this.logger.debug(`CLI command still running: coder ${args[0]} ...`);
}, EXEC_LOG_INTERVAL_MS);
try {
return await execFileAsync(binPath, args, {
...execOptions,
timeout: EXEC_TIMEOUT_MS,
signal,
});
} finally {
clearInterval(timer);
}
}
/**
* Write URL and token files under --global-config.
*/
private async writeCredentialFiles(
url: string,
token: string,
): Promise<void> {
const safeHostname = toSafeHost(url);
await Promise.all([
this.atomicWriteFile(this.pathResolver.getUrlPath(safeHostname), url),
this.atomicWriteFile(
this.pathResolver.getSessionTokenPath(safeHostname),
token,
),
]);
}
/**
* Delete URL and token files. Best-effort: never throws.
*/
private async deleteCredentialFiles(url: string): Promise<void> {
const safeHostname = toSafeHost(url);
const paths = [
this.pathResolver.getSessionTokenPath(safeHostname),
this.pathResolver.getUrlPath(safeHostname),
];
await Promise.all(
paths.map((p) =>
fs.rm(p, { force: true }).catch((error) => {
this.logger.warn("Failed to remove credential file", p, error);
}),
),
);
}
/**
* Delete keyring token via `coder logout`. Best-effort: never throws.
*/
private async deleteKeyringToken(
url: string,
configs: Pick<WorkspaceConfiguration, "get">,
signal?: AbortSignal,
): Promise<void> {
let binPath: string | undefined;
try {
binPath = await this.resolveKeyringBinary(url, configs, "keyringAuth");
} catch (error) {
this.logger.warn("Could not resolve keyring binary for delete:", error);
return;
}
if (!binPath) {
return;
}
const args = [...getHeaderArgs(configs), "logout", "--url", url, "--yes"];
try {
await this.execWithTimeout(binPath, args, { signal });
this.logger.info("Deleted token via CLI for", url);
} catch (error) {
this.logger.warn("Failed to delete token via CLI:", error);
}
}
/**
* Atomically write content to a file via temp-file + rename.
*/
private async atomicWriteFile(
filePath: string,
content: string,
): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = tempFilePath(filePath, "temp");
try {
await fs.writeFile(tempPath, content, { mode: 0o600 });
await renameWithRetry(fs.rename, tempPath, filePath);
} catch (err) {
await fs.rm(tempPath, { force: true }).catch((rmErr) => {
this.logger.warn("Failed to delete temp file", tempPath, rmErr);
});
throw err;
}
}
}