From 5eabb6dceb31bf14c7a456baa3b6c8c31a20056b Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 03:23:31 +0000 Subject: [PATCH 1/2] fix(auth): extension CoinPay sign-in never picked up the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "Sign in with CoinPay" in the extension's options page signed the WEBSITE in and left the extension signed out. coinpay-auth.js asked the backend to redirect back to chrome.identity.getRedirectURL() -> https://.chromiumapp.org/. That target is off-origin, so safeRedirect() rejected it (correctly — the session token rides in the redirect fragment) and /api/auth/coinpay/callback fell through to `c.redirect(`${APP_URL}/?signedin=1`)`. The OAuth dance completed, tronbrowser.dev got its tb_session cookie, and the extension got nothing. (Ungoogled Chromium also rewrites chromiumapp.org to a non-resolving .qjz9zk host, so launchWebAuthFlow could not have worked either.) bittorrented.js already worked around this with ext-callback; CoinPay never got the same treatment. - Add GET /api/auth/ext-login: validates an on-origin callback, and when the user is ALREADY signed in on the website hands a session straight back instead of running CoinPay a second time. Otherwise forwards to the CoinPay dance preserving the callback. - coinpaySignIn() opens that URL in a tab and waits for the token, matching the bittorrented flow. Listener is registered before the tab is created so a fast redirect can't race it. - ext-callback.js now serves both flows, keyed on `?src=tb`, and background handles the generalized 'ext-token' message. - Drop the now-unused "identity" permission from the manifest. Redirect targets stay same-origin only, so a drive-by navigation to /api/auth/ext-login lands the token in a fragment the navigating site cannot read. Co-Authored-By: Claude Opus 5 (1M context) --- .../extensions/ai-sidebar/background.js | 10 ++-- .../extensions/ai-sidebar/bittorrented.js | 4 +- .../extensions/ai-sidebar/coinpay-auth.js | 54 +++++++++++++++---- .../extensions/ai-sidebar/ext-callback.js | 34 ++++++++---- .../extensions/ai-sidebar/manifest.json | 1 - services/api/src/ext-login.test.ts | 45 ++++++++++++++++ services/api/src/ext-login.ts | 37 +++++++++++++ services/api/src/index.ts | 17 ++++++ 8 files changed, 175 insertions(+), 27 deletions(-) create mode 100644 services/api/src/ext-login.test.ts create mode 100644 services/api/src/ext-login.ts diff --git a/apps/desktop/extensions/ai-sidebar/background.js b/apps/desktop/extensions/ai-sidebar/background.js index 8fbf47e..e0f4992 100644 --- a/apps/desktop/extensions/ai-sidebar/background.js +++ b/apps/desktop/extensions/ai-sidebar/background.js @@ -93,10 +93,12 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { chrome.sidePanel.open(opts).catch((err) => console.warn('sidePanel open:', err)); } - // bittorrented.com connect callback: the ext-callback content script captured - // the API token from the redirect fragment. Store it and close the tab. - if (msg?.type === 'btr-token' && msg.token) { - chrome.storage.local.set({ btrToken: msg.token }); + // Token-grant callback: the ext-callback content script captured a token from + // the redirect fragment and told us which flow it belongs to ('tbAuthToken' + // for TronBrowser sign-in, 'btrToken' for bittorrented.com). It already stored + // it — we mirror the write for safety and close the tab. + if (msg?.type === 'ext-token' && msg.token) { + chrome.storage.local.set({ [msg.key || 'btrToken']: msg.token }); if (sender.tab?.id != null) chrome.tabs.remove(sender.tab.id).catch(() => {}); } }); diff --git a/apps/desktop/extensions/ai-sidebar/bittorrented.js b/apps/desktop/extensions/ai-sidebar/bittorrented.js index c624a39..e1f20ce 100644 --- a/apps/desktop/extensions/ai-sidebar/bittorrented.js +++ b/apps/desktop/extensions/ai-sidebar/bittorrented.js @@ -1,6 +1,6 @@ // Connect a bittorrented.com account to TronBrowser via the hosted token-grant -// flow (chrome.identity). bittorrented.com/connect mints a bearer token and -// redirects back to the extension's chromiumapp.org callback with #token=... +// flow. bittorrented.com/connect mints a bearer token and redirects back to +// tronbrowser.dev/ext-callback.html with #token=... (see connect() below). // The token is stored locally (per device) and sent as `Authorization: Bearer` // to bittorrented.com's /api/v1/* endpoints (favorites, live TV, radio, podcasts). diff --git a/apps/desktop/extensions/ai-sidebar/coinpay-auth.js b/apps/desktop/extensions/ai-sidebar/coinpay-auth.js index 8df498d..6002427 100644 --- a/apps/desktop/extensions/ai-sidebar/coinpay-auth.js +++ b/apps/desktop/extensions/ai-sidebar/coinpay-auth.js @@ -1,10 +1,19 @@ // CoinPay sign-in via the TronBrowser backend (confidential OAuth client — the // client secret lives only on the server). The extension opens the backend -// login URL through chrome.identity; the backend does the CoinPay OAuth dance -// and redirects back with a TronBrowser session token. This is the login — -// never Google. Override the API base in Settings (self-hosted backend). +// login URL in a normal tab; the backend reuses your existing website session +// (or does the CoinPay OAuth dance) and redirects back to /ext-callback.html, +// where our content script hands the session token to the extension. This is +// the login — never Google. Override the API base in Settings (self-hosted). const DEFAULT_API = 'https://tronbrowser.dev'; +// Landing page for the redirect, and the storage key its content script writes. +// The path MUST stay on the API origin: the server only honors same-origin +// redirect targets (safeRedirect), and ext-callback.js is only injected into +// tronbrowser.dev/ext-callback*. +const CALLBACK_PATH = '/ext-callback.html?src=tb'; +const HANDOFF_KEY = 'tbAuthToken'; +const SIGNIN_TIMEOUT_MS = 180000; + async function apiBase() { const { syncConfig } = await chrome.storage.local.get('syncConfig'); return (syncConfig?.url || DEFAULT_API).replace(/\/$/, ''); @@ -26,14 +35,41 @@ async function storeSession(sessionToken, method) { return label; } +// Wait for the ext-callback content script to drop the session token into +// storage. Same pattern as bittorrented.js: the content script writes storage +// directly, because an MV3 service worker can drop a message while waking up. +function waitForToken() { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + chrome.storage.onChanged.removeListener(onChange); + reject(new Error('timed out — finish signing in on tronbrowser.dev')); + }, SIGNIN_TIMEOUT_MS); + function onChange(changes, area) { + if (area !== 'local' || !changes[HANDOFF_KEY]?.newValue) return; + clearTimeout(timer); + chrome.storage.onChanged.removeListener(onChange); + resolve(changes[HANDOFF_KEY].newValue); + } + chrome.storage.onChanged.addListener(onChange); + }); +} + +// We deliberately do NOT use chrome.identity.launchWebAuthFlow. Its redirect +// URL (https://.chromiumapp.org/) is off-origin, and the API's +// safeRedirect() only honors same-origin targets — so the server dropped the +// redirect and fell back to `${APP_URL}/?signedin=1`, leaving the WEBSITE +// signed in and the extension with nothing. (Ungoogled Chromium also rewrites +// chromiumapp.org to a non-resolving .qjz9zk host.) Instead we open the login +// in a tab and collect the token from our own /ext-callback.html. export async function coinpaySignIn() { const base = await apiBase(); - const redirectUri = chrome.identity.getRedirectURL(); - const url = `${base}/api/auth/coinpay/login?redirect=${encodeURIComponent(redirectUri)}`; - const redirect = await chrome.identity.launchWebAuthFlow({ url, interactive: true }); - const frag = new URL(redirect).hash.slice(1) || new URL(redirect).search.slice(1); - const sessionToken = new URLSearchParams(frag).get('token'); - if (!sessionToken) throw new Error('no session token returned'); + const redirect = `${base}${CALLBACK_PATH}`; + const url = `${base}/api/auth/ext-login?redirect=${encodeURIComponent(redirect)}`; + await chrome.storage.local.remove(HANDOFF_KEY); + const pending = waitForToken(); // listen BEFORE the tab can redirect + await chrome.tabs.create({ url }); + const sessionToken = await pending; + await chrome.storage.local.remove(HANDOFF_KEY); // handoff key is transient await storeSession(sessionToken, 'coinpay'); return true; } diff --git a/apps/desktop/extensions/ai-sidebar/ext-callback.js b/apps/desktop/extensions/ai-sidebar/ext-callback.js index 20b416a..d3bab33 100644 --- a/apps/desktop/extensions/ai-sidebar/ext-callback.js +++ b/apps/desktop/extensions/ai-sidebar/ext-callback.js @@ -1,12 +1,17 @@ -// Runs on https://tronbrowser.dev/ext-callback* — the landing page of the -// bittorrented.com "Connect" flow. Reads the API token from the URL and stores -// it. We store DIRECTLY from the content script (content scripts can use -// chrome.storage with the "storage" permission) rather than messaging the -// background — an MV3 service worker can drop a message sent while it's waking -// up, which left the token unstored. +// Runs on https://tronbrowser.dev/ext-callback* — the landing page for both +// token-grant flows: the bittorrented.com "Connect" flow, and TronBrowser's own +// CoinPay sign-in (`?src=tb`). Reads the token from the URL and stores it under +// the key that flow waits on. We store DIRECTLY from the content script (content +// scripts can use chrome.storage with the "storage" permission) rather than +// messaging the background — an MV3 service worker can drop a message sent while +// it's waking up, which left the token unstored. // -// This replaces chrome.identity.launchWebAuthFlow, whose chromiumapp.org -// callback is broken on Ungoogled Chromium (domain substitution -> .qjz9zk). +// This replaces chrome.identity.launchWebAuthFlow for both flows. Two separate +// reasons it can't be used: its chromiumapp.org callback is rewritten to a +// non-resolving .qjz9zk host by Ungoogled Chromium's domain substitution, and +// the API's safeRedirect() only honors same-origin redirect targets — so an +// off-origin chromiumapp.org callback was dropped server-side and only the +// WEBSITE ended up signed in. (function () { try { const token = @@ -14,9 +19,16 @@ new URLSearchParams(location.search).get('token'); if (!token || !chrome?.storage?.local) return; - chrome.storage.local.set({ btrToken: token }); // store directly (reliable) - chrome.runtime.sendMessage({ type: 'btr-token', token }); // also ask bg to close the tab - history.replaceState(null, '', location.pathname); // scrub the token from the URL + // `src=tb` -> TronBrowser session (CoinPay / ext-login); anything else is + // the bittorrented.com connect flow, which predates the src marker. + const key = + new URLSearchParams(location.search).get('src') === 'tb' + ? 'tbAuthToken' + : 'btrToken'; + + chrome.storage.local.set({ [key]: token }); // store directly (reliable) + chrome.runtime.sendMessage({ type: 'ext-token', key, token }); // also ask bg to close the tab + history.replaceState(null, '', location.pathname); // scrub the token from the URL const m = document.getElementById('msg'); const s = document.getElementById('sub'); diff --git a/apps/desktop/extensions/ai-sidebar/manifest.json b/apps/desktop/extensions/ai-sidebar/manifest.json index 0c464c8..94664da 100644 --- a/apps/desktop/extensions/ai-sidebar/manifest.json +++ b/apps/desktop/extensions/ai-sidebar/manifest.json @@ -15,7 +15,6 @@ "tabs", "activeTab", "scripting", - "identity", "proxy", "privacy", "notifications" diff --git a/services/api/src/ext-login.test.ts b/services/api/src/ext-login.test.ts new file mode 100644 index 0000000..fe35211 --- /dev/null +++ b/services/api/src/ext-login.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { extLoginTarget } from './ext-login.js'; + +const APP = 'https://tronbrowser.dev'; +const CALLBACK = 'https://tronbrowser.dev/ext-callback.html?src=tb'; + +describe('extLoginTarget', () => { + it('hands an existing website session straight to the extension', () => { + expect(extLoginTarget(CALLBACK, APP, true)).toEqual({ + kind: 'session', + redirect: CALLBACK, + }); + }); + + it('runs the CoinPay dance when not signed in, preserving the callback', () => { + const t = extLoginTarget(CALLBACK, APP, false); + expect(t.kind).toBe('oauth'); + if (t.kind !== 'oauth') throw new Error('expected oauth'); + expect(t.url).toBe( + `/api/auth/coinpay/login?redirect=${encodeURIComponent(CALLBACK)}`, + ); + // The nested redirect must survive one decode intact, or the second hop + // loses the callback and we regress to the website-only login. + const nested = new URL(t.url, APP).searchParams.get('redirect'); + expect(nested).toBe(CALLBACK); + }); + + it('accepts a site-relative callback', () => { + expect(extLoginTarget('/ext-callback.html?src=tb', APP, true)).toEqual({ + kind: 'session', + redirect: CALLBACK, + }); + }); + + it('rejects off-origin callbacks — including the chromiumapp.org URL that broke this', () => { + expect(extLoginTarget('https://abc.chromiumapp.org/', APP, true).kind).toBe('reject'); + expect(extLoginTarget('https://evil.com/steal', APP, true).kind).toBe('reject'); + expect(extLoginTarget('//evil.com', APP, false).kind).toBe('reject'); + }); + + it('rejects a missing callback rather than defaulting somewhere', () => { + expect(extLoginTarget(undefined, APP, true).kind).toBe('reject'); + expect(extLoginTarget('', APP, false).kind).toBe('reject'); + }); +}); diff --git a/services/api/src/ext-login.ts b/services/api/src/ext-login.ts new file mode 100644 index 0000000..72f570f --- /dev/null +++ b/services/api/src/ext-login.ts @@ -0,0 +1,37 @@ +import { safeRedirect } from './redirect.js'; + +/** + * Where /api/auth/ext-login should send the browser. + * + * The extension can't complete a chrome.identity flow: getRedirectURL() hands + * back an off-origin https://.chromiumapp.org/ URL, which safeRedirect() + * rejects (rightly — the session token rides in the redirect fragment). The + * result was a silent half-login: the OAuth dance finished, the WEBSITE got its + * tb_session cookie, and the extension got nothing. + * + * So the extension now asks for an on-origin /ext-callback.html target, where + * its content script reads the token out of the fragment. + * + * - `reject` — redirect target missing or off-origin; refuse. + * - `session` — already signed in on the website: mint a session for the + * extension and hand it straight back, no second CoinPay trip. + * - `oauth` — not signed in: run the CoinPay dance, preserving the target. + */ +export type ExtLoginTarget = + | { kind: 'reject' } + | { kind: 'session'; redirect: string } + | { kind: 'oauth'; url: string }; + +export function extLoginTarget( + rawRedirect: string | undefined | null, + appUrl: string, + signedIn: boolean, +): ExtLoginTarget { + const redirect = safeRedirect(rawRedirect, appUrl); + if (!redirect) return { kind: 'reject' }; + if (signedIn) return { kind: 'session', redirect }; + return { + kind: 'oauth', + url: `/api/auth/coinpay/login?redirect=${encodeURIComponent(redirect)}`, + }; +} diff --git a/services/api/src/index.ts b/services/api/src/index.ts index 3a14fdf..5b814de 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -13,6 +13,7 @@ import { store } from './store/routes.js'; import { swarmRoutes } from './swarm.js'; import { dnsRoutes } from './dns.js'; import { safeRedirect } from './redirect.js'; +import { extLoginTarget } from './ext-login.js'; const CP = { clientId: process.env.COINPAY_CLIENT_ID || '', @@ -58,6 +59,22 @@ app.route('/api/swarm', swarmRoutes({ currentUser })); /* ---------- DNS verifier (signed-in ops tool for /dns) ---------- */ app.route('/api/dns', dnsRoutes({ currentUser })); +/* ---------- Extension sign-in (adopts an existing website session) ---------- */ +// The browser extension calls this instead of /coinpay/login directly: it can't +// use chrome.identity (see ext-login.ts), so it passes an on-origin +// /ext-callback.html target and picks the token out of the fragment there. +// +// Safe against a drive-by navigation from another site: the minted token only +// ever lands in the fragment of a URL on OUR origin (safeRedirect enforces +// that), which the navigating site can't read. +app.get('/api/auth/ext-login', async (c) => { + const user = await currentUser(c); + const target = extLoginTarget(c.req.query('redirect'), APP_URL, !!user); + if (target.kind === 'reject') return c.text('invalid redirect', 400); + if (target.kind === 'oauth') return c.redirect(target.url); + return await startSession(c, user!.id, target.redirect); +}); + /* ---------- CoinPay OAuth (preferred) ---------- */ app.get('/api/auth/coinpay/login', (c) => { if (!CP.clientId) return c.text('CoinPay not configured', 500); From 3fc40cc6c8b09f6973c557b08a48ff138ffc0cf4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 03:30:13 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(resolve):=20unbreak=20the=20build=20?= =?UTF-8?q?=E2=80=94=20narrow=20parseRegistryName's=20destructured=20parts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI has been red on main since #48: noUncheckedIndexedAccess types the destructured `label`/`tld` as `string | undefined`, because TS can't narrow an array to a 2-tuple from a `parts.length !== 2` check. Four TS2345/TS2322 errors failed `apps/desktop` and took the whole `pnpm -r build` with it. Guard explicitly. Behavior is unchanged: length 2 already guarantees both values, and an empty label would fail LABEL.test() on the next line anyway. Unrelated to this branch's auth work, but it blocks the PR from going green. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/src/moshpit-resolve.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/desktop/src/moshpit-resolve.ts b/apps/desktop/src/moshpit-resolve.ts index ef48a00..804c501 100644 --- a/apps/desktop/src/moshpit-resolve.ts +++ b/apps/desktop/src/moshpit-resolve.ts @@ -112,6 +112,11 @@ export function parseRegistryName(hostname: string): { label: string; tld: strin const parts = host.split('.'); if (parts.length !== 2) return null; const [label, tld] = parts; + // `parts.length !== 2` above already guarantees both exist, but + // noUncheckedIndexedAccess types them as `string | undefined` — TS can't + // narrow an array to a 2-tuple from a length check. An empty label would + // fail LABEL.test() anyway, so this guard changes no behavior. + if (!label || !tld) return null; const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; if (!LABEL.test(label) || !LABEL.test(tld)) return null; return { label, tld };