diff --git a/README.md b/README.md index 63c80b3..e92fb34 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/src/commands/login.ts b/lib/src/commands/login.ts new file mode 100644 index 0000000..eda096c --- /dev/null +++ b/lib/src/commands/login.ts @@ -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` + ) + ); +}; diff --git a/lib/src/commands/logout.ts b/lib/src/commands/logout.ts new file mode 100644 index 0000000..3a00d25 --- /dev/null +++ b/lib/src/commands/logout.ts @@ -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." + ) + ); + } +}; diff --git a/lib/src/commands/scan.ts b/lib/src/commands/scan.ts index ff75a6a..a5ecc0b 100644 --- a/lib/src/commands/scan.ts +++ b/lib/src/commands/scan.ts @@ -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 }, diff --git a/lib/src/http/client.test.ts b/lib/src/http/client.test.ts index 8a8df10..7b7be53 100644 --- a/lib/src/http/client.test.ts +++ b/lib/src/http/client.test.ts @@ -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", () => { @@ -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); diff --git a/lib/src/http/client.ts b/lib/src/http/client.ts index ae149c8..078588f 100644 --- a/lib/src/http/client.ts +++ b/lib/src/http/client.ts @@ -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; }; } diff --git a/lib/src/index.ts b/lib/src/index.ts index e25610a..b1bd4ea 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -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"; @@ -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") @@ -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) { diff --git a/lib/src/services/apiToken/collectAndSaveToken.test.ts b/lib/src/services/apiToken/collectAndSaveToken.test.ts index fc61de5..a1665db 100644 --- a/lib/src/services/apiToken/collectAndSaveToken.test.ts +++ b/lib/src/services/apiToken/collectAndSaveToken.test.ts @@ -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 @@ -37,7 +37,7 @@ describe("collectAndSaveToken", () => { }); afterEach(() => { - appContext.setApiToken(priorToken); + appContext.setAuthToken(priorToken); appContext.apiHost = priorHost; jest.restoreAllMocks(); }); @@ -45,7 +45,7 @@ describe("collectAndSaveToken", () => { 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(); @@ -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(); @@ -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(); @@ -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(); diff --git a/lib/src/services/apiToken/collectAndSaveToken.ts b/lib/src/services/apiToken/collectAndSaveToken.ts index 08ba021..562f987 100644 --- a/lib/src/services/apiToken/collectAndSaveToken.ts +++ b/lib/src/services/apiToken/collectAndSaveToken.ts @@ -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 diff --git a/lib/src/services/apiToken/collectToken.ts b/lib/src/services/apiToken/collectToken.ts index 045951f..ae1db2b 100644 --- a/lib/src/services/apiToken/collectToken.ts +++ b/lib/src/services/apiToken/collectToken.ts @@ -1,3 +1,4 @@ +import appContext from "../../utils/appContext"; import logger from "../../utils/logger"; import promptForApiToken from "./promptForApiToken"; @@ -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); diff --git a/lib/src/services/apiToken/getURLHostname.test.ts b/lib/src/services/apiToken/getURLHostname.test.ts index 05d218a..186c00e 100644 --- a/lib/src/services/apiToken/getURLHostname.test.ts +++ b/lib/src/services/apiToken/getURLHostname.test.ts @@ -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(""); + }); }); diff --git a/lib/src/services/apiToken/getURLHostname.ts b/lib/src/services/apiToken/getURLHostname.ts index a14c31e..83e00f2 100644 --- a/lib/src/services/apiToken/getURLHostname.ts +++ b/lib/src/services/apiToken/getURLHostname.ts @@ -1,5 +1,3 @@ -import URL from "url"; - /** * Get the hostname from a URL string * @param hostString @@ -7,5 +5,11 @@ import URL from "url"; */ 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 ""; + } } diff --git a/lib/src/services/apiToken/initAPIToken.test.ts b/lib/src/services/apiToken/initAPIToken.test.ts index 57aaca0..3fbaae3 100644 --- a/lib/src/services/apiToken/initAPIToken.test.ts +++ b/lib/src/services/apiToken/initAPIToken.test.ts @@ -1,5 +1,5 @@ -import fs from "fs"; import * as ConfigService from "../globalConfig"; +import * as Session from "../auth/session"; import * as ValidateToken from "./validateToken"; import * as CollectAndSaveToken from "./collectAndSaveToken"; import * as GetURLHostname from "./getURLHostname"; @@ -11,16 +11,18 @@ describe("initAPIToken", () => { let collectAndSaveTokenSpy: jest.SpiedFunction< typeof CollectAndSaveToken.default >; - let existsSyncSpy: jest.SpyInstance; - let readGlobalConfigDataSpy: jest.SpiedFunction< - typeof ConfigService.readGlobalConfigData + let readCredentialSpy: jest.SpiedFunction< + typeof ConfigService.readCredential + >; + let resolveOAuthHeaderSpy: jest.SpiedFunction< + typeof Session.resolveOAuthHeader >; let getURLHostnameSpy: jest.SpiedFunction; let priorToken: string | undefined; beforeEach(() => { - priorToken = appContext.apiToken; - appContext.setApiToken(""); + priorToken = appContext.authToken; + appContext.setAuthToken(""); validateTokenSpy = jest .spyOn(ValidateToken, "default") @@ -34,72 +36,91 @@ describe("initAPIToken", () => { return Promise.resolve("newToken"); } }); - existsSyncSpy = jest.spyOn(fs, "existsSync"); - readGlobalConfigDataSpy = jest.spyOn(ConfigService, "readGlobalConfigData"); + readCredentialSpy = jest + .spyOn(ConfigService, "readCredential") + .mockReturnValue(undefined); + resolveOAuthHeaderSpy = jest + .spyOn(Session, "resolveOAuthHeader") + .mockResolvedValue(null); getURLHostnameSpy = jest .spyOn(GetURLHostname, "default") .mockReturnValue("urlHostname"); }); afterEach(() => { - appContext.setApiToken(priorToken); + appContext.setAuthToken(priorToken); jest.restoreAllMocks(); }); it("should validate and return the token if provided", async () => { - appContext.setApiToken("validToken"); + appContext.setAuthToken("validToken"); const response = await initAPIToken(); expect(response).toBe("validToken"); expect(validateTokenSpy).toHaveBeenCalledWith("validToken"); expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); - expect(readGlobalConfigDataSpy).not.toHaveBeenCalled(); + expect(readCredentialSpy).not.toHaveBeenCalled(); expect(getURLHostnameSpy).not.toHaveBeenCalled(); }); - it("should call collectAndSaveToken if no token is provided and config file does not exist", async () => { - existsSyncSpy.mockReturnValue(false); + // CI has no browser, so DITTO_TOKEN has to win over anything saved on disk. + it("should prefer a provided token over a saved OAuth session", async () => { + appContext.setAuthToken("ciToken"); + resolveOAuthHeaderSpy.mockResolvedValue("Bearer fromSession"); + + const response = await initAPIToken(); + + expect(response).toBe("ciToken"); + expect(resolveOAuthHeaderSpy).not.toHaveBeenCalled(); + }); + + it("should use a saved OAuth session ahead of a saved API key", async () => { + resolveOAuthHeaderSpy.mockResolvedValue("Bearer fromSession"); + readCredentialSpy.mockReturnValue({ token: "myToken" }); + const response = await initAPIToken(); - expect(response).toBe("newToken"); + + expect(response).toBe("Bearer fromSession"); expect(validateTokenSpy).not.toHaveBeenCalled(); - expect(collectAndSaveTokenSpy).toHaveBeenCalled(); - expect(existsSyncSpy).toHaveBeenCalledWith(appContext.configFile); - expect(readGlobalConfigDataSpy).not.toHaveBeenCalled(); - expect(getURLHostnameSpy).not.toHaveBeenCalled(); + expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); + }); + + // Reading the config creates the file, so a first run must still reach the + // session rather than being pushed at the API key prompt. + it("should use a saved OAuth session on a first run", async () => { + resolveOAuthHeaderSpy.mockResolvedValue("Bearer fromSession"); + readCredentialSpy.mockReturnValue(undefined); + + expect(await initAPIToken()).toBe("Bearer fromSession"); + expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); }); describe("should collect and save token based on config if config does not have a token", () => { const expectCollectsFromConfig = () => { expect(validateTokenSpy).not.toHaveBeenCalled(); - expect(existsSyncSpy).toHaveBeenCalledWith(appContext.configFile); - expect(readGlobalConfigDataSpy).toHaveBeenCalledWith( - appContext.configFile + expect(readCredentialSpy).toHaveBeenCalledWith( + appContext.configFile, + "urlHostname" ); expect(getURLHostnameSpy).toHaveBeenCalledWith(appContext.apiHost); expect(collectAndSaveTokenSpy).toHaveBeenCalledWith("urlHostname"); }; - it("config[host] does not exist", async () => { - existsSyncSpy.mockReturnValue(true); - const configData = {}; - readGlobalConfigDataSpy.mockReturnValue(configData); + it("config has no entry for the host", async () => { + readCredentialSpy.mockReturnValue(undefined); const response = await initAPIToken(); expect(response).toBe("tokenWithHost"); expectCollectsFromConfig(); }); - it("config[host][0] does not exist", async () => { - existsSyncSpy.mockReturnValue(true); - const configData = { urlHostname: [] }; - readGlobalConfigDataSpy.mockReturnValue(configData); + it("the host's entry has no token", async () => { + readCredentialSpy.mockReturnValue({}); const response = await initAPIToken(); expect(response).toBe("tokenWithHost"); expectCollectsFromConfig(); }); - it("config[host][0].token is empty string", async () => { - existsSyncSpy.mockReturnValue(true); - const configData = { urlHostname: [{ token: "" }] }; - readGlobalConfigDataSpy.mockReturnValue(configData); + it("the host's token is an empty string", async () => { + readCredentialSpy.mockReturnValue({ token: "" }); const response = await initAPIToken(); expect(response).toBe("tokenWithHost"); expectCollectsFromConfig(); @@ -107,15 +128,26 @@ describe("initAPIToken", () => { }); it("should validate and return the token from the config file", async () => { - existsSyncSpy.mockReturnValue(true); - const configData = { urlHostname: [{ token: "myToken" }] }; - readGlobalConfigDataSpy.mockReturnValue(configData); + readCredentialSpy.mockReturnValue({ token: "myToken" }); const response = await initAPIToken(); expect(response).toBe("myToken"); expect(validateTokenSpy).toHaveBeenCalledWith("myToken"); - expect(existsSyncSpy).toHaveBeenCalledWith(appContext.configFile); - expect(readGlobalConfigDataSpy).toHaveBeenCalledWith(appContext.configFile); + expect(readCredentialSpy).toHaveBeenCalledWith( + appContext.configFile, + "urlHostname" + ); expect(getURLHostnameSpy).toHaveBeenCalledWith(appContext.apiHost); expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); }); + + // Expired past renewal. The API key prompt would hide the actual fix. + it("should tell the user to log in again when a stored session can't be renewed", async () => { + readCredentialSpy.mockReturnValue({ + token: "", + oauth: { accessToken: "stale", expiresAt: 1 }, + }); + + await expect(initAPIToken()).rejects.toThrow(/ditto login/); + expect(collectAndSaveTokenSpy).not.toHaveBeenCalled(); + }); }); diff --git a/lib/src/services/apiToken/initAPIToken.ts b/lib/src/services/apiToken/initAPIToken.ts index 30a7638..0ace85c 100644 --- a/lib/src/services/apiToken/initAPIToken.ts +++ b/lib/src/services/apiToken/initAPIToken.ts @@ -1,33 +1,48 @@ import appContext from "../../utils/appContext"; -import fs from "fs"; +import DittoError, { ErrorType } from "../../utils/DittoError"; import * as configService from "../globalConfig"; +import { resolveOAuthHeader } from "../auth/session"; import collectAndSaveToken from "./collectAndSaveToken"; import validateToken from "./validateToken"; import getURLHostname from "./getURLHostname"; /** - * Initializes the API token based on the appContext and config file. - * @returns The initialized API token + * The credential every command uses, in precedence order: `DITTO_TOKEN`, a + * saved OAuth session, a saved API key, then an interactive prompt. `DITTO_TOKEN` + * stays first so CI works without a browser. + * + * @returns The Authorization header value to send */ export default async function initAPIToken() { - if (appContext.apiToken) { - return await validateToken(appContext.apiToken); + if (appContext.authToken) { + return await validateToken(appContext.authToken); } - if (!fs.existsSync(appContext.configFile)) { - return await collectAndSaveToken(); - } + // Before any file-existence check: reading the config creates it, and bailing on + // a missing file would skip login entirely for first-time users. + const oauthHeader = await resolveOAuthHeader(); + if (oauthHeader) return oauthHeader; - const configData = configService.readGlobalConfigData(appContext.configFile); const sanitizedHost = getURLHostname(appContext.apiHost); + const credential = configService.readCredential( + appContext.configFile, + sanitizedHost + ); + + if (!credential?.token) { + // A stored session resolveOAuthHeader didn't return is expired past renewal. + if (credential?.oauth) { + throw new DittoError({ + type: ErrorType.AuthError, + expected: true, + message: + "Your Ditto session has expired. Run `ditto login` to log in again.", + data: {}, + }); + } - if ( - !configData[sanitizedHost] || - !configData[sanitizedHost][0] || - configData[sanitizedHost][0].token === "" - ) { return await collectAndSaveToken(sanitizedHost); } - return await validateToken(configData[sanitizedHost][0].token); + return await validateToken(credential.token); } diff --git a/lib/src/services/auth/auth0Config.test.ts b/lib/src/services/auth/auth0Config.test.ts new file mode 100644 index 0000000..d8202f7 --- /dev/null +++ b/lib/src/services/auth/auth0Config.test.ts @@ -0,0 +1,70 @@ +import getAuth0Config from "./auth0Config"; + +const ENV_KEYS = [ + "DITTO_AUTH0_DOMAIN", + "DITTO_AUTH0_CLIENT_ID", + "DITTO_AUTH0_AUDIENCE", +] as const; + +describe("getAuth0Config", () => { + const original: Record = {}; + + beforeEach(() => { + for (const key of ENV_KEYS) { + original[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (original[key] === undefined) delete process.env[key]; + else process.env[key] = original[key]; + } + }); + + // An unfilled PROD block throws the missing-config error at every user. + it("configures production from source with nothing in the environment", () => { + expect(getAuth0Config("https://api.dittowords.com")).toEqual({ + domain: expect.stringContaining("auth0.com"), + clientId: expect.stringMatching(/.+/), + audience: expect.stringMatching(/^https:\/\/.+/), + }); + }); + + // Falling back to the production client would mint a token with the wrong + // audience and fail somewhere much less obvious. + it("refuses a non-production host with nothing configured", () => { + expect(() => getAuth0Config("https://kooky-api.dittowords.com")).toThrow( + /DITTO_AUTH0_DOMAIN/ + ); + }); + + it("configures a non-production host from the environment", () => { + process.env.DITTO_AUTH0_DOMAIN = "ditto-dev.auth0.com"; + process.env.DITTO_AUTH0_CLIENT_ID = "dev-client"; + process.env.DITTO_AUTH0_AUDIENCE = "https://api.dev.dittowords.com"; + + expect(getAuth0Config("https://kooky-api.dittowords.com")).toEqual({ + domain: "ditto-dev.auth0.com", + clientId: "dev-client", + audience: "https://api.dev.dittowords.com", + }); + }); + + it("lets the environment override production too", () => { + process.env.DITTO_AUTH0_DOMAIN = "ditto-dev.auth0.com"; + process.env.DITTO_AUTH0_CLIENT_ID = "dev-client"; + process.env.DITTO_AUTH0_AUDIENCE = "https://api.dev.dittowords.com"; + + expect(getAuth0Config("https://api.dittowords.com")).toMatchObject({ + domain: "ditto-dev.auth0.com", + }); + }); + + it("refuses a localhost API with nothing configured", () => { + expect(() => getAuth0Config("http://localhost:3001")).toThrow( + /DITTO_AUTH0_CLIENT_ID/ + ); + }); +}); diff --git a/lib/src/services/auth/auth0Config.ts b/lib/src/services/auth/auth0Config.ts new file mode 100644 index 0000000..ed74137 --- /dev/null +++ b/lib/src/services/auth/auth0Config.ts @@ -0,0 +1,47 @@ +import appContext from "../../utils/appContext"; +import DittoError, { ErrorType } from "../../utils/DittoError"; +import getURLHostname from "../apiToken/getURLHostname"; + +export interface Auth0Config { + domain: string; + clientId: string; + audience: string; +} + +const PROD_API_HOSTNAME = "api.dittowords.com"; + +/** + * Public identifier information for production instance of Auth0 + */ +const PROD: Auth0Config = { + domain: "ditto-app.auth0.com", + clientId: "8KEhl0kyB5nEMBUdYUwnGPw74AodU4tu", + audience: "https://api.dittowords.com", +}; + +/** + * Gets Auth0 details for the environment that the CLI is pointed at. + */ +export default function getAuth0Config( + apiHost = appContext.apiHost +): Auth0Config { + const base = getURLHostname(apiHost) === PROD_API_HOSTNAME ? PROD : undefined; + + const domain = process.env.DITTO_AUTH0_DOMAIN || base?.domain; + const clientId = process.env.DITTO_AUTH0_CLIENT_ID || base?.clientId; + const audience = process.env.DITTO_AUTH0_AUDIENCE || base?.audience; + + if (!domain || !clientId || !audience) { + throw new DittoError({ + type: ErrorType.AuthError, + expected: true, + message: + `Ditto doesn't know how to log in to ${apiHost}. ` + + `Set DITTO_AUTH0_DOMAIN, DITTO_AUTH0_CLIENT_ID, and DITTO_AUTH0_AUDIENCE, ` + + `or use an API key instead.`, + data: { apiHost }, + }); + } + + return { domain, clientId, audience }; +} diff --git a/lib/src/services/auth/loopbackFlow.test.ts b/lib/src/services/auth/loopbackFlow.test.ts new file mode 100644 index 0000000..fd9f560 --- /dev/null +++ b/lib/src/services/auth/loopbackFlow.test.ts @@ -0,0 +1,232 @@ +import axios from "axios"; +import crypto from "crypto"; +import http from "http"; +import { + logInThroughBrowser, + refreshSession, + revokeRefreshToken, +} from "./loopbackFlow"; + +jest.mock("axios"); + +const config = { + domain: "tenant.auth0.com", + clientId: "client", + audience: "https://audience", +}; + +const post = () => axios.post as jest.Mock; + +// `restoreMocks` doesn't clear a module automock, so call history would leak. +beforeEach(() => post().mockReset()); + +const tokenResponse = (data: Record = {}) => ({ + status: 200, + data: { access_token: "at", refresh_token: "rt", expires_in: 3600, ...data }, +}); + +/** Stands in for the browser: follows the authorize URL's redirect_uri back. */ +const visitCallback = (authorizeUrl: string, query: Record) => { + const params = new URL(authorizeUrl).searchParams; + const callback = new URL(params.get("redirect_uri")!); + for (const [key, value] of Object.entries(query)) { + callback.searchParams.set(key, value); + } + return new Promise((resolve, reject) => { + http + .get(callback.toString(), (res) => { + res.resume(); + res.on("end", resolve); + }) + .on("error", reject); + }); +}; + +/** Approves the login the way Auth0 would, echoing the state back. */ +const approve = (authorizeUrl: string) => + visitCallback(authorizeUrl, { + code: "auth-code", + state: new URL(authorizeUrl).searchParams.get("state")!, + }); + +describe("logInThroughBrowser", () => { + it("exchanges the code the browser is redirected back with", async () => { + post().mockResolvedValueOnce(tokenResponse()); + + const session = await logInThroughBrowser(config, approve); + + expect(session.accessToken).toBe("at"); + expect(session.refreshToken).toBe("rt"); + expect(session.expiresAt).toBeGreaterThan(Date.now()); + + const [, body] = post().mock.calls[0]; + expect(body.grant_type).toBe("authorization_code"); + expect(body.code).toBe("auth-code"); + }); + + // PKCE stands in for the client secret, so a mismatch here means Auth0 would + // reject every exchange. + it("sends a verifier that matches the S256 challenge it asked with", async () => { + post().mockResolvedValueOnce(tokenResponse()); + let challenge: string | null = null; + + await logInThroughBrowser(config, (url) => { + const params = new URL(url).searchParams; + challenge = params.get("code_challenge"); + expect(params.get("code_challenge_method")).toBe("S256"); + return approve(url); + }); + + const [, body] = post().mock.calls[0]; + const expected = crypto + .createHash("sha256") + .update(body.code_verifier) + .digest("base64url"); + expect(challenge).toBe(expected); + }); + + it("redirects back to a loopback address", async () => { + post().mockResolvedValueOnce(tokenResponse()); + let redirectUri: string | null = null; + + await logInThroughBrowser(config, (url) => { + redirectUri = new URL(url).searchParams.get("redirect_uri"); + return approve(url); + }); + + expect(redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/); + }); + + // Without this check another site could hand us a code of its choosing. + it("refuses a response whose state doesn't match the request", async () => { + await expect( + logInThroughBrowser(config, (url) => + visitCallback(url, { code: "auth-code", state: "not-the-state" }) + ) + ).rejects.toThrow(/didn't match/i); + + expect(post()).not.toHaveBeenCalled(); + }); + + it("surfaces Auth0's description when the user denies the login", async () => { + await expect( + logInThroughBrowser(config, (url) => + visitCallback(url, { + error: "access_denied", + error_description: "User did not authorize", + }) + ) + ).rejects.toThrow(/User did not authorize/); + }); + + // A 200 with no token would otherwise be saved as a session that fails every + // request, with nothing pointing back at the login. + it("refuses a 200 that doesn't include an access token", async () => { + post().mockResolvedValueOnce({ status: 200, data: { expires_in: 3600 } }); + + await expect(logInThroughBrowser(config, approve)).rejects.toThrow( + /didn't include an access token/ + ); + }); + + it("surfaces Auth0's description when the code exchange fails", async () => { + post().mockResolvedValueOnce({ + status: 403, + data: { error: "invalid_grant", error_description: "Code expired" }, + }); + + await expect(logInThroughBrowser(config, approve)).rejects.toThrow( + /Code expired/ + ); + }); + + /** + * `server.close()` waits on keep-alive sockets, and clients ask for those by + * default — so without the `Connection: close` response header the process hangs + * and the port stays bound. Asserting the same port twice is what proves it was + * released, since otherwise the flow just falls through to the next one. + */ + it("releases the port when it's done", async () => { + post() + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(tokenResponse()); + const ports: string[] = []; + const record = (url: string) => { + ports.push(new URL(new URL(url).searchParams.get("redirect_uri")!).port); + return approve(url); + }; + + await logInThroughBrowser(config, record); + await logInThroughBrowser(config, record); + + expect(ports[0]).toBe(ports[1]); + }); +}); + +describe("refreshSession", () => { + it("keeps the existing refresh token when rotation doesn't return a new one", async () => { + post().mockResolvedValueOnce(tokenResponse({ refresh_token: undefined })); + + expect(await refreshSession(config, "original-rt")).toMatchObject({ + accessToken: "at", + refreshToken: "original-rt", + }); + }); + + it("stores the rotated refresh token when Auth0 returns one", async () => { + post().mockResolvedValueOnce( + tokenResponse({ refresh_token: "rotated-rt" }) + ); + + expect(await refreshSession(config, "original-rt")).toMatchObject({ + refreshToken: "rotated-rt", + }); + }); + + // A NaN expiresAt fails the config schema on the next read, and a schema miss + // there reads as an empty config — losing every host's credential. + it("still produces a usable expiry when expires_in is missing", async () => { + post().mockResolvedValueOnce(tokenResponse({ expires_in: undefined })); + + const session = await refreshSession(config, "original-rt"); + + expect(session?.expiresAt).toBeGreaterThan(Date.now()); + }); + + it("returns null when the refresh token is rejected", async () => { + post().mockResolvedValueOnce({ + status: 403, + data: { error: "invalid_grant" }, + }); + + expect(await refreshSession(config, "spent-rt")).toBeNull(); + }); + + // Throwing instead would surface as a login error mid-`pull`, where the caller + // already knows how to say "run `ditto login`". + it("returns null when a 200 doesn't include an access token", async () => { + post().mockResolvedValueOnce({ status: 200, data: { expires_in: 3600 } }); + + expect(await refreshSession(config, "original-rt")).toBeNull(); + }); +}); + +describe("revokeRefreshToken", () => { + it("posts the refresh token to Auth0's revoke endpoint", async () => { + post().mockResolvedValueOnce({ status: 200, data: {} }); + + await revokeRefreshToken(config, "rt"); + + const [url, body] = post().mock.calls[0]; + expect(url).toBe("https://tenant.auth0.com/oauth/revoke"); + expect(body).toMatchObject({ client_id: "client", token: "rt" }); + }); + + // The local credential is cleared either way, so a network failure here must not + // take `ditto logout` down with it. + it("swallows a failed request", async () => { + post().mockRejectedValueOnce(new Error("network down")); + + await expect(revokeRefreshToken(config, "rt")).resolves.toBeUndefined(); + }); +}); diff --git a/lib/src/services/auth/loopbackFlow.ts b/lib/src/services/auth/loopbackFlow.ts new file mode 100644 index 0000000..299356d --- /dev/null +++ b/lib/src/services/auth/loopbackFlow.ts @@ -0,0 +1,269 @@ +import axios from "axios"; +import crypto from "crypto"; +import http from "http"; + +import DittoError, { ErrorType } from "../../utils/DittoError"; +import { Auth0Config } from "./auth0Config"; + +export interface OAuthSession { + accessToken: string; + refreshToken?: string; + /** Epoch ms. */ + expiresAt: number; +} + +// `offline_access` is how we get the refresh token. +const SCOPE = "openid profile email offline_access"; + +// Expire a minute early so a token can't lapse mid-request. +const EXPIRY_MARGIN_SECONDS = 60; + +// A missing `expires_in` would make `expiresAt` NaN, which fails the config schema +// on the next read — and a schema miss there drops every saved credential. +const DEFAULT_EXPIRY_SECONDS = 3600; + +// Ports registered as an allowed callback URL in Auth0. +const PORTS = [51004, 51005, 51006]; + +const CALLBACK_PATH = "/callback"; + +// Don't leave a listening server behind if the browser never comes back. +const TIMEOUT_MS = 5 * 60 * 1000; + +const DONE_HTML = `Ditto CLI + +

You're logged in. You can close this tab and return to your terminal.

+`; + +const authError = (message: string) => + new DittoError({ + type: ErrorType.AuthError, + expected: true, + message, + data: {}, + }); + +// Auth0 describes failures in the body, so read it instead of throwing. +const post = (url: string, body: Record) => + axios.post(url, body, { validateStatus: () => true }); + +const describe = (data: any) => + data?.error_description || data?.error || "unexpected response"; + +// A 200 with no `access_token` would otherwise be saved as a session that fails +// every request, with nothing pointing back at the login. +const toSession = (data: any): OAuthSession => { + if (!data?.access_token || typeof data.access_token !== "string") { + throw authError( + "We couldn't finish the login. The response didn't include an access token." + ); + } + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: + Date.now() + + ((data.expires_in ?? DEFAULT_EXPIRY_SECONDS) - EXPIRY_MARGIN_SECONDS) * + 1000, + }; +}; + +const base64url = (bytes: Buffer) => bytes.toString("base64url"); + +// Creates the Proof Key for Code Exchange (PKCE) challenge + verifier. +// PKCE stands in for a client secret, which a published CLI can't keep. +const createPkce = () => { + const verifier = base64url(crypto.randomBytes(32)); + return { + verifier, + challenge: base64url(crypto.createHash("sha256").update(verifier).digest()), + }; +}; + +/** Binds the first free port. Nothing to close on failure — it never listened. */ +async function listen() { + for (const port of PORTS) { + const server = http.createServer(); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.removeAllListeners("error"); + resolve(); + }); + }); + return { server, port }; + } catch { + // In use; try the next one. + } + } + + throw authError( + `Ditto couldn't open a port to finish the login. Ports ${PORTS.join( + ", " + )} are all in use.` + ); +} + +/** Resolves with the authorization code the browser is redirected back with. */ +function awaitCode(server: http.Server, state: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + reject( + authError("The login timed out. Run `ditto login` to try again.") + ), + TIMEOUT_MS + ); + + server.on("request", (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404).end(); + return; + } + + // Answer before settling, so the tab shows something either way. Closing the + // connection matters: `server.close()` waits on keep-alive sockets, and + // clients default to keeping them open. + res + .writeHead(200, { "Content-Type": "text/html", Connection: "close" }) + .end(DONE_HTML); + clearTimeout(timer); + + const params = url.searchParams; + const code = params.get("code"); + + if (params.get("error")) { + return reject( + authError( + `We couldn't finish the login. ${describe({ + error: params.get("error"), + error_description: params.get("error_description"), + })}` + ) + ); + } + // Rejecting a mismatch is what stops another site from feeding us a code. + if (params.get("state") !== state) { + return reject( + authError("The login response didn't match this request.") + ); + } + if (!code) { + return reject(authError("The login response didn't include a code.")); + } + + resolve(code); + }); + }); +} + +const authorizeUrl = ( + config: Auth0Config, + params: { redirectUri: string; challenge: string; state: string } +) => + `https://${config.domain}/authorize?${new URLSearchParams({ + response_type: "code", + client_id: config.clientId, + audience: config.audience, + scope: SCOPE, + redirect_uri: params.redirectUri, + state: params.state, + code_challenge: params.challenge, + code_challenge_method: "S256", + })}`; + +/** + * Logs in with the Authorization Code Flow and PKCE, catching Auth0's redirect on + * a loopback server so there's no code for the user to copy. + * + * `showUrl` is where the caller prints and opens the URL; this module has no + * terminal or browser I/O. + */ +export async function logInThroughBrowser( + config: Auth0Config, + showUrl: (url: string) => void | Promise +): Promise { + const { verifier, challenge } = createPkce(); + const state = base64url(crypto.randomBytes(16)); + const { server, port } = await listen(); + const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`; + + try { + // Listening starts before the browser opens, so a fast redirect can't be + // missed. Both settle together because the redirect can land while `showUrl` is + // still running, and a rejection nothing is attached to is an unhandled one. + const [code] = await Promise.all([ + awaitCode(server, state), + showUrl(authorizeUrl(config, { redirectUri, challenge, state })), + ]); + + const response = await post(`https://${config.domain}/oauth/token`, { + grant_type: "authorization_code", + client_id: config.clientId, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }); + + if (response.status !== 200) { + throw authError( + `We couldn't finish the login. ${describe(response.data)}` + ); + } + + return toSession(response.data); + } finally { + server.close(); + } +} + +/** + * Trades a refresh token for a fresh access token. Null means the refresh token is + * spent and the user has to log in again. + */ +export async function refreshSession( + config: Auth0Config, + refreshToken: string +): Promise { + const response = await post(`https://${config.domain}/oauth/token`, { + grant_type: "refresh_token", + client_id: config.clientId, + refresh_token: refreshToken, + }); + + if (response.status !== 200) return null; + + try { + return { + ...toSession(response.data), + // Rotation returns a new refresh token; keep the current one if it doesn't. + refreshToken: response.data.refresh_token || refreshToken, + }; + } catch { + // A malformed renewal is a failed renewal. Throwing here would surface as a + // login error mid-`pull`, where "run `ditto login`" is the useful answer. + return null; + } +} + +/** + * Kills a refresh token at Auth0 so a copy of the config file can't be replayed + * after logout. Best-effort: the local credential is gone either way, and logout + * shouldn't fail because the network did. + */ +export async function revokeRefreshToken( + config: Auth0Config, + refreshToken: string +): Promise { + try { + await post(`https://${config.domain}/oauth/revoke`, { + client_id: config.clientId, + token: refreshToken, + }); + } catch { + // Ignore. + } +} diff --git a/lib/src/services/auth/session.test.ts b/lib/src/services/auth/session.test.ts new file mode 100644 index 0000000..6d67757 --- /dev/null +++ b/lib/src/services/auth/session.test.ts @@ -0,0 +1,75 @@ +import * as configService from "../globalConfig"; +import * as Auth0Config from "./auth0Config"; +import * as DeviceFlow from "./loopbackFlow"; +import { resolveOAuthHeader } from "./session"; + +describe("resolveOAuthHeader", () => { + const future = () => Date.now() + 60_000; + const past = () => Date.now() - 60_000; + + beforeEach(() => { + jest.spyOn(Auth0Config, "default").mockReturnValue({ + domain: "tenant.auth0.com", + clientId: "client", + audience: "https://audience", + }); + jest.spyOn(configService, "saveOAuthSession").mockImplementation(() => {}); + }); + + it("returns null when no session is stored", async () => { + jest.spyOn(configService, "readCredential").mockReturnValue(undefined); + + expect(await resolveOAuthHeader()).toBeNull(); + }); + + it("uses a stored access token that hasn't expired", async () => { + jest.spyOn(configService, "readCredential").mockReturnValue({ + oauth: { + accessToken: "still-good", + refreshToken: "rt", + expiresAt: future(), + }, + }); + const refreshSpy = jest.spyOn(DeviceFlow, "refreshSession"); + + expect(await resolveOAuthHeader()).toBe("Bearer still-good"); + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it("refreshes an expired session and persists the new one", async () => { + jest.spyOn(configService, "readCredential").mockReturnValue({ + oauth: { accessToken: "stale", refreshToken: "rt", expiresAt: past() }, + }); + const renewed = { + accessToken: "fresh", + refreshToken: "rt2", + expiresAt: future(), + }; + jest.spyOn(DeviceFlow, "refreshSession").mockResolvedValue(renewed); + + expect(await resolveOAuthHeader()).toBe("Bearer fresh"); + expect(configService.saveOAuthSession).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + renewed + ); + }); + + it("returns null when an expired session has no refresh token", async () => { + jest.spyOn(configService, "readCredential").mockReturnValue({ + oauth: { accessToken: "stale", expiresAt: past() }, + }); + + expect(await resolveOAuthHeader()).toBeNull(); + }); + + it("returns null when the refresh token is spent", async () => { + jest.spyOn(configService, "readCredential").mockReturnValue({ + oauth: { accessToken: "stale", refreshToken: "rt", expiresAt: past() }, + }); + jest.spyOn(DeviceFlow, "refreshSession").mockResolvedValue(null); + + expect(await resolveOAuthHeader()).toBeNull(); + expect(configService.saveOAuthSession).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/services/auth/session.ts b/lib/src/services/auth/session.ts new file mode 100644 index 0000000..3e191e6 --- /dev/null +++ b/lib/src/services/auth/session.ts @@ -0,0 +1,34 @@ +import appContext from "../../utils/appContext"; +import getURLHostname from "../apiToken/getURLHostname"; +import * as configService from "../globalConfig"; +import getAuth0Config from "./auth0Config"; +import { refreshSession } from "./loopbackFlow"; + +// The scheme is stored with the credential so the request interceptor stays a +// passthrough: OAuth tokens go out as Bearer, API keys verbatim. +export const bearerHeader = (accessToken: string) => `Bearer ${accessToken}`; + +/** The key credentials are filed under. Login, logout, and resolve must agree. */ +export const currentHostname = () => getURLHostname(appContext.apiHost); + +/** + * Authorization header for the saved session, renewing it first if expired. Null + * when there's no session or the refresh token is spent. + */ +export async function resolveOAuthHeader(): Promise { + const hostname = currentHostname(); + const session = configService.readCredential( + appContext.configFile, + hostname + )?.oauth; + if (!session) return null; + + if (session.expiresAt > Date.now()) return bearerHeader(session.accessToken); + if (!session.refreshToken) return null; + + const renewed = await refreshSession(getAuth0Config(), session.refreshToken); + if (!renewed) return null; + + configService.saveOAuthSession(appContext.configFile, hostname, renewed); + return bearerHeader(renewed.accessToken); +} diff --git a/lib/src/services/auth/verifyAccess.test.ts b/lib/src/services/auth/verifyAccess.test.ts new file mode 100644 index 0000000..4883099 --- /dev/null +++ b/lib/src/services/auth/verifyAccess.test.ts @@ -0,0 +1,59 @@ +import * as Client from "../../http/client"; +import verifyOAuthAccess from "./verifyAccess"; + +describe("verifyOAuthAccess", () => { + const respondWith = (response: unknown) => { + const get = jest.fn().mockResolvedValue(response); + jest.spyOn(Client, "default").mockReturnValue({ get } as any); + return get; + }; + + it("passes a working token", async () => { + respondWith({ status: 200, data: { name: "Workspace" } }); + + expect(await verifyOAuthAccess("Bearer at")).toBeNull(); + }); + + // /token-check explains some refusals in the body better than we could. + it("surfaces the API's own explanation when it sends one", async () => { + respondWith({ + status: 401, + data: "Developer Integrations are not enabled for this workspace.", + }); + + expect(await verifyOAuthAccess("Bearer at")).toEqual([ + expect.stringContaining("Developer Integrations"), + ]); + }); + + it("reports an unexplained 401 as a rejected token", async () => { + respondWith({ status: 401, data: "" }); + + const output = (await verifyOAuthAccess("Bearer at"))?.join(" "); + expect(output).toMatch(/didn't accept/i); + expect(output).toContain("401"); + }); + + it("tells the user to sign in to the web app on a 403", async () => { + respondWith({ status: 403, data: "" }); + + expect((await verifyOAuthAccess("Bearer at"))?.join(" ")).toMatch( + /sign in to ditto in your browser/i + ); + }); + + it("reports the status for anything else", async () => { + respondWith({ status: 500, data: "" }); + + expect((await verifyOAuthAccess("Bearer at"))?.join(" ")).toContain("500"); + }); + + it("reports a network failure rather than blaming the token", async () => { + const get = jest.fn().mockRejectedValue(new Error("ECONNREFUSED")); + jest.spyOn(Client, "default").mockReturnValue({ get } as any); + + expect((await verifyOAuthAccess("Bearer at"))?.join(" ")).toMatch( + /couldn't reach/i + ); + }); +}); diff --git a/lib/src/services/auth/verifyAccess.ts b/lib/src/services/auth/verifyAccess.ts new file mode 100644 index 0000000..6db9442 --- /dev/null +++ b/lib/src/services/auth/verifyAccess.ts @@ -0,0 +1,60 @@ +import getHttpClient from "../../http/client"; +import appContext from "../../utils/appContext"; +import logger from "../../utils/logger"; + +/** + * Confirms an access token works against the API. Separate from `checkToken` + * because each failure here has a different fix, and its "invalid API key" wording + * is wrong when nobody pasted a key. + * + * @returns lines to show the user, or null when the token works + */ +export default async function verifyOAuthAccess( + authorization: string +): Promise { + let status: number; + let body = ""; + + try { + const response = await getHttpClient({ token: authorization }).get( + "/token-check", + { validateStatus: () => true } + ); + status = response.status; + body = typeof response.data === "string" ? response.data.trim() : ""; + } catch { + return [ + logger.errorText( + `We couldn't reach the Ditto API at ${appContext.apiHost}.` + ), + ]; + } + + if (status === 200) return null; + + // The API explains refusals in the body (e.g.: Developer Integrations disabled) + if (body) return [logger.errorText(body)]; + + if (status === 401) { + return [ + logger.errorText("Ditto didn't accept this login."), + logger.subtle( + `The API turned down the access token (401). It may expect a different audience than this login asked for.` + ), + ]; + } + + if (status === 403) { + return [ + logger.errorText("This account can't access a workspace on this API."), + logger.subtle( + "Sign in to Ditto in your browser once with the same account, then try again." + ), + ]; + } + + return [ + logger.errorText("Ditto couldn't verify this login."), + logger.subtle(`The API answered ${status}.`), + ]; +} diff --git a/lib/src/services/globalConfig.test.ts b/lib/src/services/globalConfig.test.ts new file mode 100644 index 0000000..58564dd --- /dev/null +++ b/lib/src/services/globalConfig.test.ts @@ -0,0 +1,98 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import yaml from "js-yaml"; +import * as configService from "./globalConfig"; + +describe("globalConfig", () => { + let file: string; + + beforeEach(() => { + file = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "ditto-config-")), + "ditto" + ); + }); + + // A schema miss falls back to {}, signing out every existing user on upgrade. + it("still reads a config written before OAuth existed", () => { + fs.writeFileSync( + file, + yaml.dump({ "api.dittowords.com": [{ token: "abc.def" }] }) + ); + + expect(configService.readCredential(file, "api.dittowords.com")).toEqual({ + token: "abc.def", + }); + }); + + // The file holds API keys and refresh tokens, so nobody else on the box gets to + // read it. + it("writes the config owner-only", () => { + configService.saveToken(file, "api.dittowords.com", "abc.def"); + + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + }); + + it("round-trips an OAuth session", () => { + const oauth = { accessToken: "at", refreshToken: "rt", expiresAt: 1234 }; + + configService.saveOAuthSession(file, "api.dittowords.com", oauth); + + expect( + configService.readCredential(file, "api.dittowords.com")?.oauth + ).toEqual(oauth); + }); + + it("replaces a stored API key when a session is saved for the same host", () => { + configService.saveToken(file, "api.dittowords.com", "abc.def"); + configService.saveOAuthSession(file, "api.dittowords.com", { + accessToken: "at", + expiresAt: 1, + }); + + const entry = configService.readCredential(file, "api.dittowords.com"); + expect(entry?.token).toBe(""); + expect(entry?.oauth?.accessToken).toBe("at"); + }); + + it("leaves other hosts intact when clearing one", () => { + configService.saveToken(file, "api.dittowords.com", "a.a"); + configService.saveToken(file, "kooky-api.dittowords.com", "b.b"); + + configService.clearCredential(file, "api.dittowords.com"); + + expect( + configService.readCredential(file, "api.dittowords.com")?.token + ).toBe(""); + expect( + configService.readCredential(file, "kooky-api.dittowords.com") + ).toEqual({ + token: "b.b", + }); + }); + + /** + * Legacy mode reads this same file, requires a `token` key on every entry, and + * indexes the entry list unguarded — so an emptied host must keep one entry. + */ + it("stays readable by legacy mode after OAuth writes", () => { + configService.saveOAuthSession(file, "api.dittowords.com", { + accessToken: "at", + expiresAt: 1, + }); + configService.clearCredential(file, "kooky-api.dittowords.com"); + + const raw = yaml.load(fs.readFileSync(file, "utf8")) as Record< + string, + { token?: string }[] + >; + + for (const entries of Object.values(raw)) { + expect(entries.length).toBeGreaterThan(0); + for (const entry of entries) { + expect(Object.keys(entry)).toContain("token"); + } + } + }); +}); diff --git a/lib/src/services/globalConfig.ts b/lib/src/services/globalConfig.ts index b7d22e4..e678dcd 100644 --- a/lib/src/services/globalConfig.ts +++ b/lib/src/services/globalConfig.ts @@ -3,17 +3,27 @@ import fs from "fs"; import yaml from "js-yaml"; import { z } from "zod"; import { createFileIfMissingSync } from "../utils/fileSystem"; +import { OAuthSession } from "./auth/loopbackFlow"; -const ZGlobalConfigYAML = z.record( - z.string(), - z.array( - z.object({ - token: z.string(), - }) - ) -); +const ZOAuthSession = z.object({ + accessToken: z.string(), + refreshToken: z.string().optional(), + expiresAt: z.number(), +}); + +/** + * `token` is optional so pre-OAuth entries still parse — a schema miss falls back + * to `{}`, signing out everyone who upgrades. + */ +const ZGlobalConfigEntry = z.object({ + token: z.string().optional(), + oauth: ZOAuthSession.optional(), +}); + +const ZGlobalConfigYAML = z.record(z.string(), z.array(ZGlobalConfigEntry)); type GlobalConfigYAML = z.infer; +export type GlobalConfigEntry = z.infer; /** * Read data from a global config file @@ -40,6 +50,16 @@ export function readGlobalConfigData( */ function writeGlobalConfigData(file: string, data: object) { createFileIfMissingSync(file); + + // This file holds API keys and refresh tokens, so keep it owner-only. Best-effort: + // chmod is a partial no-op on Windows, and a config we can't lock down still beats + // failing the write. + try { + fs.chmodSync(file, 0o600); + } catch { + // Ignore. + } + const existingData = readGlobalConfigData(file); const yamlStr = yaml.dump({ ...existingData, ...data }); fs.writeFileSync(file, yamlStr, "utf8"); @@ -56,3 +76,35 @@ export function saveToken(file: string, hostname: string, token: string) { data[hostname] = [{ token }]; // only allow one token per host writeGlobalConfigData(file, data); } + +/** Saves a session. One credential per host, so this replaces a stored API key. */ +export function saveOAuthSession( + file: string, + hostname: string, + oauth: OAuthSession +) { + const data = readGlobalConfigData(file); + // Legacy mode's parser requires a `token` key on every entry, or it reads the + // whole file as unparseable and its next write drops every host. + data[hostname] = [{ token: "", oauth }]; + writeGlobalConfigData(file, data); +} + +/** The stored credential for a host, or undefined if there isn't one. */ +export function readCredential( + file: string, + hostname: string +): GlobalConfigEntry | undefined { + return readGlobalConfigData(file)[hostname]?.[0]; +} + +/** + * Forgets a host's credential. Empties the entry rather than removing the key: + * `writeGlobalConfigData` merges, so it can't express a deletion, and legacy + * `deleteToken` does the same because its reader indexes the list unguarded. + */ +export function clearCredential(file: string, hostname: string) { + const data = readGlobalConfigData(file); + data[hostname] = [{ token: "" }]; + writeGlobalConfigData(file, data); +} diff --git a/lib/src/utils/DittoError.ts b/lib/src/utils/DittoError.ts index 616117b..02b57ca 100644 --- a/lib/src/utils/DittoError.ts +++ b/lib/src/utils/DittoError.ts @@ -56,6 +56,7 @@ export enum ErrorType { ConfigYamlLoadError = "ConfigYamlLoadError", ConfigParseError = "ConfigParseError", ScanError = "ScanError", + AuthError = "AuthError", } /** @@ -66,6 +67,7 @@ type ErrorDataMap = { [ErrorType.ConfigYamlLoadError]: ConfigYamlLoadErrorData; [ErrorType.ConfigParseError]: ConfigParseErrorData; [ErrorType.ScanError]: ScanErrorData; + [ErrorType.AuthError]: AuthErrorData; }; type ConfigYamlLoadErrorData = { @@ -81,6 +83,10 @@ type ScanErrorData = { rawErrorMessage: string; }; +type AuthErrorData = { + apiHost?: string; +}; + export function isDittoError(error: unknown): error is DittoError { return error instanceof DittoError; } diff --git a/lib/src/utils/appContext.ts b/lib/src/utils/appContext.ts index 75c2d59..684249b 100644 --- a/lib/src/utils/appContext.ts +++ b/lib/src/utils/appContext.ts @@ -16,7 +16,7 @@ const DEFAULT_APP_HOST = "https://app.dittowords.com"; class AppContext { #apiHost: string; #appHost: string; - #apiToken: string | undefined; + #authToken: string | undefined; #configFile: string; #projectConfigDir: string; #projectConfigFile: string; @@ -26,7 +26,7 @@ class AppContext { constructor() { this.#apiHost = process.env.DITTO_API_HOST || DEFAULT_API_HOST; this.#appHost = process.env.DITTO_APP_HOST || DEFAULT_APP_HOST; - this.#apiToken = process.env.DITTO_TOKEN; + this.#authToken = process.env.DITTO_TOKEN; this.#configFile = process.env.DITTO_CONFIG_FILE || path.join(homedir(), ".config", "ditto"); this.#projectConfigFile = @@ -52,15 +52,9 @@ class AppContext { this.#apiHost = value; } - get apiToken() { - return this.#apiToken; - } - - get apiTokenOrThrow() { - if (!this.#apiToken) { - throw new Error("No API Token found."); - } - return this.#apiToken; + /** The `Authorization` header value: an API key verbatim, or `Bearer `. */ + get authToken() { + return this.#authToken; } get configFile() { @@ -79,8 +73,8 @@ class AppContext { this.#clientId = value; } - setApiToken(value: string | undefined) { - this.#apiToken = value; + setAuthToken(value: string | undefined) { + this.#authToken = value; } get projectConfig() { diff --git a/package.json b/package.json index ffe0dc0..cfc7246 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.6.3", + "version": "5.7.0", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js",