openvibers/openvibe.community Commits

POST /auth/fedcm swaps a FedCM assertion for a session (jwt-bearer grant, nonce pre-check); silent login skips the Network when ov_token already verifies

OP OpenVibers committed 18 Sept 2026 · f0d9c1a988 · 3 files, +159 −4


Changes

ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 0cf3442..a24e623 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -13,7 +13,13 @@
// error=login_required and lands on next + ?sso=none)
// ?next=… same-site path or an https://openvibe.network/…
// URL (sign-in-everywhere chains)
++// A silent login whose ov_token already verifies skips the
++// Network round trip and 302s straight to next.
// GET /auth/callback → server-side code exchange, set cookies
++// POST /auth/fedcm → browser-native FedCM: the navbar posts the Network's
++// assertion JWT + the nonce it minted; we check the nonce,
++// swap the assertion for tokens (jwt-bearer grant), set the
++// same cookies as the callback
// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest
// GET /auth/me → offline-verify ov_token, return profile
// POST /auth/refresh → rotate tokens via refresh_token grant
@@@ -103,6 +109,30 @@ function claimsToUser(claims) {
return user;
}
++/**
++ * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims
++ * we cross-check locally (the FedCM nonce) before the Network — which does verify
++ * the signature — sees the assertion. Never use it to trust an identity.
++ */
++function decodeJwtPayload(token) {
++ if (typeof token !== 'string') return null;
++ const parts = token.split('.');
++ if (parts.length !== 3) return null;
++ try {
++ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;
++ } catch { return null; }
++}
++
++/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */
++function fedcmNonceMatches(token, nonce) {
++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
++ const claims = decodeJwtPayload(token);
++ if (!claims || typeof claims.nonce !== 'string') return false;
++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
/**
* Only allow same-site relative paths, this site's own https origin, or an
* https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.
@@@ -211,6 +241,8 @@ function createAuthRoutes(config, auth) {
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';
++ err.error_description = data.error_description || null;
throw err;
}
return data;
@@@ -224,8 +256,17 @@ function createAuthRoutes(config, auth) {
}
// ── GET /auth/login ──────────────────────────────────────
-- router.get('/login', (req, res) => {
++ router.get('/login', async (req, res) => {
const silent = !!req.query.silent && req.query.silent !== '0';
++ if (silent) {
++ // Already signed in here? Then the silent round trip through the Network would
++ // only hand back the session we have: go straight to where the caller wanted.
++ const existing = req.cookies?.[ACCESS_COOKIE];
++ if (existing && await auth.verify(existing)) {
++ clearFlowCookies(res);
++ return res.redirect(sanitizeNext(req.query.next, config));
++ }
++ }
const { url, state } = buildAuthorizeUrl(config, auth, { silent });
res.cookie(STATE_COOKIE, state, flowCookieOpts());
const next = sanitizeNext(req.query.next, config);
@@@ -276,6 +317,40 @@ function createAuthRoutes(config, auth) {
}
});
++ // ── POST /auth/fedcm ─────────────────────────────────────
++ // The shared navbar obtained a FedCM assertion from openvibe.network (the
++ // browser's own account chooser) and posts it same-origin with the nonce it
++ // put in the FedCM request. We only pre-check the nonce; the Network verifies
++ // the assertion's signature when it swaps it for tokens.
++ const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });
++ const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());
++ router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {
++ if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });
++ const { token, nonce } = req.body || {};
++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {
++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
++ }
++ if (!fedcmNonceMatches(token, nonce)) {
++ 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,
++ });
++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
++ setSessionCookies(res, data.access_token, data.refresh_token);
++ const user = data.user || claimsToUser(await auth.verify(data.access_token));
++ return res.json({ ok: true, user });
++ } catch (err) {
++ if (err.status && err.status < 500) {
++ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });
++ }
++ console.error('[Auth] FedCM exchange failed:', err.message);
++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
// ── GET /auth/logout ─────────────────────────────────────
router.get('/logout', async (req, res) => {
// Best-effort refresh revocation. The Network's rotating refresh tokens
@@@ -344,4 +419,4 @@ function optionalAuth(auth) {
};
}
--module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser };
++module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
ddiff --git a/test/auth.test.js b/test/auth.test.js
iindex d213840..bfbc925 100644
---- a/test/auth.test.js
++++ b/test/auth.test.js
@@@ -1,5 +1,5 @@
'use strict';
--/** The OAuth client: login (incl. silent), callback, logout, me, refresh, and next sanitising. */
++/** The OAuth client: login (incl. silent + already-signed-in shortcut), callback, FedCM, logout, me, refresh, and next sanitising. */
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
@@@ -78,6 +78,27 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(bad.status, 401);
});
++ await check('silent login with a valid ov_token skips the Network and 302s to next; non-silent and bad tokens still go', async () => {
++ assert.ok(t.jar.has('ov_token'), 'precondition: signed in');
++ const short = await t.get('/auth/login?silent=1&next=/pastes%3Fsort%3Dviews');
++ assert.strictEqual(short.status, 302);
++ assert.strictEqual(short.headers.get('location'), '/pastes?sort=views');
++ const state = cookieNamed(short, 'ov_oauth_state');
++ assert.ok(!state || /Expires=Thu, 01 Jan 1970/i.test(state), 'no OAuth flow was started (state cookie only ever cleared)');
++ const evil = await t.get('/auth/login?silent=1&next=https://evil.example/');
++ assert.strictEqual(evil.headers.get('location'), '/');
++ const loud = await t.get('/auth/login?next=/my');
++ assert.strictEqual(new URL(loud.headers.get('location')).origin, t.network.url);
++ const stale = await t.get('/auth/login?silent=1&next=/my', { cookies: ['ov_token=garbage'] });
++ assert.strictEqual(stale.status, 302);
++ // The jar's good token is also sent, so exercise the shortcut miss with the jar emptied.
++ const saved = t.jar.get('ov_token'); t.jar.delete('ov_token');
++ const miss = await t.get('/auth/login?silent=1&next=/my', { cookies: ['ov_token=garbage'] });
++ assert.strictEqual(new URL(miss.headers.get('location')).origin, t.network.url);
++ assert.strictEqual(new URL(miss.headers.get('location')).searchParams.get('prompt'), 'none');
++ t.jar.set('ov_token', saved);
++ });
++
await check('callback rejects a missing or mismatched state', async () => {
const r = await t.get('/auth/callback?code=good-code&state=deadbeef');
assert.strictEqual(r.status, 400);
@@@ -107,6 +128,59 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(evil.headers.get('location'), '/');
});
++ await check('POST /auth/fedcm swaps a nonce-matching assertion for a session (jwt-bearer grant)', async () => {
++ assert.ok(!t.jar.has('ov_token'), 'precondition: signed out');
++ const post = (body, headers = {}) => t.get('/auth/fedcm', { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) });
++ const nonce = 'n-' + Math.random().toString(16).slice(2);
++ const assertion = t.network.sign({ id: 7, username: 'alex', nonce, aud: 'community' });
++ const r = await post({ token: assertion, nonce });
++ assert.strictEqual(r.status, 200, r.text);
++ const body = r.json();
++ assert.strictEqual(body.ok, true);
++ assert.strictEqual(body.user.username, 'alex');
++ const tok = cookieNamed(r, 'ov_token');
++ assert.ok(tok && !/HttpOnly/i.test(tok) && /SameSite=Lax/i.test(tok) && /Secure/.test(tok) && /Path=\//.test(tok));
++ assert.ok(/HttpOnly/i.test(cookieNamed(r, 'ov_refresh')) && /Path=\/auth/.test(cookieNamed(r, 'ov_refresh')));
++ const hint = cookieNamed(r, 'ov_sso_hint');
++ assert.ok(hint && hint.startsWith('ov_sso_hint=account') && !/HttpOnly/i.test(hint) && /Max-Age=31536000/.test(hint));
++ const grant = t.network.grants.pop();
++ assert.strictEqual(grant.grant_type, 'urn:ietf:params:oauth:grant-type:jwt-bearer');
++ assert.strictEqual(grant.assertion, assertion);
++ assert.strictEqual(grant.client_id, 'community');
++ assert.strictEqual(grant.client_secret, 'shh');
++ assert.strictEqual(grant.code, undefined);
++ const me = await t.get('/auth/me');
++ assert.strictEqual(me.json().user.username, 'alex');
++ });
++
++ await check('POST /auth/fedcm rejects a nonce mismatch, bad input and a Network-refused assertion without touching the session', async () => {
++ t.jar.delete('ov_token'); t.jar.delete('ov_refresh');
++ const post = (body, headers = {}) => t.get('/auth/fedcm', { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) });
++ const before = t.network.grants.length;
++ const assertion = t.network.sign({ id: 7, username: 'alex', nonce: 'expected' });
++ const mismatch = await post({ token: assertion, nonce: 'other' });
++ assert.strictEqual(mismatch.status, 400);
++ assert.strictEqual(mismatch.json().error, 'invalid_request');
++ const noNonce = await post({ token: t.network.sign({ id: 7, username: 'alex' }), nonce: 'x' });
++ assert.strictEqual(noNonce.status, 400);
++ assert.strictEqual((await post({ nonce: 'x' })).status, 400);
++ assert.strictEqual((await post({ token: 'not-a-jwt', nonce: 'x' })).status, 400);
++ assert.strictEqual((await post('{not json')).status, 400);
++ const form = await t.get('/auth/fedcm', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'token=x&nonce=y' });
++ assert.strictEqual(form.status, 400);
++ assert.strictEqual(t.network.grants.length, before, 'nothing reached the Network');
++ // Nonce matches but the assertion is not one the Network issued → it refuses → 401.
++ const jwt = require('jsonwebtoken');
++ const { privateKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const forged = jwt.sign({ sub: 1, username: 'mallory', nonce: 'n1' }, privateKey.export({ type: 'pkcs8', format: 'pem' }), { algorithm: 'RS256', expiresIn: '1h' });
++ const refused = await post({ token: forged, nonce: 'n1' });
++ assert.strictEqual(refused.status, 401);
++ assert.strictEqual(refused.json().error, 'invalid_grant');
++ assert.ok(/assertion rejected/.test(refused.json().error_description));
++ assert.strictEqual(t.network.grants.length, before + 1);
++ assert.ok(!t.jar.has('ov_token') && !t.jar.has('ov_refresh'), 'no session was created');
++ });
++
await t.close();
done();
})();
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
iindex 9770332..68ad185 100644
---- a/test/helpers/mock-network.js
++++ b/test/helpers/mock-network.js
@@@ -22,7 +22,13 @@ function start() {
if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });
if (body.grant_type === 'authorization_code' && body.code !== 'good-code') return json(400, { error: 'invalid_grant' });
if (body.grant_type === 'refresh_token' && body.refresh_token !== 'refresh-1') return json(400, { error: 'invalid_grant' });
-- return json(200, { access_token: sign({ id: 7, username: 'alex', display_name: 'Alex', role: 'user' }), refresh_token: 'refresh-2', token_type: 'Bearer', expires_in: 86400 });
++ if (body.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
++ // A FedCM assertion: must be one of ours (signed with our key) and carry a nonce.
++ try { const a = jwt.verify(body.assertion, publicPem, { algorithms: ['RS256'] }); if (!a.nonce) throw new Error('no nonce'); }
++ catch (e) { return json(400, { error: 'invalid_grant', error_description: `assertion rejected: ${e.message}` }); }
++ }
++ const user = { id: 7, username: 'alex', display_name: 'Alex', role: 'user' };
++ return json(200, { access_token: sign(user), refresh_token: 'refresh-2', token_type: 'Bearer', expires_in: 86400, user, preferences: { theme: 'vibe' } });
}
if (req.url === '/oauth/revoke') return json(200, { ok: true });
json(404, { error: 'not found' });