openvibers/openvibe.vip Commits

Sign-in: the OAuth half is openvibe-sdk/sso (#187)

OP OpenVibers committed 7 days ago · 0c1dca73fa · 5 files, +188 −56

The authorize URL, the PKCE S256 pair (new here: this copied layer had none),
the code, refresh and FedCM grants and the new GET /auth/health come from
openvibe-sdk/sso's createSsoClient. The cookies are the ones this site has
always used (ov_token, ov_refresh, ov_sso_hint, and the flow cookies on /auth
plus ov_oauth_verifier), the state is compared with the flow cookie in
constant time, next is still sanitized by this repo's sanitizer, the viewer middleware
and every permission check are untouched, and the redirect URI is unchanged.

The old OpenVibeAuthClient (openvibe-shared/auth-client) is gone from the
session layer: server/network.js keeps only the offline verification of the
Network user token.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex a5d6f40..7d1efce 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -138,7 +138,7 @@ async function createApp(opts = {}) {
// opts.limitsNow: the limiter's clock (tests).
const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log, valkey });
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));
-- app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
++ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
ddiff --git a/server/network.js b/server/network.js
iindex aeb1866..8e584df 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -11,7 +11,6 @@
*/
const contracts = require('openvibe-contracts');
const { createNetworkKeys, verifyUserToken, verifyServiceToken } = require('openvibe-sdk/auth');
--const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
/** keys.verifyOptions (spread into the verifiers), keys.loaded(), keys.start(), keys.stop(), keys.refresh(). */
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
@@@ -24,18 +23,10 @@ async function verifyService(config, keys, token) {
}
/**
-- * The OAuth client of the browser session layer plus offline verification of the Network user
-- * JWT (RS256, issuer = the Network's public URL).
++ * Offline verification of the Network user JWT (RS256, issuer = the Network's public URL). The OAuth half
++ * is openvibe-sdk/sso's (server/web/session.js).
*/
function createUserAuth(config, keys) {
-- const client = new OpenVibeAuthClient({
-- clientId: config.oauth.clientId,
-- clientSecret: config.oauth.clientSecret,
-- redirectUri: config.oauth.redirectUri,
-- publicKey: null,
-- authBase: config.network.url,
-- internalBase: config.network.internalUrl,
-- });
/**
* Claims of a valid user token, or null. openvibe-sdk/auth refuses service, app and mod principals and typed
* tokens (a realtime ticket, an export token): none of them is a session.
@@@ -44,7 +35,7 @@ function createUserAuth(config, keys) {
if (!token) return null;
try { return await verifyUserToken(token, { ...keys.verifyOptions, issuer: config.network.issuer }); } catch { return null; }
}
-- return { client, verify };
++ return { verify };
}
module.exports = { createKeyProvider, createUserAuth, verifyService };
ddiff --git a/server/web/session.js b/server/web/session.js
iindex 28765b9..3ce6448 100644
---- a/server/web/session.js
++++ b/server/web/session.js
@@@ -5,27 +5,38 @@
* shape as OpenVibe.Community's server/auth/routes.js. Registered in the Network as client `vip`
* with redirect https://openvibe.vip/auth/callback.
*
++ * The OAuth half is openvibe-sdk/sso's (createSsoClient): the authorize URL, the PKCE S256 pair (new
++ * here: this copied layer had none), the code, refresh and FedCM grants and the new GET /auth/health.
++ * The state is compared with the flow cookie in constant time, next is still sanitized by this repo's
++ * sanitizer, the viewer resolver and every permission check are untouched, and the redirect URI is
++ * unchanged.
++ *
* GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)
-- * GET /auth/callback → server-side code exchange, session cookies
++ * GET /auth/callback → server-side code exchange with the PKCE verifier, session cookies
* POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
++ * GET /auth/health → does the Network accept this OAuth client? 503 only when it refuses it
* GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest
* GET /auth/me → the signed-in user (offline JWT verification)
* POST /auth/refresh → rotate tokens
*
-- * Cookies (host-only): ov_token (access JWT, Lax, JS-readable for the navbar), ov_refresh (httpOnly,
-- * Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;
-- * the API never reads them.
++ * Cookies (host-only, the names this site has always used): ov_token (access JWT, Lax, JS-readable for the navbar),
++ * ov_refresh (httpOnly, Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;
++ * the API never reads them. The short-lived flow cookies on /auth are
++ * ov_oauth_state, ov_oauth_next, ov_oauth_silent plus the PKCE verifier in ov_oauth_verifier.
*/
const express = require('express');
const crypto = require('crypto');
++const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const { userPrincipal } = require('../api/auth');
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
const HINT_COOKIE = 'ov_sso_hint';
const STATE_COOKIE = 'ov_oauth_state';
++const VERIFIER_COOKIE = 'ov_oauth_verifier';
const NEXT_COOKIE = 'ov_oauth_next';
const SILENT_COOKIE = 'ov_oauth_silent';
++const SITE_NAME = 'OpenVibe.VIP';
function claimsToUser(claims) {
if (!claims) return null;
@@@ -61,6 +72,13 @@ function withParam(target, key, value) {
return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
}
++/** 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);
++}
++
/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */
function viewerMiddleware(userAuth) {
return async (req, _res, next) => {
@@@ -72,9 +90,25 @@ function viewerMiddleware(userAuth) {
};
}
--function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch } = {}) {
++function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch, log = console } = {}) {
const router = express.Router();
const secure = config.cookies.secure;
++ // The OAuth half is the SDK's: the authorize URL, the PKCE S256 pair, the token grants and the health check.
++ const sdkSso = createSsoClient({
++ site: 'vip',
++ siteName: SITE_NAME,
++ baseUrl: config.baseUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ redirectUri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ networkUrl: config.network.url,
++ networkInternalUrl: config.network.internalUrl,
++ issuer: config.network.issuer,
++ secureCookies: secure,
++ fetch: fetchImpl,
++ log,
++ });
const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
@@@ -89,44 +123,18 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
}
-- function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
--
-- async function tokenGrant(body) {
-- let lastErr = null;
-- for (const base of [config.network.internalUrl, config.network.url]) {
-- if (!base) continue;
-- try {
-- const res = await fetchImpl(`${base}/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(10_000),
-- });
-- const data = await res.json().catch(() => ({}));
-- if (!res.ok) {
-- const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
-- err.status = res.status; err.error = data.error || 'invalid_grant';
-- throw err;
-- }
-- return data;
-- } catch (err) {
-- lastErr = err;
-- if (err.status && err.status < 500) throw err;
-- }
-- }
-- throw lastErr || new Error('Network unreachable');
-- }
++ function clearFlow(res) { for (const c of [STATE_COOKIE, VERIFIER_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
router.get('/login', async (req, res) => {
const silent = !!req.query.silent && req.query.silent !== '0';
if (silent && await userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
-- const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);
-- let target = url;
-- if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }
-- res.cookie(STATE_COOKIE, state, flowOpts());
++ const started = sdkSso.authorizeUrl({ silent });
++ res.cookie(STATE_COOKIE, started.state, flowOpts());
++ res.cookie(VERIFIER_COOKIE, started.verifier, flowOpts());
const next = sanitizeNext(req.query.next, config);
if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
-- res.redirect(target);
++ res.redirect(started.url);
});
router.get('/callback', async (req, res) => {
@@@ -140,20 +148,38 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
}
if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
const expected = req.cookies && req.cookies[STATE_COOKIE];
++ const verifier = req.cookies && req.cookies[VERIFIER_COOKIE];
clearFlow(res);
-- if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
++ if (!verifier || !sameString(state, expected)) {
return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');
}
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);
return res.redirect(next);
} catch (err) {
-- console.error('[VIP] code exchange failed:', err.message);
++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
++ log.error(`[VIP] 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: SITE_NAME,
++ title: 'Sign-in isn\'t working on OpenVibe.Bot 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: `/auth/login?next=${encodeURIComponent(next)}`,
++ backHref: next || '/',
++ }));
++ }
++ log.error(`[VIP] code exchange failed: ${err.message}`);
return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
}
});
++ // GET /auth/health: does the Network accept this 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 });
++ });
++
const fedcmBody = express.json({ limit: '16kb' });
router.post('/fedcm', fedcmBody, async (req, res) => {
const { token, nonce } = req.body || {};
@@@ -164,7 +190,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
try {
-- const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
++ const data = await sdkSso.tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
setSession(res, data.access_token, data.refresh_token);
return res.json({ ok: true, user: data.user || claimsToUser(await userAuth.verify(data.access_token)) });
@@@ -201,7 +227,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
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, user: claimsToUser(await userAuth.verify(data.access_token)) });
} catch (err) {
ddiff --git a/test/auth.test.js b/test/auth.test.js
nnew file mode 100644
iindex 0000000..13a4b3a
---- /dev/null
++++ b/test/auth.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * The sign-in (openvibe-sdk/sso): the authorize URL carries PKCE S256, the flow cookies are the ones this site
++ * has always used (ov_oauth_state, ov_oauth_next, ov_oauth_silent and the PKCE verifier in ov_oauth_verifier),
++ * the state is compared with the flow cookie in constant time, next is sanitized at the login and at the
++ * callback, and GET /auth/health is 503 only when the Network refuses this site's OAuth client.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, harness } = require('./helpers/app');
++
++const { test, run } = harness('auth');
++
++(async () => {
++ const t = await boot();
++ const get = (p, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers });
++ const cookies = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]);
++ const cookieHeader = (res) => cookies(res).join('; ');
++ const flow = (res, name) => res.headers.getSetCookie().find((c) => c.startsWith(name + '=')) || '';
++
++ test('the sign-in starts at the Network with PKCE S256 and the flow cookies this site has always used', async () => {
++ const res = await get('/auth/login?next=%2Fupdates');
++ assert.strictEqual(res.status, 302);
++ const u = new URL(res.headers.get('location'));
++ assert.deepStrictEqual([u.pathname, u.searchParams.get('client_id'), u.searchParams.get('redirect_uri'), u.searchParams.get('response_type')],
++ ['/oauth/authorize', 'vip', 'http://vip.test/auth/callback', 'code']);
++ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256', 'PKCE S256 from openvibe-sdk/sso');
++ const challenge = u.searchParams.get('code_challenge');
++ assert.match(challenge, /^[A-Za-z0-9_-]{43}$/, 'the code challenge');
++ const state = u.searchParams.get('state');
++ assert.ok(state && state.length >= 16, 'the state');
++ assert.match(flow(res, 'ov_oauth_state'), new RegExp('^ov_oauth_state=' + state));
++ assert.match(flow(res, 'ov_oauth_verifier'), /Path=\/auth/i, 'the verifier stays in a flow cookie of its own');
++ // express encodes the cookie's value: the path arrives percent-encoded.
++ const nextCookie = flow(res, 'ov_oauth_next').split(';')[0].split('=')[1];
++ assert.strictEqual(decodeURIComponent(nextCookie), '/updates');
++ // The challenge is the SHA-256 of the verifier in the cookie: the Network checks exactly that.
++ const verifier = flow(res, 'ov_oauth_verifier').split(';')[0].split('=')[1];
++ assert.strictEqual(crypto.createHash('sha256').update(verifier).digest('base64url'), challenge);
++ });
++
++ test('a callback whose state does not match the flow cookie is refused, and no session is set', async () => {
++ const started = await get('/auth/login');
++ const res = await get('/auth/callback?code=whatever&state=not-the-state', { Cookie: cookieHeader(started) });
++ assert.strictEqual(res.status, 400);
++ assert.ok(!res.headers.getSetCookie().some((c) => c.startsWith('ov_token=')), 'no session');
++ });
++
++ test('a callback without the verifier cookie is refused too (the code could not be exchanged)', async () => {
++ const started = await get('/auth/login');
++ const u = new URL(started.headers.get('location'));
++ const without = cookies(started).filter((c) => !c.startsWith('ov_oauth_verifier=')).join('; ');
++ const res = await get('/auth/callback?code=whatever&state=' + encodeURIComponent(u.searchParams.get('state')), { Cookie: without });
++ assert.strictEqual(res.status, 400);
++ assert.ok(!res.headers.getSetCookie().some((c) => c.startsWith('ov_token=')), 'no session');
++ });
++
++ test('a sign-in with a code the Network issued sets the session and returns to next', async () => {
++ const started = await get('/auth/login?next=%2Fupdates');
++ const u = new URL(started.headers.get('location'));
++ const code = t.network.issueCode();
++ const res = await get('/auth/callback?code=' + code + '&state=' + encodeURIComponent(u.searchParams.get('state')), { Cookie: cookieHeader(started) });
++ assert.strictEqual(res.status, 302);
++ assert.strictEqual(res.headers.get('location'), '/updates');
++ const set = res.headers.getSetCookie();
++ assert.ok(set.some((c) => c.startsWith('ov_token=')), 'the session cookie, with the name this site has always used');
++ assert.ok(set.some((c) => c.startsWith('ov_refresh=')), 'the refresh cookie');
++ });
++
++ test('next is sanitized: off-site never, neither at the login nor at the callback', async () => {
++ const started = await get('/auth/login?next=https%3A%2F%2Fevil.com%2Fx');
++ const offSite = flow(started, 'ov_oauth_next').split(';')[0].split('=')[1];
++ assert.ok(!offSite, 'an off-site next is not kept');
++ const again = await get('/auth/login');
++ const u = new URL(again.headers.get('location'));
++ const code = t.network.issueCode();
++ const res = await get('/auth/callback?code=' + code + '&state=' + encodeURIComponent(u.searchParams.get('state')),
++ { Cookie: cookieHeader(again) + '; ov_oauth_next=//evil.com/x' });
++ assert.strictEqual(res.status, 302);
++ assert.strictEqual(res.headers.get('location'), '/', 'a tampered next goes home');
++ });
++
++ test('GET /auth/health: 200 while the Network accepts this client, 503 when it refuses it', async () => {
++ const res = await get('/auth/health');
++ assert.strictEqual(res.status, 200);
++ const json = await res.json();
++ assert.deepStrictEqual([json.ok, json.client], [true, 'ok']);
++
++ const broken = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'not-the-secret' } });
++ try {
++ const refused = await fetch(broken.base + '/auth/health', { redirect: 'manual' });
++ assert.strictEqual(refused.status, 503);
++ const reported = await refused.json();
++ assert.deepStrictEqual([reported.ok, reported.client], [false, 'invalid']);
++ } finally { await broken.close(); }
++ });
++
++ await run().finally(async () => await t.close());
++})();
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex c5ec367..c39c442 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -31,6 +31,8 @@ async function startNetwork() {
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const grants = [];
++ // The sign-in codes Network issued (openvibe-sdk/sso exchanges one with the PKCE verifier): code → tokens.
++ const codes = new Map();
let issuer = 'http://network.test';
let n = 0;
@@@ -51,9 +53,17 @@ async function startNetwork() {
const raw = await readBody(req);
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/oauth/token' && req.method === 'POST') {
-- const body = Object.fromEntries(new URLSearchParams(raw));
++ // The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (the service clients).
++ const body = raw.trim().startsWith('{') ? JSON.parse(raw) : Object.fromEntries(new URLSearchParams(raw));
grants.push(body);
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
++ // A sign-in's code: the Network answers invalid_grant for a code it never issued.
++ if (body.grant_type === 'authorization_code') {
++ const c = codes.get(String(body.code || ''));
++ if (!c) return send(res, 400, { error: 'invalid_grant', error_description: 'unknown or expired code' });
++ codes.delete(String(body.code));
++ return send(res, 200, { access_token: c.access_token, refresh_token: c.refresh_token || null, token_type: 'Bearer', expires_in: 300 });
++ }
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
}
@@@ -61,7 +71,13 @@ async function startNetwork() {
});
const url = await listen(server);
issuer = url;
-- return { url, publicPem, signService, signUser, newUser, grants, close: () => new Promise((r) => server.close(r)) };
++ /** A sign-in code the stand-in Network accepts: the code the browser comes back with. */
++ function issueCode({ access_token = null, refresh_token = 'refresh_test' } = {}) {
++ const code = `code_${crypto.randomBytes(8).toString('hex')}`;
++ codes.set(code, { access_token: access_token || signUser({ subject: 'usr_test', username: 'someone' }), refresh_token });
++ return code;
++ }
++ return { url, publicPem, signService, signUser, newUser, grants, issueCode, close: () => new Promise((r) => server.close(r)) };
}
/**