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/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 }; 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);