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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
</p>

<p align="center">
<img src="https://raw.githubusercontent.com/hyodotdev/gitdog-cli/main/.github/assets/terminal.svg?v=0.2.1" alt="Gitdog terminal: connect a project, configure automation and review pending actions" width="800">
<img src="https://raw.githubusercontent.com/hyodotdev/gitdog-cli/main/.github/assets/terminal.svg?v=0.2.2" alt="Gitdog terminal: connect a project, configure automation and review pending actions" width="800">
</p>

Connect repositories, configure issue, discussion and PR automation, review
Expand Down Expand Up @@ -64,7 +64,7 @@ Use `npx gitdog@latest --help` to explore commands. Add `--json` for scripts.

```sh
npx gitdog@latest keys create --name ci
npx --yes gitdog@0.2.1 --key-file /path/to/key.json projects list --json
npx --yes gitdog@0.2.2 --key-file /path/to/key.json projects list --json
npx gitdog@latest keys revoke KEY_ID --yes
```

Expand Down
3 changes: 2 additions & 1 deletion docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ For example: `npx gitdog@latest login`. An optional global installation with

`gitdog login` opens GitHub in your browser. Use a browser on the same computer,
or pass `--no-browser` to open the printed URL yourself. Ctrl+C cancels the entire
sign-in flow. The browser callback times out after five minutes.
sign-in flow. The browser callback times out after five minutes. When sign-in
finishes, your browser shows the result on Gitdog and the tab can be closed.

## Projects, like Firebase

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gitdog",
"version": "0.2.1",
"version": "0.2.2",
"description": "Manage Gitdog projects, GitHub automation and API keys from your terminal",
"type": "module",
"bin": {
Expand Down
69 changes: 50 additions & 19 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createServer } from "node:http";
import { createServer, type ServerResponse } from "node:http";
import { randomBytes } from "node:crypto";
import { httpClient, invoke } from "./client.js";
import { SessionStore, validTokens, tokenExpiresSoon } from "./storage.js";
import {
DEFAULT_API_URL,
DEFAULT_AUTH_SITE_URL,
DEFAULT_SITE_URL,
SessionStore,
validTokens,
tokenExpiresSoon,
} from "./storage.js";
import { asRecord, type Ui } from "./ui.js";

const runFile = promisify(execFile);
export const CALLBACK_PATH = "/gitdog/callback";
export const LOGIN_RESULT_PATH = "/cli/login";

type LoginOutcome = "success" | "failed";

function sendToLoginResult(
response: ServerResponse,
siteUrl: string,
outcome: LoginOutcome,
): Promise<void> {
if (response.destroyed) return Promise.resolve();
const location = new URL(LOGIN_RESULT_PATH, siteUrl);
location.searchParams.set("status", outcome);
return new Promise((resolve) => {
response.once("close", resolve);
response
.writeHead(302, { Location: location.href, Connection: "close" })
.end();
});
}

export async function openBrowser(
url: string,
Expand Down Expand Up @@ -38,7 +64,10 @@ export async function openBrowser(
}
}

export async function createLoginListener(timeoutMs = 300_000) {
export async function createLoginListener(
timeoutMs = 300_000,
siteUrl = DEFAULT_SITE_URL,
) {
const cancellation = new AbortController();
const state = randomBytes(32).toString("hex");
let resolveCode: (code: string) => void;
Expand All @@ -50,6 +79,13 @@ export async function createLoginListener(timeoutMs = 300_000) {
void code.catch(() => {});
let expectedHost = "";
let settled = false;
// Held open so the browser reports the real result, not just a received code.
let pendingResponse: ServerResponse | undefined;
const complete = async (outcome: LoginOutcome): Promise<void> => {
const response = pendingResponse;
pendingResponse = undefined;
if (response) await sendToLoginResult(response, siteUrl, outcome);
};
const finish = (error?: Error, value?: string) => {
if (settled) return;
settled = true;
Expand Down Expand Up @@ -98,21 +134,12 @@ export async function createLoginListener(timeoutMs = 300_000) {
response.writeHead(410).end("This sign-in has already completed.");
return;
}
if (url.searchParams.has("error")) {
response
.writeHead(400)
.end("Gitdog sign-in was not completed. Return to your terminal.");
if (url.searchParams.has("error") || !url.searchParams.has("code")) {
void sendToLoginResult(response, siteUrl, "failed");
finish(new Error("GitHub sign-in was declined or failed."));
return;
}
const value = url.searchParams.get("code");
if (!url.searchParams.has("code")) {
response
.writeHead(400)
.end("Gitdog sign-in was not completed. Return to your terminal.");
finish(new Error("GitHub sign-in was declined or failed."));
return;
}
if (
url.searchParams.getAll("code").length !== 1 ||
!value ||
Expand All @@ -121,9 +148,7 @@ export async function createLoginListener(timeoutMs = 300_000) {
response.writeHead(400).end("Missing or invalid sign-in code.");
return;
}
response.end(
"Gitdog received your sign-in. Return to your terminal to finish. You can close this tab.",
);
pendingResponse = response;
finish(undefined, value);
},
);
Expand Down Expand Up @@ -166,8 +191,10 @@ export async function createLoginListener(timeoutMs = 300_000) {
redirectTo,
signal: cancellation.signal,
code,
complete,
close: async () => {
finish(new Error("Sign-in cancelled."));
await complete("failed");
process.off("SIGINT", onInterrupt);
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
Expand All @@ -179,8 +206,11 @@ export function validateAuthRedirect(value: string, apiUrl: string): void {
const url = new URL(value);
const api = new URL(apiUrl);
const siteOrigin = api.origin.replace(/\.convex\.cloud$/, ".convex.site");
const allowedOrigins = [api.origin, siteOrigin];
if (api.origin === new URL(DEFAULT_API_URL).origin)
allowedOrigins.push(new URL(DEFAULT_AUTH_SITE_URL).origin);
if (
![api.origin, siteOrigin].includes(url.origin) ||
!allowedOrigins.includes(url.origin) ||
url.pathname !== "/api/auth/signin/github" ||
url.username ||
url.password ||
Expand All @@ -201,7 +231,7 @@ export async function login(
"Browser login needs human interaction. Omit --json; use GITDOG_TOKEN for automation.",
);
ui.banner();
const listener = await createLoginListener();
const listener = await createLoginListener(undefined, siteUrl);
const client = httpClient(apiUrl, 30_000, listener.signal);
try {
ui.info("1 / 2 Sign in with your GitHub account");
Expand Down Expand Up @@ -257,6 +287,7 @@ export async function login(
listener.signal,
);
listener.signal.throwIfAborted();
await listener.complete("success");
ui.output(`✓ Signed in as ${String(user.name ?? "GitHub user")}.`);
if (user.requiresCurrentLegalAttestation)
ui.info(
Expand Down
1 change: 1 addition & 0 deletions src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { asRecord } from "./ui.js";

export const DEFAULT_API_URL = "https://calm-hippopotamus-56.convex.cloud";
export const DEFAULT_SITE_URL = "https://gitdog.dev";
export const DEFAULT_AUTH_SITE_URL = "https://auth.gitdog.dev";
export type Tokens = { token: string; refreshToken: string };
export type Session = Tokens & { apiUrl: string; siteUrl: string };

Expand Down
2 changes: 1 addition & 1 deletion src/ui.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createInterface } from "node:readline/promises";
import { stripVTControlCharacters } from "node:util";

export const VERSION = "0.2.1";
export const VERSION = "0.2.2";
export const DOG = String.raw`
▗██▄▄ ▄▄██▖
██▀▜██▙▄ ▙▄ ▄▟██▛▀██
Expand Down
68 changes: 60 additions & 8 deletions test/auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,44 @@ test("loopback listener rejects incorrect state, path, method and duplicate code
const duplicate = new URL(correct);
duplicate.searchParams.append("code", "second");
assert.equal((await fetch(duplicate)).status, 400);
const response = await fetch(correct);
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.equal((await response.text()).includes("one-time-code"), false);
const pending = fetch(correct, { redirect: "manual" });
assert.equal(await listener.code, "one-time-code");
assert.equal((await fetch(correct)).status, 410);
await listener.complete("success");
const response = await pending;
assert.equal(response.status, 302);
assert.equal(
response.headers.get("location"),
"https://gitdog.dev/cli/login?status=success",
);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.equal(response.headers.get("referrer-policy"), "no-referrer");
});

test("loopback listener reports a failed exchange when it closes", async () => {
const listener = await createLoginListener(5000, "http://localhost:5173/");
const callback = new URL(listener.redirectTo);
callback.searchParams.set("code", "one-time-code");
const pending = fetch(callback, { redirect: "manual" });
await listener.code;
await listener.close();
assert.equal(
(await pending).headers.get("location"),
"http://localhost:5173/cli/login?status=failed",
);
});

test("loopback listener closes after the browser disconnects", async () => {
const listener = await createLoginListener(5000);
const callback = new URL(listener.redirectTo);
callback.searchParams.set("code", "one-time-code");
const disconnect = new AbortController();
const pending = fetch(callback, { signal: disconnect.signal });
await listener.code;
disconnect.abort();
await assert.rejects(pending);
await new Promise((resolve) => setTimeout(resolve, 20));
await listener.close();
});

test("loopback listener times out and supports cancellation", async () => {
Expand All @@ -47,8 +79,12 @@ test("loopback listener times out and supports cancellation", async () => {
test("Convex OAuth cancellation without a code fails immediately", async (t) => {
const listener = await createLoginListener(5000);
t.after(listener.close);
const response = await fetch(listener.redirectTo);
assert.equal(response.status, 400);
const response = await fetch(listener.redirectTo, { redirect: "manual" });
assert.equal(response.status, 302);
assert.equal(
response.headers.get("location"),
"https://gitdog.dev/cli/login?status=failed",
);
await assert.rejects(listener.code, /declined or failed/);
});

Expand All @@ -59,6 +95,20 @@ test("auth redirects cannot send the browser to a third-party origin", () => {
"https://my-app.convex.cloud",
),
);
assert.doesNotThrow(() =>
validateAuthRedirect(
"https://auth.gitdog.dev/api/auth/signin/github?code=x",
"https://calm-hippopotamus-56.convex.cloud",
),
);
assert.throws(
() =>
validateAuthRedirect(
"https://auth.gitdog.dev/api/auth/signin/github?code=x",
"https://my-app.convex.cloud",
),
/unexpected/,
);
assert.throws(
() =>
validateAuthRedirect(
Expand Down Expand Up @@ -118,7 +168,8 @@ test("real HTTP login exchanges a verifier-bound code and stores private credent
ui.banner = () => {};
ui.info = (message) => {
ui.messages.push(message);
if (message.startsWith("Open this URL")) void fetch(callback);
if (message.startsWith("Open this URL"))
void fetch(callback, { redirect: "manual" });
};
await login(server.url, "https://gitdog.dev", ui, true);
const store = new SessionStore();
Expand Down Expand Up @@ -222,7 +273,8 @@ for (const phase of ["start", "exchange", "profile", "lock"]) {
ui.banner = () => {};
ui.info = (message) => {
ui.messages.push(message);
if (message.startsWith("Open this URL")) void fetch(callback);
if (message.startsWith("Open this URL"))
void fetch(callback, { redirect: "manual" });
};
const signalHandlers = process.listenerCount("SIGINT");
await assert.rejects(
Expand Down
Loading