openvibers/openvibe.wiki Commits
Sign-in: the OAuth half moves onto openvibe-sdk/sso (plan L4) (#216)
Changes
@@@ -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, lores.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, loconst 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, loconst 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, loreturn router;}--module.exports = { createSessionRoutes, sanitizeNext, authorizeUrl, fedcmNonceMatches };++module.exports = { createSessionRoutes, sanitizeNext, fedcmNonceMatches };@@@ -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');
@@ -1,28 +1,35 @@
@@ -1,28 +1,35 @@
'use strict';
'use strict';
/**
/**
* openvibe.wiki as an OAuth2 client of OpenVibe.Network (client `wiki`, redirect
* 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/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)
* 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/logout → clears the cookies (best-effort refresh revoke)
* GET /auth/me → the signed-in profile (offline JWT verification); a guest gets 200 { user: null }
* 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
* 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 crypto = require('crypto');
const express = require('express');
const express = require('express');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
const REFRESH_COOKIE = 'ov_refresh';
const HINT_COOKIE = 'ov_sso_hint';
const HINT_COOKIE = 'ov_sso_hint';
const STATE_COOKIE = 'ov_oauth_state';
const STATE_COOKIE = 'ov_oauth_state';
+const VERIFIER_COOKIE = 'ov_oauth_verifier';
const NEXT_COOKIE = 'ov_oauth_next';
const NEXT_COOKIE = 'ov_oauth_next';
const SILENT_COOKIE = 'ov_oauth_silent';
const SILENT_COOKIE = 'ov_oauth_silent';
+const SITE_NAME = 'OpenVibe.Wiki';
function sanitizeNext(next, config) {
function sanitizeNext(next, config) {
// Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
// 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) {
@@ -46,16 +53,11 @@ function withParam(target, key, value) {
return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
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) {
function fedcmNonceMatches(token, nonce) {
@@ -72,6 +74,20 @@ function fedcmNonceMatches(token, nonce) {
@@ -72,6 +74,20 @@ function fedcmNonceMatches(token, nonce) {
function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, log = console }) {
function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, log = console }) {
const router = express.Router();
const router = express.Router();
const secure = config.cookies.secure;
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 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 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 });
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
@@ -86,28 +102,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
res.clearCookie(ACCESS_COOKIE, { ...access(), maxAge: undefined });
res.clearCookie(ACCESS_COOKIE, { ...access(), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...refresh(), 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) => {
router.get('/login', async (req, res) => {
const silent = !!req.query.silent && req.query.silent !== '0';
const silent = !!req.query.silent && req.query.silent !== '0';
@@ -116,11 +111,12 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
@@ -116,11 +111,12 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
const actor = await viewers.resolve(req, { services: false }).catch(() => null);
const actor = await viewers.resolve(req, { services: false }).catch(() => null);
if (actor && actor.kind === 'user') { clearFlow(res); return res.redirect(next); }
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 (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' });
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) => {
router.get('/callback', async (req, res) => {
@@ -134,25 +130,43 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
@@ -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');
if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
const expected = req.cookies && req.cookies[STATE_COOKIE];
const expected = req.cookies && req.cookies[STATE_COOKIE];
+ const verifier = req.cookies && req.cookies[VERIFIER_COOKIE];
clearFlow(res);
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.');
return res.status(400).type('text/plain').send('OAuth state mismatch — please try signing in again.');
}
}
try {
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);
setSession(res, data.access_token, data.refresh_token);
return res.redirect(next);
return res.redirect(next);
} catch (err) {
} 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}`);
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.');
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) => {
router.post('/fedcm', express.json({ limit: '16kb' }), async (req, res) => {
const { token, nonce } = req.body || {};
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' });
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 {
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 });
if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401 });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
return res.json({ ok: true, user: data.user || null });
return res.json({ ok: true, user: data.user || null });
@@ -193,7 +207,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
@@ -193,7 +207,7 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
const token = req.cookies && req.cookies[REFRESH_COOKIE];
const token = req.cookies && req.cookies[REFRESH_COOKIE];
if (!token) return res.status(401).json({ error: 'No refresh token' });
if (!token) return res.status(401).json({ error: 'No refresh token' });
try {
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);
setSession(res, data.access_token, data.refresh_token);
return res.json({ token: data.access_token });
return res.json({ token: data.access_token });
} catch (err) {
} catch (err) {
@@ -205,4 +219,4 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
@@ -205,4 +219,4 @@ function createSessionRoutes({ config, viewers, fetchImpl = globalThis.fetch, lo
return router;
return router;
}
}
-module.exports = { createSessionRoutes, sanitizeNext, authorizeUrl, fedcmNonceMatches };
+module.exports = { createSessionRoutes, sanitizeNext, fedcmNonceMatches };
@@ -32,8 +32,11 @@ const { sanitizeNext } = require('../server/auth/session');
@@ -32,8 +32,11 @@ const { sanitizeNext } = require('../server/auth/session');
assert.strictEqual(loc.searchParams.get('client_id'), 'wiki');
assert.strictEqual(loc.searchParams.get('client_id'), 'wiki');
assert.strictEqual(loc.searchParams.get('redirect_uri'), 'http://wiki.test/auth/callback');
assert.strictEqual(loc.searchParams.get('redirect_uri'), 'http://wiki.test/auth/callback');
const state = loc.searchParams.get('state');
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('; ');
const cookies = r.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
assert.ok(cookies.includes(`ov_oauth_state=${state}`));
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.
// A callback without the matching state cookie is refused.
r = await H.req(h, 'GET', `/auth/callback?code=abc&state=${state}`);
r = await H.req(h, 'GET', `/auth/callback?code=abc&state=${state}`);
@@ -69,6 +72,10 @@ const { sanitizeNext } = require('../server/auth/session');
@@ -69,6 +72,10 @@ const { sanitizeNext } = require('../server/auth/session');
// Silent login goes out with prompt=none.
// Silent login goes out with prompt=none.
r = await H.req(h, 'GET', '/auth/login?silent=1');
r = await H.req(h, 'GET', '/auth/login?silent=1');
assert.strictEqual(new URL(r.headers.get('location')).searchParams.get('prompt'), 'none');
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.
// Health and readiness.
assert.strictEqual((await H.req(h, 'GET', '/api/health')).json.service, 'openvibe-wiki');
assert.strictEqual((await H.req(h, 'GET', '/api/health')).json.service, 'openvibe-wiki');
const ready = await H.req(h, 'GET', '/api/ready');
const ready = await H.req(h, 'GET', '/api/ready');