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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ It's recommended to install the CLI as a development dependency to ensure your w

## Authentication

The first time you run the CLI, you’ll be asked to provide an API key. You can generate an API key from your [developer integrations settings](https://app.dittowords.com/developers/api-keys).
Run `ditto login` to log in through your browser. Your session is saved to `~/.config/ditto`; `ditto logout` forgets it.

In CI, or anywhere a browser isn’t available, set the `DITTO_TOKEN` environment variable to an API key instead. `DITTO_TOKEN` takes precedence over a saved session. You can generate an API key from your [developer integrations settings](https://app.dittowords.com/developers/api-keys).

If you have neither, the CLI will ask you for an API key the first time you run it.

See the [Authentication](http://developer.dittowords.com/api-reference/authentication) page for more information on API keys.

Expand Down
69 changes: 69 additions & 0 deletions lib/src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import open from "open";

import getAuth0Config from "../services/auth/auth0Config";
import { logInThroughBrowser } from "../services/auth/loopbackFlow";
import { bearerHeader, currentHostname } from "../services/auth/session";
import verifyOAuthAccess from "../services/auth/verifyAccess";
import * as configService from "../services/globalConfig";
import appContext from "../utils/appContext";
import logger from "../utils/logger";
import { quit } from "../utils/quit";

/** Logs in through the browser and saves the session. */
export const login = async () => {
const config = getAuth0Config();

logger.writeLine(`Logging in to Ditto at ${logger.info(appContext.apiHost)}`);

// DITTO_TOKEN outranks a saved session, so this login would otherwise look like
// it took effect and change nothing.
if (process.env.DITTO_TOKEN) {
logger.writeLine(
logger.warnText(
"DITTO_TOKEN is set, so commands will keep using that API key instead of this login."
)
);
}

const session = await logInThroughBrowser(config, async (url) => {
try {
await open(url);
} catch {
// Only worth the terminal space when there's no browser to open it: the
// authorize URL is long enough to wrap several lines.
logger.writeLine(
logger.subtle(
"\nWe couldn't open your browser. Approve the login here:"
)
);
logger.writeLine(logger.url(url));
}

// Nothing prints again until the redirect lands, so say what's happening.
logger.writeLine(
logger.subtle("\nWaiting for you to approve this login in your browser.")
);
});

const header = bearerHeader(session.accessToken);

// Verify before storing, so an audience or tenant mismatch surfaces here rather
// than on the next command.
const failure = await verifyOAuthAccess(header);
if (failure) {
return await quit(failure.join("\n"));
}

configService.saveOAuthSession(
appContext.configFile,
currentHostname(),
session
);
appContext.setAuthToken(header);

logger.writeLine(
logger.success(
`\nYou're logged in. We saved your session to ${appContext.configFile}\n`
)
);
};
40 changes: 40 additions & 0 deletions lib/src/commands/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import getAuth0Config from "../services/auth/auth0Config";
import { revokeRefreshToken } from "../services/auth/loopbackFlow";
import { currentHostname } from "../services/auth/session";
import * as configService from "../services/globalConfig";
import appContext from "../utils/appContext";
import logger from "../utils/logger";

/** Forgets the local session and revokes its refresh token at Auth0. */
export const logout = async () => {
const hostname = currentHostname();
const session = configService.readCredential(
appContext.configFile,
hostname
)?.oauth;

if (!session) {
logger.writeLine(
`You're not logged in to Ditto at ${logger.info(appContext.apiHost)}`
);
return;
}

// Before clearing, so a failure here can't leave a live token with nothing left
// on disk to revoke it with.
if (session.refreshToken) {
await revokeRefreshToken(getAuth0Config(), session.refreshToken);
}

configService.clearCredential(appContext.configFile, hostname);
logger.writeLine(logger.success("You're logged out."));

// Otherwise "You're logged out" is a lie — the env token still authenticates.
if (process.env.DITTO_TOKEN) {
logger.writeLine(
logger.warnText(
"DITTO_TOKEN is still set, so commands will keep using that API key."
)
);
}
};
2 changes: 1 addition & 1 deletion lib/src/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export const scan = async (
logExtractSummary(extractSummary, candidatesPath);
} else {
const token = await initAPIToken();
appContext.setApiToken(token);
appContext.setAuthToken(token);
const {
candidatesSignedS3Url,
record: { _id: recordId },
Expand Down
4 changes: 2 additions & 2 deletions lib/src/http/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe("defaultInterceptor", () => {
beforeEach(() => {
appContext.apiHost = HOST;
appContext.setClientId(CLIENT_ID);
appContext.setApiToken(API_TOKEN);
appContext.setAuthToken(API_TOKEN);
});

it("sets baseURL to appContext.apiHost", () => {
Expand All @@ -32,7 +32,7 @@ describe("defaultInterceptor", () => {
expect(result.headers["x-ditto-client-id"]).toBe(CLIENT_ID);
});

it("sets Authorization header to appContext.apiToken when no token is provided", () => {
it("sets Authorization header to appContext.authToken when no token is provided", () => {
const interceptor = defaultInterceptor();
const result = interceptor(INTERCEPTOR_CONFIG);

Expand Down
2 changes: 1 addition & 1 deletion lib/src/http/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function defaultInterceptor({ token, meta }: InterceptorParams = {}) {
config.headers["x-ditto-client-id"] = appContext.clientId;
config.headers["x-ditto-app"] =
meta?.githubActionRequest === "true" ? "github_action" : "cli";
config.headers.Authorization = token || appContext.apiToken;
config.headers.Authorization = token || appContext.authToken;
return config;
};
}
Expand Down
28 changes: 27 additions & 1 deletion lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// This is the main entry point for the ditto-cli command.
import * as Sentry from "@sentry/node";
import { program } from "commander";
import { login } from "./commands/login";
import { logout } from "./commands/logout";
import { pull } from "./commands/pull";
import { scan } from "./commands/scan";
import { quit } from "./utils/quit";
Expand Down Expand Up @@ -51,6 +53,30 @@ const handleCommandError = async (error: any) => {
const appEntry = async () => {
program.name("ditto-cli");

// ditto login
program
.command("login")
.description("Log in to Ditto in your browser")
.action(async () => {
try {
return await login();
} catch (error) {
handleCommandError(error);
}
});

// ditto logout
program
.command("logout")
.description("Log out of Ditto on this machine")
.action(async () => {
try {
return await logout();
} catch (error) {
handleCommandError(error);
}
});

// ditto pull
program
.command("pull")
Expand All @@ -67,7 +93,7 @@ const appEntry = async () => {
.action(async (opts: { config?: string; meta?: string[] }) => {
try {
const token = await initAPIToken();
appContext.setApiToken(token);
appContext.setAuthToken(token);
await initProjectConfig(opts);
return await pull(processCommandMetaFlag(opts.meta ?? null));
} catch (error) {
Expand Down
20 changes: 10 additions & 10 deletions lib/src/services/apiToken/collectAndSaveToken.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ describe("collectAndSaveToken", () => {
const sanitizedHost = "hostname";

beforeEach(() => {
priorToken = appContext.apiToken;
priorToken = appContext.authToken;
priorHost = appContext.apiHost;
appContext.setApiToken("");
appContext.setAuthToken("");
appContext.apiHost = apiHost;
collectTokenSpy = jest.spyOn(CollectToken, "default");
getURLHostnameSpy = jest
Expand All @@ -37,15 +37,15 @@ describe("collectAndSaveToken", () => {
});

afterEach(() => {
appContext.setApiToken(priorToken);
appContext.setAuthToken(priorToken);
appContext.apiHost = priorHost;
jest.restoreAllMocks();
});

it("collects, saves and returns a token", async () => {
collectTokenSpy.mockResolvedValue(token);

expect(appContext.apiToken).toBe("");
expect(appContext.authToken).toBe("");
const result = await collectAndSaveToken();

expect(collectTokenSpy).toHaveBeenCalled();
Expand All @@ -56,13 +56,13 @@ describe("collectAndSaveToken", () => {
token
);
expect(result).toBe(token);
expect(appContext.apiToken).toBe(token);
expect(appContext.authToken).toBe(token);
});

it("uses the host if provided", async () => {
collectTokenSpy.mockResolvedValue(token);

expect(appContext.apiToken).toBe("");
expect(appContext.authToken).toBe("");

const result = await collectAndSaveToken(host);
expect(collectTokenSpy).toHaveBeenCalled();
Expand All @@ -78,12 +78,12 @@ describe("collectAndSaveToken", () => {
it("handles empty string error", async () => {
collectTokenSpy.mockImplementation(() => Promise.reject(""));

expect(appContext.apiToken).toBe("");
expect(appContext.authToken).toBe("");
const response = await collectAndSaveToken();

expect(collectTokenSpy).toHaveBeenCalled();
expect(quitSpy).toHaveBeenCalledWith("", 0);
expect(appContext.apiToken).toBe("");
expect(appContext.authToken).toBe("");
expect(response).toBe("");
expect(getURLHostnameSpy).not.toHaveBeenCalled();
expect(saveTokenSpy).not.toHaveBeenCalled();
Expand All @@ -92,12 +92,12 @@ describe("collectAndSaveToken", () => {
it("handles other errors", async () => {
collectTokenSpy.mockImplementation(() => Promise.reject("some error"));

expect(appContext.apiToken).toBe("");
expect(appContext.authToken).toBe("");
const response = await collectAndSaveToken();

expect(collectTokenSpy).toHaveBeenCalled();
expect(quitSpy).toHaveBeenCalledWith(expect.stringContaining("Error ID:"));
expect(appContext.apiToken).toBe("");
expect(appContext.authToken).toBe("");
expect(response).toBe("");
expect(getURLHostnameSpy).not.toHaveBeenCalled();
expect(saveTokenSpy).not.toHaveBeenCalled();
Expand Down
2 changes: 1 addition & 1 deletion lib/src/services/apiToken/collectAndSaveToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default async function collectAndSaveToken(
);
const sanitizedHost = getURLHostname(host);
configService.saveToken(appContext.configFile, sanitizedHost, token);
appContext.setApiToken(token);
appContext.setAuthToken(token);
return token;
} catch (error) {
// https://github.com/enquirer/enquirer/issues/225#issue-516043136
Expand Down
3 changes: 2 additions & 1 deletion lib/src/services/apiToken/collectToken.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import appContext from "../../utils/appContext";
import logger from "../../utils/logger";
import promptForApiToken from "./promptForApiToken";

Expand All @@ -6,7 +7,7 @@ import promptForApiToken from "./promptForApiToken";
* @returns The collected token
*/
export default async function collectToken() {
const apiUrl = logger.url("https://app.dittowords.com/developers/api-keys");
const apiUrl = logger.url(`${appContext.appHost}/developers/api-keys`);
const tokenDescription = `To get started, you'll need your Ditto API key. You can find this at: ${apiUrl}.`;

logger.writeLine(tokenDescription);
Expand Down
4 changes: 4 additions & 0 deletions lib/src/services/apiToken/getURLHostname.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ describe("getURLHostname", () => {
const result = getURLHostname(expectedHostName);
expect(result).toBe(expectedHostName);
});

it("should return an empty string when the URL is unparseable", () => {
expect(getURLHostname("https://")).toBe("");
});
});
10 changes: 7 additions & 3 deletions lib/src/services/apiToken/getURLHostname.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import URL from "url";

/**
* Get the hostname from a URL string
* @param hostString
* @returns
*/
export default function getURLHostname(hostString: string) {
if (!hostString.includes("://")) return hostString;
return URL.parse(hostString).hostname || "";
// The WHATWG `URL` rather than `url.parse`, which prints a deprecation warning on
// Node 22+ and would land mid-render during login.
try {
return new URL(hostString).hostname || "";
} catch {
return "";
}
}
Loading
Loading