openvibers/openvibe.live Commits

Sign-in: move the OAuth half to openvibe-sdk/sso (PKCE, /auth/health) (#614)

OP OpenVibers committed 7 days ago · 61ca10e302 · 4 files, +384 −102

Changes

ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex fd1f0432..8e55a4ab 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -172,7 +172,8 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>GET</td><td>/api/auth/sso/login</td><td>No</td><td>302 &rarr; openvibe.network OAuth authorize</td></tr>
--<tr><td>GET</td><td>/api/auth/callback?code&amp;state</td><td>No</td><td>OAuth code&rarr;token exchange; sets cookies</td></tr>
++<tr><td>GET</td><td>/api/auth/callback?code&amp;state</td><td>No</td><td>OAuth code&rarr;token exchange (PKCE); sets cookies</td></tr>
++<tr><td>GET</td><td>/api/auth/health</td><td>No</td><td>OAuth client health; 503 only when the Network refuses this site's client</td></tr>
<tr><td>POST</td><td>/api/auth/refresh</td><td>Cookie</td><td>Refresh access token via httpOnly <code>ov_refresh</code> cookie</td></tr>
<tr><td>POST</td><td>/api/auth/logout</td><td>No</td><td>Clear auth cookies</td></tr>
<tr><td>GET</td><td>/api/auth/sso/status</td><td>No</td><td>SSO enabled flag + login URL</td></tr>
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex b468fd8a..22b14fa6 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -3,8 +3,12 @@
* All authentication is via OpenVibe.Tools OAuth2 SSO.
* No local login/register — users sign in at openvibe.network.
*
++ * The OAuth half is openvibe-sdk/sso's (createSsoClient): the authorize URL, the PKCE S256 pair, the code, refresh and
++ * FedCM grants and GET /api/auth/health. The state is compared with the flow cookie in constant time.
++ *
* GET /api/auth/sso/login — Redirect to openvibe.network OAuth (?silent=1, ?prompt=select_account|login, ?login_hint, ?next)
-- * GET /api/auth/callback — OAuth callback
++ * GET /api/auth/callback — OAuth callback (exchanges the code with the PKCE verifier)
++ * GET /api/auth/health — Does the Network accept this OAuth client? 503 only when it refuses it
* GET /api/auth/me — Current user
* PUT /api/auth/profile — Update profile
* POST /api/auth/avatar — Upload avatar
@@@ -13,11 +17,13 @@
* GET /api/auth/user/:username
* GET /api/auth/sso/status
*/
++const crypto = require('crypto');
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const fs = require('fs');
const db = require('../db/database');
++const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const { requireAuth, optionalAuth } = require('./auth');
const permissions = require('./permissions');
const config = require('../config');
@@@ -272,15 +278,54 @@ function getNetworkTokenBase() {
return config.openvibeToolsInternalUrl || getNetworkBase();
}
--function getNetworkHttpModule() {
-- const url = new URL(getNetworkTokenBase());
-- return url.protocol === 'http:' ? require('http') : require('https');
--}
--
function getNetworkRedirectUri() {
return `${config.baseUrl.toLowerCase()}/api/auth/callback`;
}
++const VERIFIER_COOKIE = 'ov_oauth_verifier';
++
++/** Compare two strings in constant time (the state and verifier must match the browser that started the flow). */
++function sameString(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);
++}
++
++let ssoClient = null;
++let ssoClientKey = null;
++/**
++ * openvibe-sdk/sso with this site's OAuth client. The registry can change the Network URLs while the process runs
++ * (config.refreshRegistry), so the client is rebuilt when the URLs it was built with change.
++ */
++function getSsoClient() {
++ const key = [config.baseUrl, getNetworkBase(), getNetworkTokenBase(), OV_CLIENT_ID].join('\n');
++ if (!ssoClient || ssoClientKey !== key) {
++ ssoClient = createSsoClient({
++ site: 'live',
++ siteName: 'OpenVibe.Live',
++ baseUrl: config.baseUrl,
++ clientId: OV_CLIENT_ID,
++ clientSecret: OV_CLIENT_SECRET,
++ redirectUri: getNetworkRedirectUri(),
++ scope: 'profile theme',
++ networkUrl: getNetworkBase(),
++ networkInternalUrl: getNetworkTokenBase(),
++ secureCookies: config.baseUrl.startsWith('https'),
++ fetch: globalThis.fetch,
++ log: console,
++ });
++ ssoClientKey = key;
++ }
++ return ssoClient;
++}
++
++/** Clear the short-lived cookies of a sign-in flow (the verifier only ever lived on the callback path). */
++function clearFlowCookies(res) {
++ res.clearCookie('oauth_state');
++ res.clearCookie(VERIFIER_COOKIE, { path: '/api/auth' });
++ res.clearCookie('oauth_next');
++}
++
/**
* Where a sign-in/sign-out may send the browser afterwards: a path on this site, or an
* https URL on the OpenVibe network — openvibe.network's "sign in everywhere" chain hops
@@@ -292,10 +337,8 @@ const HINT_COOKIE = { httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1
// ── Initiate OAuth Login (redirect to openvibe.network) ───────────
router.get('/sso/login', async (req, res) => {
-- const state = require('crypto').randomBytes(16).toString('hex');
-- // Store state in a short-lived cookie for CSRF protection
const isSecure = config.baseUrl.startsWith('https');
-- res.cookie('oauth_state', state, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
++ const flow = { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure };
// Where to go afterwards (the fanout chain, or the page that asked to sign in).
const next = safeNext(req.query.next);
// Already signed in here (a link hand-off from another OpenVibe site, or a stale hint): no
@@@ -303,28 +346,27 @@ router.get('/sso/login', async (req, res) => {
if (req.query.silent) {
try {
const have = req.cookies?.token || req.cookies?.ov_token;
-- if (have && await require('./auth').verifyToken(have)) return res.redirect(next);
++ if (have && await require('./auth').verifyToken(have)) {
++ clearFlowCookies(res);
++ return res.redirect(next);
++ }
} catch { /* fall through to the network */ }
}
-- if (next !== '/') res.cookie('oauth_next', next, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
-- else res.clearCookie('oauth_next');
-- const params = new URLSearchParams({
-- client_id: OV_CLIENT_ID,
-- redirect_uri: getNetworkRedirectUri(),
-- response_type: 'code',
-- scope: 'profile theme',
-- state,
-- });
// ?silent=1: continue as the account openvibe.network already knows (no chooser); if the
// network has no session either, it bounces back with error=login_required and we stay quiet.
-- if (req.query.silent) params.set('prompt', 'none');
// "Switch account" (the chooser) and "use another account" (the sign-in form); login_hint names the account to
-- // continue as when this browser has it (Network docs/sign-in.md).
-- else if (['select_account', 'login'].includes(String(req.query.prompt || ''))) params.set('prompt', String(req.query.prompt));
-- const hint = String(req.query.login_hint || '').trim();
-- if (/^@?[A-Za-z0-9_.-]{1,64}$/.test(hint)) params.set('login_hint', hint);
-- res.redirect(`${getNetworkBase()}/oauth/authorize?${params.toString()}`);
++ // continue as when this browser has it (Network docs/sign-in.md). openvibe-sdk/sso drops anything else.
++ const started = getSsoClient().authorizeUrl({
++ silent: !!req.query.silent,
++ prompt: String(req.query.prompt || ''),
++ loginHint: String(req.query.login_hint || ''),
++ });
++ res.cookie('oauth_state', started.state, flow);
++ res.cookie(VERIFIER_COOKIE, started.verifier, { ...flow, path: '/api/auth' });
++ if (next !== '/') res.cookie('oauth_next', next, flow);
++ else res.clearCookie('oauth_next');
++ res.redirect(started.url);
});
/**
@@@ -417,30 +459,6 @@ async function establishNetworkSession(req, res, tokenData) {
return { localUser, openvibeToolsToken, openvibeRefreshToken };
}
--/** POST to the network's /oauth/token (server-to-server) and parse the JSON answer. */
--function networkTokenRequest(payload) {
-- return new Promise((resolve, reject) => {
-- const body = JSON.stringify(payload);
-- const url = new URL(`${getNetworkTokenBase()}/oauth/token`);
-- const httpModule = getNetworkHttpModule();
-- const reqOpts = {
-- hostname: url.hostname,
-- port: url.port || (url.protocol === 'https:' ? 443 : 80),
-- path: url.pathname + url.search,
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
-- };
-- const httpReq = httpModule.request(reqOpts, (httpRes) => {
-- let data = '';
-- httpRes.on('data', chunk => data += chunk);
-- httpRes.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error('Invalid token response')); } });
-- });
-- httpReq.on('error', reject);
-- httpReq.write(body);
-- httpReq.end();
-- });
--}
--
// ── FedCM sign-in ────────────────────────────────────────────
// The browser obtained an assertion from openvibe.network for this origin (shared sso-client.js);
// exchange it server-to-server for the usual token pair and open the same session the OAuth
@@@ -453,11 +471,16 @@ router.post('/fedcm', express.json({ limit: '8kb' }), async (req, res) => {
let claims = null;
try { claims = JSON.parse(Buffer.from(String(token).split('.')[1] || '', 'base64url').toString('utf8')); } catch { /* */ }
if (!claims || (nonce && claims.nonce !== nonce)) return res.status(400).json({ error: 'Assertion does not match this request' });
-- const tokenData = await networkTokenRequest({
-- grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
-- assertion: token, client_id: OV_CLIENT_ID, client_secret: OV_CLIENT_SECRET,
-- });
-- if (tokenData.error) return res.status(401).json({ error: tokenData.error, error_description: tokenData.error_description || null });
++ let tokenData;
++ try {
++ tokenData = await getSsoClient().tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
++ } catch (err) {
++ if (err.status && err.status < 500) {
++ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || null });
++ }
++ throw err;
++ }
++ if (!tokenData.access_token) throw Object.assign(new Error('No access token in response'), { status: 401 });
const { localUser } = await establishNetworkSession(req, res, tokenData);
res.set('Cache-Control', 'no-store');
res.json({ ok: true, user: { id: localUser.id, username: localUser.username, display_name: localUser.display_name || localUser.username, avatar_url: localUser.avatar_url || null } });
@@@ -475,19 +498,20 @@ router.get('/callback', async (req, res) => {
res.clearCookie('oauth_next');
if (!code && req.query.error) {
// Silent sign-in found no network session — go on quietly as a guest.
-- res.clearCookie('oauth_state');
++ clearFlowCookies(res);
const sep = next.includes('?') ? '&' : '?';
return res.redirect(`${next}${sep}sso=none`);
}
if (!code) return res.status(400).send('Missing authorization code');
-- // Validate CSRF state
++ // Validate CSRF state and the PKCE verifier
// The state cookie is required, not just compared when present. Skipping the check when the
// cookie was missing let a crafted link sign a visitor into someone else's account (login CSRF).
-- // Only /sso/login starts this flow, and it always sets the cookie.
++ // Only /sso/login starts this flow, and it always sets both cookies; the comparison is constant time.
const savedState = req.cookies?.oauth_state;
-- if (!savedState || !state || savedState !== state) {
-- res.clearCookie('oauth_state');
++ const verifier = req.cookies?.[VERIFIER_COOKIE];
++ if (!savedState || !state || !verifier || !sameString(state, savedState)) {
++ clearFlowCookies(res);
return res.status(403).type('html').send('<!doctype html><meta charset="utf-8"><title>Sign-in expired</title>'
+ '<body style="font-family:system-ui;background:#0f0f14;color:#eee;display:grid;place-items:center;min-height:100vh;margin:0">'
+ '<div style="text-align:center"><h1 style="font-size:1.3rem">That sign-in link expired</h1>'
@@@ -495,19 +519,31 @@ router.get('/callback', async (req, res) => {
+ '<p><a style="color:#c4a1ff" href="/api/auth/sso/login">Sign in again</a> · <a style="color:#c4a1ff" href="/">Home</a></p></div>');
}
res.clearCookie('oauth_state');
++ res.clearCookie(VERIFIER_COOKIE, { path: '/api/auth' });
-- // Exchange code for tokens
-- const tokenData = await networkTokenRequest({
-- grant_type: 'authorization_code',
-- client_id: OV_CLIENT_ID,
-- client_secret: OV_CLIENT_SECRET,
-- code,
-- redirect_uri: getNetworkRedirectUri(),
-- });
--
-- if (tokenData.error) {
-- console.error('[Auth/SSO] Token exchange failed:', tokenData.error_description || tokenData.error);
-- return res.status(400).send(`OAuth error: ${tokenData.error_description || tokenData.error}`);
++ // Exchange code for tokens (openvibe-sdk/sso adds the client credentials and posts the verifier).
++ let tokenData;
++ try {
++ tokenData = await getSsoClient().tokenGrant({
++ grant_type: 'authorization_code',
++ redirect_uri: getNetworkRedirectUri(),
++ code: String(code),
++ code_verifier: verifier,
++ });
++ } catch (err) {
++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
++ console.error(`[Auth/SSO] the Network refuses this site's OAuth client (${err.error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);
++ return res.status(500).type('html').send(failurePage({
++ siteName: 'OpenVibe.Live',
++ title: 'Sign-in isn\'t working on OpenVibe.Live right now',
++ text: 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.',
++ retryHref: `/api/auth/sso/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`,
++ backHref: next || '/',
++ }));
++ }
++ console.error('[Auth/SSO] Token exchange failed:', err.error_description || err.error || err.message);
++ if (err.status && err.status < 500) return res.status(400).send(`OAuth error: ${err.error_description || err.error}`);
++ return res.status(500).send('OAuth login failed. Please try again.');
}
const { localUser, openvibeToolsToken, openvibeRefreshToken } = await establishNetworkSession(req, res, tokenData);
@@@ -537,6 +573,17 @@ router.get('/callback', async (req, res) => {
}
});
++// ── Health: does the Network accept this site's OAuth client? ───────────────
++// 503 only when the Network refuses it (invalid_client); an unreachable Network is 'unknown', not a failure of the
++// site's credentials. Cached 10 minutes by the SDK. Exposed at /api/auth/health for this site's API and at
++// /auth/health for the network-wide sign-in canary (OpenVibe.Host docs/auth-health.md).
++async function healthHandler(_req, res) {
++ const client = await getSsoClient().checkClient();
++ res.set('Cache-Control', 'no-store');
++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
++}
++router.get('/health', healthHandler);
++
// ── Token Refresh (server-to-server using httpOnly refresh cookie) ──
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies?.ov_refresh;
@@@ -547,38 +594,12 @@ router.post('/refresh', async (req, res) => {
return res.json({ access_token: null, user: null });
}
try {
-- const tokenData = await new Promise((resolve, reject) => {
-- const body = JSON.stringify({
-- grant_type: 'refresh_token',
-- client_id: OV_CLIENT_ID,
-- client_secret: OV_CLIENT_SECRET,
-- refresh_token: refreshToken,
-- });
-- const url = new URL(`${getNetworkTokenBase()}/oauth/token`);
-- const httpModule = getNetworkHttpModule();
-- const httpReq = httpModule.request({
-- hostname: url.hostname,
-- port: url.port || (url.protocol === 'https:' ? 443 : 80),
-- path: url.pathname + url.search,
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
-- }, (httpRes) => {
-- let data = '';
-- httpRes.on('data', chunk => data += chunk);
-- httpRes.on('end', () => {
-- try { resolve(JSON.parse(data)); }
-- catch { reject(new Error('Invalid response')); }
-- });
-- });
-- httpReq.on('error', reject);
-- httpReq.write(body);
-- httpReq.end();
-- });
++ const tokenData = await getSsoClient().tokenGrant({ grant_type: 'refresh_token', refresh_token: refreshToken });
-- if (tokenData.error || !tokenData.access_token) {
++ if (!tokenData.access_token) {
// Refresh failed — clear stale cookies
res.clearCookie('ov_refresh', { path: '/api/auth' });
-- return res.status(401).json({ error: tokenData.error_description || 'Refresh failed' });
++ return res.status(401).json({ error: 'Refresh failed' });
}
const isSecure = (process.env.BASE_URL || '').startsWith('https');
@@@ -588,6 +609,11 @@ router.post('/refresh', async (req, res) => {
}
res.json({ access_token: tokenData.access_token, expires_in: tokenData.expires_in || 86400 });
} catch (err) {
++ if (err.status && err.status < 500) {
++ // The Network rejected the refresh token — clear the stale cookie, as before.
++ res.clearCookie('ov_refresh', { path: '/api/auth' });
++ return res.status(401).json({ error: err.error_description || 'Refresh failed' });
++ }
console.error('[Auth] Refresh error:', err.message);
res.status(500).json({ error: 'Token refresh failed' });
}
@@@ -873,3 +899,7 @@ router.get('/ice-servers', optionalAuth, (req, res) => {
});
module.exports = router;
++// The network-wide sign-in canary probes <origin>/auth/health; this site's auth API lives under /api/auth.
++module.exports.health = healthHandler;
++// Exported for the sign-in tests: the constant-time comparison the callback uses for the state.
++module.exports.sameString = sameString;
ddiff --git a/server/index.js b/server/index.js
iindex 64fcabd9..a0a1c3fb 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -553,7 +553,7 @@ async function isBanExemptAdmin(req) { const u = await banRequestUser(req); retu
function isBanExemptAdminUser(u) { return !!(u && !u.is_banned && require('./auth/permissions').can(u, 'staff.limits.exempt')); }
/** Paths a banned network may still reach: health, the ban page's assets, SSO (so an admin can sign in), WHIP (stream-key auth, checks bans itself). */
function banPassPath(p) {
-- return p === '/api/health' || p.startsWith('/banned') || p.startsWith('/assets/') || p.startsWith('/api/auth/sso') || p === '/api/auth/callback' || p === '/api/auth/logout' || p.startsWith('/whip');
++ return p === '/api/health' || p === '/api/auth/health' || p === '/auth/health' || p.startsWith('/banned') || p.startsWith('/assets/') || p.startsWith('/api/auth/sso') || p === '/api/auth/callback' || p === '/api/auth/logout' || p.startsWith('/whip');
}
async function banNameFor(ban) {
try { if (ban && ban.user_id) { const u = await db.getUserById(ban.user_id); if (u) return u.display_name || u.username; } } catch { /* */ }
@@@ -798,6 +798,9 @@ app.use('/data/offline', express.static(paths.data('offline'), {
}));
// ── API Routes ───────────────────────────────────────────────
++// The network-wide sign-in canary probes <origin>/auth/health (OpenVibe.Host docs/auth-health.md); Live's own auth
++// API stays under /api/auth, where /api/auth/health also answers.
++app.get('/auth/health', authRoutes.health);
app.use('/api/auth', authRoutes);
app.use('/api/bench-stream', require('./bench-stream/routes'));
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
ddiff --git a/test/sso-pkce-health.test.js b/test/sso-pkce-health.test.js
nnew file mode 100644
iindex 00000000..d0fa458d
---- /dev/null
++++ b/test/sso-pkce-health.test.js
@@@ -0,0 +1,248 @@
++/**
++ * Live's sign-in is an OAuth2 client of OpenVibe.Network. The OAuth half moved to openvibe-sdk/sso: the authorize URL
++ * now carries a PKCE S256 challenge, the callback requires the matching verifier, the flow state is compared in
++ * constant time, and GET /api/auth/health says whether the Network accepts Live's client. The site's own cookie names
++ * and the hbt_ API tokens are unchanged.
++ *
++ * node test/sso-pkce-health.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++
++process.env.NODE_ENV = 'test';
++process.env.BASE_URL = 'http://live.test';
++process.env.OV_OAUTH_CLIENT_ID = 'live';
++delete process.env.OV_OAUTH_CLIENT_SECRET;
++
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++console.error = () => {};
++
++const express = require('express');
++const cookieParser = require('cookie-parser');
++const db = require('../server/db/database');
++const config = require('../server/config');
++const routes = require('../server/auth/routes');
++
++function listen(server) {
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server.address().port)));
++}
++
++function json(res, status, body) {
++ res.writeHead(status, { 'content-type': 'application/json' });
++ res.end(JSON.stringify(body));
++}
++
++/** The stand-in Network: the grants Live asks for, and the token request bodies it saw. */
++function startNetwork() {
++ const seen = [];
++ let mode = 'ok';
++ const server = http.createServer((req, res) => {
++ if (req.method !== 'POST' || req.url !== '/oauth/token') return json(res, 404, { error: 'not_found' });
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ let payload;
++ try { payload = JSON.parse(body); } catch { return json(res, 400, { error: 'invalid_request' }); }
++ seen.push(payload);
++ if (mode === 'invalid_client') return json(res, 401, { error: 'invalid_client', error_description: 'bad client' });
++ if (payload.grant_type === 'authorization_code') {
++ if (payload.code !== 'good' || !/^[A-Za-z0-9_-]{43}$/.test(String(payload.code_verifier || ''))) {
++ return json(res, 400, { error: 'invalid_grant', error_description: 'code was not issued' });
++ }
++ return json(res, 200, {
++ access_token: 'access-good', refresh_token: 'refresh-good', token_type: 'Bearer', expires_in: 3600,
++ user: { id: 'network-1', username: 'networkuser', display_name: 'Network User', role: 'user' },
++ });
++ }
++ if (payload.grant_type === 'refresh_token') {
++ if (payload.refresh_token !== 'refresh-good') return json(res, 400, { error: 'invalid_grant' });
++ return json(res, 200, { access_token: 'access-refreshed', refresh_token: 'refresh-2', expires_in: 3600 });
++ }
++ return json(res, 400, { error: 'unsupported_grant_type' });
++ });
++ });
++ return { server, seen, setMode: (m) => { mode = m; } };
++}
++
++/** A Network that refuses this site's client, for the health 503 case. */
++function startRefuser() {
++ const server = http.createServer((req, res) => {
++ if (req.method === 'POST' && req.url === '/oauth/token') return json(res, 401, { error: 'invalid_client' });
++ return json(res, 404, { error: 'not_found' });
++ });
++ return { server };
++}
++
++function setCookies(res) {
++ if (typeof res.headers.getSetCookie === 'function') return res.headers.getSetCookie();
++ const raw = res.headers.get('set-cookie') || '';
++ return raw ? raw.split(/,\s*(?=[^;,]+=)/) : [];
++}
++
++function jarOf(res) {
++ const jar = {};
++ for (const header of setCookies(res)) {
++ const pair = header.split(';')[0];
++ const at = pair.indexOf('=');
++ if (at > 0) jar[pair.slice(0, at).trim()] = pair.slice(at + 1);
++ }
++ return jar;
++}
++
++function cookieHeader(jar) {
++ return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ');
++}
++
++(async () => {
++ await db.initDb();
++ config.baseUrl = 'http://live.test';
++ config.openvibeToolsUrl = 'https://openvibe.network';
++
++ const network = startNetwork();
++ const networkPort = await listen(network.server);
++ config.openvibeToolsInternalUrl = `http://127.0.0.1:${networkPort}`;
++
++ const refuser = startRefuser();
++ const refuserPort = await listen(refuser.server);
++
++ const app = express();
++ app.use(cookieParser());
++ app.use(express.json());
++ app.get('/auth/health', routes.health);
++ app.use('/api/auth', routes);
++ const appServer = http.createServer(app);
++ const appPort = await listen(appServer);
++
++ const request = async (pathname, { cookie, method = 'GET', headers = {}, body } = {}) => {
++ const res = await fetch(`http://127.0.0.1:${appPort}${pathname}`, {
++ method, redirect: 'manual',
++ headers: { ...(cookie ? { cookie } : {}), ...headers },
++ body,
++ });
++ const text = await res.text();
++ return { status: res.status, location: res.headers.get('location'), setCookies: setCookies(res), text, headers: res.headers };
++ };
++
++ try {
++ // ── Login: PKCE S256, state, verifier cookie, silent/prompt/login_hint ──────────────────
++ const login = await request('/api/auth/sso/login?silent=1&next=%2Fdashboard');
++ assert.strictEqual(login.status, 302);
++ const authorize = new URL(login.location);
++ assert.deepStrictEqual([authorize.origin + authorize.pathname, authorize.searchParams.get('client_id')],
++ ['https://openvibe.network/oauth/authorize', 'live']);
++ assert.strictEqual(authorize.searchParams.get('redirect_uri'), 'http://live.test/api/auth/callback');
++ assert.strictEqual(authorize.searchParams.get('response_type'), 'code');
++ assert.strictEqual(authorize.searchParams.get('scope'), 'profile theme');
++ assert.strictEqual(authorize.searchParams.get('prompt'), 'none', 'silent stays prompt=none');
++ const state = authorize.searchParams.get('state');
++ assert.match(state, /^[A-Za-z0-9_-]{32}$/, 'the SDK state is 24 random bytes, base64url');
++ assert.strictEqual(authorize.searchParams.get('code_challenge_method'), 'S256');
++ assert.match(authorize.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/, 'a PKCE S256 challenge');
++ const loginJar = jarOf(login);
++ assert.strictEqual(loginJar.oauth_state, state, 'the state stays in oauth_state');
++ assert.strictEqual(decodeURIComponent(loginJar.oauth_next), '/dashboard');
++ assert.match(loginJar.ov_oauth_verifier, /^[A-Za-z0-9_-]{43}$/, 'the verifier gets its own flow cookie');
++ const verifierCookie = login.setCookies.find((c) => /^ov_oauth_verifier=/.test(c));
++ assert.match(verifierCookie, /; Max-Age=300; Path=\/api\/auth; .*HttpOnly/, '5 minutes, httpOnly, callback path');
++
++ let prompt = await request('/api/auth/sso/login?prompt=select_account&login_hint=%40alt');
++ let u = new URL(prompt.location);
++ assert.deepStrictEqual([u.searchParams.get('prompt'), u.searchParams.get('login_hint')], ['select_account', '@alt']);
++ prompt = await request('/api/auth/sso/login?prompt=consent&login_hint=%3Cb%3E');
++ u = new URL(prompt.location);
++ assert.deepStrictEqual([u.searchParams.get('prompt'), u.searchParams.get('login_hint')], [null, null], 'anything else is dropped');
++ const evilNext = await request('/api/auth/sso/login?next=https%3A%2F%2Fevil.test%2F');
++ assert.ok(!jarOf(evilNext).oauth_next, 'safeNext still drops a next that is not an OpenVibe site');
++
++ // ── The state comparison is constant time ──────────────────────────────────────────────
++ assert.strictEqual(routes.sameString('a-state', 'a-state'), true);
++ assert.strictEqual(routes.sameString('a-state', 'a-statf'), false, 'same length, different value');
++ assert.strictEqual(routes.sameString('a-state', 'a-state!'), false, 'different length');
++ assert.strictEqual(routes.sameString('', ''), false, 'an empty state is never a match');
++ assert.match(fs.readFileSync(path.join(__dirname, '..', 'server', 'auth', 'routes.js'), 'utf8'),
++ /crypto\.timingSafeEqual/, 'the callback compares the state in constant time');
++
++ // ── Callback: the verifier is required, and the state must match ───────────────────────
++ const before = network.seen.length;
++ const noVerifier = await request(`/api/auth/callback?code=good&state=${encodeURIComponent(state)}`,
++ { cookie: `oauth_state=${state}; oauth_next=%2Fdashboard` });
++ assert.strictEqual(noVerifier.status, 403);
++ assert.match(noVerifier.text, /That sign-in link expired/, 'a flow from before the deploy retries the normal way');
++ assert.strictEqual(network.seen.length, before, 'no verifier: nothing is sent to the Network');
++
++ const wrongState = state.slice(0, -1) + (state.endsWith('A') ? 'B' : 'A');
++ const mismatch = await request(`/api/auth/callback?code=good&state=${encodeURIComponent(wrongState)}`,
++ { cookie: cookieHeader(loginJar) });
++ assert.strictEqual(mismatch.status, 403);
++ assert.strictEqual(network.seen.length, before, 'a state mismatch: nothing is sent to the Network');
++
++ const callback = await request(`/api/auth/callback?code=good&state=${encodeURIComponent(state)}`,
++ { cookie: cookieHeader(loginJar) });
++ assert.strictEqual(callback.status, 200, 'the callback hands the browser the session page');
++ assert.match(callback.text, /window\.location\.replace\("\/dashboard"\)/, 'safeNext at the callback still wins');
++ const grant = network.seen[network.seen.length - 1];
++ assert.strictEqual(grant.grant_type, 'authorization_code');
++ assert.strictEqual(grant.client_id, 'live');
++ assert.strictEqual(grant.redirect_uri, 'http://live.test/api/auth/callback');
++ assert.strictEqual(grant.code_verifier, loginJar.ov_oauth_verifier, 'the code is exchanged with the verifier from the cookie');
++ const sessionJar = jarOf(callback);
++ assert.strictEqual(sessionJar.ov_token, 'access-good');
++ assert.strictEqual(sessionJar.ov_refresh, 'refresh-good');
++ assert.strictEqual(sessionJar.ov_sso_hint, 'account', 'the cookie names the site has always used are kept');
++
++ // ── The sign-in fan-out is still returned at the callback ──────────────────────────────
++ const fanout = await request('/api/auth/sso/login?next=https%3A%2F%2Fopenvibe.network%2Fsso%2Ffanout%3Fx%3D1');
++ const fanoutJar = jarOf(fanout);
++ const fanoutState = new URL(fanout.location).searchParams.get('state');
++ const fanoutCallback = await request(`/api/auth/callback?code=good&state=${encodeURIComponent(fanoutState)}`,
++ { cookie: cookieHeader(fanoutJar) });
++ assert.strictEqual(fanoutCallback.status, 200);
++ assert.match(fanoutCallback.text, /window\.location\.replace\("https:\/\/openvibe\.network\/sso\/fanout\?x=1"\)/);
++
++ // ── GET /api/auth/health: 200 when the Network accepts the client, 503 when it refuses it ─
++ const healthy = await request('/api/auth/health');
++ assert.strictEqual(healthy.status, 200);
++ assert.deepStrictEqual(JSON.parse(healthy.text), { ok: true, client: 'ok' });
++ assert.strictEqual(healthy.headers.get('cache-control'), 'no-store');
++ assert.strictEqual(network.seen[network.seen.length - 1].code, 'ov_health_check_never_issued');
++ const canary = await request('/auth/health');
++ assert.strictEqual(canary.status, 200, 'the network-wide canary probes <origin>/auth/health');
++ assert.deepStrictEqual(JSON.parse(canary.text), { ok: true, client: 'ok' });
++
++ config.openvibeToolsInternalUrl = `http://127.0.0.1:${refuserPort}`;
++ const refused = await request('/api/auth/health');
++ assert.strictEqual(refused.status, 503, 'a refused client is a 503');
++ assert.deepStrictEqual(JSON.parse(refused.text), { ok: false, client: 'invalid' });
++ assert.strictEqual((await request('/auth/health')).status, 503, 'the canary sees the refusal too');
++
++ config.openvibeToolsInternalUrl = `http://127.0.0.1:${networkPort}`;
++ assert.strictEqual((await request('/api/auth/health')).status, 200, 'the next client build is checked again');
++
++ // ── An hbt_ API token still authenticates ──────────────────────────────────────────────
++ await db.createUser({ username: 'apitokenuser', display_name: 'API Token User', stream_key: 'stream-key-1' });
++ const apiUser = await db.getUserByUsername('apitokenuser');
++ const apiToken = await db.createApiToken(apiUser.id, 'sign-in test', ['chat', 'read']);
++ const me = await request('/api/auth/me', { headers: { authorization: `Bearer ${apiToken.token}` } });
++ assert.strictEqual(me.status, 200, me.text);
++ const meJson = JSON.parse(me.text);
++ assert.strictEqual(meJson.user.username, 'apitokenuser');
++ assert.strictEqual(meJson.user.id, apiUser.id, 'requireAuth resolved the API token to the local account');
++ assert.strictEqual(meJson.capabilities.admin_panel, false, 'the signed-in capabilities are answered, not a guest 401');
++
++ quiet('sso pkce/health: all checks passed');
++ } finally {
++ appServer.close();
++ network.server.close();
++ refuser.server.close();
++ }
++ process.exit(0);
++})().catch((err) => {
++ console.log = quiet;
++ process.stderr.write(`${err.stack || err}\n`);
++ process.exit(1);
++});