Skip to content
Merged
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
26 changes: 24 additions & 2 deletions registry/coder/modules/github-upload-public-key/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,30 @@ Templates that utilize Github External Auth can automatically ensure that the Co
module "github-upload-public-key" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/github-upload-public-key/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.main.id
key_name = "ACME Coder Workspaces"
}
```

`key_name` is optional. If omitted, GitHub displays the uploaded key as `<Coder access URL> Workspaces`.

## Commit signing

By default, the module uploads the Coder public key as a GitHub authentication key. Set `key_type` to `signing` to upload it as a GitHub SSH signing key instead, so GitHub can verify commits signed with it, or to `both` to upload it as both:

```tf
module "github-upload-public-key" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/github-upload-public-key/coder"
version = "1.2.0"
agent_id = coder_agent.main.id
key_type = "both"
}
```

This module only uploads the key. Git inside the workspace must still be configured to sign commits with the Coder SSH key.

## Requirements

This module requires `curl` and `jq` to be installed inside your workspace.
Expand All @@ -38,6 +54,12 @@ CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key"

Note that the default scopes if not provided are `repo,workflow`. If the module is failing to complete after updating the external auth configuration, instruct users of the module to "Unlink" and "Link" their Github account in the External Auth user settings page to get the new scopes.

When `key_type` is `signing` or `both`, the Github app must also have read and write permissions to "SSH signing keys", and the external auth configuration must include the `write:ssh_signing_key` scope. For example:

```txt
CODER_EXTERNAL_AUTH_0_SCOPES="repo,workflow,admin:public_key,write:ssh_signing_key"
```

## Example

Using a coder github external auth with a non-default id: (default is `github`)
Expand All @@ -50,7 +72,7 @@ data "coder_external_auth" "github" {
module "github-upload-public-key" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/github-upload-public-key/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.main.id
external_auth_id = data.coder_external_auth.github.id
}
Expand Down
197 changes: 131 additions & 66 deletions registry/coder/modules/github-upload-public-key/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,16 @@ afterEach(async () => {
}
});

type Upload = { path: string; title: string };

const AUTH_KEYS_PATH = "/user/keys";
const SIGNING_KEYS_PATH = "/user/ssh_signing_keys";

const setupContainer = async (
image = "lorello/alpine-bash",
vars: Record<string, string> = {},
) => {
const { server, uploadedKeyNames } = setupServer();
const { server, uploads } = setupServer();
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
...vars,
Expand All @@ -54,11 +59,24 @@ const setupContainer = async (
await removeContainer(id);
});

return { id, instance, server, uploadedKeyNames };
const url = server.url.toString().slice(0, -1);
const run = () =>
execContainer(id, [
"env",
`CODER_ACCESS_URL=${url}`,
`GITHUB_API_URL=${url}`,
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
"-c",
instance.script,
]);

return { id, url, run, uploads };
};

const setupServer = () => {
const uploadedKeyNames: string[] = [];
const uploads: Upload[] = [];
const server = serve({
fetch: async (req) => {
const url = new URL(req.url);
Expand All @@ -68,10 +86,13 @@ const setupServer = () => {
});
}

if (url.pathname === "/user/keys") {
if (
url.pathname === AUTH_KEYS_PATH ||
url.pathname === SIGNING_KEYS_PATH
) {
if (req.method === "POST") {
const body = (await req.json()) as { title: string };
uploadedKeyNames.push(body.title);
uploads.push({ path: url.pathname, title: body.title });
return createJSONResponse(
{
key: "created",
Expand All @@ -92,6 +113,19 @@ const setupServer = () => {
]);
}

// case: key exists on the second page
if (req.headers.get("Authorization") === "Bearer paginated") {
const perPage = Number(url.searchParams.get("per_page") ?? 30);
const page = Number(url.searchParams.get("page") ?? 1);
const keys = [
...Array.from({ length: perPage }, (_, i) => ({ key: `foo-${i}` })),
{ key: "exists" },
];
return createJSONResponse(
keys.slice((page - 1) * perPage, page * perPage),
);
}

// case: key does not exist
return createJSONResponse([
{
Expand All @@ -110,7 +144,7 @@ const setupServer = () => {
port: 0,
});

return { server, uploadedKeyNames };
return { server, uploads };
};

setDefaultTimeout(30 * 1000);
Expand All @@ -125,98 +159,129 @@ describe("github-upload-public-key", () => {
});

it("creates new key if one does not exist", async () => {
const { instance, id, server, uploadedKeyNames } = await setupContainer();
const { id, url, run, uploads } = await setupContainer();
await writeCoder(id, "echo foo");

const url = server.url.toString().slice(0, -1);
const exec = await execContainer(id, [
"env",
`CODER_ACCESS_URL=${url}`,
`GITHUB_API_URL=${url}`,
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
"-c",
instance.script,
]);
const exec = await run();
expect(exec.stdout).toContain(
"Your Coder public key has been added to GitHub!",
"Your Coder public key has been added as a GitHub authentication key!",
);
expect(exec.exitCode).toBe(0);
expect(uploadedKeyNames).toEqual([`${url} Workspaces`]);
expect(uploads).toEqual([
{ path: AUTH_KEYS_PATH, title: `${url} Workspaces` },
]);
});

it("uses a custom key name", async () => {
const keyName = "ACME Coder Workspaces";
const { instance, id, server, uploadedKeyNames } = await setupContainer(
undefined,
{ key_name: keyName },
);
const { id, run, uploads } = await setupContainer(undefined, {
key_name: keyName,
});
await writeCoder(id, "echo foo");

const url = server.url.toString().slice(0, -1);
const exec = await execContainer(id, [
"env",
`CODER_ACCESS_URL=${url}`,
`GITHUB_API_URL=${url}`,
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
"-c",
instance.script,
]);
const exec = await run();
expect(exec.stdout).toContain(
"Your Coder public key has been added to GitHub!",
"Your Coder public key has been added as a GitHub authentication key!",
);
expect(exec.exitCode).toBe(0);
expect(uploadedKeyNames).toEqual([keyName]);
expect(uploads).toEqual([{ path: AUTH_KEYS_PATH, title: keyName }]);
});

it("uses a key name with special characters", async () => {
const keyName = 'Special $(id) "chars" `test`';
const { instance, id, server, uploadedKeyNames } = await setupContainer(
undefined,
{ key_name: keyName },
);
const { id, run, uploads } = await setupContainer(undefined, {
key_name: keyName,
});
await writeCoder(id, "echo foo");

const url = server.url.toString().slice(0, -1);
const exec = await execContainer(id, [
"env",
`CODER_ACCESS_URL=${url}`,
`GITHUB_API_URL=${url}`,
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
"-c",
instance.script,
]);
const exec = await run();
expect(exec.stdout).toContain(
"Your Coder public key has been added to GitHub!",
"Your Coder public key has been added as a GitHub authentication key!",
);
expect(exec.exitCode).toBe(0);
expect(uploadedKeyNames).toEqual([keyName]);
expect(uploads).toEqual([{ path: AUTH_KEYS_PATH, title: keyName }]);
});

it("does nothing if one already exists", async () => {
const { instance, id, server } = await setupContainer();
const { id, run, uploads } = await setupContainer();
// use keyword to make server return a existing key
await writeCoder(id, "echo findkey");

const url = server.url.toString().slice(0, -1);
const exec = await execContainer(id, [
"env",
`CODER_ACCESS_URL=${url}`,
`GITHUB_API_URL=${url}`,
"CODER_OWNER_SESSION_TOKEN=foo",
"CODER_EXTERNAL_AUTH_ID=github",
"bash",
"-c",
instance.script,
const exec = await run();
expect(exec.stdout).toContain(
"Your Coder public key is already a GitHub authentication key!",
);
expect(exec.exitCode).toBe(0);
expect(uploads).toEqual([]);
});

it("uploads only a signing key when key_type is signing", async () => {
const { id, url, run, uploads } = await setupContainer(undefined, {
key_type: "signing",
});
await writeCoder(id, "echo foo");

const exec = await run();
expect(exec.stdout).toContain(
"Your Coder public key has been added as a GitHub signing key!",
);
expect(exec.exitCode).toBe(0);
expect(uploads).toEqual([
{ path: SIGNING_KEYS_PATH, title: `${url} Workspaces` },
]);
});

it("uploads authentication and signing keys when key_type is both", async () => {
const { id, url, run, uploads } = await setupContainer(undefined, {
key_type: "both",
});
await writeCoder(id, "echo foo");

const exec = await run();
expect(exec.stdout).toContain(
"Your Coder public key has been added as a GitHub authentication key!",
);
expect(exec.stdout).toContain(
"Your Coder public key has been added as a GitHub signing key!",
);
expect(exec.exitCode).toBe(0);
expect(uploads).toEqual([
{ path: AUTH_KEYS_PATH, title: `${url} Workspaces` },
{ path: SIGNING_KEYS_PATH, title: `${url} Workspaces` },
]);
});

it("finds an existing key on a later page", async () => {
const { id, run, uploads } = await setupContainer(undefined, {
key_type: "both",
});
await writeCoder(id, "echo paginated");

const exec = await run();
expect(exec.stdout).toContain(
"Your Coder public key is already a GitHub authentication key!",
);
expect(exec.stdout).toContain(
"Your Coder public key is already a GitHub signing key!",
);
expect(exec.exitCode).toBe(0);
expect(uploads).toEqual([]);
});

it("skips existing keys when key_type is both", async () => {
const { id, run, uploads } = await setupContainer(undefined, {
key_type: "both",
});
await writeCoder(id, "echo findkey");

const exec = await run();
expect(exec.stdout).toContain(
"Your Coder public key is already a GitHub authentication key!",
);
expect(exec.stdout).toContain(
"Your Coder public key is already on GitHub!",
"Your Coder public key is already a GitHub signing key!",
);
expect(exec.exitCode).toBe(0);
expect(uploads).toEqual([]);
});
});
11 changes: 11 additions & 0 deletions registry/coder/modules/github-upload-public-key/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ variable "key_name" {
default = null
}

variable "key_type" {
description = "Where to upload the Coder public key on GitHub: 'auth' for an authentication key, 'signing' for a commit signing key, or 'both'."
type = string
default = "auth"
validation {
condition = contains(["auth", "signing", "both"], var.key_type)
error_message = "key_type must be one of 'auth', 'signing', or 'both'."
}
}

data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}

Expand All @@ -43,6 +53,7 @@ resource "coder_script" "github_upload_public_key" {
CODER_EXTERNAL_AUTH_ID : var.external_auth_id,
GITHUB_API_URL : var.github_api_url,
CODER_PUBLIC_KEY_NAME : base64encode(var.key_name == null ? "" : var.key_name),
KEY_TYPE : var.key_type,
})
display_name = "Github Upload Public Key"
icon = "/icon/github.svg"
Expand Down
Loading
Loading