Skip to content

Commit

Permalink
feat: Automatic token provisioning on deploy (#178)
Browse files Browse the repository at this point in the history
  • Loading branch information
arnauorriols authored Nov 11, 2023
1 parent b841621 commit 420376e
Show file tree
Hide file tree
Showing 10 changed files with 342 additions and 23 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- name: Setup Deno
uses: denoland/setup-deno@v1
with:
deno-version: ${{ matrix.deno == 'old' && '1.28.3' || (matrix.deno == 'stable' && '1.x' || matrix.deno) }}
deno-version: ${{ matrix.deno == 'old' && '1.37.2' || (matrix.deno == 'stable' && '1.x' || matrix.deno) }}

- run: deno --version

Expand Down
14 changes: 12 additions & 2 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 8 additions & 7 deletions src/subcommands/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// Copyright 2021 Deno Land Inc. All rights reserved. MIT license.

import { fromFileUrl, normalize, Spinner, wait } from "../../deps.ts";
import { fromFileUrl, normalize, Spinner } from "../../deps.ts";
import { wait } from "../utils/spinner.ts";
import { error } from "../error.ts";
import { API, APIError } from "../utils/api.ts";
import { ManifestEntry } from "../utils/api_types.ts";
import { parseEntrypoint } from "../utils/entrypoint.ts";
import { walk } from "../utils/walk.ts";
import TokenProvisioner from "../utils/access_token.ts";

const help = `deployctl deploy
Deploy a script with static files to Deno Deploy.
Expand Down Expand Up @@ -70,10 +72,6 @@ export default async function (rawArgs: Record<string, any>): Promise<void> {
Deno.exit(0);
}
const token = args.token ?? Deno.env.get("DENO_DEPLOY_TOKEN") ?? null;
if (token === null) {
console.error(help);
error("Missing access token. Set via --token or DENO_DEPLOY_TOKEN.");
}
if (entrypoint === null) {
console.error(help);
error("No entrypoint specifier given.");
Expand Down Expand Up @@ -112,7 +110,7 @@ interface DeployOpts {
prod: boolean;
exclude?: string[];
include?: string[];
token: string;
token: string | null;
project: string;
dryRun: boolean;
}
Expand All @@ -122,7 +120,10 @@ async function deploy(opts: DeployOpts): Promise<void> {
wait("").start().info("Performing dry run of deployment");
}
const projectSpinner = wait("Fetching project information...").start();
const api = API.fromToken(opts.token);
const api = opts.token
? API.fromToken(opts.token)
: API.withTokenProvisioner(TokenProvisioner);

const project = await api.getProject(opts.project);
if (project === null) {
projectSpinner.fail("Project not found.");
Expand Down
11 changes: 5 additions & 6 deletions src/subcommands/logs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Copyright 2021 Deno Land Inc. All rights reserved. MIT license.

import type { Args } from "../args.ts";
import { wait } from "../../deps.ts";
import { wait } from "../utils/spinner.ts";
import { error } from "../error.ts";
import { API, APIError } from "../utils/api.ts";
import type { Project } from "../utils/api_types.ts";
import TokenProvisioner from "../utils/access_token.ts";

const help = `deployctl logs
View logs for the given project. It supports both live logs where the logs are streamed to the console as they are
Expand Down Expand Up @@ -85,10 +86,6 @@ export default async function (args: Args): Promise<void> {
}
const token = logSubcommandArgs.token ?? Deno.env.get("DENO_DEPLOY_TOKEN") ??
null;
if (token === null) {
console.error(help);
error("Missing access token. Set via --token or DENO_DEPLOY_TOKEN.");
}
if (logSubcommandArgs.project === null) {
console.error(help);
error("Missing project ID.");
Expand All @@ -111,7 +108,9 @@ export default async function (args: Args): Promise<void> {
error("--since must be earlier than --until");
}

const api = API.fromToken(token);
const api = token
? API.fromToken(token)
: API.withTokenProvisioner(TokenProvisioner);
const { regionCodes } = await api.getMetadata();
if (logSubcommandArgs.regions !== null) {
const invalidRegions = getInvalidRegions(
Expand Down
119 changes: 119 additions & 0 deletions src/utils/access_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { interruptSpinner, wait } from "./spinner.ts";
import { error } from "../error.ts";
import { endpoint } from "./api.ts";
import tokenStorage from "./token_storage.ts";

export default {
get: tokenStorage.get,

async provision() {
// Synchronize provision routine
// to prevent multiple authorization flows from triggering concurrently
this.provisionPromise ??= provision();
const token = await this.provisionPromise;
this.provisionPromise = null;
return token;
},
provisionPromise: null as Promise<string> | null,

revoke: tokenStorage.remove,
};

async function provision(): Promise<string> {
const spinnerInterrupted = interruptSpinner();
wait("").start().info("Provisioning a new access token...");
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const claimVerifier = base64url(randomBytes);
const claimChallenge = base64url(await sha256(claimVerifier));

const tokenStream = await fetch(
`${endpoint()}/api/signin/cli/access_token`,
{ method: "POST", body: claimVerifier },
);
if (!tokenStream.ok) {
error(
`when requesting an access token: ${await tokenStream.statusText}`,
);
}
const url = `${endpoint()}/signin/cli?claim_challenge=${claimChallenge}`;

wait("").start().info(`Authorization URL: ${url}`);
let openCmd;
// TODO(arnauorriols): use npm:open or deno.land/x/open when either is compatible
switch (Deno.build.os) {
case "darwin": {
openCmd = "open";
break;
}
case "linux": {
openCmd = "xdg-open";
break;
}
case "windows": {
openCmd = "start";
break;
}
}
const open = openCmd !== undefined
? new Deno.Command(openCmd, {
args: [url],
stderr: "piped",
stdout: "piped",
})
.spawn()
: undefined;

if (open == undefined) {
const warn =
"Cannot open the authorization URL automatically. Please navigate to it manually using your usual browser";
wait("").start().info(warn);
} else if (!(await open.status).success) {
const warn =
"Failed to open the authorization URL in your default browser. Please navigate to it manually";
wait("").start().warn(warn);
if (open !== undefined) {
let error = new TextDecoder().decode((await open.output()).stderr);
const errIndent = 2;
const elipsis = "...";
const maxErrLength = warn.length - errIndent;
if (error.length > maxErrLength) {
error = error.slice(0, maxErrLength - elipsis.length) + elipsis;
}
// resulting indentation is 1 less than configured
wait({ text: "", indent: errIndent + 1 }).start().fail(error);
}
}

const spinner = wait("Waiting for authorization...").start();

const tokenOrError = await tokenStream.json();

if (tokenOrError.error) {
error(`could not provision the access token: ${tokenOrError.error}`);
}

await tokenStorage.store(tokenOrError.token);
spinner.succeed("Token obtained successfully");
spinnerInterrupted.resume();
return tokenOrError.token;
}

function base64url(binary: Uint8Array): string {
const binaryString = Array.from(binary).map((b) => String.fromCharCode(b))
.join("");
const output = btoa(binaryString);
const urlSafeOutput = output
.replaceAll("=", "")
.replaceAll("+", "-")
.replaceAll("/", "_");
return urlSafeOutput;
}

async function sha256(random_string: string): Promise<Uint8Array> {
return new Uint8Array(
await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(random_string),
),
);
}
50 changes: 43 additions & 7 deletions src/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,44 @@ export class APIError extends Error {
}
}

export function endpoint() {
return Deno.env.get("DEPLOY_API_ENDPOINT") ?? "https://dash.deno.com";
}

interface TokenProvisioner {
/**
* Get the access token from a secure local storage or any other cache form.
* If there isn't any token cached, returns `null`.
*/
get(): Promise<string | null>;
/**
* Provision a new access token for DeployCTL
*/
provision(): Promise<string>;
/**
* Delete the token from cache, forcing a new provision in the next request
*/
revoke(): Promise<void>;
}

export class API {
#endpoint: string;
#authorization: string;
#authorization: string | TokenProvisioner;

constructor(authorization: string, endpoint: string) {
constructor(
authorization: string | TokenProvisioner,
endpoint: string,
) {
this.#authorization = authorization;
this.#endpoint = endpoint;
}

static fromToken(token: string) {
const endpoint = Deno.env.get("DEPLOY_API_ENDPOINT") ??
"https://dash.deno.com";
return new API(`Bearer ${token}`, endpoint);
return new API(`Bearer ${token}`, endpoint());
}

static withTokenProvisioner(provisioner: TokenProvisioner) {
return new API(provisioner, endpoint());
}

async #request(path: string, opts: RequestOptions = {}): Promise<Response> {
Expand All @@ -63,16 +88,27 @@ export class API {
const body = opts.body !== undefined
? opts.body instanceof FormData ? opts.body : JSON.stringify(opts.body)
: undefined;
const authorization = typeof this.#authorization === "string"
? this.#authorization
: `Bearer ${
await this.#authorization.get() ?? await this.#authorization.provision()
}`;
const headers = {
"Accept": "application/json",
"Authorization": this.#authorization,
"Authorization": authorization,
...(opts.body !== undefined
? opts.body instanceof FormData
? {}
: { "Content-Type": "application/json" }
: {}),
};
return await fetch(url, { method, headers, body });
let res = await fetch(url, { method, headers, body });
if (res.status === 401 && typeof this.#authorization === "object") {
// Token expired or revoked. Provision again and retry
headers.Authorization = `Bearer ${await this.#authorization.provision()}`;
res = await fetch(url, { method, headers, body });
}
return res;
}

async #requestJson<T>(path: string, opts?: RequestOptions): Promise<T> {
Expand Down
26 changes: 26 additions & 0 deletions src/utils/spinner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Spinner, wait as innerWait } from "../../deps.ts";

let current: Spinner | null = null;

export function wait(...params: Parameters<typeof innerWait>) {
current = innerWait(...params);
return current;
}

export function interruptSpinner(): Interrupt {
current?.stop();
const interrupt = new Interrupt(current);
current = null;
return interrupt;
}

export class Interrupt {
#spinner: Spinner | null;
constructor(spinner: Spinner | null) {
this.#spinner = spinner;
}
resume() {
current = this.#spinner;
this.#spinner?.start();
}
}
Loading

0 comments on commit 420376e

Please sign in to comment.