openvibers/openvibe.sdk Commits

SDK 0.24.0: openvibe-sdk/auth verifyServiceToken(token, { jwks | publicKey, issuer, audience, contracts }) for services that receive service or app tokens. The SDK picks the key (the kid's, else each; the shared JWKS client or a pinned PEM) and every rule is the service's own pinned openvibe-contracts verifyServiceToken, passed in as contracts, so the SDK carries no copy of the rules to drift. No key is token.unavailable with a fixed reason; the fetch error goes to log. verifyUserToken refuses a typed token (typ or purpose: a realtime ticket, a FedCM assertion) as token.not_user. Types, ESM entry, bundle and docs. npm test 45/45 with the test services.

OP OpenVibers committed 29 Sept 2026 · dbf74ddae4 · 12 files, +162 −10


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 63ee0a6..08a1226 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -8,6 +8,17 @@ release may change an API and says so here.
++## 0.24.0 (2026-09-29)
++
++- `openvibe-sdk/auth` `verifyServiceToken(token, { jwks | publicKey, issuer, audience, contracts, acceptSandbox, log })`:
++ a service or app token for a service that receives them. The SDK picks the key (the one the `kid` names, else each,
++ from the shared JWKS client or a pinned PEM); every rule is the service's own pinned openvibe-contracts
++ `serviceAuth.verifyServiceToken`, passed in as `contracts`, so the SDK carries no copy of the rules. No key is
++ `token.unavailable` with the fixed reason 'signing key not loaded yet' (the fetch error goes to `log`). Replaces the
++ hand-written kid-and-contracts loop in AI, Search, Sources, Games and Codes (plan T1).
++- `verifyUserToken` refuses a typed token (`typ` or `purpose` set: Network's realtime ticket, a FedCM assertion) as
++ `token.not_user`: it is never a session, whatever its issuer and audience say. Network's session tokens carry neither.
++
## 0.23.1 (2026-09-29)
- The release version is whole again: `SDK_VERSION` and the browser bundle say 0.23.1 (0.23.0 shipped them at 0.22.0,
ddiff --git a/README.md b/README.md
iindex ed982c8..9884364 100644
---- a/README.md
++++ b/README.md
@@@ -162,7 +162,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| Subpath | Where | What |
|---|---|---|
| `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` |
--| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `verifyUserToken()`, `verifyAppToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` |
++| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` |
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
@@@ -216,6 +216,7 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
- It rejects service-principal tokens (`token.not_user`).
It returns the claims, including `subject_id`.
++- `verifyServiceToken(token, { jwks, issuer, audience, contracts })` checks a service or app token for a service that receives them: the SDK chooses the key (the `kid`'s, else each; the shared JWKS client or a pinned PEM), and every rule is your own pinned openvibe-contracts `serviceAuth.verifyServiceToken` (pass the module as `contracts`). No key answers `{ ok: false, code: 'token.unavailable' }` with a fixed reason, never the internal JWKS URL.
- `verifyAppToken(token, { jwks, issuer, audience, acceptSandbox })` checks a developer app's token the way openvibe-contracts (v0.26 and later) `verifyServiceToken()` does: the same signature and time rules, the audience (required), `actor_type: app`, the claim shape (`sub app:app_…`, `cap`, `jti`, `project_id prj_…`, `env`, optional `on_behalf_of usr_…`), and `env: sandbox` refused as `token.sandbox_refused` unless `acceptSandbox: true`. Opt in only when your service keeps sandbox traffic apart from real data. It does not check capabilities: test `claims.cap` for the one your route performs.
- `exchangeCode({ code, codeVerifier, redirectUri, clientId, clientSecret?, audience?, scope? })`: without `clientSecret` (a public app) the PKCE verifier is required; `app_…` clients must send the verifier and an `audience`. App tokens come back without a `refresh_token`.
- `startAuthorization()` / `buildAuthorizeUrl()` default `scope` to `'profile theme'` only for first-party sign-in (no `audience`). Apps pass `audience` and capability ids. Network refuses `prompt=none` for apps.
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 3a394e3..5440db5 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.23.1, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.24.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -41,7 +41,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.23.1';
++const SDK_VERSION = '0.24.0';
module.exports = {
createClient,
ddiff --git a/esm/auth.mjs b/esm/auth.mjs
iindex 5cdae8f..f4d89f4 100644
---- a/esm/auth.mjs
++++ b/esm/auth.mjs
@@@ -4,7 +4,7 @@ import cjs from '../src/auth/index.js';
export const {
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
-- verifyAppToken, createJwksClient, jwksClient, jwksStatus, exchangeCode, refreshUserToken,
-- createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER,
++ verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, exchangeCode,
++ refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 62d77a6..8852264 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.23.1",
++ "version": "0.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.23.1",
++ "version": "0.24.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex 89a03e6..ad9fe4f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.23.1",
++ "version": "0.24.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/auth/index.js b/src/auth/index.js
iindex d3fcce5..7b09ad0 100644
---- a/src/auth/index.js
++++ b/src/auth/index.js
@@@ -6,8 +6,9 @@
const browser = require('./browser');
const { createServiceTokenClient } = require('./tokens');
const { verifyUserToken, verifyAppToken } = require('./jwt');
++const { verifyServiceToken } = require('./service');
const { createJwksClient, jwksClient, jwksStatus } = require('./jwks');
const { exchangeCode, refreshUserToken } = require('./oauth');
const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('./revocations');
--module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, createJwksClient, jwksClient, jwksStatus, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };
++module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, verifyServiceToken, createJwksClient, jwksClient, jwksStatus, exchangeCode, refreshUserToken, createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER };
ddiff --git a/src/auth/jwt.js b/src/auth/jwt.js
iindex 1d2b708..c12daaa 100644
---- a/src/auth/jwt.js
++++ b/src/auth/jwt.js
@@@ -75,6 +75,9 @@ async function verifyUserToken(token, opts = {}) {
const claims = await verifyJwt(token, opts, 'user');
const principal = ['service', 'app', 'mod'].includes(claims.actor_type) || /^(svc|app|mod):/.test(String(claims.sub || ''));
if (principal && !opts.allowServiceTokens) throw fail('token.not_user', 'a service principal token is not a user token');
++ // A typed token (Network's realtime ticket, an export token: `typ` or `purpose` set) is never a session, whatever its
++ // issuer and audience say (OpenVibe.Events' rule, now every service's).
++ if (claims.typ !== undefined || claims.purpose !== undefined) throw fail('token.not_user', 'a typed token is not a user session');
return claims;
}
ddiff --git a/src/auth/service.js b/src/auth/service.js
nnew file mode 100644
iindex 0000000..bc4c1c3
---- /dev/null
++++ b/src/auth/service.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * verifyServiceToken(token, { jwks (a URL or a JWKS document) | publicKey, issuer, audience, contracts, acceptSandbox, log })
++ * → { ok: true, claims } | { ok: false, code, reason }
++ *
++ * A Network service or app token (identity.service-token-claims@1), for a service that receives them. The SDK
++ * supplies only the KEY: the process-wide JWKS client (./jwks.js: the last good keys through a Network outage,
++ * a rotation honoured on an unknown kid, unknown-kid floods throttled) or a pinned PEM. Every token rule is
++ * openvibe-contracts' serviceAuth.verifyServiceToken from the service's OWN pinned contracts module, passed in as
++ * `contracts` (the SDK carries no copy of the rules, so they cannot drift: the claim schema, issuer, the one
++ * audience, expiry, the sandbox refusal).
++ *
++ * Key choice: the key the token's `kid` names, else every key; the first verdict that is not a bad signature wins.
++ * No key at all answers token.unavailable with a fixed reason: the JWKS client's error names the internal JWKS URL
++ * and the fetch error, so it goes to `log`, never into the answer (callers answer 503 for token.unavailable).
++ */
++const { jwksClient, keysFromJwks } = require('./jwks');
++
++function headerKid(token) {
++ try { return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { return null; }
++}
++
++async function verifyServiceToken(token, { jwks = null, publicKey = null, issuer, audience, contracts, acceptSandbox = false, log = null, fetch: fetchImpl } = {}) {
++ if (!contracts || !contracts.serviceAuth || typeof contracts.serviceAuth.verifyServiceToken !== 'function') {
++ throw new TypeError('verifyServiceToken: pass your pinned openvibe-contracts module as `contracts`');
++ }
++ if (!audience) throw new TypeError('verifyServiceToken: pass the audience your service answers for (openvibe.<service>)');
++ const check = (key) => contracts.serviceAuth.verifyServiceToken(token, { publicKey: key, issuer, audience, acceptSandbox });
++ if (publicKey) return check(publicKey);
++ if (!jwks) throw new TypeError('verifyServiceToken: pass `jwks` (the JWKS URL) or `publicKey`');
++ const kid = headerKid(token);
++ let keys;
++ try {
++ keys = typeof jwks === 'string' ? await jwksClient(jwks, { log, ...(fetchImpl ? { fetch: fetchImpl } : {}) }).keysForKid(kid) : keysFromJwks(jwks);
++ } catch (err) {
++ try { (log && (log.warn || log.error) || (() => {})).call(log, `[openvibe-sdk] service token not verified: ${(err && err.message) || err}`); } catch { /* */ }
++ return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
++ }
++ const byKid = kid ? keys.filter((k) => k.kid === kid) : [];
++ let last = { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
++ for (const k of byKid.length ? byKid : keys) {
++ last = check(k.key);
++ if (last.ok || last.code !== 'token.bad_signature') return last;
++ }
++ return last;
++}
++
++module.exports = { verifyServiceToken, headerKid };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 9414005..fc64979 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.23.1';
++const SDK_VERSION = '0.24.0';
module.exports = {
createClient,
ddiff --git a/test/service-token.test.js b/test/service-token.test.js
nnew file mode 100644
iindex 0000000..ce966fd
---- /dev/null
++++ b/test/service-token.test.js
@@@ -0,0 +1,66 @@
++'use strict';
++/** verifyServiceToken: the SDK's key choice, the service's own contracts rules; typed tokens are never user sessions. */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const http = require('node:http');
++const contracts = require('openvibe-contracts');
++const { run } = require('./helpers');
++const { verifyServiceToken, verifyUserToken } = require('../src/auth');
++
++const pair = () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++const a = pair(), b = pair(), stranger = pair();
++const jwk = (pub, kid) => ({ ...pub.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid });
++const now = () => Math.floor(Date.now() / 1000);
++let n = 0;
++const ISS = 'https://openvibe.network';
++const svc = (over = {}) => ({ iss: ISS, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.media'], cap: ['media.avatar.ingest'], iat: now(), exp: now() + 300, jti: `tok_test_${String(++n).padStart(6, '0')}`, ...over });
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++const sign = (claims, key, kid) => { const input = `${b64({ alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) })}.${b64(claims)}`; return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), key).toString('base64url')}`; };
++const doc = { keys: [jwk(a.publicKey, 'old'), jwk(b.publicKey, 'new')] };
++const opts = (over = {}) => ({ jwks: doc, issuer: ISS, audience: 'openvibe.media', contracts, ...over });
++
++run([
++ ['a service token verifies with the key its kid names (a rotation keeps two keys)', async () => {
++ const r = await verifyServiceToken(sign(svc(), b.privateKey, 'new'), opts());
++ assert.equal(r.ok, true, r.reason); assert.equal(r.claims.sub, 'svc:live');
++ assert.equal((await verifyServiceToken(sign(svc(), a.privateKey, 'old'), opts())).ok, true);
++ assert.equal((await verifyServiceToken(sign(svc(), a.privateKey), opts())).ok, true, 'no kid: each key is tried');
++ const pem = a.publicKey.export({ type: 'spki', format: 'pem' });
++ assert.equal((await verifyServiceToken(sign(svc(), a.privateKey, 'old'), opts({ jwks: null, publicKey: pem }))).ok, true, 'a pinned PEM');
++ }],
++ ['every rule is the contracts module\'s: signature, audience, issuer, sandbox, claim schema', async () => {
++ assert.equal((await verifyServiceToken(sign(svc(), stranger.privateKey, 'new'), opts())).code, 'token.bad_signature');
++ assert.equal((await verifyServiceToken(sign(svc({ aud: ['openvibe.live'] }), b.privateKey, 'new'), opts())).ok, false, 'another audience');
++ assert.equal((await verifyServiceToken(sign(svc({ iss: 'https://evil.example' }), b.privateKey, 'new'), opts())).ok, false, 'another issuer');
++ const sandbox = await verifyServiceToken(sign(svc({ sub: 'app:app_01JAB2C3D4E5F6G7H8J9K0MNPQ', actor_type: 'app', env: 'sandbox', project_id: 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ', ns: ['prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'] }), b.privateKey, 'new'), opts());
++ assert.equal(sandbox.ok, false, 'a sandbox token');
++ assert.equal((await verifyServiceToken(sign(svc({ jti: 'x' }), b.privateKey, 'new'), opts())).ok, false, 'claims outside identity.service-token-claims@1');
++ }],
++ ['no key at all is token.unavailable with a fixed reason (never the internal URL or the fetch error)', async () => {
++ const logs = [];
++ const r = await verifyServiceToken(sign(svc(), b.privateKey, 'new'), opts({ jwks: 'http://127.0.0.1:9/api/.well-known/jwks', log: { warn: (m) => logs.push(m) } }));
++ assert.deepEqual([r.ok, r.code, r.reason], [false, 'token.unavailable', 'signing key not loaded yet']);
++ assert.ok(logs.some((m) => /127\.0\.0\.1:9/.test(m)), 'the real error goes to the log');
++ }],
++ ['a JWKS URL is fetched once through the shared client', async () => {
++ let hits = 0;
++ const srv = http.createServer((req, res) => { hits += 1; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(doc)); });
++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
++ const url = `http://127.0.0.1:${srv.address().port}/api/.well-known/jwks`;
++ try {
++ for (let i = 0; i < 3; i++) assert.equal((await verifyServiceToken(sign(svc(), b.privateKey, 'new'), opts({ jwks: url }))).ok, true);
++ assert.equal(hits, 1);
++ } finally { srv.close(); }
++ }],
++ ['misuse is a TypeError: no contracts module, no audience, no key source', async () => {
++ await assert.rejects(verifyServiceToken('x.y.z', { jwks: doc, audience: 'openvibe.media' }), TypeError);
++ await assert.rejects(verifyServiceToken('x.y.z', { jwks: doc, contracts }), TypeError);
++ await assert.rejects(verifyServiceToken('x.y.z', { contracts, audience: 'openvibe.media' }), TypeError);
++ }],
++ ['a typed token (realtime ticket, FedCM assertion) is never a user session', async () => {
++ const user = (over) => sign({ sub: 42, id: 42, username: 'ana', iss: ISS, aud: ['openvibe.network'], iat: now(), exp: now() + 300, ...over }, a.privateKey, 'old');
++ assert.ok(await verifyUserToken(user({}), { jwks: doc, issuer: ISS, audience: 'openvibe.network' }), 'a session token');
++ await assert.rejects(verifyUserToken(user({ typ: 'fedcm' }), { jwks: doc, issuer: ISS, audience: 'openvibe.network' }), { code: 'token.not_user' });
++ await assert.rejects(verifyUserToken(user({ typ: 'realtime', purpose: 'realtime' }), { jwks: doc, issuer: ISS, audience: 'openvibe.network' }), { code: 'token.not_user' });
++ }],
++]);
ddiff --git a/types/auth.d.ts b/types/auth.d.ts
iindex 37d2855..882aec7 100644
---- a/types/auth.d.ts
++++ b/types/auth.d.ts
@@@ -137,6 +137,28 @@ export interface VerifyAppTokenOptions {
}
export declare function verifyAppToken(token: string, opts: VerifyAppTokenOptions): Promise<AppTokenClaims>;
++/** The subset of openvibe-contracts verifyServiceToken needs (pass the service's own pinned module). */
++export interface ContractsForServiceTokens {
++ serviceAuth: { verifyServiceToken(token: string, opts: { publicKey: unknown; issuer?: string; audience?: string; acceptSandbox?: boolean }): ServiceTokenResult };
++}
++export type ServiceTokenResult = { ok: true; claims: Record<string, unknown> } | { ok: false; code: string; reason: string };
++export interface VerifyServiceTokenOptions {
++ /** A JWKS URL (the process-wide client: last good keys, rotation, backoff) or a JWKS document. */
++ jwks?: string | { keys?: object[]; public_key?: string } | null;
++ /** A pinned PEM instead of a JWKS. */
++ publicKey?: string | object | null;
++ issuer?: string;
++ /** Required: the audience your service answers for (openvibe.<service>). */
++ audience: string;
++ /** Required: your pinned openvibe-contracts module; every token rule is its verifyServiceToken. */
++ contracts: ContractsForServiceTokens;
++ acceptSandbox?: boolean;
++ log?: JwksClientOptions['log'];
++ fetch?: FetchLike;
++}
++/** A service or app token: the SDK picks the key (the kid's, else each), your contracts module applies every rule. */
++export declare function verifyServiceToken(token: string, opts: VerifyServiceTokenOptions): Promise<ServiceTokenResult>;
++
export interface UserTokenResponse {
access_token: string;
/** Absent for developer-app tokens: sign in again when they expire. */