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)
Changes
@@@ -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.### Claimsnnew file mode 100644@@@ -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.@@@ -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 chooserrenderChooser();@@@ -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'));}})();@@@ -47,6 +47,14 @@ async function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonceVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).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;@@@ -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,@@@ -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'));});@@@ -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');
@@ -59,7 +59,7 @@ Apps use the same `/oauth/token` endpoint as service principals. A client id of
@@ -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) |
| 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 |
| 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
### Claims
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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.
@@ -229,21 +229,51 @@ function showResetForm(token) {
@@ -229,21 +229,51 @@ function showResetForm(token) {
setActivePanel('panel-reset', { showTabs: false, showExtras: false });
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;
let autoContinued = false;
function maybeContinue(info) {
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;
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;
autoContinued = true;
const subtitle = document.getElementById('chooser-subtitle');
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 ──────────────────────────────────
// ── Render Account Chooser ──────────────────────────────────
@@ -379,9 +409,11 @@ async function selectAccount(account) {
@@ -379,9 +409,11 @@ async function selectAccount(account) {
oauthConfirmInFlight = false;
oauthConfirmInFlight = false;
}
}
} else {
} 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) {
@@ -452,9 +484,29 @@ function safeReturnUrl(raw) {
}
}
// safe-redirect:end
// 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)
// Get return URL (non-OAuth)
function getReturnUrl() {
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):
// After any successful sign-in the browser runs the sign-in-everywhere chain (/sso/fanout):
@@ -527,8 +579,9 @@ async function handleLogin(e) {
@@ -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>');
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);
setTimeout(() => window.location.href = viaFanout(confirmData.redirect, oauthClientId()), 800);
} else {
} 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) {
} catch (err) {
showMsg('login-msg', err.message, 'error');
showMsg('login-msg', err.message, 'error');
@@ -753,6 +806,30 @@ async function continueAnonymous() {
@@ -753,6 +806,30 @@ async function continueAnonymous() {
return;
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) {
if (hasAccounts) {
// Show account chooser
// Show account chooser
renderChooser();
renderChooser();
@@ -762,5 +839,6 @@ async function continueAnonymous() {
@@ -762,5 +839,6 @@ async function continueAnonymous() {
document.getElementById('panel-chooser').classList.remove('active');
document.getElementById('panel-chooser').classList.remove('active');
document.getElementById('auth-tabs').style.display = '';
document.getElementById('auth-tabs').style.display = '';
document.getElementById('panel-login').classList.add('active');
document.getElementById('panel-login').classList.add('active');
+ prefillUsername(params.get('login_hint'));
}
}
})();
})();
@@ -47,6 +47,14 @@ async function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonce
@@ -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);
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt, pkce.challenge, pkce.method, nonce || null);
return code;
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 || '')}`;
const withCode = (redirectUri, code, state) => `${redirectUri}${redirectUri.includes('?') ? '&' : '?'}code=${code}&state=${encodeURIComponent(state || '')}`;
function getConfig(req) { return req.app.locals.config; }
function getConfig(req) { return req.app.locals.config; }
@@ -75,6 +83,8 @@ router.get('/authorize', async (req, res) => {
@@ -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 || '',
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' });
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()}`);
return res.redirect(`${getConfig(req).loginUrl}/login?${params.toString()}`);
}
}
@@ -123,6 +133,8 @@ router.get('/authorize', async (req, res) => {
@@ -123,6 +133,8 @@ router.get('/authorize', async (req, res) => {
if (nonce) loginParams.set('nonce', nonce);
if (nonce) loginParams.set('nonce', nonce);
// The site asked for the chooser ("use another account"): the page shows it even with one account saved.
// 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));
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()}`);
res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
});
});
@@ -452,4 +464,5 @@ router.get('/userinfo', oidc.userinfo);
@@ -452,4 +464,5 @@ router.get('/userinfo', oidc.userinfo);
router.post('/userinfo', oidc.userinfo);
router.post('/userinfo', oidc.userinfo);
module.exports = router;
module.exports = router;
+module.exports.cleanLoginHint = cleanLoginHint;
module.exports.siteNameForHost = siteNameForHost;
module.exports.siteNameForHost = siteNameForHost;
@@ -38,6 +38,9 @@ function metadata(config) {
@@ -38,6 +38,9 @@ function metadata(config) {
// 'none' is for public developer apps, which always use PKCE.
// 'none' is for public developer apps, which always use PKCE.
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
code_challenge_methods_supported: ['S256'],
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'],
claims_supported: ['iss', 'sub', 'aud', 'exp', 'iat', 'nonce', 'name', 'preferred_username', 'picture', 'subject_id', 'email', 'email_verified'],
request_parameter_supported: false,
request_parameter_supported: false,
request_uri_parameter_supported: false,
request_uri_parameter_supported: false,
@@ -859,6 +859,14 @@ app.get(['/register', '/signup', '/sign-up'], (req, res) => {
@@ -859,6 +859,14 @@ app.get(['/register', '/signup', '/sign-up'], (req, res) => {
q.set('tab', 'register');
q.set('tab', 'register');
res.redirect(302, `/login?${q}`);
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) => {
app.get(['/login', '/forgot-password', '/reset-password'], (req, res) => {
res.sendFile(path.join(__dirname, '..', 'public', 'login.html'));
res.sendFile(path.join(__dirname, '..', 'public', 'login.html'));
});
});
@@ -1,52 +1,110 @@
@@ -1,52 +1,110 @@
'use strict';
'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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const vm = require('vm');
const vm = require('vm');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'login.js'), 'utf8');
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');
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' };
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 picked = [];
const subtitle = { textContent: '' };
const subtitle = { textContent: '' };
const params = new URLSearchParams(query);
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),
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) },
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');
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');
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');
console.log('login auto-continue: all checks passed');