openvibers/openvibe.live Commits
Sign-in: move the OAuth half to openvibe-sdk/sso (PKCE, /auth/health) (#614)
Changes
@@@ -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 → openvibe.network OAuth authorize</td></tr>--<tr><td>GET</td><td>/api/auth/callback?code&state</td><td>No</td><td>OAuth code→token exchange; sets cookies</td></tr>++<tr><td>GET</td><td>/api/auth/callback?code&state</td><td>No</td><td>OAuth code→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>@@@ -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 protectionconst 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 cookiesres.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;@@@ -553,7 +553,7 @@ async function isBanExemptAdmin(req) { const u = await banRequestUser(req); retufunction 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).nnew file mode 100644@@@ -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);++});
@@ -172,7 +172,8 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
@@ -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>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
<tr><td>GET</td><td>/api/auth/sso/login</td><td>No</td><td>302 → openvibe.network OAuth authorize</td></tr>
<tr><td>GET</td><td>/api/auth/sso/login</td><td>No</td><td>302 → openvibe.network OAuth authorize</td></tr>
-<tr><td>GET</td><td>/api/auth/callback?code&state</td><td>No</td><td>OAuth code→token exchange; sets cookies</td></tr>
+<tr><td>GET</td><td>/api/auth/callback?code&state</td><td>No</td><td>OAuth code→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/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>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>
<tr><td>GET</td><td>/api/auth/sso/status</td><td>No</td><td>SSO enabled flag + login URL</td></tr>
@@ -3,8 +3,12 @@
@@ -3,8 +3,12 @@
* All authentication is via OpenVibe.Tools OAuth2 SSO.
* All authentication is via OpenVibe.Tools OAuth2 SSO.
* No local login/register — users sign in at openvibe.network.
* 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/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
* GET /api/auth/me — Current user
* PUT /api/auth/profile — Update profile
* PUT /api/auth/profile — Update profile
* POST /api/auth/avatar — Upload avatar
* POST /api/auth/avatar — Upload avatar
@@ -13,11 +17,13 @@
@@ -13,11 +17,13 @@
* GET /api/auth/user/:username
* GET /api/auth/user/:username
* GET /api/auth/sso/status
* GET /api/auth/sso/status
*/
*/
+const crypto = require('crypto');
const express = require('express');
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const multer = require('multer');
const fs = require('fs');
const fs = require('fs');
const db = require('../db/database');
const db = require('../db/database');
+const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const { requireAuth, optionalAuth } = require('./auth');
const { requireAuth, optionalAuth } = require('./auth');
const permissions = require('./permissions');
const permissions = require('./permissions');
const config = require('../config');
const config = require('../config');
@@ -272,15 +278,54 @@ function getNetworkTokenBase() {
@@ -272,15 +278,54 @@ function getNetworkTokenBase() {
return config.openvibeToolsInternalUrl || getNetworkBase();
return config.openvibeToolsInternalUrl || getNetworkBase();
}
}
-function getNetworkHttpModule() {
- const url = new URL(getNetworkTokenBase());
- return url.protocol === 'http:' ? require('http') : require('https');
-}
-
function getNetworkRedirectUri() {
function getNetworkRedirectUri() {
return `${config.baseUrl.toLowerCase()}/api/auth/callback`;
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
* 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
* 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
@@ -292,10 +337,8 @@ const HINT_COOKIE = { httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1
// ── Initiate OAuth Login (redirect to openvibe.network) ───────────
// ── Initiate OAuth Login (redirect to openvibe.network) ───────────
router.get('/sso/login', async (req, res) => {
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');
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).
// Where to go afterwards (the fanout chain, or the page that asked to sign in).
const next = safeNext(req.query.next);
const next = safeNext(req.query.next);
// Already signed in here (a link hand-off from another OpenVibe site, or a stale hint): no
// 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) => {
@@ -303,28 +346,27 @@ router.get('/sso/login', async (req, res) => {
if (req.query.silent) {
if (req.query.silent) {
try {
try {
const have = req.cookies?.token || req.cookies?.ov_token;
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 */ }
} 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
// ?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.
// 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
// "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) {
@@ -417,30 +459,6 @@ async function establishNetworkSession(req, res, tokenData) {
return { localUser, openvibeToolsToken, openvibeRefreshToken };
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 ────────────────────────────────────────────
// ── FedCM sign-in ────────────────────────────────────────────
// The browser obtained an assertion from openvibe.network for this origin (shared sso-client.js);
// 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
// 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) => {
@@ -453,11 +471,16 @@ router.post('/fedcm', express.json({ limit: '8kb' }), async (req, res) => {
let claims = null;
let claims = null;
try { claims = JSON.parse(Buffer.from(String(token).split('.')[1] || '', 'base64url').toString('utf8')); } catch { /* */ }
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' });
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);
const { localUser } = await establishNetworkSession(req, res, tokenData);
res.set('Cache-Control', 'no-store');
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 } });
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) => {
@@ -475,19 +498,20 @@ router.get('/callback', async (req, res) => {
res.clearCookie('oauth_next');
res.clearCookie('oauth_next');
if (!code && req.query.error) {
if (!code && req.query.error) {
// Silent sign-in found no network session — go on quietly as a guest.
// Silent sign-in found no network session — go on quietly as a guest.
- res.clearCookie('oauth_state');
+ clearFlowCookies(res);
const sep = next.includes('?') ? '&' : '?';
const sep = next.includes('?') ? '&' : '?';
return res.redirect(`${next}${sep}sso=none`);
return res.redirect(`${next}${sep}sso=none`);
}
}
if (!code) return res.status(400).send('Missing authorization code');
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
// 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).
// 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;
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>'
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">'
+ '<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>'
+ '<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) => {
@@ -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>');
+ '<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('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);
const { localUser, openvibeToolsToken, openvibeRefreshToken } = await establishNetworkSession(req, res, tokenData);
@@ -537,6 +573,17 @@ router.get('/callback', async (req, res) => {
@@ -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) ──
// ── Token Refresh (server-to-server using httpOnly refresh cookie) ──
router.post('/refresh', async (req, res) => {
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies?.ov_refresh;
const refreshToken = req.cookies?.ov_refresh;
@@ -547,38 +594,12 @@ router.post('/refresh', async (req, res) => {
@@ -547,38 +594,12 @@ router.post('/refresh', async (req, res) => {
return res.json({ access_token: null, user: null });
return res.json({ access_token: null, user: null });
}
}
try {
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
// Refresh failed — clear stale cookies
res.clearCookie('ov_refresh', { path: '/api/auth' });
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');
const isSecure = (process.env.BASE_URL || '').startsWith('https');
@@ -588,6 +609,11 @@ router.post('/refresh', async (req, res) => {
@@ -588,6 +609,11 @@ router.post('/refresh', async (req, res) => {
}
}
res.json({ access_token: tokenData.access_token, expires_in: tokenData.expires_in || 86400 });
res.json({ access_token: tokenData.access_token, expires_in: tokenData.expires_in || 86400 });
} catch (err) {
} 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);
console.error('[Auth] Refresh error:', err.message);
res.status(500).json({ error: 'Token refresh failed' });
res.status(500).json({ error: 'Token refresh failed' });
}
}
@@ -873,3 +899,7 @@ router.get('/ice-servers', optionalAuth, (req, res) => {
@@ -873,3 +899,7 @@ router.get('/ice-servers', optionalAuth, (req, res) => {
});
});
module.exports = router;
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;
@@ -553,7 +553,7 @@ async function isBanExemptAdmin(req) { const u = await banRequestUser(req); retu
@@ -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')); }
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). */
/** 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) {
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) {
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 { /* */ }
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'), {
@@ -798,6 +798,9 @@ app.use('/data/offline', express.static(paths.data('offline'), {
}));
}));
// ── API Routes ───────────────────────────────────────────────
// ── 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/auth', authRoutes);
app.use('/api/bench-stream', require('./bench-stream/routes'));
app.use('/api/bench-stream', require('./bench-stream/routes'));
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
// Channel ↔ OpenVibe.Bot robot binding (LIVE_BOT_EMBED, off by default: the route answers 404).
new file mode 100644
new file mode 100644
@@ -0,0 +1,248 @@
@@ -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);
+});