diff --git a/README.md b/README.md index f48b9a9..800e290 100644 --- a/README.md +++ b/README.md @@ -252,8 +252,8 @@ Stale saves are rejected so concurrent edits cannot silently overwrite each othe Owners can restore prior versions, pin links to a version, and revoke links. ```sh -readm3 login --token-stdin # token from /admin → API tokens -readm3 login --coinpay # or sign in with CoinPay in the browser +readm3 login # approve in the browser; over SSH, paste the code it shows +readm3 login --token-stdin # or a token from /admin → API tokens readm3 whoami # the account, and its linked CoinPay account readm3 orgs list readm3 share README.md --org ORG_ID # view link by default; without --org, your personal workspace @@ -539,11 +539,21 @@ unlinked on `/account` when it also has an email; an account that signs in only CoinPay cannot be, since that would lock it out. Links, unlinks, sign-ins and app calls are recorded in `identity_events`; identities are in `user_identities`. -`readm3 login --coinpay` runs the same grant from the terminal against a loopback -redirect (`http://127.0.0.1:PORT/callback`, any port), then -`POST /api/v1/coinpay/oauth/cli-exchange {code, code_verifier, redirect_uri}` has -readm3 make the exchange and return an ordinary 90-day readm3 API token, saved like -`login --token-stdin`. Set `READM3_NO_BROWSER=1` to print the link without opening it. +### CLI sign-in (OAuth 2.1) + +`readm3 login` signs the CLI in with readm3's own OAuth 2.1 server +([`@profullstack/auth-system/oauth2`](https://www.npmjs.com/package/@profullstack/auth-system), +client `readm3-cli`): authorization code + PKCE S256 only, access tokens for an hour, +refresh tokens that rotate on every use, and a reused refresh token revokes the whole +sign-in. The browser step is `/oauth/authorize`, which sends a signed-out browser +through `/account` (email or CoinPay) and then asks to approve. Where the CLI can open a +browser on the same machine it waits on a loopback redirect (`http://127.0.0.1:PORT/callback`); +over SSH, or with `--no-browser` / `READM3_NO_BROWSER=1`, it prints the link to open in +any browser and `/oauth/cli` shows a code to paste back. Tokens are kept in `cloud.json` +(0600) and refreshed by every command; `readm3 logout` revokes them on the server. +`readm3 login --coinpay` is the same sign-in. Metadata is at +`/.well-known/oauth-authorization-server`; tokens at `/oauth/token`, revocation at `/oauth/revoke`. +`POST /api/v1/coinpay/oauth/cli-exchange` remains for CLI versions before 0.14.0. | Endpoint | Purpose | | --- | --- | @@ -637,7 +647,7 @@ The CLI, document commands, and MCP use one credential file, `cloud.json`, under in `/admin` to invalidate remote copies. Tokens can also be created and revoked from the account page. ```sh -readm3 login --token-stdin # paste a token, then Ctrl+D +readm3 login # or --token-stdin: paste a token, then Ctrl+D readm3 whoami readm3 settings --theme nord --flavor github readm3 save ./notes # explicitly import and save Markdown diff --git a/bun.lock b/bun.lock index 1d7f5f5..2951266 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,8 @@ "name": "@profullstack/readm3", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", - "@profullstack/footer": "^0.2.4", + "@profullstack/auth-system": "^0.8.3", + "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.5.0", "@profullstack/synconfig": "^0.1.3", "highlight.js": "^11.12.0", @@ -141,6 +142,8 @@ "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + "@profullstack/auth-system": ["@profullstack/auth-system@0.8.3", "", { "dependencies": { "bcryptjs": "^2.4.3", "jsonwebtoken": "^9.0.2", "uuid": "^11.1.0" }, "peerDependencies": { "@supabase/supabase-js": "^2.49.4", "mongodb": "^6.5.0", "mysql2": "^3.9.2", "pg": "^8.11.3", "pocketbase": "^0.21.1" }, "optionalPeers": ["@supabase/supabase-js", "mongodb", "mysql2", "pg", "pocketbase"] }, "sha512-goafDEaq8XDDCChfAfi8wvUxn1xMeGWXpr7XEH9czFU/eIq9PkehGRTy3d/YP2BGzO1iIxrTAb2m2eOMTMdYUA=="], + "@profullstack/footer": ["@profullstack/footer@0.2.4", "", { "peerDependencies": { "react": ">=18" }, "optionalPeers": ["react"], "bin": { "profullstack-footer": "bin/profullstack-footer.js" } }, "sha512-2ErIzHJCDA2baP8aK6f7rCHohW4EX+wnp7D1mrVMQTUxRcyZwKzmho96hhEFJndXRBGgxT3H/78BHOeBfLxTzg=="], "@profullstack/hqtui": ["@profullstack/hqtui@0.5.0", "", { "bin": { "hqtui": "bin/hqtui.mjs" } }, "sha512-jDwILBmdQx8pQA0ao/tzzty8u5nFNS4XhvU9xXhZvdIaMeiF3HBJIVtP6cS6kigS4lXmHR7sztbb9QJpaWwhig=="], @@ -159,8 +162,12 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -189,6 +196,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -261,6 +270,26 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -309,8 +338,12 @@ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], @@ -347,6 +380,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], diff --git a/desktop/package.json b/desktop/package.json index 93229aa..c5b054f 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "readm3-desktop", "productName": "readm3", - "version": "0.13.2", + "version": "0.14.0", "private": true, "description": "readm3 as a desktop app: a plain, fast text editor for Markdown and code.", "author": "Profullstack, Inc. ", diff --git a/package.json b/package.json index 58bca00..7f71d5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/readm3", - "version": "0.13.2", + "version": "0.14.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", @@ -43,6 +43,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", + "@profullstack/auth-system": "^0.8.3", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.5.0", "@profullstack/synconfig": "^0.1.3", diff --git a/server/accounts.ts b/server/accounts.ts index 8e53226..3522c5c 100644 --- a/server/accounts.ts +++ b/server/accounts.ts @@ -26,6 +26,22 @@ export const IDENTITY_SCHEMA = ` CREATE INDEX IF NOT EXISTS user_identities_user ON user_identities(userId); CREATE TABLE IF NOT EXISTS identity_events (id TEXT PRIMARY KEY, userId TEXT REFERENCES users(id) ON DELETE SET NULL, provider TEXT NOT NULL, providerUserId TEXT NOT NULL, event TEXT NOT NULL CHECK(event IN ('link','unlink','signin','api')), clientId TEXT, createdAt TEXT NOT NULL); CREATE INDEX IF NOT EXISTS identity_events_user ON identity_events(userId, createdAt);`; +/** + * OAuth 2.1 codes and tokens for `readm3 login` (server/oauth.ts, @profullstack/auth-system/oauth2). + * Only SHA-256 hashes are stored. Shared with server/store.ts like IDENTITY_SCHEMA. + */ +export const OAUTH_SCHEMA = ` + CREATE TABLE IF NOT EXISTS oauth_codes (codeHash TEXT PRIMARY KEY, userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, clientId TEXT NOT NULL, redirectUri TEXT NOT NULL, codeChallenge TEXT NOT NULL, scope TEXT NOT NULL, expiresAt TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS oauth_tokens (tokenHash TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('access','refresh')), userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, clientId TEXT NOT NULL, scope TEXT NOT NULL, familyId TEXT NOT NULL, expiresAt TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, used INTEGER NOT NULL DEFAULT 0); + CREATE INDEX IF NOT EXISTS oauth_tokens_family ON oauth_tokens(familyId);`; +/** Access tokens from `readm3 login` look like r3_at_<43 chars>; session and API tokens are exactly 43 characters. */ +export const OAUTH_ACCESS_PREFIX = "r3_at_"; +export const isOAuthAccessToken = (token: string) => token.length > 43 && token.startsWith(OAUTH_ACCESS_PREFIX); +/** Joins the users row `u` for a live OAuth access token (bind: tokenHash, now). */ +export const OAUTH_ACCESS_JOIN = "oauth_tokens t JOIN users u ON u.id=t.userId"; +export const OAUTH_ACCESS_WHERE = "t.tokenHash=? AND t.kind='access' AND t.revoked=0 AND t.expiresAt>?"; +/** Where a sign-in may send the browser afterwards: same-origin pages only. */ +export const SAFE_NEXT = /^\/(?:admin|viewer|account|oauth\/authorize)(?:\?|$)/; /** A user may authenticate with a verified email, or with a linked identity. Email alone stays the only way to create one by mail. */ export const VERIFIED_IDENTITY = "(EXISTS (SELECT 1 FROM account_emails ae WHERE ae.userId=u.id) OR EXISTS (SELECT 1 FROM user_identities ui WHERE ui.userId=u.id))"; @@ -49,6 +65,7 @@ export class Accounts { CREATE INDEX IF NOT EXISTS email_challenges_email ON email_challenges(email); CREATE TABLE IF NOT EXISTS account_rate_limits (key TEXT PRIMARY KEY, count INTEGER NOT NULL, expiresAt INTEGER NOT NULL); ${IDENTITY_SCHEMA} + ${OAUTH_SCHEMA} `); } private rate(key: string, limit: number, seconds: number) { @@ -66,6 +83,9 @@ export class Accounts { } /** Only verified identities (an email, or a linked CoinPay account) are returned. Use this for every workspace API. */ authenticate(token: string): Account | null { + if (isOAuthAccessToken(token)) + return this.db.query(`SELECT ${publicColumns} FROM ${OAUTH_ACCESS_JOIN} LEFT JOIN account_emails e ON e.userId=u.id + WHERE ${OAUTH_ACCESS_WHERE} AND ${VERIFIED_IDENTITY}`).get(hash(token), stamp()) as Account | null; if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return null; return this.db.query(`SELECT ${publicColumns} FROM sessions s JOIN users u ON u.id=s.userId LEFT JOIN account_emails e ON e.userId=u.id WHERE s.tokenHash=? AND s.expiresAt>? AND ${VERIFIED_IDENTITY}`).get(hash(token), stamp()) as Account | null; @@ -90,7 +110,7 @@ export class Accounts { return row; } private async requestLink(input: unknown, ip: string, requestedNext?: unknown) { - const next = typeof requestedNext === "string" && /^\/(?:admin|viewer)(?:\?|$)/.test(requestedNext) ? requestedNext : null; + const next = typeof requestedNext === "string" && SAFE_NEXT.test(requestedNext) ? requestedNext : null; this.rate(`send-ip:${ip}`, 20, 3600); const email = typeof input === "string" ? input.trim().toLowerCase() : ""; if (email.length > 254 || !/^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i.test(email) || email.split("@")[0].length > 64) { diff --git a/server/coinpay.ts b/server/coinpay.ts index d5d85b0..273a147 100644 --- a/server/coinpay.ts +++ b/server/coinpay.ts @@ -15,7 +15,7 @@ * CoinPay identity to an existing account and never becomes an account email: * an identity that is not linked yet is a new user, never a merge. */ -import type { Accounts } from "./accounts.ts"; +import { SAFE_NEXT, type Accounts } from "./accounts.ts"; import { Store, HttpError, @@ -366,7 +366,7 @@ export class CoinPay { this.require(); const requested = new URL(request.url).searchParams.get("next"); const next = - requested && /^\/(?:admin|viewer|account)(?:\?|$)/.test(requested) + requested && SAFE_NEXT.test(requested) ? requested : null; const state = secret(); diff --git a/server/oauth.ts b/server/oauth.ts new file mode 100644 index 0000000..9eb5e6c --- /dev/null +++ b/server/oauth.ts @@ -0,0 +1,190 @@ +/** + * readm3 as an OAuth 2.1 authorization server, so `readm3 login` signs a CLI in + * as the person at the browser (@profullstack/auth-system/oauth2: authorization + * code + PKCE S256 only, rotating refresh tokens, hashes only in the database). + * + * - GET /.well-known/oauth-authorization-server RFC 8414 metadata + * - GET /oauth/authorize consent page; signed-out browsers go through /account first + * - POST /oauth/authorize Approve or Cancel, then back to the CLI's redirect_uri + * - GET /oauth/cli shows the code to paste when the CLI has no browser (SSH) + * - POST /oauth/token authorization_code and refresh_token grants + * - POST /oauth/revoke RFC 7009; ends the whole sign-in + * + * 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. + */ +import type { Database } from "bun:sqlite"; +import { createOAuthServer, OAuthError, type OAuthStore, type TokenRow } from "@profullstack/auth-system/oauth2"; +import type { Accounts } from "./accounts.ts"; + +export const CLI_CLIENT_ID = "readm3-cli"; +const TOKEN_PREFIX = "r3"; // r3_at_…, r3_rt_… (OAUTH_ACCESS_PREFIX in accounts.ts) + +const iso = (value: Date | string) => new Date(value).toISOString(); + +/** auth-system's storage interface over the readm3 SQLite database (OAUTH_SCHEMA). */ +export function sqliteOAuthStore(db: Database): OAuthStore { + const prune = () => { + const stamp = new Date().toISOString(); + db.query("DELETE FROM oauth_codes WHERE expiresAt<=?").run(stamp); + db.query("DELETE FROM oauth_tokens WHERE expiresAt<=?").run(stamp); + }; + return { + async saveCode(row) { + prune(); + db.query("INSERT INTO oauth_codes (codeHash,userId,clientId,redirectUri,codeChallenge,scope,expiresAt) VALUES (?,?,?,?,?,?,?)") + .run(row.codeHash, row.userId, row.clientId, row.redirectUri, row.codeChallenge, row.scope, iso(row.expiresAt)); + }, + async takeCode(codeHash) { + // Single use: only the request that flips `used` gets the row. + return (db.query("UPDATE oauth_codes SET used=1 WHERE codeHash=? AND used=0 RETURNING codeHash,userId,clientId,redirectUri,codeChallenge,scope,expiresAt") + .get(codeHash) as Awaited>) ?? null; + }, + async saveToken(row) { + db.query("INSERT INTO oauth_tokens (tokenHash,kind,userId,clientId,scope,familyId,expiresAt) VALUES (?,?,?,?,?,?,?)") + .run(row.tokenHash, row.kind, row.userId, row.clientId, row.scope, row.familyId, iso(row.expiresAt)); + }, + async getToken(tokenHash) { + const row = db.query("SELECT * FROM oauth_tokens WHERE tokenHash=?").get(tokenHash) as (TokenRow & { revoked: number; used: number }) | null; + return row ? { ...row, revoked: !!row.revoked, used: !!row.used } : null; + }, + async markUsed(tokenHash) { + return db.query("UPDATE oauth_tokens SET used=1 WHERE tokenHash=? AND used=0 AND revoked=0").run(tokenHash).changes === 1; + }, + async revokeFamily(familyId) { + db.query("UPDATE oauth_tokens SET revoked=1 WHERE familyId=?").run(familyId); + }, + }; +} + +const escape = (value: string) => + value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); + +const page = (title: string, body: string, status = 200) => + new Response( + `${escape(title)} · readm3 + +

${escape(title)}

${body}
`, + { + status, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "x-frame-options": "DENY", + // No form-action: Approve redirects to the CLI's loopback or /oauth/cli, and browsers apply form-action to that redirect. + "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", + }, + }, + ); + +const json = (body: unknown, status = 200) => + Response.json(body, { status, headers: { "cache-control": "no-store", pragma: "no-cache" } }); + +async function formBody(request: Request): Promise> { + const type = request.headers.get("content-type") ?? ""; + if (type.includes("application/json")) { + const value = await request.json().catch(() => ({})); + return value && typeof value === "object" ? (value as Record) : {}; + } + return Object.fromEntries(new URLSearchParams(await request.text())); +} + +const AUTHORIZE_FIELDS = ["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "state", "scope"] as const; + +export function createOAuth(accounts: Accounts) { + const origin = accounts.origin; + const oauth = createOAuthServer({ + store: sqliteOAuthStore(accounts.db), + issuer: origin, + tokenPrefix: TOKEN_PREFIX, + clients: { + [CLI_CLIENT_ID]: { + name: "readm3 CLI", + // 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`], + }, + }, + }); + + return async (request: Request): Promise => { + const url = new URL(request.url); + const path = url.pathname; + if (path === "/.well-known/oauth-authorization-server" && request.method === "GET") + return Response.json(oauth.metadata(), { headers: { "cache-control": "public, max-age=3600", "access-control-allow-origin": "*" } }); + if (!path.startsWith("/oauth/")) return null; + + if (path === "/oauth/token" || path === "/oauth/revoke") { + if (request.method !== "POST") return json({ error: "invalid_request", error_description: "Use POST." }, 405); + const body = await formBody(request); + try { + if (path === "/oauth/revoke") { + await oauth.revoke(body.token); + return json({}); + } + return json(await oauth.token(body)); + } catch (error) { + if (error instanceof OAuthError) return json(error.toJSON(), error.status); + throw error; + } + } + + if (path === "/oauth/cli" && request.method === "GET") { + const error = url.searchParams.get("error"); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (error || !code || !state) + return page("Sign-in cancelled", "

Nothing was shared with the CLI. Run readm3 login again to start over.

", 400); + return page( + "Paste this code into your terminal", + `

The terminal running readm3 login is waiting for it.

${escape(`${code}#${state}`)}

It works once and expires in 5 minutes.

`, + ); + } + + if (path === "/oauth/authorize") { + const fields = request.method === "POST" ? await formBody(request) : Object.fromEntries(url.searchParams); + let params; + try { + params = oauth.validateAuthorize(fields); + } catch (error) { + // Never redirect to an unverified redirect_uri: show the problem here instead. + if (error instanceof OAuthError) return page("This sign-in link is not valid", `

${escape(error.message)}.

`, 400); + throw error; + } + const user = accounts.authenticate(accounts.token(request)); + + if (request.method === "GET") { + if (!user) { + const next = `${path}${url.search}`; + return Response.redirect(`${origin}/account?next=${encodeURIComponent(next)}`, 303); + } + const hidden = AUTHORIZE_FIELDS.filter((name) => fields[name] !== undefined) + .map((name) => ``) + .join(""); + const who = user.email ?? user.displayName; + return page( + `Sign in to ${params.clientName}?`, + `

${escape(params.clientName)} will be able to read and edit your readm3 documents as ${escape(who)}.

+

Only approve if you just ran readm3 login yourself.

+
${hidden}
`, + ); + } + + if (request.method === "POST") { + // Same-origin form posts only; the session cookie is SameSite=Lax as well. + if (request.headers.get("origin") !== origin) return page("Request refused", "

Approve from the readm3 page itself.

", 403); + if (!user) return Response.redirect(`${origin}/account`, 303); + const target = + fields.decision === "approve" + ? await oauth.approve({ userId: user.id, clientId: params.clientId, redirectUri: params.redirectUri, codeChallenge: params.codeChallenge, scope: params.scope, state: params.state }) + : oauth.denyUrl(params); + return new Response(null, { status: 303, headers: { location: target, "cache-control": "no-store" } }); + } + } + return null; + }; +} diff --git a/server/store.ts b/server/store.ts index 0f5f643..572d81c 100644 --- a/server/store.ts +++ b/server/store.ts @@ -2,7 +2,14 @@ import { Database } from "bun:sqlite"; import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; -import { IDENTITY_SCHEMA, VERIFIED_IDENTITY } from "./accounts.ts"; +import { + IDENTITY_SCHEMA, + isOAuthAccessToken, + OAUTH_ACCESS_JOIN, + OAUTH_ACCESS_WHERE, + OAUTH_SCHEMA, + VERIFIED_IDENTITY, +} from "./accounts.ts"; export type User = { id: string; @@ -86,6 +93,7 @@ export class Store { CREATE INDEX IF NOT EXISTS shares_document ON shares(documentId); CREATE INDEX IF NOT EXISTS sessions_user ON sessions(userId); ${IDENTITY_SCHEMA} + ${OAUTH_SCHEMA} `); // Columns added after a table first shipped. CREATE TABLE IF NOT EXISTS leaves an // existing table alone, so a live database gets them here, once. @@ -111,6 +119,15 @@ export class Store { return u; } authenticate(token: string): User | null { + // An access token from `readm3 login` (OAuth 2.1, server/oauth.ts). + if (isOAuthAccessToken(token)) { + const row = this.get<{ userId: string }>( + `SELECT t.userId FROM ${OAUTH_ACCESS_JOIN} WHERE ${OAUTH_ACCESS_WHERE} AND ${VERIFIED_IDENTITY}`, + checksum(token), + now(), + ); + return row ? this.user(row.userId) : null; + } const session = this.get<{ userId: string }>( `SELECT s.userId FROM sessions s JOIN users u ON u.id=s.userId WHERE s.tokenHash=? AND s.expiresAt>? AND ${VERIFIED_IDENTITY}`, checksum(token), diff --git a/server/test/coinpay.test.ts b/server/test/coinpay.test.ts index 3cc2f4e..6525c41 100644 --- a/server/test/coinpay.test.ts +++ b/server/test/coinpay.test.ts @@ -1,18 +1,11 @@ import { afterEach, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; import { Accounts } from "../accounts.ts"; import { createApi } from "../api.ts"; import { CoinPay, type CoinPayOptions } from "../coinpay.ts"; import { Store, id } from "../store.ts"; import { verifiedAccount } from "./fixtures.ts"; -const root = fileURLToPath(new URL("../..", import.meta.url)); -const runtime = process.env.READM3_TEST_NODE ? "node" : "bun"; -const entry = runtime === "node" ? "bin/readm3.mjs" : "src/cli.ts"; const origin = "http://localhost"; const cleanup: (() => void)[] = []; afterEach(() => { for (const done of cleanup.splice(0).reverse()) done(); }); @@ -227,43 +220,3 @@ test("the CLI exchange turns a loopback CoinPay grant into a readm3 API token", // Codes are single use. expect((await f.call("coinpay/oauth/cli-exchange", { data: { code, code_verifier: verifier, redirect_uri: redirectUri } })).status).toBe(400); }); - -test("readm3 login --coinpay runs the loopback grant and saves a token whoami can use", async () => { - const coinpay = fakeCoinPay(); - const store = new Store(":memory:"); - cleanup.push(() => store.close()); - let api: ReturnType | undefined; - const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) { - return (await api!(request, "cli-test")) || new Response("Not found", { status: 404 }); - } }); - cleanup.push(() => server.stop(true)); - const url = `http://127.0.0.1:${server.port}`; - const accounts = new Accounts(store.db, url, async () => {}); - api = createApi(store, url, new CoinPay(store, accounts, { clientId: "readm3-test", clientSecret: "test-secret", url: coinpay.url })); - const config = mkdtempSync(join(tmpdir(), "readm3-coinpay-cli-")); - cleanup.push(() => rmSync(config, { recursive: true, force: true })); - const env = { ...process.env, READM3_CONFIG_DIR: config, READM3_URL: url, READM3_API_URL: `${url}/api/v1`, READM3_TOKEN: "", READM3_NO_BROWSER: "1" }; - const child = Bun.spawn([Bun.which(runtime)!, entry, "login", "--coinpay"], { cwd: root, env, stdout: "pipe", stderr: "pipe" }); - // Read stderr until the authorize link appears, then play CoinPay and the browser. - const reader = child.stderr.getReader(); - let printed = ""; - while (!/https?:\/\/\S+\/api\/oauth\/authorize\S+/.test(printed)) { - const chunk = await reader.read(); - if (chunk.done) throw new Error(`CLI exited early: ${printed}`); - printed += new TextDecoder().decode(chunk.value); - } - const authorize = printed.match(/https?:\/\/\S+\/api\/oauth\/authorize\S+/)![0]; - expect(new URL(authorize).searchParams.get("code_challenge_method")).toBe("S256"); - const approved = coinpay.grant({ sub: "cp_terminal", name: "Terminal" }, authorize); - expect(approved.redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/); - const landed = await fetch(`${approved.redirectUri}?code=${approved.code}&state=${approved.state}`); - expect(landed.status).toBe(200); - const [stdout, exit] = await Promise.all([new Response(child.stdout).text(), child.exited]); - expect(exit).toBe(0); - expect(JSON.parse(stdout).user.displayName).toBe("Terminal"); - expect(JSON.parse(readFileSync(join(config, "cloud.json"), "utf8")).token).toMatch(/^[\w-]{43}$/); - const whoami = Bun.spawn([Bun.which(runtime)!, entry, "whoami"], { cwd: root, env, stdout: "pipe", stderr: "pipe" }); - const [out, err, code] = await Promise.all([new Response(whoami.stdout).text(), new Response(whoami.stderr).text(), whoami.exited]); - expect(code, err).toBe(0); - expect(JSON.parse(out).identities).toMatchObject([{ provider: "coinpay", providerUserId: "cp_terminal" }]); -}, 20_000); diff --git a/server/test/oauth.test.ts b/server/test/oauth.test.ts new file mode 100644 index 0000000..ca04887 --- /dev/null +++ b/server/test/oauth.test.ts @@ -0,0 +1,146 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Accounts } from "../accounts.ts"; +import { createApi } from "../api.ts"; +import { createOAuth } from "../oauth.ts"; +import { createSyncApi } from "../sync-api.ts"; +import { Store } from "../store.ts"; +import { verifiedAccount } from "./fixtures.ts"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const runtime = process.env.READM3_TEST_NODE ? "node" : "bun"; +const entry = runtime === "node" ? "bin/readm3.mjs" : "src/cli.ts"; + +const cleanup: (() => void)[] = []; +afterEach(() => { for (const done of cleanup.splice(0).reverse()) done(); }); + +async function site() { + const store = new Store(":memory:"); + cleanup.push(() => store.close()); + let handlers: ((request: Request) => Promise)[] = []; + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) { + for (const handle of handlers) { + const response = await handle(request); + if (response) return response; + } + return new Response("Not found", { status: 404 }); + } }); + cleanup.push(() => server.stop(true)); + const url = `http://127.0.0.1:${server.port}`; + const accounts = new Accounts(store.db, url, async () => {}); + const api = createApi(store, url); + handlers = [(r) => accounts.handle(r, "test"), createOAuth(accounts), createSyncApi(store, accounts), (r) => api(r, "test")]; + const { user, token: session } = await verifiedAccount(store, "Ada"); + return { store, url, user, cookie: `readm3_session=${session}` }; +} + +function cli(url: string, config: string, args: string[]) { + const env = { ...process.env, READM3_CONFIG_DIR: config, READM3_URL: url, READM3_API_URL: `${url}/api/v1`, READM3_TOKEN: "" }; + return Bun.spawn([Bun.which(runtime)!, entry, ...args], { cwd: root, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" }); +} +async function run(url: string, config: string, args: string[]) { + const child = cli(url, config, args); + child.stdin.end(); + const [out, err, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + expect(code, err).toBe(0); + return JSON.parse(out); +} +const form = (fields: Record) => new URLSearchParams(fields).toString(); + +test("readm3 login over SSH: approve in any browser, paste the code, refresh rotates, logout revokes", async () => { + const { url, user, cookie } = await site(); + const config = mkdtempSync(join(tmpdir(), "readm3-oauth-cli-")); + cleanup.push(() => rmSync(config, { recursive: true, force: true })); + const metadata = await (await fetch(`${url}/.well-known/oauth-authorization-server`)).json(); + expect(metadata).toMatchObject({ issuer: url, code_challenge_methods_supported: ["S256"] }); + + const child = cli(url, config, ["login", "--no-browser"]); + const reader = child.stderr.getReader(); + let printed = ""; + const link = /https?:\/\/\S+\/oauth\/authorize\S+/; + while (!link.test(printed)) { + const chunk = await reader.read(); + if (chunk.done) throw new Error(`CLI exited early: ${printed}`); + printed += new TextDecoder().decode(chunk.value); + } + const authorize = new URL(printed.match(link)![0]); + // The redirect is the server's paste page, never localhost: that is what broke over SSH. + expect(authorize.searchParams.get("redirect_uri")).toBe(`${url}/oauth/cli`); + expect(authorize.searchParams.get("code_challenge_method")).toBe("S256"); + + // Signed out: sign in first, then come back to this exact request. + const signedOut = await fetch(authorize, { redirect: "manual" }); + expect(signedOut.status).toBe(303); + expect(signedOut.headers.get("location")).toBe(`${url}/account?next=${encodeURIComponent(authorize.pathname + authorize.search)}`); + + const consent = await fetch(authorize, { headers: { cookie } }); + expect(consent.status).toBe(200); + expect(await consent.text()).toContain("ada@example.com"); + + const fields = { ...Object.fromEntries(authorize.searchParams), decision: "approve" }; + const headers = { cookie, origin: url, "content-type": "application/x-www-form-urlencoded" }; + // Another site cannot approve on the person's behalf. + expect((await fetch(`${url}/oauth/authorize`, { method: "POST", redirect: "manual", headers: { ...headers, origin: "https://evil.example" }, body: form(fields) })).status).toBe(403); + const approved = await fetch(`${url}/oauth/authorize`, { method: "POST", redirect: "manual", headers, body: form(fields) }); + expect(approved.status).toBe(303); + const shown = await (await fetch(approved.headers.get("location")!)).text(); + const pasted = shown.match(/([^<]+)<\/code>/)![1]!; + expect(pasted).toEndWith(`#${authorize.searchParams.get("state")}`); + child.stdin.write(`${pasted}\n`); + child.stdin.end(); + const [stdout, exit] = await Promise.all([new Response(child.stdout).text(), child.exited]); + expect(exit, printed).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ signedIn: true, user: { id: user.id } }); + + const saved = JSON.parse(readFileSync(join(config, "cloud.json"), "utf8")); + expect(saved.token).toStartWith("r3_at_"); + expect(saved.oauth.refresh_token).toStartWith("r3_rt_"); + expect((await run(url, config, ["whoami"])).user.id).toBe(user.id); + + // An expired access token is refreshed (and the refresh token rotated) on the next command. + writeFileSync(join(config, "cloud.json"), JSON.stringify({ ...saved, oauth: { ...saved.oauth, expires_at: 0 } })); + expect((await run(url, config, ["whoami"])).user.id).toBe(user.id); + const rotated = JSON.parse(readFileSync(join(config, "cloud.json"), "utf8")); + expect(rotated.token).not.toBe(saved.token); + expect(rotated.oauth.refresh_token).not.toBe(saved.oauth.refresh_token); + + // logout revokes the sign-in on the server, not just the local file. + await run(url, config, ["logout"]); + const me = await (await fetch(`${url}/api/v1/me`, { headers: { authorization: `Bearer ${rotated.token}` } })).json(); + expect(me.user).toBeNull(); +}, 30_000); + +test("a reused refresh token ends the whole sign-in", async () => { + const { url, cookie } = await site(); + const verifier = "v".repeat(64); + const challenge = new Bun.CryptoHasher("sha256").update(verifier).digest("base64url"); + const params = { response_type: "code", client_id: "readm3-cli", 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" }), + }); + // Any loopback port is accepted for the native client (RFC 8252). + const back = new URL(approved.headers.get("location")!); + expect(back.origin).toBe("http://127.0.0.1:9"); + const token = (body: Record) => + fetch(`${url}/oauth/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form({ client_id: "readm3-cli", ...body }) }); + const first = await (await token({ grant_type: "authorization_code", code: back.searchParams.get("code")!, redirect_uri: params.redirect_uri, code_verifier: verifier })).json(); + const second = await (await token({ grant_type: "refresh_token", refresh_token: first.refresh_token })).json(); + expect(second.access_token).toStartWith("r3_at_"); + const reuse = await token({ grant_type: "refresh_token", refresh_token: first.refresh_token }); + expect(reuse.status).toBe(400); + expect((await reuse.json()).error).toBe("invalid_grant"); + const me = await (await fetch(`${url}/api/v1/me`, { headers: { authorization: `Bearer ${second.access_token}` } })).json(); + expect(me.user).toBeNull(); +}); + +test("an unregistered redirect_uri is refused on the page, never redirected to", async () => { + const { url, cookie } = await site(); + const response = await fetch(`${url}/oauth/authorize?${form({ response_type: "code", client_id: "readm3-cli", redirect_uri: "https://evil.example/cb", code_challenge: "x".repeat(43), code_challenge_method: "S256", state: "s" })}`, { headers: { cookie }, redirect: "manual" }); + expect(response.status).toBe(400); + expect(response.headers.get("location")).toBeNull(); +}); diff --git a/site/account.ts b/site/account.ts index 5013384..d14d5c7 100644 --- a/site/account.ts +++ b/site/account.ts @@ -3,7 +3,7 @@ type CoinPayStatus = { enabled: boolean; identities: { providerUserId: string; e const el = (id: string) => document.getElementById(id) as T; const panels = ["loading", "sign-in", "check-email", "verify-email", "profile"]; const requestedNext = new URLSearchParams(location.search).get("next"); -const next = requestedNext && /^\/(?:admin|viewer)(?:\?|$)/.test(requestedNext) ? requestedNext : null; +const next = requestedNext && /^\/(?:admin|viewer|oauth\/authorize)(?:\?|$)/.test(requestedNext) ? requestedNext : null; let email = ""; let token: string | null = null; function readLink() { diff --git a/site/content/sharing.md b/site/content/sharing.md index 238a96b..6a5f601 100644 --- a/site/content/sharing.md +++ b/site/content/sharing.md @@ -113,12 +113,13 @@ and history in a SQLite database on persistent storage. ## CLI -Create a personal token under **/admin → API tokens**, then: +Sign in from the terminal: ```sh +readm3 login +# Approve in the browser (email or CoinPay). Over SSH, open the printed link in any +# browser and paste the code it shows. Or use a token from /admin → API tokens: readm3 login --token-stdin -# Paste your token, then press Ctrl+D. Or sign in with CoinPay in the browser: -readm3 login --coinpay readm3 orgs list readm3 share README.md --org ORGANIZATION_ID readm3 share proposal.md --org ORGANIZATION_ID --role edit diff --git a/site/server.ts b/site/server.ts index e51b101..a2d6494 100644 --- a/site/server.ts +++ b/site/server.ts @@ -15,6 +15,7 @@ import { createApi } from "../server/api.ts"; import { anthropicSummarizer, Billing, DEFAULT_MODEL, type Summarize } from "../server/billing.ts"; import { Payments, paymentOptions, type PaymentOptions } from "../server/payments.ts"; import { CoinPay } from "../server/coinpay.ts"; +import { createOAuth } from "../server/oauth.ts"; import { createSyncApi } from "../server/sync-api.ts"; const dist = join(dirname(fileURLToPath(import.meta.url)), "dist"); @@ -126,6 +127,8 @@ export function serveSite(options: { accounts?: Accounts; coinpay?: ConstructorP const payments = new Payments(store, billing, options.payments ?? paymentOptions()); const api = createApi(store, accounts.origin, new CoinPay(store, accounts, options.coinpay), { billing, payments }); const syncApi = createSyncApi(store, accounts); + // `readm3 login`: OAuth 2.1 for the CLI (server/oauth.ts). + const oauth = createOAuth(accounts); return Bun.serve({ port: listenPort, hostname: options.hostname ?? "0.0.0.0", @@ -158,6 +161,8 @@ export function serveSite(options: { accounts?: Accounts; coinpay?: ConstructorP : server.requestIP(request)?.address || "unknown"; const accountResponse = await accounts.handle(request, ip); if (accountResponse) return accountResponse; + const oauthResponse = await oauth(request); + if (oauthResponse) return oauthResponse; const syncResponse = await syncApi(request); if (syncResponse) return syncResponse; const documentResponse = await api(request, ip); diff --git a/src/account.ts b/src/account.ts index 95ca3bf..b5715be 100644 --- a/src/account.ts +++ b/src/account.ts @@ -1,6 +1,7 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { getAccessToken, type SavedTokens, type TokenStore } from "@profullstack/auth-system/cli"; export interface AccountUser { id: string; username: string; displayName: string } export interface Credentials { api: string; token: string; user: AccountUser; expiresAt?: string } @@ -18,6 +19,32 @@ export function writeConfig(name: string, value: unknown, root = configDir()) { renameSync(temp, path); chmodSync(path, 0o600); } +type SavedConfig = Record & { token?: string; oauth?: Omit }; +const readSaved = (root: string): SavedConfig => { + const path = join(root, "cloud.json"); + return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) as SavedConfig : {}; +}; +/** cloud.json as auth-system's token store: `token` is the access token, `oauth` holds the refresh state. */ +export function oauthStore(root = configDir()): TokenStore { + return { + async load() { + const saved = readSaved(root); + return saved.oauth && saved.token ? { ...saved.oauth, access_token: saved.token } as SavedTokens : null; + }, + async save({ access_token, ...oauth }) { writeConfig("cloud.json", { ...readSaved(root), token: access_token, oauth }, root); }, + async clear() { rmSync(join(root, "cloud.json"), { force: true }); }, + }; +} +// One refresh at a time: refresh tokens rotate, and presenting a used one revokes the sign-in. +let refreshing: Promise | undefined; +/** `token`, or its refreshed replacement when it is the saved `readm3 login` token and about to expire. */ +export async function freshToken(token: string | undefined, root = configDir()): Promise { + if (!token || process.env.READM3_TOKEN) return token; + const saved = readSaved(root); + if (!saved.oauth || saved.token !== token) return token; + refreshing ??= getAccessToken({ store: oauthStore(root) }).finally(() => { refreshing = undefined; }); + return (await refreshing) ?? token; +} export function storedCredentials(root = configDir()): Credentials | undefined { const path = join(root, "cloud.json"); if (!existsSync(path)) return; @@ -44,7 +71,7 @@ export function accountClient(options: { api: string; token?: string; fetchImpl? } export async function credentials(root = configDir()): Promise { const saved = storedCredentials(root); - const token = process.env.READM3_TOKEN || saved?.token; + const token = process.env.READM3_TOKEN || await freshToken(saved?.token, root); if (!token) throw new Error("Sign in with `readm3 login` first, or set READM3_TOKEN and READM3_API_URL."); // Never send a saved token to a different server because an environment variable changed. const api = apiUrl(process.env.READM3_API_URL || (process.env.READM3_URL ? `${process.env.READM3_URL.replace(/\/+$/, "")}/api/v1` : saved?.api)); diff --git a/src/auth-system.d.ts b/src/auth-system.d.ts new file mode 100644 index 0000000..f7b9246 --- /dev/null +++ b/src/auth-system.d.ts @@ -0,0 +1,96 @@ +// @profullstack/auth-system ships plain JavaScript; these are the parts readm3 uses. + +declare module "@profullstack/auth-system/cli" { + export interface SavedTokens { + access_token: string; + refresh_token?: string; + expires_at: number; + issuer?: string; + client_id?: string; + token_endpoint?: string; + revocation_endpoint?: string; + [key: string]: unknown; + } + export interface TokenStore { + load(): Promise; + save(data: SavedTokens): Promise; + clear(): Promise; + } + export function login(options: { + issuer: string; + clientId: string; + store?: TokenStore; + scope?: string; + manual?: boolean; + manualRedirectUri?: string; + open?: (url: string) => unknown; + log?: (message: string) => void; + input?: NodeJS.ReadableStream; + fetch?: typeof fetch; + timeoutMs?: number; + }): Promise; + export function getAccessToken(options: { store: TokenStore; fetch?: typeof fetch; skewMs?: number }): Promise; + export function logout(options: { store: TokenStore; fetch?: typeof fetch }): Promise; + export function hasDisplay(): boolean; +} + +declare module "@profullstack/auth-system/oauth2" { + export interface CodeRow { + codeHash: string; + userId: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + scope: string; + expiresAt: Date | string; + } + export interface TokenRow { + tokenHash: string; + kind: "access" | "refresh"; + userId: string; + clientId: string; + scope: string; + familyId: string; + expiresAt: Date | string; + revoked?: boolean | number; + used?: boolean | number; + } + export interface OAuthStore { + saveCode(row: CodeRow): Promise; + takeCode(codeHash: string): Promise; + saveToken(row: TokenRow): Promise; + getToken(tokenHash: string): Promise; + markUsed(tokenHash: string): Promise; + revokeFamily(familyId: string): Promise; + } + export class OAuthError extends Error { + code: string; + status: number; + constructor(code: string, description?: string, status?: number); + toJSON(): { error: string; error_description: string }; + } + export interface AuthorizeRequest { + clientId: string; + clientName: string; + redirectUri: string; + codeChallenge: string; + scope: string; + state: string; + } + export interface OAuthServer { + validateAuthorize(query: Record): AuthorizeRequest; + approve(request: Omit & { userId: string }): Promise; + denyUrl(request: { redirectUri: string; state: string }): string; + token(body: Record): Promise>; + verifyAccessToken(bearer: string): Promise<{ userId: string; clientId: string; scope: string } | null>; + revoke(token: string | undefined): Promise; + metadata(paths?: { authorizePath?: string; tokenPath?: string; revokePath?: string }): Record; + } + export function createOAuthServer(options: { + store: OAuthStore; + clients: Record; + issuer: string; + tokenPrefix?: string; + scopes?: string[]; + }): OAuthServer; +} diff --git a/src/cli.ts b/src/cli.ts index 6d9de8d..6dd2fdb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import { run, type ViewerOptions } from "./viewer.ts"; import { isCloudCommand, cloudMain, CLOUD_USAGE } from "./cloud-cli.ts"; import { isDesktopCommand, desktopMain, installedDesktop, prefersDesktop, DESKTOP_USAGE } from "./desktop-cli.ts"; -export const VERSION = "0.13.2"; +export const VERSION = "0.14.0"; /** The help text, exported so a test can hold it to the flags it documents. */ export const USAGE = `readm3 — a plain text editor for Markdown and code diff --git a/src/cloud-cli.ts b/src/cloud-cli.ts index 7df2f68..d4a242d 100644 --- a/src/cloud-cli.ts +++ b/src/cloud-cli.ts @@ -1,3 +1,5 @@ +import { login as oauthLogin, logout as oauthLogout } from "@profullstack/auth-system/cli"; +import { oauthStore } from "./account.ts"; import { pasteLocation } from "./cloud-client.ts"; import { readFileSync } from "node:fs"; import { basename } from "node:path"; @@ -11,9 +13,11 @@ import { sharedLocation, } from "./cloud-client.ts"; export const CLOUD_USAGE = `Shared Markdown - readm3 login --token-stdin Save a personal API token securely - readm3 login --coinpay Sign in with CoinPay in the browser; saves a token - readm3 logout Remove the local token + readm3 login Sign in in the browser (email or CoinPay). Over SSH, + open the printed link anywhere and paste the code back. + readm3 login --no-browser Always use the copy-and-paste code + readm3 login --token-stdin Save a personal API token from /admin instead + readm3 logout Sign out here and end this CLI sign-in readm3 share FILE [--org ID] [--role view|edit] [--force] readm3 docs list [--org ID] [--search TEXT] [--limit N] [--offset N] readm3 docs get ID [--raw] @@ -79,7 +83,7 @@ export async function cloudMain(argv: string[]) { const value = argv[i]!; if (value.startsWith("--")) { const name = value.slice(2); - if (["raw", "token-stdin", "json", "coinpay", "force"].includes(name)) flags[name] = "true"; + if (["raw", "token-stdin", "json", "coinpay", "no-browser", "force"].includes(name)) flags[name] = "true"; else { if (!argv[i + 1] || argv[i + 1]!.startsWith("--")) throw new Error(`${value} needs a value.`); @@ -108,17 +112,29 @@ export async function cloudMain(argv: string[]) { return value; }; if (command === "login") { - if (!flags["token-stdin"] && !flags.coinpay) - throw new Error( - "Use --coinpay to sign in with CoinPay, or --token-stdin and paste the API token, then end input (Ctrl+D). Tokens are created at /admin.", - ); - const token = flags.coinpay - ? await (await import("./coinpay-login.ts")).coinpayLogin(cloudConfig("-")) - : readFileSync(0, "utf8").trim(); - if (!token) throw new Error("No token supplied."); - const config = cloudConfig(token); + if (flags["token-stdin"]) { + const token = readFileSync(0, "utf8").trim(); + if (!token) throw new Error("No token supplied."); + const config = cloudConfig(token); + const user = await cloudAction("account_me", {}, config); + saveCloudConfig(config); + output({ signedIn: true, user }); + return; + } + // OAuth 2.1 (@profullstack/auth-system/cli): a loopback redirect when a browser can + // open on this machine, otherwise the server shows a code to paste back here. + // --coinpay is the same sign-in: the browser step offers CoinPay. + const server = cloudConfig("-"); + const tokens = await oauthLogin({ + issuer: server.url, + clientId: "readm3-cli", + manualRedirectUri: `${server.url}/oauth/cli`, + ...(flags["no-browser"] || process.env.READM3_NO_BROWSER ? { manual: true } : {}), + }); + const config = cloudConfig(tokens.access_token); const user = await cloudAction("account_me", {}, config); saveCloudConfig(config); + await oauthStore().save(tokens); output({ signedIn: true, user }); return; } @@ -154,10 +170,13 @@ export async function cloudMain(argv: string[]) { return; } if (command === "logout") { + // A `readm3 login` sign-in is revoked on the server; a pasted /admin token is only forgotten. + const signedIn = await oauthStore().load(); + if (signedIn) await oauthLogout({ store: oauthStore() }); clearCloudConfig(); output({ signedOut: true, - note: "Revoke the token in /admin to invalidate other copies.", + ...(signedIn ? {} : { note: "Revoke the token in /admin to invalidate other copies." }), }); return; } diff --git a/src/cloud-client.ts b/src/cloud-client.ts index 5484364..cafe20b 100644 --- a/src/cloud-client.ts +++ b/src/cloud-client.ts @@ -4,7 +4,7 @@ import { unlinkSync, } from "node:fs"; import { join } from "node:path"; -import { apiUrl, configDir, writeConfig } from "./account.ts"; +import { apiUrl, configDir, freshToken, writeConfig } from "./account.ts"; export interface CloudConfig { url: string; api?: string; @@ -41,10 +41,11 @@ export async function cloudRequest( body?: unknown, config = cloudConfig(), ): Promise { + const token = await freshToken(config.token); const response = await fetch(`${config.api || `${config.url}/api/v1`}/${path}`, { method, headers: { - ...(config.token ? { authorization: `Bearer ${config.token}` } : {}), + ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }), }, body: body === undefined ? undefined : JSON.stringify(body), @@ -62,7 +63,7 @@ export async function cloudAction( ): Promise { if (!config.token) throw new Error( - "Sign in at readm3.com/admin, create an API token, and run readm3 login --token-stdin (or set READM3_TOKEN).", + "Run readm3 login to sign in (or set READM3_TOKEN).", ); return cloudRequest("actions", "POST", { operation, args }, config); } diff --git a/src/coinpay-login.ts b/src/coinpay-login.ts deleted file mode 100644 index 21946a2..0000000 --- a/src/coinpay-login.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * `readm3 login --coinpay`: an OAuth 2.1 authorization code grant with PKCE, - * started here and finished on a loopback redirect (CoinPay accepts any port on - * 127.0.0.1). The CLI never sees a client secret: it hands the code and its - * verifier to readm3, which makes the exchange and answers with an ordinary - * readm3 API token. - */ -import { spawn } from "node:child_process"; -import { createHash, randomBytes } from "node:crypto"; -import { createServer } from "node:http"; -import { cloudRequest, type CloudConfig } from "./cloud-client.ts"; - -const WAIT_MS = 5 * 60_000; - -function openBrowser(url: string) { - const [command, ...args] = - process.platform === "darwin" - ? ["open", url] - : process.platform === "win32" - ? ["cmd", "/c", "start", "", url] - : ["xdg-open", url]; - try { - const child = spawn(command!, args, { stdio: "ignore", detached: true }); - child.on("error", () => {}); - child.unref(); - } catch { - /* The link is printed; opening it is a convenience. */ - } -} - -const page = (message: string) => - `readm3

${message}

`; - -/** Returns a readm3 API token for the CoinPay account the person signs in with. */ -export async function coinpayLogin(config: CloudConfig): Promise { - const anonymous = { ...config, token: undefined }; - const status = await cloudRequest<{ - enabled: boolean; - clientId?: string; - coinpayUrl?: string; - scopes?: string; - }>("coinpay/status", "GET", undefined, anonymous); - if (!status.enabled || !status.clientId || !status.coinpayUrl) - throw new Error("This readm3 server does not offer Sign in with CoinPay."); - const verifier = randomBytes(32).toString("base64url"); - const state = randomBytes(24).toString("base64url"); - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const port = (server.address() as { port: number }).port; - const redirectUri = `http://127.0.0.1:${port}/callback`; - try { - const code = await new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("Timed out waiting for CoinPay. Run readm3 login --coinpay again.")), - WAIT_MS, - ); - server.on("request", (request, response) => { - const url = new URL(request.url ?? "/", redirectUri); - if (url.pathname !== "/callback") { - response.writeHead(404).end(); - return; - } - const finish = (ok: boolean, message: string) => { - response - .writeHead(ok ? 200 : 400, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }) - .end(page(message)); - clearTimeout(timer); - }; - if (url.searchParams.get("state") !== state) { - // Not ours: someone else's tab or a stale link. Keep waiting for the real one. - response.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(page("This sign-in link does not belong to the waiting readm3 CLI.")); - return; - } - const error = url.searchParams.get("error"); - const code = url.searchParams.get("code"); - if (error || !code) { - finish(false, "CoinPay sign-in did not complete. You can close this tab."); - reject(new Error(`CoinPay sign-in failed: ${url.searchParams.get("error_description") || error || "no code returned"}.`)); - return; - } - finish(true, "Signed in to readm3 with CoinPay. You can close this tab and return to the terminal."); - resolve(code); - }); - const authorize = `${status.coinpayUrl}/api/oauth/authorize?${new URLSearchParams({ - client_id: status.clientId!, - redirect_uri: redirectUri, - response_type: "code", - scope: status.scopes || "openid profile email", - state, - code_challenge: createHash("sha256").update(verifier).digest("base64url"), - code_challenge_method: "S256", - })}`; - process.stderr.write(`Sign in with CoinPay in your browser. If it does not open, visit:\n${authorize}\n`); - if (!process.env.READM3_NO_BROWSER) openBrowser(authorize); - }); - const result = await cloudRequest<{ token: string }>( - "coinpay/oauth/cli-exchange", - "POST", - { code, code_verifier: verifier, redirect_uri: redirectUri }, - anonymous, - ); - return result.token; - } finally { - server.closeAllConnections?.(); - server.close(); - } -}