openvibers/openrestream Commits
Sign-in: the OAuth half moves onto openvibe-sdk/sso (plan L4) (#207)
Changes
@@@ -1,24 +1,34 @@'use strict';/*** Browser sign-in for the standalone UI: OAuth2 authorization-code client of OpenVibe.Network-- * (client `openre`, redirect https://openre.stream/auth/callback). The same shape as-- * OpenVibe.Community's session layer, without FedCM:++ * (client `openre`, redirect https://openre.stream/auth/callback). The OAuth half is openvibe-sdk/sso's++ * (createSsoClient): the authorize URL, the PKCE S256 pair, the code and refresh grants and GET /auth/health.*-- * GET /auth/login → Network /oauth/authorize (state cookie; ?next= same-site path)-- * GET /auth/callback → code exchange server-side, sets ov_token (+ ov_refresh)++ * GET /auth/login → Network /oauth/authorize (state and verifier cookies; ?next= same-site path)++ * GET /auth/callback → state checked in constant time, code exchange server-side, sets ov_token (+ ov_refresh)++ * GET /auth/health → does the Network accept this OAuth client? 503 only when it refuses it* GET /auth/logout → clears cookies* GET /auth/me → the signed-in user (offline JWT verification)* POST /auth/refresh → refresh_token grant (the shared navbar calls it)*/const crypto = require('crypto');const express = require('express');--const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++const { createSsoClient, failurePage } = require('openvibe-sdk/sso');const ACCESS = 'ov_token';const REFRESH = 'ov_refresh';const HINT = 'ov_sso_hint';const STATE = 'ov_oauth_state';++const VERIFIER = 'ov_oauth_verifier';const NEXT = 'ov_oauth_next';++const SITE_NAME = 'OpenRestream';++++/** Compare two strings in constant time (the state must match the browser that started the flow). */++function sameString(a, b) {++ const x = Buffer.from(String(a || ''));++ const y = Buffer.from(String(b || ''));++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);++}/*** A same-site path, or '/'. An absolute URL on this site's own origin (BASE_URL) counts as its path: the shared navbar's@@@ -39,34 +49,22 @@ function sanitizeNext(next, config) {function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {const router = express.Router();-- const client = new OpenVibeAuthClient({-- clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, redirectUri: config.oauth.redirectUri,-- publicKey: null, authBase: config.networkUrl, internalBase: config.networkInternalUrl,++ const sdkSso = createSsoClient({++ site: 'openre',++ siteName: SITE_NAME,++ baseUrl: config.baseUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ networkUrl: config.networkUrl,++ networkInternalUrl: config.networkInternalUrl,++ secureCookies: config.cookies.secure,++ fetch: fetchImpl,});const base = { sameSite: 'lax', secure: config.cookies.secure };const flow = { ...base, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 };-- async function tokenGrant(body) {-- let lastErr = null;-- for (const b of [config.networkInternalUrl, config.networkUrl]) {-- try {-- const res = await fetchImpl(`${b}/oauth/token`, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json' },-- body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),-- signal: AbortSignal.timeout(10000),-- });-- const data = await res.json().catch(() => ({}));-- if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status });-- return data;-- } catch (err) {-- lastErr = err;-- if (err.status && err.status < 500) throw err;-- }-- }-- throw lastErr || new Error('Network unreachable');-- }--function setSession(res, access, refresh) {res.cookie(ACCESS, access, { ...base, httpOnly: false, path: '/', maxAge: 24 * 60 * 60 * 1000 });if (refresh) res.cookie(REFRESH, refresh, { ...base, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });@@@ -74,34 +72,57 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {}router.get('/login', (req, res) => {-- const { url, state } = client.getAuthorizationUrl(config.oauth.scope);-- res.cookie(STATE, state, flow);++ const started = sdkSso.authorizeUrl();++ res.cookie(STATE, started.state, flow);++ res.cookie(VERIFIER, started.verifier, flow);res.cookie(NEXT, sanitizeNext(req.query.next, config), flow);-- res.redirect(url);++ res.redirect(started.url);});router.get('/callback', async (req, res) => {const { code, state, error } = req.query;const next = sanitizeNext(req.cookies && req.cookies[NEXT], config);const expected = req.cookies && req.cookies[STATE];++ const verifier = req.cookies && req.cookies[VERIFIER];res.clearCookie(STATE, { path: '/auth' });++ res.clearCookie(VERIFIER, { path: '/auth' });res.clearCookie(NEXT, { path: '/auth' });if (error) return res.redirect(`/?auth_error=${encodeURIComponent(String(error))}`);-- if (!code) return res.status(400).type('text/plain').send('Missing authorization code');-- if (!expected || !state || !crypto.timingSafeEqual(-- Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)),-- )) return res.status(400).type('text/plain').send('OAuth state mismatch, please sign in again.');++ const retryHref = `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`;++ const failed = (status, title, text) => res.status(status).type('html').send(failurePage({++ siteName: SITE_NAME, title, text, retryHref, backHref: next || '/',++ }));++ if (!code) return failed(400, 'Sign-in didn\'t finish', 'OpenVibe.Network sent no sign-in code. Start it again; your account is fine.');++ if (!verifier || !sameString(state, expected)) {++ return failed(400, 'Sign-in didn\'t finish', 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.');++ }try {-- const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });++ const data = await sdkSso.tokenGrant({++ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code: String(code), code_verifier: verifier,++ });setSession(res, data.access_token, data.refresh_token);// No Clear-Site-Data here or on sign-out: Chrome holds such an answer (and the cookies it sets) until the// cache is cleared, which on phones left people on the account chooser with no session (2026-10-28).return res.redirect(next);} catch (err) {-- return res.status(502).type('text/plain').send(`Sign-in failed: ${err.message}`);++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {++ console.error(`[OpenRestream] the Network refuses this site's OAuth client (${err.error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);++ return failed(500, 'Sign-in isn\'t working on OpenRestream right now', 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.');++ }++ if (err.status && err.status < 500) {++ return failed(400, 'Sign-in didn\'t finish', 'OpenVibe.Network did not accept that sign-in code. Start it again; your account is fine.');++ }++ return failed(502, 'OpenVibe.Network didn\'t answer', 'Your account is fine: the sign-in service did not reply in time. Try again in a moment.');}});++ // GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it.++ router.get('/health', async (_req, res) => {++ const client = await sdkSso.checkClient();++ res.set('Cache-Control', 'no-store');++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });++ });++router.get('/logout', (req, res) => {res.clearCookie(ACCESS, { path: '/' });res.clearCookie(REFRESH, { path: '/auth' });@@@ -121,7 +142,7 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {const refresh = req.cookies && req.cookies[REFRESH];if (!refresh) return res.status(401).json({ error: 'No refresh token' });try {-- const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });++ const data = await sdkSso.tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });setSession(res, data.access_token, data.refresh_token);return res.json({ token: data.access_token });} catch (err) {nnew file mode 100644@@@ -0,0 +1,47 @@++'use strict';++// GET /auth/health (plan L4): 200 when the Network accepts this site's OAuth client, 503 only when it refuses it.++const assert = require('assert');++const express = require('express');++const { createSsoRoutes } = require('../server/auth/sso');++++const config = {++ baseUrl: 'https://openre.stream',++ networkUrl: 'https://network.test',++ networkInternalUrl: 'https://network.test',++ oauth: { clientId: 'openre', clientSecret: 's', redirectUri: 'https://openre.stream/auth/callback', scope: 'profile' },++ cookies: { secure: true },++};++++async function probe(reply) {++ const calls = [];++ const app = express();++ app.use('/auth', createSsoRoutes({++ config,++ auth: { verifyUser: async () => null },++ fetchImpl: async (url, init) => { calls.push({ url: String(url), body: init.body }); return reply(); },++ }));++ const server = app.listen(0);++ await new Promise((resolve) => server.once('listening', resolve));++ try {++ const res = await fetch(`http://127.0.0.1:${server.address().port}/auth/health`);++ return { status: res.status, body: await res.json(), calls };++ } finally {++ server.close();++ }++}++++(async () => {++ // The health check presents a code no sign-in ever had: invalid_grant means the client id and secret are right.++ let r = await probe(() => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400, headers: { 'Content-Type': 'application/json' } }));++ assert.deepStrictEqual([r.status, r.body], [200, { ok: true, client: 'ok' }]);++ assert.strictEqual(JSON.parse(r.calls[0].body).grant_type, 'authorization_code');++++ // invalid_client: the Network refuses this client.++ r = await probe(() => new Response(JSON.stringify({ error: 'invalid_client' }), { status: 401, headers: { 'Content-Type': 'application/json' } }));++ assert.deepStrictEqual([r.status, r.body], [503, { ok: false, client: 'invalid' }]);++++ // A Network that cannot be reached is not a refusal.++ r = await probe(() => { throw new Error('down'); });++ assert.deepStrictEqual([r.status, r.body], [200, { ok: true, client: 'unknown' }]);++ console.log('auth health: 200 unless the Network refuses the client');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,24 +1,34 @@
@@ -1,24 +1,34 @@
'use strict';
'use strict';
/**
/**
* Browser sign-in for the standalone UI: OAuth2 authorization-code client of OpenVibe.Network
* Browser sign-in for the standalone UI: OAuth2 authorization-code client of OpenVibe.Network
- * (client `openre`, redirect https://openre.stream/auth/callback). The same shape as
- * OpenVibe.Community's session layer, without FedCM:
+ * (client `openre`, redirect https://openre.stream/auth/callback). The OAuth half is openvibe-sdk/sso's
+ * (createSsoClient): the authorize URL, the PKCE S256 pair, the code and refresh grants and GET /auth/health.
*
*
- * GET /auth/login → Network /oauth/authorize (state cookie; ?next= same-site path)
- * GET /auth/callback → code exchange server-side, sets ov_token (+ ov_refresh)
+ * GET /auth/login → Network /oauth/authorize (state and verifier cookies; ?next= same-site path)
+ * GET /auth/callback → state checked in constant time, code exchange server-side, sets ov_token (+ ov_refresh)
+ * GET /auth/health → does the Network accept this OAuth client? 503 only when it refuses it
* GET /auth/logout → clears cookies
* GET /auth/logout → clears cookies
* GET /auth/me → the signed-in user (offline JWT verification)
* GET /auth/me → the signed-in user (offline JWT verification)
* POST /auth/refresh → refresh_token grant (the shared navbar calls it)
* POST /auth/refresh → refresh_token grant (the shared navbar calls it)
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const ACCESS = 'ov_token';
const ACCESS = 'ov_token';
const REFRESH = 'ov_refresh';
const REFRESH = 'ov_refresh';
const HINT = 'ov_sso_hint';
const HINT = 'ov_sso_hint';
const STATE = 'ov_oauth_state';
const STATE = 'ov_oauth_state';
+const VERIFIER = 'ov_oauth_verifier';
const NEXT = 'ov_oauth_next';
const NEXT = 'ov_oauth_next';
+const SITE_NAME = 'OpenRestream';
+
+/** Compare two strings in constant time (the state must match the browser that started the flow). */
+function sameString(a, b) {
+ const x = Buffer.from(String(a || ''));
+ const y = Buffer.from(String(b || ''));
+ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
+}
/**
/**
* A same-site path, or '/'. An absolute URL on this site's own origin (BASE_URL) counts as its path: the shared navbar's
* A same-site path, or '/'. An absolute URL on this site's own origin (BASE_URL) counts as its path: the shared navbar's
@@ -39,34 +49,22 @@ function sanitizeNext(next, config) {
@@ -39,34 +49,22 @@ function sanitizeNext(next, config) {
function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
const router = express.Router();
const router = express.Router();
- const client = new OpenVibeAuthClient({
- clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, redirectUri: config.oauth.redirectUri,
- publicKey: null, authBase: config.networkUrl, internalBase: config.networkInternalUrl,
+ const sdkSso = createSsoClient({
+ site: 'openre',
+ siteName: SITE_NAME,
+ baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ networkUrl: config.networkUrl,
+ networkInternalUrl: config.networkInternalUrl,
+ secureCookies: config.cookies.secure,
+ fetch: fetchImpl,
});
});
const base = { sameSite: 'lax', secure: config.cookies.secure };
const base = { sameSite: 'lax', secure: config.cookies.secure };
const flow = { ...base, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 };
const flow = { ...base, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 };
- async function tokenGrant(body) {
- let lastErr = null;
- for (const b of [config.networkInternalUrl, config.networkUrl]) {
- try {
- const res = await fetchImpl(`${b}/oauth/token`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
- signal: AbortSignal.timeout(10000),
- });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), { status: res.status });
- return data;
- } catch (err) {
- lastErr = err;
- if (err.status && err.status < 500) throw err;
- }
- }
- throw lastErr || new Error('Network unreachable');
- }
-
function setSession(res, access, refresh) {
function setSession(res, access, refresh) {
res.cookie(ACCESS, access, { ...base, httpOnly: false, path: '/', maxAge: 24 * 60 * 60 * 1000 });
res.cookie(ACCESS, access, { ...base, httpOnly: false, path: '/', maxAge: 24 * 60 * 60 * 1000 });
if (refresh) res.cookie(REFRESH, refresh, { ...base, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });
if (refresh) res.cookie(REFRESH, refresh, { ...base, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });
@@ -74,34 +72,57 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
@@ -74,34 +72,57 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
}
}
router.get('/login', (req, res) => {
router.get('/login', (req, res) => {
- const { url, state } = client.getAuthorizationUrl(config.oauth.scope);
- res.cookie(STATE, state, flow);
+ const started = sdkSso.authorizeUrl();
+ res.cookie(STATE, started.state, flow);
+ res.cookie(VERIFIER, started.verifier, flow);
res.cookie(NEXT, sanitizeNext(req.query.next, config), flow);
res.cookie(NEXT, sanitizeNext(req.query.next, config), flow);
- res.redirect(url);
+ res.redirect(started.url);
});
});
router.get('/callback', async (req, res) => {
router.get('/callback', async (req, res) => {
const { code, state, error } = req.query;
const { code, state, error } = req.query;
const next = sanitizeNext(req.cookies && req.cookies[NEXT], config);
const next = sanitizeNext(req.cookies && req.cookies[NEXT], config);
const expected = req.cookies && req.cookies[STATE];
const expected = req.cookies && req.cookies[STATE];
+ const verifier = req.cookies && req.cookies[VERIFIER];
res.clearCookie(STATE, { path: '/auth' });
res.clearCookie(STATE, { path: '/auth' });
+ res.clearCookie(VERIFIER, { path: '/auth' });
res.clearCookie(NEXT, { path: '/auth' });
res.clearCookie(NEXT, { path: '/auth' });
if (error) return res.redirect(`/?auth_error=${encodeURIComponent(String(error))}`);
if (error) return res.redirect(`/?auth_error=${encodeURIComponent(String(error))}`);
- if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
- if (!expected || !state || !crypto.timingSafeEqual(
- Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)),
- )) return res.status(400).type('text/plain').send('OAuth state mismatch, please sign in again.');
+ const retryHref = `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`;
+ const failed = (status, title, text) => res.status(status).type('html').send(failurePage({
+ siteName: SITE_NAME, title, text, retryHref, backHref: next || '/',
+ }));
+ if (!code) return failed(400, 'Sign-in didn\'t finish', 'OpenVibe.Network sent no sign-in code. Start it again; your account is fine.');
+ if (!verifier || !sameString(state, expected)) {
+ return failed(400, 'Sign-in didn\'t finish', 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.');
+ }
try {
try {
- const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
+ const data = await sdkSso.tokenGrant({
+ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code: String(code), code_verifier: verifier,
+ });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
// No Clear-Site-Data here or on sign-out: Chrome holds such an answer (and the cookies it sets) until the
// No Clear-Site-Data here or on sign-out: Chrome holds such an answer (and the cookies it sets) until the
// cache is cleared, which on phones left people on the account chooser with no session (2026-10-28).
// cache is cleared, which on phones left people on the account chooser with no session (2026-10-28).
return res.redirect(next);
return res.redirect(next);
} catch (err) {
} catch (err) {
- return res.status(502).type('text/plain').send(`Sign-in failed: ${err.message}`);
+ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
+ console.error(`[OpenRestream] the Network refuses this site's OAuth client (${err.error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);
+ return failed(500, 'Sign-in isn\'t working on OpenRestream right now', 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.');
+ }
+ if (err.status && err.status < 500) {
+ return failed(400, 'Sign-in didn\'t finish', 'OpenVibe.Network did not accept that sign-in code. Start it again; your account is fine.');
+ }
+ return failed(502, 'OpenVibe.Network didn\'t answer', 'Your account is fine: the sign-in service did not reply in time. Try again in a moment.');
}
}
});
});
+ // GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it.
+ router.get('/health', async (_req, res) => {
+ const client = await sdkSso.checkClient();
+ res.set('Cache-Control', 'no-store');
+ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
+ });
+
router.get('/logout', (req, res) => {
router.get('/logout', (req, res) => {
res.clearCookie(ACCESS, { path: '/' });
res.clearCookie(ACCESS, { path: '/' });
res.clearCookie(REFRESH, { path: '/auth' });
res.clearCookie(REFRESH, { path: '/auth' });
@@ -121,7 +142,7 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
@@ -121,7 +142,7 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
const refresh = req.cookies && req.cookies[REFRESH];
const refresh = req.cookies && req.cookies[REFRESH];
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 sdkSso.tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
return res.json({ token: data.access_token });
return res.json({ token: data.access_token });
} catch (err) {
} catch (err) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
+'use strict';
+// GET /auth/health (plan L4): 200 when the Network accepts this site's OAuth client, 503 only when it refuses it.
+const assert = require('assert');
+const express = require('express');
+const { createSsoRoutes } = require('../server/auth/sso');
+
+const config = {
+ baseUrl: 'https://openre.stream',
+ networkUrl: 'https://network.test',
+ networkInternalUrl: 'https://network.test',
+ oauth: { clientId: 'openre', clientSecret: 's', redirectUri: 'https://openre.stream/auth/callback', scope: 'profile' },
+ cookies: { secure: true },
+};
+
+async function probe(reply) {
+ const calls = [];
+ const app = express();
+ app.use('/auth', createSsoRoutes({
+ config,
+ auth: { verifyUser: async () => null },
+ fetchImpl: async (url, init) => { calls.push({ url: String(url), body: init.body }); return reply(); },
+ }));
+ const server = app.listen(0);
+ await new Promise((resolve) => server.once('listening', resolve));
+ try {
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/auth/health`);
+ return { status: res.status, body: await res.json(), calls };
+ } finally {
+ server.close();
+ }
+}
+
+(async () => {
+ // The health check presents a code no sign-in ever had: invalid_grant means the client id and secret are right.
+ let r = await probe(() => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400, headers: { 'Content-Type': 'application/json' } }));
+ assert.deepStrictEqual([r.status, r.body], [200, { ok: true, client: 'ok' }]);
+ assert.strictEqual(JSON.parse(r.calls[0].body).grant_type, 'authorization_code');
+
+ // invalid_client: the Network refuses this client.
+ r = await probe(() => new Response(JSON.stringify({ error: 'invalid_client' }), { status: 401, headers: { 'Content-Type': 'application/json' } }));
+ assert.deepStrictEqual([r.status, r.body], [503, { ok: false, client: 'invalid' }]);
+
+ // A Network that cannot be reached is not a refusal.
+ r = await probe(() => { throw new Error('down'); });
+ assert.deepStrictEqual([r.status, r.body], [200, { ok: true, client: 'unknown' }]);
+ console.log('auth health: 200 unless the Network refuses the client');
+})().catch((err) => { console.error(err); process.exit(1); });