From 420376e368acb4c20d5e568100190a2eb576cbc7 Mon Sep 17 00:00:00 2001 From: Arnau Orriols <4871949+arnauorriols@users.noreply.github.com> Date: Sat, 11 Nov 2023 01:45:55 +0100 Subject: [PATCH] feat: Automatic token provisioning on deploy (#178) --- .github/workflows/ci.yml | 2 +- deno.lock | 14 +++- src/subcommands/deploy.ts | 15 ++-- src/subcommands/logs.ts | 11 ++- src/utils/access_token.ts | 119 ++++++++++++++++++++++++++++++ src/utils/api.ts | 50 +++++++++++-- src/utils/spinner.ts | 26 +++++++ src/utils/token_storage.ts | 64 ++++++++++++++++ src/utils/token_storage/darwin.ts | 49 ++++++++++++ src/utils/token_storage/memory.ts | 15 ++++ 10 files changed, 342 insertions(+), 23 deletions(-) create mode 100644 src/utils/access_token.ts create mode 100644 src/utils/spinner.ts create mode 100644 src/utils/token_storage.ts create mode 100644 src/utils/token_storage/darwin.ts create mode 100644 src/utils/token_storage/memory.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20bccbbd..01c18f59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/deno.lock b/deno.lock index 57765859..dbbd1fe4 100644 --- a/deno.lock +++ b/deno.lock @@ -1,12 +1,22 @@ { - "version": "2", + "version": "3", + "packages": { + "specifiers": { + "npm:keychain@1.5.0": "npm:keychain@1.5.0" + }, + "npm": { + "keychain@1.5.0": { + "integrity": "sha512-liyp4r+93RI7EB2jhwaRd4MWfdgHH6shuldkaPMkELCJjMFvOOVXuTvw1pGqFfhsrgA6OqfykWWPQgBjQakVag==", + "dependencies": {} + } + } + }, "remote": { "https://deno.land/std@0.116.0/fmt/colors.ts": "8368ddf2d48dfe413ffd04cdbb7ae6a1009cf0dccc9c7ff1d76259d9c61a0621", "https://deno.land/std@0.116.0/testing/_diff.ts": "e6a10d2aca8d6c27a9c5b8a2dbbf64353874730af539707b5b39d4128140642d", "https://deno.land/std@0.116.0/testing/asserts.ts": "a1fef0239a2c343b0baa49c77dcdd7412613c46f3aba2887c331a2d7ed1f645e", "https://deno.land/std@0.170.0/_util/asserts.ts": "d0844e9b62510f89ce1f9878b046f6a57bf88f208a10304aab50efcb48365272", "https://deno.land/std@0.170.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934", - "https://deno.land/std@0.170.0/flags/mod.ts": "4f50ec6383c02684db35de38b3ffb2cd5b9fcfcc0b1147055d1980c49e82521c", "https://deno.land/std@0.170.0/fmt/colors.ts": "03ad95e543d2808bc43c17a3dd29d25b43d0f16287fe562a0be89bf632454a12", "https://deno.land/std@0.170.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", "https://deno.land/std@0.170.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", diff --git a/src/subcommands/deploy.ts b/src/subcommands/deploy.ts index ae2d83c9..0e77a062 100644 --- a/src/subcommands/deploy.ts +++ b/src/subcommands/deploy.ts @@ -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. @@ -70,10 +72,6 @@ export default async function (rawArgs: Record): Promise { 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."); @@ -112,7 +110,7 @@ interface DeployOpts { prod: boolean; exclude?: string[]; include?: string[]; - token: string; + token: string | null; project: string; dryRun: boolean; } @@ -122,7 +120,10 @@ async function deploy(opts: DeployOpts): Promise { 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."); diff --git a/src/subcommands/logs.ts b/src/subcommands/logs.ts index 7fa1b9c7..efadf72a 100644 --- a/src/subcommands/logs.ts +++ b/src/subcommands/logs.ts @@ -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 @@ -85,10 +86,6 @@ export default async function (args: Args): Promise { } 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."); @@ -111,7 +108,9 @@ export default async function (args: Args): Promise { 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( diff --git a/src/utils/access_token.ts b/src/utils/access_token.ts new file mode 100644 index 00000000..fe495ed2 --- /dev/null +++ b/src/utils/access_token.ts @@ -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 | null, + + revoke: tokenStorage.remove, +}; + +async function provision(): Promise { + 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 { + return new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(random_string), + ), + ); +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 843f7fa7..ee468aef 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -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; + /** + * Provision a new access token for DeployCTL + */ + provision(): Promise; + /** + * Delete the token from cache, forcing a new provision in the next request + */ + revoke(): Promise; +} + 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 { @@ -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(path: string, opts?: RequestOptions): Promise { diff --git a/src/utils/spinner.ts b/src/utils/spinner.ts new file mode 100644 index 00000000..de9341c6 --- /dev/null +++ b/src/utils/spinner.ts @@ -0,0 +1,26 @@ +import { Spinner, wait as innerWait } from "../../deps.ts"; + +let current: Spinner | null = null; + +export function wait(...params: Parameters) { + 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(); + } +} diff --git a/src/utils/token_storage.ts b/src/utils/token_storage.ts new file mode 100644 index 00000000..4e2d0195 --- /dev/null +++ b/src/utils/token_storage.ts @@ -0,0 +1,64 @@ +import { interruptSpinner, wait } from "./spinner.ts"; + +interface TokenStorage { + get: () => Promise; + store: (token: string) => Promise; + remove: () => Promise; +} + +let defaultMode = false; + +let module: TokenStorage; +if (Deno.build.os === "darwin") { + const darwin = await import("./token_storage/darwin.ts"); + const memory = await import("./token_storage/memory.ts"); + module = { + get: defaultOnError( + "Failed to get token from Keychain", + memory.get, + darwin.getFromKeychain, + ), + store: defaultOnError( + "Failed to store token into Keychain", + memory.store, + darwin.storeInKeyChain, + ), + remove: defaultOnError( + "Failed to remove token from Keychain", + memory.remove, + darwin.removeFromKeyChain, + ), + }; +} else { + module = await import("./token_storage/memory.ts"); +} +export default module; + +function defaultOnError< + // deno-lint-ignore no-explicit-any + F extends (...args: any) => Promise, +>( + notification: string, + defaultFn: (...params: Parameters) => ReturnType, + fn: (...params: Parameters) => ReturnType, +): (...params: Parameters) => ReturnType { + return (...params) => { + if (defaultMode) { + return defaultFn(...params); + } else { + return fn(...params) + .catch((err) => { + const spinnerInterrupt = interruptSpinner(); + wait("").start().warn(notification); + let errStr = err.toString(); + if (errStr.length > 80) { + errStr = errStr.slice(0, 80) + "..."; + } + wait({ text: "", indent: 3 }).start().fail(errStr); + spinnerInterrupt.resume(); + defaultMode = true; + return defaultFn(...params); + }) as ReturnType; + } + }; +} diff --git a/src/utils/token_storage/darwin.ts b/src/utils/token_storage/darwin.ts new file mode 100644 index 00000000..96488d2b --- /dev/null +++ b/src/utils/token_storage/darwin.ts @@ -0,0 +1,49 @@ +import keychain from "npm:keychain@1.5.0"; + +const KEYCHAIN_CREDS = { account: "Deno Deploy", service: "DeployCTL" }; + +export function getFromKeychain(): Promise { + return new Promise((resolve, reject) => + keychain.getPassword( + KEYCHAIN_CREDS, + (err: KeychainError, token: string) => { + if (err && err.code !== "PasswordNotFound") { + reject(err); + } else { + resolve(token); + } + }, + ) + ); +} + +export function storeInKeyChain(token: string): Promise { + return new Promise((resolve, reject) => + keychain.setPassword( + { ...KEYCHAIN_CREDS, password: token }, + (err: KeychainError) => { + if (err) { + reject(err); + } else { + resolve(); + } + }, + ) + ); +} + +export function removeFromKeyChain(): Promise { + return new Promise((resolve, reject) => + keychain.deletePassword(KEYCHAIN_CREDS, (err: KeychainError) => { + if (err && err.code !== "PasswordNotFound") { + reject(err); + } else { + resolve(); + } + }) + ); +} + +interface KeychainError { + code: string; +} diff --git a/src/utils/token_storage/memory.ts b/src/utils/token_storage/memory.ts new file mode 100644 index 00000000..fa5c6821 --- /dev/null +++ b/src/utils/token_storage/memory.ts @@ -0,0 +1,15 @@ +let TOKEN: string | null; + +export function get(): Promise { + return Promise.resolve(TOKEN); +} + +export function store(token: string): Promise { + TOKEN = token; + return Promise.resolve(); +} + +export function remove(): Promise { + TOKEN = null; + return Promise.resolve(); +}