openvibers/openvibe.space Commits

Space: sessions verified by openvibe-sdk/auth (typed tokens refused) over createNetworkKeys (#28)

OP OpenVibers committed 10 Oct 2026 · 425ece4dbe · 7 files, +41 −49

* openvibe-sdk v0.40.0 (was v0.38.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.40.0 release.

* Space: sessions verified by openvibe-sdk/auth (typed tokens refused) over createNetworkKeys

Plan T1: delete hand-rolled copies. **Security fix:** this site's sign-in verified Network session tokens with openvibe-shared's `jsonwebtoken` client and its own key loader, which read only the legacy `public_key` field. That check looked at the issuer alone. A typed Network token signed with the session key, such as a FedCM assertion minted for another OpenVibe site, an export token or a realtime ticket, would pass as a session wherever the issuer matched. It now goes through openvibe-sdk/auth (v0.40.0; this PR carries the SDK v0.40.0 pin bump, whose own PR is closed as folded in).

- `createAuthClient` keeps `OpenVibeAuthClient` for OAuth (code, jwt-bearer and refresh grants) only.
- **Sessions:** checked by `verifyUserToken` over `createNetworkKeys` (`OV_NETWORK_INTERNAL_URL`, else `OV_NETWORK_URL`). It refuses service principals and typed tokens. The JWKS is retried every 30 s until it loads, refreshed every 15 minutes, and a rotation is followed on an unknown kid. The keys load at app creation, as the old warm-up fetch did, on unref'd timers.
- **The auth object:** `auth.keys` replaces `auth.client.publicKey`. `auth.ensureKey()` returns the keys once loaded (fetching once if needed), else null. The `network_jwks` readiness check reads `auth.keys.loaded()`.
- **Community only:** service tokens in `server/identity/viewer.js` are checked by `verifyServiceToken` with the pinned contracts and the same keys. No key still answers 503 `identity.unavailable`.

Test: a FedCM assertion, a realtime ticket and an export token are each refused on `/auth/me` (401), while the session token signs in. Community and Space suites are green, including Community's auth, comments, pastes API, forum, relay, members-only and actor-limits suites.

Changes

ddiff --git a/README.md b/README.md
iindex 084114f..279d227 100644
---- a/README.md
++++ b/README.md
@@@ -66,7 +66,7 @@ migration.
- OpenVibe.Network (SSO, JWKS, the `space` OAuth client)
- OpenVibe.Community (where every forum URL redirects)
--- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.38.0, `openvibe-shared` v3.0.0, pinned by release tarball
++- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.40.0, `openvibe-shared` v3.0.0, pinned by release tarball
## Capabilities
@@@ -196,6 +196,6 @@ AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
- openvibe-contracts: v0.129.0
--- openvibe-sdk: v0.38.0
++- openvibe-sdk: v0.40.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex a660b3a..59e4313 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"port": 4940,
"domain": "openvibe.space",
"contracts": "openvibe-contracts v0.129.0",
-- "sdk": "openvibe-sdk v0.38.0",
++ "sdk": "openvibe-sdk v0.40.0",
"shared": "openvibe-shared v3.0.0",
"capabilities": [],
"capabilitiesRegistered": false,
ddiff --git a/package-lock.json b/package-lock.json
iindex 7ca379f..3e382bf 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.40.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
},
@@@ -807,9 +807,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.38.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
-- "integrity": "sha512-/Z+1kB5l4Hff0E1O1Zo+DcXaZX93wrmISOPP75sB7NqVUZVeKuJgjYu6vBwG8ufJjlk0vD3ZJ0ntBLI/3IKdpg==",
++ "version": "0.40.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.40.0",
++ "integrity": "sha512-fYX0zijJZDdwjXJGwroLDlb/wniY6NXVtgdPVJJHNgcdRmaAydpr6SKuxcLffmkTquyZQptwChsCvo+D6Wx+IA==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex fcd82fa..8711b7a 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.40.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
},
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex e0d7b0f..9891893 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -35,6 +35,7 @@
const express = require('express');
const crypto = require('crypto');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
++const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');
const cache = require('openvibe-shared/cache-policy');
const ACCESS_COOKIE = 'ov_token';
@@@ -45,63 +46,46 @@ const NEXT_COOKIE = 'ov_oauth_next';
const SILENT_COOKIE = 'ov_oauth_silent';
/**
-- * Create the auth client + JWKS fetcher shared by the whole app.
-- * Verification is OFFLINE: we cache the Network's RS256 public key from
-- * GET /api/.well-known/jwks and verify JWTs locally on every request.
++ * 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.
++ *
++ * 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
*/
function createAuthClient(config) {
const client = new OpenVibeAuthClient({
clientId: config.oauth.clientId,
clientSecret: config.oauth.clientSecret,
redirectUri: config.oauth.redirectUri,
-- publicKey: null, // filled by ensureKey()
++ publicKey: null, // sessions are verified below, never by this client
authBase: config.networkUrl,
internalBase: config.networkInternalUrl,
});
++ const keys = createNetworkKeys({ network: config.networkInternalUrl || config.networkUrl, log: console });
-- let lastFetch = 0;
-- let inflight = null;
async function ensureKey() {
-- if (client.publicKey) return client.publicKey;
-- // A request that arrives while the key is being fetched waits for that fetch rather than
-- // being treated as signed out.
-- if (inflight) return inflight;
-- // Don't hammer the Network if it's down — retry at most every 30s
-- if (Date.now() - lastFetch < 30_000) return null;
-- lastFetch = Date.now();
-- inflight = fetchKey().finally(() => { inflight = null; });
-- return inflight;
-- }
-- async function fetchKey() {
-- for (const base of [config.networkInternalUrl, config.networkUrl]) {
-- if (!base) continue;
-- try {
-- const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
-- if (!res.ok) continue;
-- const jwks = await res.json();
-- if (jwks.public_key) {
-- client.publicKey = jwks.public_key;
-- console.log(`[Auth] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
-- return client.publicKey;
-- }
-- } catch (err) {
-- console.warn(`[Auth] JWKS fetch failed from ${base}: ${err.message}`);
-- }
-- }
-- return null;
++ if (!keys.loaded()) await keys.refresh();
++ return keys.loaded() ? keys : null;
}
/** Offline JWT verification. Returns decoded claims or null. */
async function verify(token) {
if (!token) return null;
-- await ensureKey();
-- return client.verifyToken(token);
++ try {
++ return await verifyUserToken(String(token), { ...keys.verifyOptions, issuer: config.networkUrl });
++ } catch {
++ return null;
++ }
}
-- // Warm the key cache at boot (non-fatal if the Network is down)
-- ensureKey().catch(() => {});
++ // Load the keys now and keep them fresh (non-fatal if the Network is down; unref'd timers).
++ keys.start().catch(() => {});
-- return { client, ensureKey, verify };
++ return { client, keys, ensureKey, verify };
}
/** Token from Authorization header or the ov_token cookie. */
ddiff --git a/server/observability.js b/server/observability.js
iindex 7fc5290..1dbe1cd 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -26,9 +26,8 @@ function createSpaceReadiness({ db, auth, config, release = null } = {}) {
{
name: 'network_jwks', required: false,
check: () => {
-- if (auth.client.publicKey) return true;
-- // Not loaded: ask again (ensureKey throttles itself to one fetch per 30 s) and report now.
-- auth.ensureKey().catch(() => {});
++ if (auth.keys.loaded()) return true;
++ // Not loaded: createNetworkKeys retries every 30 s on its own; report now.
return 'Network signing key not loaded yet: sign-in is unavailable';
},
},
ddiff --git a/test/home.test.js b/test/home.test.js
iindex f937d29..bbe42be 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -68,6 +68,15 @@ const { boot, check, done } = require('./helpers/app');
assert.match(login.headers.get('location'), /^https?:\/\/127\.0\.0\.1:\d+\/oauth\/authorize/);
});
++ await check('a session token signs in; a typed Network token (a FedCM assertion, a realtime ticket, an export token) never does', async () => {
++ const me = (token) => fetch(`${t.base}/auth/me`, { headers: { cookie: `ov_token=${token}` } });
++ const claims = { id: 7, username: 'alex', subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR' };
++ assert.strictEqual((await me(t.network.sign(claims))).status, 200);
++ for (const extra of [{ typ: 'fedcm', aud: 'space' }, { typ: 'realtime', purpose: 'realtime' }, { purpose: 'export' }]) {
++ assert.strictEqual((await me(t.network.sign({ ...claims, ...extra }))).status, 401, JSON.stringify(extra));
++ }
++ });
++
await check('there is no forum here: an unknown path is an honest 404 page', async () => {
const r = await t.get('/nope');
assert.strictEqual(r.status, 404);