openvibers/openvibe.community Commits

One sign-in module: openvibe-sdk/sso (#258)

OP OpenVibers committed 7 days ago · 308295d2b8 · 4 files, +78 −340

* Sign-in through openvibe-sdk/sso

* Classify the SDK's OAuth calls in the outbound inventory

Changes

ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 41601cc..856502e 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -1,13 +1,14 @@
'use strict';
// ═══════════════════════════════════════════════════════════════
--// openvibe.community — OAuth2 CLIENT session layer
++// openvibe.community — sign-in with OpenVibe.Network, through
++// openvibe-sdk/sso (createSsoClient).
//
// The identity provider is OpenVibe.Network (https://openvibe.network).
// This site is registered there as OAuth client `community`, redirect
--// https://openvibe.community/auth/callback. Same shape as the Tools gateway.
++// https://openvibe.community/auth/callback.
//
--// GET /auth/login → redirect to Network /oauth/authorize
++// GET /auth/login → redirect to Network /oauth/authorize with state and PKCE (S256)
// ?silent=1 adds prompt=none (one quiet round trip; a
// signed-out browser comes straight back with
// error=login_required and lands on next + ?sso=none)
@@@ -15,7 +16,7 @@
// 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 with the PKCE verifier, 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
@@@ -23,6 +24,13 @@
// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest
// GET /auth/me → offline-verify ov_token, return profile ({ user: null } for a guest)
// POST /auth/refresh → rotate tokens via refresh_token grant
++// GET /auth/health → does the Network accept this site's OAuth client? (503 when it refuses it)
++//
++// The routes, the state comparison (constant time), the PKCE verifier and the
++// failure pages are openvibe-sdk/sso's; this module only supplies the site's
++// OAuth client, cookies and the two places where Community's contract differs
++// (see below). Moving here keeps everyone signed in: the cookie names are the
++// ones this site already used.
//
// Cookies (all host-only — openvibe.community has no subdomains):
// ov_token — access JWT, SameSite=Lax, Secure, JS-readable (navbar.js reads it)
@@@ -33,25 +41,20 @@
// ═══════════════════════════════════════════════════════════════
const express = require('express');
--const crypto = require('crypto');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');
++const {
++ createSsoClient, claimsToUser, decodeJwtPayload, fedcmNonceMatches, sanitizeNext, withParam,
++} = require('openvibe-sdk/sso');
const cache = require('openvibe-shared/cache-policy');
--const ACCESS_COOKIE = 'ov_token';
--const REFRESH_COOKIE = 'ov_refresh';
--const HINT_COOKIE = 'ov_sso_hint';
--const STATE_COOKIE = 'ov_oauth_state';
--const NEXT_COOKIE = 'ov_oauth_next';
--const SILENT_COOKIE = 'ov_oauth_silent';
--
/**
-- * Create the auth client shared by the whole app: OAuth (code, jwt-bearer and refresh grants) through
-- * OpenVibeAuthClient, and OFFLINE session verification with openvibe-sdk/auth verifyUserToken against Network's keys
-- * (createNetworkKeys: the JWKS, retried every 30 s until it loads, refreshed every 15 minutes, a rotation followed on
-- * an unknown kid). A typed token (a FedCM assertion, a realtime ticket, an export token) or a service principal is
-- * never a session here.
++ * Create the auth client shared by the whole app: OAuth through OpenVibeAuthClient, and OFFLINE session verification
++ * with openvibe-sdk/auth verifyUserToken against Network's keys (createNetworkKeys: the JWKS, retried every 30 s until
++ * it loads, refreshed every 15 minutes, a rotation followed on an unknown kid). A typed token (a FedCM assertion, a
++ * realtime ticket, an export token) or a service principal is never a session here.
*
++ * auth.client the OAuth client (unused by the sign-in routes, which use openvibe-sdk/sso)
* auth.keys Network's keys (keys.verifyOptions for the other verifiers, keys.loaded() for readiness)
* auth.ensureKey() the keys once loaded (fetching once if they are not), else null
* auth.verify(token) claims | null
@@@ -92,341 +95,59 @@ function createAuthClient(config) {
function extractToken(req) {
const h = req.headers.authorization;
if (h && h.startsWith('Bearer ')) return h.slice(7);
-- return req.cookies?.[ACCESS_COOKIE] || null;
--}
--
--/** Strip registered claims — what the site shows as "the user". */
--function claimsToUser(claims) {
-- if (!claims) return null;
-- const { iat, exp, aud, iss, nbf, jti, ...user } = 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.
-- */
--function sanitizeNext(next, config) {
-- // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
-- // leave the site. A next with any control character or backslash goes home.
-- if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
-- if (!next || typeof next !== 'string') return '/';
-- if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative
-- try {
-- const u = new URL(next);
-- if (u.protocol !== 'https:') return '/';
-- const allowed = [config.baseUrl, config.networkUrl]
-- .map((b) => { try { return new URL(b).hostname; } catch { return null; } })
-- .filter(Boolean);
-- if (allowed.includes(u.hostname)) return u.toString();
-- } catch { /* fall through */ }
-- return '/';
--}
--
--/** Append ?key=value to a same-site path or absolute URL, keeping any existing query/hash. */
--function withParam(target, key, value) {
-- const hashAt = target.indexOf('#');
-- const hash = hashAt >= 0 ? target.slice(hashAt) : '';
-- const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
-- const sep = base.includes('?') ? '&' : '?';
-- return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
--}
--
--/**
-- * Build the Network authorize URL for a login. Exported (and pure) so tests can
-- * cover the silent-login shape without a browser.
-- */
--function buildAuthorizeUrl(config, auth, { silent = false } = {}) {
-- const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);
-- if (!silent) return { url, state };
-- // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the
-- // network has no session either, it bounces back with error=login_required and we stay quiet.
-- const u = new URL(url);
-- u.searchParams.set('prompt', 'none');
-- return { url: u.toString(), state };
++ return req.cookies?.['ov_token'] || null;
}
function createAuthRoutes(config, auth) {
-- const router = express.Router();
--
-- const accessCookieOpts = () => ({
-- sameSite: 'lax',
-- secure: config.cookies.secure,
-- httpOnly: false, // JS-readable by design — the shared navbar reads it client-side
-- path: '/',
-- maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT
-- });
--
-- const refreshCookieOpts = () => ({
-- sameSite: 'lax',
-- secure: config.cookies.secure,
-- httpOnly: true,
-- path: '/auth', // only ever sent back to this session layer
-- maxAge: 30 * 24 * 60 * 60 * 1000,
-- });
--
-- const flowCookieOpts = () => ({
-- sameSite: 'lax', secure: config.cookies.secure, httpOnly: true,
-- path: '/auth', maxAge: 10 * 60 * 1000,
-- });
--
-- const hintCookieOpts = () => ({
-- sameSite: 'lax',
-- secure: config.cookies.secure,
-- httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying
-- path: '/',
-- maxAge: 365 * 24 * 60 * 60 * 1000,
-- });
--
-- function setSessionCookies(res, accessToken, refreshToken) {
-- res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());
-- if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());
-- res.cookie(HINT_COOKIE, 'account', hintCookieOpts());
-- }
--
-- function clearSessionCookies(res) {
-- res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });
-- res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });
-- }
--
-- function clearFlowCookies(res) {
-- for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' });
-- }
--
-- /** Exchange at the Network — internal URL first, public as fallback. */
-- async function tokenGrant(body) {
-- let lastErr = null;
-- for (const base of [config.networkInternalUrl, config.networkUrl]) {
-- if (!base) continue;
-- try {
-- const res = await fetch(`${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';
-- err.error_description = data.error_description || null;
-- throw err;
-- }
-- return data;
-- } catch (err) {
-- lastErr = err;
-- // 4xx = the grant itself is bad; retrying against another base won't help
-- if (err.status && err.status < 500) throw err;
-- }
-- }
-- throw lastErr || new Error('Network unreachable');
-- }
--
-- // ── GET /auth/login ──────────────────────────────────────
-- 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);
-- if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());
-- else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
-- if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
-- else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
-- res.redirect(url);
-- });
--
-- // ── GET /auth/callback ───────────────────────────────────
-- router.get('/callback', async (req, res) => {
-- const { code, state, error } = req.query;
-- const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);
-- const silent = req.cookies?.[SILENT_COOKIE] === '1';
--
-- if (error) {
-- clearFlowCookies(res);
-- // Silent sign-in found no network session — go back quietly as a guest. The
-- // ?sso=none marker stops navbar.js from trying again on that page.
-- if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
-- return res.redirect(withParam('/', 'auth_error', String(error)));
-- }
-- if (!code) return res.status(400).send('Missing authorization code');
--
-- const expectedState = req.cookies?.[STATE_COOKIE];
-- clearFlowCookies(res);
-- // The state cookie is required, not just compared when present: skipping the check when
-- // the cookie is missing would let a crafted link sign a visitor into someone else's account.
-- if (!expectedState || !state || !crypto.timingSafeEqual(
-- Buffer.from(String(state).padEnd(64).slice(0, 64)),
-- Buffer.from(String(expectedState).padEnd(64).slice(0, 64))
-- )) {
-- return res.status(400).send('OAuth state mismatch — please try signing in again.');
-- }
--
-- try {
-- const data = await tokenGrant({
-- grant_type: 'authorization_code',
-- redirect_uri: config.oauth.redirectUri,
-- code,
-- });
-- setSessionCookies(res, data.access_token, data.refresh_token);
-- return res.redirect(next);
-- } catch (err) {
-- console.error('[Auth] Code exchange failed:', err.message);
-- return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
-- }
-- });
--
-- // ── 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' });
-- }
++ const sso = createSsoClient({
++ site: 'community',
++ siteName: 'OpenVibe.Community',
++ baseUrl: config.baseUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ networkUrl: config.networkUrl,
++ networkInternalUrl: config.networkInternalUrl,
++ issuer: config.networkUrl,
++ redirectUri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ secureCookies: config.cookies.secure,
++ log: console,
});
-- // ── GET /auth/logout ─────────────────────────────────────
-- // GET because the shared navbar signs out with a top-level navigation (render/layout.js sets
-- // logoutUrl '/auth/logout?next={path}'; navbar.js does `window.location.href = …`), and the
-- // Network's "sign out everywhere" is a chain of top-level redirects from openvibe.network
-- // (cross-site, but a navigation). What must not end the session is a cross-site subresource: an
-- // <img src="/auth/logout">, a frame or a fetch. Browsers label requests with fetch metadata, so
-- // those are refused; a client that sends none (an old browser, curl) is let through.
-- router.get('/logout', async (req, res) => {
-- const site = req.get('sec-fetch-site');
-- const navigation = req.get('sec-fetch-dest') === 'document'; // only a top-level navigation is a document
-- if (site && site !== 'same-origin' && site !== 'none' && !navigation) {
-- return res.status(403).type('text/plain').send('Sign-out must come from this site.');
-- }
-- // Best-effort refresh revocation. The Network's rotating refresh tokens
-- // self-invalidate, so a failure here is harmless.
-- const refresh = req.cookies?.[REFRESH_COOKIE];
-- if (refresh) {
-- try {
-- await fetch(`${config.networkInternalUrl}/oauth/revoke`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({
-- client_id: config.oauth.clientId,
-- client_secret: config.oauth.clientSecret,
-- token: refresh,
-- }),
-- signal: AbortSignal.timeout(3000),
-- });
-- } catch { /* optional */ }
-- }
-- clearSessionCookies(res);
-- res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());
-- res.redirect(sanitizeNext(req.query.next, config));
-- });
++ const router = express.Router();
-- // ── GET /auth/me ─────────────────────────────────────────
++ // GET /auth/me — the shared navbar asks this on every page view. No credential at all (a guest) is
++ // signed out, not an error (a 401 logged a console error on each); a credential that is present but
++ // invalid or expired still answers 401. Verified offline with the same keys client as the rest of the app.
router.get('/me', async (req, res) => {
const token = extractToken(req);
-- // No credential at all (a guest) is signed out, not an error: the shared navbar asks this on every
-- // page view, and a 401 logged a console error on each (browser check, OpenVibe.Host). A credential
-- // that is present but invalid or expired still answers 401.
if (!token) return res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ user: null });
const claims = await auth.verify(token);
if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });
res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
});
-- // ── POST /auth/refresh ───────────────────────────────────
-- // Small endpoint the shared navbar can call when ov_token expires.
-- router.post('/refresh', async (req, res) => {
-- const refresh = 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 });
-- setSessionCookies(res, data.access_token, data.refresh_token);
-- const claims = await auth.verify(data.access_token);
-- return res.json({ token: data.access_token, user: claimsToUser(claims) });
-- } catch (err) {
-- if (err.status && err.status < 500) {
-- clearSessionCookies(res);
-- return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });
-- }
-- console.error('[Auth] Refresh failed:', err.message);
-- return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
++ // GET /auth/logout — GET because the shared navbar signs out with a top-level navigation (render/layout.js sets
++ // logoutUrl '/auth/logout?next={path}'; navbar.js does `window.location.href = …`), and the Network's "sign out
++ // everywhere" is a chain of top-level redirects from openvibe.network (cross-site, but a navigation). What must
++ // not end the session is a cross-site subresource: an <img src="/auth/logout">, a frame or a fetch. Browsers
++ // label requests with fetch metadata, so those are refused; a client that sends none (an old browser, curl) is
++ // let through. The SDK's handler clears the session and revokes the refresh token (best effort).
++ router.get('/logout', (req, res, next) => {
++ const site = req.get('sec-fetch-site');
++ const navigation = req.get('sec-fetch-dest') === 'document';
++ if (site && site !== 'same-origin' && site !== 'none' && !navigation) {
++ return res.status(403).type('text/plain').send('Sign-out must come from this site.');
}
++ return Promise.resolve(sso.handlers.logout(req, res)).catch(next);
});
++ // /login /callback /fedcm /refresh /health, and the routes this module does not override above.
++ router.use(sso.router(express));
return router;
}
--/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */
--function optionalAuth(auth) {
-- return async (req, _res, next) => {
-- const token = extractToken(req);
-- if (token) {
-- const claims = await auth.verify(token);
-- if (claims) { req.user = claimsToUser(claims); req.token = token; }
-- }
-- next();
-- };
--}
--
--module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
++module.exports = {
++ createAuthClient, createAuthRoutes,
++ extractToken, claimsToUser, decodeJwtPayload, fedcmNonceMatches, sanitizeNext, withParam,
++};
ddiff --git a/test/auth.test.js b/test/auth.test.js
iindex 32c9b0e..773e16e 100644
---- a/test/auth.test.js
++++ b/test/auth.test.js
@@@ -18,7 +18,9 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(u.searchParams.get('response_type'), 'code');
assert.strictEqual(u.searchParams.get('scope'), 'profile theme');
assert.strictEqual(u.searchParams.get('prompt'), null);
-- assert.ok(/^[0-9a-f]{32}$/.test(u.searchParams.get('state')));
++ assert.match(u.searchParams.get('state'), /^[0-9A-Za-z_-]{32}$/, 'the SDK state: 24 random bytes, base64url');
++ assert.match(u.searchParams.get('code_challenge'), /^[0-9A-Za-z_-]{43}$/, 'PKCE S256 challenge');
++ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
const state = cookieNamed(r, 'ov_oauth_state');
assert.ok(state && /HttpOnly/i.test(state) && /Path=\/auth/.test(state) && /Secure/.test(state) && !/Domain=/i.test(state), 'state cookie: httpOnly, /auth, secure, host-only');
assert.ok(state.includes(u.searchParams.get('state')));
@@@ -119,9 +121,22 @@ const { boot, check, done } = require('./helpers/app');
t.jar.set('ov_token', saved);
});
-- await check('callback rejects a missing or mismatched state', async () => {
++ await check('callback: a missing or mismatched state never sets a session; the SDK starts a fresh sign-in once, then explains', async () => {
const r = await t.get('/auth/callback?code=good-code&state=deadbeef');
-- assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.status, 302);
++ assert.match(r.headers.get('location'), /^\/auth\/login/);
++ assert.ok(cookieNamed(r, 'ov_oauth_retry'), 'the recovery cookie');
++ assert.ok(!cookieNamed(r, 'ov_token'), 'no session was created by this callback');
++ const again = await t.get('/auth/callback?code=good-code&state=deadbeef', { cookies: ['ov_oauth_retry=1'] });
++ assert.strictEqual(again.status, 400);
++ assert.match(again.text, /Try again/);
++ });
++
++ await check('GET /auth/health answers whether the Network accepts the community client', async () => {
++ const r = await t.get('/auth/health');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.json(), { ok: true, client: 'ok' });
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
});
await check('POST /auth/refresh rotates tokens; a rejected refresh clears the session', async () => {
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
iindex f547d07..d4ba447 100644
---- a/test/helpers/mock-network.js
++++ b/test/helpers/mock-network.js
@@@ -102,7 +102,9 @@ function start({ clientSecret = 'shh' } = {}) {
});
});
function sign(claims, opts = {}) {
-- return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
++ // The Network mints person tokens with openvibe.network in `aud` (oauth-routes.js issueTokenPair); the SDK's
++ // session verification checks the audience, so a session token here carries it unless a test says otherwise.
++ return jwt.sign({ sub: claims.id, aud: ['openvibe.network'], ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
}
/** A client-credentials service token (identity.service-token-claims@1). */
function signService({ sub = 'svc:live', actorType = 'service', aud = ['openvibe.community'], cap = [], iss = issuer, expSec = 300, key = privatePem, extra = {} } = {}) {
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 7cdc324..4888e83 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/app');
// file → [call sites, where they go and why the URL is not a user's]
const INVENTORY = {
'server/app.js': [1, 'createAuthClient(config): the Network JWKS (OV_NETWORK_INTERNAL_URL)'],
-- 'server/auth/routes.js': [3, '/oauth/token, /oauth/revoke: OV_NETWORK_INTERNAL_URL, fixed paths (the signing keys come from openvibe-sdk/auth)'],
++ 'server/auth/routes.js': [1, 'createAuthClient(config): Network\'s keys (openvibe-sdk/auth, OV_NETWORK_INTERNAL_URL); the OAuth token/revoke calls are openvibe-sdk/sso\'s now'],
'server/chat-rooms.js': [1, 'OV_CHAT_INTERNAL_URL /api/chat/rooms/<slug>/attachments; the slug must match ROOM_SLUG and is encoded'],
'server/events.js': [3, 'service token (Network) and the outbox publisher: EVENTS_URL'],
'server/identity/network.js': [3, 'Network /internal/identity/resolve-batch and /internal/identity/resolve?username= with a service token (configured base, never a user URL)'],