openvibers/openvibe.network Commits
Account chooser: the app name comes from GET /oauth/client-info (client_id + registered redirect_uri), never from the page URL — a crafted link could claim to be any app; third-party apps also show where the code goes
Changes
@@@ -392,7 +392,22 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Interconst API_BASE = window.location.origin;const params = new URLSearchParams(window.location.search);const isOAuth = params.has('client_id') && params.has('redirect_uri');--const clientName = params.get('client_name') || params.get('client_id') || '';++// The app's name comes from the Network (GET /oauth/client-info), never from this page's URL:++// a crafted link could otherwise claim to be any app. Until it answers, show nothing.++let clientName = '';++let clientHost = '';++if (isOAuth) {++ fetch(`${API_BASE}/oauth/client-info?${new URLSearchParams({ client_id: params.get('client_id'), redirect_uri: params.get('redirect_uri'),++ code_challenge: params.get('code_challenge') || '', code_challenge_method: params.get('code_challenge_method') || '' })}`, { credentials: 'omit' })++ .then(r => (r.ok ? r.json() : null))++ .then((info) => {++ if (!info || !info.name) return;++ clientName = info.third_party ? `${info.name} (third-party app)` : info.name;++ clientHost = info.third_party ? info.redirect_host || '' : '';++ renderChooserSubtitle();++ })++ .catch(() => {});++}let oauthConfirmInFlight = false;// Generate particles@@@ -559,19 +574,24 @@ function showResetForm(token) {}// ── Render Account Chooser ──────────────────────────────────++function renderChooserSubtitle() {++ const subtitle = document.getElementById('chooser-subtitle');++ if (!subtitle) return;++ if (isOAuth && clientName) {++ subtitle.innerHTML = `Choose an account to continue to <span class="app-name">${escapeHtml(clientName)}</span>`++ + (clientHost ? ` <span class="app-host">(${escapeHtml(clientHost)})</span>` : '');++ } else {++ subtitle.textContent = 'Choose an account to continue';++ }++}++function renderChooser() {const accounts = getSavedAccounts();const list = document.getElementById('account-list');const activeId = localStorage.getItem('openvibe_active_account');list.innerHTML = '';-- // Set subtitle-- const subtitle = document.getElementById('chooser-subtitle');-- if (isOAuth && clientName) {-- subtitle.innerHTML = `Choose an account to continue to <span class="app-name">${escapeHtml(clientName)}</span>`;-- } else {-- subtitle.textContent = 'Choose an account to continue';-- }++ renderChooserSubtitle();accounts.forEach(account => {const item = document.createElement('div');@@@ -117,6 +117,31 @@ router.get('/authorize', (req, res) => {res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);});++// ── GET /oauth/client-info ──────────────────────────────────++// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a++// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted++// link could claim to be any app. redirect_host is shown beside it: where the code will go.++router.get('/client-info', (req, res) => {++ const db = getDb(req);++ res.set('Cache-Control', 'no-store');++ const { client_id, redirect_uri } = req.query;++ if (!client_id || !redirect_uri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });++ let host = '';++ try { host = new URL(String(redirect_uri)).host; } catch { return res.status(400).json({ error: 'Invalid redirect_uri' }); }++ if (devTokens.isAppClient(client_id)) {++ const found = devTokens.checkAuthorizeRequest(db, req.query);++ if (found.error && !found.pkce) return res.status(404).json({ error: found.error });++ const app = found.app || null;++ if (!app) return res.status(404).json({ error: 'Unknown client_id' });++ return res.json({ name: app.name, third_party: true, redirect_host: host });++ }++ const client = db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));++ if (!client) return res.status(404).json({ error: 'Unknown client_id' });++ const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());++ if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });++ res.json({ name: client.name || String(client_id), third_party: false, redirect_host: host });++});++// ── POST /oauth/confirm ─────────────────────────────────────// Called from the account chooser UI. Accepts an openvibe.network JWT// token and OAuth params, validates everything, issues an@@@ -251,6 +251,15 @@ const secretsSeen = [];a = await authorize({ code_challenge: challenge, code_challenge_method: 'S256' });assert.strictEqual(a.status, 302);assert.match(new URL(a.headers.get('location')).searchParams.get('client_name'), /third-party app/);++ // The chooser's app name comes from the Network, by client_id and a registered redirect only.++ const info = (q) => fetch(`${base}/oauth/client-info?${new URLSearchParams(q)}`).then(async x => ({ status: x.status, body: await x.json() }));++ let ci = await info({ client_id: B, redirect_uri: REDIRECT, code_challenge: challenge, code_challenge_method: 'S256' });++ assert.strictEqual(ci.status, 200);++ assert.strictEqual(ci.body.third_party, true);++ assert.strictEqual(ci.body.redirect_host, new URL(REDIRECT).host);++ assert.ok(ci.body.name && !/OpenVibe Official/.test(ci.body.name));++ ci = await info({ client_id: B, redirect_uri: 'https://evil.example.com/cb', client_name: 'OpenVibe Official' });++ assert.strictEqual(ci.status, 404, 'no name for an unregistered redirect');a = await authorize({ code_challenge: challenge, code_challenge_method: 'S256', prompt: 'none' });assert.strictEqual(new URL(a.headers.get('location')).searchParams.get('error'), 'interaction_required', 'no silent codes for apps');const confirm = (who, extra = {}) => fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' },
@@ -392,7 +392,22 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -392,7 +392,22 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
const API_BASE = window.location.origin;
const API_BASE = window.location.origin;
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(window.location.search);
const isOAuth = params.has('client_id') && params.has('redirect_uri');
const isOAuth = params.has('client_id') && params.has('redirect_uri');
-const clientName = params.get('client_name') || params.get('client_id') || '';
+// The app's name comes from the Network (GET /oauth/client-info), never from this page's URL:
+// a crafted link could otherwise claim to be any app. Until it answers, show nothing.
+let clientName = '';
+let clientHost = '';
+if (isOAuth) {
+ fetch(`${API_BASE}/oauth/client-info?${new URLSearchParams({ client_id: params.get('client_id'), redirect_uri: params.get('redirect_uri'),
+ code_challenge: params.get('code_challenge') || '', code_challenge_method: params.get('code_challenge_method') || '' })}`, { credentials: 'omit' })
+ .then(r => (r.ok ? r.json() : null))
+ .then((info) => {
+ if (!info || !info.name) return;
+ clientName = info.third_party ? `${info.name} (third-party app)` : info.name;
+ clientHost = info.third_party ? info.redirect_host || '' : '';
+ renderChooserSubtitle();
+ })
+ .catch(() => {});
+}
let oauthConfirmInFlight = false;
let oauthConfirmInFlight = false;
// Generate particles
// Generate particles
@@ -559,19 +574,24 @@ function showResetForm(token) {
@@ -559,19 +574,24 @@ function showResetForm(token) {
}
}
// ── Render Account Chooser ──────────────────────────────────
// ── Render Account Chooser ──────────────────────────────────
+function renderChooserSubtitle() {
+ const subtitle = document.getElementById('chooser-subtitle');
+ if (!subtitle) return;
+ if (isOAuth && clientName) {
+ subtitle.innerHTML = `Choose an account to continue to <span class="app-name">${escapeHtml(clientName)}</span>`
+ + (clientHost ? ` <span class="app-host">(${escapeHtml(clientHost)})</span>` : '');
+ } else {
+ subtitle.textContent = 'Choose an account to continue';
+ }
+}
+
function renderChooser() {
function renderChooser() {
const accounts = getSavedAccounts();
const accounts = getSavedAccounts();
const list = document.getElementById('account-list');
const list = document.getElementById('account-list');
const activeId = localStorage.getItem('openvibe_active_account');
const activeId = localStorage.getItem('openvibe_active_account');
list.innerHTML = '';
list.innerHTML = '';
- // Set subtitle
- const subtitle = document.getElementById('chooser-subtitle');
- if (isOAuth && clientName) {
- subtitle.innerHTML = `Choose an account to continue to <span class="app-name">${escapeHtml(clientName)}</span>`;
- } else {
- subtitle.textContent = 'Choose an account to continue';
- }
+ renderChooserSubtitle();
accounts.forEach(account => {
accounts.forEach(account => {
const item = document.createElement('div');
const item = document.createElement('div');
@@ -117,6 +117,31 @@ router.get('/authorize', (req, res) => {
@@ -117,6 +117,31 @@ router.get('/authorize', (req, res) => {
res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
});
});
+// ── GET /oauth/client-info ──────────────────────────────────
+// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a
+// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted
+// link could claim to be any app. redirect_host is shown beside it: where the code will go.
+router.get('/client-info', (req, res) => {
+ const db = getDb(req);
+ res.set('Cache-Control', 'no-store');
+ const { client_id, redirect_uri } = req.query;
+ if (!client_id || !redirect_uri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });
+ let host = '';
+ try { host = new URL(String(redirect_uri)).host; } catch { return res.status(400).json({ error: 'Invalid redirect_uri' }); }
+ if (devTokens.isAppClient(client_id)) {
+ const found = devTokens.checkAuthorizeRequest(db, req.query);
+ if (found.error && !found.pkce) return res.status(404).json({ error: found.error });
+ const app = found.app || null;
+ if (!app) return res.status(404).json({ error: 'Unknown client_id' });
+ return res.json({ name: app.name, third_party: true, redirect_host: host });
+ }
+ const client = db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));
+ if (!client) return res.status(404).json({ error: 'Unknown client_id' });
+ const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());
+ if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });
+ res.json({ name: client.name || String(client_id), third_party: false, redirect_host: host });
+});
+
// ── POST /oauth/confirm ─────────────────────────────────────
// ── POST /oauth/confirm ─────────────────────────────────────
// Called from the account chooser UI. Accepts an openvibe.network JWT
// Called from the account chooser UI. Accepts an openvibe.network JWT
// token and OAuth params, validates everything, issues an
// token and OAuth params, validates everything, issues an
@@ -251,6 +251,15 @@ const secretsSeen = [];
@@ -251,6 +251,15 @@ const secretsSeen = [];
a = await authorize({ code_challenge: challenge, code_challenge_method: 'S256' });
a = await authorize({ code_challenge: challenge, code_challenge_method: 'S256' });
assert.strictEqual(a.status, 302);
assert.strictEqual(a.status, 302);
assert.match(new URL(a.headers.get('location')).searchParams.get('client_name'), /third-party app/);
assert.match(new URL(a.headers.get('location')).searchParams.get('client_name'), /third-party app/);
+ // The chooser's app name comes from the Network, by client_id and a registered redirect only.
+ const info = (q) => fetch(`${base}/oauth/client-info?${new URLSearchParams(q)}`).then(async x => ({ status: x.status, body: await x.json() }));
+ let ci = await info({ client_id: B, redirect_uri: REDIRECT, code_challenge: challenge, code_challenge_method: 'S256' });
+ assert.strictEqual(ci.status, 200);
+ assert.strictEqual(ci.body.third_party, true);
+ assert.strictEqual(ci.body.redirect_host, new URL(REDIRECT).host);
+ assert.ok(ci.body.name && !/OpenVibe Official/.test(ci.body.name));
+ ci = await info({ client_id: B, redirect_uri: 'https://evil.example.com/cb', client_name: 'OpenVibe Official' });
+ assert.strictEqual(ci.status, 404, 'no name for an unregistered redirect');
a = await authorize({ code_challenge: challenge, code_challenge_method: 'S256', prompt: 'none' });
a = await authorize({ code_challenge: challenge, code_challenge_method: 'S256', prompt: 'none' });
assert.strictEqual(new URL(a.headers.get('location')).searchParams.get('error'), 'interaction_required', 'no silent codes for apps');
assert.strictEqual(new URL(a.headers.get('location')).searchParams.get('error'), 'interaction_required', 'no silent codes for apps');
const confirm = (who, extra = {}) => fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' },
const confirm = (who, extra = {}) => fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' },