openvibers/openvibe.network Commits
A public first-party client that signs the person in for real (the phone app) (#641)
Changes
@@@ -141,3 +141,14 @@ NOTIFICATION_EMAIL_CRITICAL_ONLY=true# The private key stays on openvibe.network only. Other services verify# tokens OFFLINE via the JWKS endpoint (/api/.well-known/jwks).# ═══════════════════════════════════════════════════════════════++++# ═══════════════════════════════════════════════════════════════++# OpenVibe Link on Android (server/auth/app-links.js)++# ═══════════════════════════════════════════════════════════════++# The SHA-256 fingerprints of the certificate that signs the RELEASE build of network.openvibe.link, comma-separated,++# colon-hex (AA:BB:…). They are what /.well-known/assetlinks.json publishes so Android hands the https://++# openvibe.network/link/auth sign-in return to the app alone; the certificates are public values, not secrets.++#++# Leave it unset until the owner's release key exists. With no fingerprint, Android verifies nothing, /link/auth opens++# this site's page, and OpenVibe Link gets the extension's read-only Dock token instead of a session.++# LINK_ANDROID_CERT_SHA256=AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- A public first-party app that signs the person in for real (OpenVibe Link on the phone, plan 2026-11-07 §13.2).++-- Until now every public client's token was the extension's read-only Dock token: a private issuer, its own audiences++-- and no role, so no service's normal user-token check would take it. An app that answers approvals and uploads files++-- needs the person's own session; its proof is PKCE, not a secret an APK cannot keep. 0 keeps every client as it is.++ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS session_token bigint DEFAULT 0;nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- Whether a refresh family is a session family (OpenVibe Link's sign-in through the https App Link, plan 2026-11-07++-- §13.2). The kind is decided once, when the code is exchanged: a family started through openvibe-link://auth — a++-- scheme any app can claim — must never become a session on a later refresh. 0 keeps every family a Dock family,++-- which is what every row written before this column is.++ALTER TABLE oauth_tokens ADD COLUMN IF NOT EXISTS session_family bigint DEFAULT 0;@@@ -47,7 +47,7 @@ if (isOAuth) {function renderConsent(info) {const box = document.getElementById('oauth-consent');const purposes = info && Array.isArray(info.purposes) ? info.purposes : [];-- if (!box || !info || (!info.third_party && !purposes.length)) return;++ if (!box || !info || (!info.third_party && !info.act_as_you && !purposes.length)) return;const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };box.replaceChildren();// An app's permission questions (consent:<purpose>): named here, asked one by one after the account is chosen.@@@ -58,6 +58,17 @@ function renderConsent(info) {p.append('. Each one stays off unless you tick it.');box.append(p);};++ // An app that acts AS the person (OpenVibe Link on the phone) says so in the Network's own words, every time:++ // it is never signed in silently and is never shown as a plain first-party site.++ if (info.act_as_you) {++ const head = el('p', 'consent-head');++ head.append(el('b', null, info.name), ' ');++ head.append(el('span', null, info.consent_text || 'will act as you.'));++ box.append(head);++ asks();++ box.hidden = false;++ return;++ }if (!info.third_party) {const head = el('p', 'consent-head');head.append(el('b', null, info.name), ' asks for your permission after you choose an account.');@@@ -283,8 +294,9 @@ function takeChoice() {let autoContinued = false;function maybeContinue(info) {if (autoContinued || !isOAuth || !info) return;++ // An app that acts as the person is chosen by the person, in this tab: its sentence is read before any code.++ if (info.third_party || info.act_as_you) return;const chosen = takeChoice();-- if (info.third_party) return;if (['select_account', 'login'].includes(params.get('prompt') || '')) return;const hint = String(params.get('login_hint') || '').trim().replace(/^@/, '').toLowerCase();const accounts = getSavedAccounts().filter(a => !a.is_anon);nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++/**++ * Android App Links for OpenVibe Link (plan 2026-11-07 §13.2, RFC 8252 §7.2).++ *++ * A custom scheme belongs to whoever registers it first: any app may answer openvibe-link://auth, run its own++ * authorization request, keep the redirect and receive a code it redeems with its own PKCE pair. An https link is++ * claimed instead: Android hands https://openvibe.network/link/auth to the one app named in++ * /.well-known/assetlinks.json for the certificate it was signed with, and to the browser only when none matches.++ *++ * So a session token — the person's own role and audiences — is issued only when BOTH hold: the authorization returned++ * to that https link, and at least one release fingerprint is published. Without fingerprints (the owner has not made++ * the release key yet) nothing is verified, the https link opens this page in the browser, and the link client gets the++ * extension's read-only Dock token, as every other public client does.++ */++const PACKAGE = 'network.openvibe.link';++const APP_LINK = 'https://openvibe.network/link/auth';++// A debug build's return: still a valid redirect, but a scheme any app can claim, so a Dock token only.++const CUSTOM_SCHEME = 'openvibe-link://auth';++const FINGERPRINT_RE = /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/;++++/** The sentence the account chooser says before OpenVibe Link signs in: it acts as the person, never as staff. */++const CONSENT_TEXT = "OpenVibe Link on your phone will act as you: answer your agents' approvals and send files to your Download.";++++/** The release certificate fingerprints published today; none until LINK_ANDROID_CERT_SHA256 is set. */++function fingerprints() {++ return String(process.env.LINK_ANDROID_CERT_SHA256 || '')++ .split(',')++ .map((s) => s.trim().toUpperCase())++ .filter((s) => FINGERPRINT_RE.test(s));++}++++/** The authorization returned to the claimed https link (an exact match, ignoring case). */++function isAppLink(uri) {++ return typeof uri === 'string' && uri.toLowerCase() === APP_LINK;++}++++/** /.well-known/assetlinks.json: the app allowed to claim /link/auth, for the certificates in the env. */++function assetLinks() {++ return [{++ relation: ['delegate_permission/common.handle_all_urls'],++ target: { namespace: 'android_app', package_name: PACKAGE, sha256_cert_fingerprints: fingerprints() },++ }];++}++++/**++ * The page /link/auth answers when the browser got the link because no installed app could claim it (the fingerprints++ * are unpublished, the app is missing, or the phone could not verify). It says what to do and never echoes the code:++ * the code is single-use and belongs to the app, not to a page anyone could bookmark.++ */++function page() {++ return `<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<meta name="robots" content="noindex, nofollow">++<title>Open this in OpenVibe Link</title>++<style>++body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0b1120;color:#e6ecff;font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}++main{max-width:34rem;padding:2rem}++h1{font-size:1.6rem;margin:0 0 1rem}++p{margin:0 0 .8rem;color:#a9b6d6}++</style>++</head>++<body>++<main>++<h1>Open this in OpenVibe Link</h1>++<p>This is where signing in to OpenVibe Link on your phone returns. If you can read this page, the app did not open it.</p>++<p>Make sure OpenVibe Link is installed on this phone, then start the sign-in again from the app.</p>++</main>++</body>++</html>`;++}++++/** The two routes, mounted by server/index.js and by its tests. */++function mount(app) {++ app.get('/.well-known/assetlinks.json', (_req, res) => {++ res.set('Cache-Control', 'public, max-age=300');++ res.type('application/json').send(JSON.stringify(assetLinks()));++ });++ app.get('/link/auth', (_req, res) => {++ res.set('X-Robots-Tag', 'noindex, nofollow').set('Cache-Control', 'no-store');++ res.type('html').send(page());++ });++ return app;++}++++module.exports = { PACKAGE, APP_LINK, CUSTOM_SCHEME, CONSENT_TEXT, fingerprints, isAppLink, assetLinks, page, mount };@@@ -33,6 +33,11 @@ function isDockClient(client) {return isPublicClient(client) && client.client_id === CLIENT_ID;}++/** A public client that signs the person in for real (OpenVibe Link), when its authorization earns it (oauth-routes). */++function isSessionClient(client) {++ return isPublicClient(client) && Number(client.session_token) === 1;++}++function dockIssuer(config) {return `${String(config.jwt.issuer || '').replace(/\/+$/, '')}/dock`;}@@@ -116,6 +121,6 @@ async function verifyDockSession(token, { db, publicKey, config }) {module.exports = {CLIENT_ID, CLIENT_NAME, SCOPES, AUDIENCES, FAMILY_RE,-- isPublicClient, isDockClient, dockIssuer, scopesOf, hasScope, allowedScopes,++ isPublicClient, isDockClient, isSessionClient, dockIssuer, scopesOf, hasScope, allowedScopes,readScopeForClient, readState, verifyDockSession,};@@@ -18,6 +18,8 @@ const refreshTokens = require('./refresh-tokens');const dock = require('./dock-tokens');const { isExtensionOrigin } = require('./extension-origin');const consentApps = require('../consent/apps');++const appLinks = require('./app-links');++const { staff } = require('openvibe-contracts');const router = express.Router();function getDb(req) { return req.app.locals.db; }@@@ -152,6 +154,11 @@ router.get('/authorize', async (req, res) => {const nonce = oidc.cleanNonce(req.query.nonce);if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });++ // A session client is never signed in silently: prompt=none would return a code with no chooser and the sentence++ // about acting as the person never said. RFC 6749 §3.1.2.6 answers interaction_required instead.++ if (dock.isSessionClient(client) && String(req.query.prompt || '') === 'none') {++ return res.redirect(withError(redirect_uri, 'interaction_required', 'consent is always shown for this app', requestState));++ }// prompt=none (silent SSO from an app whose own session lapsed): if this browser still has a// live/renewable openvibe.network session, continue as that account with no chooser. With no// session, bounce straight back with error=login_required so the app can stay quiet. From an@@@ -230,11 +237,17 @@ router.get('/client-info', async (req, res) => {const { capabilities, refused } = devTokens.consentView(askedConsent.rest);return res.json({ name: app.name, third_party: true, redirect_host: host, capabilities, refused, purposes });}-- const client = await db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));++ const client = await db.prepare('SELECT name, redirect_uris, is_public, session_token FROM oauth_clients WHERE client_id = ?').get(String(client_id));if (!client) return res.status(404).json({ error: 'Unknown client_id' });const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });-- res.json({ name: siteNameForHost(host) || client.name || String(client_id), third_party: false, redirect_host: host, capabilities: [], refused: [], purposes });++ // A session client acts as the person after they choose an account: the chooser says so, always, and never++ // continues by itself (public/js/login.js). third_party stays false: it is ours, and it is not a developer app.++ res.json({++ name: siteNameForHost(host) || client.name || String(client_id), third_party: false, redirect_host: host,++ capabilities: [], refused: [], purposes,++ ...(dock.isSessionClient(client) ? { act_as_you: true, consent_text: appLinks.CONSENT_TEXT } : {}),++ });});// ── POST /oauth/confirm ─────────────────────────────────────@@@ -464,8 +477,13 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectUconst user = await db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });++ // A session token only through the claimed https App Link and only with a published release certificate: a custom++ // scheme can be answered by any app, and no fingerprint means Android verified nothing. Everything else is the++ // read-only Dock token, as for every other public client.++ const session = dock.isSessionClient(client) && appLinks.isAppLink(authCode.redirect_uri) && appLinks.fingerprints().length > 0;++// Issue tokens-- const issued = await issueTokenPair(db, config, req, user, client, {}, authCode.scope);++ const issued = await issueTokenPair(db, config, req, user, client, {}, authCode.scope, { session });const { accessToken, refreshToken } = issued;await recordLinkedService(db, user, client);@@@ -537,8 +555,11 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {}}-- // Issue new token pair: the next generation of the same family-- const issued = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation }, stored.scope);++ // Issue new token pair: the next generation of the same family, never a different kind. A session family stays a++ // session family while a release certificate is published; withdrawing LINK_ANDROID_CERT_SHA256 stops sessions at++ // the next refresh, and every rotation of a Dock family is still a Dock token.++ const session = dock.isSessionClient(client) && Number(stored.session_family) === 1 && appLinks.fingerprints().length > 0;++ const issued = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation }, stored.scope, { session });await recordLinkedService(db, user, client);res.json({@@@ -550,10 +571,25 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {});}--async function issueTokenPair(db, config, req, user, client, family = {}, scope = null) {++/**++ * The role a phone's token may carry: the person's own when it is not a staff role, and "user" when it is. A staff++ * member's phone answers their own work; it is not a staff credential (openvibe-contracts' staff map says which roles++ * carry capabilities).++ */++function nonStaffRole(role) {++ const known = staff.map.roles.find((r) => r.id === role);++ return !known || known.staff ? 'user' : known.id;++}++++async function issueTokenPair(db, config, req, user, client, family = {}, scope = null, { session = false } = {}) {const privateKey = req.app.locals.privateKey;const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';const publicClient = dock.isPublicClient(client);++ // A public first-party app that signs a person in for real (OpenVibe Link on the phone) gets the person's own++ // session: the normal issuer and audiences, so every service that takes a user token takes it, and the person's++ // role. Its proof is PKCE, since an APK cannot keep a client secret. Every other public client keeps the++ // extension's read-only Dock token (private issuer, its own audiences, no role).++ const sessionClient = dock.isSessionClient(client) && session === true;const ttl = publicClient ? Math.max(60, Math.min(86400, Number(client.access_token_ttl_s) || 3600)) : null;const familyId = family.familyId || refreshTokens.newFamily();const subjectId = await subjects.ensureUserSubject(db, user) || undefined;@@@ -576,14 +612,22 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope};// A Dock token is a read credential: no role and no staff caps, and a private issuer so no other// service's normal user-token check accepts it. The family id lets Network end it on revoke.-- const claims = publicClient++ // A session token carries the person's non-staff role and NO staff claims: the phone acts as them, not as their++ // office. client_id, scope and fam stay, so the account page can end the family and the app knows what it holds.++ const claims = publicClient && !sessionClient? { ...base, client_id: client.client_id, scope: String(scope || ''), fam: familyId, ...consentClaim }-- : { ...base, role: user.role, ...staffClaims(user), ...consentClaim };++ : {++ ...base,++ role: sessionClient ? nonStaffRole(user.role) : user.role,++ ...(sessionClient ? {} : staffClaims(user)),++ ...(sessionClient ? { client_id: client.client_id, scope: String(scope || ''), fam: familyId } : {}),++ ...consentClaim,++ };const accessToken = jwt.sign(claims, privateKey, {algorithm,-- issuer: publicClient ? dock.dockIssuer(config) : config.jwt.issuer,-- audience: publicClient ? [...dock.AUDIENCES] : ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'],++ issuer: publicClient && !sessionClient ? dock.dockIssuer(config) : config.jwt.issuer,++ audience: publicClient && !sessionClient ? [...dock.AUDIENCES] : ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'],expiresIn: publicClient ? ttl : config.jwt.accessTokenExpiry,});@@@ -592,7 +636,7 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scopeconst issued = await refreshTokens.issue(db, {clientId: client.client_id, userId: user.id,scope: publicClient ? String(scope || '') : scopeWithConsent('profile theme', askedConsent.purposes),-- familyId, generation: family.generation || 0,++ familyId, generation: family.generation || 0, session: sessionClient ? 1 : 0,});// The token response's scope names a consent purpose only when it was granted (the app learns "not granted", no more).@@@ -38,11 +38,13 @@ async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T* clear). The family id is also signed into a Dock access token, so the caller must know it before* the pair is minted.*/--async function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, now = Date.now() }) {++async function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, session = 0, now = Date.now() }) {const token = crypto.randomBytes(48).toString('hex');const fam = familyId || newFamily();-- await db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation)-- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), fam, generation);++ // The family's kind (session or Dock) is fixed when it starts; a rotation carries it, so a refresh can never++ // promote a family that came in through a scheme any app can claim (migrations/0050).++ await db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation, session_family)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), fam, generation, session ? 1 : 0);return { token, familyId: fam };}@@@ -191,14 +191,23 @@ async function seedDb(db, { log = console } = {}) {'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/','https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',] },++ // Link on the phone (plan 2026-11-07 §13.2): a public first-party client with no secret — the Android app++ // cannot keep one — PKCE S256 only. Its redirect is the claimed https App Link (server/auth/app-links.js:++ // only the app named in /.well-known/assetlinks.json for a published release certificate gets it); the++ // custom scheme stays for debug builds. `session: true` gives it the person's own session token instead of++ // the extension's read-only Dock token, but only when the authorization returned to the https link and a++ // fingerprint is published. The scope ceiling is the profile and theme a sign-in asks for.++ { client_id: 'link', name: 'OpenVibe Link', public: true, session: true,++ allowed_scopes: 'profile theme', access_token_ttl_s: 86400,++ redirect_uris: ['https://openvibe.network/link/auth', 'openvibe-link://auth'] },];let seededAny = false;for (const c of contractClients) {const existing = await db.prepare('SELECT client_id, name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);if (!existing) {if (c.public) {-- await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public, allowed_scopes, access_token_ttl_s) VALUES (?, NULL, ?, ?, 1, 1, ?, ?)')-- .run(c.client_id, c.name, JSON.stringify(c.redirect_uris), c.allowed_scopes, c.access_token_ttl_s);++ await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public, allowed_scopes, access_token_ttl_s, session_token) VALUES (?, NULL, ?, ?, 1, 1, ?, ?, ?)')++ .run(c.client_id, c.name, JSON.stringify(c.redirect_uris), c.allowed_scopes, c.access_token_ttl_s, c.session ? 1 : 0);console.log(`[DB] Seeded public OAuth2 client: ${c.client_id} (no secret)`);} else {await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)')@@@ -210,8 +219,8 @@ async function seedDb(db, { log = console } = {}) {}await mergeRedirectUris(db, c.client_id, c.redirect_uris, { log });if (c.public) {-- await db.prepare('UPDATE oauth_clients SET name = ?, is_public = 1, allowed_scopes = ?, access_token_ttl_s = ?, client_secret = NULL, redirect_uris = ? WHERE client_id = ?')-- .run(c.name, c.allowed_scopes, c.access_token_ttl_s, JSON.stringify(c.redirect_uris), c.client_id);++ await db.prepare('UPDATE oauth_clients SET name = ?, is_public = 1, allowed_scopes = ?, access_token_ttl_s = ?, session_token = ?, client_secret = NULL, redirect_uris = ? WHERE client_id = ?')++ .run(c.name, c.allowed_scopes, c.access_token_ttl_s, c.session ? 1 : 0, JSON.stringify(c.redirect_uris), c.client_id);}// A client created first as a service principal carries the placeholder name "OpenVibe.<id> (service)",// which the sign-in page shows people ("Sign in to OpenVibe.food (service)"). Give it the site's name; a@@@ -1047,6 +1047,10 @@ app.get('/verify-email', (req, res) => {lookBoot.send(res, 'verify-email.html');});++// Android App Links for OpenVibe Link (plan 2026-11-07 §13.2): /.well-known/assetlinks.json claims /link/auth for the++// release certificate in LINK_ANDROID_CERT_SHA256, and /link/auth is the plain page a browser shows when no app did.++require('./auth/app-links').mount(app);++// FedCM identity provider (browser-native sign-in for the other OpenVibe sites) — server/auth/fedcm.js{const fedcm = require('./auth/fedcm');@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.nnew file mode 100644@@@ -0,0 +1,234 @@++'use strict';++// OpenVibe Link on the phone (plan 2026-11-07 §13.2, security round 2): a public first-party OAuth client (`link`) with++// no secret and PKCE S256. Its session — the person's own token, so Actor and the drive accept it — is issued only when++// BOTH hold: the authorization returned to the claimed https App Link (https://openvibe.network/link/auth, claimed in++// /.well-known/assetlinks.json for the release certificate) and LINK_ANDROID_CERT_SHA256 publishes at least one++// fingerprint. A custom scheme any app can answer, and an unpublished certificate, get the extension's read-only Dock++// token instead. The chooser always says what the app does, and prompt=none is refused.++//++// node --import ./test/helpers/pg-preload.mjs test/oauth-link.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const oauthRoutes = require('../server/auth/oauth-routes');++const authRoutes = require('../server/auth/routes');++const appLinks = require('../server/auth/app-links');++const { makeRequireAuth } = require('../server/auth/session');++++const ISSUER = 'https://openvibe.network';++const APP_LINK = 'https://openvibe.network/link/auth';++const SCHEME = 'openvibe-link://auth';++const PRINTS = ['AA:'.repeat(31) + 'AA', 'BB:'.repeat(31) + 'BB'];++const b64 = (buf) => Buffer.from(buf).toString('base64url');++const verifier = () => b64(crypto.randomBytes(32));++const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());++++(async () => {++ const db = getDb();++ const quiet = console.log; console.log = () => {};++ const keys = crypto.generateKeyPairSync('rsa', {++ modulusLength: 2048,++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },++ publicKeyEncoding: { type: 'spki', format: 'pem' },++ });++ const config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '7d' } };++ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'phoneana', '[email protected]', 'x', 'user')").run();++ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (2, 'phoneadmin', '[email protected]', 'x', 'admin')").run();++ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (3, 'phonestreamer', '[email protected]', 'x', 'streamer')").run();++ const user = await db.prepare('SELECT * FROM users WHERE id = 1').get();++ const admin = await db.prepare('SELECT * FROM users WHERE id = 2').get();++ const streamer = await db.prepare('SELECT * FROM users WHERE id = 3').get();++ for (const u of [user, admin, streamer]) await subjects.ensureUserSubject(db, u);++++ const requireAuth = makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);++ const requireDock = (scope) => makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken, { dockScope: scope });++ const app = express();++ app.use(express.json());++ app.use(express.urlencoded({ extended: true }));++ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++ app.use('/oauth', oauthRoutes);++ appLinks.mount(app);++ app.get('/api/v1/me/private', requireAuth, (req, res) => res.json({ ok: true, user: req.user.username }));++ app.get('/api/v1/me/actions', requireDock('activity'), (req, res) => res.json({ ok: true }));++ app.post('/api/v1/me/actions', requireAuth, (req, res) => res.json({ ok: true }));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++++ const authorize = async (query) => {++ const r = await fetch(`${base}/oauth/authorize?${new URLSearchParams(query)}`, { redirect: 'manual' });++ return { status: r.status, location: r.headers.get('location') || null };++ };++ const confirm = async (token, body) => {++ const r = await fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token, ...body }) });++ return { status: r.status, body: await r.json().catch(() => null) };++ };++ const token = async (body) => {++ const r = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body) });++ return { status: r.status, body: await r.json().catch(() => null) };++ };++ const api = async (method, route, tok) => {++ const r = await fetch(`${base}${route}`, { method, headers: { authorization: `Bearer ${tok}` } });++ return { status: r.status, body: await r.json().catch(() => null) };++ };++ const info = async (query) => {++ const r = await fetch(`${base}/oauth/client-info?${new URLSearchParams(query)}`);++ return { status: r.status, body: await r.json().catch(() => null) };++ };++ /** A full code grant for one redirect and one person, with the verifier it must be redeemed with. */++ const grant = async (redirectUri, who = user) => {++ const v = verifier();++ const state = 'state_' + crypto.randomBytes(12).toString('hex');++ const start = await authorize({ client_id: 'link', redirect_uri: redirectUri, response_type: 'code', scope: 'profile theme', state, code_challenge: challenge(v), code_challenge_method: 'S256' });++ assert.strictEqual(start.status, 302);++ assert.match(start.location, /\/login\?/);++ const cx = await confirm(authRoutes.signToken(who, keys.privateKey, config), { client_id: 'link', redirect_uri: redirectUri, response_type: 'code', scope: 'profile theme', state, code_challenge: challenge(v), code_challenge_method: 'S256' });++ assert.strictEqual(cx.status, 200, JSON.stringify(cx.body));++ const redirect = new URL(cx.body.redirect);++ assert.strictEqual(redirect.searchParams.get('state'), state);++ const code = redirect.searchParams.get('code');++ assert.ok(code);++ const issued = await token({ grant_type: 'authorization_code', client_id: 'link', code, redirect_uri: redirectUri, code_verifier: v });++ assert.strictEqual(issued.status, 200, JSON.stringify(issued.body));++ return issued.body;++ };++ const claimsOf = (body) => jwt.decode(body.access_token);++++ try {++ // ── The migration is expand only, and the client is seeded with the claimed link and the debug scheme.++ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0050_oauth_token_session_families.sql'), 'utf8');++ assert.match(sql, /^-- phase: expand/m);++ assert.match(sql, /ADD COLUMN IF NOT EXISTS session_family/);++ assert.doesNotMatch(sql, /ADD COLUMN[^;]*NOT NULL/i);++++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get('link');++ assert.ok(client, 'the link client is seeded');++ assert.strictEqual(Number(client.is_public), 1);++ assert.strictEqual(Number(client.session_token), 1);++ assert.strictEqual(client.allowed_scopes, 'profile theme');++ assert.deepStrictEqual(JSON.parse(client.redirect_uris), [APP_LINK, SCHEME]);++ assert.strictEqual(client.client_secret, null, 'a public client keeps no secret');++++ // ── Claimed https redirect (RFC 8252 §7.2): assetlinks.json names the package and the env's certificates.++ delete process.env.LINK_ANDROID_CERT_SHA256;++ let links = await (await fetch(`${base}/.well-known/assetlinks.json`)).json();++ assert.deepStrictEqual(links[0].target.package_name, 'network.openvibe.link');++ assert.deepStrictEqual(links[0].target.sha256_cert_fingerprints, [], 'no release key, no claim');++ assert.deepStrictEqual(links[0].relation, ['delegate_permission/common.handle_all_urls']);++ process.env.LINK_ANDROID_CERT_SHA256 = `${PRINTS[0].toLowerCase()}, not-a-fingerprint,${PRINTS[1]}`;++ links = await (await fetch(`${base}/.well-known/assetlinks.json`)).json();++ assert.deepStrictEqual(links[0].target.sha256_cert_fingerprints, PRINTS, 'the env fingerprints, upper-cased, junk dropped');++++ // ── /link/auth is the plain page a browser gets when no installed app could claim it.++ const page = await fetch(`${base}/link/auth?code=SECRET_CODE&state=s`);++ assert.strictEqual(page.status, 200);++ assert.match(page.headers.get('x-robots-tag') || '', /noindex/);++ const html = await page.text();++ assert.match(html, /Open this in OpenVibe Link/);++ assert.doesNotMatch(html, /SECRET_CODE/, 'the page never echoes the code');++++ // ── Consent is always shown: client-info says what it does, and prompt=none is refused, never silent.++ const linkInfo = await info({ client_id: 'link', redirect_uri: APP_LINK, scope: 'profile theme' });++ assert.strictEqual(linkInfo.status, 200);++ assert.strictEqual(linkInfo.body.act_as_you, true);++ assert.match(linkInfo.body.consent_text, /act as you/);++ assert.match(linkInfo.body.consent_text, /approvals/);++ const companionInfo = await info({ client_id: 'companion', redirect_uri: 'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/', scope: 'dock' });++ assert.strictEqual(companionInfo.status, 200);++ assert.strictEqual(companionInfo.body.act_as_you, undefined, 'the extension reads; it does not act as you');++ const silent = await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme', state: 's0', prompt: 'none', code_challenge: challenge(verifier()), code_challenge_method: 'S256' });++ assert.strictEqual(silent.status, 302);++ assert.match(silent.location, /^https:\/\/openvibe\.network\/link\/auth\?/);++ assert.match(silent.location, /error=interaction_required/);++ assert.doesNotMatch(silent.location, /code=/, 'no code without the chooser');++++ // ── Public client rules still hold: exact redirect, state, PKCE S256, a scope inside the ceiling.++ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme' })).status, 400, 'state required');++ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', state: 's', scope: 'profile theme' })).status, 400, 'PKCE required');++ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', state: 's', scope: 'dock', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'scope ceiling');++ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: 'openvibe-link://other', response_type: 'code', state: 's', scope: 'theme', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'exact redirect');++++ // ── The custom scheme gets the read-only Dock token, even with certificates published: any app can claim it.++ process.env.LINK_ANDROID_CERT_SHA256 = PRINTS.join(',');++ const viaScheme = await grant(SCHEME);++ let claims = claimsOf(viaScheme);++ assert.strictEqual(claims.iss, `${ISSUER}/dock`, 'the private issuer: no normal user-token check takes it');++ assert.ok(claims.aud.includes('openvibe.chat'));++ assert.strictEqual(claims.role, undefined, 'a Dock token carries no role');++ assert.strictEqual(claims.staff_caps, undefined);++ assert.strictEqual(claims.client_id, 'link');++ assert.ok(claims.fam);++ assert.strictEqual((await api('GET', '/api/v1/me/private', viaScheme.access_token)).status, 401, 'not a session');++++ // ── The claimed https link with NO published certificate: Dock too. Android verified nothing.++ delete process.env.LINK_ANDROID_CERT_SHA256;++ const unverified = await grant(APP_LINK);++ assert.strictEqual(claimsOf(unverified).iss, `${ISSUER}/dock`);++ assert.strictEqual(claimsOf(unverified).role, undefined);++++ // ── The claimed https link WITH a certificate: the person's own session.++ process.env.LINK_ANDROID_CERT_SHA256 = PRINTS.join(',');++ const session = await grant(APP_LINK);++ claims = claimsOf(session);++ assert.strictEqual(session.expires_in, 86400);++ assert.strictEqual(claims.iss, ISSUER, 'the normal issuer');++ assert.ok(claims.aud.includes('openvibe.network'), 'the normal audiences');++ assert.strictEqual(claims.role, 'user');++ assert.strictEqual(claims.client_id, 'link');++ assert.strictEqual(claims.scope, 'profile theme');++ assert.ok(claims.fam, 'the family, so the account page can end it');++ assert.strictEqual(claims.is_owner, undefined);++ assert.strictEqual(claims.staff_caps, undefined, 'a session token carries no staff claims');++ assert.strictEqual(claims.typ, undefined);++ assert.strictEqual((await api('GET', '/api/v1/me/actions', session.access_token)).status, 200);++ assert.strictEqual((await api('POST', '/api/v1/me/actions', session.access_token)).status, 200, 'answering an approval');++ assert.strictEqual((await api('GET', '/api/v1/me/private', session.access_token)).status, 200, 'a plain user token');++ assert.strictEqual((await api('GET', '/api/v1/me/private', 'nope')).status, 401);++++ // ── A staff member's phone acts as a user: role capped, no staff claims. A non-staff role is kept.++ const adminSession = claimsOf(await grant(APP_LINK, admin));++ assert.strictEqual(adminSession.role, 'user', "an admin's phone is not an admin");++ assert.strictEqual(adminSession.staff_caps, undefined);++ assert.strictEqual(adminSession.is_owner, undefined);++ assert.strictEqual(claimsOf(await grant(APP_LINK, streamer)).role, 'streamer', 'a streamer is not staff');++ assert.strictEqual(claimsOf(await grant(APP_LINK, streamer)).staff_caps, undefined);++++ // ── A refresh keeps its family's kind: Dock stays Dock, session stays session (while a certificate is published).++ const dockRefresh = await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: viaScheme.refresh_token });++ assert.strictEqual(dockRefresh.status, 200, JSON.stringify(dockRefresh.body));++ assert.strictEqual(claimsOf(dockRefresh.body).iss, `${ISSUER}/dock`, 'a scheme family never becomes a session');++ assert.notStrictEqual(dockRefresh.body.refresh_token, viaScheme.refresh_token, 'rotated');++++ const sessionRefresh = await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: session.refresh_token });++ assert.strictEqual(sessionRefresh.status, 200, JSON.stringify(sessionRefresh.body));++ assert.strictEqual(claimsOf(sessionRefresh.body).iss, ISSUER);++ assert.strictEqual(claimsOf(sessionRefresh.body).role, 'user');++ assert.strictEqual(claimsOf(sessionRefresh.body).staff_caps, undefined);++ assert.strictEqual((await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: session.refresh_token })).status, 400, 'the old refresh token is spent');++++ // Withdrawing the certificate stops sessions at the next refresh; the family cannot promote itself back.++ delete process.env.LINK_ANDROID_CERT_SHA256;++ const downgraded = await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: sessionRefresh.body.refresh_token });++ assert.strictEqual(downgraded.status, 200, JSON.stringify(downgraded.body));++ assert.strictEqual(claimsOf(downgraded.body).iss, `${ISSUER}/dock`, 'no certificate, no session');++++ // ── A wrong verifier is refused (and spent, as the flow requires).++ const v2 = verifier();++ await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme', state: 's2', code_challenge: challenge(v2), code_challenge_method: 'S256' });++ const cx2 = await confirm(authRoutes.signToken(user, keys.privateKey, config), { client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme', state: 's2', code_challenge: challenge(v2), code_challenge_method: 'S256' });++ const code2 = new URL(cx2.body.redirect).searchParams.get('code');++ assert.strictEqual((await token({ grant_type: 'authorization_code', client_id: 'link', code: code2, redirect_uri: APP_LINK, code_verifier: verifier() })).status, 400, 'a wrong verifier is refused');++ } finally {++ delete process.env.LINK_ANDROID_CERT_SHA256;++ await new Promise((r) => server.close(r));++ console.log = quiet;++ }++ console.log('oauth link: all passed');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -20,5 +20,9 @@ assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', usassert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.secrets.manage'), false);// Both user-token signers add them.const fs = require('fs');--for (const f of ['server/auth/routes.js', 'server/auth/oauth-routes.js']) assert.ok(fs.readFileSync(require('path').join(__dirname, '..', f), 'utf8').includes('...staffClaims(user)'), f);++for (const f of ['server/auth/routes.js', 'server/auth/oauth-routes.js']) assert.ok(fs.readFileSync(require('path').join(__dirname, '..', f), 'utf8').includes('staffClaims(user)'), f);++// A phone's session token (OpenVibe Link) is not staff authority: oauth-routes leaves the claims out and caps the role.++const oauth = fs.readFileSync(require('path').join(__dirname, '..', 'server/auth/oauth-routes.js'), 'utf8');++assert.ok(oauth.includes('sessionClient ? {} : staffClaims(user)'), 'a session client gets no staff claims');++assert.ok(oauth.includes('nonStaffRole(user.role)'), 'a session client keeps only a non-staff role');console.log('staff claims: all checks passed');@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -141,3 +141,14 @@ NOTIFICATION_EMAIL_CRITICAL_ONLY=true
@@ -141,3 +141,14 @@ NOTIFICATION_EMAIL_CRITICAL_ONLY=true
# The private key stays on openvibe.network only. Other services verify
# The private key stays on openvibe.network only. Other services verify
# tokens OFFLINE via the JWKS endpoint (/api/.well-known/jwks).
# tokens OFFLINE via the JWKS endpoint (/api/.well-known/jwks).
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
+
+# ═══════════════════════════════════════════════════════════════
+# OpenVibe Link on Android (server/auth/app-links.js)
+# ═══════════════════════════════════════════════════════════════
+# The SHA-256 fingerprints of the certificate that signs the RELEASE build of network.openvibe.link, comma-separated,
+# colon-hex (AA:BB:…). They are what /.well-known/assetlinks.json publishes so Android hands the https://
+# openvibe.network/link/auth sign-in return to the app alone; the certificates are public values, not secrets.
+#
+# Leave it unset until the owner's release key exists. With no fingerprint, Android verifies nothing, /link/auth opens
+# this site's page, and OpenVibe Link gets the extension's read-only Dock token instead of a session.
+# LINK_ANDROID_CERT_SHA256=AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- A public first-party app that signs the person in for real (OpenVibe Link on the phone, plan 2026-11-07 §13.2).
+-- Until now every public client's token was the extension's read-only Dock token: a private issuer, its own audiences
+-- and no role, so no service's normal user-token check would take it. An app that answers approvals and uploads files
+-- needs the person's own session; its proof is PKCE, not a secret an APK cannot keep. 0 keeps every client as it is.
+ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS session_token bigint DEFAULT 0;
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- Whether a refresh family is a session family (OpenVibe Link's sign-in through the https App Link, plan 2026-11-07
+-- §13.2). The kind is decided once, when the code is exchanged: a family started through openvibe-link://auth — a
+-- scheme any app can claim — must never become a session on a later refresh. 0 keeps every family a Dock family,
+-- which is what every row written before this column is.
+ALTER TABLE oauth_tokens ADD COLUMN IF NOT EXISTS session_family bigint DEFAULT 0;
@@ -47,7 +47,7 @@ if (isOAuth) {
@@ -47,7 +47,7 @@ if (isOAuth) {
function renderConsent(info) {
function renderConsent(info) {
const box = document.getElementById('oauth-consent');
const box = document.getElementById('oauth-consent');
const purposes = info && Array.isArray(info.purposes) ? info.purposes : [];
const purposes = info && Array.isArray(info.purposes) ? info.purposes : [];
- if (!box || !info || (!info.third_party && !purposes.length)) return;
+ if (!box || !info || (!info.third_party && !info.act_as_you && !purposes.length)) return;
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
box.replaceChildren();
box.replaceChildren();
// An app's permission questions (consent:<purpose>): named here, asked one by one after the account is chosen.
// An app's permission questions (consent:<purpose>): named here, asked one by one after the account is chosen.
@@ -58,6 +58,17 @@ function renderConsent(info) {
@@ -58,6 +58,17 @@ function renderConsent(info) {
p.append('. Each one stays off unless you tick it.');
p.append('. Each one stays off unless you tick it.');
box.append(p);
box.append(p);
};
};
+ // An app that acts AS the person (OpenVibe Link on the phone) says so in the Network's own words, every time:
+ // it is never signed in silently and is never shown as a plain first-party site.
+ if (info.act_as_you) {
+ const head = el('p', 'consent-head');
+ head.append(el('b', null, info.name), ' ');
+ head.append(el('span', null, info.consent_text || 'will act as you.'));
+ box.append(head);
+ asks();
+ box.hidden = false;
+ return;
+ }
if (!info.third_party) {
if (!info.third_party) {
const head = el('p', 'consent-head');
const head = el('p', 'consent-head');
head.append(el('b', null, info.name), ' asks for your permission after you choose an account.');
head.append(el('b', null, info.name), ' asks for your permission after you choose an account.');
@@ -283,8 +294,9 @@ function takeChoice() {
@@ -283,8 +294,9 @@ function takeChoice() {
let autoContinued = false;
let autoContinued = false;
function maybeContinue(info) {
function maybeContinue(info) {
if (autoContinued || !isOAuth || !info) return;
if (autoContinued || !isOAuth || !info) return;
+ // An app that acts as the person is chosen by the person, in this tab: its sentence is read before any code.
+ if (info.third_party || info.act_as_you) return;
const chosen = takeChoice();
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 hint = String(params.get('login_hint') || '').trim().replace(/^@/, '').toLowerCase();
const hint = String(params.get('login_hint') || '').trim().replace(/^@/, '').toLowerCase();
const accounts = getSavedAccounts().filter(a => !a.is_anon);
const accounts = getSavedAccounts().filter(a => !a.is_anon);
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+/**
+ * Android App Links for OpenVibe Link (plan 2026-11-07 §13.2, RFC 8252 §7.2).
+ *
+ * A custom scheme belongs to whoever registers it first: any app may answer openvibe-link://auth, run its own
+ * authorization request, keep the redirect and receive a code it redeems with its own PKCE pair. An https link is
+ * claimed instead: Android hands https://openvibe.network/link/auth to the one app named in
+ * /.well-known/assetlinks.json for the certificate it was signed with, and to the browser only when none matches.
+ *
+ * So a session token — the person's own role and audiences — is issued only when BOTH hold: the authorization returned
+ * to that https link, and at least one release fingerprint is published. Without fingerprints (the owner has not made
+ * the release key yet) nothing is verified, the https link opens this page in the browser, and the link client gets the
+ * extension's read-only Dock token, as every other public client does.
+ */
+const PACKAGE = 'network.openvibe.link';
+const APP_LINK = 'https://openvibe.network/link/auth';
+// A debug build's return: still a valid redirect, but a scheme any app can claim, so a Dock token only.
+const CUSTOM_SCHEME = 'openvibe-link://auth';
+const FINGERPRINT_RE = /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/;
+
+/** The sentence the account chooser says before OpenVibe Link signs in: it acts as the person, never as staff. */
+const CONSENT_TEXT = "OpenVibe Link on your phone will act as you: answer your agents' approvals and send files to your Download.";
+
+/** The release certificate fingerprints published today; none until LINK_ANDROID_CERT_SHA256 is set. */
+function fingerprints() {
+ return String(process.env.LINK_ANDROID_CERT_SHA256 || '')
+ .split(',')
+ .map((s) => s.trim().toUpperCase())
+ .filter((s) => FINGERPRINT_RE.test(s));
+}
+
+/** The authorization returned to the claimed https link (an exact match, ignoring case). */
+function isAppLink(uri) {
+ return typeof uri === 'string' && uri.toLowerCase() === APP_LINK;
+}
+
+/** /.well-known/assetlinks.json: the app allowed to claim /link/auth, for the certificates in the env. */
+function assetLinks() {
+ return [{
+ relation: ['delegate_permission/common.handle_all_urls'],
+ target: { namespace: 'android_app', package_name: PACKAGE, sha256_cert_fingerprints: fingerprints() },
+ }];
+}
+
+/**
+ * The page /link/auth answers when the browser got the link because no installed app could claim it (the fingerprints
+ * are unpublished, the app is missing, or the phone could not verify). It says what to do and never echoes the code:
+ * the code is single-use and belongs to the app, not to a page anyone could bookmark.
+ */
+function page() {
+ return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta name="robots" content="noindex, nofollow">
+<title>Open this in OpenVibe Link</title>
+<style>
+body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0b1120;color:#e6ecff;font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
+main{max-width:34rem;padding:2rem}
+h1{font-size:1.6rem;margin:0 0 1rem}
+p{margin:0 0 .8rem;color:#a9b6d6}
+</style>
+</head>
+<body>
+<main>
+<h1>Open this in OpenVibe Link</h1>
+<p>This is where signing in to OpenVibe Link on your phone returns. If you can read this page, the app did not open it.</p>
+<p>Make sure OpenVibe Link is installed on this phone, then start the sign-in again from the app.</p>
+</main>
+</body>
+</html>`;
+}
+
+/** The two routes, mounted by server/index.js and by its tests. */
+function mount(app) {
+ app.get('/.well-known/assetlinks.json', (_req, res) => {
+ res.set('Cache-Control', 'public, max-age=300');
+ res.type('application/json').send(JSON.stringify(assetLinks()));
+ });
+ app.get('/link/auth', (_req, res) => {
+ res.set('X-Robots-Tag', 'noindex, nofollow').set('Cache-Control', 'no-store');
+ res.type('html').send(page());
+ });
+ return app;
+}
+
+module.exports = { PACKAGE, APP_LINK, CUSTOM_SCHEME, CONSENT_TEXT, fingerprints, isAppLink, assetLinks, page, mount };
@@ -33,6 +33,11 @@ function isDockClient(client) {
@@ -33,6 +33,11 @@ function isDockClient(client) {
return isPublicClient(client) && client.client_id === CLIENT_ID;
return isPublicClient(client) && client.client_id === CLIENT_ID;
}
}
+/** A public client that signs the person in for real (OpenVibe Link), when its authorization earns it (oauth-routes). */
+function isSessionClient(client) {
+ return isPublicClient(client) && Number(client.session_token) === 1;
+}
+
function dockIssuer(config) {
function dockIssuer(config) {
return `${String(config.jwt.issuer || '').replace(/\/+$/, '')}/dock`;
return `${String(config.jwt.issuer || '').replace(/\/+$/, '')}/dock`;
}
}
@@ -116,6 +121,6 @@ async function verifyDockSession(token, { db, publicKey, config }) {
@@ -116,6 +121,6 @@ async function verifyDockSession(token, { db, publicKey, config }) {
module.exports = {
module.exports = {
CLIENT_ID, CLIENT_NAME, SCOPES, AUDIENCES, FAMILY_RE,
CLIENT_ID, CLIENT_NAME, SCOPES, AUDIENCES, FAMILY_RE,
- isPublicClient, isDockClient, dockIssuer, scopesOf, hasScope, allowedScopes,
+ isPublicClient, isDockClient, isSessionClient, dockIssuer, scopesOf, hasScope, allowedScopes,
readScopeForClient, readState, verifyDockSession,
readScopeForClient, readState, verifyDockSession,
};
};
@@ -18,6 +18,8 @@ const refreshTokens = require('./refresh-tokens');
@@ -18,6 +18,8 @@ const refreshTokens = require('./refresh-tokens');
const dock = require('./dock-tokens');
const dock = require('./dock-tokens');
const { isExtensionOrigin } = require('./extension-origin');
const { isExtensionOrigin } = require('./extension-origin');
const consentApps = require('../consent/apps');
const consentApps = require('../consent/apps');
+const appLinks = require('./app-links');
+const { staff } = require('openvibe-contracts');
const router = express.Router();
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
@@ -152,6 +154,11 @@ router.get('/authorize', async (req, res) => {
@@ -152,6 +154,11 @@ router.get('/authorize', async (req, res) => {
const nonce = oidc.cleanNonce(req.query.nonce);
const nonce = oidc.cleanNonce(req.query.nonce);
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
+ // A session client is never signed in silently: prompt=none would return a code with no chooser and the sentence
+ // about acting as the person never said. RFC 6749 §3.1.2.6 answers interaction_required instead.
+ if (dock.isSessionClient(client) && String(req.query.prompt || '') === 'none') {
+ return res.redirect(withError(redirect_uri, 'interaction_required', 'consent is always shown for this app', requestState));
+ }
// prompt=none (silent SSO from an app whose own session lapsed): if this browser still has a
// prompt=none (silent SSO from an app whose own session lapsed): if this browser still has a
// live/renewable openvibe.network session, continue as that account with no chooser. With no
// live/renewable openvibe.network session, continue as that account with no chooser. With no
// session, bounce straight back with error=login_required so the app can stay quiet. From an
// session, bounce straight back with error=login_required so the app can stay quiet. From an
@@ -230,11 +237,17 @@ router.get('/client-info', async (req, res) => {
@@ -230,11 +237,17 @@ router.get('/client-info', async (req, res) => {
const { capabilities, refused } = devTokens.consentView(askedConsent.rest);
const { capabilities, refused } = devTokens.consentView(askedConsent.rest);
return res.json({ name: app.name, third_party: true, redirect_host: host, capabilities, refused, purposes });
return res.json({ name: app.name, third_party: true, redirect_host: host, capabilities, refused, purposes });
}
}
- const client = await db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));
+ const client = await db.prepare('SELECT name, redirect_uris, is_public, session_token FROM oauth_clients WHERE client_id = ?').get(String(client_id));
if (!client) return res.status(404).json({ error: 'Unknown client_id' });
if (!client) return res.status(404).json({ error: 'Unknown client_id' });
const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());
const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());
if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });
if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });
- res.json({ name: siteNameForHost(host) || client.name || String(client_id), third_party: false, redirect_host: host, capabilities: [], refused: [], purposes });
+ // A session client acts as the person after they choose an account: the chooser says so, always, and never
+ // continues by itself (public/js/login.js). third_party stays false: it is ours, and it is not a developer app.
+ res.json({
+ name: siteNameForHost(host) || client.name || String(client_id), third_party: false, redirect_host: host,
+ capabilities: [], refused: [], purposes,
+ ...(dock.isSessionClient(client) ? { act_as_you: true, consent_text: appLinks.CONSENT_TEXT } : {}),
+ });
});
});
// ── POST /oauth/confirm ─────────────────────────────────────
// ── POST /oauth/confirm ─────────────────────────────────────
@@ -464,8 +477,13 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
@@ -464,8 +477,13 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
+ // A session token only through the claimed https App Link and only with a published release certificate: a custom
+ // scheme can be answered by any app, and no fingerprint means Android verified nothing. Everything else is the
+ // read-only Dock token, as for every other public client.
+ const session = dock.isSessionClient(client) && appLinks.isAppLink(authCode.redirect_uri) && appLinks.fingerprints().length > 0;
+
// Issue tokens
// Issue tokens
- const issued = await issueTokenPair(db, config, req, user, client, {}, authCode.scope);
+ const issued = await issueTokenPair(db, config, req, user, client, {}, authCode.scope, { session });
const { accessToken, refreshToken } = issued;
const { accessToken, refreshToken } = issued;
await recordLinkedService(db, user, client);
await recordLinkedService(db, user, client);
@@ -537,8 +555,11 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
@@ -537,8 +555,11 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
}
}
}
}
- // Issue new token pair: the next generation of the same family
- const issued = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation }, stored.scope);
+ // Issue new token pair: the next generation of the same family, never a different kind. A session family stays a
+ // session family while a release certificate is published; withdrawing LINK_ANDROID_CERT_SHA256 stops sessions at
+ // the next refresh, and every rotation of a Dock family is still a Dock token.
+ const session = dock.isSessionClient(client) && Number(stored.session_family) === 1 && appLinks.fingerprints().length > 0;
+ const issued = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation }, stored.scope, { session });
await recordLinkedService(db, user, client);
await recordLinkedService(db, user, client);
res.json({
res.json({
@@ -550,10 +571,25 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
@@ -550,10 +571,25 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
});
});
}
}
-async function issueTokenPair(db, config, req, user, client, family = {}, scope = null) {
+/**
+ * The role a phone's token may carry: the person's own when it is not a staff role, and "user" when it is. A staff
+ * member's phone answers their own work; it is not a staff credential (openvibe-contracts' staff map says which roles
+ * carry capabilities).
+ */
+function nonStaffRole(role) {
+ const known = staff.map.roles.find((r) => r.id === role);
+ return !known || known.staff ? 'user' : known.id;
+}
+
+async function issueTokenPair(db, config, req, user, client, family = {}, scope = null, { session = false } = {}) {
const privateKey = req.app.locals.privateKey;
const privateKey = req.app.locals.privateKey;
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
const publicClient = dock.isPublicClient(client);
const publicClient = dock.isPublicClient(client);
+ // A public first-party app that signs a person in for real (OpenVibe Link on the phone) gets the person's own
+ // session: the normal issuer and audiences, so every service that takes a user token takes it, and the person's
+ // role. Its proof is PKCE, since an APK cannot keep a client secret. Every other public client keeps the
+ // extension's read-only Dock token (private issuer, its own audiences, no role).
+ const sessionClient = dock.isSessionClient(client) && session === true;
const ttl = publicClient ? Math.max(60, Math.min(86400, Number(client.access_token_ttl_s) || 3600)) : null;
const ttl = publicClient ? Math.max(60, Math.min(86400, Number(client.access_token_ttl_s) || 3600)) : null;
const familyId = family.familyId || refreshTokens.newFamily();
const familyId = family.familyId || refreshTokens.newFamily();
const subjectId = await subjects.ensureUserSubject(db, user) || undefined;
const subjectId = await subjects.ensureUserSubject(db, user) || undefined;
@@ -576,14 +612,22 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
@@ -576,14 +612,22 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
};
};
// A Dock token is a read credential: no role and no staff caps, and a private issuer so no other
// A Dock token is a read credential: no role and no staff caps, and a private issuer so no other
// service's normal user-token check accepts it. The family id lets Network end it on revoke.
// service's normal user-token check accepts it. The family id lets Network end it on revoke.
- const claims = publicClient
+ // A session token carries the person's non-staff role and NO staff claims: the phone acts as them, not as their
+ // office. client_id, scope and fam stay, so the account page can end the family and the app knows what it holds.
+ const claims = publicClient && !sessionClient
? { ...base, client_id: client.client_id, scope: String(scope || ''), fam: familyId, ...consentClaim }
? { ...base, client_id: client.client_id, scope: String(scope || ''), fam: familyId, ...consentClaim }
- : { ...base, role: user.role, ...staffClaims(user), ...consentClaim };
+ : {
+ ...base,
+ role: sessionClient ? nonStaffRole(user.role) : user.role,
+ ...(sessionClient ? {} : staffClaims(user)),
+ ...(sessionClient ? { client_id: client.client_id, scope: String(scope || ''), fam: familyId } : {}),
+ ...consentClaim,
+ };
const accessToken = jwt.sign(claims, privateKey, {
const accessToken = jwt.sign(claims, privateKey, {
algorithm,
algorithm,
- issuer: publicClient ? dock.dockIssuer(config) : config.jwt.issuer,
- audience: publicClient ? [...dock.AUDIENCES] : ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'],
+ issuer: publicClient && !sessionClient ? dock.dockIssuer(config) : config.jwt.issuer,
+ audience: publicClient && !sessionClient ? [...dock.AUDIENCES] : ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'],
expiresIn: publicClient ? ttl : config.jwt.accessTokenExpiry,
expiresIn: publicClient ? ttl : config.jwt.accessTokenExpiry,
});
});
@@ -592,7 +636,7 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
@@ -592,7 +636,7 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
const issued = await refreshTokens.issue(db, {
const issued = await refreshTokens.issue(db, {
clientId: client.client_id, userId: user.id,
clientId: client.client_id, userId: user.id,
scope: publicClient ? String(scope || '') : scopeWithConsent('profile theme', askedConsent.purposes),
scope: publicClient ? String(scope || '') : scopeWithConsent('profile theme', askedConsent.purposes),
- familyId, generation: family.generation || 0,
+ familyId, generation: family.generation || 0, session: sessionClient ? 1 : 0,
});
});
// The token response's scope names a consent purpose only when it was granted (the app learns "not granted", no more).
// The token response's scope names a consent purpose only when it was granted (the app learns "not granted", no more).
@@ -38,11 +38,13 @@ async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T
@@ -38,11 +38,13 @@ async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T
* clear). The family id is also signed into a Dock access token, so the caller must know it before
* clear). The family id is also signed into a Dock access token, so the caller must know it before
* the pair is minted.
* the pair is minted.
*/
*/
-async function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, now = Date.now() }) {
+async function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, session = 0, now = Date.now() }) {
const token = crypto.randomBytes(48).toString('hex');
const token = crypto.randomBytes(48).toString('hex');
const fam = familyId || newFamily();
const fam = familyId || newFamily();
- await db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation)
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), fam, generation);
+ // The family's kind (session or Dock) is fixed when it starts; a rotation carries it, so a refresh can never
+ // promote a family that came in through a scheme any app can claim (migrations/0050).
+ await db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation, session_family)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), fam, generation, session ? 1 : 0);
return { token, familyId: fam };
return { token, familyId: fam };
}
}
@@ -191,14 +191,23 @@ async function seedDb(db, { log = console } = {}) {
@@ -191,14 +191,23 @@ async function seedDb(db, { log = console } = {}) {
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
] },
] },
+ // Link on the phone (plan 2026-11-07 §13.2): a public first-party client with no secret — the Android app
+ // cannot keep one — PKCE S256 only. Its redirect is the claimed https App Link (server/auth/app-links.js:
+ // only the app named in /.well-known/assetlinks.json for a published release certificate gets it); the
+ // custom scheme stays for debug builds. `session: true` gives it the person's own session token instead of
+ // the extension's read-only Dock token, but only when the authorization returned to the https link and a
+ // fingerprint is published. The scope ceiling is the profile and theme a sign-in asks for.
+ { client_id: 'link', name: 'OpenVibe Link', public: true, session: true,
+ allowed_scopes: 'profile theme', access_token_ttl_s: 86400,
+ redirect_uris: ['https://openvibe.network/link/auth', 'openvibe-link://auth'] },
];
];
let seededAny = false;
let seededAny = false;
for (const c of contractClients) {
for (const c of contractClients) {
const existing = await db.prepare('SELECT client_id, name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);
const existing = await db.prepare('SELECT client_id, name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);
if (!existing) {
if (!existing) {
if (c.public) {
if (c.public) {
- await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public, allowed_scopes, access_token_ttl_s) VALUES (?, NULL, ?, ?, 1, 1, ?, ?)')
- .run(c.client_id, c.name, JSON.stringify(c.redirect_uris), c.allowed_scopes, c.access_token_ttl_s);
+ await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public, allowed_scopes, access_token_ttl_s, session_token) VALUES (?, NULL, ?, ?, 1, 1, ?, ?, ?)')
+ .run(c.client_id, c.name, JSON.stringify(c.redirect_uris), c.allowed_scopes, c.access_token_ttl_s, c.session ? 1 : 0);
console.log(`[DB] Seeded public OAuth2 client: ${c.client_id} (no secret)`);
console.log(`[DB] Seeded public OAuth2 client: ${c.client_id} (no secret)`);
} else {
} else {
await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)')
await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)')
@@ -210,8 +219,8 @@ async function seedDb(db, { log = console } = {}) {
@@ -210,8 +219,8 @@ async function seedDb(db, { log = console } = {}) {
}
}
await mergeRedirectUris(db, c.client_id, c.redirect_uris, { log });
await mergeRedirectUris(db, c.client_id, c.redirect_uris, { log });
if (c.public) {
if (c.public) {
- await db.prepare('UPDATE oauth_clients SET name = ?, is_public = 1, allowed_scopes = ?, access_token_ttl_s = ?, client_secret = NULL, redirect_uris = ? WHERE client_id = ?')
- .run(c.name, c.allowed_scopes, c.access_token_ttl_s, JSON.stringify(c.redirect_uris), c.client_id);
+ await db.prepare('UPDATE oauth_clients SET name = ?, is_public = 1, allowed_scopes = ?, access_token_ttl_s = ?, session_token = ?, client_secret = NULL, redirect_uris = ? WHERE client_id = ?')
+ .run(c.name, c.allowed_scopes, c.access_token_ttl_s, c.session ? 1 : 0, JSON.stringify(c.redirect_uris), c.client_id);
}
}
// A client created first as a service principal carries the placeholder name "OpenVibe.<id> (service)",
// A client created first as a service principal carries the placeholder name "OpenVibe.<id> (service)",
// which the sign-in page shows people ("Sign in to OpenVibe.food (service)"). Give it the site's name; a
// which the sign-in page shows people ("Sign in to OpenVibe.food (service)"). Give it the site's name; a
@@ -1047,6 +1047,10 @@ app.get('/verify-email', (req, res) => {
@@ -1047,6 +1047,10 @@ app.get('/verify-email', (req, res) => {
lookBoot.send(res, 'verify-email.html');
lookBoot.send(res, 'verify-email.html');
});
});
+// Android App Links for OpenVibe Link (plan 2026-11-07 §13.2): /.well-known/assetlinks.json claims /link/auth for the
+// release certificate in LINK_ANDROID_CERT_SHA256, and /link/auth is the plain page a browser shows when no app did.
+require('./auth/app-links').mount(app);
+
// FedCM identity provider (browser-native sign-in for the other OpenVibe sites) — server/auth/fedcm.js
// FedCM identity provider (browser-native sign-in for the other OpenVibe sites) — server/auth/fedcm.js
{
{
const fedcm = require('./auth/fedcm');
const fedcm = require('./auth/fedcm');
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
+'use strict';
+// OpenVibe Link on the phone (plan 2026-11-07 §13.2, security round 2): a public first-party OAuth client (`link`) with
+// no secret and PKCE S256. Its session — the person's own token, so Actor and the drive accept it — is issued only when
+// BOTH hold: the authorization returned to the claimed https App Link (https://openvibe.network/link/auth, claimed in
+// /.well-known/assetlinks.json for the release certificate) and LINK_ANDROID_CERT_SHA256 publishes at least one
+// fingerprint. A custom scheme any app can answer, and an unpublished certificate, get the extension's read-only Dock
+// token instead. The chooser always says what the app does, and prompt=none is refused.
+//
+// node --import ./test/helpers/pg-preload.mjs test/oauth-link.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const oauthRoutes = require('../server/auth/oauth-routes');
+const authRoutes = require('../server/auth/routes');
+const appLinks = require('../server/auth/app-links');
+const { makeRequireAuth } = require('../server/auth/session');
+
+const ISSUER = 'https://openvibe.network';
+const APP_LINK = 'https://openvibe.network/link/auth';
+const SCHEME = 'openvibe-link://auth';
+const PRINTS = ['AA:'.repeat(31) + 'AA', 'BB:'.repeat(31) + 'BB'];
+const b64 = (buf) => Buffer.from(buf).toString('base64url');
+const verifier = () => b64(crypto.randomBytes(32));
+const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
+
+(async () => {
+ const db = getDb();
+ const quiet = console.log; console.log = () => {};
+ const keys = crypto.generateKeyPairSync('rsa', {
+ modulusLength: 2048,
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ });
+ const config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '7d' } };
+ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'phoneana', '[email protected]', 'x', 'user')").run();
+ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (2, 'phoneadmin', '[email protected]', 'x', 'admin')").run();
+ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (3, 'phonestreamer', '[email protected]', 'x', 'streamer')").run();
+ const user = await db.prepare('SELECT * FROM users WHERE id = 1').get();
+ const admin = await db.prepare('SELECT * FROM users WHERE id = 2').get();
+ const streamer = await db.prepare('SELECT * FROM users WHERE id = 3').get();
+ for (const u of [user, admin, streamer]) await subjects.ensureUserSubject(db, u);
+
+ const requireAuth = makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
+ const requireDock = (scope) => makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken, { dockScope: scope });
+ const app = express();
+ app.use(express.json());
+ app.use(express.urlencoded({ extended: true }));
+ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+ app.use('/oauth', oauthRoutes);
+ appLinks.mount(app);
+ app.get('/api/v1/me/private', requireAuth, (req, res) => res.json({ ok: true, user: req.user.username }));
+ app.get('/api/v1/me/actions', requireDock('activity'), (req, res) => res.json({ ok: true }));
+ app.post('/api/v1/me/actions', requireAuth, (req, res) => res.json({ ok: true }));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const authorize = async (query) => {
+ const r = await fetch(`${base}/oauth/authorize?${new URLSearchParams(query)}`, { redirect: 'manual' });
+ return { status: r.status, location: r.headers.get('location') || null };
+ };
+ const confirm = async (token, body) => {
+ const r = await fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token, ...body }) });
+ return { status: r.status, body: await r.json().catch(() => null) };
+ };
+ const token = async (body) => {
+ const r = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body) });
+ return { status: r.status, body: await r.json().catch(() => null) };
+ };
+ const api = async (method, route, tok) => {
+ const r = await fetch(`${base}${route}`, { method, headers: { authorization: `Bearer ${tok}` } });
+ return { status: r.status, body: await r.json().catch(() => null) };
+ };
+ const info = async (query) => {
+ const r = await fetch(`${base}/oauth/client-info?${new URLSearchParams(query)}`);
+ return { status: r.status, body: await r.json().catch(() => null) };
+ };
+ /** A full code grant for one redirect and one person, with the verifier it must be redeemed with. */
+ const grant = async (redirectUri, who = user) => {
+ const v = verifier();
+ const state = 'state_' + crypto.randomBytes(12).toString('hex');
+ const start = await authorize({ client_id: 'link', redirect_uri: redirectUri, response_type: 'code', scope: 'profile theme', state, code_challenge: challenge(v), code_challenge_method: 'S256' });
+ assert.strictEqual(start.status, 302);
+ assert.match(start.location, /\/login\?/);
+ const cx = await confirm(authRoutes.signToken(who, keys.privateKey, config), { client_id: 'link', redirect_uri: redirectUri, response_type: 'code', scope: 'profile theme', state, code_challenge: challenge(v), code_challenge_method: 'S256' });
+ assert.strictEqual(cx.status, 200, JSON.stringify(cx.body));
+ const redirect = new URL(cx.body.redirect);
+ assert.strictEqual(redirect.searchParams.get('state'), state);
+ const code = redirect.searchParams.get('code');
+ assert.ok(code);
+ const issued = await token({ grant_type: 'authorization_code', client_id: 'link', code, redirect_uri: redirectUri, code_verifier: v });
+ assert.strictEqual(issued.status, 200, JSON.stringify(issued.body));
+ return issued.body;
+ };
+ const claimsOf = (body) => jwt.decode(body.access_token);
+
+ try {
+ // ── The migration is expand only, and the client is seeded with the claimed link and the debug scheme.
+ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0050_oauth_token_session_families.sql'), 'utf8');
+ assert.match(sql, /^-- phase: expand/m);
+ assert.match(sql, /ADD COLUMN IF NOT EXISTS session_family/);
+ assert.doesNotMatch(sql, /ADD COLUMN[^;]*NOT NULL/i);
+
+ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get('link');
+ assert.ok(client, 'the link client is seeded');
+ assert.strictEqual(Number(client.is_public), 1);
+ assert.strictEqual(Number(client.session_token), 1);
+ assert.strictEqual(client.allowed_scopes, 'profile theme');
+ assert.deepStrictEqual(JSON.parse(client.redirect_uris), [APP_LINK, SCHEME]);
+ assert.strictEqual(client.client_secret, null, 'a public client keeps no secret');
+
+ // ── Claimed https redirect (RFC 8252 §7.2): assetlinks.json names the package and the env's certificates.
+ delete process.env.LINK_ANDROID_CERT_SHA256;
+ let links = await (await fetch(`${base}/.well-known/assetlinks.json`)).json();
+ assert.deepStrictEqual(links[0].target.package_name, 'network.openvibe.link');
+ assert.deepStrictEqual(links[0].target.sha256_cert_fingerprints, [], 'no release key, no claim');
+ assert.deepStrictEqual(links[0].relation, ['delegate_permission/common.handle_all_urls']);
+ process.env.LINK_ANDROID_CERT_SHA256 = `${PRINTS[0].toLowerCase()}, not-a-fingerprint,${PRINTS[1]}`;
+ links = await (await fetch(`${base}/.well-known/assetlinks.json`)).json();
+ assert.deepStrictEqual(links[0].target.sha256_cert_fingerprints, PRINTS, 'the env fingerprints, upper-cased, junk dropped');
+
+ // ── /link/auth is the plain page a browser gets when no installed app could claim it.
+ const page = await fetch(`${base}/link/auth?code=SECRET_CODE&state=s`);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.headers.get('x-robots-tag') || '', /noindex/);
+ const html = await page.text();
+ assert.match(html, /Open this in OpenVibe Link/);
+ assert.doesNotMatch(html, /SECRET_CODE/, 'the page never echoes the code');
+
+ // ── Consent is always shown: client-info says what it does, and prompt=none is refused, never silent.
+ const linkInfo = await info({ client_id: 'link', redirect_uri: APP_LINK, scope: 'profile theme' });
+ assert.strictEqual(linkInfo.status, 200);
+ assert.strictEqual(linkInfo.body.act_as_you, true);
+ assert.match(linkInfo.body.consent_text, /act as you/);
+ assert.match(linkInfo.body.consent_text, /approvals/);
+ const companionInfo = await info({ client_id: 'companion', redirect_uri: 'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/', scope: 'dock' });
+ assert.strictEqual(companionInfo.status, 200);
+ assert.strictEqual(companionInfo.body.act_as_you, undefined, 'the extension reads; it does not act as you');
+ const silent = await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme', state: 's0', prompt: 'none', code_challenge: challenge(verifier()), code_challenge_method: 'S256' });
+ assert.strictEqual(silent.status, 302);
+ assert.match(silent.location, /^https:\/\/openvibe\.network\/link\/auth\?/);
+ assert.match(silent.location, /error=interaction_required/);
+ assert.doesNotMatch(silent.location, /code=/, 'no code without the chooser');
+
+ // ── Public client rules still hold: exact redirect, state, PKCE S256, a scope inside the ceiling.
+ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme' })).status, 400, 'state required');
+ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', state: 's', scope: 'profile theme' })).status, 400, 'PKCE required');
+ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', state: 's', scope: 'dock', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'scope ceiling');
+ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: 'openvibe-link://other', response_type: 'code', state: 's', scope: 'theme', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'exact redirect');
+
+ // ── The custom scheme gets the read-only Dock token, even with certificates published: any app can claim it.
+ process.env.LINK_ANDROID_CERT_SHA256 = PRINTS.join(',');
+ const viaScheme = await grant(SCHEME);
+ let claims = claimsOf(viaScheme);
+ assert.strictEqual(claims.iss, `${ISSUER}/dock`, 'the private issuer: no normal user-token check takes it');
+ assert.ok(claims.aud.includes('openvibe.chat'));
+ assert.strictEqual(claims.role, undefined, 'a Dock token carries no role');
+ assert.strictEqual(claims.staff_caps, undefined);
+ assert.strictEqual(claims.client_id, 'link');
+ assert.ok(claims.fam);
+ assert.strictEqual((await api('GET', '/api/v1/me/private', viaScheme.access_token)).status, 401, 'not a session');
+
+ // ── The claimed https link with NO published certificate: Dock too. Android verified nothing.
+ delete process.env.LINK_ANDROID_CERT_SHA256;
+ const unverified = await grant(APP_LINK);
+ assert.strictEqual(claimsOf(unverified).iss, `${ISSUER}/dock`);
+ assert.strictEqual(claimsOf(unverified).role, undefined);
+
+ // ── The claimed https link WITH a certificate: the person's own session.
+ process.env.LINK_ANDROID_CERT_SHA256 = PRINTS.join(',');
+ const session = await grant(APP_LINK);
+ claims = claimsOf(session);
+ assert.strictEqual(session.expires_in, 86400);
+ assert.strictEqual(claims.iss, ISSUER, 'the normal issuer');
+ assert.ok(claims.aud.includes('openvibe.network'), 'the normal audiences');
+ assert.strictEqual(claims.role, 'user');
+ assert.strictEqual(claims.client_id, 'link');
+ assert.strictEqual(claims.scope, 'profile theme');
+ assert.ok(claims.fam, 'the family, so the account page can end it');
+ assert.strictEqual(claims.is_owner, undefined);
+ assert.strictEqual(claims.staff_caps, undefined, 'a session token carries no staff claims');
+ assert.strictEqual(claims.typ, undefined);
+ assert.strictEqual((await api('GET', '/api/v1/me/actions', session.access_token)).status, 200);
+ assert.strictEqual((await api('POST', '/api/v1/me/actions', session.access_token)).status, 200, 'answering an approval');
+ assert.strictEqual((await api('GET', '/api/v1/me/private', session.access_token)).status, 200, 'a plain user token');
+ assert.strictEqual((await api('GET', '/api/v1/me/private', 'nope')).status, 401);
+
+ // ── A staff member's phone acts as a user: role capped, no staff claims. A non-staff role is kept.
+ const adminSession = claimsOf(await grant(APP_LINK, admin));
+ assert.strictEqual(adminSession.role, 'user', "an admin's phone is not an admin");
+ assert.strictEqual(adminSession.staff_caps, undefined);
+ assert.strictEqual(adminSession.is_owner, undefined);
+ assert.strictEqual(claimsOf(await grant(APP_LINK, streamer)).role, 'streamer', 'a streamer is not staff');
+ assert.strictEqual(claimsOf(await grant(APP_LINK, streamer)).staff_caps, undefined);
+
+ // ── A refresh keeps its family's kind: Dock stays Dock, session stays session (while a certificate is published).
+ const dockRefresh = await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: viaScheme.refresh_token });
+ assert.strictEqual(dockRefresh.status, 200, JSON.stringify(dockRefresh.body));
+ assert.strictEqual(claimsOf(dockRefresh.body).iss, `${ISSUER}/dock`, 'a scheme family never becomes a session');
+ assert.notStrictEqual(dockRefresh.body.refresh_token, viaScheme.refresh_token, 'rotated');
+
+ const sessionRefresh = await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: session.refresh_token });
+ assert.strictEqual(sessionRefresh.status, 200, JSON.stringify(sessionRefresh.body));
+ assert.strictEqual(claimsOf(sessionRefresh.body).iss, ISSUER);
+ assert.strictEqual(claimsOf(sessionRefresh.body).role, 'user');
+ assert.strictEqual(claimsOf(sessionRefresh.body).staff_caps, undefined);
+ assert.strictEqual((await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: session.refresh_token })).status, 400, 'the old refresh token is spent');
+
+ // Withdrawing the certificate stops sessions at the next refresh; the family cannot promote itself back.
+ delete process.env.LINK_ANDROID_CERT_SHA256;
+ const downgraded = await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: sessionRefresh.body.refresh_token });
+ assert.strictEqual(downgraded.status, 200, JSON.stringify(downgraded.body));
+ assert.strictEqual(claimsOf(downgraded.body).iss, `${ISSUER}/dock`, 'no certificate, no session');
+
+ // ── A wrong verifier is refused (and spent, as the flow requires).
+ const v2 = verifier();
+ await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme', state: 's2', code_challenge: challenge(v2), code_challenge_method: 'S256' });
+ const cx2 = await confirm(authRoutes.signToken(user, keys.privateKey, config), { client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme', state: 's2', code_challenge: challenge(v2), code_challenge_method: 'S256' });
+ const code2 = new URL(cx2.body.redirect).searchParams.get('code');
+ assert.strictEqual((await token({ grant_type: 'authorization_code', client_id: 'link', code: code2, redirect_uri: APP_LINK, code_verifier: verifier() })).status, 400, 'a wrong verifier is refused');
+ } finally {
+ delete process.env.LINK_ANDROID_CERT_SHA256;
+ await new Promise((r) => server.close(r));
+ console.log = quiet;
+ }
+ console.log('oauth link: all passed');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -20,5 +20,9 @@ assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', us
@@ -20,5 +20,9 @@ assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', us
assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.secrets.manage'), false);
assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.secrets.manage'), false);
// Both user-token signers add them.
// Both user-token signers add them.
const fs = require('fs');
const fs = require('fs');
-for (const f of ['server/auth/routes.js', 'server/auth/oauth-routes.js']) assert.ok(fs.readFileSync(require('path').join(__dirname, '..', f), 'utf8').includes('...staffClaims(user)'), f);
+for (const f of ['server/auth/routes.js', 'server/auth/oauth-routes.js']) assert.ok(fs.readFileSync(require('path').join(__dirname, '..', f), 'utf8').includes('staffClaims(user)'), f);
+// A phone's session token (OpenVibe Link) is not staff authority: oauth-routes leaves the claims out and caps the role.
+const oauth = fs.readFileSync(require('path').join(__dirname, '..', 'server/auth/oauth-routes.js'), 'utf8');
+assert.ok(oauth.includes('sessionClient ? {} : staffClaims(user)'), 'a session client gets no staff claims');
+assert.ok(oauth.includes('nonStaffRole(user.role)'), 'a session client keeps only a non-staff role');
console.log('staff claims: all checks passed');
console.log('staff claims: all checks passed');
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');