openvibers/openvibe.bot Commits
Sign-in: the OAuth half is openvibe-sdk/sso (#302)
Changes
@@@ -175,7 +175,7 @@ function createApp(opts = {}) {// A computer command may carry a file to write (≤ 256 KB, base64): its route alone takes a larger body.app.use(/^\/api\/v1\/machines\/[^/]+\/commands$/, express.json({ limit: '400kb' }));app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks, claims }));-- app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));++ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP// refuses, so these three public pages get the front page's allowances (HOME_CSP). The `profile` is the@@@ -15,7 +15,6 @@* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the* names of subjects the robot's owner added.*/--const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');const { serviceAuth } = require('openvibe-contracts');const { BotError } = require('./util');@@@ -25,16 +24,8 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = consolereturn createNetworkKeys({ network: config.network.internalUrl, publicKey: config.network.publicKey || null, fetch: fetchImpl, log });}--/** The OAuth client of the browser session layer plus offline verification of the Network user JWT. */++/** Offline verification of the Network user JWT (the OAuth half is openvibe-sdk/sso's, in 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 verifyUserToken refuses service, app and mod principals* and typed tokens (a realtime ticket, a FedCM assertion, an export token); a node token (a machine paired for Bot)@@@ -48,7 +39,7 @@ function createUserAuth(config, keys) {if (claims.actor_type === 'node') return null;return claims;}-- return { client, verify };++ return { verify };}/**@@@ -6,23 +6,37 @@* https://openvibe.bot/auth/callback. The panel (built by Opus) signs people in through this; the* control WebSocket accepts the `ov_token` cookie this sets.*++ * 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, 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 and the short-lived flow cookies on++ * /auth (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.Bot';function claimsToUser(claims) {if (!claims) return null;@@@ -56,6 +70,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 (req, _res, next) => {@@@ -68,9 +89,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: 'bot',++ 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 });@@@ -85,44 +122,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) => {@@@ -136,20 +147,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('[Bot] code exchange failed:', err.message);++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {++ log.error(`[Bot] 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(`[Bot] 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 || {};@@@ -160,7 +189,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)) });@@@ -197,7 +226,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, check, done } = require('./helpers/app');++++(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 + '=')) || '';++++ await check('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=%2Frobots');++ 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', 'bot', 'http://bot.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), '/robots');++ // 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);++ });++++ await check('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');++ });++++ await check('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');++ });++++ await check('a sign-in with a code the Network issued sets the session and returns to next', async () => {++ const started = await get('/auth/login?next=%2Frobots');++ 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'), '/robots');++ 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');++ });++++ await check('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');++ });++++ await check('GET /auth/health: 200 while the Network accepts this client', 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' } });++ await check('a client the Network refuses is a 503, and only then', async () => {++ const res = await fetch(broken.base + '/auth/health', { redirect: 'manual' });++ assert.strictEqual(res.status, 503);++ const json = await res.json();++ assert.deepStrictEqual([json.ok, json.client], [false, 'invalid']);++ });++ await broken.close();++++ await t.close();++ done();++})();@@@ -46,6 +46,8 @@ async function startNetwork() {let n = 100;// Every client-credentials request Network's /oauth/token served: { client_id, audience, scope }.const tokenCalls = [];++ // The sign-in codes Network issued (openvibe-sdk/sso exchanges one with the PKCE verifier): code → tokens.++ const codes = new Map();function signService({ sub = 'svc:live', aud = ['openvibe.bot'], cap = [], expSec = 300 } = {}) {const now = Math.floor(Date.now() / 1000);@@@ -115,9 +117,18 @@ async function startNetwork() {if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });if (req.url === '/api/health') return send(res, 200, { ok: true });if (req.url === '/oauth/token' && req.method === 'POST') {-- const body = Object.fromEntries(new URLSearchParams(await readBody(req)));++ // The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (the service clients).++ const text = await readBody(req);++ const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));tokenCalls.push({ client_id: body.client_id, audience: body.audience || null, scope: body.scope || null });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 || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });}@@@ -175,7 +186,13 @@ async function startNetwork() {});const url = await listen(server);issuer = url;-- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, statuses, tokenCalls, failPairings, users, failIdentity: (status) => { identityFailure = status; }, 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, addNode, signNode, nodes, revokes, pairings, statuses, tokenCalls, issueCode, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };}async function startEvents() {
@@ -175,7 +175,7 @@ function createApp(opts = {}) {
@@ -175,7 +175,7 @@ function createApp(opts = {}) {
// A computer command may carry a file to write (≤ 256 KB, base64): its route alone takes a larger body.
// A computer command may carry a file to write (≤ 256 KB, base64): its route alone takes a larger body.
app.use(/^\/api\/v1\/machines\/[^/]+\/commands$/, express.json({ limit: '400kb' }));
app.use(/^\/api\/v1\/machines\/[^/]+\/commands$/, express.json({ limit: '400kb' }));
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks, claims }));
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks, claims }));
- app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
+ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP
// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP
// refuses, so these three public pages get the front page's allowances (HOME_CSP). The `profile` is the
// refuses, so these three public pages get the front page's allowances (HOME_CSP). The `profile` is the
@@ -15,7 +15,6 @@
@@ -15,7 +15,6 @@
* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the
* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the
* names of subjects the robot's owner added.
* names of subjects the robot's owner added.
*/
*/
-const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');
const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { BotError } = require('./util');
const { BotError } = require('./util');
@@ -25,16 +24,8 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
@@ -25,16 +24,8 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
return createNetworkKeys({ network: config.network.internalUrl, publicKey: config.network.publicKey || null, fetch: fetchImpl, log });
return createNetworkKeys({ network: config.network.internalUrl, publicKey: config.network.publicKey || null, fetch: fetchImpl, log });
}
}
-/** The OAuth client of the browser session layer plus offline verification of the Network user JWT. */
+/** Offline verification of the Network user JWT (the OAuth half is openvibe-sdk/sso's, in 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 verifyUserToken refuses service, app and mod principals
* Claims of a valid user token, or null. openvibe-sdk/auth verifyUserToken refuses service, app and mod principals
* and typed tokens (a realtime ticket, a FedCM assertion, an export token); a node token (a machine paired for Bot)
* and typed tokens (a realtime ticket, a FedCM assertion, an export token); a node token (a machine paired for Bot)
@@ -48,7 +39,7 @@ function createUserAuth(config, keys) {
@@ -48,7 +39,7 @@ function createUserAuth(config, keys) {
if (claims.actor_type === 'node') return null;
if (claims.actor_type === 'node') return null;
return claims;
return claims;
}
}
- return { client, verify };
+ return { verify };
}
}
/**
/**
@@ -6,23 +6,37 @@
@@ -6,23 +6,37 @@
* https://openvibe.bot/auth/callback. The panel (built by Opus) signs people in through this; the
* https://openvibe.bot/auth/callback. The panel (built by Opus) signs people in through this; the
* control WebSocket accepts the `ov_token` cookie this sets.
* control WebSocket accepts the `ov_token` cookie this sets.
*
*
+ * 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, 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 and the short-lived flow cookies on
+ * /auth (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.Bot';
function claimsToUser(claims) {
function claimsToUser(claims) {
if (!claims) return null;
if (!claims) return null;
@@ -56,6 +70,13 @@ function withParam(target, key, value) {
@@ -56,6 +70,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 (req, _res, next) => {
return (req, _res, next) => {
@@ -68,9 +89,25 @@ function viewerMiddleware(userAuth) {
@@ -68,9 +89,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: 'bot',
+ 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 });
@@ -85,44 +122,18 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -85,44 +122,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) => {
@@ -136,20 +147,38 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -136,20 +147,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('[Bot] code exchange failed:', err.message);
+ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
+ log.error(`[Bot] 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(`[Bot] 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 || {};
@@ -160,7 +189,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -160,7 +189,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)) });
@@ -197,7 +226,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
@@ -197,7 +226,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, check, done } = require('./helpers/app');
+
+(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 + '=')) || '';
+
+ await check('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=%2Frobots');
+ 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', 'bot', 'http://bot.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), '/robots');
+ // 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);
+ });
+
+ await check('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');
+ });
+
+ await check('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');
+ });
+
+ await check('a sign-in with a code the Network issued sets the session and returns to next', async () => {
+ const started = await get('/auth/login?next=%2Frobots');
+ 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'), '/robots');
+ 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');
+ });
+
+ await check('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');
+ });
+
+ await check('GET /auth/health: 200 while the Network accepts this client', 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' } });
+ await check('a client the Network refuses is a 503, and only then', async () => {
+ const res = await fetch(broken.base + '/auth/health', { redirect: 'manual' });
+ assert.strictEqual(res.status, 503);
+ const json = await res.json();
+ assert.deepStrictEqual([json.ok, json.client], [false, 'invalid']);
+ });
+ await broken.close();
+
+ await t.close();
+ done();
+})();
@@ -46,6 +46,8 @@ async function startNetwork() {
@@ -46,6 +46,8 @@ async function startNetwork() {
let n = 100;
let n = 100;
// Every client-credentials request Network's /oauth/token served: { client_id, audience, scope }.
// Every client-credentials request Network's /oauth/token served: { client_id, audience, scope }.
const tokenCalls = [];
const tokenCalls = [];
+ // The sign-in codes Network issued (openvibe-sdk/sso exchanges one with the PKCE verifier): code → tokens.
+ const codes = new Map();
function signService({ sub = 'svc:live', aud = ['openvibe.bot'], cap = [], expSec = 300 } = {}) {
function signService({ sub = 'svc:live', aud = ['openvibe.bot'], cap = [], expSec = 300 } = {}) {
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
@@ -115,9 +117,18 @@ async function startNetwork() {
@@ -115,9 +117,18 @@ async function startNetwork() {
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 === '/api/health') return send(res, 200, { ok: true });
if (req.url === '/api/health') return send(res, 200, { ok: true });
if (req.url === '/oauth/token' && req.method === 'POST') {
if (req.url === '/oauth/token' && req.method === 'POST') {
- const body = Object.fromEntries(new URLSearchParams(await readBody(req)));
+ // The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (the service clients).
+ const text = await readBody(req);
+ const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
tokenCalls.push({ client_id: body.client_id, audience: body.audience || null, scope: body.scope || null });
tokenCalls.push({ client_id: body.client_id, audience: body.audience || null, scope: body.scope || null });
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 || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
}
}
@@ -175,7 +186,13 @@ async function startNetwork() {
@@ -175,7 +186,13 @@ async function startNetwork() {
});
});
const url = await listen(server);
const url = await listen(server);
issuer = url;
issuer = url;
- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, statuses, tokenCalls, failPairings, users, failIdentity: (status) => { identityFailure = status; }, 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, addNode, signNode, nodes, revokes, pairings, statuses, tokenCalls, issueCode, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
}
}
async function startEvents() {
async function startEvents() {