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.
Changes
@@@ -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,@@@ -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.@@@ -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,@@@ -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;@@@ -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",@@@ -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",@@@ -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 };@@@ -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;}nnew file mode 100644@@@ -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 };@@@ -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,nnew file mode 100644@@@ -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' });++ }],++]);@@@ -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. */
@@ -8,6 +8,17 @@ release may change an API and says so here.
@@ -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)
## 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,
- 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,
@@ -162,7 +162,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -162,7 +162,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| Subpath | Where | What |
| Subpath | Where | What |
|---|---|---|
|---|---|---|
| `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` |
| `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/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/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 |
| `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
@@ -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 rejects service-principal tokens (`token.not_user`).
It returns the claims, including `subject_id`.
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.
- `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`.
- `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.
- `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.
@@ -1,4 +1,4 @@
@@ -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
// 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.
// (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 */
/* eslint-disable */
@@ -41,7 +41,7 @@ const { paginate, offsetPager } = __load(7);
@@ -41,7 +41,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
@@ -4,7 +4,7 @@ import cjs from '../src/auth/index.js';
@@ -4,7 +4,7 @@ import cjs from '../src/auth/index.js';
export const {
export const {
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
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;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.23.1",
+ "version": "0.24.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.23.1",
+ "version": "0.24.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -6,8 +6,9 @@
@@ -6,8 +6,9 @@
const browser = require('./browser');
const browser = require('./browser');
const { createServiceTokenClient } = require('./tokens');
const { createServiceTokenClient } = require('./tokens');
const { verifyUserToken, verifyAppToken } = require('./jwt');
const { verifyUserToken, verifyAppToken } = require('./jwt');
+const { verifyServiceToken } = require('./service');
const { createJwksClient, jwksClient, jwksStatus } = require('./jwks');
const { createJwksClient, jwksClient, jwksStatus } = require('./jwks');
const { exchangeCode, refreshUserToken } = require('./oauth');
const { exchangeCode, refreshUserToken } = require('./oauth');
const { createRevocationStore, createPgRevocationStore, revocationSchema, TOKEN_VALID_AFTER } = require('./revocations');
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 };
@@ -75,6 +75,9 @@ async function verifyUserToken(token, opts = {}) {
@@ -75,6 +75,9 @@ async function verifyUserToken(token, opts = {}) {
const claims = await verifyJwt(token, opts, 'user');
const claims = await verifyJwt(token, opts, 'user');
const principal = ['service', 'app', 'mod'].includes(claims.actor_type) || /^(svc|app|mod):/.test(String(claims.sub || ''));
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');
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;
return claims;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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 };
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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' });
+ }],
+]);
@@ -137,6 +137,28 @@ export interface VerifyAppTokenOptions {
@@ -137,6 +137,28 @@ export interface VerifyAppTokenOptions {
}
}
export declare function verifyAppToken(token: string, opts: VerifyAppTokenOptions): Promise<AppTokenClaims>;
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 {
export interface UserTokenResponse {
access_token: string;
access_token: string;
/** Absent for developer-app tokens: sign in again when they expire. */
/** Absent for developer-app tokens: sign in again when they expire. */