openvibers/openvibe.network Commits

Dock: one-click extension sign-in (public client, scopes, revoke, account page) (#445)

OP OpenVibers committed 13 days ago · 31141a7a73 · 26 files, +934 −100

Changes

ddiff --git a/deploy/nginx/openvibe.network.conf b/deploy/nginx/openvibe.network.conf
iindex e0f6e91e..eb3da140 100644
---- a/deploy/nginx/openvibe.network.conf
++++ b/deploy/nginx/openvibe.network.conf
@@@ -78,6 +78,19 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
++ # ── Revocation (same zone as token issuance) ────────────────
++ location = /oauth/revoke {
++ limit_req zone=openvibenetwork_token burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4000;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++
# ── Sign-in redirects (their own limit, above) ──────────
location = /oauth/authorize {
limit_req zone=openvibenetwork_authorize burst=60 nodelay;
ddiff --git a/docs/dock.md b/docs/dock.md
iindex 72c3449d..7aae0e53 100644
---- a/docs/dock.md
++++ b/docs/dock.md
@@@ -13,7 +13,8 @@ Both are the person's own: the subject comes from the caller's token and from no
reads or writes another's state. They take the person's own session — the `ov_token` cookie on openvibe.network, a
Bearer user token anywhere else — and the CORS allow-list and the CSRF rules are exactly the ones the other
`/api/v1/me/*` routes use (the first-party origin allow-list in `server/index.js`; a foreign origin is refused
--server-side).
++server-side). The browser extension's Dock token is the one scoped exception (`dock`; see `docs/sign-in.md`): it is
++accepted only here and only with that scope, and it is never a cookie session.
| Route | Answer | Notes |
|---|---|---|
ddiff --git a/docs/sign-in.md b/docs/sign-in.md
iindex b5401c26..c7c6662a 100644
---- a/docs/sign-in.md
++++ b/docs/sign-in.md
@@@ -54,6 +54,25 @@ can build the same links:
`return` must be an https origin of a first-party site (`GET /api/auth/return-origins` lists them: every domain in the
service manifests). Anything else goes to the Network's home.
++## The browser extension (public client)
++
++The OpenVibe Companion's Dock signs in as `companion`, a **public** first-party client with no secret:
++authorization code + PKCE S256 only, exact redirect URIs
++`https://<chrome-id>.chromiumapp.org/` and
++`https://<sha1-of-addon-id>.extensions.allizom.org/`. Its access token is issued with the private
++issuer `<network issuer>/dock`, the audiences `openvibe.network` and `openvibe.chat`, no `role` or
++`staff_caps`, and only these scopes:
++
++| Scope | The Dock may |
++|---|---|
++| `dock` | read and write the person's Dock state |
++| `activity` | read the action list and notifications, mark them read, get a realtime ticket |
++| `chat.read` | list and read DMs, mark a conversation read |
++
++It may not approve an action, send a message, change the account, spend, or call another service with
++that token. The refresh token lives 30 days, rotates on every use and is revoked by the extension's
++Sign out, by the account page's **Signed-in apps** section (`/my`), or by signing out everywhere.
++
## Signing out
A site's **Sign out** ends that site's session and keeps the browser from signing back in quietly (`ov_sso_hint=guest`).
ddiff --git a/migrations/0032_oauth_public_clients.sql b/migrations/0032_oauth_public_clients.sql
nnew file mode 100644
iindex 00000000..2a5e1e68
---- /dev/null
++++ b/migrations/0032_oauth_public_clients.sql
@@@ -0,0 +1,17 @@
++-- phase: expand
++-- Public OAuth clients (the Dock in the browser extension, 2026-10-30): a first-party client with no
++-- secret, PKCE S256 required, exact redirect URIs and a server-side scope ceiling. Existing
++-- confidential clients keep their secret and their optional PKCE; every new column is nullable or has
++-- a default, so an older release reads the old columns and the new code reads both shapes.
++--
++-- allowed_scopes is the ceiling a public client may ask for; NULL means "not a scoped client" (every
++-- existing row). access_token_ttl_s is the public client's short access-token life; NULL means the
++-- server default. The token claims are Network-private; no contract changes.
++ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS is_public bigint DEFAULT 0;
++ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS allowed_scopes text COLLATE "C";
++ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS access_token_ttl_s integer;
++ALTER TABLE oauth_clients ALTER COLUMN client_secret DROP NOT NULL;
++
++-- The account page lists a person's live refresh families; the Dock guard checks that a family is still
++-- live on every call. Both are lookups by user (and the existing family index covers the family lookup).
++CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user_live ON oauth_tokens(user_id, revoked);
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex bc320bab..2327b40e 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -43,6 +43,7 @@ async function loadUser() {
loadNotifPrefs(); loadQuietHours();
loadLinked();
loadSessions();
++ loadApps();
loadBlocks();
loadAnonInfo();
loadEmailStatus();
ddiff --git a/public/js/my/security.js b/public/js/my/security.js
iindex 16271359..c13f767f 100644
---- a/public/js/my/security.js
++++ b/public/js/my/security.js
@@@ -325,6 +325,53 @@ async function revokeAllSessions() {
} catch (err) { alert('Error: ' + err.message); }
}
++// Signed-in apps (public OAuth clients; the extension's Dock sign-in)
++async function loadApps() {
++ const list = document.getElementById('apps-list');
++ if (!list) return;
++ if (isAnonSession()) { list.textContent = 'Anonymous mode has no app sign-ins.'; return; }
++ try {
++ const data = await apiFetch('/api/auth/apps');
++ list.replaceChildren();
++ const apps = data.apps || [];
++ if (!apps.length) {
++ const p = document.createElement('p');
++ p.style.cssText = 'color:var(--text-muted);font-size:13px';
++ p.textContent = 'No apps are signed in.';
++ list.appendChild(p);
++ return;
++ }
++ const fmt = (v) => {
++ if (!v) return '';
++ const t = new Date(String(v).replace(' ', 'T') + (String(v).endsWith('Z') ? '' : 'Z'));
++ return Number.isFinite(t.getTime()) ? t.toLocaleDateString() : '';
++ };
++ for (const app of apps) {
++ const row = document.createElement('div'); row.className = 'linked-item';
++ const icon = document.createElement('span'); icon.className = 'svc-icon';
++ const i = document.createElement('i'); i.className = 'fa-solid fa-puzzle-piece'; icon.appendChild(i);
++ const main = document.createElement('div');
++ const name = document.createElement('div'); name.className = 'svc-name'; name.textContent = app.name || app.client_id;
++ const meta = document.createElement('div'); meta.className = 'svc-user';
++ const scopes = String(app.scope || '').split(/\s+/).filter(Boolean).join(', ');
++ meta.textContent = `Signed in ${fmt(app.first_seen) || 'recently'} · last used ${fmt(app.last_refresh) || 'recently'}${scopes ? ' · ' + scopes : ''}`;
++ main.append(name, meta);
++ const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-danger';
++ btn.textContent = 'Revoke'; btn.setAttribute('aria-label', `Revoke ${app.name || app.client_id}`);
++ btn.addEventListener('click', async () => {
++ btn.disabled = true;
++ try { await apiFetch('/api/auth/apps/' + encodeURIComponent(app.family_id), { method: 'DELETE' }); }
++ catch { /* the list reload says what is left */ }
++ loadApps();
++ });
++ row.append(icon, main, btn);
++ list.appendChild(row);
++ }
++ } catch {
++ list.textContent = 'Unable to load signed-in apps.';
++ }
++}
++
async function signOutEverywhere() {
if (!confirm('Sign out on every device and every OpenVibe site, this one included?')) return;
try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }
ddiff --git a/public/my.html b/public/my.html
iindex 94a2171b..049e2b0d 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -341,6 +341,11 @@
<button class="btn" onclick="signOutEverywhere()"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere, this device too</button>
</div>
</div>
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-puzzle-piece"></i></span> Signed-in apps</h3>
++ <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Extensions and apps that hold their own sign-in. Revoking one ends its refresh token; the next request asks you to sign in again.</p>
++ <div id="apps-list"><div class="loading-spinner">Loading apps...</div></div>
++ </div>
<div class="card" id="blocks-card">
<h3><span class="icon"><i class="fa-solid fa-user-slash"></i></span> Blocked people</h3>
<p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">People you block cannot message you, mention you or reply to what you post on OpenVibe sites, and you get no notifications from them. Moderators' actions still reach you.</p>
ddiff --git a/server/actions/index.js b/server/actions/index.js
iindex 0986e72e..f78715dd 100644
---- a/server/actions/index.js
++++ b/server/actions/index.js
@@@ -141,11 +141,11 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
}
/** The routes, behind Network's session check (cookie on openvibe.network, Bearer elsewhere), like the bell's. */
--function router({ actions, requireAuth }) {
++function router({ actions, requireAuth, requireDock = () => requireAuth }) {
const r = express.Router();
r.use(express.json({ limit: '4kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
-- r.get('/', requireAuth, async (req, res) => {
++ r.get('/', requireDock('activity'), async (req, res) => {
try {
const actor = await store.actorOf(req.app.locals.db, req.user);
res.json(await actions.forPerson(actor));
ddiff --git a/server/auth/dock-tokens.js b/server/auth/dock-tokens.js
nnew file mode 100644
iindex 00000000..080e7ba1
---- /dev/null
++++ b/server/auth/dock-tokens.js
@@@ -0,0 +1,121 @@
++'use strict';
++/**
++ * The Dock's private access token (the one-click extension sign-in, 2026-10-30).
++ *
++ * The extension is a public OAuth client (`companion`) with no secret. Its access token is a normal
++ * person's token in every way except two:
++ *
++ * * its issuer is `<network issuer>/dock`, so `verifySession` and every other service's normal
++ * `verifyUserToken` (which checks the Network issuer) refuse it — only the routes that opt in
++ * below accept it;
++ * * it carries `client_id`, `scope` and `fam`, no `role` and no `staff_caps`; it is a read-only
++ * Dock credential, never staff authority.
++ *
++ * The token is not a sliding session: it lives for the client's `access_token_ttl_s` (one hour) and
++ * then the extension refreshes. A revoke (Sign out, the account page) revokes the refresh family; the
++ * family check here makes the access token stop at once on Network. Chat cannot see this database, so
++ * a revoked access token can live there until its `exp`; that is stated on the account page.
++ */
++const jwt = require('jsonwebtoken');
++
++const CLIENT_ID = 'companion';
++const CLIENT_NAME = 'OpenVibe extension';
++const SCOPES = Object.freeze(['dock', 'activity', 'chat.read']);
++const AUDIENCES = Object.freeze(['openvibe.network', 'openvibe.chat']);
++const FAMILY_RE = /^fam_[0-9a-f]{24}$/;
++const SCOPE_RE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
++
++function isPublicClient(client) {
++ return !!client && Number(client.is_public) === 1;
++}
++
++function isDockClient(client) {
++ return isPublicClient(client) && client.client_id === CLIENT_ID;
++}
++
++function dockIssuer(config) {
++ return `${String(config.jwt.issuer || '').replace(/\/+$/, '')}/dock`;
++}
++
++function scopesOf(claims) {
++ return String((claims && claims.scope) || '').split(/\s+/).filter(Boolean);
++}
++
++function hasScope(claims, scope) {
++ return scopesOf(claims).includes(scope);
++}
++
++function allowedScopes(client) {
++ return String((client && client.allowed_scopes) || '').split(/\s+/).filter(Boolean);
++}
++
++/**
++ * `{ scope }` or `{ error }`. Only a public client is scoped; every existing confidential client keeps
++ * its unscoped token. An absent/empty scope is refused (not "all"), and a duplicate or unknown id is
++ * refused rather than silently dropped.
++ */
++function readScopeForClient(client, raw) {
++ if (!isPublicClient(client)) return { scope: null };
++ const list = String(raw == null ? '' : raw).trim().split(/\s+/).filter(Boolean);
++ if (!list.length) return { error: 'scope is required' };
++ if (list.length > 20) return { error: 'too many scopes' };
++ if (new Set(list).size !== list.length) return { error: 'duplicate scope' };
++ const allowed = new Set(allowedScopes(client));
++ const bad = list.filter((s) => !SCOPE_RE.test(s) || !allowed.has(s));
++ if (bad.length) return { error: `scope not allowed: ${bad.join(' ')}` };
++ return { scope: list.join(' ') };
++}
++
++/** A non-empty, bounded `state` for a public client; the server only echoes it, the app compares it. */
++function readState(raw) {
++ const value = String(raw == null ? '' : raw);
++ if (!value || value.length > 256) return null;
++ return value;
++}
++
++async function verifyDockSession(token, { db, publicKey, config }) {
++ if (!token) return { error: 'Authentication required', status: 401 };
++ const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
++ let decoded;
++ try {
++ decoded = jwt.verify(token, publicKey, {
++ algorithms: [algorithm],
++ issuer: dockIssuer(config),
++ audience: 'openvibe.network',
++ });
++ } catch (err) {
++ return { error: 'Invalid or expired token', status: 401 };
++ }
++ if (decoded.typ !== undefined || decoded.purpose !== undefined) return { error: 'Invalid token', status: 401 };
++ if (decoded.actor_type !== undefined) return { error: 'Invalid token', status: 401 };
++ if (decoded.client_id !== CLIENT_ID) return { error: 'Invalid token', status: 401 };
++ if (decoded.role !== undefined || decoded.staff_caps !== undefined) return { error: 'Invalid token', status: 401 };
++ if (!FAMILY_RE.test(String(decoded.fam || ''))) return { error: 'Invalid token', status: 401 };
++
++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
++ if (!user) return { error: 'User not found', status: 401 };
++ if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
++ if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };
++ if (user.deleted_at) return { error: 'Account deleted', status: 401, deleted: true };
++ if (user.token_valid_after) {
++ const validAfter = new Date(String(user.token_valid_after).replace(' ', 'T') + (/[zZ]$/.test(String(user.token_valid_after)) ? '' : 'Z')).getTime();
++ if (Number(decoded.iat) * 1000 < validAfter) return { error: 'Token revoked', status: 401 };
++ }
++
++ // The family must still have one live refresh generation: Sign out and the account page revoke
++ // every generation, so the access token ends here too. `expires_at` is text; parse it in JS.
++ const live = await db.prepare(`SELECT expires_at FROM oauth_tokens
++ WHERE family_id = ? AND client_id = ? AND user_id = ? AND revoked = 0
++ ORDER BY generation DESC LIMIT 1`).get(decoded.fam, CLIENT_ID, user.id);
++ if (!live) return { error: 'Token revoked', status: 401 };
++ const expires = new Date(String(live.expires_at).replace(' ', 'T') + (/[zZ]$/.test(String(live.expires_at)) ? '' : 'Z')).getTime();
++ if (!(expires > Date.now())) return { error: 'Token expired', status: 401 };
++
++ return { user, decoded, scopes: scopesOf(decoded), dock: true, renew: false };
++}
++
++module.exports = {
++ CLIENT_ID, CLIENT_NAME, SCOPES, AUDIENCES, FAMILY_RE,
++ isPublicClient, isDockClient, dockIssuer, scopesOf, hasScope, allowedScopes,
++ readScopeForClient, readState, verifyDockSession,
++};
ddiff --git a/server/auth/extension-origin.js b/server/auth/extension-origin.js
nnew file mode 100644
iindex 00000000..a5e6d063
---- /dev/null
++++ b/server/auth/extension-origin.js
@@@ -0,0 +1,16 @@
++'use strict';
++// A browser extension page is not a web origin. Chrome sends `Origin: chrome-extension://<id>` for
++// some extension fetches and Firefox can send `moz-extension://<uuid>`; the UUID is random per install,
++// so there is no origin to put on an allow-list. The rules are the same for both:
++//
++// * the origin alone is never a reason to answer 403;
++// * no Access-Control-Allow-Origin / Access-Control-Allow-Credentials is sent (an extension page with
++// its host permissions does not need CORS, and a page origin must never get a credentialed grant);
++// * a cookie session is never accepted from such an origin — only a Bearer token is.
++const EXTENSION_ORIGIN_RE = /^(?:chrome-extension|moz-extension):\/\/[^/]+$/;
++
++function isExtensionOrigin(origin) {
++ return typeof origin === 'string' && EXTENSION_ORIGIN_RE.test(origin.trim());
++}
++
++module.exports = { isExtensionOrigin };
ddiff --git a/server/auth/oauth-limit.js b/server/auth/oauth-limit.js
nnew file mode 100644
iindex 00000000..50d3bb10
---- /dev/null
++++ b/server/auth/oauth-limit.js
@@@ -0,0 +1,25 @@
++'use strict';
++// OAuth endpoints are unauthenticated on purpose (a password check, a code exchange, a refresh), so
++// they get their own limit rather than the general /api one. The key is the address AND the client:
++// one developer app cannot spend the whole address's budget, and one address cannot hammer one app.
++// Same shape as the nginx openvibenetwork_token zone (60 a minute, burst 30); nginx still does its own
++// per-address check in front of this.
++const rateLimit = require('express-rate-limit');
++
++function oauthKey(req) {
++ const client = String((req.body && req.body.client_id) || (req.query && req.query.client_id) || '').slice(0, 80);
++ return `${req.ip}:${client || 'anonymous'}`;
++}
++
++function createOAuthLimiter({ windowMs = 60_000, max = 60 } = {}) {
++ return rateLimit({
++ windowMs,
++ max,
++ standardHeaders: 'draft-7',
++ legacyHeaders: false,
++ keyGenerator: oauthKey,
++ message: { error: 'too_many_requests', error_description: 'Too many OAuth requests; wait a minute.' },
++ });
++}
++
++module.exports = { createOAuthLimiter, oauthKey };
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex c02b6053..5f648771 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -15,6 +15,8 @@ const devTokens = require('../developer/tokens');
const devPolicy = require('../developer/policy');
const oidc = require('./oidc');
const refreshTokens = require('./refresh-tokens');
++const dock = require('./dock-tokens');
++const { isExtensionOrigin } = require('./extension-origin');
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
@@@ -40,6 +42,27 @@ function sameSecret(a, b) {
const y = Buffer.from(String(b || ''));
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
}
++
++/**
++ * The authorize/confirm parameters a public client must send: a bounded state, a PKCE S256
++ * challenge, and a scope inside the client's allowed_scopes. Confidential first-party clients keep
++ * their existing behaviour (optional PKCE, raw scope, no state requirement).
++ */
++function publicAuthorizeParams(client, src) {
++ if (!dock.isPublicClient(client)) {
++ const pkce = readChallenge(src);
++ if (pkce.error) return { error: pkce.error };
++ return { pkce, scope: src.scope || null, state: String(src.state == null ? '' : src.state) };
++ }
++ const state = dock.readState(src.state);
++ if (!state) return { error: 'state is required' };
++ const pkce = readChallenge(src);
++ if (pkce.error) return { error: pkce.error };
++ if (!pkce.challenge) return { error: 'code_challenge is required (S256)' };
++ const scoped = dock.readScopeForClient(client, src.scope);
++ if (scoped.error) return { error: scoped.error };
++ return { pkce, scope: scoped.scope, state };
++}
async function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonce }) {
const code = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
@@@ -97,28 +120,33 @@ router.get('/authorize', async (req, res) => {
if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
return res.status(400).json({ error: 'Invalid redirect_uri' });
}
-- const pkce = readChallenge(req.query);
-- if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
++ const allowed = publicAuthorizeParams(client, req.query);
++ if (allowed.error) return res.status(400).json({ error: 'invalid_request', error_description: allowed.error });
++ const { pkce } = allowed;
++ const requestScope = allowed.scope;
++ const requestState = allowed.state;
const nonce = oidc.cleanNonce(req.query.nonce);
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
// 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.
++ // session, bounce straight back with error=login_required so the app can stay quiet. From an
++ // extension origin a cookie never counts (server/auth/session.js): only a Bearer would, and an
++ // authorize navigation does not carry one.
if (String(req.query.prompt || '') === 'none') {
const sep = redirect_uri.includes('?') ? '&' : '?';
try {
const { verifySession, setSessionCookies } = require('./session');
-- const have = req.cookies?.ov_token || req.cookies?.ov_sso;
++ const have = isExtensionOrigin(req.headers.origin) ? null : (req.cookies?.ov_token || req.cookies?.ov_sso);
const out = await verifySession(have, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });
if (!out.error) {
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req), { renew: out.decoded }) : have;
-- setSessionCookies(res, token);
-- const code = await issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope, pkce, nonce });
-- return res.redirect(withCode(redirect_uri, code, state));
++ if (!isExtensionOrigin(req.headers.origin)) setSessionCookies(res, token);
++ const code = await issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope: requestScope, pkce, nonce });
++ return res.redirect(withCode(redirect_uri, code, requestState));
}
} catch { /* fall through */ }
-- return res.redirect(`${redirect_uri}${sep}error=login_required&state=${encodeURIComponent(state || '')}`);
++ return res.redirect(`${redirect_uri}${sep}error=login_required&state=${encodeURIComponent(requestState || '')}`);
}
// Otherwise the account chooser — the user picks which account to continue with
const loginParams = new URLSearchParams({
@@@ -126,8 +154,8 @@ router.get('/authorize', async (req, res) => {
client_name: client.name || client_id,
redirect_uri,
response_type,
-- scope: scope || 'profile theme',
-- state: state || '',
++ scope: requestScope || 'profile theme',
++ state: requestState || '',
});
if (pkce.challenge) { loginParams.set('code_challenge', pkce.challenge); loginParams.set('code_challenge_method', pkce.method); }
if (nonce) loginParams.set('nonce', nonce);
@@@ -196,6 +224,8 @@ router.post('/confirm', async (req, res) => {
const devApp = devTokens.isAppClient(client_id) ? await devTokens.checkAuthorizeRequest(db, req.body) : null;
if (devApp && devApp.error) return res.status(400).json({ error: devApp.pkce ? 'invalid_request' : devApp.error, error_description: devApp.error });
let pkce = null;
++ let requestScope;
++ let requestState;
if (!devApp) {
// Validate client
const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
@@@ -206,8 +236,11 @@ router.post('/confirm', async (req, res) => {
if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
return res.status(400).json({ error: 'Invalid redirect_uri' });
}
-- pkce = readChallenge(req.body);
-- if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
++ const allowed = publicAuthorizeParams(client, req.body);
++ if (allowed.error) return res.status(400).json({ error: 'invalid_request', error_description: allowed.error });
++ pkce = allowed.pkce;
++ requestScope = allowed.scope;
++ requestState = allowed.state;
}
const nonce = oidc.cleanNonce(req.body.nonce);
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
@@@ -232,13 +265,13 @@ router.post('/confirm', async (req, res) => {
}
// Issue authorization code (bound to the PKCE challenge when the client sent one)
-- const code = await issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce, nonce });
++ const code = await issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope: requestScope !== undefined ? requestScope : scope, pkce, nonce });
// Also set cookie to this account so openvibe.network itself knows the active session.
// Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.
require('./session').setSessionCookies(res, token);
-- res.json({ redirect: withCode(redirect_uri, code, state) });
++ res.json({ redirect: withCode(redirect_uri, code, requestState !== undefined ? requestState : state) });
});
// ── POST /token ──────────────────────────────────────────────
@@@ -250,6 +283,13 @@ router.post('/token', async (req, res) => {
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
res.set('Cache-Control', 'no-store'); // every grant answers with tokens (RFC 6749 §5.1)
++ // A public first-party client has no secret and only the two user grants. Resolve it before the
++ // service-principal branch so client_credentials can never mint a service token for it.
++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
++ if (client && dock.isPublicClient(client) && !['authorization_code', 'refresh_token'].includes(grant_type)) {
++ return res.status(400).json({ error: 'unauthorized_client', error_description: 'a public client uses authorization_code or refresh_token' });
++ }
++
// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
if (devTokens.isAppClient(client_id)) {
const out = await devTokens.handleTokenRequest(db, req.body, { privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer, config, ctx: req.ov });
@@@ -282,9 +322,12 @@ router.post('/token', async (req, res) => {
return res.status(out.status).json(out.body);
}
-- // Validate client credentials
-- const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
-- if (!client || !sameSecret(client.client_secret, client_secret)) {
++ // Validate client credentials. A public client sends none; every confidential client keeps the
++ // required secret and the old code path exactly.
++ if (!client) return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
++ if (dock.isPublicClient(client)) {
++ if (client_secret) return res.status(400).json({ error: 'invalid_request', error_description: 'public clients do not send a client_secret' });
++ } else if (!sameSecret(client.client_secret, client_secret)) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
@@@ -307,7 +350,7 @@ router.post('/token', async (req, res) => {
* owns service_user_id; the exchange never overwrites it, only bumps last_used_at.
*/
async function recordLinkedService(db, user, client) {
-- if (!client?.client_id || !user?.id || user.is_anon) return;
++ if (!client?.client_id || !user?.id || user.is_anon || dock.isPublicClient(client)) return;
try {
await db.prepare(`
INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at, last_used_at)
@@@ -332,8 +375,15 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
const expiresAt = new Date(authCode.expires_at + (authCode.expires_at.includes('Z') ? '' : 'Z'));
if (now > expiresAt) return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code expired' });
-- // A code bound to a PKCE challenge is only redeemable with the matching verifier.
-- if (authCode.code_challenge && !verifierMatches(codeVerifier, authCode.code_challenge)) {
++ // A public client's code must have been issued with a PKCE S256 challenge and be redeemed with
++ // the matching verifier; a confidential client keeps today's optional-PKCE behaviour. A failed
++ // check burns the code.
++ const publicClient = dock.isPublicClient(client);
++ if (publicClient && (!authCode.code_challenge || !verifierMatches(codeVerifier, authCode.code_challenge))) {
++ await db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?').run(code);
++ return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
++ }
++ if (!publicClient && authCode.code_challenge && !verifierMatches(codeVerifier, authCode.code_challenge)) {
await db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?').run(code);
return res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
}
@@@ -348,7 +398,8 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
// Issue tokens
-- const { accessToken, refreshToken } = await issueTokenPair(db, config, req, user, client);
++ const issued = await issueTokenPair(db, config, req, user, client, {}, authCode.scope);
++ const { accessToken, refreshToken } = issued;
await recordLinkedService(db, user, client);
// Get preferences for theme sync
@@@ -363,11 +414,12 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'Bearer',
-- expires_in: 86400, // 24h
++ expires_in: issued.expiresIn,
scope: authCode.scope,
...(idToken ? { id_token: idToken } : {}),
-- user: safeUser,
-- preferences: prefs || { theme_id: 'vibe' },
++ ...(publicClient
++ ? { user: { id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url } }
++ : { user: safeUser, preferences: prefs || { theme_id: 'vibe' } }),
});
}
@@@ -389,11 +441,11 @@ async function handleFedcmAssertionGrant(db, config, req, res, client, assertion
}
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
-- const { accessToken, refreshToken } = await issueTokenPair(db, config, req, user, client);
++ const issued = await issueTokenPair(db, config, req, user, client);
await recordLinkedService(db, user, client);
const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
const { password_hash, token_valid_after, ...safeUser } = user;
-- res.json({ access_token: accessToken, refresh_token: refreshToken, token_type: 'Bearer', expires_in: 86400, scope: 'profile theme', user: safeUser, preferences: prefs || { theme_id: 'vibe' } });
++ res.json({ access_token: issued.accessToken, refresh_token: issued.refreshToken, token_type: 'Bearer', expires_in: issued.expiresIn, scope: 'profile theme', user: safeUser, preferences: prefs || { theme_id: 'vibe' } });
}
async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
@@@ -419,49 +471,86 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
}
// Issue new token pair: the next generation of the same family
-- const { accessToken, refreshToken: newRefresh } = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation });
++ const issued = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation }, stored.scope);
await recordLinkedService(db, user, client);
res.json({
-- access_token: accessToken,
-- refresh_token: newRefresh,
++ access_token: issued.accessToken,
++ refresh_token: issued.refreshToken,
token_type: 'Bearer',
-- expires_in: 86400,
++ expires_in: issued.expiresIn,
scope: stored.scope,
});
}
--async function issueTokenPair(db, config, req, user, client, family = {}) {
++async function issueTokenPair(db, config, req, user, client, family = {}, scope = null) {
const privateKey = req.app.locals.privateKey;
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
--
-- const accessToken = jwt.sign(
-- {
-- sub: user.id,
-- id: user.id,
-- subject_id: await subjects.ensureUserSubject(db, user) || undefined,
-- username: user.username,
-- display_name: user.display_name || user.username,
-- role: user.role,
-- avatar_url: user.avatar_url,
-- profile_color: user.profile_color,
-- ...staffClaims(user),
-- },
-- privateKey,
-- {
-- algorithm,
-- issuer: config.jwt.issuer,
-- audience: ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'],
-- expiresIn: config.jwt.accessTokenExpiry,
-- }
-- );
++ const publicClient = dock.isPublicClient(client);
++ 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;
++
++ const base = {
++ sub: user.id,
++ id: user.id,
++ subject_id: subjectId,
++ username: user.username,
++ display_name: user.display_name || user.username,
++ avatar_url: user.avatar_url,
++ profile_color: user.profile_color,
++ };
++ // 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
++ ? { ...base, client_id: client.client_id, scope: String(scope || ''), fam: familyId }
++ : { ...base, role: user.role, ...staffClaims(user) };
++
++ 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'],
++ expiresIn: publicClient ? ttl : config.jwt.accessTokenExpiry,
++ });
// 30 days; only its SHA-256 is stored. A sign-in starts a family; a refresh continues it.
-- const refreshTokenValue = await refreshTokens.issue(db, { clientId: client.client_id, userId: user.id, familyId: family.familyId, generation: family.generation || 0 });
++ const issued = await refreshTokens.issue(db, {
++ clientId: client.client_id, userId: user.id, scope: publicClient ? String(scope || '') : 'profile theme',
++ familyId, generation: family.generation || 0,
++ });
-- return { accessToken, refreshToken: refreshTokenValue };
++ return { accessToken, refreshToken: issued.token, expiresIn: publicClient ? ttl : 86400, familyId: issued.familyId };
}
++/**
++ * RFC 7009 revoke. The extension sends its refresh token here on Sign out; the account page revokes a
++ * family directly. A public client authenticates with possession of the token alone (no secret); a
++ * confidential client still authenticates with its secret. Unknown, expired and already-revoked tokens
++ * answer 200 {} so the endpoint is not an oracle.
++ */
++router.post('/revoke', async (req, res) => {
++ const db = await getDb(req);
++ res.set('Cache-Control', 'no-store');
++ const token = String(req.body?.token || '');
++ const clientId = String(req.body?.client_id || '');
++ if (!clientId) return res.status(400).json({ error: 'invalid_request', error_description: 'client_id is required' });
++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId);
++ if (!client) return res.json({});
++ if (!dock.isPublicClient(client) && !sameSecret(client.client_secret, req.body?.client_secret)) {
++ return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
++ }
++ const row = await refreshTokens.find(db, token);
++ if (row && row.client_id === clientId) {
++ const familyId = row.family_id || `fam_legacy_${row.id}`;
++ await refreshTokens.revokeFamily(db, familyId, 'signed_out');
++ try {
++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(row.user_id, 'oauth_refresh_revoked', JSON.stringify({ client_id: clientId, family_id: familyId }));
++ } catch { /* audit is best effort */ }
++ }
++ return res.json({});
++});
++
// ── GET|POST /oauth/userinfo ─────────────────────────────────
// OpenID Connect UserInfo: standard claims for a Network access token (Bearer).
router.get('/userinfo', oidc.userinfo);
ddiff --git a/server/auth/refresh-tokens.js b/server/auth/refresh-tokens.js
iindex d62bece9..184798b8 100644
---- a/server/auth/refresh-tokens.js
++++ b/server/auth/refresh-tokens.js
@@@ -33,12 +33,17 @@ const at = (v) => (v ? new Date(String(v).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\
/** Columns and index (boot, idempotent). No data is changed here. */
async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T2); nothing is created at runtime */ }
--/** Store a new refresh token; returns the token (the only time it exists in clear). */
++/**
++ * Store a new refresh token; returns `{ token, familyId }` (the token is the only time it exists in
++ * 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() }) {
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(), familyId || newFamily(), generation);
-- return token;
++ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), fam, generation);
++ return { token, familyId: fam };
}
/** The stored row for a presented token, or null. Never matches a stored hash presented as a token. */
@@@ -91,4 +96,4 @@ async function rotate(db, presented, clientId, { now = Date.now() } = {}) {
return { ok: true, row, familyId: row.family_id || `fam_legacy_${row.id}`, generation: (row.generation || 0) + 1 };
}
--module.exports = { ensureSchema, issue, find, rotate, revokeFamily, hash, PREFIX, REUSE_GRACE_S, TTL_MS };
++module.exports = { ensureSchema, issue, find, rotate, revokeFamily, newFamily, hash, PREFIX, REUSE_GRACE_S, TTL_MS };
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 8dadd045..a97e746d 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -775,6 +775,59 @@ router.delete('/sessions', requireAuth, async (req, res) => {
res.json({ ok: true, revoked: active, token: signToken(user, req.app.locals.privateKey, getConfig(req), { renew: req.tokenClaims || {} }) });
});
++// ── Signed-in apps (public OAuth clients) ────────────────────
++// The extension's refresh family appears here once per sign-in (one per browser). Only public clients
++// are listed: the first-party site sessions stay in Linked Services / Active Sessions. Revoking a
++// family ends the refresh token; Network's Dock guard also refuses its access token at once.
++const APP_FAMILY_RE = /^fam_(?:[0-9a-f]{24}|legacy_\d+)$/;
++
++router.get('/apps', requireAuth, async (req, res) => {
++ const db = await getDb(req);
++ const rows = await db.prepare(`
++ SELECT t.family_id, t.client_id, t.scope, t.created_at, t.expires_at, c.name
++ FROM oauth_tokens t
++ JOIN oauth_clients c ON c.client_id = t.client_id
++ WHERE t.user_id = ? AND t.revoked = 0 AND c.is_public = 1
++ ORDER BY t.created_at DESC
++ `).all(req.user.id);
++ const byFamily = new Map();
++ for (const row of rows) {
++ if (!row.family_id || !APP_FAMILY_RE.test(String(row.family_id))) continue;
++ const expires = new Date(String(row.expires_at).replace(' ', 'T') + (/[zZ]$/.test(String(row.expires_at)) ? '' : 'Z')).getTime();
++ if (!(expires > Date.now())) continue;
++ const current = byFamily.get(row.family_id);
++ if (!current) {
++ byFamily.set(row.family_id, {
++ family_id: row.family_id, client_id: row.client_id, name: row.name || row.client_id,
++ scope: row.scope || '', first_seen: row.created_at, last_refresh: row.created_at, expires_at: row.expires_at,
++ });
++ } else {
++ if (String(row.created_at) < String(current.first_seen)) current.first_seen = row.created_at;
++ if (String(row.created_at) > String(current.last_refresh)) {
++ current.last_refresh = row.created_at;
++ current.scope = row.scope || current.scope;
++ current.expires_at = row.expires_at;
++ }
++ }
++ }
++ res.json({ ok: true, apps: [...byFamily.values()].sort((a, b) => String(b.last_refresh).localeCompare(String(a.last_refresh))) });
++});
++
++router.delete('/apps/:family', requireAuth, async (req, res) => {
++ const db = await getDb(req);
++ const family = String(req.params.family || '');
++ if (!APP_FAMILY_RE.test(family)) return res.json({ ok: true, revoked: 0 });
++ const row = await db.prepare('SELECT client_id FROM oauth_tokens WHERE family_id = ? AND user_id = ? LIMIT 1').get(family, req.user.id);
++ if (!row) return res.json({ ok: true, revoked: 0 });
++ const changed = await db.prepare("UPDATE oauth_tokens SET revoked = 1, revoked_reason = 'revoked_by_user', revoked_at = ov_now() WHERE family_id = ? AND user_id = ? AND revoked = 0")
++ .run(family, req.user.id);
++ try {
++ await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
++ .run(req.user.id, 'oauth_app_revoked', JSON.stringify({ client_id: row.client_id, family_id: family }));
++ } catch { /* audit is best effort */ }
++ res.json({ ok: true, revoked: changed.changes });
++});
++
// ═══════════════════════════════════════════════════════════════
// User Card / Public Profile
// ═══════════════════════════════════════════════════════════════
ddiff --git a/server/auth/session.js b/server/auth/session.js
iindex fc188cdd..29c89146 100644
---- a/server/auth/session.js
++++ b/server/auth/session.js
@@@ -10,6 +10,8 @@
*/
'use strict';
const jwt = require('jsonwebtoken');
++const { isExtensionOrigin } = require('./extension-origin');
++const { verifyDockSession, hasScope } = require('./dock-tokens');
const GRACE_MS = 60 * 24 * 60 * 60 * 1000; // renewable for 60 days after the token's exp
@@@ -73,37 +75,69 @@ function clearSessionCookies(res) {
/**
* The session token a request carries: Bearer header, then the page cookie, then the cross-site
* one. Only for endpoints that change nothing (/sso/check) — see makeRequireAuth for why.
++ * From an extension origin only the Bearer counts: a cookie the browser happens to hold must never
++ * act for an extension page.
*/
function requestToken(req) {
const h = req.headers?.authorization;
if (h && h.startsWith('Bearer ')) return h.slice(7);
++ if (isExtensionOrigin(req.headers?.origin)) return null;
return req.cookies?.ov_token || req.cookies?.ov_sso || null;
}
--/** Express guard factory: attaches req.user / req.token; slides the session when due. */
--function makeRequireAuth(getCtx, signToken) {
++
++/** The token `requireAuth` reads: Bearer, else the page cookie (never ov_sso); none from an extension origin without Bearer. */
++function sessionToken(req) {
++ const h = req.headers?.authorization;
++ if (h && h.startsWith('Bearer ')) return h.slice(7);
++ if (isExtensionOrigin(req.headers?.origin)) return null;
++ return req.cookies?.ov_token || null;
++}
++
++/**
++ * Express guard factory: attaches req.user / req.token; slides the session when due.
++ *
++ * `dockScope` opens the guard to the extension's private-issuer Dock token as well, for that one
++ * scope only. Routes that do not pass it (every account/password/money route) keep refusing the Dock
++ * token because its issuer is not the Network's.
++ */
++function makeRequireAuth(getCtx, signToken, { dockScope = null } = {}) {
return async function requireAuth(req, res, next) {
-- const authHeader = req.headers.authorization;
-- // Never ov_sso here: it is SameSite=None, so a hostile page could make the browser send it
-- // on a cross-site POST. Only the read-only /sso/check and the prompt=none authorize
-- // (which yields a code bound to a registered redirect_uri) may consult it.
-- const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.ov_token;
++ const token = sessionToken(req);
const ctx = await getCtx(req);
-- const out = await verifySession(token, ctx);
++ let out = await verifySession(token, ctx);
++ let dock = false;
++ if (out.error && dockScope) {
++ const dockOut = await verifyDockSession(token, ctx);
++ if (!dockOut.error) {
++ if (!hasScope(dockOut.decoded, dockScope)) {
++ return res.status(403).json({ error: 'insufficient_scope', error_description: `This sign-in can read ${String(dockOut.decoded.scope || '').split(/\s+/).filter(Boolean).join(', ')}, not ${dockScope}.` });
++ }
++ out = dockOut;
++ dock = true;
++ } else if (dockOut.status && dockOut.status !== 401) {
++ out = dockOut;
++ }
++ }
if (out.error) return res.status(out.status).json(out.status === 403 ? { error: out.error, ban_reason: out.ban_reason } : { error: out.error });
req.user = out.user;
req.token = token;
req.tokenClaims = out.decoded; // auth_time: account merge needs a recent real sign-in
-- if (out.renew && typeof signToken === 'function') {
++ req.dockScope = dock ? (out.scopes || []) : null;
++ if (out.renew && !dock && typeof signToken === 'function') {
try {
const fresh = signToken(out.user, req.app.locals.privateKey, ctx.config, { renew: out.decoded });
req.token = fresh;
-- setSessionCookies(res, fresh);
-- res.set('X-OV-Token', fresh); // JS clients swap their stored token
-- res.set('Access-Control-Expose-Headers', 'X-OV-Token');
++ if (isExtensionOrigin(req.headers.origin)) {
++ res.set('X-OV-Token', fresh); // an extension swaps its Bearer; never a cookie
++ } else {
++ setSessionCookies(res, fresh);
++ res.set('X-OV-Token', fresh); // JS clients swap their stored token
++ res.set('Access-Control-Expose-Headers', 'X-OV-Token');
++ }
} catch { /* renewal is best-effort */ }
}
next();
};
}
--module.exports = { verifySession, isUserSessionClaims, makeRequireAuth, GRACE_MS, COOKIE, SSO_COOKIE, setSessionCookies, clearSessionCookies, requestToken };
++module.exports = { verifySession, isUserSessionClaims, makeRequireAuth, sessionToken, GRACE_MS, COOKIE, SSO_COOKIE, setSessionCookies, clearSessionCookies, requestToken };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 3165f8bf..062947f4 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -160,18 +160,38 @@ async function seedDb(db, { log = console } = {}) {
// OpenVibe Git: Forgejo at openvibe.codes/git (OpenVibe.Codes deploy/forgejo, WS-X12), signing in through OpenID
// Connect; its callback is Forgejo's /user/oauth2/<source name>/callback. git.openvibe.codes once that name exists.
{ client_id: 'forgejo', name: 'OpenVibe Git', redirect_uris: ['https://openvibe.codes/git/user/oauth2/OpenVibe/callback', 'https://git.openvibe.codes/user/oauth2/OpenVibe/callback'] },
++ // The Dock in the browser extension (2026-10-30): a public first-party client with no secret,
++ // PKCE S256 required, the Chrome/Firefox extension redirect URIs exactly, and a read-only scope
++ // ceiling. The Chrome id is fixed by browser/manifest.json's key; the Firefox hash is SHA-1 of
++ // the add-on id (extension id -> getRedirectURL). Both are public values.
++ { client_id: 'companion', name: 'OpenVibe extension', public: true,
++ allowed_scopes: 'dock activity chat.read', access_token_ttl_s: 3600,
++ redirect_uris: [
++ 'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
++ 'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
++ ] },
];
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) {
-- await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)')
-- .run(c.client_id, uuidv4(), c.name, JSON.stringify(c.redirect_uris));
-- console.log(`[DB] Seeded OAuth2 client: ${c.client_id} (secret stored in oauth_clients; read it from the database, never from logs)`);
++ 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);
++ 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)')
++ .run(c.client_id, uuidv4(), c.name, JSON.stringify(c.redirect_uris));
++ console.log(`[DB] Seeded OAuth2 client: ${c.client_id} (secret stored in oauth_clients; read it from the database, never from logs)`);
++ }
seededAny = true;
continue;
}
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);
++ }
// 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
// name someone chose stays. A site's own former name is renamed too (FORMER_NAMES).
ddiff --git a/server/index.js b/server/index.js
iindex 7963121b..63f97a3e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -322,8 +322,7 @@ app.use(svc.telemetryMiddleware);
// ── Extract bearer token (available as req.token for optional-auth proxies) ─
app.use((req, _res, next) => {
-- const ah = req.headers.authorization;
-- req.token = ah?.startsWith('Bearer ') ? ah.slice(7) : req.cookies?.ov_token || null;
++ req.token = require('./auth/session').sessionToken(req);
next();
});
@@@ -392,7 +391,11 @@ discordService.init().catch(err => console.error('[Discord] Init error:', err.me
const authRoutes = require('./auth/routes');
const jwt = require('jsonwebtoken');
// Sliding session guard (server/auth/session.js): renewable tokens are accepted and renewed.
--const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
++const authCtx = () => ({ db, publicKey, config });
++const requireAuth = require('./auth/session').makeRequireAuth(authCtx, authRoutes.signToken);
++// The extension's private-issuer Dock token, accepted only on the mounts that name a scope. Every
++// other route keeps `requireAuth`, which refuses the different issuer.
++const requireDock = (scope) => require('./auth/session').makeRequireAuth(authCtx, authRoutes.signToken, { dockScope: scope });
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
// Staff capabilities and the staff list (WS-D task 4; services need network.staff.read).
@@@ -513,7 +516,7 @@ app.get('/internal/blocks', require('./identity/principals').guard('network.bloc
// OpenVibe.Events' /realtime/stream as the signed-in person with a two-minute, single-use ticket
// (server/auth/realtime-ticket.js). REALTIME_TICKETS=off answers 503 and every badge stays on polling.
app.use('/api/v1/realtime', rateLimit({ windowMs: 60_000, max: 60 }), require('./auth/realtime-ticket').router({
-- db, requireAuth, privateKey, issuer: config.jwt.issuer, streamUrl: process.env.OV_EVENTS_PUBLIC_URL,
++ db, requireAuth: requireDock('activity'), privateKey, issuer: config.jwt.issuer, streamUrl: process.env.OV_EVENTS_PUBLIC_URL,
}));
// ── Routes ───────────────────────────────────────────────────
@@@ -601,7 +604,9 @@ app.use('/api/auth', require('./auth/email-verify').routes(requireAuth));
// Discord account linking (OAuth2 flow)
app.use('/api/auth/discord', requireAuth, require('./auth/discord-link'));
--// OAuth2 authorization endpoints (token issuance counted by grant type: server/observability.js)
++// OAuth2 authorization endpoints (token issuance counted by grant type: server/observability.js).
++// The app limit is per address AND client (server/auth/oauth-limit.js); nginx keeps its own per-address zone.
++app.use(['/oauth/authorize', '/oauth/token', '/oauth/revoke'], require('./auth/oauth-limit').createOAuthLimiter());
app.use('/oauth/token', observability.tokenEndpointMetrics);
app.use('/oauth', require('./auth/oauth-routes'));
@@@ -637,16 +642,16 @@ require('./developer/confirmations').start(db);
ai: process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700',
domains: process.env.OV_DOMAINS_INTERNAL_URL || 'http://127.0.0.1:5070',
} });
-- app.use('/api/v1/me/actions', rateLimit({ windowMs: 60_000, max: 120 }), require('./actions').router({ actions, requireAuth }));
++ app.use('/api/v1/me/actions', rateLimit({ windowMs: 60_000, max: 120 }), require('./actions').router({ actions, requireAuth, requireDock }));
// The Dock's state (the Dock plan 2026-10-30, K-D1, server/dock): one row per person, so the little floating
// surface every OpenVibe site draws follows them across every domain (localStorage is per site). Its counts are
// the Action Center's own, so a page view never fans out further than the aggregator's 10 s cache already does.
const dock = require('./dock').createDock({ db, actions, notifications: notificationService });
-- app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth }));
++ app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth: requireDock('dock') }));
}
// Notification API (authenticated users)
--app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
++app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth, requireDock));
// Push Notifications API
const pushService = require('./push/push-service');
ddiff --git a/server/notifications/routes.js b/server/notifications/routes.js
iindex 60e7d010..a39e0fd3 100644
---- a/server/notifications/routes.js
++++ b/server/notifications/routes.js
@@@ -9,11 +9,11 @@
const express = require('express');
const router = express.Router();
--module.exports = function createNotificationRoutes(db, notificationService, requireAuth) {
++module.exports = function createNotificationRoutes(db, notificationService, requireAuth, requireDock = () => requireAuth) {
// ─── GET /api/notifications ────────────────────────────
// Query params: limit, offset, category, type, tab (the bell's tabs, ./tabs.js), unread_only, q, since
-- router.get('/', requireAuth, async (req, res) => {
++ router.get('/', requireDock('activity'), async (req, res) => {
try {
const userId = req.user.id;
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
@@@ -35,7 +35,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
// ─── GET /api/notifications/unread-count ───────────────
-- router.get('/unread-count', requireAuth, async (req, res) => {
++ router.get('/unread-count', requireDock('activity'), async (req, res) => {
try {
const count = await notificationService.getUnreadCount(req.user.id);
res.json({ ok: true, count });
@@@ -46,7 +46,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── GET /api/notifications/unread-by-tab ──────────────
// The counts on the bell's tabs: { tabs: { all, mentions, live, alerts, system, agents } }.
-- router.get('/unread-by-tab', requireAuth, async (req, res) => {
++ router.get('/unread-by-tab', requireDock('activity'), async (req, res) => {
try {
res.json({ ok: true, tabs: await notificationService.getUnreadByTab(req.user.id) });
} catch (err) {
@@@ -55,7 +55,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
// ─── GET /api/notifications/unread-by-category ─────────
-- router.get('/unread-by-category', requireAuth, async (req, res) => {
++ router.get('/unread-by-category', requireDock('activity'), async (req, res) => {
try {
const categories = await notificationService.getUnreadByCategory(req.user.id);
res.json({ ok: true, categories });
@@@ -65,7 +65,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
// ─── GET /api/notifications/newest ─────────────────────
-- router.get('/newest', requireAuth, async (req, res) => {
++ router.get('/newest', requireDock('activity'), async (req, res) => {
try {
const newest = await notificationService.getNewest(req.user.id);
res.json({ ok: true, notification: newest || null });
@@@ -75,7 +75,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
// ─── POST /api/notifications/:id/read ──────────────────
-- router.post('/:id/read', requireAuth, async (req, res) => {
++ router.post('/:id/read', requireDock('activity'), async (req, res) => {
try {
const ok = await notificationService.markRead(req.params.id, req.user.id);
res.json({ ok, unread: await notificationService.getUnreadCount(req.user.id) });
@@@ -86,7 +86,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── POST /api/notifications/read-batch ────────────────
// Body: { ids: [] } — the inbox marks everything it rendered as seen in one call.
-- router.post('/read-batch', requireAuth, async (req, res) => {
++ router.post('/read-batch', requireDock('activity'), async (req, res) => {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];
const changed = await notificationService.markReadMany(ids, req.user.id);
@@@ -97,7 +97,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
// ─── POST /api/notifications/read-all ──────────────────
-- router.post('/read-all', requireAuth, async (req, res) => {
++ router.post('/read-all', requireDock('activity'), async (req, res) => {
try {
const { category } = req.body || {};
const changed = await notificationService.markAllRead(req.user.id, category || null);
@@@ -108,7 +108,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
// ─── POST /api/notifications/:id/dismiss ───────────────
-- router.post('/:id/dismiss', requireAuth, async (req, res) => {
++ router.post('/:id/dismiss', requireDock('activity'), async (req, res) => {
try {
const ok = await notificationService.dismiss(req.params.id, req.user.id);
res.json({ ok });
@@@ -130,7 +130,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── GET /api/notifications/quiet-hours ────────────────
// The person's do-not-disturb window (default 22:00–07:00 UTC, off) and whether it is now.
-- router.get('/quiet-hours', requireAuth, async (req, res) => {
++ router.get('/quiet-hours', requireDock('activity'), async (req, res) => {
try {
const quiet = await notificationService.getQuietHours(req.user.id);
const in_quiet = await notificationService.inQuietHours(req.user.id);
ddiff --git a/server/public-cors.js b/server/public-cors.js
iindex 553e4aae..69e675e9 100644
---- a/server/public-cors.js
++++ b/server/public-cors.js
@@@ -17,6 +17,7 @@
const ALLOW_HEADERS = 'traceparent, X-OpenVibe-Request-Id, Authorization, Content-Type';
const EXPOSE_HEADERS = 'X-OpenVibe-Request-Id, traceparent';
const MAX_AGE_S = 86400;
++const { isExtensionOrigin } = require('./auth/extension-origin');
// A registry segment: URL-safe characters, never '.' or '..' and never an encoded dot or slash.
const SEGMENT_RE = /^[A-Za-z0-9_~:@!$&'()*+,;=.-]+$/;
@@@ -52,9 +53,26 @@ function publicCors(req, res, next) {
return next();
}
--/** One CORS middleware for the app: public discovery paths open, everything else `restricted`. */
++/**
++ * Extension pages carry a chrome-extension:// or moz-extension:// Origin whose UUID changes per
++ * install. They are never refused for the origin alone, but they get no CORS grant: no echoed
++ * Origin and no Access-Control-Allow-Credentials. An extension page with its host permissions does
++ * not need CORS; and a cookie session is refused separately (auth/session.js) because only a Bearer
++ * token may act from such an origin.
++ */
++function extensionCors(req, res, next) {
++ if (req.method === 'OPTIONS') {
++ res.statusCode = 204;
++ res.setHeader('Content-Length', '0');
++ return res.end();
++ }
++ return next();
++}
++
++/** One CORS middleware for the app: extension origins get no grant, discovery is open, the rest restricted. */
function gate(restricted) {
return function corsGate(req, res, next) {
++ if (isExtensionOrigin(req.headers.origin)) return extensionCors(req, res, next);
if (isPublicDiscoveryPath(req.path)) return publicCors(req, res, next);
return restricted(req, res, next);
};
@@@ -97,4 +115,4 @@ function restrictedCors(origin) {
return require('cors')({ origin, credentials: true, maxAge: RESTRICTED_MAX_AGE_S });
}
--module.exports = { gate, publicCors, restrictedCors, isPublicDiscoveryPath, originGuard, ALLOW_HEADERS, EXPOSE_HEADERS, RESTRICTED_MAX_AGE_S };
++module.exports = { gate, publicCors, extensionCors, restrictedCors, isPublicDiscoveryPath, originGuard, ALLOW_HEADERS, EXPOSE_HEADERS, RESTRICTED_MAX_AGE_S };
ddiff --git a/test/account-apps.test.js b/test/account-apps.test.js
nnew file mode 100644
iindex 00000000..ed8036f5
---- /dev/null
++++ b/test/account-apps.test.js
@@@ -0,0 +1,79 @@
++'use strict';
++// Signed-in apps on the Network account page (2026-10-30): the extension's live refresh families are
++// listed per person, only public clients, and revoking one family ends it. Another person's family is
++// not touched; a Dock access token cannot use the account API.
++//
++// node --import ./test/helpers/pg-preload.mjs test/account-apps.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++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 authRoutes = require('../server/auth/routes');
++
++(async () => {
++ const db = getDb();
++ const keys = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++ });
++ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++ const mk = async (name) => {
++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?) RETURNING id').run(name, `${name}@example.test`, 'x', 'user')).lastInsertRowid;
++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
++ await subjects.ensureUserSubject(db, user);
++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
++ };
++ const ana = await mk('appsana');
++ const bob = await mk('appsbob');
++ const tokenOf = (u) => authRoutes.signToken(u, keys.privateKey, config);
++ const family = (n) => `fam_${crypto.randomBytes(12).toString('hex')}`;
++ const anaFam = family(), bobFam = family(), liveFam = family();
++ const insert = async (tok, client, user, fam) => db.prepare('INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation) VALUES (?, ?, ?, ?, ?, ?, 0)')
++ .run(tok, client, user.id, 'dock activity chat.read', new Date(Date.now() + 86400e3).toISOString(), fam);
++ await insert(crypto.randomBytes(48).toString('hex'), 'companion', ana, anaFam);
++ await insert(crypto.randomBytes(48).toString('hex'), 'companion', ana, anaFam);
++ await insert(crypto.randomBytes(48).toString('hex'), 'companion', bob, bobFam);
++ await insert(crypto.randomBytes(48).toString('hex'), 'live', ana, liveFam);
++
++ const app = express();
++ app.use(express.json());
++ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++ app.use('/api/auth', authRoutes);
++ 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 call = (method, path, tok) => fetch(base + path, { method, headers: { authorization: `Bearer ${tok}` } }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
++
++ try {
++ const before = await call('GET', '/api/auth/apps', tokenOf(ana));
++ assert.strictEqual(before.status, 200, JSON.stringify(before.body));
++ assert.strictEqual(before.body.apps.length, 1, 'one row per family, public clients only');
++ assert.deepStrictEqual([before.body.apps[0].family_id, before.body.apps[0].client_id, before.body.apps[0].name], [anaFam, 'companion', 'OpenVibe extension']);
++ assert.strictEqual(before.body.apps[0].scope, 'dock activity chat.read');
++
++ const revoked = await call('DELETE', `/api/auth/apps/${anaFam}`, tokenOf(ana));
++ assert.strictEqual(revoked.status, 200);
++ assert.ok(revoked.body.revoked >= 2, 'every live generation of the family is revoked');
++ assert.strictEqual((await call('GET', '/api/auth/apps', tokenOf(ana))).body.apps.length, 0);
++ assert.strictEqual((await call('GET', '/api/auth/apps', tokenOf(bob))).body.apps.length, 1, 'another person’s family is untouched');
++
++ const notMine = await call('DELETE', `/api/auth/apps/${bobFam}`, tokenOf(ana));
++ assert.strictEqual(notMine.body.revoked, 0, 'a family is not revocable by another person');
++ assert.strictEqual((await call('GET', '/api/auth/apps', tokenOf(bob))).body.apps.length, 1);
++
++ // A Dock access token (private issuer) is not a normal session and cannot list/revoke apps.
++ const dockTok = jwt.sign({
++ sub: ana.id, id: ana.id, subject_id: ana.subject_id, username: ana.username, client_id: 'companion',
++ scope: 'dock activity chat.read', fam: bobFam, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600,
++ }, keys.privateKey, { algorithm: 'RS256', issuer: 'https://openvibe.network/dock', audience: ['openvibe.network', 'openvibe.chat'] });
++ assert.strictEqual((await call('GET', '/api/auth/apps', dockTok)).status, 401);
++ assert.strictEqual((await call('DELETE', `/api/auth/apps/${bobFam}`, dockTok)).status, 401);
++ console.log('account apps: all checks passed');
++ } finally {
++ await new Promise((r) => server.close(r));
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 60e1ad28..17ecec6f 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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']]);
++ 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']]);
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.
ddiff --git a/test/api-rate-limit.test.js b/test/api-rate-limit.test.js
iindex e7908f87..c27be45b 100644
---- a/test/api-rate-limit.test.js
++++ b/test/api-rate-limit.test.js
@@@ -18,6 +18,9 @@ assert.ok(/zone=openvibenetwork_api:10m rate=10r\/s/.test(conf), "nginx's /api/
assert.ok(/zone=openvibenetwork_authorize:10m rate=60r\/m;/.test(conf), 'authorize: 60 a minute per address');
const block = (re) => { const m = re.exec(conf); assert.ok(m, String(re)); return conf.slice(m.index, conf.indexOf('}', m.index)); };
assert.match(block(/location = \/oauth\/authorize \{/), /limit_req zone=openvibenetwork_authorize burst=60 nodelay;/);
++assert.match(block(/location = \/oauth\/revoke \{/), /limit_req zone=openvibenetwork_token burst=30 nodelay;/, 'revoke uses the token zone');
++const idx = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
++assert.match(idx, /\['\/oauth\/authorize', '\/oauth\/token', '\/oauth\/revoke'\], require\('\.\/auth\/oauth-limit'\)\.createOAuthLimiter\(\)/, 'the app limits OAuth per address and client');
const strict = /location ~ (\S+) \{\s*limit_req zone=openvibenetwork_auth burst=5 nodelay;/.exec(conf);
assert.ok(strict && /register\|login/.test(strict[1]) && !/authorize/.test(strict[1]), 'the strict zone covers sign-in and registration, not authorize');
assert.ok(/zone=openvibenetwork_auth:10m rate=5r\/m;/.test(conf));
ddiff --git a/test/extension-origin.test.js b/test/extension-origin.test.js
nnew file mode 100644
iindex 00000000..e22662ce
---- /dev/null
++++ b/test/extension-origin.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++// Extension origins on Network (2026-10-30): chrome-extension:// and moz-extension:// are never refused
++// for the origin alone, get no CORS grant (no echoed Origin, no Allow-Credentials), and a cookie session
++// never authenticates from one — only a Bearer does.
++//
++// node --import ./test/helpers/pg-preload.mjs test/extension-origin.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const authRoutes = require('../server/auth/routes');
++const publicCors = require('../server/public-cors');
++const { makeRequireAuth } = require('../server/auth/session');
++
++(async () => {
++ const db = getDb();
++ const keys = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++ });
++ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++ const id = (await db.prepare("INSERT INTO users (username, email, password_hash, role) VALUES ('extana', '[email protected]', 'x', 'user') RETURNING id").run()).lastInsertRowid;
++ const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
++ await subjects.ensureUserSubject(db, user);
++ const token = authRoutes.signToken(user, keys.privateKey, config);
++ const requireAuth = makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
++ const app = express();
++ app.use(require('cookie-parser')());
++ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++ const allowed = new Set(['https://openvibe.network']);
++ const guard = publicCors.originGuard((o) => allowed.has(o), { log: { warn() {} } });
++ app.use(publicCors.gate(publicCors.restrictedCors(guard.origin)));
++ app.use(guard.denied);
++ app.get('/api/v1/me/private', requireAuth, (req, res) => res.json({ ok: true, user: req.user.username }));
++ 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 call = (origin, { bearer = null, cookie = null } = {}) => fetch(`${base}/api/v1/me/private`, {
++ headers: { ...(origin ? { origin } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), ...(cookie ? { cookie } : {}) },
++ }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), acao: r.headers.get('access-control-allow-origin'), acac: r.headers.get('access-control-allow-credentials') }));
++ try {
++ for (const origin of ['chrome-extension://bjcjcfcecpkkmfndegkjekoipdmbgakd', 'moz-extension://8d3b2a5e-0a9c-4f7e-9c3b-2a1f6d4e8c11']) {
++ const r = await call(origin, { bearer: token });
++ assert.deepStrictEqual([r.status, r.acao, r.acac], [200, null, null], `${origin}: not refused, no CORS grant`);
++ }
++ assert.strictEqual((await call('chrome-extension://abc', { cookie: `ov_token=${token}` })).status, 401, 'a cookie never acts from an extension origin');
++ assert.strictEqual((await call('moz-extension://abc', { cookie: `ov_token=${token}` })).status, 401);
++ assert.strictEqual((await call('chrome-extension://abc', { bearer: token, cookie: 'ov_token=wrong' })).status, 200, 'the Bearer acts, the cookie is ignored');
++ assert.strictEqual((await call('https://evil.example', { bearer: token })).status, 403, 'a foreign web origin is still refused');
++ assert.strictEqual((await call('https://openvibe.network', { cookie: `ov_token=${token}` })).status, 200, 'first-party cookies still work');
++ console.log('extension origin: all checks passed');
++ } finally {
++ await new Promise((r) => server.close(r));
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/oauth-extension.test.js b/test/oauth-extension.test.js
nnew file mode 100644
iindex 00000000..fe0bbe02
---- /dev/null
++++ b/test/oauth-extension.test.js
@@@ -0,0 +1,169 @@
++'use strict';
++// One-click sign-in for the browser extension (2026-10-30): a public first-party OAuth client
++// (`companion`) with no secret, PKCE S256, exact extension redirects, a read-only scope ceiling and a
++// private-issuer access token. The Dock guard accepts it for `dock`, the normal account guard does not;
++// refresh rotates; revoke ends the family at once on Network. Existing confidential clients keep their
++// secret and their optional PKCE.
++//
++// node --import ./test/helpers/pg-preload.mjs test/oauth-extension.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 dockMod = require('../server/dock');
++const { makeRequireAuth } = require('../server/auth/session');
++
++const ISSUER = 'https://openvibe.network';
++const CHROME = 'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/';
++const FIREFOX = 'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/';
++
++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' } };
++ const user = { id: 1, username: 'dockana', display_name: 'Dock Ana', role: 'user', password_hash: 'x' };
++ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'dockana', '[email protected]', 'x', 'user')").run();
++ await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = 1').get());
++
++ const dock = dockMod.createDock({ db });
++ 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);
++ app.use('/api/v1/me/dock', dockMod.router({ dock, requireAuth: requireDock('dock') }));
++ app.get('/api/v1/me/private', requireAuth, (req, res) => res.json({ ok: true }));
++ app.get('/api/v1/me/actions', requireDock('activity'), (req, res) => res.json({ ok: true, scope: req.dockScope }));
++ 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 normal = authRoutes.signToken(await db.prepare('SELECT * FROM users WHERE id = 1').get(), keys.privateKey, config);
++
++ 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, body: await r.json().catch(() => null) };
++ };
++ const confirm = async (body) => {
++ const r = await fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: normal, ...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, path, tok) => {
++ const r = await fetch(`${base}${path}`, { method, headers: { authorization: `Bearer ${tok}` } });
++ return { status: r.status, body: await r.json().catch(() => null) };
++ };
++
++ try {
++ // The migration is expand only: nullable/default columns, no NOT NULL on the new ones.
++ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0032_oauth_public_clients.sql'), 'utf8');
++ assert.match(sql, /^-- phase: expand/m);
++ assert.doesNotMatch(sql, /ADD COLUMN[^;]*NOT NULL/i, 'new columns are nullable/default');
++ assert.match(sql, /ALTER COLUMN client_secret DROP NOT NULL/);
++
++ // The registered name and exact redirects.
++ let r = await fetch(`${base}/oauth/client-info?client_id=companion&redirect_uri=${encodeURIComponent(CHROME)}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual((await r.json()).name, 'OpenVibe extension');
++ for (const uri of [CHROME, FIREFOX]) {
++ const found = await db.prepare('SELECT redirect_uris FROM oauth_clients WHERE client_id = ?').get('companion');
++ assert.ok(JSON.parse(found.redirect_uris).includes(uri), `registered: ${uri}`);
++ }
++
++ // Public client: state + PKCE + allowed scope required.
++ assert.strictEqual((await authorize({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', scope: 'dock' })).status, 400, 'state required');
++ assert.strictEqual((await authorize({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', state: 's', scope: 'dock' })).status, 400, 'PKCE required');
++ assert.strictEqual((await authorize({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', state: 's', scope: 'dock chat.write', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'scope ceiling');
++ assert.strictEqual((await authorize({ client_id: 'companion', redirect_uri: 'https://evil.example/cb', response_type: 'code', state: 's', scope: 'dock', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'exact redirect');
++
++ // Code -> token with no secret.
++ const codeVerifier = verifier();
++ const state = 'state_' + crypto.randomBytes(12).toString('hex');
++ const start = await authorize({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', scope: 'dock activity chat.read', state, code_challenge: challenge(codeVerifier), code_challenge_method: 'S256' });
++ assert.strictEqual(start.status, 302);
++ assert.match(start.location, /\/login\?/);
++ assert.match(start.location, /client_name=OpenVibe\+extension/);
++ const cx = await confirm({ client_id: 'companion', redirect_uri: CHROME, response_type: 'code', scope: 'dock activity chat.read', state, code_challenge: challenge(codeVerifier), 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 noSecret = await token({ grant_type: 'authorization_code', client_id: 'companion', code, redirect_uri: CHROME, code_verifier: codeVerifier });
++ assert.strictEqual(noSecret.status, 200, JSON.stringify(noSecret.body));
++ assert.strictEqual(noSecret.body.expires_in, 3600);
++ assert.strictEqual(noSecret.body.scope, 'dock activity chat.read');
++ const claims = jwt.decode(noSecret.body.access_token);
++ assert.strictEqual(claims.iss, `${ISSUER}/dock`);
++ assert.deepStrictEqual(claims.aud, ['openvibe.network', 'openvibe.chat']);
++ assert.deepStrictEqual([claims.client_id, claims.scope, claims.role, claims.staff_caps], ['companion', 'dock activity chat.read', undefined, undefined]);
++ assert.match(claims.fam, /^fam_[0-9a-f]{24}$/);
++ assert.ok(noSecret.body.refresh_token);
++
++ // The Dock accepts it; a normal account route refuses it. Activity reads are open, action
++ // writes are not, and a token that only has `dock` is refused the activity route.
++ assert.strictEqual((await api('GET', '/api/v1/me/dock', noSecret.body.access_token)).status, 200);
++ assert.strictEqual((await api('GET', '/api/v1/me/private', noSecret.body.access_token)).status, 401, 'not a normal session');
++ assert.strictEqual((await api('GET', '/api/v1/me/private', normal)).status, 200, 'a normal session still works');
++ assert.deepStrictEqual((await api('GET', '/api/v1/me/actions', noSecret.body.access_token)).body.scope, ['dock', 'activity', 'chat.read']);
++ assert.strictEqual((await api('POST', '/api/v1/me/actions', noSecret.body.access_token)).status, 401, 'approve/decline is not a Dock-token route');
++ const dockOnly = jwt.sign({
++ sub: 1, id: 1, username: 'dockana', client_id: 'companion', scope: 'dock', fam: claims.fam,
++ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600,
++ }, keys.privateKey, { algorithm: 'RS256', issuer: `${ISSUER}/dock`, audience: ['openvibe.network', 'openvibe.chat'] });
++ assert.strictEqual((await api('GET', '/api/v1/me/actions', dockOnly)).status, 403, 'scope ceiling is enforced per route');
++
++ // Refresh rotates: the old token is refused, the new one works, and reuse after the grace would
++ // revoke the family (the rotation path itself is covered by test/refresh-tokens.test.js).
++ const refreshed = await token({ grant_type: 'refresh_token', client_id: 'companion', refresh_token: noSecret.body.refresh_token });
++ assert.strictEqual(refreshed.status, 200, JSON.stringify(refreshed.body));
++ assert.strictEqual(refreshed.body.expires_in, 3600);
++ assert.notStrictEqual(refreshed.body.refresh_token, noSecret.body.refresh_token);
++ assert.strictEqual((await token({ grant_type: 'refresh_token', client_id: 'companion', refresh_token: noSecret.body.refresh_token })).status, 400);
++ assert.strictEqual((await api('GET', '/api/v1/me/dock', refreshed.body.access_token)).status, 200);
++
++ // Sign out revokes the family: the access token stops on Network at once.
++ const revoked = await fetch(`${base}/oauth/revoke`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: 'companion', token: refreshed.body.refresh_token }) });
++ assert.strictEqual(revoked.status, 200, JSON.stringify(await revoked.json()));
++ assert.strictEqual((await api('GET', '/api/v1/me/dock', refreshed.body.access_token)).status, 401, 'family revoked');
++
++ // Existing confidential clients do not change: secret required, PKCE still optional.
++ const liveVerifier = verifier();
++ const liveState = 'live_' + crypto.randomBytes(8).toString('hex');
++ const liveStart = await authorize({ client_id: 'live', redirect_uri: 'https://openvibe.live/api/auth/callback', response_type: 'code', scope: 'profile theme', state: liveState });
++ assert.strictEqual(liveStart.status, 302, 'PKCE stays optional for a confidential client');
++ const live = await confirm({ client_id: 'live', redirect_uri: 'https://openvibe.live/api/auth/callback', response_type: 'code', scope: 'profile theme', state: liveState });
++ assert.strictEqual(live.status, 200, JSON.stringify(live.body));
++ const liveCode = new URL(live.body.redirect).searchParams.get('code');
++ assert.strictEqual((await token({ grant_type: 'authorization_code', client_id: 'live', code: liveCode, redirect_uri: 'https://openvibe.live/api/auth/callback' })).status, 401, 'the live secret is still required');
++ const secret = (await db.prepare("SELECT client_secret FROM oauth_clients WHERE client_id = 'live'").get()).client_secret;
++ const withSecret = await token({ grant_type: 'authorization_code', client_id: 'live', client_secret: secret, code: liveCode, redirect_uri: 'https://openvibe.live/api/auth/callback' });
++ assert.strictEqual(withSecret.status, 200, JSON.stringify(withSecret.body));
++ assert.strictEqual(jwt.decode(withSecret.body.access_token).iss, ISSUER, 'a confidential client keeps the normal issuer');
++ } finally {
++ console.log = quiet;
++ await new Promise((r) => server.close(r));
++ }
++ console.log('oauth extension: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/oauth-rate-limit.test.js b/test/oauth-rate-limit.test.js
nnew file mode 100644
iindex 00000000..b250f821
---- /dev/null
++++ b/test/oauth-rate-limit.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++// OAuth limits (server/auth/oauth-limit.js): per address AND client, so one app cannot spend a shared
++// address's budget and one address cannot hammer one app. The production mount uses 60 a minute; this
++// test builds the same limiter with max 2 to prove the key.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { createOAuthLimiter, oauthKey } = require('../server/auth/oauth-limit');
++
++(async () => {
++ const app = express();
++ app.use(express.json());
++ app.use(express.urlencoded({ extended: true }));
++ app.use(['/oauth/token', '/oauth/revoke'], createOAuthLimiter({ windowMs: 60_000, max: 2 }));
++ app.post(['/oauth/token', '/oauth/revoke'], (req, res) => res.json({ ok: true, key: oauthKey(req) }));
++ 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 post = async (path, client_id) => {
++ const body = new URLSearchParams({ client_id, grant_type: 'refresh_token' }).toString();
++ const r = await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body });
++ return { status: r.status, body: await r.json().catch(() => ({})) };
++ };
++ try {
++ assert.deepStrictEqual([(await post('/oauth/token', 'companion')).status, (await post('/oauth/token', 'companion')).status], [200, 200]);
++ const blocked = await post('/oauth/token', 'companion');
++ assert.strictEqual(blocked.status, 429, 'the third request for one address+client is limited');
++ assert.strictEqual((await post('/oauth/token', 'live')).status, 200, 'another client on the same address is a separate bucket');
++ assert.strictEqual((await post('/oauth/revoke', 'companion')).status, 429, 'revoke shares the client bucket');
++ assert.strictEqual((await post('/oauth/revoke', 'live')).status, 200, 'and the other client is not blocked');
++ assert.match((await post('/oauth/token', 'companion')).body.error, /too_many_requests/);
++ console.log('oauth rate limit: all checks passed');
++ } finally {
++ await new Promise((r) => server.close(r));
++ }
++})();
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 16d410da..021ce305 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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']]);
++ 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']]);
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
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');