openvibers/openvibe.network Commits

OpenID Connect discovery at the issuer (roadmap §4.1, §15.4): https://openvibe.network/.well-known/openid-configuration answered with the account page; discovery lived only at /oauth/.well-known/openid-configuration. The document is now served at the issuer's root, at RFC 8414's /.well-known/oauth-authorization-server and still at the old path, and /.well-known/openvibe points at the standard one. The metadata is made true rather than aspirational: it advertised scope openid and RS256 id tokens while none was ever issued, and named /api/auth/me (not OIDC-shaped) as userinfo. Scope openid now adds an RS256 id_token to the code grant (kid ov-network-1 as the JWKS publishes, aud = client, sub = the Network id as a string like access tokens, subject_id, and the authorize nonce, threaded through the account chooser and stored with the code in oauth_codes.nonce); /oauth/userinfo returns the same claims for an unexpired access token. Without openid the token response is unchanged. server/auth/oidc.js; test/oidc-discovery.test.js boots the real server for the documents and runs the code flow for id_token and userinfo

OP OpenVibers committed 24 Sept 2026 · abee5dc11c · 9 files, +268 −26


Changes

ddiff --git a/README.md b/README.md
iindex 290964c2..98bce524 100644
---- a/README.md
++++ b/README.md
@@@ -9,6 +9,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## What it does
- **SSO Identity Provider** — "One Account. All of OpenVibe." Central registration and login, OAuth2 Authorization Code flow for OpenVibe.Live, OpenVibe.Tools, OpenVibe.Games, and OpenVibe.Media.
++- **OpenID Connect** — discovery at the issuer's root, `/.well-known/openid-configuration` (also `/.well-known/oauth-authorization-server`, and the older `/oauth/.well-known/openid-configuration`). With scope `openid` the code grant adds an RS256 `id_token` (aud = client, the authorize `nonce` echoed, `sub` = the Network id as a string, `subject_id` = the canonical usr_ id); `/oauth/userinfo` returns the same claims for an access token (`server/auth/oidc.js`).
- **Unified Notification System** — Cross-service notifications with priority levels, category filtering, toast popups, bell badge, sounds, and rich content (buttons, inputs, media). All services push notifications to the central API; clients poll every 15 seconds.
- **Email Alerts** — Critical notifications (moderation actions, system alerts) can be delivered by the built-in email service. Configuration is managed through the admin panel.
- **Anonymous Users** — Browse and interact without an account. Anon users receive a unique number, can accumulate stats, and optionally link to a registered account later.
ddiff --git a/package.json b/package.json
iindex 6e855f89..ea21bf77 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/public/login.html b/public/login.html
iindex 49bd0df8..12d22825 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -672,6 +672,7 @@ async function selectAccount(account) {
state: params.get('state') || '',
code_challenge: params.get('code_challenge') || undefined,
code_challenge_method: params.get('code_challenge_method') || undefined,
++ nonce: params.get('nonce') || undefined,
}),
});
const data = await res.json();
@@@ -829,6 +830,7 @@ async function handleLogin(e) {
state: params.get('state') || '',
code_challenge: params.get('code_challenge') || undefined,
code_challenge_method: params.get('code_challenge_method') || undefined,
++ nonce: params.get('nonce') || undefined,
}),
});
const confirmData = await confirmRes.json();
@@@ -910,6 +912,7 @@ async function handleRegister(e) {
state: params.get('state') || '',
code_challenge: params.get('code_challenge') || undefined,
code_challenge_method: params.get('code_challenge_method') || undefined,
++ nonce: params.get('nonce') || undefined,
}),
});
const confirmData = await confirmRes.json();
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 4810bf54..5ab1963f 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -11,6 +11,7 @@ const jwt = require('jsonwebtoken');
const subjects = require('../identity/subjects');
const principals = require('../identity/principals');
const devTokens = require('../developer/tokens');
++const oidc = require('./oidc');
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
@@@ -36,11 +37,11 @@ function sameSecret(a, b) {
const y = Buffer.from(String(b || ''));
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
}
--function issueCode(db, { clientId, userId, redirectUri, scope, pkce }) {
++function issueCode(db, { clientId, userId, redirectUri, scope, pkce, nonce }) {
const code = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
-- db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at, code_challenge, code_challenge_method)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt, pkce.challenge, pkce.method);
++ db.prepare(`INSERT INTO oauth_codes (code, client_id, user_id, redirect_uri, scope, expires_at, code_challenge, code_challenge_method, nonce)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(code, clientId, userId, redirectUri, scope || 'profile theme', expiresAt, pkce.challenge, pkce.method, nonce || null);
return code;
}
const withCode = (redirectUri, code, state) => `${redirectUri}${redirectUri.includes('?') ? '&' : '?'}code=${code}&state=${encodeURIComponent(state || '')}`;
@@@ -85,6 +86,8 @@ router.get('/authorize', (req, res) => {
}
const pkce = readChallenge(req.query);
if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
++ const nonce = oidc.cleanNonce(req.query.nonce);
++ if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
// prompt=none (silent SSO from an app whose own session lapsed): if this browser still has a
// live/renewable openvibe.network session, continue as that account with no chooser. With no
@@@ -98,7 +101,7 @@ router.get('/authorize', (req, res) => {
if (!out.error) {
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : have;
setSessionCookies(res, token);
-- const code = issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope, pkce });
++ const code = issueCode(db, { clientId: client_id, userId: out.user.id, redirectUri: redirect_uri, scope, pkce, nonce });
return res.redirect(withCode(redirect_uri, code, state));
}
} catch { /* fall through */ }
@@@ -114,6 +117,7 @@ router.get('/authorize', (req, res) => {
state: state || '',
});
if (pkce.challenge) { loginParams.set('code_challenge', pkce.challenge); loginParams.set('code_challenge_method', pkce.method); }
++ if (nonce) loginParams.set('nonce', nonce);
res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
});
@@@ -171,6 +175,8 @@ router.post('/confirm', (req, res) => {
pkce = readChallenge(req.body);
if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
}
++ const nonce = oidc.cleanNonce(req.body.nonce);
++ if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
// Verify the token as a live, unexpired session: not revoked by a password change
// (token_valid_after), not a FedCM assertion or a service/app token.
@@@ -192,7 +198,7 @@ router.post('/confirm', (req, res) => {
}
// Issue authorization code (bound to the PKCE challenge when the client sent one)
-- const code = issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce });
++ const code = issueCode(db, { clientId: client_id, userId: user.id, redirectUri: redirect_uri, scope, pkce, nonce });
// Also set cookie to this account so openvibe.network itself knows the active session.
// Host-only (NO Domain attribute) — the ov_token cookie belongs to openvibe.network alone.
@@@ -300,12 +306,17 @@ function handleAuthCodeGrant(db, config, req, res, client, code, redirectUri, co
const prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
const { password_hash, token_valid_after, ...safeUser } = user;
++ // OpenID Connect (server/auth/oidc.js): scope openid adds an id_token for this client.
++ const idToken = oidc.wantsOpenid(authCode.scope)
++ ? oidc.idToken({ user: { ...user, subject_id: subjects.ensureUserSubject(db, user) || user.subject_id }, clientId: client.client_id, nonce: authCode.nonce, privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer })
++ : undefined;
res.json({
access_token: accessToken,
refresh_token: refreshToken,
token_type: 'Bearer',
expires_in: 86400, // 24h
scope: authCode.scope,
++ ...(idToken ? { id_token: idToken } : {}),
user: safeUser,
preferences: prefs || { theme_id: 'vibe' },
});
@@@ -411,24 +422,14 @@ function issueTokenPair(db, config, req, user, client) {
return { accessToken, refreshToken: refreshTokenValue };
}
--// ── GET /.well-known/openid-configuration ────────────────────
--// Discovery endpoint for OIDC-compatible clients.
--router.get('/.well-known/openid-configuration', (req, res) => {
-- const config = getConfig(req);
-- res.json({
-- issuer: config.jwt.issuer,
-- authorization_endpoint: `${config.baseUrl}/oauth/authorize`,
-- token_endpoint: `${config.baseUrl}/oauth/token`,
-- userinfo_endpoint: `${config.baseUrl}/api/auth/me`,
-- jwks_uri: `${config.baseUrl}/api/.well-known/jwks`,
-- response_types_supported: ['code'],
-- grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
-- subject_types_supported: ['public'],
-- id_token_signing_alg_values_supported: ['RS256'],
-- scopes_supported: ['profile', 'theme', 'openid'],
-- token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
-- code_challenge_methods_supported: ['S256'],
-- });
--});
++// ── GET /oauth/.well-known/openid-configuration ──────────────
++// The older discovery location; the standard one is the issuer's root (server/auth/oidc.js mounts
++// /.well-known/openid-configuration and /.well-known/oauth-authorization-server). Same document.
++router.get('/.well-known/openid-configuration', oidc.sendMetadata);
++
++// ── GET|POST /oauth/userinfo ─────────────────────────────────
++// OpenID Connect UserInfo: standard claims for a Network access token (Bearer).
++router.get('/userinfo', oidc.userinfo);
++router.post('/userinfo', oidc.userinfo);
module.exports = router;
ddiff --git a/server/auth/oidc.js b/server/auth/oidc.js
nnew file mode 100644
iindex 00000000..202b6220
---- /dev/null
++++ b/server/auth/oidc.js
@@@ -0,0 +1,102 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// OpenID Connect on Network's OAuth 2.0 authorization server (roadmap §4.1, §15.4).
++//
++// GET /.well-known/openid-configuration OIDC Discovery 1.0, at the issuer's root (the
++// standard location: <issuer>/.well-known/...)
++// GET /.well-known/oauth-authorization-server RFC 8414, the same metadata
++// GET /oauth/.well-known/openid-configuration the older location, kept (server/auth/oauth-routes.js)
++// GET|POST /oauth/userinfo standard claims for a Network access token
++//
++// The authorization-code grant adds an id_token when the code's scope includes `openid` (idToken
++// below), echoing the nonce the client sent to /oauth/authorize. `sub` is the account's Network id as
++// a string, the same value access tokens carry; `subject_id` is its canonical usr_ id (ADR-001).
++// ═══════════════════════════════════════════════════════════════
++const jwt = require('jsonwebtoken');
++
++const KID = 'ov-network-1'; // the kid /api/.well-known/jwks publishes for the RS256 key
++const ID_TOKEN_TTL_S = 3600;
++const NONCE_MAX = 255;
++
++/** The discovery document. `issuer` is the token issuer, verbatim; endpoints live at Network's base URL. */
++function metadata(config) {
++ const issuer = config.jwt.issuer;
++ const base = String(config.baseUrl || issuer).replace(/\/+$/, '');
++ return {
++ issuer,
++ authorization_endpoint: `${base}/oauth/authorize`,
++ token_endpoint: `${base}/oauth/token`,
++ userinfo_endpoint: `${base}/oauth/userinfo`,
++ jwks_uri: `${base}/api/.well-known/jwks`,
++ // openid adds an id_token to the code grant; profile and theme are the first-party scopes.
++ // Developer apps (app_<ULID>) ask for capability ids instead (docs/developer-projects.md).
++ scopes_supported: ['openid', 'profile', 'theme'],
++ response_types_supported: ['code'],
++ response_modes_supported: ['query'],
++ grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials', 'urn:ietf:params:oauth:grant-type:jwt-bearer'],
++ subject_types_supported: ['public'],
++ id_token_signing_alg_values_supported: ['RS256'],
++ // 'none' is for public developer apps, which always use PKCE.
++ token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
++ code_challenge_methods_supported: ['S256'],
++ claims_supported: ['iss', 'sub', 'aud', 'exp', 'iat', 'nonce', 'name', 'preferred_username', 'picture', 'subject_id'],
++ request_parameter_supported: false,
++ request_uri_parameter_supported: false,
++ claims_parameter_supported: false,
++ };
++}
++
++function sendMetadata(req, res) {
++ res.set('Cache-Control', 'public, max-age=3600');
++ res.json(metadata(req.app.locals.config));
++}
++
++/** Mount the root discovery documents on the app (before the 404 handler). */
++function mount(app) {
++ app.get(['/.well-known/openid-configuration', '/.well-known/oauth-authorization-server'], sendMetadata);
++}
++
++const wantsOpenid = (scope) => String(scope || '').split(/\s+/).includes('openid');
++
++/** A nonce as the client sent it to /oauth/authorize (printable, bounded), or null. */
++function cleanNonce(v) {
++ if (v === undefined || v === null || v === '') return null;
++ const s = String(v);
++ return s.length <= NONCE_MAX && /^[\x21-\x7e]+$/.test(s) ? s : undefined; // undefined = refuse
++}
++
++function profileClaims(user) {
++ return {
++ preferred_username: user.username,
++ name: user.display_name || user.username,
++ ...(user.avatar_url ? { picture: user.avatar_url } : {}),
++ ...(user.subject_id ? { subject_id: user.subject_id } : {}),
++ };
++}
++
++/** The id_token for a code whose scope includes openid. */
++function idToken({ user, clientId, nonce, privateKey, issuer }) {
++ const rs = String(privateKey).includes('BEGIN');
++ return jwt.sign({ sub: String(user.id), ...profileClaims(user), ...(nonce ? { nonce } : {}) }, privateKey, {
++ algorithm: rs ? 'RS256' : 'HS256', issuer, audience: clientId, expiresIn: ID_TOKEN_TTL_S, ...(rs ? { keyid: KID } : {}),
++ });
++}
++
++/** GET|POST /oauth/userinfo with `Authorization: Bearer <Network access token>`. */
++function userinfo(req, res) {
++ res.set('Cache-Control', 'no-store');
++ const ah = String(req.headers.authorization || '');
++ const token = ah.startsWith('Bearer ') ? ah.slice(7).trim() : '';
++ const fail = (status, error) => res.status(status).set('WWW-Authenticate', `Bearer error="${error}"`).json({ error });
++ if (!token) return fail(401, 'invalid_token');
++ const out = require('./session').verifySession(token, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config: req.app.locals.config });
++ if (out.error) return fail(out.status === 403 ? 403 : 401, out.status === 403 ? 'insufficient_scope' : 'invalid_token');
++ // verifySession renews a recently expired session; an expired bearer token is not accepted here.
++ if (typeof out.decoded.exp !== 'number' || out.decoded.exp * 1000 < Date.now()) return fail(401, 'invalid_token');
++ const user = out.user;
++ let subjectId = user.subject_id || null;
++ try { subjectId = require('../identity/subjects').ensureUserSubject(req.app.locals.db, user) || subjectId; } catch { /* the claim is optional */ }
++ res.json({ sub: String(user.id), ...profileClaims({ ...user, subject_id: subjectId }) });
++}
++
++module.exports = { metadata, mount, sendMetadata, idToken, userinfo, wantsOpenid, cleanNonce, KID, ID_TOKEN_TTL_S };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 0b000182..31c0a186 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -397,6 +397,8 @@ function initDb(dbPath) {
{ table: 'linked_accounts', column: 'service_username', sql: "ALTER TABLE linked_accounts ADD COLUMN service_username TEXT" },
{ table: 'oauth_codes', column: 'code_challenge', sql: "ALTER TABLE oauth_codes ADD COLUMN code_challenge TEXT" },
{ table: 'oauth_codes', column: 'code_challenge_method', sql: "ALTER TABLE oauth_codes ADD COLUMN code_challenge_method TEXT" },
++ // OpenID Connect: the nonce sent to /oauth/authorize, echoed in the id_token (server/auth/oidc.js).
++ { table: 'oauth_codes', column: 'nonce', sql: "ALTER TABLE oauth_codes ADD COLUMN nonce TEXT" },
{ table: 'url_registry', column: 'source', sql: "ALTER TABLE url_registry ADD COLUMN source TEXT NOT NULL DEFAULT 'admin'" },
{ table: 'users', column: 'is_anon', sql: "ALTER TABLE users ADD COLUMN is_anon INTEGER DEFAULT 0" },
{ table: 'users', column: 'anon_number', sql: "ALTER TABLE users ADD COLUMN anon_number INTEGER" },
ddiff --git a/server/index.js b/server/index.js
iindex 6704dc57..4ea531ee 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -622,6 +622,8 @@ app.get('/verify-email', (req, res) => {
const fedcm = require('./auth/fedcm');
const ctx = () => ({ db, publicKey, privateKey, config });
app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));
++ // OpenID Connect discovery at the issuer's root (and RFC 8414's name for it): server/auth/oidc.js
++ require('./auth/oidc').mount(app);
app.use('/fedcm', fedcm.createFedcmRoutes(ctx));
}
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
iindex e43b9062..13ba94d1 100644
---- a/server/registry/ecosystem.js
++++ b/server/registry/ecosystem.js
@@@ -203,7 +203,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
issuer,
jwks_uri: `${issuer}/api/.well-known/jwks`,
token_endpoint: `${issuer}/oauth/token`,
-- openid_configuration: `${issuer}/oauth/.well-known/openid-configuration`,
++ openid_configuration: `${issuer}/.well-known/openid-configuration`,
registry: `${issuer}/api/v1/registry`,
contracts: { package: 'openvibe-contracts', version: contractsPkg.version, repository: 'https://github.com/OpenVibers/OpenVibe.Contracts', catalog: `${issuer}/api/v1/registry/contracts` },
// origin is set only where the public domain serves the service itself; a domain that still
ddiff --git a/test/oidc-discovery.test.js b/test/oidc-discovery.test.js
nnew file mode 100644
iindex 00000000..32dcc923
---- /dev/null
++++ b/test/oidc-discovery.test.js
@@@ -0,0 +1,131 @@
++'use strict';
++// OpenID Connect discovery at the issuer (roadmap §4.1, §15.4): <issuer>/.well-known/openid-configuration
++// answers with correct metadata (and so do RFC 8414's /.well-known/oauth-authorization-server and the older
++// /oauth/.well-known/openid-configuration), and what it advertises is true: scope openid adds an id_token
++// (RS256, kid from the JWKS, aud = client, the authorize nonce echoed), and /oauth/userinfo answers with
++// the same sub for the access token.
++// node test/oidc-discovery.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { bootServer } = require('./helpers/boot-server');
++const { initDb } = require('../server/db/database');
++
++const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_uri', 'response_types_supported', 'subject_types_supported', 'id_token_signing_alg_values_supported'];
++
++(async () => {
++ // ── The real server: discovery at the issuer's root ──
++ const ISSUER = 'https://openvibe.network';
++ const srv = await bootServer({ env: { OV_NETWORK_URL: ISSUER, BASE_URL: ISSUER } });
++ try {
++ const get = (p) => fetch(srv.base + p).then(async r => ({ status: r.status, type: r.headers.get('content-type') || '', body: await r.json().catch(() => null) }));
++ const root = await get('/.well-known/openid-configuration');
++ assert.strictEqual(root.status, 200, 'discovery answers at the issuer root');
++ assert.ok(root.type.includes('application/json'), 'as JSON');
++ for (const k of REQUIRED) assert.ok(root.body[k], `metadata has ${k}`);
++ assert.strictEqual(root.body.issuer, ISSUER, 'issuer is exactly the URL the document is served under');
++ assert.strictEqual(root.body.authorization_endpoint, `${ISSUER}/oauth/authorize`);
++ assert.strictEqual(root.body.token_endpoint, `${ISSUER}/oauth/token`);
++ assert.strictEqual(root.body.userinfo_endpoint, `${ISSUER}/oauth/userinfo`);
++ assert.strictEqual(root.body.jwks_uri, `${ISSUER}/api/.well-known/jwks`);
++ assert.deepStrictEqual(root.body.response_types_supported, ['code']);
++ assert.deepStrictEqual(root.body.code_challenge_methods_supported, ['S256']);
++ assert.ok(root.body.scopes_supported.includes('openid'));
++ assert.ok(root.body.grant_types_supported.includes('authorization_code') && root.body.grant_types_supported.includes('refresh_token'));
++ assert.deepStrictEqual(root.body.id_token_signing_alg_values_supported, ['RS256']);
++ // The same document at RFC 8414's name and at the older path.
++ for (const p of ['/.well-known/oauth-authorization-server', '/oauth/.well-known/openid-configuration']) {
++ const r = await get(p);
++ assert.strictEqual(r.status, 200, `${p} answers`);
++ assert.deepStrictEqual(r.body, root.body, `${p} is the same document`);
++ }
++ // Every endpoint it names is served here (paths relative to this server).
++ for (const k of ['jwks_uri', 'authorization_endpoint', 'userinfo_endpoint']) {
++ const r = await fetch(srv.base + new URL(root.body[k]).pathname, { redirect: 'manual' });
++ assert.notStrictEqual(r.status, 404, `${k} is served (got ${r.status})`);
++ }
++ // The platform descriptor points at the standard location.
++ assert.strictEqual((await get('/.well-known/openvibe')).body.openid_configuration, `${ISSUER}/.well-known/openid-configuration`);
++ } catch (err) { console.error(srv.logs()); throw err; } finally { await srv.stop(); }
++
++ // ── What it advertises is true: id_token and userinfo ──
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-oidc-'));
++ const log = console.log; console.log = () => {};
++ const db = initDb(path.join(dir, 'network.db'));
++ console.log = log;
++ db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
++ db.prepare("INSERT INTO users (id, username, password_hash, display_name) VALUES (7, 'viewer', 'x', 'Viewer')").run();
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ const app = express();
++ app.use(express.json());
++ app.use(express.urlencoded({ extended: true }));
++ app.locals.db = db;
++ app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h', refreshTokenExpiry: '30d' } };
++ app.locals.privateKey = keys.privateKey;
++ app.locals.publicKey = keys.publicKey;
++ app.use('/oauth', require('../server/auth/oauth-routes'));
++ const server = http.createServer(app);
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const REDIRECT = 'https://openvibe.live/api/auth/callback';
++ const userToken = jwt.sign({ sub: 7, id: 7, username: 'viewer' }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '5m' });
++ const post = (p, body) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(async r => ({ status: r.status, body: await r.json() }));
++ const codeOf = (redirect) => new URL(redirect).searchParams.get('code');
++ const exchange = (code) => post('/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: 'live-secret', code, redirect_uri: REDIRECT });
++
++ // authorize carries the nonce to the account chooser; a malformed one is refused
++ let r = await fetch(`${base}/oauth/authorize?${new URLSearchParams({ client_id: 'live', redirect_uri: REDIRECT, response_type: 'code', scope: 'openid profile', state: 's', nonce: 'n-0S6_WzA2Mj' })}`, { redirect: 'manual' });
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(new URL(r.headers.get('location')).searchParams.get('nonce'), 'n-0S6_WzA2Mj', 'the nonce reaches the account chooser');
++ r = await fetch(`${base}/oauth/authorize?${new URLSearchParams({ client_id: 'live', redirect_uri: REDIRECT, response_type: 'code', nonce: 'x'.repeat(300) })}`, { redirect: 'manual' });
++ assert.strictEqual(r.status, 400, 'an oversized nonce is refused');
++
++ // scope openid: an id_token for this client, signed with the JWKS key, nonce echoed
++ let c = await post('/oauth/confirm', { token: userToken, client_id: 'live', redirect_uri: REDIRECT, scope: 'openid profile', state: 's', nonce: 'n-0S6_WzA2Mj' });
++ assert.strictEqual(c.status, 200, JSON.stringify(c.body));
++ let t = await exchange(codeOf(c.body.redirect));
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ assert.ok(t.body.id_token, 'scope openid returns an id_token');
++ const header = JSON.parse(Buffer.from(t.body.id_token.split('.')[0], 'base64url').toString());
++ assert.strictEqual(header.alg, 'RS256');
++ assert.strictEqual(header.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');
++ const id = jwt.verify(t.body.id_token, keys.publicKey, { algorithms: ['RS256'], issuer: ISSUER, audience: 'live' });
++ assert.strictEqual(id.sub, '7', 'sub is the Network id as a string, as access tokens carry it');
++ assert.strictEqual(id.nonce, 'n-0S6_WzA2Mj', 'the nonce is echoed');
++ assert.strictEqual(id.preferred_username, 'viewer');
++ assert.match(id.subject_id, /^usr_/, 'the canonical subject id');
++ assert.ok(id.exp - id.iat <= 3600);
++
++ // without openid: no id_token (the flow Live and the other sites use is unchanged)
++ c = await post('/oauth/confirm', { token: userToken, client_id: 'live', redirect_uri: REDIRECT, scope: 'profile theme' });
++ t = await exchange(codeOf(c.body.redirect));
++ assert.strictEqual(t.status, 200);
++ assert.strictEqual(t.body.id_token, undefined, 'no openid scope, no id_token');
++ const access = t.body.access_token;
++
++ // userinfo: the same sub for the access token; no token, a service-shaped token or garbage → 401
++ let u = await fetch(`${base}/oauth/userinfo`, { headers: { authorization: `Bearer ${access}` } }).then(async x => ({ status: x.status, body: await x.json() }));
++ assert.strictEqual(u.status, 200, JSON.stringify(u.body));
++ assert.strictEqual(u.body.sub, '7');
++ assert.strictEqual(u.body.preferred_username, 'viewer');
++ assert.strictEqual(u.body.name, 'Viewer');
++ assert.match(u.body.subject_id, /^usr_/);
++ u = await fetch(`${base}/oauth/userinfo`, { method: 'POST', headers: { authorization: `Bearer ${access}` } });
++ assert.strictEqual(u.status, 200, 'POST works too');
++ u = await fetch(`${base}/oauth/userinfo`);
++ assert.strictEqual(u.status, 401);
++ assert.match(u.headers.get('www-authenticate'), /invalid_token/);
++ u = await fetch(`${base}/oauth/userinfo`, { headers: { authorization: 'Bearer nope' } });
++ assert.strictEqual(u.status, 401);
++ const expired = jwt.sign({ sub: 7, id: 7, username: 'viewer', iat: Math.floor(Date.now() / 1000) - 7200 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
++ u = await fetch(`${base}/oauth/userinfo`, { headers: { authorization: `Bearer ${expired}` } });
++ assert.strictEqual(u.status, 401, 'an expired access token is refused');
++
++ server.close();
++ console.log('oidc discovery: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });