openvibers/openvibe.network Commits
Dock: one-click extension sign-in (public client, scopes, revoke, account page) (#445)
Changes
@@@ -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;@@@ -13,7 +13,8 @@ Both are the person's own: the subject comes from the caller's token and from noreads or writes another's state. They take the person's own session — the `ov_token` cookie on openvibe.network, aBearer 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 ||---|---|---|@@@ -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 theservice 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 outA site's **Sign out** ends that site's session and keeps the browser from signing back in quietly (`ov_sso_hint=guest`).nnew file mode 100644@@@ -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);@@@ -43,6 +43,7 @@ async function loadUser() {loadNotifPrefs(); loadQuietHours();loadLinked();loadSessions();++ loadApps();loadBlocks();loadAnonInfo();loadEmailStatus();@@@ -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' }); }@@@ -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>@@@ -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));nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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 withconst 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 clientconst 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, redirectUconst 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, redirectUif (!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, redirectUaccess_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);@@@ -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 };@@@ -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// ═══════════════════════════════════════════════════════════════@@@ -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 };@@@ -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).@@@ -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.meconst 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 APIconst pushService = require('./push/push-service');@@@ -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);@@@ -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 };nnew file mode 100644@@@ -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); });@@@ -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.@@@ -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));nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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));++ }++})();@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -78,6 +78,19 @@ server {
@@ -78,6 +78,19 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
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) ──────────
# ── Sign-in redirects (their own limit, above) ──────────
location = /oauth/authorize {
location = /oauth/authorize {
limit_req zone=openvibenetwork_authorize burst=60 nodelay;
limit_req zone=openvibenetwork_authorize burst=60 nodelay;
@@ -13,7 +13,8 @@ Both are the person's own: the subject comes from the caller's token and from no
@@ -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
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
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
`/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 |
| Route | Answer | Notes |
|---|---|---|
|---|---|---|
@@ -54,6 +54,25 @@ can build the same links:
@@ -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
`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.
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
## 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`).
A site's **Sign out** ends that site's session and keeps the browser from signing back in quietly (`ov_sso_hint=guest`).
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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);
@@ -43,6 +43,7 @@ async function loadUser() {
@@ -43,6 +43,7 @@ async function loadUser() {
loadNotifPrefs(); loadQuietHours();
loadNotifPrefs(); loadQuietHours();
loadLinked();
loadLinked();
loadSessions();
loadSessions();
+ loadApps();
loadBlocks();
loadBlocks();
loadAnonInfo();
loadAnonInfo();
loadEmailStatus();
loadEmailStatus();
@@ -325,6 +325,53 @@ async function revokeAllSessions() {
@@ -325,6 +325,53 @@ async function revokeAllSessions() {
} catch (err) { alert('Error: ' + err.message); }
} 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() {
async function signOutEverywhere() {
if (!confirm('Sign out on every device and every OpenVibe site, this one included?')) return;
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' }); }
try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }
@@ -341,6 +341,11 @@
@@ -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>
<button class="btn" onclick="signOutEverywhere()"><i class="fa-solid fa-right-from-bracket"></i> Sign out everywhere, this device too</button>
</div>
</div>
</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">
<div class="card" id="blocks-card">
<h3><span class="icon"><i class="fa-solid fa-user-slash"></i></span> Blocked people</h3>
<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>
<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>
@@ -141,11 +141,11 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
@@ -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. */
/** 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();
const r = express.Router();
r.use(express.json({ limit: '4kb' }));
r.use(express.json({ limit: '4kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
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 {
try {
const actor = await store.actorOf(req.app.locals.db, req.user);
const actor = await store.actorOf(req.app.locals.db, req.user);
res.json(await actions.forPerson(actor));
res.json(await actions.forPerson(actor));
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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 };
@@ -15,6 +15,8 @@ const devTokens = require('../developer/tokens');
@@ -15,6 +15,8 @@ const devTokens = require('../developer/tokens');
const devPolicy = require('../developer/policy');
const devPolicy = require('../developer/policy');
const oidc = require('./oidc');
const oidc = require('./oidc');
const refreshTokens = require('./refresh-tokens');
const refreshTokens = require('./refresh-tokens');
+const dock = require('./dock-tokens');
+const { isExtensionOrigin } = require('./extension-origin');
const router = express.Router();
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
@@ -40,6 +42,27 @@ function sameSecret(a, b) {
@@ -40,6 +42,27 @@ function sameSecret(a, b) {
const y = Buffer.from(String(b || ''));
const y = Buffer.from(String(b || ''));
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
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 }) {
async function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonce }) {
const code = crypto.randomBytes(32).toString('hex');
const code = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
@@ -97,28 +120,33 @@ router.get('/authorize', async (req, res) => {
@@ -97,28 +120,33 @@ router.get('/authorize', async (req, res) => {
if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
return res.status(400).json({ error: 'Invalid redirect_uri' });
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);
const nonce = oidc.cleanNonce(req.query.nonce);
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
// prompt=none (silent SSO from an app whose own session lapsed): if this browser still has a
// prompt=none (silent SSO from an app whose own session lapsed): if this browser still has a
// live/renewable openvibe.network session, continue as that account with no chooser. With no
// live/renewable openvibe.network session, continue as that account with no chooser. With no
- // session, bounce straight back with error=login_required so the app can stay quiet.
+ // 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') {
if (String(req.query.prompt || '') === 'none') {
const sep = redirect_uri.includes('?') ? '&' : '?';
const sep = redirect_uri.includes('?') ? '&' : '?';
try {
try {
const { verifySession, setSessionCookies } = require('./session');
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) });
const out = await verifySession(have, { db, publicKey: req.app.locals.publicKey, config: getConfig(req) });
if (!out.error) {
if (!out.error) {
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req), { renew: out.decoded }) : have;
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 */ }
} 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
// Otherwise the account chooser — the user picks which account to continue with
const loginParams = new URLSearchParams({
const loginParams = new URLSearchParams({
@@ -126,8 +154,8 @@ router.get('/authorize', async (req, res) => {
@@ -126,8 +154,8 @@ router.get('/authorize', async (req, res) => {
client_name: client.name || client_id,
client_name: client.name || client_id,
redirect_uri,
redirect_uri,
response_type,
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 (pkce.challenge) { loginParams.set('code_challenge', pkce.challenge); loginParams.set('code_challenge_method', pkce.method); }
if (nonce) loginParams.set('nonce', nonce);
if (nonce) loginParams.set('nonce', nonce);
@@ -196,6 +224,8 @@ router.post('/confirm', async (req, res) => {
@@ -196,6 +224,8 @@ router.post('/confirm', async (req, res) => {
const devApp = devTokens.isAppClient(client_id) ? await devTokens.checkAuthorizeRequest(db, req.body) : null;
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 });
if (devApp && devApp.error) return res.status(400).json({ error: devApp.pkce ? 'invalid_request' : devApp.error, error_description: devApp.error });
let pkce = null;
let pkce = null;
+ let requestScope;
+ let requestState;
if (!devApp) {
if (!devApp) {
// Validate client
// Validate client
const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
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) => {
@@ -206,8 +236,11 @@ router.post('/confirm', async (req, res) => {
if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
return res.status(400).json({ error: 'Invalid redirect_uri' });
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);
const nonce = oidc.cleanNonce(req.body.nonce);
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
@@ -232,13 +265,13 @@ router.post('/confirm', async (req, res) => {
@@ -232,13 +265,13 @@ router.post('/confirm', async (req, res) => {
}
}
// Issue authorization code (bound to the PKCE challenge when the client sent one)
// 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.
// 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.
// Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.
require('./session').setSessionCookies(res, token);
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 ──────────────────────────────────────────────
// ── POST /token ──────────────────────────────────────────────
@@ -250,6 +283,13 @@ router.post('/token', async (req, res) => {
@@ -250,6 +283,13 @@ router.post('/token', async (req, res) => {
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
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)
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.
// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
if (devTokens.isAppClient(client_id)) {
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 });
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) => {
@@ -282,9 +322,12 @@ router.post('/token', async (req, res) => {
return res.status(out.status).json(out.body);
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' });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
}
@@ -307,7 +350,7 @@ router.post('/token', async (req, res) => {
@@ -307,7 +350,7 @@ router.post('/token', async (req, res) => {
* owns service_user_id; the exchange never overwrites it, only bumps last_used_at.
* owns service_user_id; the exchange never overwrites it, only bumps last_used_at.
*/
*/
async function recordLinkedService(db, user, client) {
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 {
try {
await db.prepare(`
await db.prepare(`
INSERT INTO linked_accounts (user_id, service, service_user_id, service_username, linked_at, last_used_at)
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
@@ -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'));
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' });
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);
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' });
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
@@ -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' });
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
// Issue tokens
// 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);
await recordLinkedService(db, user, client);
// Get preferences for theme sync
// Get preferences for theme sync
@@ -363,11 +414,12 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
@@ -363,11 +414,12 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
access_token: accessToken,
access_token: accessToken,
refresh_token: refreshToken,
refresh_token: refreshToken,
token_type: 'Bearer',
token_type: 'Bearer',
- expires_in: 86400, // 24h
+ expires_in: issued.expiresIn,
scope: authCode.scope,
scope: authCode.scope,
...(idToken ? { id_token: idToken } : {}),
...(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
@@ -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);
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' });
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);
await recordLinkedService(db, user, client);
const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
const prefs = await db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
const { password_hash, token_valid_after, ...safeUser } = user;
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) {
async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
@@ -419,49 +471,86 @@ 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
// 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);
await recordLinkedService(db, user, client);
res.json({
res.json({
- access_token: accessToken,
- refresh_token: newRefresh,
+ access_token: issued.accessToken,
+ refresh_token: issued.refreshToken,
token_type: 'Bearer',
token_type: 'Bearer',
- expires_in: 86400,
+ expires_in: issued.expiresIn,
scope: stored.scope,
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 privateKey = req.app.locals.privateKey;
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
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.
// 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 ─────────────────────────────────
// ── GET|POST /oauth/userinfo ─────────────────────────────────
// OpenID Connect UserInfo: standard claims for a Network access token (Bearer).
// OpenID Connect UserInfo: standard claims for a Network access token (Bearer).
router.get('/userinfo', oidc.userinfo);
router.get('/userinfo', oidc.userinfo);
@@ -33,12 +33,17 @@ const at = (v) => (v ? new Date(String(v).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\
@@ -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. */
/** 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 */ }
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() }) {
async function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, now = Date.now() }) {
const token = crypto.randomBytes(48).toString('hex');
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)
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. */
/** 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() } = {}) {
@@ -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 };
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 };
@@ -775,6 +775,59 @@ router.delete('/sessions', requireAuth, async (req, res) => {
@@ -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 || {} }) });
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
// User Card / Public Profile
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -10,6 +10,8 @@
@@ -10,6 +10,8 @@
*/
*/
'use strict';
'use strict';
const jwt = require('jsonwebtoken');
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
const GRACE_MS = 60 * 24 * 60 * 60 * 1000; // renewable for 60 days after the token's exp
@@ -73,37 +75,69 @@ function clearSessionCookies(res) {
@@ -73,37 +75,69 @@ function clearSessionCookies(res) {
/**
/**
* The session token a request carries: Bearer header, then the page cookie, then the cross-site
* 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.
* 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) {
function requestToken(req) {
const h = req.headers?.authorization;
const h = req.headers?.authorization;
if (h && h.startsWith('Bearer ')) return h.slice(7);
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;
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) {
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 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 });
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.user = out.user;
req.token = token;
req.token = token;
req.tokenClaims = out.decoded; // auth_time: account merge needs a recent real sign-in
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 {
try {
const fresh = signToken(out.user, req.app.locals.privateKey, ctx.config, { renew: out.decoded });
const fresh = signToken(out.user, req.app.locals.privateKey, ctx.config, { renew: out.decoded });
req.token = fresh;
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 */ }
} catch { /* renewal is best-effort */ }
}
}
next();
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 };
@@ -160,18 +160,38 @@ async function seedDb(db, { log = console } = {}) {
@@ -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
// 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.
// 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'] },
{ 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;
let seededAny = false;
for (const c of contractClients) {
for (const c of contractClients) {
const existing = await db.prepare('SELECT client_id, name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);
const existing = await db.prepare('SELECT client_id, name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);
if (!existing) {
if (!existing) {
- 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;
seededAny = true;
continue;
continue;
}
}
await mergeRedirectUris(db, c.client_id, c.redirect_uris, { log });
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)",
// A client created first as a service principal carries the placeholder name "OpenVibe.<id> (service)",
// which the sign-in page shows people ("Sign in to OpenVibe.food (service)"). Give it the site's name; a
// which the sign-in page shows people ("Sign in to OpenVibe.food (service)"). Give it the site's name; a
// name someone chose stays. A site's own former name is renamed too (FORMER_NAMES).
// name someone chose stays. A site's own former name is renamed too (FORMER_NAMES).
@@ -322,8 +322,7 @@ app.use(svc.telemetryMiddleware);
@@ -322,8 +322,7 @@ app.use(svc.telemetryMiddleware);
// ── Extract bearer token (available as req.token for optional-auth proxies) ─
// ── Extract bearer token (available as req.token for optional-auth proxies) ─
app.use((req, _res, next) => {
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();
next();
});
});
@@ -392,7 +391,11 @@ discordService.init().catch(err => console.error('[Discord] Init error:', err.me
@@ -392,7 +391,11 @@ discordService.init().catch(err => console.error('[Discord] Init error:', err.me
const authRoutes = require('./auth/routes');
const authRoutes = require('./auth/routes');
const jwt = require('jsonwebtoken');
const jwt = require('jsonwebtoken');
// Sliding session guard (server/auth/session.js): renewable tokens are accepted and renewed.
// 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.
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
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).
// 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
@@ -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
// 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.
// (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({
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 ───────────────────────────────────────────────────
// ── Routes ───────────────────────────────────────────────────
@@ -601,7 +604,9 @@ app.use('/api/auth', require('./auth/email-verify').routes(requireAuth));
@@ -601,7 +604,9 @@ app.use('/api/auth', require('./auth/email-verify').routes(requireAuth));
// Discord account linking (OAuth2 flow)
// Discord account linking (OAuth2 flow)
app.use('/api/auth/discord', requireAuth, require('./auth/discord-link'));
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/token', observability.tokenEndpointMetrics);
app.use('/oauth', require('./auth/oauth-routes'));
app.use('/oauth', require('./auth/oauth-routes'));
@@ -637,16 +642,16 @@ require('./developer/confirmations').start(db);
@@ -637,16 +642,16 @@ require('./developer/confirmations').start(db);
ai: process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700',
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',
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
// 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
// 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.
// 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 });
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)
// Notification API (authenticated users)
-app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth));
+app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth, requireDock));
// Push Notifications API
// Push Notifications API
const pushService = require('./push/push-service');
const pushService = require('./push/push-service');
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
const express = require('express');
const express = require('express');
const router = express.Router();
const router = express.Router();
-module.exports = function createNotificationRoutes(db, notificationService, requireAuth) {
+module.exports = function createNotificationRoutes(db, notificationService, requireAuth, requireDock = () => requireAuth) {
// ─── GET /api/notifications ────────────────────────────
// ─── GET /api/notifications ────────────────────────────
// Query params: limit, offset, category, type, tab (the bell's tabs, ./tabs.js), unread_only, q, since
// 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 {
try {
const userId = req.user.id;
const userId = req.user.id;
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
@@ -35,7 +35,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -35,7 +35,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── GET /api/notifications/unread-count ───────────────
// ─── GET /api/notifications/unread-count ───────────────
- router.get('/unread-count', requireAuth, async (req, res) => {
+ router.get('/unread-count', requireDock('activity'), async (req, res) => {
try {
try {
const count = await notificationService.getUnreadCount(req.user.id);
const count = await notificationService.getUnreadCount(req.user.id);
res.json({ ok: true, count });
res.json({ ok: true, count });
@@ -46,7 +46,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -46,7 +46,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── GET /api/notifications/unread-by-tab ──────────────
// ─── GET /api/notifications/unread-by-tab ──────────────
// The counts on the bell's tabs: { tabs: { all, mentions, live, alerts, system, agents } }.
// 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 {
try {
res.json({ ok: true, tabs: await notificationService.getUnreadByTab(req.user.id) });
res.json({ ok: true, tabs: await notificationService.getUnreadByTab(req.user.id) });
} catch (err) {
} catch (err) {
@@ -55,7 +55,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -55,7 +55,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── GET /api/notifications/unread-by-category ─────────
// ─── 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 {
try {
const categories = await notificationService.getUnreadByCategory(req.user.id);
const categories = await notificationService.getUnreadByCategory(req.user.id);
res.json({ ok: true, categories });
res.json({ ok: true, categories });
@@ -65,7 +65,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -65,7 +65,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── GET /api/notifications/newest ─────────────────────
// ─── GET /api/notifications/newest ─────────────────────
- router.get('/newest', requireAuth, async (req, res) => {
+ router.get('/newest', requireDock('activity'), async (req, res) => {
try {
try {
const newest = await notificationService.getNewest(req.user.id);
const newest = await notificationService.getNewest(req.user.id);
res.json({ ok: true, notification: newest || null });
res.json({ ok: true, notification: newest || null });
@@ -75,7 +75,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -75,7 +75,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── POST /api/notifications/:id/read ──────────────────
// ─── POST /api/notifications/:id/read ──────────────────
- router.post('/:id/read', requireAuth, async (req, res) => {
+ router.post('/:id/read', requireDock('activity'), async (req, res) => {
try {
try {
const ok = await notificationService.markRead(req.params.id, req.user.id);
const ok = await notificationService.markRead(req.params.id, req.user.id);
res.json({ ok, unread: await notificationService.getUnreadCount(req.user.id) });
res.json({ ok, unread: await notificationService.getUnreadCount(req.user.id) });
@@ -86,7 +86,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -86,7 +86,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── POST /api/notifications/read-batch ────────────────
// ─── POST /api/notifications/read-batch ────────────────
// Body: { ids: [] } — the inbox marks everything it rendered as seen in one call.
// 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 {
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];
const ids = Array.isArray(req.body?.ids) ? req.body.ids : [];
const changed = await notificationService.markReadMany(ids, req.user.id);
const changed = await notificationService.markReadMany(ids, req.user.id);
@@ -97,7 +97,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -97,7 +97,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── POST /api/notifications/read-all ──────────────────
// ─── POST /api/notifications/read-all ──────────────────
- router.post('/read-all', requireAuth, async (req, res) => {
+ router.post('/read-all', requireDock('activity'), async (req, res) => {
try {
try {
const { category } = req.body || {};
const { category } = req.body || {};
const changed = await notificationService.markAllRead(req.user.id, category || null);
const changed = await notificationService.markAllRead(req.user.id, category || null);
@@ -108,7 +108,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -108,7 +108,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
});
});
// ─── POST /api/notifications/:id/dismiss ───────────────
// ─── POST /api/notifications/:id/dismiss ───────────────
- router.post('/:id/dismiss', requireAuth, async (req, res) => {
+ router.post('/:id/dismiss', requireDock('activity'), async (req, res) => {
try {
try {
const ok = await notificationService.dismiss(req.params.id, req.user.id);
const ok = await notificationService.dismiss(req.params.id, req.user.id);
res.json({ ok });
res.json({ ok });
@@ -130,7 +130,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
@@ -130,7 +130,7 @@ module.exports = function createNotificationRoutes(db, notificationService, requ
// ─── GET /api/notifications/quiet-hours ────────────────
// ─── GET /api/notifications/quiet-hours ────────────────
// The person's do-not-disturb window (default 22:00–07:00 UTC, off) and whether it is now.
// 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 {
try {
const quiet = await notificationService.getQuietHours(req.user.id);
const quiet = await notificationService.getQuietHours(req.user.id);
const in_quiet = await notificationService.inQuietHours(req.user.id);
const in_quiet = await notificationService.inQuietHours(req.user.id);
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
const ALLOW_HEADERS = 'traceparent, X-OpenVibe-Request-Id, Authorization, Content-Type';
const ALLOW_HEADERS = 'traceparent, X-OpenVibe-Request-Id, Authorization, Content-Type';
const EXPOSE_HEADERS = 'X-OpenVibe-Request-Id, traceparent';
const EXPOSE_HEADERS = 'X-OpenVibe-Request-Id, traceparent';
const MAX_AGE_S = 86400;
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.
// A registry segment: URL-safe characters, never '.' or '..' and never an encoded dot or slash.
const SEGMENT_RE = /^[A-Za-z0-9_~:@!$&'()*+,;=.-]+$/;
const SEGMENT_RE = /^[A-Za-z0-9_~:@!$&'()*+,;=.-]+$/;
@@ -52,9 +53,26 @@ function publicCors(req, res, next) {
@@ -52,9 +53,26 @@ function publicCors(req, res, next) {
return 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) {
function gate(restricted) {
return function corsGate(req, res, next) {
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);
if (isPublicDiscoveryPath(req.path)) return publicCors(req, res, next);
return restricted(req, res, next);
return restricted(req, res, next);
};
};
@@ -97,4 +115,4 @@ function restrictedCors(origin) {
@@ -97,4 +115,4 @@ function restrictedCors(origin) {
return require('cors')({ origin, credentials: true, maxAge: RESTRICTED_MAX_AGE_S });
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -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); });
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand']]);
+ 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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
@@ -18,6 +18,9 @@ assert.ok(/zone=openvibenetwork_api:10m rate=10r\/s/.test(conf), "nginx's /api/
@@ -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');
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)); };
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\/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);
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(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));
assert.ok(/zone=openvibenetwork_auth:10m rate=5r\/m;/.test(conf));
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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));
+ }
+})();
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand']]);
+ 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');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');