openvibers/openvibe.sdk Commits
0.73.0: sso recovers once, explains itself, and can be checked (#100)
Changes
@@@ -3,7 +3,26 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.--## 0.72.0 (unreleased)++## 0.73.0 (2026-10-30)++++- **`openvibe-sdk/sso`: a sign-in that recovers, explains itself and can be checked.**++ - A lost or expired flow (no or another state cookie) and a code the Network no longer accepts (`invalid_grant`) start++ the sign-in once more by themselves. A two-minute `ov_oauth_retry` cookie keeps it from looping, and neither ever++ sets a session.++ - A second failure, a client the Network refuses (`invalid_client`) and a Network that does not answer show a small,++ self-contained page in plain words, with "Try again" and "Back to <site>", in place of a bare line of text.++ - A refused client is logged as a configuration error that names `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET`.++ - `GET /auth/health` and `checkClient()`: a token request with a code that is never issued. `invalid_grant` means the++ client is fine; `invalid_client` that its id or secret is wrong (503). The answer is cached 10 minutes.++ - Why: OpenVibe.Cam ran for a day on the placeholder secret from its `.env.example`. Every sign-in ended on "The++ Network refused the sign-in code", and nothing checked.++ - `siteName` option for the pages.++ - `legacyCookies: { access, refresh }` moves a site off its own sign-in code without signing anyone out. Its old++ cookies are read as the session until the next sign-in or refresh writes `ov_token`/`ov_refresh` and clears them;++ logout clears both. `httpOnlyAccess: true` keeps the access token httpOnly, as those sites did (their navbar asks++ `/auth/me`).++++## 0.72.0 (2026-10-29)- **`openvibe-sdk/vision`** (VIS-5, plan §5.8b): typed browser and server calls for Vision's image tools, private search, face privacy and consent APIs, and MCP discovery. The service enforces token grants and consent; the client preserves problem errors and binary blur results.@@@ -2,16 +2,16 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.72.0 (prepared for release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.++**Status:** alpha, v0.73.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.--After v0.72.0 is tagged:++After v0.73.0 is tagged:```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.72.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0```It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.72.0",++ "version": "0.73.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.72.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.73.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, bot, search, bench, vision, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -45,7 +45,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.72.0';++const SDK_VERSION = '0.73.0';module.exports = {createClient,@@@ -722,6 +722,7 @@"claimsToUser": "function","createSsoClient": "function","decodeJwtPayload": "function",++ "failurePage": "function","fedcmNonceMatches": "function","parseCookies": "function","sanitizeNext": "function",@@@ -3,6 +3,6 @@ import cjs from '../src/sso/index.js';export const {createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser,-- parseCookies, COOKIES,++ parseCookies, failurePage, COOKIES,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.72.0",++ "version": "0.73.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.72.0",++ "version": "0.73.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.72.0",++ "version": "0.73.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, 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",@@@ -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.72.0';++const SDK_VERSION = '0.73.0';module.exports = {createClient,@@@ -25,6 +25,18 @@* GET /logout → cookies cleared, the refresh token revoked (best effort), hint=guest* GET /me → the verified session's profile* POST /refresh → rotate the tokens with the refresh token++ * Moving a site from its own sign-in code (0.73.0): legacyCookies: { access: ['cam_at'], refresh: [{ name: 'cam_rt',++ * path: '/auth' }] } reads the old cookies as the session until the next sign-in or refresh writes ov_token/ov_refresh++ * and clears them, so nobody is signed out by the move; logout clears both.++ * GET /health → { ok, client: 'ok' | 'invalid' | 'unknown' }: does the Network accept this site's OAuth client?++ * (a token request with a code that cannot exist: invalid_grant means the client is fine,++ * invalid_client means its id or secret is wrong; cached 10 minutes; 503 only for 'invalid')++ *++ * When a sign-in cannot finish (0.73.0): a lost or expired flow (the state cookie is gone or does not match) and a code++ * the Network no longer accepts (invalid_grant: used twice, or too old) start the sign-in once more by themselves, with++ * a two-minute ov_oauth_retry cookie so it never loops; neither sets a session. A second failure, a client the Network++ * refuses (invalid_client: a configuration error, logged as one with what to check) and a Network that does not answer++ * show a small page in plain words with "Try again" and "Back to <site>", never a bare line of text.* Cookies (host-only): ov_token (the access JWT, SameSite=Lax, readable by the shared navbar), ov_refresh (httpOnly,* Path=/auth), ov_sso_hint ('account' / 'guest'), and the short-lived flow cookies on /auth.*@@@ -43,6 +55,9 @@ const NEXT_COOKIE = 'ov_oauth_next';const SILENT_COOKIE = 'ov_oauth_silent';const VERIFIER_COOKIE = 'ov_oauth_verifier';const FLOW_COOKIES = [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE, VERIFIER_COOKIE];++const RETRY_COOKIE = 'ov_oauth_retry';++// A code no sign-in can ever have: the health check presents it and reads which way the Network refuses it.++const HEALTH_CODE = 'ov_health_check_never_issued';const trim = (u) => String(u || '').replace(/\/+$/, '');@@@ -117,6 +132,24 @@ function sameString(a, b) {return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);}++const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);++++/**++ * The page a sign-in that cannot finish shows: what happened in plain words, "Try again" (a fresh sign-in back to where++ * the person was going) and the way back. Self-contained (no script, no font, no request): the theme's colours when the++ * site's page variables are not there, light or dark as the device is.++ */++function failurePage({ siteName, title, text, retryHref, backHref }) {++ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><meta name="color-scheme" content="light dark"><title>${escapeHtml(title)} · ${escapeHtml(siteName)}</title>`++ + '<style>:root{--b:#0a0f1c;--c:#131c2e;--t:#e6edf7;--m:#96a7c2;--a:#3b82f6;--l:#1f2d47}@media (prefers-color-scheme:light){:root{--b:#f6f7fb;--c:#fff;--t:#0f172a;--m:#475569;--a:#1d4ed8;--l:#e3e7ef}}'++ + 'body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--b);color:var(--t);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}'++ + 'main{box-sizing:border-box;width:min(440px,calc(100vw - 32px));padding:28px 24px;border:1px solid var(--l);border-radius:18px;background:var(--c)}'++ + 'h1{margin:0 0 8px;font-size:1.3rem}p{margin:0 0 20px;color:var(--m)}nav{display:flex;flex-wrap:wrap;gap:10px}'++ + 'a{display:inline-flex;align-items:center;min-height:44px;padding:0 18px;border-radius:999px;font-weight:650;text-decoration:none}'++ + 'a.go{background:var(--a);color:#fff}a.back{color:var(--t);border:1px solid var(--l)}a:focus-visible{outline:3px solid var(--a);outline-offset:2px}</style></head>'++ + `<body><main><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p><nav><a class="go" href="${escapeHtml(retryHref)}">Try again</a><a class="back" href="${escapeHtml(backHref)}">Back to ${escapeHtml(siteName)}</a></nav></main></body></html>`;++}++function createSsoClient(opts = {}) {const site = String(opts.site || 'site');const baseUrl = trim(opts.baseUrl);@@@ -133,6 +166,13 @@ function createSsoClient(opts = {}) {const accessMaxAgeMs = opts.accessMaxAgeMs || 24 * 3600e3;if (!baseUrl || !opts.clientId) throw new TypeError('createSsoClient: baseUrl and clientId are required');const next = (value) => sanitizeNext(value, { baseUrl, networkUrl });++ const siteName = String(opts.siteName || `OpenVibe.${site.charAt(0).toUpperCase()}${site.slice(1)}`);++ // A site's own cookie names before it moved here: read as the session, cleared when the SDK writes its own.++ const legacyList = (l) => (Array.isArray(l) ? l : []).map((x) => (typeof x === 'string' ? { name: x, path: '/' } : { name: String(x.name), path: x.path || '/' })).filter((x) => /^[A-Za-z0-9_-]{1,64}$/.test(x.name));++ const legacy = { access: legacyList(opts.legacyCookies && opts.legacyCookies.access), refresh: legacyList(opts.legacyCookies && opts.legacyCookies.refresh) };++ const readCookie = (req, name, old) => { const c = parseCookies(req); if (c[name]) return c[name]; for (const l of old) if (c[l.name]) return c[l.name]; return null; };++ const readAccess = (req) => readCookie(req, ACCESS_COOKIE, legacy.access);++ const readRefresh = (req) => readCookie(req, REFRESH_COOKIE, legacy.refresh);/** The verified claims of a session token, or null. Never throws. */async function verify(token) {@@@ -149,25 +189,47 @@ function createSsoClient(opts = {}) {function extractToken(req) {const h = String((req.headers && req.headers.authorization) || '');if (h.startsWith('Bearer ')) return h.slice(7).trim() || null;-- return parseCookies(req)[ACCESS_COOKIE] || null;++ return readAccess(req);}const cookie = (maxAge, { httpOnly = true, path = '/' } = {}) => ({ sameSite: 'lax', secure, httpOnly, path, ...(maxAge ? { maxAge } : {}) });-- const accessOpts = () => cookie(accessMaxAgeMs, { httpOnly: false }); // the shared navbar reads it++ // The shared navbar reads ov_token unless the site asks otherwise (httpOnlyAccess: its navbar asks /auth/me instead,++ // as the sites that kept their own sign-in code did, so no script can read the token).++ const accessOpts = () => cookie(accessMaxAgeMs, { httpOnly: !!opts.httpOnlyAccess });const refreshOpts = () => cookie(30 * 24 * 3600e3, { path: '/auth' });const flowOpts = () => cookie(10 * 60e3, { path: '/auth' });const hintOpts = () => cookie(365 * 24 * 3600e3, { httpOnly: false });++ const clearLegacy = (res) => { for (const l of [...legacy.access, ...legacy.refresh]) res.clearCookie(l.name, { path: l.path }); };function setSession(res, accessToken, refreshToken) {++ clearLegacy(res);res.cookie(ACCESS_COOKIE, accessToken, accessOpts());if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshOpts());res.cookie(HINT_COOKIE, 'account', hintOpts());}function clearSession(res) {++ clearLegacy(res);res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });}const clearFlow = (res) => { for (const c of FLOW_COOKIES) res.clearCookie(c, { path: '/auth' }); };++ const retryOpts = () => cookie(2 * 60e3, { path: '/auth' });++ const loginHref = (target) => `/auth/login${target && target !== '/' ? `?next=${encodeURIComponent(target)}` : ''}`;++ /** A sign-in that can recover starts once more by itself; the second time it shows the page. Never sets a session. */++ function retryOrExplain(req, res, target, page) {++ if (parseCookies(req)[RETRY_COOKIE] !== '1') {++ res.cookie(RETRY_COOKIE, '1', retryOpts());++ return res.redirect(loginHref(target));++ }++ res.clearCookie(RETRY_COOKIE, { path: '/auth' });++ return explain(res, 400, target, page);++ }++ function explain(res, status, target, { title, text }) {++ if (typeof res.type === 'function') res.type('html');++ if (typeof res.set === 'function') res.set('Cache-Control', 'no-store');++ return res.status(status).send(failurePage({ siteName, title, text, retryHref: loginHref(target), backHref: target || '/' }));++ }++ const SIGN_IN_AGAIN = { title: 'Sign-in didn\'t finish', text: 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.' };/** A token grant at the Network: the internal URL first, the public one if it cannot be reached. */async function tokenGrant(body) {@@@ -223,7 +285,7 @@ function createSsoClient(opts = {}) {const q = req.query || {};const silent = !!q.silent && q.silent !== '0';// Already signed in here? A silent round trip would only hand back the session we have.-- if (silent && await verify(parseCookies(req)[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(next(q.next)); }++ if (silent && await verify(readAccess(req))) { clearFlow(res); return res.redirect(next(q.next)); }const { url, state, verifier } = authorizeUrl({ silent, prompt: String(q.prompt || ''), loginHint: q.login_hint });res.cookie(STATE_COOKIE, state, flowOpts());res.cookie(VERIFIER_COOKIE, verifier, flowOpts());@@@ -244,20 +306,28 @@ function createSsoClient(opts = {}) {if (silent || q.error === 'login_required') return res.redirect(withParam(target, 'sso', 'none'));return res.redirect(withParam('/', 'auth_error', String(q.error)));}-- if (!q.code) return res.status(400).send('Missing authorization code');++ if (!q.code) return explain(res, 400, target, SIGN_IN_AGAIN);const expected = c[STATE_COOKIE], verifier = c[VERIFIER_COOKIE];clearFlow(res);// The state cookie is required, not only compared when present: skipping the check without it would let a-- // crafted link sign a visitor into someone else's account.-- if (!sameString(q.state, expected) || !verifier) return res.status(400).send('OAuth state mismatch. Please try signing in again.');++ // crafted link sign a visitor into someone else's account. A mismatch never sets a session; it starts a fresh++ // sign-in of the visitor's own (once), which is what a lost or expired flow cookie needs.++ if (!sameString(q.state, expected) || !verifier) return retryOrExplain(req, res, target, SIGN_IN_AGAIN);try {const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: redirectUri, code: String(q.code), code_verifier: verifier });setSession(res, data.access_token, data.refresh_token);++ res.clearCookie(RETRY_COOKIE, { path: '/auth' });++ health = { client: 'ok', at: Date.now() };return res.redirect(target);} catch (err) {-- log.error(`[${site} auth] code exchange failed: ${err.message}`);-- if (err.status && err.status < 500) return res.redirect(withParam('/', 'auth_error', String(err.error || 'invalid_grant')));-- return res.status(502).send('Sign-in failed: OpenVibe.Network could not be reached. Please try again.');++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {++ health = { client: 'invalid', at: Date.now() };++ log.error(`[${site} auth] the Network refuses this site's OAuth client (${err.error || err.status}): check its client id and secret (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET) against the Network's registration`);++ return explain(res, 500, target, { title: `Sign-in isn't working on ${siteName} right now`, text: 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.' });++ }++ log.warn(`[${site} auth] code exchange failed: ${err.message}`);++ if (err.status && err.status < 500) return retryOrExplain(req, res, target, SIGN_IN_AGAIN);++ return explain(res, 502, target, { title: 'OpenVibe.Network didn\'t answer', text: 'Your account is fine: the sign-in service did not reply in time. Try again in a moment.' });}},@@@ -281,7 +351,7 @@ function createSsoClient(opts = {}) {async logout(req, res) {// Best-effort revocation: the Network's rotating refresh tokens invalidate themselves anyway.-- const refresh = parseCookies(req)[REFRESH_COOKIE];++ const refresh = readRefresh(req);if (refresh) {try {await fetchImpl(`${networkInternalUrl}/oauth/revoke`, {@@@ -305,7 +375,7 @@ function createSsoClient(opts = {}) {},async refresh(req, res) {-- const refresh = parseCookies(req)[REFRESH_COOKIE];++ const refresh = readRefresh(req);if (!refresh) return res.status(401).json({ error: 'No refresh token' });try {const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });@@@ -319,6 +389,29 @@ function createSsoClient(opts = {}) {},};++ let health = null; // { client, at }: the last check, or the last sign-in's verdict++ /**++ * Does the Network accept this site's OAuth client? A token request with a code no sign-in ever has: invalid_grant++ * means the id and secret are right, invalid_client that they are not. Cached 10 minutes. → 'ok' | 'invalid' | 'unknown'++ */++ async function checkClient({ force = false } = {}) {++ if (!force && health && Date.now() - health.at < 10 * 60e3) return health.client;++ try {++ await tokenGrant({ grant_type: 'authorization_code', redirect_uri: redirectUri, code: HEALTH_CODE, code_verifier: HEALTH_CODE });++ health = { client: 'ok', at: Date.now() }; // a stub that accepts anything: the client is not refused++ } catch (err) {++ const invalid = err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client';++ health = { client: invalid ? 'invalid' : (err.status && err.status < 500 ? 'ok' : 'unknown'), at: Date.now() };++ if (invalid) log.error(`[${site} auth] the Network refuses this site's OAuth client: check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);++ }++ return health.client;++ }++ handlers.health = async (req, res) => {++ const client = await checkClient();++ if (typeof res.set === 'function') res.set('Cache-Control', 'no-store');++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });++ };++/** The /auth router. Pass your express module (the SDK does not depend on it). */function router(express) {if (!express || typeof express.Router !== 'function') throw new TypeError('sso.router(express): pass the express module');@@@ -332,6 +425,7 @@ function createSsoClient(opts = {}) {r.get('/logout', wrap(handlers.logout));r.get('/me', wrap(handlers.me));r.post('/refresh', wrap(handlers.refresh));++ r.get('/health', wrap(handlers.health));return r;}@@@ -350,10 +444,10 @@ function createSsoClient(opts = {}) {return (req, res, nextFn) => opt(req, res, () => (req.user ? nextFn() : res.status(401).json({ error: 'Sign in required' })));}-- return { router, handlers, optionalAuth, requireAuth, verify, extractToken, authorizeUrl, tokenGrant, redirectUri, audience };++ return { router, handlers, optionalAuth, requireAuth, verify, extractToken, authorizeUrl, tokenGrant, checkClient, redirectUri, audience };}module.exports = {-- createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser, parseCookies,++ createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser, parseCookies, failurePage,COOKIES: Object.freeze({ access: ACCESS_COOKIE, refresh: REFRESH_COOKIE, hint: HINT_COOKIE }),};@@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');const crypto = require('node:crypto');const http = require('node:http');const { run } = require('./helpers');--const { createSsoClient, sanitizeNext, withParam, fedcmNonceMatches } = require('../src/sso');++const { createSsoClient, sanitizeNext, withParam, fedcmNonceMatches, failurePage } = require('../src/sso');const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const ISS = 'https://openvibe.network';@@@ -26,6 +26,7 @@ const server = http.createServer((req, res) => {net.grants.push(body);if (body.client_secret !== 'secret') return send(401, { error: 'invalid_client' });if (body.grant_type === 'refresh_token' && net.refuseRefresh) return send(400, { error: 'invalid_grant' });++ if (body.grant_type === 'authorization_code' && (body.code === 'ov_health_check_never_issued' || body.code === 'used')) return send(400, { error: 'invalid_grant' });return send(200, { access_token: session(), refresh_token: `rt_${net.grants.length}`, token_type: 'Bearer' });}return send(404, {});@@@ -40,6 +41,8 @@ function fakeRes() {r.status = (s) => { r.statusCode = s; return r; };r.json = (b) => { r.body = b; return r; };r.send = (b) => { r.body = b; return r; };++ r.type = (t) => { r.contentType = t; return r; };++ r.set = (k, v) => { (r.headers = r.headers || {})[k] = v; return r; };return r;}const cookieHeader = (o) => Object.entries(o).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');@@@ -88,12 +91,24 @@ run([}],['callback: the state cookie is required and must match; the code is exchanged with the secret and the PKCE verifier', async () => {++ // No state cookie, or another state: never a session; a fresh sign-in of the visitor's own, once (0.73.0).const noCookie = fakeRes();-- await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: {} }, noCookie);-- assert.equal(noCookie.statusCode, 400, 'no state cookie');++ await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_next: '/deals' }) } }, noCookie);++ assert.equal(noCookie.statusCode, 302, 'no state cookie: start again');++ assert.equal(noCookie.location, '/auth/login?next=%2Fdeals', 'back to where the visitor was going');++ assert.equal(noCookie.cookies.ov_oauth_retry.v, '1');++ assert.ok(!noCookie.cookies.ov_token, 'no session without a matching state');const mismatch = fakeRes();-- await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 't', ov_oauth_verifier: 'v' }) } }, mismatch);-- assert.equal(mismatch.statusCode, 400, 'another state');++ await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 't', ov_oauth_verifier: 'v', ov_oauth_retry: '1' }) } }, mismatch);++ assert.equal(mismatch.statusCode, 400, 'the second time: the page, not another loop');++ assert.equal(mismatch.contentType, 'html');++ assert.match(mismatch.body, /<h1>Sign-in didn't finish<\/h1>/);++ assert.match(mismatch.body, /<a class="go" href="\/auth\/login">Try again<\/a>/);++ assert.ok(!mismatch.cookies.ov_token && mismatch.cleared.includes('ov_oauth_retry'), 'no session; the retry mark is spent');++ // A code the Network no longer accepts (used twice, too old): the same, once.++ const used = fakeRes();++ await sso.handlers.callback({ query: { code: 'used', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'v' }) } }, used);++ assert.equal(used.statusCode, 302); assert.equal(used.location, '/auth/login'); assert.ok(!used.cookies.ov_token);const ok = fakeRes();await sso.handlers.callback({ query: { code: 'c1', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'ver', ov_oauth_next: '/deals' }) } }, ok);assert.equal(ok.location, '/deals');@@@ -107,6 +122,57 @@ run([assert.equal(quiet.location, '/x?sso=none', 'a silent sign-in with no Network session goes back quietly');}],++ ['a client the Network refuses: the page says it is our problem, logs what to check, and /auth/health reports it', async () => {++ const errors = [];++ const bad = createSsoClient({ site: 'cam', baseUrl: 'https://openvibe.cam', clientId: 'cam', clientSecret: '...', networkUrl: ISS, networkInternalUrl: base, issuer: ISS, log: { warn() {}, error: (m) => errors.push(m) } });++ const res = fakeRes();++ await bad.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'v' }) } }, res);++ assert.equal(res.statusCode, 500, 'no retry loop for a configuration error');++ assert.match(res.body, /Sign-in isn't working on OpenVibe\.Cam right now/);++ assert.match(res.body, /not with your account/);++ assert.ok(errors.some((m) => /refuses this site's OAuth client/.test(m) && /OV_OAUTH_CLIENT_SECRET/.test(m)), 'the log names what to check');++ const h = fakeRes();++ await bad.handlers.health({}, h);++ assert.deepEqual([h.statusCode, h.body], [503, { ok: false, client: 'invalid' }]);++ assert.equal(await bad.checkClient({ force: true }), 'invalid', 'a forced check asks the Network again');++ const good = fakeRes();++ await sso.handlers.health({}, good);++ assert.deepEqual([good.statusCode, good.body.client], [200, 'ok']);++ assert.equal(await sso.checkClient({ force: true }), 'ok', 'invalid_grant for a code that cannot exist: the client is fine');++ assert.ok(net.grants.some((g) => g.code === 'ov_health_check_never_issued'), 'the check presents the never-issued code');++ // The Network does not answer: the page, 502, no retry.++ const down = createSsoClient({ site: 'cam', baseUrl: 'https://openvibe.cam', clientId: 'cam', clientSecret: 'secret', networkUrl: 'http://127.0.0.1:9', networkInternalUrl: 'http://127.0.0.1:9', issuer: ISS, log: { warn() {}, error() {} } });++ const d = fakeRes();++ await down.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'v' }) } }, d);++ assert.equal(d.statusCode, 502); assert.match(d.body, /OpenVibe\.Network didn't answer/);++ assert.equal(await down.checkClient({ force: true }), 'unknown', 'unreachable is unknown, not invalid');++ // The page escapes what it is given and needs nothing from outside.++ const page = failurePage({ siteName: '<b>X</b>', title: 'T"', text: "a'b", retryHref: '/auth/login?next=%2F%22x', backHref: '/' });++ assert.ok(!page.includes('<b>X</b>') && page.includes('<b>X</b>') && page.includes('T"') && page.includes('a'b'));++ assert.ok(!/<script|https?:\/\//.test(page), 'no script and no outside request');++ }],++++ ['legacy cookies: a site moving here keeps its sessions, then writes the SDK names and clears the old ones', async () => {++ const moved = createSsoClient({ site: 'cam', baseUrl: 'https://openvibe.cam', clientId: 'cam', clientSecret: 'secret', networkUrl: ISS, networkInternalUrl: base, issuer: ISS, audience: ['openvibe.network', 'openvibe.live'], legacyCookies: { access: ['cam_at'], refresh: [{ name: 'cam_rt', path: '/auth' }, 'bad name;'] }, log: { warn() {}, error() {} } });++ const me = fakeRes();++ await moved.handlers.me({ headers: { cookie: cookieHeader({ cam_at: session() }) } }, me);++ assert.equal(me.body.user.username, 'ana', 'the old access cookie is the session');++ const r = fakeRes();++ await moved.handlers.refresh({ headers: { cookie: cookieHeader({ cam_rt: 'old-rt' }) } }, r);++ assert.ok(r.body.token && r.cookies.ov_token && r.cookies.ov_refresh, 'the old refresh cookie refreshes; the SDK names are written');++ assert.equal(net.grants.at(-1).refresh_token, 'old-rt');++ assert.ok(r.cleared.includes('cam_at') && r.cleared.includes('cam_rt') && !r.cleared.includes('bad name;'), 'the old cookies are cleared; a bad name is ignored');++ const out = fakeRes();++ await moved.handlers.logout({ headers: { cookie: cookieHeader({ cam_rt: 'x' }) }, query: {} }, out);++ assert.ok(out.cleared.includes('cam_at') && out.cleared.includes('ov_token'), 'logout clears both');++ assert.ok(net.revoked.includes('x'), 'logout revokes the old refresh token too');++ net.revoked.splice(0);++ const strict = createSsoClient({ site: 'cam', baseUrl: 'https://openvibe.cam', clientId: 'cam', clientSecret: 'secret', networkUrl: ISS, networkInternalUrl: base, issuer: ISS, httpOnlyAccess: true, log: { warn() {}, error() {} } });++ const s2 = fakeRes();++ await strict.handlers.refresh({ headers: { cookie: cookieHeader({ ov_refresh: 'r' }) } }, s2);++ assert.equal(s2.cookies.ov_token.o.httpOnly, true, 'httpOnlyAccess keeps the token from scripts');++ }],++['me and the middleware: a session verifies offline; a FedCM assertion, a service token, an expired token do not', async () => {const me = fakeRes();await sso.handlers.me({ headers: { cookie: cookieHeader({ ov_token: session() }) } }, me);@@@ -28,16 +28,22 @@ export interface SsoClientOptions {secureCookies?: boolean;accessMaxAgeMs?: number;fetch?: FetchLike;-- log?: Pick<Console, 'warn' | 'log'>;++ log?: Pick<Console, 'warn' | 'log'> & Partial<Pick<Console, 'error'>>;++ /** The name the sign-in pages show (default OpenVibe.<Site>). */++ siteName?: string;++ /** Keep the access token httpOnly (no script can read it; the navbar asks /auth/me). Default false: the shared navbar reads ov_token. */++ httpOnlyAccess?: boolean;++ /** A site's own cookie names before it moved to the SDK: read as the session until the SDK writes its own. */++ legacyCookies?: { access?: Array<string | { name: string; path?: string }>; refresh?: Array<string | { name: string; path?: string }> };}/** The verified session's claims without iat/exp/aud/iss/nbf/jti. */export type SsoUser = Omit<UserTokenClaims, 'iat' | 'exp' | 'aud' | 'iss' | 'nbf' | 'jti'> & Record<string, unknown>;export interface SsoClient {-- /** GET /login /callback /logout /me, POST /fedcm /refresh; mount at /auth. */++ /** GET /login /callback /logout /me /health, POST /fedcm /refresh; mount at /auth. */router(express: any): any;-- handlers: { login: Handler; callback: Handler; fedcm: Handler; logout: Handler; me: Handler; refresh: Handler };++ handlers: { login: Handler; callback: Handler; fedcm: Handler; logout: Handler; me: Handler; refresh: Handler; health: Handler };/** Sets req.user and req.token when a valid session is presented. */optionalAuth(): (req: Req, res: Res, next: Next) => void;/** 401 JSON without a valid session. */@@@ -48,6 +54,8 @@ export interface SsoClient {extractToken(req: Req): string | null;authorizeUrl(opts?: { silent?: boolean }): { url: string; state: string; verifier: string };tokenGrant(body: Record<string, unknown>): Promise<Record<string, any>>;++ /** Does the Network accept this client? Cached 10 minutes; GET /auth/health answers with it (503 when 'invalid'). */++ checkClient(opts?: { force?: boolean }): Promise<'ok' | 'invalid' | 'unknown'>;redirectUri: string;audience: string | string[];}@@@ -56,6 +64,8 @@ export declare function createSsoClient(opts: SsoClientOptions): SsoClient;/** A same-site path, this site's https origin or the Network's; anything else (control characters, backslashes) is '/'. */export declare function sanitizeNext(next: unknown, opts?: { baseUrl?: string; networkUrl?: string }): string;export declare function withParam(target: string, key: string, value: string): string;++/** The self-contained page a sign-in that cannot finish shows (text is escaped). */++export declare function failurePage(o: { siteName: string; title: string; text: string; retryHref: string; backHref: string }): string;/** The payload of a JWT, unverified; null when it does not parse. */export declare function decodeJwtPayload(token: string): Record<string, any> | null;export declare function fedcmNonceMatches(token: string, nonce: string): boolean;
@@ -3,7 +3,26 @@
@@ -3,7 +3,26 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
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.
release may change an API and says so here.
-## 0.72.0 (unreleased)
+## 0.73.0 (2026-10-30)
+
+- **`openvibe-sdk/sso`: a sign-in that recovers, explains itself and can be checked.**
+ - A lost or expired flow (no or another state cookie) and a code the Network no longer accepts (`invalid_grant`) start
+ the sign-in once more by themselves. A two-minute `ov_oauth_retry` cookie keeps it from looping, and neither ever
+ sets a session.
+ - A second failure, a client the Network refuses (`invalid_client`) and a Network that does not answer show a small,
+ self-contained page in plain words, with "Try again" and "Back to <site>", in place of a bare line of text.
+ - A refused client is logged as a configuration error that names `OV_OAUTH_CLIENT_ID` / `OV_OAUTH_CLIENT_SECRET`.
+ - `GET /auth/health` and `checkClient()`: a token request with a code that is never issued. `invalid_grant` means the
+ client is fine; `invalid_client` that its id or secret is wrong (503). The answer is cached 10 minutes.
+ - Why: OpenVibe.Cam ran for a day on the placeholder secret from its `.env.example`. Every sign-in ended on "The
+ Network refused the sign-in code", and nothing checked.
+ - `siteName` option for the pages.
+ - `legacyCookies: { access, refresh }` moves a site off its own sign-in code without signing anyone out. Its old
+ cookies are read as the session until the next sign-in or refresh writes `ov_token`/`ov_refresh` and clears them;
+ logout clears both. `httpOnlyAccess: true` keeps the access token httpOnly, as those sites did (their navbar asks
+ `/auth/me`).
+
+## 0.72.0 (2026-10-29)
- **`openvibe-sdk/vision`** (VIS-5, plan §5.8b): typed browser and server calls for Vision's image tools, private search, face privacy and consent APIs, and MCP discovery. The service enforces token grants and consent; the client preserves problem errors and binary blur results.
- **`openvibe-sdk/vision`** (VIS-5, plan §5.8b): typed browser and server calls for Vision's image tools, private search, face privacy and consent APIs, and MCP discovery. The service enforces token grants and consent; the client preserves problem errors and binary blur results.
@@ -2,16 +2,16 @@
@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.72.0 (prepared for release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
+**Status:** alpha, v0.73.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
-After v0.72.0 is tagged:
+After v0.73.0 is tagged:
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.72.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0
```
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.72.0",
+ "version": "0.73.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.72.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.73.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, bot, search, bench, vision, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, bench, vision, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -45,7 +45,7 @@ const { paginate, offsetPager } = __load(7);
@@ -45,7 +45,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.72.0';
+const SDK_VERSION = '0.73.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -722,6 +722,7 @@
@@ -722,6 +722,7 @@
"claimsToUser": "function",
"claimsToUser": "function",
"createSsoClient": "function",
"createSsoClient": "function",
"decodeJwtPayload": "function",
"decodeJwtPayload": "function",
+ "failurePage": "function",
"fedcmNonceMatches": "function",
"fedcmNonceMatches": "function",
"parseCookies": "function",
"parseCookies": "function",
"sanitizeNext": "function",
"sanitizeNext": "function",
@@ -3,6 +3,6 @@ import cjs from '../src/sso/index.js';
@@ -3,6 +3,6 @@ import cjs from '../src/sso/index.js';
export const {
export const {
createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser,
createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser,
- parseCookies, COOKIES,
+ parseCookies, failurePage, COOKIES,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.72.0",
+ "version": "0.73.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.72.0",
+ "version": "0.73.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.72.0",
+ "version": "0.73.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, 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, Bot robots, 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",
@@ -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.72.0';
+const SDK_VERSION = '0.73.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -25,6 +25,18 @@
@@ -25,6 +25,18 @@
* GET /logout → cookies cleared, the refresh token revoked (best effort), hint=guest
* GET /logout → cookies cleared, the refresh token revoked (best effort), hint=guest
* GET /me → the verified session's profile
* GET /me → the verified session's profile
* POST /refresh → rotate the tokens with the refresh token
* POST /refresh → rotate the tokens with the refresh token
+ * Moving a site from its own sign-in code (0.73.0): legacyCookies: { access: ['cam_at'], refresh: [{ name: 'cam_rt',
+ * path: '/auth' }] } reads the old cookies as the session until the next sign-in or refresh writes ov_token/ov_refresh
+ * and clears them, so nobody is signed out by the move; logout clears both.
+ * GET /health → { ok, client: 'ok' | 'invalid' | 'unknown' }: does the Network accept this site's OAuth client?
+ * (a token request with a code that cannot exist: invalid_grant means the client is fine,
+ * invalid_client means its id or secret is wrong; cached 10 minutes; 503 only for 'invalid')
+ *
+ * When a sign-in cannot finish (0.73.0): a lost or expired flow (the state cookie is gone or does not match) and a code
+ * the Network no longer accepts (invalid_grant: used twice, or too old) start the sign-in once more by themselves, with
+ * a two-minute ov_oauth_retry cookie so it never loops; neither sets a session. A second failure, a client the Network
+ * refuses (invalid_client: a configuration error, logged as one with what to check) and a Network that does not answer
+ * show a small page in plain words with "Try again" and "Back to <site>", never a bare line of text.
* Cookies (host-only): ov_token (the access JWT, SameSite=Lax, readable by the shared navbar), ov_refresh (httpOnly,
* Cookies (host-only): ov_token (the access JWT, SameSite=Lax, readable by the shared navbar), ov_refresh (httpOnly,
* Path=/auth), ov_sso_hint ('account' / 'guest'), and the short-lived flow cookies on /auth.
* Path=/auth), ov_sso_hint ('account' / 'guest'), and the short-lived flow cookies on /auth.
*
*
@@ -43,6 +55,9 @@ const NEXT_COOKIE = 'ov_oauth_next';
@@ -43,6 +55,9 @@ const NEXT_COOKIE = 'ov_oauth_next';
const SILENT_COOKIE = 'ov_oauth_silent';
const SILENT_COOKIE = 'ov_oauth_silent';
const VERIFIER_COOKIE = 'ov_oauth_verifier';
const VERIFIER_COOKIE = 'ov_oauth_verifier';
const FLOW_COOKIES = [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE, VERIFIER_COOKIE];
const FLOW_COOKIES = [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE, VERIFIER_COOKIE];
+const RETRY_COOKIE = 'ov_oauth_retry';
+// A code no sign-in can ever have: the health check presents it and reads which way the Network refuses it.
+const HEALTH_CODE = 'ov_health_check_never_issued';
const trim = (u) => String(u || '').replace(/\/+$/, '');
const trim = (u) => String(u || '').replace(/\/+$/, '');
@@ -117,6 +132,24 @@ function sameString(a, b) {
@@ -117,6 +132,24 @@ function sameString(a, b) {
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
}
}
+const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+
+/**
+ * The page a sign-in that cannot finish shows: what happened in plain words, "Try again" (a fresh sign-in back to where
+ * the person was going) and the way back. Self-contained (no script, no font, no request): the theme's colours when the
+ * site's page variables are not there, light or dark as the device is.
+ */
+function failurePage({ siteName, title, text, retryHref, backHref }) {
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><meta name="color-scheme" content="light dark"><title>${escapeHtml(title)} · ${escapeHtml(siteName)}</title>`
+ + '<style>:root{--b:#0a0f1c;--c:#131c2e;--t:#e6edf7;--m:#96a7c2;--a:#3b82f6;--l:#1f2d47}@media (prefers-color-scheme:light){:root{--b:#f6f7fb;--c:#fff;--t:#0f172a;--m:#475569;--a:#1d4ed8;--l:#e3e7ef}}'
+ + 'body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--b);color:var(--t);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}'
+ + 'main{box-sizing:border-box;width:min(440px,calc(100vw - 32px));padding:28px 24px;border:1px solid var(--l);border-radius:18px;background:var(--c)}'
+ + 'h1{margin:0 0 8px;font-size:1.3rem}p{margin:0 0 20px;color:var(--m)}nav{display:flex;flex-wrap:wrap;gap:10px}'
+ + 'a{display:inline-flex;align-items:center;min-height:44px;padding:0 18px;border-radius:999px;font-weight:650;text-decoration:none}'
+ + 'a.go{background:var(--a);color:#fff}a.back{color:var(--t);border:1px solid var(--l)}a:focus-visible{outline:3px solid var(--a);outline-offset:2px}</style></head>'
+ + `<body><main><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p><nav><a class="go" href="${escapeHtml(retryHref)}">Try again</a><a class="back" href="${escapeHtml(backHref)}">Back to ${escapeHtml(siteName)}</a></nav></main></body></html>`;
+}
+
function createSsoClient(opts = {}) {
function createSsoClient(opts = {}) {
const site = String(opts.site || 'site');
const site = String(opts.site || 'site');
const baseUrl = trim(opts.baseUrl);
const baseUrl = trim(opts.baseUrl);
@@ -133,6 +166,13 @@ function createSsoClient(opts = {}) {
@@ -133,6 +166,13 @@ function createSsoClient(opts = {}) {
const accessMaxAgeMs = opts.accessMaxAgeMs || 24 * 3600e3;
const accessMaxAgeMs = opts.accessMaxAgeMs || 24 * 3600e3;
if (!baseUrl || !opts.clientId) throw new TypeError('createSsoClient: baseUrl and clientId are required');
if (!baseUrl || !opts.clientId) throw new TypeError('createSsoClient: baseUrl and clientId are required');
const next = (value) => sanitizeNext(value, { baseUrl, networkUrl });
const next = (value) => sanitizeNext(value, { baseUrl, networkUrl });
+ const siteName = String(opts.siteName || `OpenVibe.${site.charAt(0).toUpperCase()}${site.slice(1)}`);
+ // A site's own cookie names before it moved here: read as the session, cleared when the SDK writes its own.
+ const legacyList = (l) => (Array.isArray(l) ? l : []).map((x) => (typeof x === 'string' ? { name: x, path: '/' } : { name: String(x.name), path: x.path || '/' })).filter((x) => /^[A-Za-z0-9_-]{1,64}$/.test(x.name));
+ const legacy = { access: legacyList(opts.legacyCookies && opts.legacyCookies.access), refresh: legacyList(opts.legacyCookies && opts.legacyCookies.refresh) };
+ const readCookie = (req, name, old) => { const c = parseCookies(req); if (c[name]) return c[name]; for (const l of old) if (c[l.name]) return c[l.name]; return null; };
+ const readAccess = (req) => readCookie(req, ACCESS_COOKIE, legacy.access);
+ const readRefresh = (req) => readCookie(req, REFRESH_COOKIE, legacy.refresh);
/** The verified claims of a session token, or null. Never throws. */
/** The verified claims of a session token, or null. Never throws. */
async function verify(token) {
async function verify(token) {
@@ -149,25 +189,47 @@ function createSsoClient(opts = {}) {
@@ -149,25 +189,47 @@ function createSsoClient(opts = {}) {
function extractToken(req) {
function extractToken(req) {
const h = String((req.headers && req.headers.authorization) || '');
const h = String((req.headers && req.headers.authorization) || '');
if (h.startsWith('Bearer ')) return h.slice(7).trim() || null;
if (h.startsWith('Bearer ')) return h.slice(7).trim() || null;
- return parseCookies(req)[ACCESS_COOKIE] || null;
+ return readAccess(req);
}
}
const cookie = (maxAge, { httpOnly = true, path = '/' } = {}) => ({ sameSite: 'lax', secure, httpOnly, path, ...(maxAge ? { maxAge } : {}) });
const cookie = (maxAge, { httpOnly = true, path = '/' } = {}) => ({ sameSite: 'lax', secure, httpOnly, path, ...(maxAge ? { maxAge } : {}) });
- const accessOpts = () => cookie(accessMaxAgeMs, { httpOnly: false }); // the shared navbar reads it
+ // The shared navbar reads ov_token unless the site asks otherwise (httpOnlyAccess: its navbar asks /auth/me instead,
+ // as the sites that kept their own sign-in code did, so no script can read the token).
+ const accessOpts = () => cookie(accessMaxAgeMs, { httpOnly: !!opts.httpOnlyAccess });
const refreshOpts = () => cookie(30 * 24 * 3600e3, { path: '/auth' });
const refreshOpts = () => cookie(30 * 24 * 3600e3, { path: '/auth' });
const flowOpts = () => cookie(10 * 60e3, { path: '/auth' });
const flowOpts = () => cookie(10 * 60e3, { path: '/auth' });
const hintOpts = () => cookie(365 * 24 * 3600e3, { httpOnly: false });
const hintOpts = () => cookie(365 * 24 * 3600e3, { httpOnly: false });
+ const clearLegacy = (res) => { for (const l of [...legacy.access, ...legacy.refresh]) res.clearCookie(l.name, { path: l.path }); };
function setSession(res, accessToken, refreshToken) {
function setSession(res, accessToken, refreshToken) {
+ clearLegacy(res);
res.cookie(ACCESS_COOKIE, accessToken, accessOpts());
res.cookie(ACCESS_COOKIE, accessToken, accessOpts());
if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshOpts());
if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshOpts());
res.cookie(HINT_COOKIE, 'account', hintOpts());
res.cookie(HINT_COOKIE, 'account', hintOpts());
}
}
function clearSession(res) {
function clearSession(res) {
+ clearLegacy(res);
res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
}
}
const clearFlow = (res) => { for (const c of FLOW_COOKIES) res.clearCookie(c, { path: '/auth' }); };
const clearFlow = (res) => { for (const c of FLOW_COOKIES) res.clearCookie(c, { path: '/auth' }); };
+ const retryOpts = () => cookie(2 * 60e3, { path: '/auth' });
+ const loginHref = (target) => `/auth/login${target && target !== '/' ? `?next=${encodeURIComponent(target)}` : ''}`;
+ /** A sign-in that can recover starts once more by itself; the second time it shows the page. Never sets a session. */
+ function retryOrExplain(req, res, target, page) {
+ if (parseCookies(req)[RETRY_COOKIE] !== '1') {
+ res.cookie(RETRY_COOKIE, '1', retryOpts());
+ return res.redirect(loginHref(target));
+ }
+ res.clearCookie(RETRY_COOKIE, { path: '/auth' });
+ return explain(res, 400, target, page);
+ }
+ function explain(res, status, target, { title, text }) {
+ if (typeof res.type === 'function') res.type('html');
+ if (typeof res.set === 'function') res.set('Cache-Control', 'no-store');
+ return res.status(status).send(failurePage({ siteName, title, text, retryHref: loginHref(target), backHref: target || '/' }));
+ }
+ const SIGN_IN_AGAIN = { title: 'Sign-in didn\'t finish', text: 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.' };
/** A token grant at the Network: the internal URL first, the public one if it cannot be reached. */
/** A token grant at the Network: the internal URL first, the public one if it cannot be reached. */
async function tokenGrant(body) {
async function tokenGrant(body) {
@@ -223,7 +285,7 @@ function createSsoClient(opts = {}) {
@@ -223,7 +285,7 @@ function createSsoClient(opts = {}) {
const q = req.query || {};
const q = req.query || {};
const silent = !!q.silent && q.silent !== '0';
const silent = !!q.silent && q.silent !== '0';
// Already signed in here? A silent round trip would only hand back the session we have.
// Already signed in here? A silent round trip would only hand back the session we have.
- if (silent && await verify(parseCookies(req)[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(next(q.next)); }
+ if (silent && await verify(readAccess(req))) { clearFlow(res); return res.redirect(next(q.next)); }
const { url, state, verifier } = authorizeUrl({ silent, prompt: String(q.prompt || ''), loginHint: q.login_hint });
const { url, state, verifier } = authorizeUrl({ silent, prompt: String(q.prompt || ''), loginHint: q.login_hint });
res.cookie(STATE_COOKIE, state, flowOpts());
res.cookie(STATE_COOKIE, state, flowOpts());
res.cookie(VERIFIER_COOKIE, verifier, flowOpts());
res.cookie(VERIFIER_COOKIE, verifier, flowOpts());
@@ -244,20 +306,28 @@ function createSsoClient(opts = {}) {
@@ -244,20 +306,28 @@ function createSsoClient(opts = {}) {
if (silent || q.error === 'login_required') return res.redirect(withParam(target, 'sso', 'none'));
if (silent || q.error === 'login_required') return res.redirect(withParam(target, 'sso', 'none'));
return res.redirect(withParam('/', 'auth_error', String(q.error)));
return res.redirect(withParam('/', 'auth_error', String(q.error)));
}
}
- if (!q.code) return res.status(400).send('Missing authorization code');
+ if (!q.code) return explain(res, 400, target, SIGN_IN_AGAIN);
const expected = c[STATE_COOKIE], verifier = c[VERIFIER_COOKIE];
const expected = c[STATE_COOKIE], verifier = c[VERIFIER_COOKIE];
clearFlow(res);
clearFlow(res);
// The state cookie is required, not only compared when present: skipping the check without it would let a
// The state cookie is required, not only compared when present: skipping the check without it would let a
- // crafted link sign a visitor into someone else's account.
- if (!sameString(q.state, expected) || !verifier) return res.status(400).send('OAuth state mismatch. Please try signing in again.');
+ // crafted link sign a visitor into someone else's account. A mismatch never sets a session; it starts a fresh
+ // sign-in of the visitor's own (once), which is what a lost or expired flow cookie needs.
+ if (!sameString(q.state, expected) || !verifier) return retryOrExplain(req, res, target, SIGN_IN_AGAIN);
try {
try {
const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: redirectUri, code: String(q.code), code_verifier: verifier });
const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: redirectUri, code: String(q.code), code_verifier: verifier });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
+ res.clearCookie(RETRY_COOKIE, { path: '/auth' });
+ health = { client: 'ok', at: Date.now() };
return res.redirect(target);
return res.redirect(target);
} catch (err) {
} catch (err) {
- log.error(`[${site} auth] code exchange failed: ${err.message}`);
- if (err.status && err.status < 500) return res.redirect(withParam('/', 'auth_error', String(err.error || 'invalid_grant')));
- return res.status(502).send('Sign-in failed: OpenVibe.Network could not be reached. Please try again.');
+ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
+ health = { client: 'invalid', at: Date.now() };
+ log.error(`[${site} auth] the Network refuses this site's OAuth client (${err.error || err.status}): check its client id and secret (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET) against the Network's registration`);
+ return explain(res, 500, target, { title: `Sign-in isn't working on ${siteName} right now`, text: 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.' });
+ }
+ log.warn(`[${site} auth] code exchange failed: ${err.message}`);
+ if (err.status && err.status < 500) return retryOrExplain(req, res, target, SIGN_IN_AGAIN);
+ return explain(res, 502, target, { title: 'OpenVibe.Network didn\'t answer', text: 'Your account is fine: the sign-in service did not reply in time. Try again in a moment.' });
}
}
},
},
@@ -281,7 +351,7 @@ function createSsoClient(opts = {}) {
@@ -281,7 +351,7 @@ function createSsoClient(opts = {}) {
async logout(req, res) {
async logout(req, res) {
// Best-effort revocation: the Network's rotating refresh tokens invalidate themselves anyway.
// Best-effort revocation: the Network's rotating refresh tokens invalidate themselves anyway.
- const refresh = parseCookies(req)[REFRESH_COOKIE];
+ const refresh = readRefresh(req);
if (refresh) {
if (refresh) {
try {
try {
await fetchImpl(`${networkInternalUrl}/oauth/revoke`, {
await fetchImpl(`${networkInternalUrl}/oauth/revoke`, {
@@ -305,7 +375,7 @@ function createSsoClient(opts = {}) {
@@ -305,7 +375,7 @@ function createSsoClient(opts = {}) {
},
},
async refresh(req, res) {
async refresh(req, res) {
- const refresh = parseCookies(req)[REFRESH_COOKIE];
+ const refresh = readRefresh(req);
if (!refresh) return res.status(401).json({ error: 'No refresh token' });
if (!refresh) return res.status(401).json({ error: 'No refresh token' });
try {
try {
const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
@@ -319,6 +389,29 @@ function createSsoClient(opts = {}) {
@@ -319,6 +389,29 @@ function createSsoClient(opts = {}) {
},
},
};
};
+ let health = null; // { client, at }: the last check, or the last sign-in's verdict
+ /**
+ * Does the Network accept this site's OAuth client? A token request with a code no sign-in ever has: invalid_grant
+ * means the id and secret are right, invalid_client that they are not. Cached 10 minutes. → 'ok' | 'invalid' | 'unknown'
+ */
+ async function checkClient({ force = false } = {}) {
+ if (!force && health && Date.now() - health.at < 10 * 60e3) return health.client;
+ try {
+ await tokenGrant({ grant_type: 'authorization_code', redirect_uri: redirectUri, code: HEALTH_CODE, code_verifier: HEALTH_CODE });
+ health = { client: 'ok', at: Date.now() }; // a stub that accepts anything: the client is not refused
+ } catch (err) {
+ const invalid = err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client';
+ health = { client: invalid ? 'invalid' : (err.status && err.status < 500 ? 'ok' : 'unknown'), at: Date.now() };
+ if (invalid) log.error(`[${site} auth] the Network refuses this site's OAuth client: check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);
+ }
+ return health.client;
+ }
+ handlers.health = async (req, res) => {
+ const client = await checkClient();
+ if (typeof res.set === 'function') res.set('Cache-Control', 'no-store');
+ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
+ };
+
/** The /auth router. Pass your express module (the SDK does not depend on it). */
/** The /auth router. Pass your express module (the SDK does not depend on it). */
function router(express) {
function router(express) {
if (!express || typeof express.Router !== 'function') throw new TypeError('sso.router(express): pass the express module');
if (!express || typeof express.Router !== 'function') throw new TypeError('sso.router(express): pass the express module');
@@ -332,6 +425,7 @@ function createSsoClient(opts = {}) {
@@ -332,6 +425,7 @@ function createSsoClient(opts = {}) {
r.get('/logout', wrap(handlers.logout));
r.get('/logout', wrap(handlers.logout));
r.get('/me', wrap(handlers.me));
r.get('/me', wrap(handlers.me));
r.post('/refresh', wrap(handlers.refresh));
r.post('/refresh', wrap(handlers.refresh));
+ r.get('/health', wrap(handlers.health));
return r;
return r;
}
}
@@ -350,10 +444,10 @@ function createSsoClient(opts = {}) {
@@ -350,10 +444,10 @@ function createSsoClient(opts = {}) {
return (req, res, nextFn) => opt(req, res, () => (req.user ? nextFn() : res.status(401).json({ error: 'Sign in required' })));
return (req, res, nextFn) => opt(req, res, () => (req.user ? nextFn() : res.status(401).json({ error: 'Sign in required' })));
}
}
- return { router, handlers, optionalAuth, requireAuth, verify, extractToken, authorizeUrl, tokenGrant, redirectUri, audience };
+ return { router, handlers, optionalAuth, requireAuth, verify, extractToken, authorizeUrl, tokenGrant, checkClient, redirectUri, audience };
}
}
module.exports = {
module.exports = {
- createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser, parseCookies,
+ createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser, parseCookies, failurePage,
COOKIES: Object.freeze({ access: ACCESS_COOKIE, refresh: REFRESH_COOKIE, hint: HINT_COOKIE }),
COOKIES: Object.freeze({ access: ACCESS_COOKIE, refresh: REFRESH_COOKIE, hint: HINT_COOKIE }),
};
};
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const crypto = require('node:crypto');
const http = require('node:http');
const http = require('node:http');
const { run } = require('./helpers');
const { run } = require('./helpers');
-const { createSsoClient, sanitizeNext, withParam, fedcmNonceMatches } = require('../src/sso');
+const { createSsoClient, sanitizeNext, withParam, fedcmNonceMatches, failurePage } = require('../src/sso');
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const ISS = 'https://openvibe.network';
const ISS = 'https://openvibe.network';
@@ -26,6 +26,7 @@ const server = http.createServer((req, res) => {
@@ -26,6 +26,7 @@ const server = http.createServer((req, res) => {
net.grants.push(body);
net.grants.push(body);
if (body.client_secret !== 'secret') return send(401, { error: 'invalid_client' });
if (body.client_secret !== 'secret') return send(401, { error: 'invalid_client' });
if (body.grant_type === 'refresh_token' && net.refuseRefresh) return send(400, { error: 'invalid_grant' });
if (body.grant_type === 'refresh_token' && net.refuseRefresh) return send(400, { error: 'invalid_grant' });
+ if (body.grant_type === 'authorization_code' && (body.code === 'ov_health_check_never_issued' || body.code === 'used')) return send(400, { error: 'invalid_grant' });
return send(200, { access_token: session(), refresh_token: `rt_${net.grants.length}`, token_type: 'Bearer' });
return send(200, { access_token: session(), refresh_token: `rt_${net.grants.length}`, token_type: 'Bearer' });
}
}
return send(404, {});
return send(404, {});
@@ -40,6 +41,8 @@ function fakeRes() {
@@ -40,6 +41,8 @@ function fakeRes() {
r.status = (s) => { r.statusCode = s; return r; };
r.status = (s) => { r.statusCode = s; return r; };
r.json = (b) => { r.body = b; return r; };
r.json = (b) => { r.body = b; return r; };
r.send = (b) => { r.body = b; return r; };
r.send = (b) => { r.body = b; return r; };
+ r.type = (t) => { r.contentType = t; return r; };
+ r.set = (k, v) => { (r.headers = r.headers || {})[k] = v; return r; };
return r;
return r;
}
}
const cookieHeader = (o) => Object.entries(o).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');
const cookieHeader = (o) => Object.entries(o).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');
@@ -88,12 +91,24 @@ run([
@@ -88,12 +91,24 @@ run([
}],
}],
['callback: the state cookie is required and must match; the code is exchanged with the secret and the PKCE verifier', async () => {
['callback: the state cookie is required and must match; the code is exchanged with the secret and the PKCE verifier', async () => {
+ // No state cookie, or another state: never a session; a fresh sign-in of the visitor's own, once (0.73.0).
const noCookie = fakeRes();
const noCookie = fakeRes();
- await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: {} }, noCookie);
- assert.equal(noCookie.statusCode, 400, 'no state cookie');
+ await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_next: '/deals' }) } }, noCookie);
+ assert.equal(noCookie.statusCode, 302, 'no state cookie: start again');
+ assert.equal(noCookie.location, '/auth/login?next=%2Fdeals', 'back to where the visitor was going');
+ assert.equal(noCookie.cookies.ov_oauth_retry.v, '1');
+ assert.ok(!noCookie.cookies.ov_token, 'no session without a matching state');
const mismatch = fakeRes();
const mismatch = fakeRes();
- await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 't', ov_oauth_verifier: 'v' }) } }, mismatch);
- assert.equal(mismatch.statusCode, 400, 'another state');
+ await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 't', ov_oauth_verifier: 'v', ov_oauth_retry: '1' }) } }, mismatch);
+ assert.equal(mismatch.statusCode, 400, 'the second time: the page, not another loop');
+ assert.equal(mismatch.contentType, 'html');
+ assert.match(mismatch.body, /<h1>Sign-in didn't finish<\/h1>/);
+ assert.match(mismatch.body, /<a class="go" href="\/auth\/login">Try again<\/a>/);
+ assert.ok(!mismatch.cookies.ov_token && mismatch.cleared.includes('ov_oauth_retry'), 'no session; the retry mark is spent');
+ // A code the Network no longer accepts (used twice, too old): the same, once.
+ const used = fakeRes();
+ await sso.handlers.callback({ query: { code: 'used', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'v' }) } }, used);
+ assert.equal(used.statusCode, 302); assert.equal(used.location, '/auth/login'); assert.ok(!used.cookies.ov_token);
const ok = fakeRes();
const ok = fakeRes();
await sso.handlers.callback({ query: { code: 'c1', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'ver', ov_oauth_next: '/deals' }) } }, ok);
await sso.handlers.callback({ query: { code: 'c1', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'ver', ov_oauth_next: '/deals' }) } }, ok);
assert.equal(ok.location, '/deals');
assert.equal(ok.location, '/deals');
@@ -107,6 +122,57 @@ run([
@@ -107,6 +122,57 @@ run([
assert.equal(quiet.location, '/x?sso=none', 'a silent sign-in with no Network session goes back quietly');
assert.equal(quiet.location, '/x?sso=none', 'a silent sign-in with no Network session goes back quietly');
}],
}],
+ ['a client the Network refuses: the page says it is our problem, logs what to check, and /auth/health reports it', async () => {
+ const errors = [];
+ const bad = createSsoClient({ site: 'cam', baseUrl: 'https://openvibe.cam', clientId: 'cam', clientSecret: '...', networkUrl: ISS, networkInternalUrl: base, issuer: ISS, log: { warn() {}, error: (m) => errors.push(m) } });
+ const res = fakeRes();
+ await bad.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'v' }) } }, res);
+ assert.equal(res.statusCode, 500, 'no retry loop for a configuration error');
+ assert.match(res.body, /Sign-in isn't working on OpenVibe\.Cam right now/);
+ assert.match(res.body, /not with your account/);
+ assert.ok(errors.some((m) => /refuses this site's OAuth client/.test(m) && /OV_OAUTH_CLIENT_SECRET/.test(m)), 'the log names what to check');
+ const h = fakeRes();
+ await bad.handlers.health({}, h);
+ assert.deepEqual([h.statusCode, h.body], [503, { ok: false, client: 'invalid' }]);
+ assert.equal(await bad.checkClient({ force: true }), 'invalid', 'a forced check asks the Network again');
+ const good = fakeRes();
+ await sso.handlers.health({}, good);
+ assert.deepEqual([good.statusCode, good.body.client], [200, 'ok']);
+ assert.equal(await sso.checkClient({ force: true }), 'ok', 'invalid_grant for a code that cannot exist: the client is fine');
+ assert.ok(net.grants.some((g) => g.code === 'ov_health_check_never_issued'), 'the check presents the never-issued code');
+ // The Network does not answer: the page, 502, no retry.
+ const down = createSsoClient({ site: 'cam', baseUrl: 'https://openvibe.cam', clientId: 'cam', clientSecret: 'secret', networkUrl: 'http://127.0.0.1:9', networkInternalUrl: 'http://127.0.0.1:9', issuer: ISS, log: { warn() {}, error() {} } });
+ const d = fakeRes();
+ await down.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'v' }) } }, d);
+ assert.equal(d.statusCode, 502); assert.match(d.body, /OpenVibe\.Network didn't answer/);
+ assert.equal(await down.checkClient({ force: true }), 'unknown', 'unreachable is unknown, not invalid');
+ // The page escapes what it is given and needs nothing from outside.
+ const page = failurePage({ siteName: '<b>X</b>', title: 'T"', text: "a'b", retryHref: '/auth/login?next=%2F%22x', backHref: '/' });
+ assert.ok(!page.includes('<b>X</b>') && page.includes('<b>X</b>') && page.includes('T"') && page.includes('a'b'));
+ assert.ok(!/<script|https?:\/\//.test(page), 'no script and no outside request');
+ }],
+
+ ['legacy cookies: a site moving here keeps its sessions, then writes the SDK names and clears the old ones', async () => {
+ const moved = createSsoClient({ site: 'cam', baseUrl: 'https://openvibe.cam', clientId: 'cam', clientSecret: 'secret', networkUrl: ISS, networkInternalUrl: base, issuer: ISS, audience: ['openvibe.network', 'openvibe.live'], legacyCookies: { access: ['cam_at'], refresh: [{ name: 'cam_rt', path: '/auth' }, 'bad name;'] }, log: { warn() {}, error() {} } });
+ const me = fakeRes();
+ await moved.handlers.me({ headers: { cookie: cookieHeader({ cam_at: session() }) } }, me);
+ assert.equal(me.body.user.username, 'ana', 'the old access cookie is the session');
+ const r = fakeRes();
+ await moved.handlers.refresh({ headers: { cookie: cookieHeader({ cam_rt: 'old-rt' }) } }, r);
+ assert.ok(r.body.token && r.cookies.ov_token && r.cookies.ov_refresh, 'the old refresh cookie refreshes; the SDK names are written');
+ assert.equal(net.grants.at(-1).refresh_token, 'old-rt');
+ assert.ok(r.cleared.includes('cam_at') && r.cleared.includes('cam_rt') && !r.cleared.includes('bad name;'), 'the old cookies are cleared; a bad name is ignored');
+ const out = fakeRes();
+ await moved.handlers.logout({ headers: { cookie: cookieHeader({ cam_rt: 'x' }) }, query: {} }, out);
+ assert.ok(out.cleared.includes('cam_at') && out.cleared.includes('ov_token'), 'logout clears both');
+ assert.ok(net.revoked.includes('x'), 'logout revokes the old refresh token too');
+ net.revoked.splice(0);
+ const strict = createSsoClient({ site: 'cam', baseUrl: 'https://openvibe.cam', clientId: 'cam', clientSecret: 'secret', networkUrl: ISS, networkInternalUrl: base, issuer: ISS, httpOnlyAccess: true, log: { warn() {}, error() {} } });
+ const s2 = fakeRes();
+ await strict.handlers.refresh({ headers: { cookie: cookieHeader({ ov_refresh: 'r' }) } }, s2);
+ assert.equal(s2.cookies.ov_token.o.httpOnly, true, 'httpOnlyAccess keeps the token from scripts');
+ }],
+
['me and the middleware: a session verifies offline; a FedCM assertion, a service token, an expired token do not', async () => {
['me and the middleware: a session verifies offline; a FedCM assertion, a service token, an expired token do not', async () => {
const me = fakeRes();
const me = fakeRes();
await sso.handlers.me({ headers: { cookie: cookieHeader({ ov_token: session() }) } }, me);
await sso.handlers.me({ headers: { cookie: cookieHeader({ ov_token: session() }) } }, me);
@@ -28,16 +28,22 @@ export interface SsoClientOptions {
@@ -28,16 +28,22 @@ export interface SsoClientOptions {
secureCookies?: boolean;
secureCookies?: boolean;
accessMaxAgeMs?: number;
accessMaxAgeMs?: number;
fetch?: FetchLike;
fetch?: FetchLike;
- log?: Pick<Console, 'warn' | 'log'>;
+ log?: Pick<Console, 'warn' | 'log'> & Partial<Pick<Console, 'error'>>;
+ /** The name the sign-in pages show (default OpenVibe.<Site>). */
+ siteName?: string;
+ /** Keep the access token httpOnly (no script can read it; the navbar asks /auth/me). Default false: the shared navbar reads ov_token. */
+ httpOnlyAccess?: boolean;
+ /** A site's own cookie names before it moved to the SDK: read as the session until the SDK writes its own. */
+ legacyCookies?: { access?: Array<string | { name: string; path?: string }>; refresh?: Array<string | { name: string; path?: string }> };
}
}
/** The verified session's claims without iat/exp/aud/iss/nbf/jti. */
/** The verified session's claims without iat/exp/aud/iss/nbf/jti. */
export type SsoUser = Omit<UserTokenClaims, 'iat' | 'exp' | 'aud' | 'iss' | 'nbf' | 'jti'> & Record<string, unknown>;
export type SsoUser = Omit<UserTokenClaims, 'iat' | 'exp' | 'aud' | 'iss' | 'nbf' | 'jti'> & Record<string, unknown>;
export interface SsoClient {
export interface SsoClient {
- /** GET /login /callback /logout /me, POST /fedcm /refresh; mount at /auth. */
+ /** GET /login /callback /logout /me /health, POST /fedcm /refresh; mount at /auth. */
router(express: any): any;
router(express: any): any;
- handlers: { login: Handler; callback: Handler; fedcm: Handler; logout: Handler; me: Handler; refresh: Handler };
+ handlers: { login: Handler; callback: Handler; fedcm: Handler; logout: Handler; me: Handler; refresh: Handler; health: Handler };
/** Sets req.user and req.token when a valid session is presented. */
/** Sets req.user and req.token when a valid session is presented. */
optionalAuth(): (req: Req, res: Res, next: Next) => void;
optionalAuth(): (req: Req, res: Res, next: Next) => void;
/** 401 JSON without a valid session. */
/** 401 JSON without a valid session. */
@@ -48,6 +54,8 @@ export interface SsoClient {
@@ -48,6 +54,8 @@ export interface SsoClient {
extractToken(req: Req): string | null;
extractToken(req: Req): string | null;
authorizeUrl(opts?: { silent?: boolean }): { url: string; state: string; verifier: string };
authorizeUrl(opts?: { silent?: boolean }): { url: string; state: string; verifier: string };
tokenGrant(body: Record<string, unknown>): Promise<Record<string, any>>;
tokenGrant(body: Record<string, unknown>): Promise<Record<string, any>>;
+ /** Does the Network accept this client? Cached 10 minutes; GET /auth/health answers with it (503 when 'invalid'). */
+ checkClient(opts?: { force?: boolean }): Promise<'ok' | 'invalid' | 'unknown'>;
redirectUri: string;
redirectUri: string;
audience: string | string[];
audience: string | string[];
}
}
@@ -56,6 +64,8 @@ export declare function createSsoClient(opts: SsoClientOptions): SsoClient;
@@ -56,6 +64,8 @@ export declare function createSsoClient(opts: SsoClientOptions): SsoClient;
/** A same-site path, this site's https origin or the Network's; anything else (control characters, backslashes) is '/'. */
/** A same-site path, this site's https origin or the Network's; anything else (control characters, backslashes) is '/'. */
export declare function sanitizeNext(next: unknown, opts?: { baseUrl?: string; networkUrl?: string }): string;
export declare function sanitizeNext(next: unknown, opts?: { baseUrl?: string; networkUrl?: string }): string;
export declare function withParam(target: string, key: string, value: string): string;
export declare function withParam(target: string, key: string, value: string): string;
+/** The self-contained page a sign-in that cannot finish shows (text is escaped). */
+export declare function failurePage(o: { siteName: string; title: string; text: string; retryHref: string; backHref: string }): string;
/** The payload of a JWT, unverified; null when it does not parse. */
/** The payload of a JWT, unverified; null when it does not parse. */
export declare function decodeJwtPayload(token: string): Record<string, any> | null;
export declare function decodeJwtPayload(token: string): Record<string, any> | null;
export declare function fedcmNonceMatches(token: string, nonce: string): boolean;
export declare function fedcmNonceMatches(token: string, nonce: string): boolean;