openvibers/openvibe.network Commits

Sessions: a FedCM assertion is not a session; password changes end OAuth sign-ins and refresh tokens

OP OpenVibers committed 23 Sept 2026 · 173cd66825 · 6 files, +141 −16

Exploits fixed:
- A FedCM assertion (typ fedcm, 5 minutes, minted for one RP origin) is signed with the session
  key and verifySession, /api/auth/refresh, /oauth/confirm and /internal/verify-token only checked
  the issuer. Any page on an owned-zone origin (or an RP server) holding an assertion could use it
  as a full Network session, refresh it into a 7-day renewable token, or trade it at /oauth/confirm
  for an authorization code for another client. Tokens with typ or actor_type are now refused as
  user sessions everywhere.
- /oauth/confirm verified the JWT itself and never checked token_valid_after, so a token revoked
  by a password change or reset still signed in to every first-party site and developer app
  through the account chooser. It now uses verifySession (revocation, ban) and still refuses
  expired tokens.
- The refresh_token grant ignored token_valid_after: refresh tokens issued before a password
  change or reset kept minting access tokens for 30 days. They are now refused; rotation is
  atomic so one refresh token cannot be redeemed twice concurrently.
- oauth_clients secrets are compared in constant time at /oauth/token.

Changes

ddiff --git a/package.json b/package.json
iindex 5fffdc47..0b744a7f 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/registry-ecosystem.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/security-session.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex f02efe14..73924e3d 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -31,6 +31,11 @@ function verifierMatches(verifier, challenge) {
const a = Buffer.from(digest); const b = Buffer.from(String(challenge));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
++function sameSecret(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
function issueCode(db, { clientId, userId, redirectUri, scope, pkce }) {
const code = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
@@@ -142,20 +147,17 @@ router.post('/confirm', (req, res) => {
if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
}
-- // Verify the token
-- const publicKey = req.app.locals.publicKey;
-- const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
-- let decoded;
-- try {
-- decoded = jwt.verify(token, publicKey, { algorithms: [algorithm], issuer: config.jwt.issuer });
-- } catch {
++ // Verify the token as a live, unexpired session: not revoked by a password change
++ // (token_valid_after), not a FedCM assertion or a service/app token.
++ const out = require('./session').verifySession(String(token), { db, publicKey: req.app.locals.publicKey, config });
++ if (out.error) {
++ if (out.status === 403 || out.error === 'User not found') return res.status(403).json({ error: 'User not found or banned' });
return res.status(401).json({ error: 'Invalid or expired token' });
}
--
-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
-- if (!user || user.is_banned) {
-- return res.status(403).json({ error: 'User not found or banned' });
++ if (typeof out.decoded.exp !== 'number' || out.decoded.exp * 1000 < Date.now()) {
++ return res.status(401).json({ error: 'Invalid or expired token' });
}
++ const user = out.user;
if (devApp) {
const out = devTokens.issueCode(db, { app: devApp.app, project: devApp.project, user, redirectUri: redirect_uri, scope, challenge: String(req.body.code_challenge) });
@@@ -202,7 +204,7 @@ router.post('/token', (req, res) => {
// Validate client credentials
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
-- if (!client || client.client_secret !== client_secret) {
++ if (!client || !sameSecret(client.client_secret, client_secret)) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
@@@ -321,12 +323,21 @@ function handleRefreshGrant(db, config, req, res, client, refreshToken) {
const expiresAt = new Date(stored.expires_at + (stored.expires_at.includes('Z') ? '' : 'Z'));
if (now > expiresAt) return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token expired' });
-- // Revoke old refresh token (rotation)
-- db.prepare('UPDATE oauth_tokens SET revoked = 1 WHERE id = ?').run(stored.id);
++ // Revoke old refresh token (rotation), atomically: two concurrent uses cannot both succeed
++ if (db.prepare('UPDATE oauth_tokens SET revoked = 1 WHERE id = ? AND revoked = 0').run(stored.id).changes !== 1) {
++ return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token revoked' });
++ }
// Get user
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(stored.user_id);
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
++ // A password change/reset (token_valid_after) ends every refresh token issued before it.
++ if (user.token_valid_after && stored.created_at) {
++ const at = (v) => new Date(String(v).replace(' ', 'T') + (String(v).includes('Z') ? '' : 'Z')).getTime();
++ if (at(stored.created_at) < at(user.token_valid_after)) {
++ return res.status(400).json({ error: 'invalid_grant', error_description: 'Refresh token revoked' });
++ }
++ }
// Issue new token pair
const { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client);
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex e98eafab..ea769d9e 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -334,6 +334,9 @@ router.post('/refresh', (req, res) => {
}
}
++ // A FedCM assertion or a service/app token is not a session to refresh.
++ if (!require('./session').isUserSessionClaims(decoded)) return res.status(401).json({ error: 'Invalid token' });
++
// Verify user still exists and is valid
const db = getDb(req);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
ddiff --git a/server/auth/session.js b/server/auth/session.js
iindex 66df001a..e153fb25 100644
---- a/server/auth/session.js
++++ b/server/auth/session.js
@@@ -13,6 +13,11 @@ const jwt = require('jsonwebtoken');
const GRACE_MS = 60 * 24 * 60 * 60 * 1000; // renewable for 60 days after the token's exp
++/** Claims of a user session token, as opposed to a FedCM assertion or a service/app token. */
++function isUserSessionClaims(decoded) {
++ return !!decoded && typeof decoded === 'object' && decoded.typ === undefined && decoded.actor_type === undefined;
++}
++
function verifySession(token, { db, publicKey, config }) {
if (!token) return { error: 'Authentication required', status: 401 };
const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
@@@ -28,6 +33,9 @@ function verifySession(token, { db, publicKey, config }) {
return { error: 'Invalid or expired token', status: 401 };
}
}
++ // Same signing key, different token kinds: a FedCM assertion (minted for one RP origin, to be
++ // exchanged by that RP's server) and service/app tokens are never a person's session.
++ if (!isUserSessionClaims(decoded)) return { error: 'Invalid token', status: 401 };
const user = 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 };
@@@ -93,4 +101,4 @@ function makeRequireAuth(getCtx, signToken) {
};
}
--module.exports = { verifySession, makeRequireAuth, GRACE_MS, COOKIE, SSO_COOKIE, setSessionCookies, clearSessionCookies, requestToken };
++module.exports = { verifySession, isUserSessionClaims, makeRequireAuth, GRACE_MS, COOKIE, SSO_COOKIE, setSessionCookies, clearSessionCookies, requestToken };
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 1fee65f1..d7a2c3df 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -77,6 +77,8 @@ router.post('/verify-token', (req, res) => {
algorithms: [algorithm],
issuer: config.jwt.issuer
});
++ // A FedCM assertion or a service/app token (same signing key) is not a user token.
++ if (!require('../auth/session').isUserSessionClaims(decoded)) return res.json({ valid: false, error: 'not a user token' });
const db = getDb(req);
const user = db.prepare('SELECT id, username, display_name, role, avatar_url, profile_color AS color FROM users WHERE id = ?').get(decoded.sub || decoded.id);
res.json({ valid: true, decoded, user: user || null });
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
nnew file mode 100644
iindex 00000000..d15f344b
---- /dev/null
++++ b/test/security-session.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++// Session-token confusion and revocation:
++// - a FedCM assertion (typ fedcm, minted for one RP origin) is not a Network session anywhere
++// - a token revoked by a password change (token_valid_after) cannot start an OAuth sign-in
++// - refresh tokens issued before a password change stop working
++// node test/security-session.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const cookieParser = require('cookie-parser');
++const jwt = require('jsonwebtoken');
++const { initDb } = require('../server/db/database');
++const { verifySession } = require('../server/auth/session');
++const { signAssertion } = require('../server/auth/fedcm');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-secsess-'));
++const log = console.log; console.log = () => {};
++const db = initDb(path.join(dir, 'network.db'));
++console.log = log;
++db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
++db.prepare("INSERT INTO users (id, username, password_hash) VALUES (7, 'viewer', 'x')").run();
++db.prepare("INSERT INTO users (id, username, password_hash) VALUES (8, 'changed', 'x')").run();
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
++const config = { internalKey: 'k'.repeat(32), baseUrl: ISSUER, loginUrl: ISSUER, networkUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h', refreshTokenExpiry: '30d' } };
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++app.use(cookieParser());
++app.locals.db = db;
++app.locals.config = config;
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/api/auth', require('../server/auth/routes'));
++app.use('/internal', require('../server/internal/routes'));
++const server = http.createServer(app);
++
++const REDIRECT = 'https://openvibe.live/api/auth/callback';
++const ctx = { db, publicKey: keys.publicKey, privateKey: keys.privateKey, config };
++const user7 = db.prepare('SELECT * FROM users WHERE id = 7').get();
++const assertion = signAssertion(user7, 'https://evil.openvibe.tools', 'n', ctx);
++const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h', ...opts });
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const post = (p, body, headers = {}) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })
++ .then(async r => ({ status: r.status, body: await r.json().catch(() => ({})) }));
++
++ // ── FedCM assertions are not sessions ────────────────────────────
++ assert.ok(!verifySession(sign(7), ctx).error, 'a normal user token is a session');
++ assert.ok(verifySession(assertion, ctx).error, 'a FedCM assertion is not a Network session');
++ let r = await post('/oauth/confirm', { token: assertion, client_id: 'live', redirect_uri: REDIRECT });
++ assert.strictEqual(r.status, 401, 'a FedCM assertion cannot sign in to another client through /oauth/confirm');
++ r = await post('/api/auth/refresh', {}, { authorization: `Bearer ${assertion}` });
++ assert.strictEqual(r.status, 401, 'a FedCM assertion cannot be refreshed into a full session');
++ r = await post('/internal/verify-token', { token: assertion }, { 'x-internal-key': config.internalKey });
++ assert.strictEqual(r.body.valid, false, 'internal verify-token does not call a FedCM assertion a user token');
++ r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
++ assert.strictEqual(r.body.valid, true);
++
++ // ── Revoked tokens (password changed after iat) ──────────────────
++ const old = sign(8, { expiresIn: '1h' });
++ // one refresh token issued before the change
++ r = await post('/oauth/confirm', { token: old, client_id: 'live', redirect_uri: REDIRECT });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ const code = new URL(r.body.redirect).searchParams.get('code');
++ const t = await post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code, redirect_uri: REDIRECT });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ // backdate what was issued so far, then change the password now
++ db.prepare("UPDATE oauth_tokens SET created_at = datetime('now', '-10 minutes') WHERE user_id = 8").run();
++ db.prepare("UPDATE users SET token_valid_after = CURRENT_TIMESTAMP WHERE id = 8").run();
++ const stale = jwt.sign({ sub: 8, id: 8, username: 'changed', iat: Math.floor(Date.now() / 1000) - 600 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
++ assert.ok(verifySession(stale, ctx).error, 'the session guard refuses the revoked token');
++ r = await post('/oauth/confirm', { token: stale, client_id: 'live', redirect_uri: REDIRECT });
++ assert.strictEqual(r.status, 401, 'a revoked token cannot start an OAuth sign-in');
++ r = await post('/oauth/token', { grant_type: 'refresh_token', client_id: 'live', client_secret: 'live-secret', refresh_token: t.body.refresh_token });
++ assert.strictEqual(r.status, 400, 'a refresh token issued before the password change is dead');
++ assert.strictEqual(r.body.error, 'invalid_grant');
++
++ // an unaffected user keeps refreshing, and rotation still works
++ r = await post('/oauth/confirm', { token: sign(7), client_id: 'live', redirect_uri: REDIRECT });
++ const t7 = await post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code: new URL(r.body.redirect).searchParams.get('code'), redirect_uri: REDIRECT });
++ assert.strictEqual(t7.status, 200);
++ r = await post('/oauth/token', { grant_type: 'refresh_token', client_id: 'live', client_secret: 'live-secret', refresh_token: t7.body.refresh_token });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ r = await post('/oauth/token', { grant_type: 'refresh_token', client_id: 'live', client_secret: 'live-secret', refresh_token: t7.body.refresh_token });
++ assert.strictEqual(r.status, 400, 'a rotated refresh token is single-use');
++ r = await post('/oauth/token', { grant_type: 'refresh_token', client_id: 'live', client_secret: 'wrong-secret', refresh_token: t7.body.refresh_token });
++ assert.strictEqual(r.status, 401, 'wrong client secret');
++
++ server.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ console.log('session token confusion and revocation: all checks passed');
++})().catch((err) => { console.error(err); server.close(); process.exit(1); });