openvibers/openvibe.media Commits
Media: one set of Network keys and openvibe-sdk/auth checks (site sign-in refuses typed tokens) (#78)
Changes
@@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pgexport and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay andthe account and revocation subscriptions)- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host--- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.38.0 (tokens, events outbox, usage readings, per-actor limits),++- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.40.0 (tokens, events outbox, usage readings, per-actor limits),`openvibe-shared` v3.0.0, pinned by release tarball## Capabilities@@@ -124,7 +124,7 @@ server/storage.orphans.scan (monthly storage orphan report, service-wide, report only),storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)client-ip.js trust proxy = loopback; req.ip is the only client address--(openvibe-shared v3.0.0, openvibe-contracts v0.129.0, openvibe-sdk v0.38.0: pinned release tarballs, installed by npm)++(openvibe-shared v3.0.0, openvibe-contracts v0.129.0, openvibe-sdk v0.40.0: pinned release tarballs, installed by npm)scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator toolsscripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals@@@ -721,6 +721,6 @@ restart; afterwards `sudo ovhost rollback media --to <sha>`.<!-- versions:start -->- openvibe-contracts: v0.129.0--- openvibe-sdk: v0.38.0++- openvibe-sdk: v0.40.0- openvibe-shared: v3.0.0<!-- versions:end -->@@@ -7,7 +7,7 @@"port": 4100,"domain": "openvibe.media","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": ["media.object.upload",@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","multer": "^1.4.5-lts.1","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","sharp": "^0.33.4"@@@ -1863,9 +1863,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"@@@ -21,7 +21,7 @@"iovalkey": "^0.4.0","multer": "^1.4.5-lts.1","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","sharp": "^0.33.4"@@@ -5,8 +5,10 @@* 1. App API key — `Authorization: Bearer <app_api_key>` (server-to-server).* Constant-time hash compare against the `apps` row; a key is ONLY valid* for its own `:app` path segment.-- * 2. Network user JWT — RS256, verified OFFLINE against the JWKS public key-- * fetched from OV_NETWORK_URL at boot (cached, refreshed periodically).++ * 2. Network user JWT — RS256, verified OFFLINE (openvibe-sdk/auth verifyUserToken) against Network's keys:++ * createNetworkKeys over OV_NETWORK_URL's JWKS, loaded at boot (retried every 30 s until it loads,++ * refreshed every 15 minutes, a rotation followed on an unknown kid). The same keys verify service++ * tokens and the site sign-in (server/user-auth.js).* Browser endpoints additionally check the Origin header against the* app's allowed_origins list.*@@@ -20,84 +22,45 @@'use strict';const crypto = require('crypto');--const { serviceAuth, capabilities, http } = require('openvibe-contracts');++const contracts = require('openvibe-contracts');++const sdkAuth = require('openvibe-sdk/auth');const db = require('./db/database');const config = require('./config');--// ── JWKS → PEM (offline user-JWT verification) ───────────────++const { capabilities, http } = contracts;--let _networkPublicKeyPem = null;--let _jwksTimer = null;++// ── Network keys (offline user-JWT and service-token verification) ──--async function fetchNetworkPublicKey() {-- const url = `${config.network.url}/api/.well-known/jwks`;-- try {-- const res = await fetch(url, { signal: AbortSignal.timeout(8000) });-- if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);-- const body = await res.json();-- const jwk = (body.keys || []).find(k => k.kty === 'RSA') || (body.keys || [])[0];-- if (jwk) {-- const keyObj = crypto.createPublicKey({ key: jwk, format: 'jwk' });-- _networkPublicKeyPem = keyObj.export({ type: 'spki', format: 'pem' });-- } else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) {-- // Network's inherited endpoint shape: { public_key: <PEM>, algorithm }-- _networkPublicKeyPem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });-- } else {-- throw new Error('JWKS contained no keys');-- }-- console.log('[Auth] Network JWKS public key loaded');-- return _networkPublicKeyPem;-- } catch (err) {-- console.warn(`[Auth] Could not fetch JWKS from ${url}: ${err.message} — user-JWT auth unavailable until it loads`);-- return null;-- }--}++// Created here, fetched only by startJwksRefresh() at boot (never in a drill) or by the first verification.++let keys = sdkAuth.createNetworkKeys({ network: config.network.url, log: console });--function startJwksRefresh() {-- // Until the key loads, retry every 30s — Network may still be booting-- // (user-JWT auth is down for browsers the whole time the key is missing).-- const tryLoad = () => fetchNetworkPublicKey().then((pem) => {-- if (!pem) setTimeout(tryLoad, 30 * 1000).unref?.();-- }).catch(() => { setTimeout(tryLoad, 30 * 1000).unref?.(); });-- tryLoad();-- _jwksTimer = setInterval(() => fetchNetworkPublicKey().catch(() => {}), 6 * 60 * 60 * 1000);-- if (_jwksTimer.unref) _jwksTimer.unref();--}++/** Network's keys, for server/user-auth.js (the site sign-in verifies with the same ones). */++function networkKeys() { return keys; }--/** Whether the Network public key has loaded (user JWTs and service tokens verify only once it has). */--function jwksLoaded() { return !!_networkPublicKeyPem; }++/** Boot: load the keys now, retry every 30 s while Network boots, refresh every 15 minutes. */++function startJwksRefresh() { keys.start().catch(() => { /* logged by the JWKS client */ }); }--function stopJwksRefresh() {-- if (_jwksTimer) { clearInterval(_jwksTimer); _jwksTimer = null; }--}++/** Whether the Network keys have loaded (user JWTs and service tokens verify only once they have). */++function jwksLoaded() { return keys.loaded(); }--/** Verify an RS256 user JWT offline. Returns the payload or null. */--function verifyUserJwt(token) {-- if (!_networkPublicKeyPem || !token) return null;-- const parts = String(token).split('.');-- if (parts.length !== 3) return null;-- try {-- const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));-- if (header.alg !== 'RS256') return null;-- const ok = crypto.verify(-- 'RSA-SHA256',-- Buffer.from(`${parts[0]}.${parts[1]}`),-- _networkPublicKeyPem,-- Buffer.from(parts[2], 'base64url')-- );-- if (!ok) return null;-- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));-- if (payload.exp && Date.now() / 1000 > payload.exp) return null;-- if (payload.iss && payload.iss !== config.network.url) return null;-- // aud must include this service when present.-- if (payload.aud) {-- const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];-- if (!aud.includes('openvibe.media')) return null;-- }-- return payload;-- } catch {-- return null;++function stopJwksRefresh() { keys.stop(); }++++/** One fetch now: the keys, or null. */++async function fetchNetworkPublicKey() { return await keys.refresh(); }++++/**++ * A Network session token for this service, or null: RS256, issuer, expiry, never a service principal or a typed++ * token (openvibe-sdk/auth verifyUserToken), and `aud` must include openvibe.media when it is present.++ */++async function verifyUserJwt(token) {++ if (!token) return null;++ let payload;++ try { payload = await sdkAuth.verifyUserToken(String(token), { ...keys.verifyOptions, issuer: config.network.url }); } catch { return null; }++ if (payload.aud) {++ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];++ if (!aud.includes('openvibe.media')) return null;}++ return payload;}/**@@@ -105,14 +68,13 @@ function verifyUserJwt(token) {* Returns { ok, claims } or { ok: false, code, reason }. `acceptSandbox` is true only on the* developer-project tenant routes; everywhere else an env=sandbox token is token.sandbox_refused.*/--function verifyServiceTokenResult(token, { acceptSandbox = false } = {}) {-- if (!_networkPublicKeyPem) return { ok: false, code: 'token.unavailable', reason: 'Network signing key not loaded yet' };-- return serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media', acceptSandbox });++async function verifyServiceTokenResult(token, { acceptSandbox = false } = {}) {++ return await sdkAuth.verifyServiceToken(token, { ...keys.verifyOptions, issuer: config.network.url, audience: 'openvibe.media', contracts, acceptSandbox });}/** A Network-issued service token for this service (sandbox tokens refused), or null. */--function verifyServiceToken(token) {-- const r = verifyServiceTokenResult(token);++async function verifyServiceToken(token) {++ const r = await verifyServiceTokenResult(token);return r.ok ? r.claims : null;}@@@ -320,7 +282,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespaconst appRoute = project && !!named && PROJECT_ID_RE.test(appId);let svc = null;if (token && token.split('.').length === 3) {-- const r = verifyServiceTokenResult(token, { acceptSandbox: appRoute });++ const r = await verifyServiceTokenResult(token, { acceptSandbox: appRoute });if (r.ok) svc = r.claims;else if (r.code === 'token.sandbox_refused') {return problem(res, 401, 'token.sandbox_refused', 'sandbox tokens are accepted only on developer-project tenant routes (/api/v1/<project_id>/files, /api/v2/<project_id>/objects)');@@@ -394,7 +356,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa//// Answered specifically rather than falling through to a bare 401, so anyone// who builds against the old shape is told what to do instead.-- if (allowUser && token && verifyUserJwt(token)) {++ if (allowUser && token && await verifyUserJwt(token)) {return res.status(403).json({error: 'A user JWT cannot identify a user here. Call from your server with '+ 'your app key and set X-OV-User-Id to the caller\'s id in YOUR user space.',@@@ -509,7 +471,12 @@ module.exports = {verifyServiceToken,verifyServiceTokenResult,PROJECT_ID_RE,-- _setNetworkPublicKeyForTests(pem) { _networkPublicKeyPem = pem; },++ // Tests pin a key (a PEM) instead of Network's JWKS; null goes back to the JWKS.++ _setNetworkPublicKeyForTests(pem) {++ keys.stop();++ keys = pem ? sdkAuth.createNetworkKeys({ publicKey: pem }) : sdkAuth.createNetworkKeys({ network: config.network.url, log: console });++ },++ networkKeys,tenantCors,optionalIdentity,// verifyUserJwt stays internal: it proves a token is a genuine Network credential,@@@ -3,7 +3,7 @@* OpenVibe.Media as a receiver of Network service tokens (plan T2, X-Internal-Key retirement).** guard(capability) protects an internal route: loopback only (nothing that came through nginx) and a-- * Network service token holding `capability` for audience openvibe.media, verified with the same key++ * Network service token holding `capability` for audience openvibe.media, verified with the same keys* Media verifies user JWTs with (server/auth.js verifyServiceTokenResult; sandbox refused). The token* is judged on its own: a Bearer is never downgraded to another credential, and nothing else opens the* route (the X-Internal-Key path is retired).@@@ -17,16 +17,19 @@ const isLocal = (req) => /^(::1|::ffff:127\.|127\.)/.test(String(req.socket.remofunction guard(capability) {return function mediaServiceGuard(req, res, next) {++ check(req, res, next).catch(next);++ };++ async function check(req, res, next) {if (viaProxy(req) || !isLocal(req)) return res.status(404).json({ error: 'Not found' });const bearer = String(req.headers.authorization || '');if (!bearer.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required' });-- const r = auth.verifyServiceTokenResult(bearer.slice(7).trim());++ const r = await auth.verifyServiceTokenResult(bearer.slice(7).trim());if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason });const c = capabilities.check(r.claims, capability);if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason });req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };next();-- };++ }}module.exports = { guard };@@@ -35,6 +35,7 @@const express = require('express');const crypto = require('crypto');const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++const { verifyUserToken } = require('openvibe-sdk/auth');const cache = require('openvibe-shared/cache-policy');const ACCESS_COOKIE = 'ov_token';@@@ -45,59 +46,36 @@ 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 and refresh grants) through OpenVibeAuthClient, and++ * OFFLINE session verification with openvibe-sdk/auth verifyUserToken against the Network keys server/auth.js holds++ * (loaded at boot, never in a drill). A typed token (a FedCM assertion, a realtime ticket, an export token) or a++ * service principal is never a session here.*/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 clientauthBase: config.networkUrl,internalBase: config.networkInternalUrl,});-- let lastFetch = 0;-- async function ensureKey() {-- if (client.publicKey) return client.publicKey;-- // 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();-- 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;-- }--/** Offline JWT verification. Returns decoded claims or null. */async function verify(token) {if (!token) return null;-- await ensureKey();-- const claims = await client.verifyToken(token);++ let claims;++ try {++ claims = await verifyUserToken(String(token), { ...require('./auth').networkKeys().verifyOptions, issuer: config.networkUrl });++ } catch {++ return null;++ }// Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).-- if (claims && require('./revocations').isRevoked(claims)) return null;++ if (require('./revocations').isRevoked(claims)) return null;return claims;}-- // Warm the key cache at boot (non-fatal if the Network is down). A restore drill asks Network-- // for nothing (MEDIA_DRILL; its /auth routes answer 503).-- if (!require('./drill').enabled) ensureKey().catch(() => {});---- return { client, ensureKey, verify };++ return { client, verify };}/** Token from Authorization header or the ov_token cookie. */nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++// Media's Network keys (server/auth.js, openvibe-sdk/auth createNetworkKeys): the API, the internal guard and the++// site sign-in (server/user-auth.js) verify with the same keys, loaded from Network's JWKS at boot and following a++// rotation on an unknown kid. The site sign-in never takes a typed token (a FedCM assertion, a realtime ticket, an++// export token) or a service principal for a session, whoever signed it.++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++++const pair = () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++const K1 = pair(), K2 = pair(), ROGUE = pair();++const jwkOf = (k, kid) => ({ ...k.publicKey.export({ format: 'jwk' }), alg: 'RS256', use: 'sig', kid });++let published = [jwkOf(K1, 'k1')];++let fetches = 0;++const network = http.createServer((req, res) => {++ if (req.url !== '/api/.well-known/jwks') { res.statusCode = 404; return res.end(); }++ fetches++;++ res.setHeader('content-type', 'application/json');++ res.end(JSON.stringify({ keys: published }));++});++++(async () => {++ await new Promise((r) => network.listen(0, '127.0.0.1', r));++ const ISS = `http://127.0.0.1:${network.address().port}`;++ process.env.OV_NETWORK_URL = ISS;++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++ const warn = console.warn; const log = console.log;++ console.warn = () => {}; console.log = () => {};++ const express = require('express');++ const auth = require('../server/auth');++ const userAuth = require('../server/user-auth');++ const { guard } = require('../server/service-guard');++++ const now = () => Math.floor(Date.now() / 1000);++ const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++ const sign = (k, kid, claims) => {++ const input = `${enc({ alg: 'RS256', typ: 'JWT', kid })}.${enc(claims)}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), k.privateKey).toString('base64url')}`;++ };++ const session = (extra = {}) => ({ sub: 7, subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', username: 'x', role: 'user', iss: ISS, aud: ['openvibe.network', 'openvibe.media'], iat: now(), exp: now() + 600, ...extra });++ const service = (cap) => ({ iss: ISS, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.media'], cap, ns: [], iat: now(), exp: now() + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });++ const site = userAuth.createAuthClient({ networkUrl: ISS, networkInternalUrl: ISS, oauth: { clientId: 'media', clientSecret: '', redirectUri: 'https://media.test/auth/callback' } });++++ const app = express();++ app.post('/internal/probe', guard('media.avatar.ingest'), (req, res) => res.json({ sub: req.principal.sub }));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const probe = (token) => fetch(`http://127.0.0.1:${server.address().port}/internal/probe`, { method: 'POST', headers: { authorization: `Bearer ${token}` } });++++ try {++ assert.strictEqual(auth.jwksLoaded(), false, 'nothing fetched at module load');++ auth.startJwksRefresh();++ for (let i = 0; i < 100 && !auth.jwksLoaded(); i++) await new Promise((r) => setTimeout(r, 10));++ assert.strictEqual(auth.jwksLoaded(), true, 'loaded at boot');++++ const claims = await site.verify(sign(K1, 'k1', session()));++ assert.ok(claims && claims.subject_id === 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', 'a session signs in on the site');++ for (const [what, extra] of [['a FedCM assertion', { typ: 'fedcm' }], ['a realtime ticket', { typ: 'realtime', purpose: 'realtime' }],++ ['an export token', { purpose: 'export' }], ['a service principal', { sub: 'svc:live', actor_type: 'service' }]]) {++ assert.strictEqual(await site.verify(sign(K1, 'k1', session(extra))), null, `${what} is not a session on the site`);++ }++ assert.strictEqual(await site.verify(sign(ROGUE, 'k1', session())), null, 'a key Network never published');++++ const before = fetches;++ published = [jwkOf(K2, 'k2'), jwkOf(K1, 'k1')];++ let r = await probe(sign(K2, 'k2', service(['media.avatar.ingest'])));++ assert.strictEqual(r.status, 200, 'the guard follows a rotation');++ assert.strictEqual(fetches, before + 1, 'after one refetch');++ assert.ok(await site.verify(sign(K2, 'k2', session())), 'and so does the site sign-in');++ r = await probe(sign(K2, 'k2', service([])));++ assert.strictEqual(r.status, 403);++ r = await probe(sign(ROGUE, 'k2', service(['media.avatar.ingest'])));++ assert.strictEqual(r.status, 401, 'a forged service token');++ r = await probe(sign(K2, 'k2', session()));++ assert.strictEqual(r.status, 401, 'a session token is not a service token');++ } finally {++ auth.stopJwksRefresh();++ server.close();++ network.close();++ console.warn = warn; console.log = log;++ }++ console.log('network-keys: all checks passed');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -29,9 +29,8 @@ const https = require('https');// ── Inventory: every outbound call in server/, and whose address it uses ──const OUTBOUND = {'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below',-- 'server/auth.js': 'Network JWKS (OV_NETWORK_URL)','server/account-data.js': 'Network internal API (OV_NETWORK_INTERNAL_URL), fixed paths: export parts and deletion confirmations',-- 'server/user-auth.js': 'Network OAuth and JWKS (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL)',++ 'server/user-auth.js': 'Network OAuth (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL); the signing keys come from openvibe-sdk/auth','server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)','server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path','server/public/dev-data.js': 'the app\'s internal URL (APP_INTERNAL_URLS), ids and encoded names in the path',
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
the account and revocation subscriptions)
the account and revocation subscriptions)
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
-- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.38.0 (tokens, events outbox, usage readings, per-actor limits),
+- `openvibe-contracts` v0.129.0, `openvibe-sdk` v0.40.0 (tokens, events outbox, usage readings, per-actor limits),
`openvibe-shared` v3.0.0, pinned by release tarball
`openvibe-shared` v3.0.0, pinned by release tarball
## Capabilities
## Capabilities
@@ -124,7 +124,7 @@ server/
@@ -124,7 +124,7 @@ server/
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
client-ip.js trust proxy = loopback; req.ip is the only client address
client-ip.js trust proxy = loopback; req.ip is the only client address
-(openvibe-shared v3.0.0, openvibe-contracts v0.129.0, openvibe-sdk v0.38.0: pinned release tarballs, installed by npm)
+(openvibe-shared v3.0.0, openvibe-contracts v0.129.0, openvibe-sdk v0.40.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
@@ -721,6 +721,6 @@ restart; afterwards `sudo ovhost rollback media --to <sha>`.
@@ -721,6 +721,6 @@ restart; afterwards `sudo ovhost rollback media --to <sha>`.
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.129.0
- openvibe-contracts: v0.129.0
-- openvibe-sdk: v0.38.0
+- openvibe-sdk: v0.40.0
- openvibe-shared: v3.0.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"port": 4100,
"port": 4100,
"domain": "openvibe.media",
"domain": "openvibe.media",
"contracts": "openvibe-contracts v0.129.0",
"contracts": "openvibe-contracts v0.129.0",
- "sdk": "openvibe-sdk v0.38.0",
+ "sdk": "openvibe-sdk v0.40.0",
"shared": "openvibe-shared v3.0.0",
"shared": "openvibe-shared v3.0.0",
"capabilities": [
"capabilities": [
"media.object.upload",
"media.object.upload",
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
@@ -1863,9 +1863,9 @@
@@ -1863,9 +1863,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
@@ -5,8 +5,10 @@
@@ -5,8 +5,10 @@
* 1. App API key — `Authorization: Bearer <app_api_key>` (server-to-server).
* 1. App API key — `Authorization: Bearer <app_api_key>` (server-to-server).
* Constant-time hash compare against the `apps` row; a key is ONLY valid
* Constant-time hash compare against the `apps` row; a key is ONLY valid
* for its own `:app` path segment.
* for its own `:app` path segment.
- * 2. Network user JWT — RS256, verified OFFLINE against the JWKS public key
- * fetched from OV_NETWORK_URL at boot (cached, refreshed periodically).
+ * 2. Network user JWT — RS256, verified OFFLINE (openvibe-sdk/auth verifyUserToken) against Network's keys:
+ * createNetworkKeys over OV_NETWORK_URL's JWKS, loaded at boot (retried every 30 s until it loads,
+ * refreshed every 15 minutes, a rotation followed on an unknown kid). The same keys verify service
+ * tokens and the site sign-in (server/user-auth.js).
* Browser endpoints additionally check the Origin header against the
* Browser endpoints additionally check the Origin header against the
* app's allowed_origins list.
* app's allowed_origins list.
*
*
@@ -20,84 +22,45 @@
@@ -20,84 +22,45 @@
'use strict';
'use strict';
const crypto = require('crypto');
const crypto = require('crypto');
-const { serviceAuth, capabilities, http } = require('openvibe-contracts');
+const contracts = require('openvibe-contracts');
+const sdkAuth = require('openvibe-sdk/auth');
const db = require('./db/database');
const db = require('./db/database');
const config = require('./config');
const config = require('./config');
-// ── JWKS → PEM (offline user-JWT verification) ───────────────
+const { capabilities, http } = contracts;
-let _networkPublicKeyPem = null;
-let _jwksTimer = null;
+// ── Network keys (offline user-JWT and service-token verification) ──
-async function fetchNetworkPublicKey() {
- const url = `${config.network.url}/api/.well-known/jwks`;
- try {
- const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
- if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
- const body = await res.json();
- const jwk = (body.keys || []).find(k => k.kty === 'RSA') || (body.keys || [])[0];
- if (jwk) {
- const keyObj = crypto.createPublicKey({ key: jwk, format: 'jwk' });
- _networkPublicKeyPem = keyObj.export({ type: 'spki', format: 'pem' });
- } else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) {
- // Network's inherited endpoint shape: { public_key: <PEM>, algorithm }
- _networkPublicKeyPem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
- } else {
- throw new Error('JWKS contained no keys');
- }
- console.log('[Auth] Network JWKS public key loaded');
- return _networkPublicKeyPem;
- } catch (err) {
- console.warn(`[Auth] Could not fetch JWKS from ${url}: ${err.message} — user-JWT auth unavailable until it loads`);
- return null;
- }
-}
+// Created here, fetched only by startJwksRefresh() at boot (never in a drill) or by the first verification.
+let keys = sdkAuth.createNetworkKeys({ network: config.network.url, log: console });
-function startJwksRefresh() {
- // Until the key loads, retry every 30s — Network may still be booting
- // (user-JWT auth is down for browsers the whole time the key is missing).
- const tryLoad = () => fetchNetworkPublicKey().then((pem) => {
- if (!pem) setTimeout(tryLoad, 30 * 1000).unref?.();
- }).catch(() => { setTimeout(tryLoad, 30 * 1000).unref?.(); });
- tryLoad();
- _jwksTimer = setInterval(() => fetchNetworkPublicKey().catch(() => {}), 6 * 60 * 60 * 1000);
- if (_jwksTimer.unref) _jwksTimer.unref();
-}
+/** Network's keys, for server/user-auth.js (the site sign-in verifies with the same ones). */
+function networkKeys() { return keys; }
-/** Whether the Network public key has loaded (user JWTs and service tokens verify only once it has). */
-function jwksLoaded() { return !!_networkPublicKeyPem; }
+/** Boot: load the keys now, retry every 30 s while Network boots, refresh every 15 minutes. */
+function startJwksRefresh() { keys.start().catch(() => { /* logged by the JWKS client */ }); }
-function stopJwksRefresh() {
- if (_jwksTimer) { clearInterval(_jwksTimer); _jwksTimer = null; }
-}
+/** Whether the Network keys have loaded (user JWTs and service tokens verify only once they have). */
+function jwksLoaded() { return keys.loaded(); }
-/** Verify an RS256 user JWT offline. Returns the payload or null. */
-function verifyUserJwt(token) {
- if (!_networkPublicKeyPem || !token) return null;
- const parts = String(token).split('.');
- if (parts.length !== 3) return null;
- try {
- const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
- if (header.alg !== 'RS256') return null;
- const ok = crypto.verify(
- 'RSA-SHA256',
- Buffer.from(`${parts[0]}.${parts[1]}`),
- _networkPublicKeyPem,
- Buffer.from(parts[2], 'base64url')
- );
- if (!ok) return null;
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
- if (payload.exp && Date.now() / 1000 > payload.exp) return null;
- if (payload.iss && payload.iss !== config.network.url) return null;
- // aud must include this service when present.
- if (payload.aud) {
- const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
- if (!aud.includes('openvibe.media')) return null;
- }
- return payload;
- } catch {
- return null;
+function stopJwksRefresh() { keys.stop(); }
+
+/** One fetch now: the keys, or null. */
+async function fetchNetworkPublicKey() { return await keys.refresh(); }
+
+/**
+ * A Network session token for this service, or null: RS256, issuer, expiry, never a service principal or a typed
+ * token (openvibe-sdk/auth verifyUserToken), and `aud` must include openvibe.media when it is present.
+ */
+async function verifyUserJwt(token) {
+ if (!token) return null;
+ let payload;
+ try { payload = await sdkAuth.verifyUserToken(String(token), { ...keys.verifyOptions, issuer: config.network.url }); } catch { return null; }
+ if (payload.aud) {
+ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
+ if (!aud.includes('openvibe.media')) return null;
}
}
+ return payload;
}
}
/**
/**
@@ -105,14 +68,13 @@ function verifyUserJwt(token) {
@@ -105,14 +68,13 @@ function verifyUserJwt(token) {
* Returns { ok, claims } or { ok: false, code, reason }. `acceptSandbox` is true only on the
* Returns { ok, claims } or { ok: false, code, reason }. `acceptSandbox` is true only on the
* developer-project tenant routes; everywhere else an env=sandbox token is token.sandbox_refused.
* developer-project tenant routes; everywhere else an env=sandbox token is token.sandbox_refused.
*/
*/
-function verifyServiceTokenResult(token, { acceptSandbox = false } = {}) {
- if (!_networkPublicKeyPem) return { ok: false, code: 'token.unavailable', reason: 'Network signing key not loaded yet' };
- return serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media', acceptSandbox });
+async function verifyServiceTokenResult(token, { acceptSandbox = false } = {}) {
+ return await sdkAuth.verifyServiceToken(token, { ...keys.verifyOptions, issuer: config.network.url, audience: 'openvibe.media', contracts, acceptSandbox });
}
}
/** A Network-issued service token for this service (sandbox tokens refused), or null. */
/** A Network-issued service token for this service (sandbox tokens refused), or null. */
-function verifyServiceToken(token) {
- const r = verifyServiceTokenResult(token);
+async function verifyServiceToken(token) {
+ const r = await verifyServiceTokenResult(token);
return r.ok ? r.claims : null;
return r.ok ? r.claims : null;
}
}
@@ -320,7 +282,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
@@ -320,7 +282,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
const appRoute = project && !!named && PROJECT_ID_RE.test(appId);
const appRoute = project && !!named && PROJECT_ID_RE.test(appId);
let svc = null;
let svc = null;
if (token && token.split('.').length === 3) {
if (token && token.split('.').length === 3) {
- const r = verifyServiceTokenResult(token, { acceptSandbox: appRoute });
+ const r = await verifyServiceTokenResult(token, { acceptSandbox: appRoute });
if (r.ok) svc = r.claims;
if (r.ok) svc = r.claims;
else if (r.code === 'token.sandbox_refused') {
else if (r.code === 'token.sandbox_refused') {
return problem(res, 401, 'token.sandbox_refused', 'sandbox tokens are accepted only on developer-project tenant routes (/api/v1/<project_id>/files, /api/v2/<project_id>/objects)');
return problem(res, 401, 'token.sandbox_refused', 'sandbox tokens are accepted only on developer-project tenant routes (/api/v1/<project_id>/files, /api/v2/<project_id>/objects)');
@@ -394,7 +356,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
@@ -394,7 +356,7 @@ function tenantAuth({ allowUser = false, capability = null, verb = null, namespa
//
//
// Answered specifically rather than falling through to a bare 401, so anyone
// Answered specifically rather than falling through to a bare 401, so anyone
// who builds against the old shape is told what to do instead.
// who builds against the old shape is told what to do instead.
- if (allowUser && token && verifyUserJwt(token)) {
+ if (allowUser && token && await verifyUserJwt(token)) {
return res.status(403).json({
return res.status(403).json({
error: 'A user JWT cannot identify a user here. Call from your server with '
error: 'A user JWT cannot identify a user here. Call from your server with '
+ 'your app key and set X-OV-User-Id to the caller\'s id in YOUR user space.',
+ 'your app key and set X-OV-User-Id to the caller\'s id in YOUR user space.',
@@ -509,7 +471,12 @@ module.exports = {
@@ -509,7 +471,12 @@ module.exports = {
verifyServiceToken,
verifyServiceToken,
verifyServiceTokenResult,
verifyServiceTokenResult,
PROJECT_ID_RE,
PROJECT_ID_RE,
- _setNetworkPublicKeyForTests(pem) { _networkPublicKeyPem = pem; },
+ // Tests pin a key (a PEM) instead of Network's JWKS; null goes back to the JWKS.
+ _setNetworkPublicKeyForTests(pem) {
+ keys.stop();
+ keys = pem ? sdkAuth.createNetworkKeys({ publicKey: pem }) : sdkAuth.createNetworkKeys({ network: config.network.url, log: console });
+ },
+ networkKeys,
tenantCors,
tenantCors,
optionalIdentity,
optionalIdentity,
// verifyUserJwt stays internal: it proves a token is a genuine Network credential,
// verifyUserJwt stays internal: it proves a token is a genuine Network credential,
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
* OpenVibe.Media as a receiver of Network service tokens (plan T2, X-Internal-Key retirement).
* OpenVibe.Media as a receiver of Network service tokens (plan T2, X-Internal-Key retirement).
*
*
* guard(capability) protects an internal route: loopback only (nothing that came through nginx) and a
* guard(capability) protects an internal route: loopback only (nothing that came through nginx) and a
- * Network service token holding `capability` for audience openvibe.media, verified with the same key
+ * Network service token holding `capability` for audience openvibe.media, verified with the same keys
* Media verifies user JWTs with (server/auth.js verifyServiceTokenResult; sandbox refused). The token
* Media verifies user JWTs with (server/auth.js verifyServiceTokenResult; sandbox refused). The token
* is judged on its own: a Bearer is never downgraded to another credential, and nothing else opens the
* is judged on its own: a Bearer is never downgraded to another credential, and nothing else opens the
* route (the X-Internal-Key path is retired).
* route (the X-Internal-Key path is retired).
@@ -17,16 +17,19 @@ const isLocal = (req) => /^(::1|::ffff:127\.|127\.)/.test(String(req.socket.remo
@@ -17,16 +17,19 @@ const isLocal = (req) => /^(::1|::ffff:127\.|127\.)/.test(String(req.socket.remo
function guard(capability) {
function guard(capability) {
return function mediaServiceGuard(req, res, next) {
return function mediaServiceGuard(req, res, next) {
+ check(req, res, next).catch(next);
+ };
+ async function check(req, res, next) {
if (viaProxy(req) || !isLocal(req)) return res.status(404).json({ error: 'Not found' });
if (viaProxy(req) || !isLocal(req)) return res.status(404).json({ error: 'Not found' });
const bearer = String(req.headers.authorization || '');
const bearer = String(req.headers.authorization || '');
if (!bearer.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required' });
if (!bearer.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required' });
- const r = auth.verifyServiceTokenResult(bearer.slice(7).trim());
+ const r = await auth.verifyServiceTokenResult(bearer.slice(7).trim());
if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason });
if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason });
const c = capabilities.check(r.claims, capability);
const c = capabilities.check(r.claims, capability);
if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason });
if (!c.allowed) return http.sendProblem(res, 403, c.code || 'capability.denied', { detail: c.reason });
req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
next();
next();
- };
+ }
}
}
module.exports = { guard };
module.exports = { guard };
@@ -35,6 +35,7 @@
@@ -35,6 +35,7 @@
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+const { verifyUserToken } = require('openvibe-sdk/auth');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
@@ -45,59 +46,36 @@ const NEXT_COOKIE = 'ov_oauth_next';
@@ -45,59 +46,36 @@ const NEXT_COOKIE = 'ov_oauth_next';
const SILENT_COOKIE = 'ov_oauth_silent';
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 and refresh grants) through OpenVibeAuthClient, and
+ * OFFLINE session verification with openvibe-sdk/auth verifyUserToken against the Network keys server/auth.js holds
+ * (loaded at boot, never in a drill). A typed token (a FedCM assertion, a realtime ticket, an export token) or a
+ * service principal is never a session here.
*/
*/
function createAuthClient(config) {
function createAuthClient(config) {
const client = new OpenVibeAuthClient({
const client = new OpenVibeAuthClient({
clientId: config.oauth.clientId,
clientId: config.oauth.clientId,
clientSecret: config.oauth.clientSecret,
clientSecret: config.oauth.clientSecret,
redirectUri: config.oauth.redirectUri,
redirectUri: config.oauth.redirectUri,
- publicKey: null, // filled by ensureKey()
+ publicKey: null, // sessions are verified below, never by this client
authBase: config.networkUrl,
authBase: config.networkUrl,
internalBase: config.networkInternalUrl,
internalBase: config.networkInternalUrl,
});
});
- let lastFetch = 0;
- async function ensureKey() {
- if (client.publicKey) return client.publicKey;
- // 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();
- 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;
- }
-
/** Offline JWT verification. Returns decoded claims or null. */
/** Offline JWT verification. Returns decoded claims or null. */
async function verify(token) {
async function verify(token) {
if (!token) return null;
if (!token) return null;
- await ensureKey();
- const claims = await client.verifyToken(token);
+ let claims;
+ try {
+ claims = await verifyUserToken(String(token), { ...require('./auth').networkKeys().verifyOptions, issuer: config.networkUrl });
+ } catch {
+ return null;
+ }
// Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).
// Signed out everywhere / password changed / banned since this token was issued (WS-B task 4).
- if (claims && require('./revocations').isRevoked(claims)) return null;
+ if (require('./revocations').isRevoked(claims)) return null;
return claims;
return claims;
}
}
- // Warm the key cache at boot (non-fatal if the Network is down). A restore drill asks Network
- // for nothing (MEDIA_DRILL; its /auth routes answer 503).
- if (!require('./drill').enabled) ensureKey().catch(() => {});
-
- return { client, ensureKey, verify };
+ return { client, verify };
}
}
/** Token from Authorization header or the ov_token cookie. */
/** Token from Authorization header or the ov_token cookie. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+// Media's Network keys (server/auth.js, openvibe-sdk/auth createNetworkKeys): the API, the internal guard and the
+// site sign-in (server/user-auth.js) verify with the same keys, loaded from Network's JWKS at boot and following a
+// rotation on an unknown kid. The site sign-in never takes a typed token (a FedCM assertion, a realtime ticket, an
+// export token) or a service principal for a session, whoever signed it.
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+
+const pair = () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+const K1 = pair(), K2 = pair(), ROGUE = pair();
+const jwkOf = (k, kid) => ({ ...k.publicKey.export({ format: 'jwk' }), alg: 'RS256', use: 'sig', kid });
+let published = [jwkOf(K1, 'k1')];
+let fetches = 0;
+const network = http.createServer((req, res) => {
+ if (req.url !== '/api/.well-known/jwks') { res.statusCode = 404; return res.end(); }
+ fetches++;
+ res.setHeader('content-type', 'application/json');
+ res.end(JSON.stringify({ keys: published }));
+});
+
+(async () => {
+ await new Promise((r) => network.listen(0, '127.0.0.1', r));
+ const ISS = `http://127.0.0.1:${network.address().port}`;
+ process.env.OV_NETWORK_URL = ISS;
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+ const warn = console.warn; const log = console.log;
+ console.warn = () => {}; console.log = () => {};
+ const express = require('express');
+ const auth = require('../server/auth');
+ const userAuth = require('../server/user-auth');
+ const { guard } = require('../server/service-guard');
+
+ const now = () => Math.floor(Date.now() / 1000);
+ const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+ const sign = (k, kid, claims) => {
+ const input = `${enc({ alg: 'RS256', typ: 'JWT', kid })}.${enc(claims)}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), k.privateKey).toString('base64url')}`;
+ };
+ const session = (extra = {}) => ({ sub: 7, subject_id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', username: 'x', role: 'user', iss: ISS, aud: ['openvibe.network', 'openvibe.media'], iat: now(), exp: now() + 600, ...extra });
+ const service = (cap) => ({ iss: ISS, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.media'], cap, ns: [], iat: now(), exp: now() + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });
+ const site = userAuth.createAuthClient({ networkUrl: ISS, networkInternalUrl: ISS, oauth: { clientId: 'media', clientSecret: '', redirectUri: 'https://media.test/auth/callback' } });
+
+ const app = express();
+ app.post('/internal/probe', guard('media.avatar.ingest'), (req, res) => res.json({ sub: req.principal.sub }));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const probe = (token) => fetch(`http://127.0.0.1:${server.address().port}/internal/probe`, { method: 'POST', headers: { authorization: `Bearer ${token}` } });
+
+ try {
+ assert.strictEqual(auth.jwksLoaded(), false, 'nothing fetched at module load');
+ auth.startJwksRefresh();
+ for (let i = 0; i < 100 && !auth.jwksLoaded(); i++) await new Promise((r) => setTimeout(r, 10));
+ assert.strictEqual(auth.jwksLoaded(), true, 'loaded at boot');
+
+ const claims = await site.verify(sign(K1, 'k1', session()));
+ assert.ok(claims && claims.subject_id === 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', 'a session signs in on the site');
+ for (const [what, extra] of [['a FedCM assertion', { typ: 'fedcm' }], ['a realtime ticket', { typ: 'realtime', purpose: 'realtime' }],
+ ['an export token', { purpose: 'export' }], ['a service principal', { sub: 'svc:live', actor_type: 'service' }]]) {
+ assert.strictEqual(await site.verify(sign(K1, 'k1', session(extra))), null, `${what} is not a session on the site`);
+ }
+ assert.strictEqual(await site.verify(sign(ROGUE, 'k1', session())), null, 'a key Network never published');
+
+ const before = fetches;
+ published = [jwkOf(K2, 'k2'), jwkOf(K1, 'k1')];
+ let r = await probe(sign(K2, 'k2', service(['media.avatar.ingest'])));
+ assert.strictEqual(r.status, 200, 'the guard follows a rotation');
+ assert.strictEqual(fetches, before + 1, 'after one refetch');
+ assert.ok(await site.verify(sign(K2, 'k2', session())), 'and so does the site sign-in');
+ r = await probe(sign(K2, 'k2', service([])));
+ assert.strictEqual(r.status, 403);
+ r = await probe(sign(ROGUE, 'k2', service(['media.avatar.ingest'])));
+ assert.strictEqual(r.status, 401, 'a forged service token');
+ r = await probe(sign(K2, 'k2', session()));
+ assert.strictEqual(r.status, 401, 'a session token is not a service token');
+ } finally {
+ auth.stopJwksRefresh();
+ server.close();
+ network.close();
+ console.warn = warn; console.log = log;
+ }
+ console.log('network-keys: all checks passed');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -29,9 +29,8 @@ const https = require('https');
@@ -29,9 +29,8 @@ const https = require('https');
// ── Inventory: every outbound call in server/, and whose address it uses ──
// ── Inventory: every outbound call in server/, and whose address it uses ──
const OUTBOUND = {
const OUTBOUND = {
'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below',
'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below',
- 'server/auth.js': 'Network JWKS (OV_NETWORK_URL)',
'server/account-data.js': 'Network internal API (OV_NETWORK_INTERNAL_URL), fixed paths: export parts and deletion confirmations',
'server/account-data.js': 'Network internal API (OV_NETWORK_INTERNAL_URL), fixed paths: export parts and deletion confirmations',
- 'server/user-auth.js': 'Network OAuth and JWKS (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL)',
+ 'server/user-auth.js': 'Network OAuth (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL); the signing keys come from openvibe-sdk/auth',
'server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)',
'server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)',
'server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path',
'server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path',
'server/public/dev-data.js': 'the app\'s internal URL (APP_INTERNAL_URLS), ids and encoded names in the path',
'server/public/dev-data.js': 'the app\'s internal URL (APP_INTERNAL_URLS), ids and encoded names in the path',