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
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",++ "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"},@@@ -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();@@@ -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 withconst 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 tokenconst 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 userconst 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'],});});@@@ -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" },@@@ -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;nnew file mode 100644@@@ -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); });
@@ -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",
+ "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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -650,6 +650,8 @@ async function selectAccount(account) {
@@ -650,6 +650,8 @@ async function selectAccount(account) {
redirect_uri: params.get('redirect_uri'),
redirect_uri: params.get('redirect_uri'),
scope: params.get('scope') || 'profile theme',
scope: params.get('scope') || 'profile theme',
state: params.get('state') || '',
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();
const data = await res.json();
@@ -783,6 +785,8 @@ async function handleLogin(e) {
@@ -783,6 +785,8 @@ async function handleLogin(e) {
redirect_uri: params.get('redirect_uri'),
redirect_uri: params.get('redirect_uri'),
scope: params.get('scope') || 'profile theme',
scope: params.get('scope') || 'profile theme',
state: params.get('state') || '',
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();
const confirmData = await confirmRes.json();
@@ -862,6 +866,8 @@ async function handleRegister(e) {
@@ -862,6 +866,8 @@ async function handleRegister(e) {
redirect_uri: params.get('redirect_uri'),
redirect_uri: params.get('redirect_uri'),
scope: params.get('scope') || 'profile theme',
scope: params.get('scope') || 'profile theme',
state: params.get('state') || '',
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();
const confirmData = await confirmRes.json();
@@ -13,6 +13,31 @@ const principals = require('../identity/principals');
@@ -13,6 +13,31 @@ const principals = require('../identity/principals');
const router = express.Router();
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
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; }
function getConfig(req) { return req.app.locals.config; }
// ── GET /authorize ───────────────────────────────────────────
// ── GET /authorize ───────────────────────────────────────────
@@ -39,6 +64,8 @@ router.get('/authorize', (req, res) => {
@@ -39,6 +64,8 @@ router.get('/authorize', (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 });
// 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
@@ -52,14 +79,11 @@ router.get('/authorize', (req, res) => {
@@ -52,14 +79,11 @@ router.get('/authorize', (req, res) => {
if (!out.error) {
if (!out.error) {
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : have;
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : have;
setSessionCookies(res, token);
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 */ }
} 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
// Otherwise the account chooser — the user picks which account to continue with
const loginParams = new URLSearchParams({
const loginParams = new URLSearchParams({
@@ -70,6 +94,7 @@ router.get('/authorize', (req, res) => {
@@ -70,6 +94,7 @@ router.get('/authorize', (req, res) => {
scope: scope || 'profile theme',
scope: scope || 'profile theme',
state: state || '',
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()}`);
res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
});
});
@@ -95,6 +120,8 @@ router.post('/confirm', (req, res) => {
@@ -95,6 +120,8 @@ router.post('/confirm', (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.body);
+ if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
// Verify the token
// Verify the token
const publicKey = req.app.locals.publicKey;
const publicKey = req.app.locals.publicKey;
@@ -111,20 +138,14 @@ router.post('/confirm', (req, res) => {
@@ -111,20 +138,14 @@ router.post('/confirm', (req, res) => {
return res.status(403).json({ error: 'User not found or banned' });
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.
// 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);
- 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 ──────────────────────────────────────────────
// ── POST /token ──────────────────────────────────────────────
@@ -153,7 +174,7 @@ router.post('/token', (req, res) => {
@@ -153,7 +174,7 @@ router.post('/token', (req, res) => {
}
}
if (grant_type === 'authorization_code') {
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') {
} else if (grant_type === 'refresh_token') {
return handleRefreshGrant(db, config, req, res, client, refresh_token);
return handleRefreshGrant(db, config, req, res, client, refresh_token);
} else if (grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
} else if (grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
@@ -183,7 +204,7 @@ function recordLinkedService(db, user, client) {
@@ -183,7 +204,7 @@ function recordLinkedService(db, user, client) {
} catch (err) { console.warn('[OAuth] linked service record failed:', err.message); }
} 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' });
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);
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) {
@@ -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'));
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' });
- // 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
// Get user
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);
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) => {
@@ -329,6 +358,7 @@ router.get('/.well-known/openid-configuration', (req, res) => {
id_token_signing_alg_values_supported: ['RS256'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['profile', 'theme', 'openid'],
scopes_supported: ['profile', 'theme', 'openid'],
token_endpoint_auth_methods_supported: ['client_secret_post'],
token_endpoint_auth_methods_supported: ['client_secret_post'],
+ code_challenge_methods_supported: ['S256'],
});
});
});
});
@@ -475,6 +475,8 @@ function initDb(dbPath) {
@@ -475,6 +475,8 @@ function initDb(dbPath) {
// ── Migration: Add columns that may not exist ────────────
// ── Migration: Add columns that may not exist ────────────
const migrations = [
const migrations = [
{ table: 'linked_accounts', column: 'service_username', sql: "ALTER TABLE linked_accounts ADD COLUMN service_username TEXT" },
{ 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: '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: '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" },
{ table: 'users', column: 'anon_number', sql: "ALTER TABLE users ADD COLUMN anon_number INTEGER" },
@@ -26,7 +26,7 @@ const contractsPkg = require('openvibe-contracts/package.json');
@@ -26,7 +26,7 @@ const contractsPkg = require('openvibe-contracts/package.json');
const INTERNAL = {
const INTERNAL = {
network: 'http://127.0.0.1:4000', live: 'http://127.0.0.1:3000', media: 'http://127.0.0.1:4100',
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',
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;
const POLL_MS = 60 * 1000;
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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); });