openvibers/openvibe.wiki Commits

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

OP OpenVibers committed 7 days ago · 5c16f1c256 · 2 files, +65 −44

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
resolver and every permission check are untouched, and the redirect URI is
unchanged.

Changes

ddiff --git a/server/auth/session.js b/server/auth/session.js
iindex 0589210..f32ad67 100644
---- a/server/auth/session.js
++++ b/server/auth/session.js
@@@ -1,28 +1,35 @@
'use strict';
/**
* openvibe.wiki as an OAuth2 client of OpenVibe.Network (client `wiki`, redirect
-- * <BASE_URL>/auth/callback) — the same session layer as OpenVibe.Community's server/auth/routes.js.
++ * <BASE_URL>/auth/callback). The OAuth half is openvibe-sdk/sso's (createSsoClient): the authorize URL, the PKCE
++ * S256 pair, the code, refresh and FedCM grants and GET /auth/health. The state is compared with the flow cookie in
++ * constant time.
*
* GET /auth/login → Network /oauth/authorize (?silent=1 adds prompt=none; ?next= same-site path)
-- * GET /auth/callback → server-side code exchange, sets the cookies
++ * GET /auth/callback → server-side code exchange with the PKCE verifier, sets the cookies
* POST /auth/fedcm → the shared navbar's FedCM assertion (nonce checked here, signature by the Network)
++ * GET /auth/health → does the Network accept this OAuth client? 503 only when it refuses it
* GET /auth/logout → clears the cookies (best-effort refresh revoke)
* GET /auth/me → the signed-in profile (offline JWT verification); a guest gets 200 { user: null }
* POST /auth/refresh → rotates tokens with the refresh token
*
-- * Cookies (host-only): ov_token (access JWT, JS-readable for the navbar), ov_refresh (httpOnly,
-- * Path=/auth), ov_sso_hint ('account' | 'guest', tells the navbar whether a silent login is worth it).
++ * Cookies (host-only, the names this site has always used): ov_token (access JWT, JS-readable for the navbar),
++ * ov_refresh (httpOnly, Path=/auth), ov_sso_hint ('account' | 'guest', tells the navbar whether a silent login is
++ * worth it). The redirect URI is unchanged.
*/
const crypto = require('crypto');
const express = require('express');
const cache = require('openvibe-shared/cache-policy');
++const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
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.Wiki';
function sanitizeNext(next, config) {
// Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
@@@ -46,16 +53,11 @@ function withParam(target, key, value) {
return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
}
--function authorizeUrl(config, { silent = false } = {}) {
-- const state = crypto.randomBytes(16).toString('hex');
-- const u = new URL(`${config.networkUrl}/oauth/authorize`);
-- u.searchParams.set('client_id', config.oauth.clientId);
-- u.searchParams.set('redirect_uri', config.oauth.redirectUri);
-- u.searchParams.set('response_type', 'code');
-- u.searchParams.set('scope', config.oauth.scope);
-- u.searchParams.set('state', state);
-- if (silent) u.searchParams.set('prompt', 'none');
-- return { url: u.toString(), state };
++/** 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);
}
function fedcmNonceMatches(token, nonce) {
@@@ -72,6 +74,20 @@ function fedcmNonceMatches(token, nonce) {
function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, log = console }) {
const router = express.Router();
const secure = config.cookies.secure;
++ const sdkSso = createSsoClient({
++ site: 'wiki',
++ 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: secure,
++ fetch: fetchImpl,
++ log,
++ });
const access = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
const refresh = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
const flow = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
@@@ -86,28 +102,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
res.clearCookie(ACCESS_COOKIE, { ...access(), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...refresh(), 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.networkInternalUrl, config.networkUrl]) {
-- 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(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, error: data.error || 'invalid_grant' });
-- 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';
@@@ -116,11 +111,12 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
const actor = await viewers.resolve(req, { services: false }).catch(() => null);
if (actor && actor.kind === 'user') { clearFlow(res); return res.redirect(next); }
}
-- const { url, state } = authorizeUrl(config, { silent });
-- res.cookie(STATE_COOKIE, state, flow());
++ const started = sdkSso.authorizeUrl({ silent });
++ res.cookie(STATE_COOKIE, started.state, flow());
++ res.cookie(VERIFIER_COOKIE, started.verifier, flow());
if (next !== '/') res.cookie(NEXT_COOKIE, next, flow()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
if (silent) res.cookie(SILENT_COOKIE, '1', flow()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
-- res.redirect(url);
++ res.redirect(started.url);
});
router.get('/callback', async (req, res) => {
@@@ -134,25 +130,43 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
}
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('OAuth 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) {
++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
++ log.error(`[Wiki] 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.Wiki 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(`[Wiki] 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 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.post('/fedcm', express.json({ limit: '16kb' }), async (req, res) => {
const { token, nonce } = req.body || {};
if (typeof token !== 'string' || !token || !fedcmNonceMatches(token, nonce)) return res.status(400).json({ error: 'invalid_request', error_description: 'token and a matching nonce are required' });
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 });
setSession(res, data.access_token, data.refresh_token);
return res.json({ ok: true, user: data.user || null });
@@@ -193,7 +207,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
const token = req.cookies && req.cookies[REFRESH_COOKIE];
if (!token) return res.status(401).json({ error: 'No refresh token' });
try {
-- const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: token });
++ const data = await sdkSso.tokenGrant({ grant_type: 'refresh_token', refresh_token: token });
setSession(res, data.access_token, data.refresh_token);
return res.json({ token: data.access_token });
} catch (err) {
@@@ -205,4 +219,4 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
return router;
}
--module.exports = { createSessionRoutes, sanitizeNext, authorizeUrl, fedcmNonceMatches };
++module.exports = { createSessionRoutes, sanitizeNext, fedcmNonceMatches };
ddiff --git a/test/auth.test.js b/test/auth.test.js
iindex 54ffb85..4334fd5 100644
---- a/test/auth.test.js
++++ b/test/auth.test.js
@@@ -32,8 +32,11 @@ const { sanitizeNext } = require('../server/auth/session');
assert.strictEqual(loc.searchParams.get('client_id'), 'wiki');
assert.strictEqual(loc.searchParams.get('redirect_uri'), 'http://wiki.test/auth/callback');
const state = loc.searchParams.get('state');
++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/, 'PKCE S256 from openvibe-sdk/sso');
const cookies = r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
assert.ok(cookies.includes(`ov_oauth_state=${state}`));
++ assert.match(cookies, /ov_oauth_verifier=[^;]+/, 'the verifier stays in its own flow cookie');
// A callback without the matching state cookie is refused.
r = await H.req(h, 'GET', `/auth/callback?code=abc&state=${state}`);
@@@ -69,6 +72,10 @@ const { sanitizeNext } = require('../server/auth/session');
// Silent login goes out with prompt=none.
r = await H.req(h, 'GET', '/auth/login?silent=1');
assert.strictEqual(new URL(r.headers.get('location')).searchParams.get('prompt'), 'none');
++ // /auth/health: the token endpoint answers, so the client is not refused.
++ r = await H.req(h, 'GET', '/auth/health');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.json, { ok: true, client: 'ok' });
// Health and readiness.
assert.strictEqual((await H.req(h, 'GET', '/api/health')).json.service, 'openvibe-wiki');
const ready = await H.req(h, 'GET', '/api/ready');