openvibers/openvibe.sdk Commits

0.73.0: sso recovers once, explains itself, and can be checked (#100)

OP OpenVibers committed 14 days ago · f5e5f81cb1 · 12 files, +223 −33

* 0.73.0: sso recovers once, explains itself, and can be checked

A lost or expired flow and a code the Network no longer accepts start the
sign-in once more by themselves (a two-minute ov_oauth_retry cookie, never a
session). A second failure, a client the Network refuses and a Network that does
not answer show a small self-contained page with Try again and Back to <site>,
not a bare line of text. A refused client is logged as a configuration error
naming OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET. GET /auth/health and
checkClient() present a code that is never issued: invalid_grant means the client
is fine, invalid_client that it is not (503). Checked against the production
Network: 400 invalid_grant with the right secret, 401 invalid_client without.

Why: OpenVibe.Cam ran a day on its .env.example placeholder secret; every
sign-in ended on "The Network refused the sign-in code".

* sso: legacyCookies moves a site here without signing anyone out

A site's own cookie names (Cam's cam_at / cam_rt) are read as the session until
the next sign-in or refresh writes ov_token / ov_refresh and clears them; logout
clears and revokes both. The ~25 sites with a copied sign-in module move to this
one next, under the rule that viewer logins never sign out on their own.

* sso: httpOnlyAccess keeps the access token from scripts

The sites that kept their own sign-in code stored the access token httpOnly (their
navbar asks /auth/me); httpOnlyAccess: true keeps that when they move here.
Default unchanged: the shared navbar reads ov_token.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex c3da853..e505886 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,7 +3,26 @@
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.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.
ddiff --git a/README.md b/README.md
iindex 415f164..8ffbfcb 100644
---- a/README.md
++++ b/README.md
@@@ -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).
ddiff --git a/STATUS.json b/STATUS.json
iindex ccd95ed..282dd00 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 9892d6f..4824863 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.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,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 10e99cd..2ed3242 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -722,6 +722,7 @@
"claimsToUser": "function",
"createSsoClient": "function",
"decodeJwtPayload": "function",
++ "failurePage": "function",
"fedcmNonceMatches": "function",
"parseCookies": "function",
"sanitizeNext": "function",
ddiff --git a/esm/sso.mjs b/esm/sso.mjs
iindex 1d8e1c2..31f7410 100644
---- a/esm/sso.mjs
++++ b/esm/sso.mjs
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 3af7517..903b5e3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 31d5802..abb4ebb 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 92c2668..63ffb32 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.72.0';
++const SDK_VERSION = '0.73.0';
module.exports = {
createClient,
ddiff --git a/src/sso/index.js b/src/sso/index.js
iindex 6d142cb..afe3980 100644
---- a/src/sso/index.js
++++ b/src/sso/index.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 }),
};
ddiff --git a/test/sso.test.js b/test/sso.test.js
iindex e1e8026..60580e4 100644
---- a/test/sso.test.js
++++ b/test/sso.test.js
@@@ -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&#39;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&#39;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&#39;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('&lt;b&gt;X&lt;/b&gt;') && page.includes('T&quot;') && page.includes('a&#39;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);
ddiff --git a/types/sso.d.ts b/types/sso.d.ts
iindex 713a1fd..7f3bef6 100644
---- a/types/sso.d.ts
++++ b/types/sso.d.ts
@@@ -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;