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
Changes
@@@ -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.@@@ -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"},@@@ -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();@@@ -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, coconst 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, // 24hscope: 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;nnew file mode 100644@@@ -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 };@@@ -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" },@@@ -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));}@@@ -203,7 +203,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = gissuer,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 stillnnew file mode 100644@@@ -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); });
@@ -9,6 +9,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -9,6 +9,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## What it does
## 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.
- **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.
- **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.
- **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.
- **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.
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -672,6 +672,7 @@ async function selectAccount(account) {
@@ -672,6 +672,7 @@ async function selectAccount(account) {
state: params.get('state') || '',
state: params.get('state') || '',
code_challenge: params.get('code_challenge') || undefined,
code_challenge: params.get('code_challenge') || undefined,
code_challenge_method: params.get('code_challenge_method') || undefined,
code_challenge_method: params.get('code_challenge_method') || undefined,
+ nonce: params.get('nonce') || undefined,
}),
}),
});
});
const data = await res.json();
const data = await res.json();
@@ -829,6 +830,7 @@ async function handleLogin(e) {
@@ -829,6 +830,7 @@ async function handleLogin(e) {
state: params.get('state') || '',
state: params.get('state') || '',
code_challenge: params.get('code_challenge') || undefined,
code_challenge: params.get('code_challenge') || undefined,
code_challenge_method: params.get('code_challenge_method') || undefined,
code_challenge_method: params.get('code_challenge_method') || undefined,
+ nonce: params.get('nonce') || undefined,
}),
}),
});
});
const confirmData = await confirmRes.json();
const confirmData = await confirmRes.json();
@@ -910,6 +912,7 @@ async function handleRegister(e) {
@@ -910,6 +912,7 @@ async function handleRegister(e) {
state: params.get('state') || '',
state: params.get('state') || '',
code_challenge: params.get('code_challenge') || undefined,
code_challenge: params.get('code_challenge') || undefined,
code_challenge_method: params.get('code_challenge_method') || undefined,
code_challenge_method: params.get('code_challenge_method') || undefined,
+ nonce: params.get('nonce') || undefined,
}),
}),
});
});
const confirmData = await confirmRes.json();
const confirmData = await confirmRes.json();
@@ -11,6 +11,7 @@ const jwt = require('jsonwebtoken');
@@ -11,6 +11,7 @@ const jwt = require('jsonwebtoken');
const subjects = require('../identity/subjects');
const subjects = require('../identity/subjects');
const principals = require('../identity/principals');
const principals = require('../identity/principals');
const devTokens = require('../developer/tokens');
const devTokens = require('../developer/tokens');
+const oidc = require('./oidc');
const router = express.Router();
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
function getDb(req) { return req.app.locals.db; }
@@ -36,11 +37,11 @@ function sameSecret(a, b) {
@@ -36,11 +37,11 @@ function sameSecret(a, b) {
const y = Buffer.from(String(b || ''));
const y = Buffer.from(String(b || ''));
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
}
}
-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 code = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
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;
return code;
}
}
const withCode = (redirectUri, code, state) => `${redirectUri}${redirectUri.includes('?') ? '&' : '?'}code=${code}&state=${encodeURIComponent(state || '')}`;
const withCode = (redirectUri, code, state) => `${redirectUri}${redirectUri.includes('?') ? '&' : '?'}code=${code}&state=${encodeURIComponent(state || '')}`;
@@ -85,6 +86,8 @@ router.get('/authorize', (req, res) => {
@@ -85,6 +86,8 @@ router.get('/authorize', (req, res) => {
}
}
const pkce = readChallenge(req.query);
const pkce = readChallenge(req.query);
if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
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
// 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
// live/renewable openvibe.network session, continue as that account with no chooser. With no
@@ -98,7 +101,7 @@ router.get('/authorize', (req, res) => {
@@ -98,7 +101,7 @@ router.get('/authorize', (req, res) => {
if (!out.error) {
if (!out.error) {
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : have;
const token = out.renew ? require('./routes').signToken(out.user, req.app.locals.privateKey, getConfig(req)) : have;
setSessionCookies(res, token);
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));
return res.redirect(withCode(redirect_uri, code, state));
}
}
} catch { /* fall through */ }
} catch { /* fall through */ }
@@ -114,6 +117,7 @@ router.get('/authorize', (req, res) => {
@@ -114,6 +117,7 @@ router.get('/authorize', (req, res) => {
state: state || '',
state: state || '',
});
});
if (pkce.challenge) { loginParams.set('code_challenge', pkce.challenge); loginParams.set('code_challenge_method', pkce.method); }
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()}`);
res.redirect(`${getConfig(req).loginUrl}/login?${loginParams.toString()}`);
});
});
@@ -171,6 +175,8 @@ router.post('/confirm', (req, res) => {
@@ -171,6 +175,8 @@ router.post('/confirm', (req, res) => {
pkce = readChallenge(req.body);
pkce = readChallenge(req.body);
if (pkce.error) return res.status(400).json({ error: 'invalid_request', error_description: pkce.error });
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
// 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.
// (token_valid_after), not a FedCM assertion or a service/app token.
@@ -192,7 +198,7 @@ router.post('/confirm', (req, res) => {
@@ -192,7 +198,7 @@ router.post('/confirm', (req, res) => {
}
}
// Issue authorization code (bound to the PKCE challenge when the client sent one)
// 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.
// 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.
// 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
@@ -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 prefs = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(user.id);
const { password_hash, token_valid_after, ...safeUser } = user;
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({
res.json({
access_token: accessToken,
access_token: accessToken,
refresh_token: refreshToken,
refresh_token: refreshToken,
token_type: 'Bearer',
token_type: 'Bearer',
expires_in: 86400, // 24h
expires_in: 86400, // 24h
scope: authCode.scope,
scope: authCode.scope,
+ ...(idToken ? { id_token: idToken } : {}),
user: safeUser,
user: safeUser,
preferences: prefs || { theme_id: 'vibe' },
preferences: prefs || { theme_id: 'vibe' },
});
});
@@ -411,24 +422,14 @@ function issueTokenPair(db, config, req, user, client) {
@@ -411,24 +422,14 @@ function issueTokenPair(db, config, req, user, client) {
return { accessToken, refreshToken: refreshTokenValue };
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;
module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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 };
@@ -397,6 +397,8 @@ function initDb(dbPath) {
@@ -397,6 +397,8 @@ function initDb(dbPath) {
{ table: 'linked_accounts', column: 'service_username', sql: "ALTER TABLE linked_accounts ADD COLUMN service_username TEXT" },
{ 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', 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" },
{ 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: '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: '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" },
{ table: 'users', column: 'anon_number', sql: "ALTER TABLE users ADD COLUMN anon_number INTEGER" },
@@ -622,6 +622,8 @@ app.get('/verify-email', (req, res) => {
@@ -622,6 +622,8 @@ app.get('/verify-email', (req, res) => {
const fedcm = require('./auth/fedcm');
const fedcm = require('./auth/fedcm');
const ctx = () => ({ db, publicKey, privateKey, config });
const ctx = () => ({ db, publicKey, privateKey, config });
app.get('/.well-known/web-identity', fedcm.wellKnown(ctx));
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));
app.use('/fedcm', fedcm.createFedcmRoutes(ctx));
}
}
@@ -203,7 +203,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
@@ -203,7 +203,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
issuer,
issuer,
jwks_uri: `${issuer}/api/.well-known/jwks`,
jwks_uri: `${issuer}/api/.well-known/jwks`,
token_endpoint: `${issuer}/oauth/token`,
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`,
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` },
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
// origin is set only where the public domain serves the service itself; a domain that still
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -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); });