Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions apps/desktop/extensions/ai-sidebar/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
}
});
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/extensions/ai-sidebar/bittorrented.js
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
54 changes: 45 additions & 9 deletions apps/desktop/extensions/ai-sidebar/coinpay-auth.js
Original file line number Diff line number Diff line change
@@ -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(/\/$/, '');
Expand All @@ -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://<ext-id>.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;
}
Expand Down
34 changes: 23 additions & 11 deletions apps/desktop/extensions/ai-sidebar/ext-callback.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
// 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 =
new URLSearchParams(location.hash.slice(1)).get('token') ||
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');
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/extensions/ai-sidebar/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"tabs",
"activeTab",
"scripting",
"identity",
"proxy",
"privacy",
"notifications"
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/moshpit-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
45 changes: 45 additions & 0 deletions services/api/src/ext-login.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
37 changes: 37 additions & 0 deletions services/api/src/ext-login.ts
Original file line number Diff line number Diff line change
@@ -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://<ext-id>.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)}`,
};
}
17 changes: 17 additions & 0 deletions services/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
Expand Down Expand Up @@ -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);
Expand Down
Loading