Skip to content

[DIT-13378] Add ability to sign in through OAuth to Ditto CLI - #147

Merged
jholiga merged 3 commits into
masterfrom
joey/dit-13378-add-oauth-support-for-some-or-all-of-the-api-endpoints-and
Aug 5, 2026
Merged

[DIT-13378] Add ability to sign in through OAuth to Ditto CLI#147
jholiga merged 3 commits into
masterfrom
joey/dit-13378-add-oauth-support-for-some-or-all-of-the-api-endpoints-and

Conversation

@jholiga

@jholiga jholiga commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

CLI: Add OAuth login

Overview

Adds ditto login and ditto logout so you can authenticate in a browser instead of pasting an API key.

Login uses the OAuth Authorization Code flow with PKCE. The session is saved to ~/.config/ditto next to any existing API keys.

Credential precedence:

  1. DITTO_TOKEN set in environment (so as not to break CI)
  2. A saved OAuth session
  3. A saved API key
  4. The interactive API key prompt

Other notes:

  • No new dependencies — http and crypto from Node, and open, which the CLI already used.
  • The config file stays readable by --legacy mode, which requires a token key on every entry and drops the whole file without one. There's a test pinning this.
  • Legacy mode is untouched and still API-key only. It calls /v1/*, which isn't OAuth-enabled on the API.

Links

Screenshots or videos

Screen.Recording.2026-08-03.at.4.28.43.PM.mov

Two visual surfaces worth capturing:

  • The terminal output during login and logout.
  • The browser page shown after approving, which says "You're logged in. You can close this tab and return to your terminal."

Test Plan

Needs joey/dit-13378-add-oauth-support-for-some-or-all-of-the-api-endpoints-and deployed to kooky. (Auth0 config already set up for kooky)

set the following in your .env file: DITTO_API_HOST, DITTO_AUTH0_DOMAIN, DITTO_AUTH0_CLIENT_ID, DITTO_AUTH0_AUDIENCE), then node esbuild.mjs.

  • node bin/ditto.js login opens the browser, and approving prints "You're logged in"
  • cat ~/.config/ditto shows an oauth block with both accessToken and refreshToken — a missing refreshToken means Auth0's offline access settings are wrong, and login will silently stop working in a day
  • node bin/ditto.js pull succeeds without prompting for a key
  • node bin/ditto.js logout prints "You're logged out", and the oauth block is gone from ~/.config/ditto
  • No regression, API key: DITTO_TOKEN=<api key> node bin/ditto.js pull still works
  • No regression, first run: move ~/.config/ditto aside, run node bin/ditto.js pull, and confirm the API key prompt still appears
  • No regression, legacy: node bin/ditto.js pull --legacy still works with an API key
  • With DITTO_TOKEN set, ditto login warns that commands will keep using the API key
  • Expired session recovers cleanly: in ~/.config/ditto, set expiresAt to 1 and delete the refreshToken line, then run pull — it should say "Your Ditto session has expired. Run ditto login to log in again" rather than dropping you at the API key prompt
  • npx jest passes

@jholiga
jholiga marked this pull request as ready for review August 3, 2026 19:38
@jholiga
jholiga requested a review from JWhite30515 August 3, 2026 20:23
@JWhite30515

Copy link
Copy Markdown
Screenshot 2026-08-04 at 2 38 24 PM

Not sure what controls what access we're asking for, but wouldn't it be more accurate to say we're asking for resource access to things like projects, text items, etc?

@JWhite30515

Copy link
Copy Markdown

sort of a side nit but mind if we add a try catch block to getURLHostname, looks like the edits in this branch expose a codepath during login that hits the code that imports url package, which, depending on local node version, shows error message

Depending on user's node version, this message can bubble up and look like an error on their end (it's not, it's a code warning) (pops up when linking without established Ditto config)

Screenshot 2026-08-04 at 3 12 21 PM

AI writeup suggests just adding a try/catch to getURLHostname.ts

Fix

Three lines, in getURLHostname.ts, dropping the shadowing import:

export default function getURLHostname(hostString: string) {
  if (!hostString.includes("://")) return hostString;
  try {
    return new URL(hostString).hostname || "";
  } catch {
    return "";
  }
}

AI writeup on issue:

Is it this branch?

getURLHostname itself is pre-existing (since 5.0.0), so the branch didn't introduce the deprecated call. But the branch did change when it fires. On master, initAPIToken returned early via if (!fs.existsSync(appContext.configFile)) return collectAndSaveToken(), so with a nonexistent config you'd only hit url.parse in collectAndSaveToken.ts:24 — after you typed a key. This branch removed that early return (correctly — reading the config creates it, and bailing would skip login for first-time users), so getURLHostname now runs before the prompt and the warning lands mid-render.

So: pre-existing latent issue, newly visible in exactly the flow you were testing.

Your setup / Node version

Nothing wrong locally. It's purely your Node version:

node v20.19.5 -> silent
v24.13.0 (yours) -> warns

Comment thread package.json

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just as reminder we'll need proper release note in changelog for 5.7 update: https://github.com/dittowords/cli/releases

Comment thread lib/src/utils/appContext.ts Outdated
Comment on lines 56 to 58
get apiToken() {
return this.#apiToken;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this be better renamed to authToken?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

Comment thread lib/src/commands/logout.ts Outdated
import appContext from "../utils/appContext";
import logger from "../utils/logger";

/** Forgets the local session. Doesn't revoke it at Auth0. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason we don't revoke the auth0 connection as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was trying to keep the scope tidy but a best effort attempt is a pretty small lift. I added it

Comment thread lib/src/services/auth/loopbackFlow.ts Outdated
Comment on lines +53 to +60
const toSession = (data: any): OAuthSession => ({
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt:
Date.now() +
((data.expires_in ?? DEFAULT_EXPIRY_SECONDS) - EXPIRY_MARGIN_SECONDS) *
1000,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is an AI flagged defensive nit but seems reasonable, we shouldn't try creating a session if access token is invalid:

a. Don't build a session out of a response you didn't check — loopbackFlow.ts:53:

const toSession = (data: any): OAuthSession => {
  if (typeof data?.access_token !== "string" || !data.access_token) {
    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,
  };
};

refreshSession then needs to treat that throw as "refresh failed" rather than letting it escape mid-pull:

try {
  return { ...toSession(response.data), refreshToken: response.data.refresh_token || refreshToken };
} catch {
  return null;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added!


const base64url = (bytes: Buffer) => bytes.toString("base64url");

// PKCE stands in for a client secret, which a published CLI can't keep.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// PKCE stands in for a client secret, which a published CLI can't keep.
// Creates Proof Key for Code Exchange (PKCE) challenger + verifier for oauth connection
// PKCE stands in for a client secret, which a published CLI can't keep.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

@JWhite30515

Copy link
Copy Markdown

Another AI-ism but it recommends wrapping file in writeGlobalConfigData with chmod 600, so that only owner can read/write to the file (~/.config/ditto, which has API keys)

Take it with an AI grain of salt but might be worth for security's sake?

function writeGlobalConfigData(file: string, data: object) {
  createFileIfMissing(file);
  try {
    fs.chmodSync(file, 0o600); // Owner-only: this file holds API keys.
  } catch {
    // Best-effort; see the note in src/services/globalConfig.ts.
  }
  const existingData = readGlobalConfigData(file);
  const yamlStr = yaml.dump({ ...existingData, ...data });
  fs.writeFileSync(file, yamlStr, "utf8");
}

@JWhite30515 JWhite30515 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't have the greatest low-level understanding of everything we're doing but high-level LGTM, just left some nits, we should definitely handle the warned node error for url.parse I highlighted

@jholiga
jholiga merged commit f3296a5 into master Aug 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants