openvibers/openrestream Commits

Sign-in: the OAuth half moves onto openvibe-sdk/sso (plan L4) (#207)

OP OpenVibers committed 7 days ago · e3edfbb5c0 · 2 files, +107 −39

The authorize URL, the PKCE S256 pair (new here: the shared auth-client had
none), the code and refresh grants and the new GET /auth/health come from
openvibe-sdk/sso's createSsoClient. Cookies are unchanged (ov_token,
ov_refresh, ov_sso_hint, ov_oauth_state, ov_oauth_next, plus the verifier in
ov_oauth_verifier), the state is compared in constant time, next is still
sanitized by this repo's sanitizer, and the token is still verified with
openvibe-sdk/auth. The redirect URI is unchanged.

Changes

ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex 94aa7d7..19fa344 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -1,24 +1,34 @@
'use strict';
/**
* Browser sign-in for the standalone UI: OAuth2 authorization-code client of OpenVibe.Network
-- * (client `openre`, redirect https://openre.stream/auth/callback). The same shape as
-- * OpenVibe.Community's session layer, without FedCM:
++ * (client `openre`, redirect https://openre.stream/auth/callback). The OAuth half is openvibe-sdk/sso's
++ * (createSsoClient): the authorize URL, the PKCE S256 pair, the code and refresh grants and GET /auth/health.
*
-- * GET /auth/login → Network /oauth/authorize (state cookie; ?next= same-site path)
-- * GET /auth/callback → code exchange server-side, sets ov_token (+ ov_refresh)
++ * GET /auth/login → Network /oauth/authorize (state and verifier cookies; ?next= same-site path)
++ * GET /auth/callback → state checked in constant time, code exchange server-side, sets ov_token (+ ov_refresh)
++ * GET /auth/health → does the Network accept this OAuth client? 503 only when it refuses it
* GET /auth/logout → clears cookies
* GET /auth/me → the signed-in user (offline JWT verification)
* POST /auth/refresh → refresh_token grant (the shared navbar calls it)
*/
const crypto = require('crypto');
const express = require('express');
--const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
++const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const ACCESS = 'ov_token';
const REFRESH = 'ov_refresh';
const HINT = 'ov_sso_hint';
const STATE = 'ov_oauth_state';
++const VERIFIER = 'ov_oauth_verifier';
const NEXT = 'ov_oauth_next';
++const SITE_NAME = 'OpenRestream';
++
++/** Compare two strings in constant time (the state 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);
++}
/**
* A same-site path, or '/'. An absolute URL on this site's own origin (BASE_URL) counts as its path: the shared navbar's
@@@ -39,34 +49,22 @@ function sanitizeNext(next, config) {
function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
const router = express.Router();
-- const client = new OpenVibeAuthClient({
-- clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, redirectUri: config.oauth.redirectUri,
-- publicKey: null, authBase: config.networkUrl, internalBase: config.networkInternalUrl,
++ const sdkSso = createSsoClient({
++ site: 'openre',
++ siteName: SITE_NAME,
++ baseUrl: config.baseUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ redirectUri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ networkUrl: config.networkUrl,
++ networkInternalUrl: config.networkInternalUrl,
++ secureCookies: config.cookies.secure,
++ fetch: fetchImpl,
});
const base = { sameSite: 'lax', secure: config.cookies.secure };
const flow = { ...base, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 };
-- async function tokenGrant(body) {
-- let lastErr = null;
-- for (const b of [config.networkInternalUrl, config.networkUrl]) {
-- try {
-- const res = await fetchImpl(`${b}/oauth/token`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
-- signal: AbortSignal.timeout(10000),
-- });
-- const data = await res.json().catch(() => ({}));
-- if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status });
-- return data;
-- } catch (err) {
-- lastErr = err;
-- if (err.status && err.status < 500) throw err;
-- }
-- }
-- throw lastErr || new Error('Network unreachable');
-- }
--
function setSession(res, access, refresh) {
res.cookie(ACCESS, access, { ...base, httpOnly: false, path: '/', maxAge: 24 * 60 * 60 * 1000 });
if (refresh) res.cookie(REFRESH, refresh, { ...base, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });
@@@ -74,34 +72,57 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
}
router.get('/login', (req, res) => {
-- const { url, state } = client.getAuthorizationUrl(config.oauth.scope);
-- res.cookie(STATE, state, flow);
++ const started = sdkSso.authorizeUrl();
++ res.cookie(STATE, started.state, flow);
++ res.cookie(VERIFIER, started.verifier, flow);
res.cookie(NEXT, sanitizeNext(req.query.next, config), flow);
-- res.redirect(url);
++ res.redirect(started.url);
});
router.get('/callback', async (req, res) => {
const { code, state, error } = req.query;
const next = sanitizeNext(req.cookies && req.cookies[NEXT], config);
const expected = req.cookies && req.cookies[STATE];
++ const verifier = req.cookies && req.cookies[VERIFIER];
res.clearCookie(STATE, { path: '/auth' });
++ res.clearCookie(VERIFIER, { path: '/auth' });
res.clearCookie(NEXT, { path: '/auth' });
if (error) return res.redirect(`/?auth_error=${encodeURIComponent(String(error))}`);
-- if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
-- if (!expected || !state || !crypto.timingSafeEqual(
-- Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)),
-- )) return res.status(400).type('text/plain').send('OAuth state mismatch, please sign in again.');
++ const retryHref = `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`;
++ const failed = (status, title, text) => res.status(status).type('html').send(failurePage({
++ siteName: SITE_NAME, title, text, retryHref, backHref: next || '/',
++ }));
++ if (!code) return failed(400, 'Sign-in didn\'t finish', 'OpenVibe.Network sent no sign-in code. Start it again; your account is fine.');
++ if (!verifier || !sameString(state, expected)) {
++ return failed(400, 'Sign-in didn\'t finish', 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.');
++ }
try {
-- const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
++ const data = await sdkSso.tokenGrant({
++ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code: String(code), code_verifier: verifier,
++ });
setSession(res, data.access_token, data.refresh_token);
// No Clear-Site-Data here or on sign-out: Chrome holds such an answer (and the cookies it sets) until the
// cache is cleared, which on phones left people on the account chooser with no session (2026-10-28).
return res.redirect(next);
} catch (err) {
-- return res.status(502).type('text/plain').send(`Sign-in failed: ${err.message}`);
++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
++ console.error(`[OpenRestream] the Network refuses this site's OAuth client (${err.error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);
++ return failed(500, 'Sign-in isn\'t working on OpenRestream right now', 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.');
++ }
++ if (err.status && err.status < 500) {
++ return failed(400, 'Sign-in didn\'t finish', 'OpenVibe.Network did not accept that sign-in code. Start it again; your account is fine.');
++ }
++ return failed(502, 'OpenVibe.Network didn\'t answer', 'Your account is fine: the sign-in service did not reply in time. Try again in a moment.');
}
});
++ // GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it.
++ router.get('/health', async (_req, res) => {
++ const client = await sdkSso.checkClient();
++ res.set('Cache-Control', 'no-store');
++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
++ });
++
router.get('/logout', (req, res) => {
res.clearCookie(ACCESS, { path: '/' });
res.clearCookie(REFRESH, { path: '/auth' });
@@@ -121,7 +142,7 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
const refresh = req.cookies && req.cookies[REFRESH];
if (!refresh) return res.status(401).json({ error: 'No refresh token' });
try {
-- const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
++ const data = await sdkSso.tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
setSession(res, data.access_token, data.refresh_token);
return res.json({ token: data.access_token });
} catch (err) {
ddiff --git a/test/auth-health.test.js b/test/auth-health.test.js
nnew file mode 100644
iindex 0000000..8001067
---- /dev/null
++++ b/test/auth-health.test.js
@@@ -0,0 +1,47 @@
++'use strict';
++// GET /auth/health (plan L4): 200 when the Network accepts this site's OAuth client, 503 only when it refuses it.
++const assert = require('assert');
++const express = require('express');
++const { createSsoRoutes } = require('../server/auth/sso');
++
++const config = {
++ baseUrl: 'https://openre.stream',
++ networkUrl: 'https://network.test',
++ networkInternalUrl: 'https://network.test',
++ oauth: { clientId: 'openre', clientSecret: 's', redirectUri: 'https://openre.stream/auth/callback', scope: 'profile' },
++ cookies: { secure: true },
++};
++
++async function probe(reply) {
++ const calls = [];
++ const app = express();
++ app.use('/auth', createSsoRoutes({
++ config,
++ auth: { verifyUser: async () => null },
++ fetchImpl: async (url, init) => { calls.push({ url: String(url), body: init.body }); return reply(); },
++ }));
++ const server = app.listen(0);
++ await new Promise((resolve) => server.once('listening', resolve));
++ try {
++ const res = await fetch(`http://127.0.0.1:${server.address().port}/auth/health`);
++ return { status: res.status, body: await res.json(), calls };
++ } finally {
++ server.close();
++ }
++}
++
++(async () => {
++ // The health check presents a code no sign-in ever had: invalid_grant means the client id and secret are right.
++ let r = await probe(() => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400, headers: { 'Content-Type': 'application/json' } }));
++ assert.deepStrictEqual([r.status, r.body], [200, { ok: true, client: 'ok' }]);
++ assert.strictEqual(JSON.parse(r.calls[0].body).grant_type, 'authorization_code');
++
++ // invalid_client: the Network refuses this client.
++ r = await probe(() => new Response(JSON.stringify({ error: 'invalid_client' }), { status: 401, headers: { 'Content-Type': 'application/json' } }));
++ assert.deepStrictEqual([r.status, r.body], [503, { ok: false, client: 'invalid' }]);
++
++ // A Network that cannot be reached is not a refusal.
++ r = await probe(() => { throw new Error('down'); });
++ assert.deepStrictEqual([r.status, r.body], [200, { ok: true, client: 'unknown' }]);
++ console.log('auth health: 200 unless the Network refuses the client');
++})().catch((err) => { console.error(err); process.exit(1); });