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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,16 @@ or lands in History when you have unsaved edits. There is no `--wait`: a second
hands its files to the window already open and exits, so `readm3 edit` is not a
`core.editor` for git.

**Signing in** in the app is **Sign in with your browser** on the Account page: your
browser opens readm3.com, where your password manager and email are, you approve the
desktop app, and the app is signed in (OAuth with a loopback redirect, then one exchange
of that token for the window's session at `POST /oauth/desktop-session`). An emailed link
or CoinPay page inside the app would sign in the browser instead, so the app does not offer them.

**Open in app** on readm3.com/viewer opens the same page in the desktop app through a
`readm3://open?url=…` link the app registers (macOS, Windows, and Linux through the
installer's desktop entry). When nothing answers, the button becomes **Get the app**.

Files opened with "Open with" (Finder and Explorer included) go straight into the
editor. The window loads readm3.com, so sign-in, sync and sharing work as on the web;
a copy of the editor ships inside the app for a first launch with no connection. The
Expand Down
86 changes: 82 additions & 4 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@
* Files and folders named on the command line (`readm3 edit .`, like `subl .`), or
* handed over by the desktop's "Open with", are read here and given to the page
* through preload.cjs, which can write back only to files this process gave it.
*
* readm3:// links ("Open in app" on readm3.com) open a readm3.com page in this window, and
* "Sign in with your browser" signs the window in through the system browser, where the
* person's password manager is: OAuth with a loopback redirect, then one exchange of the
* token for a session cookie in this window (POST /oauth/desktop-session).
*/
const { app, BrowserWindow, Menu, ipcMain, net, protocol, shell } = require("electron");
const { app, BrowserWindow, Menu, ipcMain, net, protocol, session, shell } = require("electron");
const { createHash, randomBytes } = require("node:crypto");
const { createServer } = require("node:http");
const { readFile, readdir, writeFile, stat } = require("node:fs/promises");
const { basename, join, resolve } = require("node:path");
const { pathToFileURL } = require("node:url");
const { fileURLToPath, pathToFileURL } = require("node:url");

const ORIGIN = (process.env.READM3_URL || "https://readm3.com").replace(/\/$/, "");
const LOCAL = "app://readm3";
Expand Down Expand Up @@ -69,7 +76,68 @@ async function load(paths, cwd = process.cwd()) {
}

/** Arguments that name files, not switches; argv[0] is the binary and, unpackaged, argv[1] is the app. */
const fileArgs = (argv) => argv.slice(app.isPackaged ? 1 : 2).filter((arg) => !arg.startsWith("-"));
// A desktop entry with %U hands local files over as file:// URLs.
const fileArgs = (argv) => argv.slice(app.isPackaged ? 1 : 2).filter((arg) => !arg.startsWith("-") && !arg.startsWith("readm3:"))
.map((arg) => (arg.startsWith("file://") ? fileURLToPath(arg) : arg));
const linkArg = (argv) => argv.find((arg) => arg.startsWith("readm3:"));

/** The readm3.com page a readm3://open?url=… link names; anything off that origin is ignored. */
function linkTarget(link) {
try {
const target = new URL(link).searchParams.get("url");
return target && target.startsWith(`${ORIGIN}/`) ? target : undefined;
} catch { return undefined; }
}
let launchLink;
function openLink(link) {
const target = link && linkTarget(link);
if (!window) { launchLink = target; return; }
if (window.isMinimized()) window.restore();
window.focus();
if (target) void window.loadURL(target);
}

const base64url = (bytes) => Buffer.from(bytes).toString("base64url");
/** Sign this window in through the system browser; resolves once the session cookie is set. */
async function signInWithBrowser() {
const verifier = base64url(randomBytes(32));
const state = base64url(randomBytes(16));
const challenge = base64url(createHash("sha256").update(verifier).digest());
const server = createServer();
await new Promise((done) => server.listen(0, "127.0.0.1", done));
const redirect = `http://127.0.0.1:${server.address().port}/callback`;
try {
const code = await new Promise((done, fail) => {
const timer = setTimeout(() => fail(new Error("Sign-in timed out. Try again.")), 10 * 60 * 1000);
server.on("request", (request, response) => {
const url = new URL(request.url, redirect);
if (url.pathname !== "/callback") { response.writeHead(404).end(); return; }
const ok = url.searchParams.get("state") === state && url.searchParams.get("code");
response.writeHead(ok ? 200 : 400, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html><meta charset="utf-8"><title>readm3</title><body style="font:16px system-ui;display:grid;place-items:center;min-height:90vh"><p>${ok ? "You are signed in to the readm3 desktop app. You can close this tab." : "Sign-in was cancelled. Nothing was shared."}</p>`);
clearTimeout(timer);
if (ok) done(url.searchParams.get("code")); else fail(new Error("Sign-in was cancelled."));
});
const authorize = new URL(`${ORIGIN}/oauth/authorize`);
for (const [key, value] of Object.entries({ response_type: "code", client_id: "readm3-desktop", redirect_uri: redirect, code_challenge: challenge, code_challenge_method: "S256", state }))
authorize.searchParams.set(key, value);
void shell.openExternal(authorize.toString());
});
const tokens = await (await fetch(`${ORIGIN}/oauth/token`, {
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "authorization_code", client_id: "readm3-desktop", code, redirect_uri: redirect, code_verifier: verifier }),
})).json();
if (!tokens.access_token) throw new Error(tokens.error_description || "Sign-in failed. Try again.");
const response = await fetch(`${ORIGIN}/oauth/desktop-session`, { method: "POST", headers: { authorization: `Bearer ${tokens.access_token}` } });
const cookie = await response.json();
if (!response.ok) throw new Error(cookie.error_description || "Sign-in failed. Try again.");
await session.defaultSession.cookies.set({
url: ORIGIN, name: cookie.name, value: cookie.value, path: "/", httpOnly: true,
secure: ORIGIN.startsWith("https:"), sameSite: "lax", expirationDate: Date.now() / 1000 + cookie.maxAge,
});
window?.focus();
} finally { server.close(); }
}

async function openPaths(paths, cwd) {
const files = await load(paths, cwd);
Expand All @@ -95,7 +163,8 @@ function createWindow() {
window.webContents.on("will-navigate", (event, url) => {
if (!/^https:/.test(url) && !trusted(url)) { event.preventDefault(); }
});
window.loadURL(`${ORIGIN}/viewer`).catch(() => window.loadURL(`${LOCAL}/viewer`));
window.loadURL(launchLink ?? `${ORIGIN}/viewer`).catch(() => window.loadURL(`${LOCAL}/viewer`));
launchLink = undefined;
}

/** Menu items run the page's own commands by name, the same ones the command palette lists. */
Expand Down Expand Up @@ -145,7 +214,10 @@ else {
app.on("second-instance", (_event, argv, workingDirectory) => {
if (window) { if (window.isMinimized()) window.restore(); window.focus(); }
void openPaths(fileArgs(argv), workingDirectory);
if (linkArg(argv)) openLink(linkArg(argv));
});
// macOS delivers readm3:// links as events; Windows and Linux put them in argv.
app.on("open-url", (event, link) => { event.preventDefault(); openLink(link); });
// macOS hands files over as events (Finder, `open -a readm3`), before and after launch.
app.on("open-file", (event, path) => { event.preventDefault(); void openPaths([path]); });
app.whenReady().then(async () => {
Expand All @@ -156,7 +228,12 @@ else {
if (!file.startsWith(SITE)) return new Response("Not found", { status: 404 });
return net.fetch(pathToFileURL(file).toString());
});
if (app.isPackaged) app.setAsDefaultProtocolClient("readm3");
ipcMain.handle("launch-files", () => { const files = pending; pending = []; return files; });
ipcMain.handle("sign-in-with-browser", async (event) => {
if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed.");
await signInWithBrowser();
});
// The page re-reads a file to notice it changing on disk (a git checkout, a build).
ipcMain.handle("read-file", async (event, path) => {
if (!trusted(event.senderFrame?.url ?? "")) throw new Error("Not allowed.");
Expand All @@ -171,6 +248,7 @@ else {
});
Menu.setApplicationMenu(menu());
pending.push(...await load(fileArgs(process.argv)));
if (linkArg(process.argv)) launchLink = linkTarget(linkArg(process.argv));
createWindow();
});
app.on("window-all-closed", () => app.quit());
Expand Down
3 changes: 2 additions & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "readm3-desktop",
"productName": "readm3",
"version": "0.14.1",
"version": "0.15.0",
"private": true,
"description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.",
"author": "Profullstack, Inc. <hello@profullstack.com>",
Expand All @@ -28,6 +28,7 @@
"to": "site",
"filter": ["viewer/**", "viewer-assets/**", "viewer.webmanifest", "favicon.svg"]
}],
"protocols": [{ "name": "readm3", "schemes": ["readm3"] }],
"fileAssociations": [
{ "ext": ["md", "markdown"], "name": "Markdown", "role": "Editor" },
{ "ext": ["txt", "json", "yaml", "yml", "toml", "js", "ts", "py", "sh", "css", "html"], "name": "Text", "role": "Editor" }
Expand Down
1 change: 1 addition & 0 deletions desktop/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const custom = typeof process !== "undefined" ? process.env?.READM3_URL?.replace
if (origin === "https://readm3.com" || origin === "app://readm3" || (custom && origin === custom)) {
contextBridge.exposeInMainWorld("readm3Desktop", {
takeLaunchFiles: () => ipcRenderer.invoke("launch-files"),
signInWithBrowser: () => ipcRenderer.invoke("sign-in-with-browser"),
onOpenFiles: (callback) => { ipcRenderer.on("open-files", (_event, files) => callback(files)); },
readFile: (path) => ipcRenderer.invoke("read-file", path),
writeFile: (path, data) => ipcRenderer.invoke("write-file", path, data),
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": "@profullstack/readm3",
"version": "0.14.1",
"version": "0.15.0",
"description": "Markdown editing and sharing for people and teams. Web/PWA, CLI, API and MCP with version history, view/edit links, and a terminal reader.",
"type": "module",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion server/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const secret = () => randomBytes(32).toString("base64url");
const id = () => randomBytes(12).toString("base64url");
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
const stamp = () => new Date().toISOString();
const SESSION_SECONDS = 30 * 86400;
export const SESSION_SECONDS = 30 * 86400;
/** `email` is null for an account that signs in only through a linked identity (CoinPay). */
export type Account = { id: string; username: string; displayName: string; admin: number; createdAt: string; email: string | null; emailVerifiedAt: string | null };
export type Mail = { to: string; url: string };
Expand Down
27 changes: 25 additions & 2 deletions server/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@
*
* The CLI uses a loopback redirect when it can open a browser on the same machine,
* and /oauth/cli otherwise, so a login started over SSH never lands on localhost.
*
* The desktop app signs in the same way (its window has no password manager, and an
* emailed link opens in the browser), then trades its access token once for a
* browser session in its own window:
* - POST /oauth/desktop-session Bearer readm3-desktop token → session cookie value
*/
import type { Database } from "bun:sqlite";
import { createOAuthServer, OAuthError, type OAuthStore, type TokenRow } from "@profullstack/auth-system/oauth2";
import type { Accounts } from "./accounts.ts";
import { SESSION_SECONDS, type Accounts } from "./accounts.ts";

export const CLI_CLIENT_ID = "readm3-cli";
export const DESKTOP_CLIENT_ID = "readm3-desktop";
const TOKEN_PREFIX = "r3"; // r3_at_…, r3_rt_… (OAUTH_ACCESS_PREFIX in accounts.ts)

const iso = (value: Date | string) => new Date(value).toISOString();
Expand Down Expand Up @@ -108,6 +114,10 @@ export function createOAuth(accounts: Accounts) {
// Any port on 127.0.0.1 (RFC 8252), or the paste-a-code page for SSH sessions.
redirectUris: ["http://127.0.0.1/callback", `${origin}/oauth/cli`],
},
[DESKTOP_CLIENT_ID]: {
name: "the readm3 desktop app",
redirectUris: ["http://127.0.0.1/callback"],
},
},
});

Expand All @@ -133,6 +143,19 @@ export function createOAuth(accounts: Accounts) {
}
}

if (path === "/oauth/desktop-session") {
if (request.method !== "POST") return json({ error: "invalid_request", error_description: "Use POST." }, 405);
const bearer = request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1] ?? "";
// Only a token issued to the desktop app becomes a browser session; a CLI token never does.
const grant = bearer ? await oauth.verifyAccessToken(bearer) : null;
const user = grant?.clientId === DESKTOP_CLIENT_ID ? accounts.authenticate(bearer) : null;
if (!user) return json({ error: "invalid_token", error_description: "Sign in again from the desktop app." }, 401);
const session = accounts.startSession(user.id, "", "Desktop app");
// The token has done its one job; the session cookie is what the window keeps.
await oauth.revoke(bearer);
return json({ name: accounts.cookieName, value: session, maxAge: SESSION_SECONDS });
}

if (path === "/oauth/cli" && request.method === "GET") {
const error = url.searchParams.get("error");
const code = url.searchParams.get("code");
Expand Down Expand Up @@ -169,7 +192,7 @@ export function createOAuth(accounts: Accounts) {
return page(
`Sign in to ${params.clientName}?`,
`<p><strong>${escape(params.clientName)}</strong> will be able to read and edit your readm3 documents as <strong>${escape(who)}</strong>.</p>
<p>Only approve if you just ran <strong>readm3 login</strong> yourself.</p>
<p>Only approve if you just ${params.clientId === DESKTOP_CLIENT_ID ? "chose <strong>Sign in with your browser</strong> in the readm3 desktop app" : "ran <strong>readm3 login</strong>"} yourself.</p>
<form method="post" action="/oauth/authorize">${hidden}<button name="decision" value="approve">Approve</button><button name="decision" value="deny">Cancel</button></form>`,
);
}
Expand Down
41 changes: 41 additions & 0 deletions server/test/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,44 @@ test("an unregistered redirect_uri is refused on the page, never redirected to",
expect(response.status).toBe(400);
expect(response.headers.get("location")).toBeNull();
});

/** Approve a loopback sign-in for a client as the signed-in browser, and return its access token. */
async function loopbackToken(url: string, cookie: string, clientId: string) {
const verifier = "d".repeat(64);
const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url");
const params = { response_type: "code", client_id: clientId, redirect_uri: "http://127.0.0.1:9/callback", code_challenge: challenge, code_challenge_method: "S256", state: "s" };
const approved = await fetch(`${url}/oauth/authorize`, {
method: "POST", redirect: "manual",
headers: { cookie, origin: url, "content-type": "application/x-www-form-urlencoded" },
body: form({ ...params, decision: "approve" }),
});
const code = new URL(approved.headers.get("location")!).searchParams.get("code")!;
const tokens = await (await fetch(`${url}/oauth/token`, {
method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" },
body: form({ client_id: clientId, grant_type: "authorization_code", code, redirect_uri: params.redirect_uri, code_verifier: verifier }),
})).json();
return tokens.access_token as string;
}

test("the desktop app signs in through the browser and trades its token once for a session", async () => {
const { url, user, cookie } = await site();
const consent = await (await fetch(`${url}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id: "readm3-desktop", redirect_uri: "http://127.0.0.1:9/callback", code_challenge: "x".repeat(43), code_challenge_method: "S256", state: "s" })}`, { headers: { cookie } })).text();
expect(consent).toContain("Sign in with your browser");

const access = await loopbackToken(url, cookie, "readm3-desktop");
const exchange = () => fetch(`${url}/oauth/desktop-session`, { method: "POST", headers: { authorization: `Bearer ${access}` } });
const granted = await exchange();
expect(granted.status).toBe(200);
const session = await granted.json();
expect(session).toMatchObject({ name: "readm3_session", maxAge: 30 * 86400 });
// The value is an ordinary browser session for the same person.
const me = await (await fetch(`${url}/api/auth/session`, { headers: { cookie: `${session.name}=${session.value}` } })).json();
expect(me.user.id).toBe(user.id);
// It works once: the token is revoked by the exchange.
expect((await exchange()).status).toBe(401);

// A CLI token is never turned into a browser session.
const cli = await loopbackToken(url, cookie, "readm3-cli");
expect((await fetch(`${url}/oauth/desktop-session`, { method: "POST", headers: { authorization: `Bearer ${cli}` } })).status).toBe(401);
expect((await fetch(`${url}/oauth/desktop-session`, { method: "POST" })).status).toBe(401);
});
20 changes: 19 additions & 1 deletion site/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ if (coinpayNotice) {
returned.delete("coinpay_error"); returned.delete("coinpay");
history.replaceState(null, "", location.pathname + (returned.size ? `?${returned}` : "") + location.hash);
}
/** Inside the desktop app (desktop/preload.cjs): sign in through the system browser, where the password manager is. */
const desktop = (window as unknown as { readm3Desktop?: { signInWithBrowser?: () => Promise<void> } }).readm3Desktop;
const inDesktop = typeof desktop?.signInWithBrowser === "function";
let timer: ReturnType<typeof setInterval> | undefined;
let busy = false;

Expand All @@ -36,7 +39,8 @@ async function coinpay(panel: string) {
if (!response.ok) return;
} catch { return; }
const button = el<HTMLAnchorElement>("coinpay-sign-in");
button.hidden = !status.enabled || panel !== "sign-in";
// In the desktop app CoinPay's page would open in this window, with no password manager.
button.hidden = !status.enabled || panel !== "sign-in" || inDesktop;
button.href = `/api/v1/coinpay/oauth/start${next ? `?next=${encodeURIComponent(next)}` : ""}`;
const linked = status.identities[0];
el("coinpay-link").hidden = panel !== "profile" || (!status.enabled && !linked);
Expand Down Expand Up @@ -125,6 +129,20 @@ el("coinpay-unlink").addEventListener("click", () => void action(el<HTMLButtonEl
if (!response.ok) throw new Error(result.error || "Could not unlink CoinPay.");
await coinpay("profile"); message("status", "CoinPay is unlinked. Sign in with your email from now on.");
}));
if (inDesktop) {
// An emailed link opens in the browser and would sign the browser in, not this app.
el("email-form").hidden = true;
el("desktop-sign-in").hidden = false;
const fine = document.querySelector<HTMLElement>("#sign-in .fine-print");
if (fine) fine.textContent = "Your browser opens readm3.com. Sign in there with your email or CoinPay, approve the desktop app, and come back.";
el("desktop-sign-in").addEventListener("click", () => void action(el<HTMLButtonElement>("desktop-sign-in"), async () => {
message("status", "Finish signing in in your browser, then come back here.");
// Electron prefixes errors from the main process; show only the reason.
try { await desktop!.signInWithBrowser!(); }
catch (error) { throw new Error(String((error as Error).message).replace(/^Error invoking remote method '[^']+': (?:Error: )?/, "")); }
location.assign(next ?? "/viewer");
}));
}
async function initialize() {
try {
if (token) {
Expand Down
Loading
Loading