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
Changes
@@@ -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 };@@@ -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();})();@@@ -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' });
@@ -13,7 +13,13 @@
@@ -13,7 +13,13 @@
// error=login_required and lands on next + ?sso=none)
// error=login_required and lands on next + ?sso=none)
// ?next=… same-site path or an https://openvibe.network/…
// ?next=… same-site path or an https://openvibe.network/…
// URL (sign-in-everywhere chains)
// 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
// 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/logout → clear cookies (+ best-effort refresh revoke), hint=guest
// GET /auth/me → offline-verify ov_token, return profile
// GET /auth/me → offline-verify ov_token, return profile
// POST /auth/refresh → rotate tokens via refresh_token grant
// POST /auth/refresh → rotate tokens via refresh_token grant
@@ -103,6 +109,30 @@ function claimsToUser(claims) {
@@ -103,6 +109,30 @@ function claimsToUser(claims) {
return user;
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
* 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.
* https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.
@@ -211,6 +241,8 @@ function createAuthRoutes(config, auth) {
@@ -211,6 +241,8 @@ function createAuthRoutes(config, auth) {
if (!res.ok) {
if (!res.ok) {
const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
err.status = res.status;
err.status = res.status;
+ err.error = data.error || 'invalid_grant';
+ err.error_description = data.error_description || null;
throw err;
throw err;
}
}
return data;
return data;
@@ -224,8 +256,17 @@ function createAuthRoutes(config, auth) {
@@ -224,8 +256,17 @@ function createAuthRoutes(config, auth) {
}
}
// ── GET /auth/login ──────────────────────────────────────
// ── GET /auth/login ──────────────────────────────────────
- router.get('/login', (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';
+ 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 });
const { url, state } = buildAuthorizeUrl(config, auth, { silent });
res.cookie(STATE_COOKIE, state, flowCookieOpts());
res.cookie(STATE_COOKIE, state, flowCookieOpts());
const next = sanitizeNext(req.query.next, config);
const next = sanitizeNext(req.query.next, config);
@@ -276,6 +317,40 @@ function createAuthRoutes(config, auth) {
@@ -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 ─────────────────────────────────────
// ── GET /auth/logout ─────────────────────────────────────
router.get('/logout', async (req, res) => {
router.get('/logout', async (req, res) => {
// Best-effort refresh revocation. The Network's rotating refresh tokens
// Best-effort refresh revocation. The Network's rotating refresh tokens
@@ -344,4 +419,4 @@ function optionalAuth(auth) {
@@ -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 };
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'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 assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -78,6 +78,27 @@ const { boot, check, done } = require('./helpers/app');
@@ -78,6 +78,27 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(bad.status, 401);
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 () => {
await check('callback rejects a missing or mismatched state', async () => {
const r = await t.get('/auth/callback?code=good-code&state=deadbeef');
const r = await t.get('/auth/callback?code=good-code&state=deadbeef');
assert.strictEqual(r.status, 400);
assert.strictEqual(r.status, 400);
@@ -107,6 +128,59 @@ const { boot, check, done } = require('./helpers/app');
@@ -107,6 +128,59 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(evil.headers.get('location'), '/');
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();
await t.close();
done();
done();
})();
})();
@@ -22,7 +22,13 @@ function start() {
@@ -22,7 +22,13 @@ function start() {
if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });
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 === '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' });
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 });
if (req.url === '/oauth/revoke') return json(200, { ok: true });
json(404, { error: 'not found' });
json(404, { error: 'not found' });