Skip to content

Commit

Permalink
Read auth information from env (#279)
Browse files Browse the repository at this point in the history
* Read auth information from env

* review

* rename auth.ts

* fix imports
  • Loading branch information
mythmon authored Dec 1, 2023
1 parent 940d4b7 commit 5ce3da8
Show file tree
Hide file tree
Showing 8 changed files with 97 additions and 51 deletions.
4 changes: 2 additions & 2 deletions bin/observable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,10 @@ switch (command) {
break;
}
case "login":
await import("../src/auth.js").then((auth) => auth.login());
await import("../src/observableApiAuth.js").then((auth) => auth.login());
break;
case "whoami":
await import("../src/auth.js").then((auth) => auth.whoami());
await import("../src/observableApiAuth.js").then((auth) => auth.whoami());
break;
default:
console.error("Usage: observable <command>");
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"test:coverage": "c8 yarn test:mocha",
"test:mocha": "rm -rf test/.observablehq/cache test/input/build/*/.observablehq/cache && tsx --no-warnings=ExperimentalWarning ./node_modules/.bin/mocha 'test/**/*-test.*'",
"test:lint": "eslint src test",
"test:tsc": "tsc --noEmit"
"test:tsc": "tsc --noEmit",
"observable": "tsx ./bin/observable.ts"
},
"c8": {
"all": true,
Expand Down
23 changes: 13 additions & 10 deletions src/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import readline from "node:readline/promises";
import {commandRequiresAuthenticationMessage} from "./auth.js";
import type {BuildEffects} from "./build.js";
import {build} from "./build.js";
import type {Logger, Writer} from "./logger.js";
import {ObservableApiClient, getObservableUiHost} from "./observableApiClient.js";
import type {DeployConfig} from "./observableApiConfig.js";
import {getDeployConfig, getObservableApiKey, setDeployConfig} from "./observableApiConfig.js";
import {
type ApiKey,
type DeployConfig,
getDeployConfig,
getObservableApiKey,
setDeployConfig
} from "./observableApiConfig.js";

export interface DeployOptions {
sourceRoot: string;
}

export interface DeployEffects {
getObservableApiKey: () => Promise<string | null>;
getObservableApiKey: (logger: Logger) => Promise<ApiKey>;
getDeployConfig: (sourceRoot: string) => Promise<DeployConfig | null>;
setDeployConfig: (sourceRoot: string, config: DeployConfig) => Promise<void>;
logger: Logger;
Expand All @@ -31,13 +35,12 @@ const defaultEffects: DeployEffects = {

/** Deploy a project to ObservableHQ */
export async function deploy({sourceRoot}: DeployOptions, effects = defaultEffects): Promise<void> {
const apiKey = await effects.getObservableApiKey();
const {logger} = effects;
if (!apiKey) {
logger.log(commandRequiresAuthenticationMessage);
return;
}
const apiClient = new ObservableApiClient({apiKey, logger});
const apiKey = await effects.getObservableApiKey(logger);
const apiClient = new ObservableApiClient({
apiKey,
logger
});

// Find the existing project or create a new one.
const deployConfig = await effects.getDeployConfig(sourceRoot);
Expand Down
49 changes: 26 additions & 23 deletions src/auth.ts → src/observableApiAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import open from "open";
import {HttpError, isHttpError} from "./error.js";
import type {Logger} from "./logger.js";
import {ObservableApiClient, getObservableUiHost} from "./observableApiClient.js";
import {getObservableApiKey, setObservableApiKey} from "./observableApiConfig.js";
import {type ApiKey, getObservableApiKey, setObservableApiKey} from "./observableApiConfig.js";

const OBSERVABLEHQ_UI_HOST = getObservableUiHost();

Expand All @@ -22,7 +22,7 @@ export interface CommandEffects {
logger: Logger;
isatty: (fd: number) => boolean;
waitForEnter: () => Promise<void>;
getObservableApiKey: () => Promise<string | null>;
getObservableApiKey: (logger: Logger) => Promise<ApiKey>;
setObservableApiKey: (id: string, key: string) => Promise<void>;
exitSuccess: () => void;
}
Expand Down Expand Up @@ -67,33 +67,36 @@ export async function login(effects = defaultEffects) {
}

export async function whoami(effects = defaultEffects) {
const apiKey = await effects.getObservableApiKey();
const {logger} = effects;
if (apiKey) {
const apiClient = new ObservableApiClient({
apiKey,
logger
});
const apiKey = await effects.getObservableApiKey(logger);
const apiClient = new ObservableApiClient({
apiKey,
logger
});

try {
const user = await apiClient.getCurrentUser();
logger.log();
logger.log(`You are logged into ${OBSERVABLEHQ_UI_HOST.hostname} as ${formatUser(user)}.`);
logger.log();
logger.log("You have access to the following workspaces:");
for (const workspace of user.workspaces) {
logger.log(` * ${formatUser(workspace)}`);
}
logger.log();
} catch (error) {
if (isHttpError(error) && error.statusCode == 401) {
try {
const user = await apiClient.getCurrentUser();
logger.log();
logger.log(`You are logged into ${OBSERVABLEHQ_UI_HOST.hostname} as ${formatUser(user)}.`);
logger.log();
logger.log("You have access to the following workspaces:");
for (const workspace of user.workspaces) {
logger.log(` * ${formatUser(workspace)}`);
}
logger.log();
} catch (error) {
console.log(error);
if (isHttpError(error) && error.statusCode == 401) {
if (apiKey.source === "env") {
logger.log(`Your API key is invalid. Check the value of the ${apiKey.envVar} environment variable.`);
} else if (apiKey.source === "file") {
logger.log("Your API key is invalid. Run `observable login` to log in again.");
} else {
throw error;
logger.log("Your API key is invalid.");
}
} else {
throw error;
}
} else {
logger.log(commandRequiresAuthenticationMessage);
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/observableApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import packageJson from "../package.json";
import {HttpError} from "./error.js";
import type {Logger} from "./logger.js";
import type {ApiKey} from "./observableApiConfig.js";

export interface GetCurrentUserResponse {
id: string;
Expand Down Expand Up @@ -58,14 +59,14 @@ export class ObservableApiClient {
logger = console
}: {
apiHost?: URL;
apiKey: string;
apiKey: ApiKey;
logger: Logger;
}) {
this._apiHost = apiHost;
this._logger = logger;
this._apiHeaders = [
["Accept", "application/json"],
["Authorization", `apikey ${apiKey}`],
["Authorization", `apikey ${apiKey.key}`],
["User-Agent", `Observable CLI ${packageJson.version}`],
["X-Observable-Api-Version", "2023-11-06"]
];
Expand Down
21 changes: 18 additions & 3 deletions src/observableApiConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {isEnoent} from "./error.js";
import type {Logger} from "./logger.js";
import {commandRequiresAuthenticationMessage} from "./observableApiAuth.js";

const userConfigName = ".observablehq";
interface UserConfig {
Expand All @@ -19,9 +21,22 @@ export interface DeployConfig {
};
}

export async function getObservableApiKey(): Promise<string | null> {
const {config} = await loadUserConfig();
return config.auth?.key ?? null;
export type ApiKey =
| {source: "file"; filePath: string; key: string}
| {source: "env"; envVar: string; key: string}
| {source: "test"; key: string};

export async function getObservableApiKey(logger: Logger = console): Promise<ApiKey> {
const envVar = "OBSERVABLEHQ_TOKEN";
if (process.env[envVar]) {
return {source: "env", envVar, key: process.env[envVar]};
}
const {config, configPath} = await loadUserConfig();
if (config.auth?.key) {
return {source: "file", filePath: configPath, key: config.auth.key};
}
logger.log(commandRequiresAuthenticationMessage);
process.exit(1);
}

export async function setObservableApiKey(id: string, key: string): Promise<void> {
Expand Down
20 changes: 16 additions & 4 deletions test/deploy-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {Readable, Writable} from "node:stream";
import type {DeployEffects} from "../src/deploy.js";
import {deploy} from "../src/deploy.js";
import {isHttpError} from "../src/error.js";
import type {Logger} from "../src/logger.js";
import {commandRequiresAuthenticationMessage} from "../src/observableApiAuth.js";
import type {DeployConfig} from "../src/observableApiConfig.js";
import {MockLogger} from "./mocks/logger.js";
import {
Expand Down Expand Up @@ -48,8 +50,12 @@ class MockDeployEffects implements DeployEffects {
});
}

async getObservableApiKey() {
return this._observableApiKey;
async getObservableApiKey(logger: Logger) {
if (!this._observableApiKey) {
logger.log(commandRequiresAuthenticationMessage);
throw new Error("no key available in this test");
}
return {source: "test" as const, key: this._observableApiKey};
}

async getDeployConfig() {
Expand Down Expand Up @@ -104,10 +110,16 @@ describe("deploy", () => {
const apiMock = new ObservableApiMock().start();
const effects = new MockDeployEffects({apiKey: null});

await deploy({sourceRoot: TEST_SOURCE_ROOT}, effects);
try {
await deploy({sourceRoot: TEST_SOURCE_ROOT}, effects);
assert.fail("expected error");
} catch (err) {
if (!(err instanceof Error)) throw err;
assert.equal(err.message, "no key available in this test");
effects.logger.assertExactLogs([/^You need to be authenticated/]);
}

apiMock.close();
effects.logger.assertExactLogs([/^You need to be authenticated/]);
});

it("handles multiple user workspaces", async () => {
Expand Down
23 changes: 17 additions & 6 deletions test/auth-test.ts → test/observableApiAuth-test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from "node:assert";
import {type CommandEffects, login, whoami} from "../src/auth.js";
import type {Logger} from "../src/logger.js";
import {type CommandEffects, commandRequiresAuthenticationMessage, login, whoami} from "../src/observableApiAuth.js";
import {MockLogger} from "./mocks/logger.js";
import {ObservableApiMock} from "./mocks/observableApi.js";

Expand Down Expand Up @@ -43,10 +44,16 @@ describe("login command", () => {
});

describe("whoami command", () => {
it("works when there is no API key", async () => {
it("errors when there is no API key", async () => {
const effects = new MockEffects({apiKey: null});
await whoami(effects);
effects.logger.assertExactLogs([/^You need to be authenticated/]);
try {
await whoami(effects);
assert.fail("error expected");
} catch (err) {
if (!(err instanceof Error)) throw err;
assert.equal(err.message, "no key available in this test");
effects.logger.assertExactLogs([/^You need to be authenticated/]);
}
});

it("works when there is an API key that is invalid", async () => {
Expand Down Expand Up @@ -94,8 +101,12 @@ class MockEffects implements CommandEffects {
this._observableApiKey = apiKey;
}

getObservableApiKey() {
return Promise.resolve(this._observableApiKey);
getObservableApiKey(logger: Logger) {
if (!this._observableApiKey) {
logger.log(commandRequiresAuthenticationMessage);
throw new Error("no key available in this test");
}
return Promise.resolve({source: "test" as const, key: this._observableApiKey});
}
isatty() {
return true;
Expand Down

0 comments on commit 5ce3da8

Please sign in to comment.