Skip to content

Commit cc2823b

Browse files
committed
Do not require token auth with mTLS
1 parent 2d428eb commit cc2823b

File tree

5 files changed

+117
-69
lines changed

5 files changed

+117
-69
lines changed

package.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -74,17 +74,17 @@
7474
"default": ""
7575
},
7676
"coder.tlsCertFile": {
77-
"markdownDescription": "Path to file for TLS client cert",
77+
"markdownDescription": "Path to file for TLS client cert. When specified, token authorization will be skipped.",
7878
"type": "string",
7979
"default": ""
8080
},
8181
"coder.tlsKeyFile": {
82-
"markdownDescription": "Path to file for TLS client key",
82+
"markdownDescription": "Path to file for TLS client key. When specified, token authorization will be skipped.",
8383
"type": "string",
8484
"default": ""
8585
},
8686
"coder.tlsCaFile": {
87-
"markdownDescription": "Path to file for TLS certificate authority",
87+
"markdownDescription": "Path to file for TLS certificate authority.",
8888
"type": "string",
8989
"default": ""
9090
},

src/api.ts

+24
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ import { getProxyForUrl } from "./proxy"
1010
import { Storage } from "./storage"
1111
import { expandPath } from "./util"
1212

13+
/**
14+
* Return whether the API will need a token for authorization.
15+
* If mTLS is in use (as specified by the cert or key files being set) then
16+
* token authorization is disabled. Otherwise, it is enabled.
17+
*/
18+
export function needToken(): boolean {
19+
const cfg = vscode.workspace.getConfiguration()
20+
const certFile = expandPath(String(cfg.get("coder.tlsCertFile") ?? "").trim())
21+
const keyFile = expandPath(String(cfg.get("coder.tlsKeyFile") ?? "").trim())
22+
return !certFile && !keyFile
23+
}
24+
25+
/**
26+
* Create a new agent based off the current settings.
27+
*/
1328
async function createHttpAgent(): Promise<ProxyAgent> {
1429
const cfg = vscode.workspace.getConfiguration()
1530
const insecure = Boolean(cfg.get("coder.insecure"))
@@ -32,7 +47,16 @@ async function createHttpAgent(): Promise<ProxyAgent> {
3247
})
3348
}
3449

50+
// The agent is a singleton so we only have to listen to the configuration once
51+
// (otherwise we would have to carefully dispose agents to remove their
52+
// configuration listeners), and to share the connection pool.
3553
let agent: Promise<ProxyAgent> | undefined = undefined
54+
55+
/**
56+
* Get the existing agent or create one if necessary. On settings change,
57+
* recreate the agent. The agent on the client is not automatically updated;
58+
* this must be called before every request to get the latest agent.
59+
*/
3660
async function getHttpAgent(): Promise<ProxyAgent> {
3761
if (!agent) {
3862
vscode.workspace.onDidChangeConfiguration((e) => {

src/commands.ts

+78-58
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Api } from "coder/site/src/api/api"
22
import { getErrorMessage } from "coder/site/src/api/errors"
33
import { User, Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"
44
import * as vscode from "vscode"
5-
import { makeCoderSdk } from "./api"
5+
import { makeCoderSdk, needToken } from "./api"
66
import { extractAgents } from "./api-helper"
77
import { CertificateError } from "./error"
88
import { Storage } from "./storage"
@@ -147,78 +147,33 @@ export class Commands {
147147
// a host label.
148148
const label = typeof args[2] === "undefined" ? toSafeHost(url) : args[2]
149149

150-
// Use a temporary client to avoid messing with the global one while trying
151-
// to log in.
152-
const restClient = await makeCoderSdk(url, undefined, this.storage)
153-
154-
let user: User | undefined
155-
let token: string | undefined = args[1]
156-
if (!token) {
157-
const opened = await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`))
158-
if (!opened) {
159-
vscode.window.showWarningMessage("You must accept the URL prompt to generate an API key.")
160-
return
161-
}
162-
163-
token = await vscode.window.showInputBox({
164-
title: "Coder API Key",
165-
password: true,
166-
placeHolder: "Copy your API key from the opened browser page.",
167-
value: await this.storage.getSessionToken(),
168-
ignoreFocusOut: true,
169-
validateInput: async (value) => {
170-
restClient.setSessionToken(value)
171-
try {
172-
user = await restClient.getAuthenticatedUser()
173-
if (!user) {
174-
throw new Error("Failed to get authenticated user")
175-
}
176-
} catch (err) {
177-
// For certificate errors show both a notification and add to the
178-
// text under the input box, since users sometimes miss the
179-
// notification.
180-
if (err instanceof CertificateError) {
181-
err.showNotification()
182-
183-
return {
184-
message: err.x509Err || err.message,
185-
severity: vscode.InputBoxValidationSeverity.Error,
186-
}
187-
}
188-
// This could be something like the header command erroring or an
189-
// invalid session token.
190-
const message = getErrorMessage(err, "no response from the server")
191-
return {
192-
message: "Failed to authenticate: " + message,
193-
severity: vscode.InputBoxValidationSeverity.Error,
194-
}
195-
}
196-
},
197-
})
198-
}
199-
if (!token || !user) {
200-
return
150+
// Try to get a token from the user, if we need one, and their user.
151+
const res = await this.maybeAskToken(url, args[1])
152+
if (!res) {
153+
return // The user aborted.
201154
}
202155

203-
// The URL and token are good; authenticate the global client.
156+
// The URL is good and the token is either good or not required; authorize
157+
// the global client.
204158
this.restClient.setHost(url)
205-
this.restClient.setSessionToken(token)
159+
this.restClient.setSessionToken(res.token)
206160

207161
// Store these to be used in later sessions.
208162
await this.storage.setUrl(url)
209-
await this.storage.setSessionToken(token)
163+
await this.storage.setSessionToken(res.token)
210164

211165
// Store on disk to be used by the cli.
212-
await this.storage.configureCli(label, url, token)
166+
await this.storage.configureCli(label, url, res.token)
213167

168+
// These contexts control various menu items and the sidebar.
214169
await vscode.commands.executeCommand("setContext", "coder.authenticated", true)
215-
if (user.roles.find((role) => role.name === "owner")) {
170+
if (res.user.roles.find((role) => role.name === "owner")) {
216171
await vscode.commands.executeCommand("setContext", "coder.isOwner", true)
217172
}
218173

219174
vscode.window
220175
.showInformationMessage(
221-
`Welcome to Coder, ${user.username}!`,
176+
`Welcome to Coder, ${res.user.username}!`,
222177
{
223178
detail: "You can now use the Coder extension to manage your Coder instance.",
224179
},
@@ -234,6 +189,71 @@ export class Commands {
234189
vscode.commands.executeCommand("coder.refreshWorkspaces")
235190
}
236191

192+
/**
193+
* If necessary, ask for a token, and keep asking until the token has been
194+
* validated. Return the token and user that was fetched to validate the
195+
* token.
196+
*/
197+
private async maybeAskToken(url: string, token: string): Promise<{user: User; token: string} | null> {
198+
const restClient = await makeCoderSdk(url, token, this.storage)
199+
if (!needToken()) {
200+
return {
201+
// For non-token auth, we write a blank token since the `vscodessh`
202+
// command currently always requires a token file.
203+
token: "",
204+
user: await restClient.getAuthenticatedUser(),
205+
}
206+
}
207+
208+
// This prompt is for convenience; do not error if they close it since
209+
// they may already have a token or already have the page opened.
210+
await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`))
211+
212+
// For token auth, start with the existing token in the prompt or the last
213+
// used token. Once submitted, if there is a failure we will keep asking
214+
// the user for a new token until they quit.
215+
let user: User | undefined
216+
const validatedToken = await vscode.window.showInputBox({
217+
title: "Coder API Key",
218+
password: true,
219+
placeHolder: "Paste your API key.",
220+
value: token || (await this.storage.getSessionToken()),
221+
ignoreFocusOut: true,
222+
validateInput: async (value) => {
223+
restClient.setSessionToken(value)
224+
try {
225+
user = await restClient.getAuthenticatedUser()
226+
} catch (err) {
227+
// For certificate errors show both a notification and add to the
228+
// text under the input box, since users sometimes miss the
229+
// notification.
230+
if (err instanceof CertificateError) {
231+
err.showNotification()
232+
233+
return {
234+
message: err.x509Err || err.message,
235+
severity: vscode.InputBoxValidationSeverity.Error,
236+
}
237+
}
238+
// This could be something like the header command erroring or an
239+
// invalid session token.
240+
const message = getErrorMessage(err, "no response from the server")
241+
return {
242+
message: "Failed to authenticate: " + message,
243+
severity: vscode.InputBoxValidationSeverity.Error,
244+
}
245+
}
246+
},
247+
})
248+
249+
if (validatedToken && user) {
250+
return { token: validatedToken, user }
251+
}
252+
253+
// User aborted.
254+
return null
255+
}
256+
237257
/**
238258
* View the logs for the currently connected workspace.
239259
*/

src/extension.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import axios, { isAxiosError } from "axios"
33
import { getErrorMessage } from "coder/site/src/api/errors"
44
import * as module from "module"
55
import * as vscode from "vscode"
6-
import { makeCoderSdk } from "./api"
6+
import { makeCoderSdk, needToken } from "./api"
77
import { errToStr } from "./api-helper"
88
import { Commands } from "./commands"
99
import { CertificateError, getErrorDetail } from "./error"
@@ -92,8 +92,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
9292
}
9393

9494
// If the token is missing we will get a 401 later and the user will be
95-
// prompted to sign in again, so we do not need to ensure it is set.
96-
const token = params.get("token")
95+
// prompted to sign in again, so we do not need to ensure it is set now.
96+
// For non-token auth, we write a blank token since the `vscodessh`
97+
// command currently always requires a token file. However, if there is
98+
// a query parameter for non-token auth go ahead and use it anyway; all
99+
// that really matters is the file is created.
100+
const token = needToken() ? params.get("token") : (params.get("token") ?? "")
97101
if (token) {
98102
restClient.setSessionToken(token)
99103
await storage.setSessionToken(token)

src/storage.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -435,8 +435,8 @@ export class Storage {
435435
/**
436436
* Configure the CLI for the deployment with the provided label.
437437
*
438-
* Falsey values are a no-op; we avoid unconfiguring the CLI to avoid breaking
439-
* existing connections.
438+
* Falsey URLs and null tokens are a no-op; we avoid unconfiguring the CLI to
439+
* avoid breaking existing connections.
440440
*/
441441
public async configureCli(label: string, url: string | undefined, token: string | undefined | null) {
442442
await Promise.all([this.updateUrlForCli(label, url), this.updateTokenForCli(label, token)])
@@ -459,15 +459,15 @@ export class Storage {
459459
/**
460460
* Update the session token for a deployment with the provided label on disk
461461
* which can be used by the CLI via --session-token-file. If the token is
462-
* falsey, do nothing.
462+
* null, do nothing.
463463
*
464464
* If the label is empty, read the old deployment-unaware config instead.
465465
*/
466466
private async updateTokenForCli(label: string, token: string | undefined | null) {
467-
if (token) {
467+
if (token !== null) {
468468
const tokenPath = this.getSessionTokenPath(label)
469469
await fs.mkdir(path.dirname(tokenPath), { recursive: true })
470-
await fs.writeFile(tokenPath, token)
470+
await fs.writeFile(tokenPath, token ?? "")
471471
}
472472
}
473473

0 commit comments

Comments
 (0)