openvibers/openvibe.sdk Commits

v0.9.0: openvibe-sdk/auth createRevocationStore — per-person token cutoffs from network.user.token_valid_after (Contracts 0.39.0): apply(event), isRevoked(claims) with Network's rule iat * 1000 < valid_after, only forward, source network only; SQLite-backed or in memory

OP OpenVibers committed 24 Sept 2026 · 5d9576f0e7 · 9 files, +146 −5


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d39233c..a5adf89 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,10 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.9.0 (2026-09-24)
++
++`openvibe-sdk/auth` **`createRevocationStore(db?)`**: token cutoffs from Network's `network.user.token_valid_after` (Contracts 0.39.0), so a service refuses a person's older tokens as soon as they sign out everywhere, change their password or are banned. `apply(event)`, `isRevoked(claims)`, `cutoffFor(subject)`, `record()`; SQLite-backed or in memory. Additive.
++
## 0.8.0 (2026-09-24)
`openvibe-sdk/frame`: **the OpenVibe Frame**, the navbar, footer and themes every OpenVibe site sits in, for any app, first-party or not. It covers:
ddiff --git a/README.md b/README.md
iindex b8319e1..f0d2829 100644
---- a/README.md
++++ b/README.md
@@@ -209,6 +209,7 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
- `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.
- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.
- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.
++- `createRevocationStore(db?)` keeps each person's token cutoff from Network's `network.user.token_valid_after` event (Contracts 0.39.0: sign out everywhere, a password change or reset, a ban, staff ending someone's sessions). Subscribe to the event, pass each delivery to `store.apply(event)` (`'revoked'` means the cutoff moved: close that person's sockets, drop their cached sessions), and after `verifyUserToken()` refuse the token when `store.isRevoked(claims)`. That is Network's rule, `iat * 1000 < valid_after`. With a better-sqlite3 handle the cutoffs survive restarts; the store only ever moves a cutoff forward and ignores anything not from `network`.
### Receiving event webhooks
ddiff --git a/esm/auth.mjs b/esm/auth.mjs
iindex 4c71eef..ff69a70 100644
---- a/esm/auth.mjs
++++ b/esm/auth.mjs
@@@ -4,6 +4,6 @@ import cjs from '../src/auth/index.js';
export const {
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
-- verifyAppToken, exchangeCode, refreshUserToken,
++ verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 6239263..752bf28 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.8.0",
++ "version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.8.0",
++ "version": "0.9.0",
"license": "MIT",
"devDependencies": {
"better-sqlite3": "^11.10.0",
ddiff --git a/package.json b/package.json
iindex 066be60..c8b75dc 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.8.0",
++ "version": "0.9.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs), VIP memberships and developer projects, 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 4ae4975..6fc12cb 100644
---- a/src/auth/index.js
++++ b/src/auth/index.js
@@@ -7,5 +7,6 @@ const browser = require('./browser');
const { createServiceTokenClient } = require('./tokens');
const { verifyUserToken, verifyAppToken } = require('./jwt');
const { exchangeCode, refreshUserToken } = require('./oauth');
++const { createRevocationStore, TOKEN_VALID_AFTER } = require('./revocations');
--module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken };
++module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken, createRevocationStore, TOKEN_VALID_AFTER };
ddiff --git a/src/auth/revocations.js b/src/auth/revocations.js
nnew file mode 100644
iindex 0000000..62da832
---- /dev/null
++++ b/src/auth/revocations.js
@@@ -0,0 +1,84 @@
++'use strict';
++/**
++ * Per-person token cutoffs from OpenVibe.Network (Contracts 0.39.0 network.user.token_valid_after).
++ *
++ * When someone signs out everywhere, changes or resets their password, is banned or has their sessions
++ * ended by staff, Network moves their cutoff and publishes it. A service that accepts Network user
++ * tokens subscribes to the event, applies it here and asks isRevoked() after verifying a token:
++ * Network's rule is `iat * 1000 < valid_after` → refuse.
++ *
++ * const cutoffs = createRevocationStore(db) // a better-sqlite3 handle; omit for memory only
++ * cutoffs.apply(envelope) → 'revoked' | 'unchanged' | 'ignored:<why>' (idempotent: keeps the later cutoff)
++ * cutoffs.isRevoked(claims) → true when claims.iat is before claims.subject_id's cutoff
++ * cutoffs.cutoffFor(subject) → ms (0 = none)
++ * cutoffs.record(subject, validAfterMs, reason?) → true when it moved forward
++ *
++ * apply() only acts on source network and a well-formed payload, so a forged or foreign envelope that
++ * slipped past a signature check still changes nothing. Closing sockets and dropping caches is the
++ * caller's: apply() returns 'revoked' when the cutoff moved.
++ */
++
++const EVENT_TYPE = 'network.user.token_valid_after';
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TABLE_RE = /^[a-z_][a-z0-9_]{0,62}$/;
++
++function createRevocationStore(db = null, { table = 'ov_token_revocations', now = () => Date.now(), maxCache = 50000 } = {}) {
++ if (!TABLE_RE.test(table)) throw new TypeError(`createRevocationStore: bad table name ${table}`);
++ const cache = new Map();
++ let q = null;
++ function ready() {
++ if (!db || q) return q;
++ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (
++ subject_id TEXT PRIMARY KEY,
++ valid_after_ms INTEGER NOT NULL,
++ reason TEXT,
++ updated_at INTEGER NOT NULL
++ )`);
++ q = {
++ get: db.prepare(`SELECT valid_after_ms FROM ${table} WHERE subject_id = ?`),
++ put: db.prepare(`INSERT INTO ${table} (subject_id, valid_after_ms, reason, updated_at) VALUES (?, ?, ?, ?)
++ ON CONFLICT(subject_id) DO UPDATE SET valid_after_ms = excluded.valid_after_ms, reason = excluded.reason, updated_at = excluded.updated_at
++ WHERE excluded.valid_after_ms > ${table}.valid_after_ms`),
++ };
++ return q;
++ }
++
++ function cutoffFor(subject) {
++ if (!subject) return 0;
++ if (cache.has(subject)) return cache.get(subject);
++ const s = ready();
++ const row = s ? s.get.get(subject) : null;
++ const ms = row ? Number(row.valid_after_ms) || 0 : 0;
++ if (cache.size >= maxCache) cache.clear();
++ cache.set(subject, ms);
++ return ms;
++ }
++
++ function record(subject, validAfterMs, reason = null) {
++ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(validAfterMs)) return false;
++ if (!(validAfterMs > cutoffFor(subject))) return false;
++ const s = ready();
++ if (s) s.put.run(subject, validAfterMs, reason, now());
++ cache.set(subject, validAfterMs);
++ return true;
++ }
++
++ function apply(event) {
++ if (!event || event.event_type !== EVENT_TYPE) return 'ignored:type';
++ if (event.source !== 'network') return 'ignored:source';
++ const p = event.payload && typeof event.payload === 'object' ? event.payload : {};
++ const subject = p.subject && p.subject.type === 'user' ? p.subject.id : null;
++ const ms = Date.parse(p.valid_after);
++ if (!SUBJECT_RE.test(String(subject || '')) || !Number.isFinite(ms)) return 'ignored:payload';
++ return record(subject, ms, typeof p.reason === 'string' ? p.reason.slice(0, 40) : null) ? 'revoked' : 'unchanged';
++ }
++
++ function isRevoked(claims) {
++ if (!claims || typeof claims.iat !== 'number' || typeof claims.subject_id !== 'string') return false;
++ return claims.iat * 1000 < cutoffFor(claims.subject_id);
++ }
++
++ return { apply, record, isRevoked, cutoffFor, EVENT_TYPE };
++}
++
++module.exports = { createRevocationStore, TOKEN_VALID_AFTER: EVENT_TYPE };
ddiff --git a/test/revocations.test.js b/test/revocations.test.js
nnew file mode 100644
iindex 0000000..d6a5690
---- /dev/null
++++ b/test/revocations.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++// openvibe-sdk/auth createRevocationStore: network.user.token_valid_after cutoffs (Contracts 0.39.0).
++const assert = require('assert');
++const Database = require('better-sqlite3');
++const { createRevocationStore, TOKEN_VALID_AFTER } = require('../src/auth');
++
++const A = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++const B = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4';
++const ev = (subject, iso, over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_type: TOKEN_VALID_AFTER, version: 1, source: 'network', payload: { subject: { type: 'user', id: subject }, valid_after: iso, reason: 'signed_out_everywhere' }, ...over });
++const T = Date.parse('2026-09-24T20:00:00Z');
++
++const db = new Database(':memory:');
++const s = createRevocationStore(db);
++assert.strictEqual(s.isRevoked({ subject_id: A, iat: T / 1000 - 10 }), false, 'nothing known');
++assert.strictEqual(s.apply(ev(A, new Date(T).toISOString())), 'revoked');
++assert.strictEqual(s.isRevoked({ subject_id: A, iat: T / 1000 - 1 }), true, 'issued before: refused');
++assert.strictEqual(s.isRevoked({ subject_id: A, iat: T / 1000 }), false, 'issued in the cutoff second: accepted (Network rule)');
++assert.strictEqual(s.isRevoked({ subject_id: B, iat: T / 1000 - 1 }), false, 'someone else');
++assert.strictEqual(s.isRevoked({ subject_id: A }), false, 'no iat, nothing to compare');
++assert.strictEqual(s.apply(ev(A, new Date(T - 60000).toISOString())), 'unchanged', 'never moves back');
++assert.strictEqual(s.apply(ev(A, new Date(T).toISOString())), 'unchanged', 'a redelivery');
++assert.strictEqual(s.apply(ev(A, new Date(T + 1).toISOString(), { source: 'live' })), 'ignored:source');
++assert.strictEqual(s.apply(ev('gst_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', new Date(T).toISOString())), 'ignored:payload');
++assert.strictEqual(s.apply({ ...ev(A, 'soon') }), 'ignored:payload');
++assert.strictEqual(s.apply({ event_type: 'network.module.updated' }), 'ignored:type');
++assert.strictEqual(s.cutoffFor(A), T);
++
++// Survives a restart (a new store on the same database); memory-only works without one.
++assert.strictEqual(createRevocationStore(db).cutoffFor(A), T);
++const mem = createRevocationStore();
++assert.strictEqual(mem.apply(ev(B, new Date(T).toISOString())), 'revoked');
++assert.strictEqual(mem.isRevoked({ subject_id: B, iat: T / 1000 - 5 }), true);
++assert.throws(() => createRevocationStore(db, { table: 'x; DROP TABLE y' }), /bad table name/);
++console.log('revocations: all checks passed');
ddiff --git a/types/auth.d.ts b/types/auth.d.ts
iindex c73cbd3..51366de 100644
---- a/types/auth.d.ts
++++ b/types/auth.d.ts
@@@ -117,3 +117,20 @@ export interface ExchangeCodeOptions {
}
export declare function exchangeCode(opts: ExchangeCodeOptions): Promise<UserTokenResponse>;
export declare function refreshUserToken(opts: { refreshToken: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;
++
++/** Per-person token cutoffs from network.user.token_valid_after (Contracts 0.39.0). */
++export interface RevocationStore {
++ /** Apply an envelope: 'revoked' when the cutoff moved, 'unchanged', or 'ignored:<why>'. */
++ apply(event: unknown): 'revoked' | 'unchanged' | `ignored:${string}`;
++ /** Keep the later cutoff; true when it moved forward. */
++ record(subject: string, validAfterMs: number, reason?: string | null): boolean;
++ /** True when claims.iat (seconds) is before claims.subject_id's cutoff. */
++ isRevoked(claims: { iat?: number; subject_id?: string } | null | undefined): boolean;
++ /** The cutoff in ms, 0 when none. */
++ cutoffFor(subject: string): number;
++ readonly EVENT_TYPE: 'network.user.token_valid_after';
++}
++export interface RevocationStoreOptions { table?: string; now?: () => number; maxCache?: number }
++/** A better-sqlite3 handle keeps cutoffs across restarts; without one they live in memory. */
++export declare function createRevocationStore(db?: unknown, opts?: RevocationStoreOptions): RevocationStore;
++export declare const TOKEN_VALID_AFTER: 'network.user.token_valid_after';