openvibers/openvibe.network Commits

Sign-in works like Google's: the chooser on every interactive sign-in; switch_to and add_account bring a site back signed in as the chosen account; login_hint; return URLs from the manifests (#339)

OP OpenVibers committed 19 days ago · f84cd200d0 · 7 files, +266 −45

Signing out of a site and back in went straight through as the only saved account (maybeContinue), switch_to and
add_account were ignored (the site's session never changed), and return URLs to sites outside a hard-coded list
went home. The chooser now continues by itself only for the account chosen a moment ago in this tab or named by
login_hint (never for a third-party app); switch_to and add_account go back through the site's sign-in, which
continues as the chosen account; /oauth/authorize passes login_hint; GET /api/auth/return-origins lists the
first-party origins; discovery says prompt_values_supported. docs/sign-in.md.


Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

Co-authored-by: Claude Opus 5.5 <[email protected]>

Changes

ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex c05ae64c..6a1cdbdd 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -59,7 +59,7 @@ Apps use the same `/oauth/token` endpoint as service principals. A client id of
| confidential | `client_credentials` | `client_id`, `client_secret`, `audience`, optional `scope` (space-separated capability ids to narrow) |
| public or confidential | `authorization_code` | `client_id`, `code`, `redirect_uri`, `code_verifier`, `audience`, optional `scope`, plus `client_secret` if confidential |
--Authorization code flow: `/oauth/authorize` needs `code_challenge` with `code_challenge_method=S256` for every app, public or confidential. The redirect URI must match a registered one exactly. `prompt=none` is refused with `error=interaction_required`, so an app never gets a code without the person choosing to continue. The account chooser labels the app "(third-party app)". A sandbox app can be authorized only by members of its project. Codes are single use and expire after 5 minutes. A failed PKCE check burns the code. The `scope` passed to `/authorize` (capability ids) limits what the code can yield, and the exchange can narrow it but never widen it.
++Authorization code flow: `/oauth/authorize` needs `code_challenge` with `code_challenge_method=S256` for every app, public or confidential. The redirect URI must match a registered one exactly. `prompt=none` is refused with `error=interaction_required`, so an app never gets a code without the person choosing to continue. `prompt=select_account`, `prompt=login` and `login_hint` work as in [sign-in.md](sign-in.md); a hint only fills in the account, it never skips the consent. The account chooser labels the app "(third-party app)". A sandbox app can be authorized only by members of its project. Codes are single use and expire after 5 minutes. A failed PKCE check burns the code. The `scope` passed to `/authorize` (capability ids) limits what the code can yield, and the exchange can narrow it but never widen it.
### Claims
ddiff --git a/docs/sign-in.md b/docs/sign-in.md
nnew file mode 100644
iindex 00000000..b5401c26
---- /dev/null
++++ b/docs/sign-in.md
@@@ -0,0 +1,61 @@
++# Signing in with OpenVibe.Network
++
++OpenVibe.Network is the one account system behind every OpenVibe site, and any app can use it. It is a standard
++OAuth 2.0 / OpenID Connect provider:
++
++| What | Where |
++|---|---|
++| Discovery | `https://openvibe.network/.well-known/openid-configuration` |
++| Authorize | `https://openvibe.network/oauth/authorize` (code flow, PKCE S256) |
++| Token | `https://openvibe.network/oauth/token` |
++| User info | `https://openvibe.network/oauth/userinfo` |
++| Keys | `https://openvibe.network/api/.well-known/jwks` |
++
++A Node site gets the whole client (routes, PKCE, cookies, offline verification) from `openvibe-sdk/sso`. Third-party
++apps register in the developer console ([developer-projects.md](developer-projects.md)).
++
++## The account chooser
++
++A browser can be signed in to several accounts at once, the way Google's sign-in works. `/oauth/authorize` sends an
++interactive sign-in to the chooser (`/login`), which lists this browser's accounts, with **Use another account**,
++**Create a new account** and **Forgot password**. Picking an account sends the app its code.
++
++The chooser **does not continue by itself** on an interactive sign-in, even with one account saved. Signing out of a
++site and pressing Sign in again shows the chooser. It continues without a click only:
++
++- for the account the person chose a moment ago in the same tab (a site menu's switch or "add another account" below:
++ the page remembers it for two minutes, once);
++- for the account the app names with `login_hint`, when this browser has it saved.
++
++It never continues by itself for a third-party app (the person sees its consent), when the app asks for the chooser,
++or as an anonymous account.
++
++## Parameters
++
++| Parameter | Effect |
++|---|---|
++| `prompt=none` | No page: a code for the account this browser is signed in to, or `error=login_required`. Sites use it to sign people in quietly when they open a site. Refused for third-party apps (`interaction_required`). |
++| `prompt=select_account` | The chooser, even when `login_hint` names a saved account. |
++| `prompt=login` | The sign-in form ("use another account"). |
++| `login_hint` | The account to continue as: a username (`@` optional) or an account id. Saved in this browser: continues as it. Not saved: the sign-in form with the username filled in. |
++| `nonce` | Echoed in the `id_token` (scope `openid`). |
++
++## Switching and adding accounts from a site
++
++The shared navbar's account menu works this way on every site, whatever session the site keeps, and any site or app
++can build the same links:
++
++- **Switch:** `https://openvibe.network/login?switch_to=<account id>&login_hint=<username>&return=<the site's sign-in URL>`.
++ The Network makes that account the active one (or asks for its password), then goes to `return`. The site's sign-in
++ starts an ordinary authorize, and the chooser continues as the account just chosen.
++- **Add another account:** `https://openvibe.network/login?add_account=1&return=<the site's sign-in URL>`: the sign-in
++ form, then the same way back.
++
++`return` must be an https origin of a first-party site (`GET /api/auth/return-origins` lists them: every domain in the
++service manifests). Anything else goes to the Network's home.
++
++## Signing out
++
++A site's **Sign out** ends that site's session and keeps the browser from signing back in quietly (`ov_sso_hint=guest`).
++The next Sign in shows the chooser. Signing out of OpenVibe.Network itself (openvibe.network/my) removes the account
++from this browser.
ddiff --git a/public/js/login.js b/public/js/login.js
iindex 3654e263..a1bfbe01 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -229,21 +229,51 @@ function showResetForm(token) {
setActivePanel('panel-reset', { showTabs: false, showExtras: false });
}
--// ── Straight through ────────────────────────────────────────
--// A first-party site's sign-in with one account saved in this browser (the one signed in) continues as it: nothing to
--// click, the same as the silent sign-in sites try first. The chooser stays for a third-party app (its consent), for
--// several saved accounts, and when the site asks for it (prompt=select_account or login: "use another account").
++// ── Continue as the account just chosen ─────────────────────
++// An app's sign-in shows the chooser, the way Google's does (the owner, 2026-10-26: signing out and back in "immediately
++// logs you in and doesn't allow you to choose"). Seamless sign-in across sites is the sites' silent one (prompt=none,
++// answered by /oauth/authorize without this page). The chooser continues by itself only for an account the person chose
++// a moment ago in this tab (a site menu's switch or "add another account": rememberChoice before going back through the
++// site's sign-in) or one the site names (login_hint, OpenID Connect). Never for a third-party app (its consent), never
++// when the site asks for the chooser (prompt=select_account | login), never as an anonymous account.
++const CONTINUE_KEY = 'ov_continue_as';
++const CONTINUE_MS = 2 * 60 * 1000;
++function rememberChoice(id) {
++ try { sessionStorage.setItem(CONTINUE_KEY, JSON.stringify({ id: String(id), at: Date.now() })); } catch { /* this tab only */ }
++}
++function takeChoice() {
++ try {
++ const raw = sessionStorage.getItem(CONTINUE_KEY);
++ sessionStorage.removeItem(CONTINUE_KEY);
++ const c = raw ? JSON.parse(raw) : null;
++ return c && c.id && Date.now() - Number(c.at) < CONTINUE_MS ? String(c.id) : null;
++ } catch { return null; }
++}
let autoContinued = false;
function maybeContinue(info) {
-- if (autoContinued || !isOAuth || !info || info.third_party) return;
++ if (autoContinued || !isOAuth || !info) return;
++ const chosen = takeChoice();
++ if (info.third_party) return;
if (['select_account', 'login'].includes(params.get('prompt') || '')) return;
-- const accounts = getSavedAccounts();
-- const activeId = localStorage.getItem('openvibe_active_account');
-- if (accounts.length !== 1 || accounts[0].is_anon || (activeId && String(accounts[0].id) !== String(activeId))) return;
++ const hint = String(params.get('login_hint') || '').trim().replace(/^@/, '').toLowerCase();
++ const accounts = getSavedAccounts().filter(a => !a.is_anon);
++ const account = (chosen && accounts.find(a => String(a.id) === chosen))
++ || (hint && accounts.find(a => String(a.username || '').toLowerCase() === hint || String(a.id) === hint));
++ if (!account) return;
autoContinued = true;
const subtitle = document.getElementById('chooser-subtitle');
-- if (subtitle) subtitle.textContent = `Continuing to ${info.name} as ${accounts[0].display_name || accounts[0].username}…`;
-- selectAccount(accounts[0]);
++ if (subtitle) subtitle.textContent = `Continuing to ${info.name} as ${account.display_name || account.username}…`;
++ selectAccount(account);
++}
++
++/** The sign-in form with a username filled in (login_hint, or a saved account that needs its password again). */
++function prefillUsername(name) {
++ const v = String(name || '').trim().replace(/^@/, '');
++ const u = document.querySelector('#login-form input[name="username"]');
++ if (!v || !u || u.value) return;
++ u.value = v;
++ const pw = document.querySelector('#login-form input[name="password"]');
++ if (pw) pw.focus();
}
// ── Render Account Chooser ──────────────────────────────────
@@@ -379,9 +409,11 @@ async function selectAccount(account) {
oauthConfirmInFlight = false;
}
} else {
-- // Not OAuth — just switch the active account and go home
-- toast('Welcome back, ' + (account.display_name || account.username) + '! <span class="ov-mark" data-size="16" data-static="1"></span>', 'success');
-- setTimeout(() => window.location.href = viaFanout(getReturnUrl()), 600);
++ // Not an app sign-in: this account is the active one now. Back where the person came from: a site's sign-in
++ // (its account menu) continues as it there (rememberChoice).
++ rememberChoice(account.id);
++ toast('Switched to ' + escapeHtml(account.display_name || account.username), 'success');
++ goBack(300);
}
}
@@@ -452,9 +484,29 @@ function safeReturnUrl(raw) {
}
// safe-redirect:end
++// The first-party sites this page may send people back to (?return=): the manifests' and the admin list's origins
++// (GET /api/auth/return-origins). The owned zones above stay the floor when that cannot be read.
++let returnOrigins = new Set();
++const returnOriginsReady = fetch(`${API_BASE}/api/auth/return-origins`, { credentials: 'omit' })
++ .then(r => (r.ok ? r.json() : null))
++ .then((d) => { if (d && Array.isArray(d.origins)) returnOrigins = new Set(d.origins.map(String)); })
++ .catch(() => {});
++
// Get return URL (non-OAuth)
function getReturnUrl() {
-- return safeReturnUrl(params.get('return'));
++ const raw = params.get('return');
++ const safe = safeReturnUrl(raw);
++ if (safe !== '/' || !raw || /[\\\u0000-\u001f\u007f]/.test(String(raw))) return safe;
++ try {
++ const u = new URL(String(raw));
++ if (u.protocol === 'https:' && !u.username && !u.password && returnOrigins.has(u.origin)) return u.toString();
++ } catch { /* not a URL */ }
++ return '/';
++}
++/** Leave for the return URL once the list of sites is known (at most a short wait). */
++function goBack(delayMs) {
++ const wait = new Promise(r => setTimeout(r, 1500));
++ Promise.race([returnOriginsReady, wait]).then(() => setTimeout(() => { window.location.href = viaFanout(getReturnUrl()); }, delayMs || 0));
}
// After any successful sign-in the browser runs the sign-in-everywhere chain (/sso/fanout):
@@@ -527,8 +579,9 @@ async function handleLogin(e) {
toast('Welcome back, ' + (data.user.display_name || data.user.username) + '! <span class="ov-mark" data-size="16" data-static="1"></span>');
setTimeout(() => window.location.href = viaFanout(confirmData.redirect, oauthClientId()), 800);
} else {
-- toast('Welcome back, ' + (data.user.display_name || data.user.username) + '! <span class="ov-mark" data-size="16" data-static="1"></span>');
-- setTimeout(() => window.location.href = viaFanout(getReturnUrl()), 800);
++ rememberChoice(data.user.id);
++ toast('Welcome back, ' + escapeHtml(data.user.display_name || data.user.username) + '! <span class="ov-mark" data-size="16" data-static="1"></span>');
++ goBack(800);
}
} catch (err) {
showMsg('login-msg', err.message, 'error');
@@@ -753,6 +806,30 @@ async function continueAnonymous() {
return;
}
++ // A site's account menu (the shared navbar): switch to one of this browser's accounts, then back through the site's
++ // sign-in, which continues as it. An account not saved here (or signed out) gets its sign-in, the name filled in.
++ const switchTo = params.get('switch_to');
++ if (switchTo && !isOAuth) {
++ const account = accounts.find(a => String(a.id) === String(switchTo));
++ if (account) {
++ renderChooser();
++ showChooser();
++ const subtitle = document.getElementById('chooser-subtitle');
++ if (subtitle) subtitle.textContent = `Switching to ${account.display_name || account.username}…`;
++ selectAccount(account);
++ return;
++ }
++ showAuthForm('login');
++ prefillUsername(params.get('login_hint'));
++ return;
++ }
++ // "Add another account" (a site's menu) and "use another account" (an app's prompt=login): the sign-in form.
++ if (params.get('add_account') === '1' || params.get('prompt') === 'login') {
++ showAuthForm('login');
++ prefillUsername(params.get('login_hint'));
++ return;
++ }
++
if (hasAccounts) {
// Show account chooser
renderChooser();
@@@ -762,5 +839,6 @@ async function continueAnonymous() {
document.getElementById('panel-chooser').classList.remove('active');
document.getElementById('auth-tabs').style.display = '';
document.getElementById('panel-login').classList.add('active');
++ prefillUsername(params.get('login_hint'));
}
})();
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 9458fd10..bb25c318 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -47,6 +47,14 @@ async function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonce
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt, pkce.challenge, pkce.method, nonce || null);
return code;
}
++/**
++ * OpenID Connect's login_hint: which account the site expects (a username, @ allowed, or an account id). The chooser
++ * continues as it when this browser has it saved, and fills in the sign-in form otherwise. Anything else is dropped.
++ */
++function cleanLoginHint(raw) {
++ const v = String(raw == null ? '' : raw).trim();
++ return v && v.length <= 64 && /^@?[A-Za-z0-9_.-]+$/.test(v) ? v : '';
++}
const withCode = (redirectUri, code, state) => `${redirectUri}${redirectUri.includes('?') ? '&' : '?'}code=${code}&state=${encodeURIComponent(state || '')}`;
function getConfig(req) { return req.app.locals.config; }
@@@ -75,6 +83,8 @@ router.get('/authorize', async (req, res) => {
}
const params = new URLSearchParams({ client_id, client_name: `${found.app.name} (third-party app)`, redirect_uri, response_type, scope: scope || '', state: state || '',
code_challenge: String(req.query.code_challenge), code_challenge_method: 'S256' });
++ const appHint = cleanLoginHint(req.query.login_hint); // fills in the sign-in form; a third-party app never continues by itself
++ if (appHint) params.set('login_hint', appHint);
return res.redirect(`${getConfig(req).loginUrl}/login?${params.toString()}`);
}
@@@ -123,6 +133,8 @@ router.get('/authorize', async (req, res) => {
if (nonce) loginParams.set('nonce', nonce);
// The site asked for the chooser ("use another account"): the page shows it even with one account saved.
if (['select_account', 'login'].includes(String(req.query.prompt || ''))) loginParams.set('prompt', String(req.query.prompt));
++ const hint = cleanLoginHint(req.query.login_hint);
++ if (hint) loginParams.set('login_hint', hint);
res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
});
@@@ -452,4 +464,5 @@ router.get('/userinfo', oidc.userinfo);
router.post('/userinfo', oidc.userinfo);
module.exports = router;
++module.exports.cleanLoginHint = cleanLoginHint;
module.exports.siteNameForHost = siteNameForHost;
ddiff --git a/server/auth/oidc.js b/server/auth/oidc.js
iindex d3549e5c..e0244f42 100644
---- a/server/auth/oidc.js
++++ b/server/auth/oidc.js
@@@ -38,6 +38,9 @@ function metadata(config) {
// 'none' is for public developer apps, which always use PKCE.
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
code_challenge_methods_supported: ['S256'],
++ // none: an answer without a page (login_required when nobody is signed in); select_account: the chooser even with
++ // one account; login: the sign-in form ("use another account"). login_hint names the account to continue as.
++ prompt_values_supported: ['none', 'login', 'select_account'],
claims_supported: ['iss', 'sub', 'aud', 'exp', 'iat', 'nonce', 'name', 'preferred_username', 'picture', 'subject_id', 'email', 'email_verified'],
request_parameter_supported: false,
request_uri_parameter_supported: false,
ddiff --git a/server/index.js b/server/index.js
iindex 330c6a8f..a1d8a8df 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -859,6 +859,14 @@ app.get(['/register', '/signup', '/sign-up'], (req, res) => {
q.set('tab', 'register');
res.redirect(302, `/login?${q}`);
});
++// The sites the sign-in page may send people back to after a sign-in that is not an app's (?return=, a site menu's
++// "switch account" or "add another account"): every https origin of the CORS allow-list (the service manifests'
++// domains, the admin list, the defaults). Public: the manifests are.
++app.get('/api/auth/return-origins', (req, res) => {
++ res.set('Cache-Control', 'public, max-age=300');
++ res.json({ origins: [...buildAllowedOriginsSet()].filter((o) => o.startsWith('https://')).sort() });
++});
++
app.get(['/login', '/forgot-password', '/reset-password'], (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'login.html'));
});
ddiff --git a/test/login-auto-continue.test.js b/test/login-auto-continue.test.js
iindex 1bd89b64..7934ff93 100644
---- a/test/login-auto-continue.test.js
++++ b/test/login-auto-continue.test.js
@@@ -1,52 +1,110 @@
'use strict';
--// Straight through on /login (public/js/login.js maybeContinue): a first-party site's sign-in with one account saved in
--// this browser continues as it with nothing to click; a third-party app, several accounts, another active account, an
--// anonymous one, or a site asking for the chooser (prompt=select_account | login) still shows it. And /oauth/authorize
--// passes that prompt on to the page. Runs the function itself with stand-ins for the page.
++// When /login continues by itself (public/js/login.js maybeContinue). An app's sign-in shows the chooser, the way
++// Google's does (the owner, 2026-10-26: signing out and back in "immediately logs you in and doesn't allow you to
++// choose"); seamless sign-in across sites is the sites' silent one (prompt=none, answered by /oauth/authorize without
++// this page). It continues only for the account chosen a moment ago in this tab (a site menu's switch or "add another
++// account" sets ov_continue_as before going back through the site's sign-in) or the one the site names (login_hint).
++// Never for a third-party app, never when the site asks for the chooser, never as an anonymous account. The page's
++// init sends switch_to / add_account / prompt=login to the right panel, and /oauth/authorize passes prompt and
++// login_hint on. Runs the page's own functions with stand-ins.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'login.js'), 'utf8');
--const start = js.indexOf('let autoContinued = false;');
--const fnStart = js.indexOf('function maybeContinue(info) {', start);
--assert.ok(start > 0 && fnStart > start, 'maybeContinue exists');
--let depth = 0, end = fnStart;
--for (let i = js.indexOf('{', fnStart); i < js.length; i++) {
-- if (js[i] === '{') depth++;
-- else if (js[i] === '}' && --depth === 0) { end = i + 1; break; }
++function block(from, fnName) {
++ const start = js.indexOf(from);
++ const fnStart = js.indexOf(`function ${fnName}(`, start);
++ assert.ok(start >= 0 && fnStart > start, `${fnName} exists`);
++ let depth = 0;
++ for (let i = js.indexOf('{', fnStart); i < js.length; i++) {
++ if (js[i] === '{') depth++;
++ else if (js[i] === '}' && --depth === 0) return js.slice(start, i + 1);
++ }
++ throw new Error(`unbalanced ${fnName}`);
}
--const source = js.slice(start, end);
++const source = block("const CONTINUE_KEY = 'ov_continue_as';", 'maybeContinue');
assert.match(js, /renderConsent\(info\);\n\s*maybeContinue\(info\);/, 'it runs once the Network names the app');
const me = { id: 7, username: 'goosely', display_name: 'goosely', token: 't' };
--function run({ info = { name: 'OpenVibe.Agents', third_party: false }, accounts = [me], active = '7', query = 'client_id=actor&redirect_uri=x' } = {}) {
++const alt = { id: 8, username: 'Alt', display_name: 'Alt', token: 'u' };
++function run({ info = { name: 'OpenVibe.Video', third_party: false }, accounts = [me], query = 'client_id=media-hub&redirect_uri=x', chosen = null, age = 0 } = {}) {
const picked = [];
const subtitle = { textContent: '' };
const params = new URLSearchParams(query);
-- vm.runInNewContext(`${source}; maybeContinue(info); maybeContinue(info);`, {
-- info, params, isOAuth: params.has('client_id') && params.has('redirect_uri'),
++ const session = {};
++ if (chosen != null) session.ov_continue_as = JSON.stringify({ id: String(chosen), at: Date.now() - age });
++ vm.runInNewContext(`${source}; maybeContinue(info); maybeContinue(info); this.left = sessionStorage.getItem('ov_continue_as');`, {
++ info, params, isOAuth: params.has('client_id') && params.has('redirect_uri'), Date, JSON, String, Number,
getSavedAccounts: () => accounts, selectAccount: (a) => picked.push(a.id),
-- localStorage: { getItem: (k) => (k === 'openvibe_active_account' ? active : null) },
++ sessionStorage: { getItem: (k) => (k in session ? session[k] : null), setItem: (k, v) => { session[k] = String(v); }, removeItem: (k) => { delete session[k]; } },
document: { getElementById: (id) => (id === 'chooser-subtitle' ? subtitle : null) },
});
-- return { picked, subtitle: subtitle.textContent };
++ return { picked, subtitle: subtitle.textContent, left: session.ov_continue_as || null };
}
--let r = run();
--assert.deepStrictEqual(r.picked, [7], 'one saved account, a first-party site: continues once, as it');
--assert.strictEqual(r.subtitle, 'Continuing to OpenVibe.Agents as goosely…');
--assert.deepStrictEqual(run({ active: null }).picked, [7], 'no active account recorded: the only one');
--assert.deepStrictEqual(run({ info: { name: 'ClipTool', third_party: true } }).picked, [], 'a third-party app: the person chooses (and sees its consent)');
--assert.deepStrictEqual(run({ accounts: [me, { id: 8, username: 'alt', token: 'u' }] }).picked, [], 'several accounts: the chooser');
--assert.deepStrictEqual(run({ active: '9' }).picked, [], 'another account is the active one: the chooser');
--assert.deepStrictEqual(run({ accounts: [{ ...me, is_anon: true }] }).picked, [], 'an anonymous account is never assumed');
--assert.deepStrictEqual(run({ query: 'client_id=actor&redirect_uri=x&prompt=select_account' }).picked, [], 'asked for the chooser');
--assert.deepStrictEqual(run({ query: 'client_id=actor&redirect_uri=x&prompt=login' }).picked, []);
--assert.deepStrictEqual(run({ query: 'next=/my' }).picked, [], 'not an app sign-in');
++// The bug: one saved account, signed out of the site, Sign in again: the chooser, not straight back in.
++assert.deepStrictEqual(run().picked, [], 'an app sign-in shows the chooser, even with one saved account');
++assert.deepStrictEqual(run({ accounts: [me, alt] }).picked, []);
++// Chosen a moment ago in this tab (a site menu): continues once, as it, and the mark is used up.
++let r = run({ accounts: [me, alt], chosen: 8 });
++assert.deepStrictEqual(r.picked, [8], 'the account just chosen');
++assert.strictEqual(r.subtitle, 'Continuing to OpenVibe.Video as Alt…');
++assert.strictEqual(r.left, null, 'used once');
++assert.deepStrictEqual(run({ chosen: 7, age: 3 * 60 * 1000 }).picked, [], 'a choice from minutes ago is not a choice now');
++assert.deepStrictEqual(run({ chosen: 99 }).picked, [], 'an account not saved here');
++assert.strictEqual(run({ chosen: 7, query: 'client_id=a&redirect_uri=x&prompt=select_account' }).left, null, 'asked for the chooser: the mark is dropped too');
++
++// The site names the account (OpenID Connect login_hint): a username (any case, @ allowed) or an id.
++assert.deepStrictEqual(run({ accounts: [me, alt], query: 'client_id=a&redirect_uri=x&login_hint=alt' }).picked, [8]);
++assert.deepStrictEqual(run({ accounts: [me, alt], query: 'client_id=a&redirect_uri=x&login_hint=%40goosely' }).picked, [7]);
++assert.deepStrictEqual(run({ query: 'client_id=a&redirect_uri=x&login_hint=someoneelse' }).picked, [], 'not saved here: the chooser');
++
++// Never by itself:
++assert.deepStrictEqual(run({ info: { name: 'ClipTool', third_party: true }, chosen: 7 }).picked, [], 'a third-party app: the person chooses (and sees its consent)');
++assert.deepStrictEqual(run({ chosen: 7, query: 'client_id=a&redirect_uri=x&prompt=select_account' }).picked, [], 'asked for the chooser');
++assert.deepStrictEqual(run({ chosen: 7, query: 'client_id=a&redirect_uri=x&prompt=login' }).picked, []);
++assert.deepStrictEqual(run({ accounts: [{ ...me, is_anon: true }], chosen: 7 }).picked, [], 'an anonymous account is never assumed');
++assert.deepStrictEqual(run({ chosen: 7, query: 'return=/my' }).picked, [], 'not an app sign-in');
++
++// Every way off this page to a site remembers the account, so that site's sign-in continues as it.
++const select = block('async function selectAccount(account)', 'selectAccount');
++assert.match(select, /\} else \{[\s\S]*rememberChoice\(account\.id\);[\s\S]*goBack\(/, 'switching (not an app) remembers the account and goes back');
++const login = block('async function handleLogin(e)', 'handleLogin');
++assert.match(login, /rememberChoice\(data\.user\.id\);\s*\n[^\n]*\n\s*goBack\(800\);/, 'a new sign-in (add another account) too');
++
++// The page's init: switch_to switches (or asks for that account's password), add_account and prompt=login show the form.
++const init = js.slice(js.indexOf('(function init() {'));
++assert.match(init, /const switchTo = params\.get\('switch_to'\);\s*\n\s*if \(switchTo && !isOAuth\) \{[\s\S]*selectAccount\(account\);[\s\S]*prefillUsername\(params\.get\('login_hint'\)\);/);
++assert.match(init, /params\.get\('add_account'\) === '1' \|\| params\.get\('prompt'\) === 'login'\) \{\s*\n\s*showAuthForm\('login'\);/);
++
++// Where the page may go back to: the owned zones, or any first-party origin the Network names (manifests).
++const ret = block('let returnOrigins = new Set();', 'getReturnUrl');
++const safe = block('const OV_OWNED_ZONES', 'safeReturnUrl');
++function back(raw, origins = []) {
++ const ctx = { params: new URLSearchParams({ return: raw }), URL, window: { location: { origin: 'https://openvibe.network', hostname: 'openvibe.network' } }, fetch: () => new Promise(() => {}), API_BASE: 'https://openvibe.network' };
++ vm.runInNewContext(`${safe}\n${ret.replace('let returnOrigins = new Set();', 'var returnOrigins = new Set();')}\nreturnOrigins = new Set(${JSON.stringify(origins)});\nthis.out = getReturnUrl();`, ctx);
++ return ctx.out;
++}
++assert.strictEqual(back('https://openvibe.live/api/auth/sso/login?next=%2F'), 'https://openvibe.live/api/auth/sso/login?next=%2F');
++assert.strictEqual(back('https://openvibe.video/auth/login?next=x'), '/', 'not an owned zone, the list not read yet: home');
++assert.strictEqual(back('https://openvibe.video/auth/login?next=x', ['https://openvibe.video']), 'https://openvibe.video/auth/login?next=x', 'a manifest origin');
++assert.strictEqual(back('https://evil.example/x', ['https://openvibe.video']), '/');
++assert.strictEqual(back('http://openvibe.video/x', ['https://openvibe.video']), '/', 'https only');
++assert.strictEqual(back('https://user:[email protected]/x', ['https://openvibe.video']), '/');
++assert.strictEqual(back('https://openvibe.video/x\\@evil.example', ['https://openvibe.video']), '/', 'backslashes go home');
++
++// /oauth/authorize passes the prompt and a clean login_hint on.
const routes = fs.readFileSync(path.join(__dirname, '..', 'server', 'auth', 'oauth-routes.js'), 'utf8');
assert.match(routes, /\['select_account', 'login'\]\.includes\(String\(req\.query\.prompt \|\| ''\)\)\) loginParams\.set\('prompt'/, '/oauth/authorize passes the prompt to the page');
++const { cleanLoginHint } = require('../server/auth/oauth-routes');
++assert.strictEqual(cleanLoginHint('@goosely'), '@goosely');
++assert.strictEqual(cleanLoginHint('42'), '42');
++assert.strictEqual(cleanLoginHint('a b'), '');
++assert.strictEqual(cleanLoginHint('<script>'), '');
++assert.strictEqual(cleanLoginHint('x'.repeat(65)), '');
++assert.deepStrictEqual(require('../server/auth/oidc').metadata({ jwt: { issuer: 'https://openvibe.network' }, baseUrl: 'https://openvibe.network' }).prompt_values_supported, ['none', 'login', 'select_account']);
console.log('login auto-continue: all checks passed');