openvibers/openvibe.network Commits
Sessions: a FedCM assertion is not a session; password changes end OAuth sign-ins and refresh tokens
Changes
@@@ -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"},@@@ -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 credentialsconst 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 userconst 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 pairconst { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client);@@@ -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 validconst db = getDb(req);const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);@@@ -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 };@@@ -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 });nnew file mode 100644@@@ -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); });
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -31,6 +31,11 @@ function verifierMatches(verifier, challenge) {
@@ -31,6 +31,11 @@ function verifierMatches(verifier, challenge) {
const a = Buffer.from(digest); const b = Buffer.from(String(challenge));
const a = Buffer.from(digest); const b = Buffer.from(String(challenge));
return a.length === b.length && crypto.timingSafeEqual(a, b);
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 }) {
function issueCode(db, { clientId, userId, redirectUri, scope, pkce }) {
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();
@@ -142,20 +147,17 @@ router.post('/confirm', (req, res) => {
@@ -142,20 +147,17 @@ router.post('/confirm', (req, res) => {
if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
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' });
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) {
if (devApp) {
const out = devTokens.issueCode(db, { app: devApp.app, project: devApp.project, user, redirectUri: redirect_uri, scope, challenge: String(req.body.code_challenge) });
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) => {
@@ -202,7 +204,7 @@ router.post('/token', (req, res) => {
// Validate client credentials
// Validate client credentials
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(client_id);
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' });
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) {
@@ -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'));
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' });
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
// Get user
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(stored.user_id);
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' });
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
// Issue new token pair
const { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client);
const { accessToken, refreshToken: newRefresh } = issueTokenPair(db, config, req, user, client);
@@ -334,6 +334,9 @@ router.post('/refresh', (req, res) => {
@@ -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
// Verify user still exists and is valid
const db = getDb(req);
const db = getDb(req);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.sub || decoded.id);
@@ -13,6 +13,11 @@ const jwt = require('jsonwebtoken');
@@ -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
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 }) {
function verifySession(token, { db, publicKey, config }) {
if (!token) return { error: 'Authentication required', status: 401 };
if (!token) return { error: 'Authentication required', status: 401 };
const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
@@ -28,6 +33,9 @@ function verifySession(token, { db, publicKey, config }) {
@@ -28,6 +33,9 @@ function verifySession(token, { db, publicKey, config }) {
return { error: 'Invalid or expired token', status: 401 };
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);
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) return { error: 'User not found', status: 401 };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
@@ -93,4 +101,4 @@ function makeRequireAuth(getCtx, signToken) {
@@ -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 };
@@ -77,6 +77,8 @@ router.post('/verify-token', (req, res) => {
@@ -77,6 +77,8 @@ router.post('/verify-token', (req, res) => {
algorithms: [algorithm],
algorithms: [algorithm],
issuer: config.jwt.issuer
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 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);
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 });
res.json({ valid: true, decoded, user: user || null });
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -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); });