openvibers/openvibe.network Commits

OAuth: PKCE (S256 only) bound to authorization codes and checked at the token endpoint; codes redeem atomically once; state is URL-encoded in redirects; registry polls Billing's health

OP OpenVibers committed 23 Sept 2026 · e4ceedf82d · 6 files, +156 −21


Changes

ddiff --git a/package.json b/package.json
iindex e16b8bb8..0fe1a0cf 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",
++ "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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/public/login.html b/public/login.html
iindex 08eaf2bf..83be4a63 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -650,6 +650,8 @@ async function selectAccount(account) {
redirect_uri: params.get('redirect_uri'),
scope: params.get('scope') || 'profile theme',
state: params.get('state') || '',
++ code_challenge: params.get('code_challenge') || undefined,
++ code_challenge_method: params.get('code_challenge_method') || undefined,
}),
});
const data = await res.json();
@@@ -783,6 +785,8 @@ async function handleLogin(e) {
redirect_uri: params.get('redirect_uri'),
scope: params.get('scope') || 'profile theme',
state: params.get('state') || '',
++ code_challenge: params.get('code_challenge') || undefined,
++ code_challenge_method: params.get('code_challenge_method') || undefined,
}),
});
const confirmData = await confirmRes.json();
@@@ -862,6 +866,8 @@ async function handleRegister(e) {
redirect_uri: params.get('redirect_uri'),
scope: params.get('scope') || 'profile theme',
state: params.get('state') || '',
++ code_challenge: params.get('code_challenge') || undefined,
++ code_challenge_method: params.get('code_challenge_method') || undefined,
}),
});
const confirmData = await confirmRes.json();
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 80877ef3..20fccdd5 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -13,6 +13,31 @@ const principals = require('../identity/principals');
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
++
++// PKCE (RFC 7636). S256 only: a `plain` challenge protects nothing once the authorize URL leaks.
++const PKCE_RE = /^[A-Za-z0-9_-]{43,128}$/;
++function readChallenge(src) {
++ const challenge = src.code_challenge ? String(src.code_challenge) : null;
++ const method = src.code_challenge_method ? String(src.code_challenge_method) : (challenge ? 'plain' : null);
++ if (!challenge) return { challenge: null, method: null };
++ if (method !== 'S256') return { error: 'code_challenge_method must be S256' };
++ if (!PKCE_RE.test(challenge)) return { error: 'malformed code_challenge' };
++ return { challenge, method };
++}
++function verifierMatches(verifier, challenge) {
++ if (!PKCE_RE.test(String(verifier || ''))) return false;
++ const digest = crypto.createHash('sha256').update(String(verifier)).digest('base64url');
++ const a = Buffer.from(digest); const b = Buffer.from(String(challenge));
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++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();
++ db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at, code_challenge, code_challenge_method)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt, pkce.challenge, pkce.method);
++ return code;
++}
++const withCode = (redirectUri, code, state) => `${redirectUri}${redirectUri.includes('?') ? '&' : '?'}code=${code}&state=${encodeURIComponent(state || '')}`;
function getConfig(req) { return req.app.locals.config; }
// ── GET /authorize ───────────────────────────────────────────
@@@ -39,6 +64,8 @@ router.get('/authorize', (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 });
// 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
@@@ -52,14 +79,11 @@ router.get('/authorize', (req, res) => {
if (!out.error) {
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : have;
setSessionCookies(res, token);
-- const code = crypto.randomBytes(32).toString('hex');
-- const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
-- db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at) VALUES (?, ?, ?, ?, ?, ?)`)
-- .run(code, client_id, out.user.id, redirect_uri, scope || 'profile theme', expiresAt);
-- return res.redirect(`${redirect_uri}${sep}code=${code}&state=${state || ''}`);
++ const code = issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope, pkce });
++ return res.redirect(withCode(redirect_uri, code, state));
}
} catch { /* fall through */ }
-- return res.redirect(`${redirect_uri}${sep}error=login_required&state=${state || ''}`);
++ return res.redirect(`${redirect_uri}${sep}error=login_required&state=${encodeURIComponent(state || '')}`);
}
// Otherwise the account chooser — the user picks which account to continue with
const loginParams = new URLSearchParams({
@@@ -70,6 +94,7 @@ router.get('/authorize', (req, res) => {
scope: scope || 'profile theme',
state: state || '',
});
++ if (pkce.challenge) { loginParams.set('code_challenge', pkce.challenge); loginParams.set('code_challenge_method', pkce.method); }
res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
});
@@@ -95,6 +120,8 @@ router.post('/confirm', (req, res) => {
if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
return res.status(400).json({ error: 'Invalid redirect_uri' });
}
++ const pkce = readChallenge(req.body);
++ if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
// Verify the token
const publicKey = req.app.locals.publicKey;
@@@ -111,20 +138,14 @@ router.post('/confirm', (req, res) => {
return res.status(403).json({ error: 'User not found or banned' });
}
-- // Issue authorization code
-- const code = crypto.randomBytes(32).toString('hex');
-- const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
-- db.prepare(`
-- INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at)
-- VALUES (?, ?, ?, ?, ?, ?)
-- `).run(code, client_id, user.id, redirect_uri, scope || 'profile theme', expiresAt);
++ // Issue authorization code (bound to the PKCE challenge when the client sent one)
++ const code = issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce });
// 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);
-- const sep = redirect_uri.includes('?') ? '&' : '?';
-- res.json({ redirect: `${redirect_uri}${sep}code=${code}&state=${state || ''}` });
++ res.json({ redirect: withCode(redirect_uri, code, state) });
});
// ── POST /token ──────────────────────────────────────────────
@@@ -153,7 +174,7 @@ router.post('/token', (req, res) => {
}
if (grant_type === 'authorization_code') {
-- return handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri);
++ return handleAuthCodeGrant(db, config, req, res, client, code, redirect_uri, req.body.code_verifier);
} else if (grant_type === 'refresh_token') {
return handleRefreshGrant(db, config, req, res, client, refresh_token);
} else if (grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
@@@ -183,7 +204,7 @@ function recordLinkedService(db, user, client) {
} catch (err) { console.warn('[OAuth] linked service record failed:', err.message); }
}
--function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri) {
++function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, codeVerifier) {
if (!code) return res.status(400).json({ error: 'invalid_request', error_description: 'Missing code' });
const authCode = db.prepare('SELECT * FROM oauth_codes WHERE code = ?').get(code);
@@@ -196,8 +217,16 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri) {
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' });
-- // Mark code as used
-- db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ?').run(code);
++ // A code bound to a PKCE challenge is only redeemable with the matching verifier.
++ if (authCode.code_challenge && !verifierMatches(codeVerifier, authCode.code_challenge)) {
++ 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' });
++ }
++
++ // Mark code as used (atomically: two concurrent exchanges of one code cannot both succeed)
++ if (db.prepare('UPDATE oauth_codes SET used = 1 WHERE code = ? AND used = 0').run(code).changes !== 1) {
++ return res.status(400).json({ error: 'invalid_grant', error_description: 'Code already used' });
++ }
// Get user
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);
@@@ -329,6 +358,7 @@ router.get('/.well-known/openid-configuration', (req, res) => {
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['profile', 'theme', 'openid'],
token_endpoint_auth_methods_supported: ['client_secret_post'],
++ code_challenge_methods_supported: ['S256'],
});
});
ddiff --git a/server/db/database.js b/server/db/database.js
iindex d8289a87..9bdecc7a 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -475,6 +475,8 @@ function initDb(dbPath) {
// ── Migration: Add columns that may not exist ────────────
const migrations = [
{ table: 'linked_accounts', column: 'service_username', sql: "ALTER TABLE linked_accounts ADD COLUMN service_username TEXT" },
++ { table: 'oauth_codes', column: 'code_challenge', sql: "ALTER TABLE oauth_codes ADD COLUMN code_challenge TEXT" },
++ { table: 'oauth_codes', column: 'code_challenge_method', sql: "ALTER TABLE oauth_codes ADD COLUMN code_challenge_method TEXT" },
{ table: 'url_registry', column: 'source', sql: "ALTER TABLE url_registry ADD COLUMN source TEXT NOT NULL DEFAULT 'admin'" },
{ table: 'users', column: 'is_anon', sql: "ALTER TABLE users ADD COLUMN is_anon INTEGER DEFAULT 0" },
{ table: 'users', column: 'anon_number', sql: "ALTER TABLE users ADD COLUMN anon_number INTEGER" },
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
iindex 6a19e562..7edde8c5 100644
---- a/server/registry/ecosystem.js
++++ b/server/registry/ecosystem.js
@@@ -26,7 +26,7 @@ const contractsPkg = require('openvibe-contracts/package.json');
const INTERNAL = {
network: 'http://127.0.0.1:4000', live: 'http://127.0.0.1:3000', media: 'http://127.0.0.1:4100',
tools: 'http://127.0.0.1:4001', games: 'http://127.0.0.1:8000', community: 'http://127.0.0.1:4200',
-- events: 'http://127.0.0.1:4300',
++ events: 'http://127.0.0.1:4300', billing: 'http://127.0.0.1:4600',
};
const POLL_MS = 60 * 1000;
ddiff --git a/test/oauth-pkce.test.js b/test/oauth-pkce.test.js
nnew file mode 100644
iindex 00000000..65d2b862
---- /dev/null
++++ b/test/oauth-pkce.test.js
@@@ -0,0 +1,97 @@
++'use strict';
++// OAuth authorization-code flow with PKCE (RFC 7636, S256 only) and single-use codes.
++// node test/oauth-pkce.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 jwt = require('jsonwebtoken');
++const { initDb } = require('../server/db/database');
++
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-pkce-'));
++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();
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++app.locals.db = db;
++app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h', refreshTokenExpiry: '30d' } };
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/oauth', require('../server/auth/oauth-routes'));
++const server = http.createServer(app);
++
++const REDIRECT = 'https://openvibe.live/api/auth/callback';
++const verifier = crypto.randomBytes(32).toString('base64url');
++const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++const userToken = jwt.sign({ sub: 7, id: 7, username: 'viewer' }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '5m' });
++
++(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) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
++ .then(async r => ({ status: r.status, body: await r.json() }));
++ const confirm = (extra = {}) => post('/oauth/confirm', { token: userToken, client_id: 'live', redirect_uri: REDIRECT, state: 'a b&c', ...extra });
++ const codeOf = (redirect) => new URL(redirect).searchParams.get('code');
++ const exchange = (code, extra = {}) => post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code, redirect_uri: REDIRECT, ...extra });
++
++ // authorize forwards the challenge to the account chooser; plain and malformed challenges are refused
++ let r = await fetch(`${base}/oauth/authorize?${new URLSearchParams({ client_id: 'live', redirect_uri: REDIRECT, response_type: 'code', state: 's', code_challenge: challenge, code_challenge_method: 'S256' })}`, { redirect: 'manual' });
++ assert.strictEqual(r.status, 302);
++ const login = new URL(r.headers.get('location'));
++ assert.strictEqual(login.searchParams.get('code_challenge'), challenge);
++ assert.strictEqual(login.searchParams.get('code_challenge_method'), 'S256');
++ r = await fetch(`${base}/oauth/authorize?${new URLSearchParams({ client_id: 'live', redirect_uri: REDIRECT, response_type: 'code', code_challenge: challenge, code_challenge_method: 'plain' })}`, { redirect: 'manual' });
++ assert.strictEqual(r.status, 400, 'plain PKCE is refused');
++ r = await fetch(`${base}/oauth/authorize?${new URLSearchParams({ client_id: 'live', redirect_uri: REDIRECT, response_type: 'code', code_challenge: 'short', code_challenge_method: 'S256' })}`, { redirect: 'manual' });
++ assert.strictEqual(r.status, 400, 'malformed challenge is refused');
++
++ // state is URL-encoded in the redirect
++ let c = await confirm({ code_challenge: challenge, code_challenge_method: 'S256' });
++ assert.strictEqual(c.status, 200, JSON.stringify(c.body));
++ assert.strictEqual(new URL(c.body.redirect).searchParams.get('state'), 'a b&c');
++
++ // a PKCE-bound code needs the verifier; a wrong verifier burns the code
++ let t = await exchange(codeOf(c.body.redirect));
++ assert.strictEqual(t.status, 400); assert.match(t.body.error_description, /PKCE/);
++ t = await exchange(codeOf(c.body.redirect), { code_verifier: verifier });
++ assert.strictEqual(t.status, 400, 'a failed verification burns the code');
++
++ c = await confirm({ code_challenge: challenge, code_challenge_method: 'S256' });
++ t = await exchange(codeOf(c.body.redirect), { code_verifier: crypto.randomBytes(32).toString('base64url') });
++ assert.strictEqual(t.status, 400, 'wrong verifier');
++
++ c = await confirm({ code_challenge: challenge, code_challenge_method: 'S256' });
++ const code = codeOf(c.body.redirect);
++ t = await exchange(code, { code_verifier: verifier });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ assert.ok(t.body.access_token);
++ t = await exchange(code, { code_verifier: verifier });
++ assert.strictEqual(t.status, 400, 'codes are single-use');
++
++ // clients that do not send PKCE keep working (confidential clients with a secret)
++ c = await confirm();
++ t = await exchange(codeOf(c.body.redirect));
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++
++ // concurrent redemption of one code: exactly one wins
++ c = await confirm({ code_challenge: challenge, code_challenge_method: 'S256' });
++ const racing = await Promise.all([1, 2, 3].map(() => exchange(codeOf(c.body.redirect), { code_verifier: verifier })));
++ assert.strictEqual(racing.filter(x => x.status === 200).length, 1);
++
++ // discovery advertises S256
++ const disc = await fetch(`${base}/oauth/.well-known/openid-configuration`).then(x => x.json());
++ assert.deepStrictEqual(disc.code_challenge_methods_supported, ['S256']);
++
++ console.log('oauth pkce: all checks passed');
++ server.close();
++})().catch(err => { console.error(err); process.exit(1); });