openvibers/openvibe.vip Commits
Sign-in: the OAuth half is openvibe-sdk/sso (#187)
Changes
@@@ -138,7 +138,7 @@ async function createApp(opts = {}) {// opts.limitsNow: the limiter's clock (tests).const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log, valkey });app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));-- app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));++ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).@@@ -11,7 +11,6 @@*/const contracts = require('openvibe-contracts');const { createNetworkKeys, verifyUserToken, verifyServiceToken } = require('openvibe-sdk/auth');--const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');/** keys.verifyOptions (spread into the verifiers), keys.loaded(), keys.start(), keys.stop(), keys.refresh(). */function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {@@@ -24,18 +23,10 @@ async function verifyService(config, keys, token) {}/**-- * The OAuth client of the browser session layer plus offline verification of the Network user-- * JWT (RS256, issuer = the Network's public URL).++ * Offline verification of the Network user JWT (RS256, issuer = the Network's public URL). The OAuth half++ * is openvibe-sdk/sso's (server/web/session.js).*/function createUserAuth(config, keys) {-- const client = new OpenVibeAuthClient({-- clientId: config.oauth.clientId,-- clientSecret: config.oauth.clientSecret,-- redirectUri: config.oauth.redirectUri,-- publicKey: null,-- authBase: config.network.url,-- internalBase: config.network.internalUrl,-- });/*** Claims of a valid user token, or null. openvibe-sdk/auth refuses service, app and mod principals and typed* tokens (a realtime ticket, an export token): none of them is a session.@@@ -44,7 +35,7 @@ function createUserAuth(config, keys) {if (!token) return null;try { return await verifyUserToken(token, { ...keys.verifyOptions, issuer: config.network.issuer }); } catch { return null; }}-- return { client, verify };++ return { verify };}module.exports = { createKeyProvider, createUserAuth, verifyService };@@@ -5,27 +5,38 @@* shape as OpenVibe.Community's server/auth/routes.js. Registered in the Network as client `vip`* with redirect https://openvibe.vip/auth/callback.*++ * The OAuth half is openvibe-sdk/sso's (createSsoClient): the authorize URL, the PKCE S256 pair (new++ * here: this copied layer had none), the code, refresh and FedCM grants and the new GET /auth/health.++ * The state is compared with the flow cookie in constant time, next is still sanitized by this repo's++ * sanitizer, the viewer resolver and every permission check are untouched, and the redirect URI is++ * unchanged.++ ** GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)-- * GET /auth/callback → server-side code exchange, session cookies++ * GET /auth/callback → server-side code exchange with the PKCE verifier, session cookies* POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)++ * GET /auth/health → does the Network accept this OAuth client? 503 only when it refuses it* GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest* GET /auth/me → the signed-in user (offline JWT verification)* POST /auth/refresh → rotate tokens*-- * Cookies (host-only): ov_token (access JWT, Lax, JS-readable for the navbar), ov_refresh (httpOnly,-- * Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;-- * the API never reads them.++ * Cookies (host-only, the names this site has always used): ov_token (access JWT, Lax, JS-readable for the navbar),++ * ov_refresh (httpOnly, Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;++ * the API never reads them. The short-lived flow cookies on /auth are++ * ov_oauth_state, ov_oauth_next, ov_oauth_silent plus the PKCE verifier in ov_oauth_verifier.*/const express = require('express');const crypto = require('crypto');++const { createSsoClient, failurePage } = require('openvibe-sdk/sso');const { userPrincipal } = require('../api/auth');const ACCESS_COOKIE = 'ov_token';const REFRESH_COOKIE = 'ov_refresh';const HINT_COOKIE = 'ov_sso_hint';const STATE_COOKIE = 'ov_oauth_state';++const VERIFIER_COOKIE = 'ov_oauth_verifier';const NEXT_COOKIE = 'ov_oauth_next';const SILENT_COOKIE = 'ov_oauth_silent';++const SITE_NAME = 'OpenVibe.VIP';function claimsToUser(claims) {if (!claims) return null;@@@ -61,6 +72,13 @@ function withParam(target, key, value) {return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;}++/** Compare two strings in constant time (the state must match the browser that started the flow). */++function sameString(a, b) {++ const x = Buffer.from(String(a || ''));++ const y = Buffer.from(String(b || ''));++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);++}++/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */function viewerMiddleware(userAuth) {return async (req, _res, next) => {@@@ -72,9 +90,25 @@ function viewerMiddleware(userAuth) {};}--function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch } = {}) {++function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch, log = console } = {}) {const router = express.Router();const secure = config.cookies.secure;++ // The OAuth half is the SDK's: the authorize URL, the PKCE S256 pair, the token grants and the health check.++ const sdkSso = createSsoClient({++ site: 'vip',++ siteName: SITE_NAME,++ baseUrl: config.baseUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ networkUrl: config.network.url,++ networkInternalUrl: config.network.internalUrl,++ issuer: config.network.issuer,++ secureCookies: secure,++ fetch: fetchImpl,++ log,++ });const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });@@@ -89,44 +123,18 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });}-- function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }---- async function tokenGrant(body) {-- let lastErr = null;-- for (const base of [config.network.internalUrl, config.network.url]) {-- if (!base) continue;-- try {-- const res = await fetchImpl(`${base}/oauth/token`, {-- method: 'POST', headers: { 'Content-Type': 'application/json' },-- body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),-- signal: AbortSignal.timeout(10_000),-- });-- const data = await res.json().catch(() => ({}));-- if (!res.ok) {-- const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);-- err.status = res.status; err.error = data.error || 'invalid_grant';-- throw err;-- }-- return data;-- } catch (err) {-- lastErr = err;-- if (err.status && err.status < 500) throw err;-- }-- }-- throw lastErr || new Error('Network unreachable');-- }++ function clearFlow(res) { for (const c of [STATE_COOKIE, VERIFIER_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }router.get('/login', async (req, res) => {const silent = !!req.query.silent && req.query.silent !== '0';if (silent && await userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }-- const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);-- let target = url;-- if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }-- res.cookie(STATE_COOKIE, state, flowOpts());++ const started = sdkSso.authorizeUrl({ silent });++ res.cookie(STATE_COOKIE, started.state, flowOpts());++ res.cookie(VERIFIER_COOKIE, started.verifier, flowOpts());const next = sanitizeNext(req.query.next, config);if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });-- res.redirect(target);++ res.redirect(started.url);});router.get('/callback', async (req, res) => {@@@ -140,20 +148,38 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }}if (!code) return res.status(400).type('text/plain').send('Missing authorization code');const expected = req.cookies && req.cookies[STATE_COOKIE];++ const verifier = req.cookies && req.cookies[VERIFIER_COOKIE];clearFlow(res);-- if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {++ if (!verifier || !sameString(state, expected)) {return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');}try {-- const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });++ const data = await sdkSso.tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code: String(code), code_verifier: verifier });setSession(res, data.access_token, data.refresh_token);return res.redirect(next);} catch (err) {-- console.error('[VIP] code exchange failed:', err.message);++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {++ log.error(`[VIP] the Network refuses this site's OAuth client (${err.error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);++ return res.status(500).type('html').send(failurePage({++ siteName: SITE_NAME,++ title: 'Sign-in isn\'t working on OpenVibe.Bot right now',++ text: 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.',++ retryHref: `/auth/login?next=${encodeURIComponent(next)}`,++ backHref: next || '/',++ }));++ }++ log.error(`[VIP] code exchange failed: ${err.message}`);return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');}});++ // GET /auth/health: does the Network accept this OAuth client? 503 only when it refuses it.++ router.get('/health', async (_req, res) => {++ const client = await sdkSso.checkClient();++ res.set('Cache-Control', 'no-store');++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });++ });++const fedcmBody = express.json({ limit: '16kb' });router.post('/fedcm', fedcmBody, async (req, res) => {const { token, nonce } = req.body || {};@@@ -164,7 +190,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });try {-- const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });++ const data = await sdkSso.tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });setSession(res, data.access_token, data.refresh_token);return res.json({ ok: true, user: data.user || claimsToUser(await userAuth.verify(data.access_token)) });@@@ -201,7 +227,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }const refresh = req.cookies && req.cookies[REFRESH_COOKIE];if (!refresh) return res.status(401).json({ error: 'No refresh token' });try {-- const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });++ const data = await sdkSso.tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });setSession(res, data.access_token, data.refresh_token);return res.json({ token: data.access_token, user: claimsToUser(await userAuth.verify(data.access_token)) });} catch (err) {nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * The sign-in (openvibe-sdk/sso): the authorize URL carries PKCE S256, the flow cookies are the ones this site++ * has always used (ov_oauth_state, ov_oauth_next, ov_oauth_silent and the PKCE verifier in ov_oauth_verifier),++ * the state is compared with the flow cookie in constant time, next is sanitized at the login and at the++ * callback, and GET /auth/health is 503 only when the Network refuses this site's OAuth client.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, harness } = require('./helpers/app');++++const { test, run } = harness('auth');++++(async () => {++ const t = await boot();++ const get = (p, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers });++ const cookies = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]);++ const cookieHeader = (res) => cookies(res).join('; ');++ const flow = (res, name) => res.headers.getSetCookie().find((c) => c.startsWith(name + '=')) || '';++++ test('the sign-in starts at the Network with PKCE S256 and the flow cookies this site has always used', async () => {++ const res = await get('/auth/login?next=%2Fupdates');++ assert.strictEqual(res.status, 302);++ const u = new URL(res.headers.get('location'));++ assert.deepStrictEqual([u.pathname, u.searchParams.get('client_id'), u.searchParams.get('redirect_uri'), u.searchParams.get('response_type')],++ ['/oauth/authorize', 'vip', 'http://vip.test/auth/callback', 'code']);++ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256', 'PKCE S256 from openvibe-sdk/sso');++ const challenge = u.searchParams.get('code_challenge');++ assert.match(challenge, /^[A-Za-z0-9_-]{43}$/, 'the code challenge');++ const state = u.searchParams.get('state');++ assert.ok(state && state.length >= 16, 'the state');++ assert.match(flow(res, 'ov_oauth_state'), new RegExp('^ov_oauth_state=' + state));++ assert.match(flow(res, 'ov_oauth_verifier'), /Path=\/auth/i, 'the verifier stays in a flow cookie of its own');++ // express encodes the cookie's value: the path arrives percent-encoded.++ const nextCookie = flow(res, 'ov_oauth_next').split(';')[0].split('=')[1];++ assert.strictEqual(decodeURIComponent(nextCookie), '/updates');++ // The challenge is the SHA-256 of the verifier in the cookie: the Network checks exactly that.++ const verifier = flow(res, 'ov_oauth_verifier').split(';')[0].split('=')[1];++ assert.strictEqual(crypto.createHash('sha256').update(verifier).digest('base64url'), challenge);++ });++++ test('a callback whose state does not match the flow cookie is refused, and no session is set', async () => {++ const started = await get('/auth/login');++ const res = await get('/auth/callback?code=whatever&state=not-the-state', { Cookie: cookieHeader(started) });++ assert.strictEqual(res.status, 400);++ assert.ok(!res.headers.getSetCookie().some((c) => c.startsWith('ov_token=')), 'no session');++ });++++ test('a callback without the verifier cookie is refused too (the code could not be exchanged)', async () => {++ const started = await get('/auth/login');++ const u = new URL(started.headers.get('location'));++ const without = cookies(started).filter((c) => !c.startsWith('ov_oauth_verifier=')).join('; ');++ const res = await get('/auth/callback?code=whatever&state=' + encodeURIComponent(u.searchParams.get('state')), { Cookie: without });++ assert.strictEqual(res.status, 400);++ assert.ok(!res.headers.getSetCookie().some((c) => c.startsWith('ov_token=')), 'no session');++ });++++ test('a sign-in with a code the Network issued sets the session and returns to next', async () => {++ const started = await get('/auth/login?next=%2Fupdates');++ const u = new URL(started.headers.get('location'));++ const code = t.network.issueCode();++ const res = await get('/auth/callback?code=' + code + '&state=' + encodeURIComponent(u.searchParams.get('state')), { Cookie: cookieHeader(started) });++ assert.strictEqual(res.status, 302);++ assert.strictEqual(res.headers.get('location'), '/updates');++ const set = res.headers.getSetCookie();++ assert.ok(set.some((c) => c.startsWith('ov_token=')), 'the session cookie, with the name this site has always used');++ assert.ok(set.some((c) => c.startsWith('ov_refresh=')), 'the refresh cookie');++ });++++ test('next is sanitized: off-site never, neither at the login nor at the callback', async () => {++ const started = await get('/auth/login?next=https%3A%2F%2Fevil.com%2Fx');++ const offSite = flow(started, 'ov_oauth_next').split(';')[0].split('=')[1];++ assert.ok(!offSite, 'an off-site next is not kept');++ const again = await get('/auth/login');++ const u = new URL(again.headers.get('location'));++ const code = t.network.issueCode();++ const res = await get('/auth/callback?code=' + code + '&state=' + encodeURIComponent(u.searchParams.get('state')),++ { Cookie: cookieHeader(again) + '; ov_oauth_next=//evil.com/x' });++ assert.strictEqual(res.status, 302);++ assert.strictEqual(res.headers.get('location'), '/', 'a tampered next goes home');++ });++++ test('GET /auth/health: 200 while the Network accepts this client, 503 when it refuses it', async () => {++ const res = await get('/auth/health');++ assert.strictEqual(res.status, 200);++ const json = await res.json();++ assert.deepStrictEqual([json.ok, json.client], [true, 'ok']);++++ const broken = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'not-the-secret' } });++ try {++ const refused = await fetch(broken.base + '/auth/health', { redirect: 'manual' });++ assert.strictEqual(refused.status, 503);++ const reported = await refused.json();++ assert.deepStrictEqual([reported.ok, reported.client], [false, 'invalid']);++ } finally { await broken.close(); }++ });++++ await run().finally(async () => await t.close());++})();@@@ -31,6 +31,8 @@ async function startNetwork() {const publicPem = publicKey.export({ type: 'spki', format: 'pem' });const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });const grants = [];++ // The sign-in codes Network issued (openvibe-sdk/sso exchanges one with the PKCE verifier): code → tokens.++ const codes = new Map();let issuer = 'http://network.test';let n = 0;@@@ -51,9 +53,17 @@ async function startNetwork() {const raw = await readBody(req);if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });if (req.url === '/oauth/token' && req.method === 'POST') {-- const body = Object.fromEntries(new URLSearchParams(raw));++ // The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (the service clients).++ const body = raw.trim().startsWith('{') ? JSON.parse(raw) : Object.fromEntries(new URLSearchParams(raw));grants.push(body);if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });++ // A sign-in's code: the Network answers invalid_grant for a code it never issued.++ if (body.grant_type === 'authorization_code') {++ const c = codes.get(String(body.code || ''));++ if (!c) return send(res, 400, { error: 'invalid_grant', error_description: 'unknown or expired code' });++ codes.delete(String(body.code));++ return send(res, 200, { access_token: c.access_token, refresh_token: c.refresh_token || null, token_type: 'Bearer', expires_in: 300 });++ }const cap = String(body.scope || '').split(/\s+/).filter(Boolean);return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });}@@@ -61,7 +71,13 @@ async function startNetwork() {});const url = await listen(server);issuer = url;-- return { url, publicPem, signService, signUser, newUser, grants, close: () => new Promise((r) => server.close(r)) };++ /** A sign-in code the stand-in Network accepts: the code the browser comes back with. */++ function issueCode({ access_token = null, refresh_token = 'refresh_test' } = {}) {++ const code = `code_${crypto.randomBytes(8).toString('hex')}`;++ codes.set(code, { access_token: access_token || signUser({ subject: 'usr_test', username: 'someone' }), refresh_token });++ return code;++ }++ return { url, publicPem, signService, signUser, newUser, grants, issueCode, close: () => new Promise((r) => server.close(r)) };}/**
@@ -138,7 +138,7 @@ async function createApp(opts = {}) {
@@ -138,7 +138,7 @@ async function createApp(opts = {}) {
// opts.limitsNow: the limiter's clock (tests).
// opts.limitsNow: the limiter's clock (tests).
const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log, valkey });
const limits = createActorLimits({ config, now: opts.limitsNow || now, registry: metrics.registry, log, valkey });
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits }));
- app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
+ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'vip', service: 'vip', host: 'openvibe.vip', name: 'OpenVibe.VIP', profile: 'ugc' })); }
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
@@ -11,7 +11,6 @@
@@ -11,7 +11,6 @@
*/
*/
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { createNetworkKeys, verifyUserToken, verifyServiceToken } = require('openvibe-sdk/auth');
const { createNetworkKeys, verifyUserToken, verifyServiceToken } = require('openvibe-sdk/auth');
-const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
/** keys.verifyOptions (spread into the verifiers), keys.loaded(), keys.start(), keys.stop(), keys.refresh(). */
/** keys.verifyOptions (spread into the verifiers), keys.loaded(), keys.start(), keys.stop(), keys.refresh(). */
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
@@ -24,18 +23,10 @@ async function verifyService(config, keys, token) {
@@ -24,18 +23,10 @@ async function verifyService(config, keys, token) {
}
}
/**
/**
- * The OAuth client of the browser session layer plus offline verification of the Network user
- * JWT (RS256, issuer = the Network's public URL).
+ * Offline verification of the Network user JWT (RS256, issuer = the Network's public URL). The OAuth half
+ * is openvibe-sdk/sso's (server/web/session.js).
*/
*/
function createUserAuth(config, keys) {
function createUserAuth(config, keys) {
- const client = new OpenVibeAuthClient({
- clientId: config.oauth.clientId,
- clientSecret: config.oauth.clientSecret,
- redirectUri: config.oauth.redirectUri,
- publicKey: null,
- authBase: config.network.url,
- internalBase: config.network.internalUrl,
- });
/**
/**
* Claims of a valid user token, or null. openvibe-sdk/auth refuses service, app and mod principals and typed
* Claims of a valid user token, or null. openvibe-sdk/auth refuses service, app and mod principals and typed
* tokens (a realtime ticket, an export token): none of them is a session.
* tokens (a realtime ticket, an export token): none of them is a session.
@@ -44,7 +35,7 @@ function createUserAuth(config, keys) {
@@ -44,7 +35,7 @@ function createUserAuth(config, keys) {
if (!token) return null;
if (!token) return null;
try { return await verifyUserToken(token, { ...keys.verifyOptions, issuer: config.network.issuer }); } catch { return null; }
try { return await verifyUserToken(token, { ...keys.verifyOptions, issuer: config.network.issuer }); } catch { return null; }
}
}
- return { client, verify };
+ return { verify };
}
}
module.exports = { createKeyProvider, createUserAuth, verifyService };
module.exports = { createKeyProvider, createUserAuth, verifyService };
@@ -5,27 +5,38 @@
@@ -5,27 +5,38 @@
* shape as OpenVibe.Community's server/auth/routes.js. Registered in the Network as client `vip`
* shape as OpenVibe.Community's server/auth/routes.js. Registered in the Network as client `vip`
* with redirect https://openvibe.vip/auth/callback.
* with redirect https://openvibe.vip/auth/callback.
*
*
+ * The OAuth half is openvibe-sdk/sso's (createSsoClient): the authorize URL, the PKCE S256 pair (new
+ * here: this copied layer had none), the code, refresh and FedCM grants and the new GET /auth/health.
+ * The state is compared with the flow cookie in constant time, next is still sanitized by this repo's
+ * sanitizer, the viewer resolver and every permission check are untouched, and the redirect URI is
+ * unchanged.
+ *
* GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)
* GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)
- * GET /auth/callback → server-side code exchange, session cookies
+ * GET /auth/callback → server-side code exchange with the PKCE verifier, session cookies
* POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
* POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
+ * GET /auth/health → does the Network accept this OAuth client? 503 only when it refuses it
* GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest
* GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest
* GET /auth/me → the signed-in user (offline JWT verification)
* GET /auth/me → the signed-in user (offline JWT verification)
* POST /auth/refresh → rotate tokens
* POST /auth/refresh → rotate tokens
*
*
- * Cookies (host-only): ov_token (access JWT, Lax, JS-readable for the navbar), ov_refresh (httpOnly,
- * Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;
- * the API never reads them.
+ * Cookies (host-only, the names this site has always used): ov_token (access JWT, Lax, JS-readable for the navbar),
+ * ov_refresh (httpOnly, Path=/auth), ov_sso_hint. These cookies only ever sign a person into the pages of this site;
+ * the API never reads them. The short-lived flow cookies on /auth are
+ * ov_oauth_state, ov_oauth_next, ov_oauth_silent plus the PKCE verifier in ov_oauth_verifier.
*/
*/
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
+const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const { userPrincipal } = require('../api/auth');
const { userPrincipal } = require('../api/auth');
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
const REFRESH_COOKIE = 'ov_refresh';
const HINT_COOKIE = 'ov_sso_hint';
const HINT_COOKIE = 'ov_sso_hint';
const STATE_COOKIE = 'ov_oauth_state';
const STATE_COOKIE = 'ov_oauth_state';
+const VERIFIER_COOKIE = 'ov_oauth_verifier';
const NEXT_COOKIE = 'ov_oauth_next';
const NEXT_COOKIE = 'ov_oauth_next';
const SILENT_COOKIE = 'ov_oauth_silent';
const SILENT_COOKIE = 'ov_oauth_silent';
+const SITE_NAME = 'OpenVibe.VIP';
function claimsToUser(claims) {
function claimsToUser(claims) {
if (!claims) return null;
if (!claims) return null;
@@ -61,6 +72,13 @@ function withParam(target, key, value) {
@@ -61,6 +72,13 @@ function withParam(target, key, value) {
return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
}
}
+/** Compare two strings in constant time (the state must match the browser that started the flow). */
+function sameString(a, b) {
+ const x = Buffer.from(String(a || ''));
+ const y = Buffer.from(String(b || ''));
+ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
+}
+
/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */
/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */
function viewerMiddleware(userAuth) {
function viewerMiddleware(userAuth) {
return async (req, _res, next) => {
return async (req, _res, next) => {
@@ -72,9 +90,25 @@ function viewerMiddleware(userAuth) {
@@ -72,9 +90,25 @@ function viewerMiddleware(userAuth) {
};
};
}
}
-function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch } = {}) {
+function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch, log = console } = {}) {
const router = express.Router();
const router = express.Router();
const secure = config.cookies.secure;
const secure = config.cookies.secure;
+ // The OAuth half is the SDK's: the authorize URL, the PKCE S256 pair, the token grants and the health check.
+ const sdkSso = createSsoClient({
+ site: 'vip',
+ siteName: SITE_NAME,
+ baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ networkUrl: config.network.url,
+ networkInternalUrl: config.network.internalUrl,
+ issuer: config.network.issuer,
+ secureCookies: secure,
+ fetch: fetchImpl,
+ log,
+ });
const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
@@ -89,44 +123,18 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -89,44 +123,18 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
}
}
- function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
-
- async function tokenGrant(body) {
- let lastErr = null;
- for (const base of [config.network.internalUrl, config.network.url]) {
- if (!base) continue;
- try {
- const res = await fetchImpl(`${base}/oauth/token`, {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
- signal: AbortSignal.timeout(10_000),
- });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) {
- const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
- err.status = res.status; err.error = data.error || 'invalid_grant';
- throw err;
- }
- return data;
- } catch (err) {
- lastErr = err;
- if (err.status && err.status < 500) throw err;
- }
- }
- throw lastErr || new Error('Network unreachable');
- }
+ function clearFlow(res) { for (const c of [STATE_COOKIE, VERIFIER_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
router.get('/login', async (req, res) => {
router.get('/login', async (req, res) => {
const silent = !!req.query.silent && req.query.silent !== '0';
const silent = !!req.query.silent && req.query.silent !== '0';
if (silent && await userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
if (silent && await userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
- const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);
- let target = url;
- if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }
- res.cookie(STATE_COOKIE, state, flowOpts());
+ const started = sdkSso.authorizeUrl({ silent });
+ res.cookie(STATE_COOKIE, started.state, flowOpts());
+ res.cookie(VERIFIER_COOKIE, started.verifier, flowOpts());
const next = sanitizeNext(req.query.next, config);
const next = sanitizeNext(req.query.next, config);
if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
- res.redirect(target);
+ res.redirect(started.url);
});
});
router.get('/callback', async (req, res) => {
router.get('/callback', async (req, res) => {
@@ -140,20 +148,38 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -140,20 +148,38 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
}
}
if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
const expected = req.cookies && req.cookies[STATE_COOKIE];
const expected = req.cookies && req.cookies[STATE_COOKIE];
+ const verifier = req.cookies && req.cookies[VERIFIER_COOKIE];
clearFlow(res);
clearFlow(res);
- if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
+ if (!verifier || !sameString(state, expected)) {
return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');
return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');
}
}
try {
try {
- const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
+ const data = await sdkSso.tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code: String(code), code_verifier: verifier });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
return res.redirect(next);
return res.redirect(next);
} catch (err) {
} catch (err) {
- console.error('[VIP] code exchange failed:', err.message);
+ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
+ log.error(`[VIP] the Network refuses this site's OAuth client (${err.error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);
+ return res.status(500).type('html').send(failurePage({
+ siteName: SITE_NAME,
+ title: 'Sign-in isn\'t working on OpenVibe.Bot right now',
+ text: 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.',
+ retryHref: `/auth/login?next=${encodeURIComponent(next)}`,
+ backHref: next || '/',
+ }));
+ }
+ log.error(`[VIP] code exchange failed: ${err.message}`);
return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
}
}
});
});
+ // GET /auth/health: does the Network accept this OAuth client? 503 only when it refuses it.
+ router.get('/health', async (_req, res) => {
+ const client = await sdkSso.checkClient();
+ res.set('Cache-Control', 'no-store');
+ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
+ });
+
const fedcmBody = express.json({ limit: '16kb' });
const fedcmBody = express.json({ limit: '16kb' });
router.post('/fedcm', fedcmBody, async (req, res) => {
router.post('/fedcm', fedcmBody, async (req, res) => {
const { token, nonce } = req.body || {};
const { token, nonce } = req.body || {};
@@ -164,7 +190,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -164,7 +190,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);
const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
try {
try {
- const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
+ const data = await sdkSso.tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
return res.json({ ok: true, user: data.user || claimsToUser(await userAuth.verify(data.access_token)) });
return res.json({ ok: true, user: data.user || claimsToUser(await userAuth.verify(data.access_token)) });
@@ -201,7 +227,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -201,7 +227,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
if (!refresh) return res.status(401).json({ error: 'No refresh token' });
if (!refresh) return res.status(401).json({ error: 'No refresh token' });
try {
try {
- const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
+ const data = await sdkSso.tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
setSession(res, data.access_token, data.refresh_token);
setSession(res, data.access_token, data.refresh_token);
return res.json({ token: data.access_token, user: claimsToUser(await userAuth.verify(data.access_token)) });
return res.json({ token: data.access_token, user: claimsToUser(await userAuth.verify(data.access_token)) });
} catch (err) {
} catch (err) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * The sign-in (openvibe-sdk/sso): the authorize URL carries PKCE S256, the flow cookies are the ones this site
+ * has always used (ov_oauth_state, ov_oauth_next, ov_oauth_silent and the PKCE verifier in ov_oauth_verifier),
+ * the state is compared with the flow cookie in constant time, next is sanitized at the login and at the
+ * callback, and GET /auth/health is 503 only when the Network refuses this site's OAuth client.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, harness } = require('./helpers/app');
+
+const { test, run } = harness('auth');
+
+(async () => {
+ const t = await boot();
+ const get = (p, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers });
+ const cookies = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]);
+ const cookieHeader = (res) => cookies(res).join('; ');
+ const flow = (res, name) => res.headers.getSetCookie().find((c) => c.startsWith(name + '=')) || '';
+
+ test('the sign-in starts at the Network with PKCE S256 and the flow cookies this site has always used', async () => {
+ const res = await get('/auth/login?next=%2Fupdates');
+ assert.strictEqual(res.status, 302);
+ const u = new URL(res.headers.get('location'));
+ assert.deepStrictEqual([u.pathname, u.searchParams.get('client_id'), u.searchParams.get('redirect_uri'), u.searchParams.get('response_type')],
+ ['/oauth/authorize', 'vip', 'http://vip.test/auth/callback', 'code']);
+ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256', 'PKCE S256 from openvibe-sdk/sso');
+ const challenge = u.searchParams.get('code_challenge');
+ assert.match(challenge, /^[A-Za-z0-9_-]{43}$/, 'the code challenge');
+ const state = u.searchParams.get('state');
+ assert.ok(state && state.length >= 16, 'the state');
+ assert.match(flow(res, 'ov_oauth_state'), new RegExp('^ov_oauth_state=' + state));
+ assert.match(flow(res, 'ov_oauth_verifier'), /Path=\/auth/i, 'the verifier stays in a flow cookie of its own');
+ // express encodes the cookie's value: the path arrives percent-encoded.
+ const nextCookie = flow(res, 'ov_oauth_next').split(';')[0].split('=')[1];
+ assert.strictEqual(decodeURIComponent(nextCookie), '/updates');
+ // The challenge is the SHA-256 of the verifier in the cookie: the Network checks exactly that.
+ const verifier = flow(res, 'ov_oauth_verifier').split(';')[0].split('=')[1];
+ assert.strictEqual(crypto.createHash('sha256').update(verifier).digest('base64url'), challenge);
+ });
+
+ test('a callback whose state does not match the flow cookie is refused, and no session is set', async () => {
+ const started = await get('/auth/login');
+ const res = await get('/auth/callback?code=whatever&state=not-the-state', { Cookie: cookieHeader(started) });
+ assert.strictEqual(res.status, 400);
+ assert.ok(!res.headers.getSetCookie().some((c) => c.startsWith('ov_token=')), 'no session');
+ });
+
+ test('a callback without the verifier cookie is refused too (the code could not be exchanged)', async () => {
+ const started = await get('/auth/login');
+ const u = new URL(started.headers.get('location'));
+ const without = cookies(started).filter((c) => !c.startsWith('ov_oauth_verifier=')).join('; ');
+ const res = await get('/auth/callback?code=whatever&state=' + encodeURIComponent(u.searchParams.get('state')), { Cookie: without });
+ assert.strictEqual(res.status, 400);
+ assert.ok(!res.headers.getSetCookie().some((c) => c.startsWith('ov_token=')), 'no session');
+ });
+
+ test('a sign-in with a code the Network issued sets the session and returns to next', async () => {
+ const started = await get('/auth/login?next=%2Fupdates');
+ const u = new URL(started.headers.get('location'));
+ const code = t.network.issueCode();
+ const res = await get('/auth/callback?code=' + code + '&state=' + encodeURIComponent(u.searchParams.get('state')), { Cookie: cookieHeader(started) });
+ assert.strictEqual(res.status, 302);
+ assert.strictEqual(res.headers.get('location'), '/updates');
+ const set = res.headers.getSetCookie();
+ assert.ok(set.some((c) => c.startsWith('ov_token=')), 'the session cookie, with the name this site has always used');
+ assert.ok(set.some((c) => c.startsWith('ov_refresh=')), 'the refresh cookie');
+ });
+
+ test('next is sanitized: off-site never, neither at the login nor at the callback', async () => {
+ const started = await get('/auth/login?next=https%3A%2F%2Fevil.com%2Fx');
+ const offSite = flow(started, 'ov_oauth_next').split(';')[0].split('=')[1];
+ assert.ok(!offSite, 'an off-site next is not kept');
+ const again = await get('/auth/login');
+ const u = new URL(again.headers.get('location'));
+ const code = t.network.issueCode();
+ const res = await get('/auth/callback?code=' + code + '&state=' + encodeURIComponent(u.searchParams.get('state')),
+ { Cookie: cookieHeader(again) + '; ov_oauth_next=//evil.com/x' });
+ assert.strictEqual(res.status, 302);
+ assert.strictEqual(res.headers.get('location'), '/', 'a tampered next goes home');
+ });
+
+ test('GET /auth/health: 200 while the Network accepts this client, 503 when it refuses it', async () => {
+ const res = await get('/auth/health');
+ assert.strictEqual(res.status, 200);
+ const json = await res.json();
+ assert.deepStrictEqual([json.ok, json.client], [true, 'ok']);
+
+ const broken = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'not-the-secret' } });
+ try {
+ const refused = await fetch(broken.base + '/auth/health', { redirect: 'manual' });
+ assert.strictEqual(refused.status, 503);
+ const reported = await refused.json();
+ assert.deepStrictEqual([reported.ok, reported.client], [false, 'invalid']);
+ } finally { await broken.close(); }
+ });
+
+ await run().finally(async () => await t.close());
+})();
@@ -31,6 +31,8 @@ async function startNetwork() {
@@ -31,6 +31,8 @@ async function startNetwork() {
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const grants = [];
const grants = [];
+ // The sign-in codes Network issued (openvibe-sdk/sso exchanges one with the PKCE verifier): code → tokens.
+ const codes = new Map();
let issuer = 'http://network.test';
let issuer = 'http://network.test';
let n = 0;
let n = 0;
@@ -51,9 +53,17 @@ async function startNetwork() {
@@ -51,9 +53,17 @@ async function startNetwork() {
const raw = await readBody(req);
const raw = await readBody(req);
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/oauth/token' && req.method === 'POST') {
if (req.url === '/oauth/token' && req.method === 'POST') {
- const body = Object.fromEntries(new URLSearchParams(raw));
+ // The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (the service clients).
+ const body = raw.trim().startsWith('{') ? JSON.parse(raw) : Object.fromEntries(new URLSearchParams(raw));
grants.push(body);
grants.push(body);
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
+ // A sign-in's code: the Network answers invalid_grant for a code it never issued.
+ if (body.grant_type === 'authorization_code') {
+ const c = codes.get(String(body.code || ''));
+ if (!c) return send(res, 400, { error: 'invalid_grant', error_description: 'unknown or expired code' });
+ codes.delete(String(body.code));
+ return send(res, 200, { access_token: c.access_token, refresh_token: c.refresh_token || null, token_type: 'Bearer', expires_in: 300 });
+ }
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
@@ -61,7 +71,13 @@ async function startNetwork() {
@@ -61,7 +71,13 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
issuer = url;
- return { url, publicPem, signService, signUser, newUser, grants, close: () => new Promise((r) => server.close(r)) };
+ /** A sign-in code the stand-in Network accepts: the code the browser comes back with. */
+ function issueCode({ access_token = null, refresh_token = 'refresh_test' } = {}) {
+ const code = `code_${crypto.randomBytes(8).toString('hex')}`;
+ codes.set(code, { access_token: access_token || signUser({ subject: 'usr_test', username: 'someone' }), refresh_token });
+ return code;
+ }
+ return { url, publicPem, signService, signUser, newUser, grants, issueCode, close: () => new Promise((r) => server.close(r)) };
}
}
/**
/**