openvibers/openvibe.vip Commits

Auth: Network tokens through openvibe-sdk/auth (no hand-rolled JWKS) (#35)

OP OpenVibers committed 10 Oct 2026 · 8d94ce67fa · 6 files, +55 −64

Plan T1, the kernel: delete the hand-rolled auth copies. VIP verified Network tokens with its own code: a JWKS loop (one RSA key, refetched every 6 h, retried every 30 s, errors swallowed) plus `jsonwebtoken`. That copy was word for word the same as Tips'. Now it goes through `openvibe-sdk/auth`.

- **Keys:** the SDK's process-wide JWKS client keeps the keys fresh and serves the last good ones through a Network outage. It backs off on failures and refetches at once when a token names an unknown `kid`, so a key rotation is honoured immediately (the old loop held one key for up to 6 h). `OV_NETWORK_PUBLIC_KEY` still pins a key. Readiness (`network_jwks`) reads the client's status.
- **User tokens:** `verifyUserToken` with the Network issuer. Besides the old checks (service, app and mod principals are never sessions), it refuses **typed tokens**: a Network realtime ticket or export token, signed by the same key, used to pass as a VIP session. They no longer do.
- **Service tokens:** `verifyServiceToken` with audience `openvibe.vip`; same result shape and the same problem codes. No key yet still answers 503 `identity.unavailable`.
- `verify` is async now: the API middleware, the page viewer middleware and the session routes await it, and a failure is still `null`, never a throw. `jsonwebtoken` moves to devDependencies (only the test stubs sign tokens).

`ov test`: all 16 test files are green.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex a3b88ea..5950cfa 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,14 +13,14 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
},
"devDependencies": {
-- "@electric-sql/pglite": "^0.5.8"
++ "@electric-sql/pglite": "^0.5.8",
++ "jsonwebtoken": "^9.0.2"
},
"engines": {
"node": ">=22.0.0"
ddiff --git a/package.json b/package.json
iindex 214c464..4b813b1 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,6 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
@@@ -40,6 +39,7 @@
"node": ">=22.0.0"
},
"devDependencies": {
-- "@electric-sql/pglite": "^0.5.8"
++ "@electric-sql/pglite": "^0.5.8",
++ "jsonwebtoken": "^9.0.2"
}
}
ddiff --git a/server/api/auth.js b/server/api/auth.js
iindex 8c03768..7ca8e4d 100644
---- a/server/api/auth.js
++++ b/server/api/auth.js
@@@ -19,7 +19,8 @@
* A request that presents a token is judged on that token alone: a bad one is refused, never
* downgraded to anonymous.
*/
--const { serviceAuth, capabilities, http, ids, staff: staffMap } = require('openvibe-contracts');
++const { capabilities, http, ids, staff: staffMap } = require('openvibe-contracts');
++const { verifyService } = require('../network');
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const ANON = Object.freeze({ kind: 'anonymous' });
@@@ -44,27 +45,28 @@ function userPrincipal(claims) {
}
function createApiAuth({ config, keys, userAuth }) {
-- function resolve(req) {
++ async function resolve(req) {
const header = String(req.headers.authorization || '');
if (!header.startsWith('Bearer ')) return { principal: ANON };
const token = header.slice(7).trim();
-- const publicKey = keys.get();
-- if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
++ if (!keys.ready()) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
const payload = decodePayload(token);
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
++ let r;
++ try { r = await verifyService(config, keys, token); } catch { return { error: [503, 'identity.unavailable', 'the Network signing keys could not be read'] }; }
if (!r.ok) return { error: [401, r.code, r.reason] };
return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti } };
}
-- const claims = userAuth.verify(token);
++ const claims = await userAuth.verify(token);
if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
const p = userPrincipal(claims);
if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
return { principal: p };
}
-- function middleware(req, res, next) {
-- const r = resolve(req);
++ async function middleware(req, res, next) {
++ let r;
++ try { r = await resolve(req); } catch (err) { return next(err); }
if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
req.principal = r.principal;
return next();
ddiff --git a/server/network.js b/server/network.js
iindex 3233071..72bf20d 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -1,44 +1,37 @@
'use strict';
/**
-- * OpenVibe.Network client (the same shape as OpenVibe.Tips and OpenVibe.Community).
++ * OpenVibe.Network client.
*
-- * keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,
-- * refreshed every 6 h and retried every 30 s until it loads). It verifies service tokens
-- * (audience openvibe.vip) and the browser's Network user JWT, both offline.
++ * keys the Network's RS256 signing keys through openvibe-sdk/auth: one process-wide JWKS client per URL keeps
++ * them fresh, serves the last good keys through a Network outage, backs off, and refetches at once when a
++ * token names a key it does not have (a rotation). OV_NETWORK_PUBLIC_KEY pins one key instead. Service
++ * tokens (audience openvibe.vip) and the browser's Network user JWT are both verified offline.
*/
const crypto = require('crypto');
--const jwt = require('jsonwebtoken');
++const contracts = require('openvibe-contracts');
++const { jwksClient, verifyUserToken, verifyServiceToken } = require('openvibe-sdk/auth');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
-- let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
-- let timer = null;
-- async function load() {
-- const url = `${config.network.internalUrl}/api/.well-known/jwks`;
-- try {
-- const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
-- if (!res.ok) throw new Error(`JWKS ${res.status}`);
-- const body = await res.json();
-- const jwk = (body.keys || []).find((k) => k.kty === 'RSA');
-- if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
-- else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
-- else throw new Error('JWKS contained no keys');
-- return pem;
-- } catch (e) {
-- log.warn(`[VIP] Network key not loaded from ${url}: ${e.message}`);
-- return null;
-- }
-- }
-- function start() {
-- if (config.network.publicKey || timer) return;
-- const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
-- retry();
-- timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);
-- timer.unref();
-- }
-- function stop() { if (timer) clearInterval(timer); timer = null; }
-- return { get: () => pem, load, start, stop };
++ const url = `${config.network.internalUrl}/api/.well-known/jwks`;
++ const pinned = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey) : null;
++ const client = pinned ? null : jwksClient(url, { fetch: fetchImpl, log });
++ /** Where a verification takes its keys from (openvibe-sdk/auth options). */
++ const source = pinned ? { publicKey: pinned } : { jwks: url, fetch: fetchImpl };
++ return {
++ source,
++ ready: () => (pinned ? true : client.status().ready),
++ /** Fetch the keys now (boot, tests); true when there is a key to verify with. */
++ async load() { if (client) await client.refresh().catch(() => {}); return pinned ? true : client.status().ready; },
++ start() { if (client) client.start(); },
++ stop() { if (client) client.stop(); },
++ };
++}
++
++/** A service principal's token (audience openvibe.vip): contracts' result { ok, code, reason, claims }. */
++async function verifyService(config, keys, token) {
++ return await verifyServiceToken(token, { ...keys.source, issuer: config.network.issuer, audience: config.audience, contracts });
}
/**
@@@ -54,19 +47,15 @@ function createUserAuth(config, keys) {
authBase: config.network.url,
internalBase: config.network.internalUrl,
});
-- /** Claims of a valid user token, or null. Service tokens are never user tokens. */
-- function verify(token) {
++ /**
++ * Claims of a valid user token, or null. openvibe-sdk/auth refuses service, app and mod principals and typed
++ * tokens (a realtime ticket, an export token): none of them is a session.
++ */
++ async function verify(token) {
if (!token) return null;
-- const key = keys.get();
-- if (!key) return null;
-- let claims;
-- try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }
-- if (!claims || typeof claims !== 'object') return null;
-- if (typeof claims.sub === 'string' && /^(svc|app|mod):/.test(claims.sub)) return null;
-- if (claims.actor_type === 'service') return null;
-- return claims;
++ try { return await verifyUserToken(token, { ...keys.source, issuer: config.network.issuer }); } catch { return null; }
}
return { client, verify };
}
--module.exports = { createKeyProvider, createUserAuth };
++module.exports = { createKeyProvider, createUserAuth, verifyService };
ddiff --git a/server/observability.js b/server/observability.js
iindex 9e3758d..4af5648 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -33,7 +33,7 @@ function createVipReadiness({ db, keys, config, outbox, now, release = null, fet
{ name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-actor limits count in this process only' }) },
{
name: 'network_jwks', required: false,
-- check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified'),
++ check: () => (keys.ready() ? true : 'Network signing key not loaded yet: tokens cannot be verified'),
},
{
name: 'billing', required: false, cacheMs: 15_000, timeoutMs: 2500,
ddiff --git a/server/web/session.js b/server/web/session.js
iindex d54a9fb..1e68e4a 100644
---- a/server/web/session.js
++++ b/server/web/session.js
@@@ -63,9 +63,9 @@ function withParam(target, key, value) {
/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */
function viewerMiddleware(userAuth) {
-- return (req, _res, next) => {
++ return async (req, _res, next) => {
const token = req.cookies && req.cookies[ACCESS_COOKIE];
-- const claims = token ? userAuth.verify(token) : null;
++ const claims = token ? await userAuth.verify(token) : null;
req.viewer = claims ? userPrincipal(claims) : null;
req.viewerClaims = claims ? claimsToUser(claims) : null;
next();
@@@ -116,9 +116,9 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
throw lastErr || new Error('Network unreachable');
}
-- router.get('/login', (req, res) => {
++ router.get('/login', async (req, res) => {
const silent = !!req.query.silent && req.query.silent !== '0';
-- if (silent && userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
++ if (silent && await userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);
let target = url;
if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }
@@@ -167,7 +167,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
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' });
setSession(res, data.access_token, data.refresh_token);
-- return res.json({ ok: true, user: data.user || claimsToUser(userAuth.verify(data.access_token)) });
++ return res.json({ ok: true, user: data.user || claimsToUser(await userAuth.verify(data.access_token)) });
} catch (err) {
if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant' });
return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
@@@ -190,9 +190,9 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
res.redirect(sanitizeNext(req.query.next, config));
});
-- router.get('/me', (req, res) => {
++ router.get('/me', async (req, res) => {
const token = (String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null) || (req.cookies && req.cookies[ACCESS_COOKIE]);
-- const claims = userAuth.verify(token);
++ const claims = await userAuth.verify(token);
if (!claims) return res.status(401).json({ error: 'Not authenticated' });
res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
});
@@@ -203,7 +203,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
try {
const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
setSession(res, data.access_token, data.refresh_token);
-- return res.json({ token: data.access_token, user: claimsToUser(userAuth.verify(data.access_token)) });
++ return res.json({ token: data.access_token, user: claimsToUser(await userAuth.verify(data.access_token)) });
} catch (err) {
if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });