openvibers/openvibe.tools Commits
Move the gateway's sign-in onto openvibe-sdk/sso (#216)
Changes
@@@ -18,9 +18,11 @@ service is an OAuth2 **client** (`client_id: tools`), not a provider.`/api/pastes/*` is forwarded to OpenVibe.Community (`OV_COMMUNITY_INTERNAL_URL`)with the visitor's `ov_token`.- **Session layer** — `/auth/login`, `/auth/callback`, `/auth/logout`,-- `/auth/me`, `/auth/refresh`. Sets the shared `ov_token` cookie on-- `.openvibe.tools` so every satellite subdomain can read it and verify it-- offline against the Network's public key (JWKS).++ `/auth/me`, `/auth/refresh`, `/auth/health`. The OAuth half (the authorize++ URL with PKCE S256, the code / refresh / FedCM grants and the client check)++ is `openvibe-sdk/sso`'s; the cookies stay the gateway's own. Sets the shared++ `ov_token` cookie on `.openvibe.tools` so every satellite subdomain can read++ it and verify it offline against the Network's public key (JWKS).## Architecture@@@ -6,15 +6,24 @@// The identity provider is OpenVibe.Network (https://openvibe.network).// This gateway is registered there as OAuth client `tools`.//++// The OAuth half is openvibe-sdk/sso's (createSsoClient): the authorize URL++// with PKCE (S256), the code / refresh / FedCM grants and GET /auth/health.++// The session half stays this gateway's own, because the cookies belong to++// the whole *.openvibe.tools zone (the SDK's cookies are host-only) and every++// satellite reads ov_token from JavaScript.++//// GET /auth/login → redirect to Network /oauth/authorize// (?silent=1 adds prompt=none: no login UI, the// Network answers login_required when nobody is// signed in there). A browser that already holds a// valid ov_token skips the Network and goes to next.--// GET /auth/callback → server-side code exchange, set cookies++// GET /auth/callback → server-side code exchange (with the PKCE++// verifier), set cookies// POST /auth/fedcm → browser-native FedCM sign-in: the page posts the// Network's assertion JWT + its nonce, we exchange it// (jwt-bearer grant) and set the same cookies++// 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)// GET /auth/me → offline-verify ov_token, return profile// POST /auth/refresh → rotate tokens via refresh_token grant@@@ -28,19 +37,28 @@// One year, JS-readable, Domain=.openvibe.tools. The shared// navbar reads it: only when it says 'account' does a page// with no session attempt ONE silent (prompt=none) login.++// ov_oauth_state / _next / _silent / _verifier — the sign-in flow's own++// cookies: Domain=.openvibe.tools, Path=/auth, httpOnly,++// 10 minutes. The verifier is the PKCE verifier; the state is++// compared with it in constant time.// ═══════════════════════════════════════════════════════════════const express = require('express');const crypto = require('crypto');--const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++const { createSsoClient, failurePage, fedcmNonceMatches, claimsToUser } = require('openvibe-sdk/sso');++const { verifyUserToken } = require('openvibe-sdk/auth');const ACCESS_COOKIE = 'ov_token';const REFRESH_COOKIE = 'ov_refresh';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 SSO_HINT_COOKIE = 'ov_sso_hint';++// The audience the Network's user tokens carry for this site (the guard checks the same one).++const AUDIENCE = 'openvibe.tools';++// Origin of the identity provider's own "sign you in everywhere" chain. The Network hops// through /auth/login?silent=1&next=https://openvibe.network/sso/fanout?… and back, so// its URLs are valid post-login targets alongside our own hosts.@@@ -93,27 +111,13 @@ function fedcmCors(req, res, next) {}/**-- * Read a JWT's payload WITHOUT checking its signature. Only used to compare the FedCM-- * assertion's nonce with the one the page posted; the Network verifies the signature when-- * the assertion is exchanged. Returns null when the token is not a three-part JWT.++ * Compare two strings in constant time (the state must match the browser that started the++ * flow). The length is not secret, so unequal lengths answer false at once.*/--function decodeJwtPayload(token) {-- if (typeof token !== 'string') return null;-- const parts = token.split('.');-- if (parts.length !== 3) return null;-- try {-- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));-- return payload && typeof payload === 'object' ? payload : null;-- } catch {-- return null;-- }--}----/** Constant-time string comparison; unequal lengths short-circuit (the length is not secret). */--function safeEqual(a, b) {-- if (typeof a !== 'string' || typeof b !== 'string' || !a || !b) return false;-- if (a.length !== b.length) return false;-- return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));++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);}/**@@@ -122,14 +126,9 @@ function safeEqual(a, b) {* GET /api/.well-known/jwks and verify JWTs locally on every request.*/function createAuthClient(config) {-- const client = new OpenVibeAuthClient({-- clientId: config.oauth.clientId,-- clientSecret: config.oauth.clientSecret,-- redirectUri: config.oauth.redirectUri,-- publicKey: null, // filled by ensureKey()-- authBase: config.networkUrl,-- internalBase: config.networkInternalUrl,-- });++ // The Network's RS256 public key (PEM). The guard verifies signed-in visitors with it++ // (auth.client.publicKey) and /api/ready reports whether it loaded.++ const client = { publicKey: null };let lastFetch = 0;const gapMs = config.keyRetryMs || 30_000;@@@ -156,11 +155,20 @@ function createAuthClient(config) {return null;}-- /** Offline JWT verification. Returns decoded claims or null. */++ /**++ * Offline JWT verification (openvibe-sdk/auth): the signature, the issuer, the audience++ * and the expiry. A service, app, module or node token — and any typed token (a FedCM++ * assertion, an export ticket) — is never a person's session, whatever it names.++ */async function verify(token) {if (!token) return null;await ensureKey();-- return client.verifyToken(token);++ if (!client.publicKey) return null;++ try {++ return await verifyUserToken(token, { publicKey: client.publicKey, issuer: config.networkUrl, audience: AUDIENCE });++ } catch {++ return null;++ }}// Warm the key cache at boot, and keep trying every 30 s until it loads (non-fatal if Network is down). After the@@@ -187,6 +195,24 @@ function extractToken(req) {function createAuthRoutes(config, auth) {const router = express.Router();++ // The OAuth half is the SDK's: the authorize URL (PKCE S256 + state), the code, refresh++ // and FedCM grants, and the client check behind GET /auth/health. The cookies below stay++ // this gateway's own — they belong to the whole *.openvibe.tools zone.++ const sso = createSsoClient({++ site: 'tools',++ siteName: 'OpenVibe.Tools',++ baseUrl: config.baseUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ networkUrl: config.networkUrl,++ networkInternalUrl: config.networkInternalUrl,++ issuer: config.networkUrl,++ audience: AUDIENCE,++ secureCookies: config.cookies.secure,++ });++const accessCookieOpts = () => ({domain: config.cookies.domain || undefined,sameSite: 'lax',@@@ -276,66 +302,34 @@ function createAuthRoutes(config, auth) {return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;}-- /** Exchange at the Network — internal URL first, public as fallback. */-- async function tokenGrant(body) {-- let lastErr = null;-- for (const base of [config.networkInternalUrl, config.networkUrl]) {-- if (!base) continue;-- try {-- const res = await fetch(`${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.code = typeof data.error === 'string' ? data.error : null;-- err.description = typeof data.error_description === 'string' ? data.error_description : null;-- throw err;-- }-- return data;-- } catch (err) {-- lastErr = err;-- // 4xx = the grant itself is bad; retrying against another base won't help-- if (err.status && err.status < 500) throw err;-- }-- }-- throw lastErr || new Error('Network unreachable');-- }--// ── GET /auth/login ──────────────────────────────────────router.get('/login', async (req, res) => {// Silent mode (?silent=1): the shared navbar uses it when a page has no session but// the browser carries an ov_sso_hint=account cookie. prompt=none tells the Network to// answer without any UI — a code if the user is signed in there, login_required if not.const silent = String(req.query.silent || '') === '1';++ const next = sanitizeNext(req.query.next);if (silent) {// Already signed in here (a valid ov_token rode along)? Then the round trip to the// Network would only hand back the session this browser already has: go straight// to the page that asked.const existing = req.cookies?.[ACCESS_COOKIE];if (existing && await auth.verify(existing)) {-- return res.redirect(sanitizeNext(req.query.next));++ return res.redirect(next);}}-- let { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);-- if (silent) url += '&prompt=none';++ // The SDK's authorize URL: state + PKCE (S256). The verifier goes into a flow cookie++ // of its own and comes back with the code.++ const started = sso.authorizeUrl({ silent });// Domain-wide: a login can start on net./dev./pastes. but the OAuth// redirect_uri is pinned to the apex — the callback must see these cookies.-- res.cookie(STATE_COOKIE, state, flowCookieOpts());-- const next = sanitizeNext(req.query.next);++ res.cookie(STATE_COOKIE, started.state, flowCookieOpts());++ res.cookie(VERIFIER_COOKIE, started.verifier, flowCookieOpts());if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());else clearFlowCookie(res, NEXT_COOKIE);if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());else clearFlowCookie(res, SILENT_COOKIE);-- res.redirect(url);++ res.redirect(started.url);});// ── GET /auth/callback ───────────────────────────────────@@@ -350,6 +344,7 @@ function createAuthRoutes(config, auth) {const next = sanitizeNext(req.cookies?.[NEXT_COOKIE]);clearFlowCookie(res, NEXT_COOKIE);clearFlowCookie(res, STATE_COOKIE);++ clearFlowCookie(res, VERIFIER_COOKIE);return res.redirect(withParam(next, 'sso', 'none'));}return res.redirect(`/?auth_error=${encodeURIComponent(error)}`);@@@ -357,19 +352,22 @@ function createAuthRoutes(config, auth) {if (!code) return res.status(400).send('Missing authorization code');const expectedState = req.cookies?.[STATE_COOKIE];++ const verifier = req.cookies?.[VERIFIER_COOKIE];clearFlowCookie(res, STATE_COOKIE);-- if (!expectedState || !state || !crypto.timingSafeEqual(-- Buffer.from(String(state).padEnd(64).slice(0, 64)),-- Buffer.from(String(expectedState).padEnd(64).slice(0, 64))-- )) {++ clearFlowCookie(res, VERIFIER_COOKIE);++ // The state cookie is required, not only compared when it is there: without it a++ // crafted link could sign a visitor into somebody else's account. The comparison is++ // in constant time, and the PKCE verifier must come from the same flow.++ if (!expectedState || !state || !verifier || !sameString(state, expectedState)) {return res.status(400).send('OAuth state mismatch — please try signing in again.');}try {-- const data = await tokenGrant({++ const data = await sso.tokenGrant({grant_type: 'authorization_code',redirect_uri: config.oauth.redirectUri,-- code,++ code: String(code),++ code_verifier: verifier,});setSessionCookies(res, data.access_token, data.refresh_token);setSsoHint(res, 'account');@@@ -377,11 +375,32 @@ function createAuthRoutes(config, auth) {clearFlowCookie(res, NEXT_COOKIE);return res.redirect(next);} catch (err) {++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {++ // The Network refuses our client (a wrong id or secret): a configuration++ // problem, not the person's account — say so plainly.++ console.error(`[Auth] the Network refuses this site's OAuth client (${err.error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);++ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE]);++ return res.status(500).type('html').send(failurePage({++ siteName: 'OpenVibe.Tools',++ title: "Sign-in isn't working on OpenVibe.Tools 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 && next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`,++ backHref: next || '/',++ }));++ }console.error('[Auth] Code exchange failed:', err.message);return res.status(502).send('Sign-in failed — could not reach the OpenVibe.Network. Please try again.');}});++ // ── GET /auth/health ─────────────────────────────────────++ // Does the Network accept this site's OAuth client? 503 only when it refuses it.++ router.get('/health', async (_req, res) => {++ const client = await sso.checkClient();++ res.set('Cache-Control', 'no-store');++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });++ });++// ── POST /auth/fedcm ─────────────────────────────────────// Browser-native FedCM sign-in. The shared navbar asks the browser for an identity// credential from openvibe.network (with a nonce it generated) and posts the resulting@@@ -403,14 +422,12 @@ function createAuthRoutes(config, auth) {// Nonce binding: the assertion must carry the very nonce this page generated, or it// was minted for a different request. Signature-less decode is enough for that — the// Network verifies the signature when we exchange it.-- const claims = decodeJwtPayload(token);-- if (!claims) return res.status(400).json({ error: 'invalid_request', error_description: 'Token is not a JWT' });-- if (!safeEqual(String(claims.nonce ?? ''), nonce)) {++ if (!fedcmNonceMatches(token, nonce)) {return res.status(400).json({ error: 'invalid_request', error_description: 'Nonce mismatch' });}try {-- const data = await tokenGrant({ grant_type: JWT_BEARER_GRANT, assertion: token });++ const data = await sso.tokenGrant({ grant_type: JWT_BEARER_GRANT, assertion: token });if (!data.access_token) {return res.status(401).json({ error: 'invalid_grant', error_description: 'The OpenVibe.Network issued no access token' });}@@@ -419,18 +436,15 @@ function createAuthRoutes(config, auth) {let user = data.user || null;if (!user) {const verified = await auth.verify(data.access_token);-- if (verified) {-- const { iat, exp, aud, iss, ...rest } = verified;-- user = rest;-- }++ if (verified) user = claimsToUser(verified);}return res.json({ ok: true, user });} catch (err) {if (err.status && err.status < 500) {// The Network refused the assertion (expired, wrong audience, unknown user…).return res.status(401).json({-- error: err.code || 'invalid_grant',-- error_description: err.description || err.message,++ error: err.error || 'invalid_grant',++ error_description: err.error_description || err.message,});}console.error('[Auth] FedCM exchange failed:', err.message);@@@ -471,8 +485,7 @@ function createAuthRoutes(config, auth) {if (!token) return res.json({ user: null }); // signed out: 200 with no user (as Blog, Community, Wiki), not a 401 in every signed-out visitor's consoleconst claims = await auth.verify(token);if (!claims) return res.json({ user: null }); // an expired token is signed out too; the navbar refreshes through /auth/refresh-- const { iat, exp, aud, iss, ...user } = claims;-- res.json({ user, expires_at: exp ? exp * 1000 : null });++ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });});// ── POST /auth/refresh ───────────────────────────────────@@@ -481,15 +494,10 @@ function createAuthRoutes(config, auth) {const refresh = 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 sso.tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });setSessionCookies(res, data.access_token, data.refresh_token);const claims = await auth.verify(data.access_token);-- let user = null;-- if (claims) {-- const { iat, exp, aud, iss, ...rest } = claims;-- user = rest;-- }-- return res.json({ token: data.access_token, user });++ return res.json({ token: data.access_token, user: claims ? claimsToUser(claims) : null });} catch (err) {if (err.status && err.status < 500) {clearSessionCookies(res);@@@ -216,7 +216,8 @@ app.use(guard.identify);app.use(require('../../_shared/usage').usagePages({ snapshot: toolRegistry.snapshot }));app.use('/api/', guard.apiQuota);// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.--app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, skip: (req) => req.path === '/me', keyGenerator: (req) => guard.caller(req).ipKey }));++// /auth/health is a monitor's read of the OAuth client, not a sign-in attempt: it must answer even when the limit is spent.++app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, skip: (req) => req.path === '/me' || req.path === '/health', keyGenerator: (req) => guard.caller(req).ipKey }));// ── Tool registry (ADR-027, capability tools.tool.read) ──────// GET /api/v1/tools (tools.tool-list@1), /api/v1/tools/:id (tools.tool@1), /api/v1/tools/:id/schema.nnew file mode 100644@@@ -0,0 +1,234 @@++'use strict';++// The gateway's sign-in flow (apps/gateway/server/auth/routes.js): the OAuth half is openvibe-sdk/sso's++// (the authorize URL with PKCE S256, the code / refresh / FedCM grants and GET /auth/health), the++// session half stays the gateway's own, because its cookies belong to the whole *.openvibe.tools++// zone. A stand-in Network speaks the token endpoint (JSON, like the SDK posts it) and answers the++// authorize request with a code bound to the challenge it was given.++//++// What this pins down:++// - the flow cookies and the session cookies keep their names, domains, paths and httpOnly flags++// - the authorize URL carries a PKCE S256 challenge, and the code is only redeemable with the++// verifier from the flow cookie (a code with the wrong verifier is refused by the Network)++// - a state that does not match the flow cookie never sets a session++// - next is sanitised at the login and again at the callback++// - GET /auth/health answers 200 while the Network accepts the client and 503 when it refuses it++// - a service principal's token is not a person's session++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const cookieParser = require('cookie-parser');++const { createAuthClient, createAuthRoutes } = require('../server/auth/routes');++++const CLIENT_ID = 'tools';++const CLIENT_SECRET = 'tools-secret';++const REDIRECT = 'https://openvibe.tools/auth/callback';++const DOMAIN = '.openvibe.tools';++++const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++const t = () => Math.floor(Date.now() / 1000);++const sign = (claims) => {++ const head = b64({ alg: 'RS256', typ: 'JWT' });++ const body = b64(claims);++ return `${head}.${body}.${crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey).toString('base64url')}`;++};++++async function startNetwork() {++ const codes = new Map(); // code -> { challenge }++ const seen = [];++ let issuer = '';++ const srv = http.createServer((req, res) => {++ const raw = [];++ req.on('data', (c) => raw.push(c));++ req.on('end', () => {++ const text = Buffer.concat(raw).toString('utf8');++ const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };++ const url = new URL(req.url, 'http://x');++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicKey, algorithm: 'RS256' });++ if (url.pathname === '/oauth/authorize') {++ // The Network's authorize endpoint: a code bound to the challenge it was given.++ const challenge = url.searchParams.get('code_challenge');++ const code = crypto.randomBytes(16).toString('hex');++ codes.set(code, { challenge });++ return json(200, { code, state: url.searchParams.get('state') });++ }++ if (url.pathname === '/oauth/token' && req.method === 'POST') {++ // The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form.++ const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));++ seen.push(body);++ if (body.client_id !== CLIENT_ID || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });++ if (body.grant_type === 'authorization_code') {++ const entry = codes.get(body.code);++ if (!entry) return json(400, { error: 'invalid_grant', error_description: 'Invalid authorization code' });++ codes.delete(body.code);++ const digest = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');++ if (entry.challenge && digest !== entry.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });++ return json(200, {++ access_token: sign({ iss: issuer, sub: '1001', id: 1001, subject_id: 'usr_01J0000000000000000000000', username: 'person1', aud: ['openvibe.tools'], iat: t(), exp: t() + 3600 }),++ refresh_token: 'refresh-x',++ token_type: 'Bearer',++ });++ }++ if (body.grant_type === 'refresh_token') {++ return json(200, {++ access_token: sign({ iss: issuer, sub: '1001', id: 1001, subject_id: 'usr_01J0000000000000000000000', username: 'person1', aud: ['openvibe.tools'], iat: t(), exp: t() + 3600 }),++ refresh_token: 'refresh-y',++ token_type: 'Bearer',++ });++ }++ if (body.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {++ return json(200, {++ access_token: sign({ iss: issuer, sub: '1001', id: 1001, subject_id: 'usr_01J0000000000000000000000', username: 'person1', aud: ['openvibe.tools'], iat: t(), exp: t() + 3600 }),++ refresh_token: 'refresh-z',++ token_type: 'Bearer',++ });++ }++ return json(400, { error: 'unsupported_grant_type' });++ }++ return json(404, {});++ });++ });++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const url = `http://127.0.0.1:${srv.address().port}`;++ issuer = url;++ return { url, codes, seen, close: () => new Promise((r) => srv.close(r)) };++}++++/** The Set-Cookie headers of a reply, by cookie name. */++function cookieMap(res) {++ const out = {};++ for (const c of res.headers.getSetCookie()) {++ const [pair] = c.split(';');++ const i = pair.indexOf('=');++ out[pair.slice(0, i)] = { value: decodeURIComponent(pair.slice(i + 1)), raw: c };++ }++ return out;++}++/** The flow cookies as a Cookie header (the browser's own jar, by hand). */++function flowCookie(cookies, names) {++ return names.filter((n) => cookies[n]).map((n) => `${n}=${encodeURIComponent(cookies[n].value)}`).join('; ');++}++++async function serve(app) {++ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });++ return { base: `http://127.0.0.1:${srv.address().port}`, close: () => new Promise((r) => srv.close(r)) };++}++++/** The gateway's auth routes, mounted the way index.js mounts them. */++function gateway(net, { clientSecret = CLIENT_SECRET } = {}) {++ const config = {++ baseUrl: 'https://openvibe.tools',++ networkUrl: net.url,++ networkInternalUrl: net.url,++ oauth: { clientId: CLIENT_ID, clientSecret, redirectUri: REDIRECT, scope: 'profile theme' },++ cookies: { domain: DOMAIN, secure: true },++ keyRetryMs: 100,++ };++ const auth = createAuthClient(config);++ const app = express();++ app.use(cookieParser());++ app.use('/auth', createAuthRoutes(config, auth));++ return { app, auth };++}++++(async () => {++ const net = await startNetwork();++ let gw = null;++ try {++ const { app, auth } = gateway(net);++ gw = await serve(app);++++ // ── GET /auth/login: the authorize URL with a PKCE S256 challenge ──++ const login = await fetch(`${gw.base}/auth/login?next=%2Ftools%3Fa%3D1`, { redirect: 'manual' });++ assert.strictEqual(login.status, 302, 'the login redirects');++ const location = new URL(login.headers.get('location'));++ assert.strictEqual(location.pathname, '/oauth/authorize');++ assert.strictEqual(location.searchParams.get('response_type'), 'code');++ assert.strictEqual(location.searchParams.get('client_id'), CLIENT_ID);++ assert.strictEqual(location.searchParams.get('redirect_uri'), REDIRECT);++ assert.strictEqual(location.searchParams.get('code_challenge_method'), 'S256', 'PKCE S256');++ assert.ok(location.searchParams.get('code_challenge'), 'a challenge');++ assert.ok(location.searchParams.get('state'), 'a state');++++ const cookies = cookieMap(login);++ // The flow cookies keep their names and their attributes (zone-wide, /auth, httpOnly).++ for (const name of ['ov_oauth_state', 'ov_oauth_verifier', 'ov_oauth_next']) {++ assert.ok(cookies[name], `${name} is set`);++ assert.match(cookies[name].raw, /Domain=\.openvibe\.tools/, name);++ assert.match(cookies[name].raw, /Path=\/auth/, name);++ assert.match(cookies[name].raw, /HttpOnly/, name);++ }++ assert.strictEqual(cookies.ov_oauth_next.value, '/tools?a=1', 'the next target is kept');++ assert.ok(!cookies.ov_token, 'no session yet');++++ // ── GET /auth/callback: a state that does not match never sets a session ──++ const bad = await fetch(`${gw.base}/auth/callback?code=x&state=not-the-state`, { redirect: 'manual', headers: { Cookie: flowCookie(cookies, ['ov_oauth_state', 'ov_oauth_verifier', 'ov_oauth_next']) } });++ assert.strictEqual(bad.status, 400, 'a wrong state is refused');++ assert.ok(!cookieMap(bad).ov_token, 'no session from a wrong state');++++ // ── the whole flow: the code is only redeemable with the flow's verifier ──++ const authorized = await fetch(location.href, { redirect: 'manual' });++ const { code } = await authorized.json();++ const cb = await fetch(`${gw.base}/auth/callback?code=${code}&state=${encodeURIComponent(location.searchParams.get('state'))}`, { redirect: 'manual', headers: { Cookie: flowCookie(cookies, ['ov_oauth_state', 'ov_oauth_verifier', 'ov_oauth_next']) } });++ assert.strictEqual(cb.status, 302, 'the callback redirects');++ assert.strictEqual(cb.headers.get('location'), '/tools?a=1', 'back to the next target');++ const granted = net.seen.find((b) => b.grant_type === 'authorization_code');++ assert.ok(granted, 'the code was exchanged');++ assert.strictEqual(granted.code_verifier, cookies.ov_oauth_verifier.value, 'the PKCE verifier went with it');++ assert.strictEqual(granted.redirect_uri, REDIRECT, 'the redirect URI is unchanged');++++ const session = cookieMap(cb);++ // The session cookies keep their names, domains, paths and httpOnly flags.++ assert.match(session.ov_token.raw, /Domain=\.openvibe\.tools/, 'ov_token is zone-wide');++ assert.match(session.ov_token.raw, /Path=\/;/, 'ov_token is on /');++ assert.ok(!/HttpOnly/i.test(session.ov_token.raw), 'ov_token stays readable by the satellites');++ assert.match(session.ov_refresh.raw, /Domain=\.openvibe\.tools/, 'ov_refresh is zone-wide');++ assert.match(session.ov_refresh.raw, /Path=\/auth/, 'ov_refresh only leaves for /auth');++ assert.match(session.ov_refresh.raw, /HttpOnly/i, 'ov_refresh is httpOnly');++ assert.strictEqual(session.ov_sso_hint.value, 'account', 'the hint says account');++ assert.ok(!/HttpOnly/i.test(session.ov_sso_hint.raw), 'the hint is readable by the navbar');++++ // ── GET /auth/me: the session reads back as the person ──++ const me = await fetch(`${gw.base}/auth/me`, { headers: { Cookie: `ov_token=${encodeURIComponent(session.ov_token.value)}` } });++ assert.strictEqual(me.status, 200);++ const meBody = await me.json();++ assert.strictEqual(meBody.user.username, 'person1', 'the profile');++ assert.ok(!('aud' in meBody.user) && !('iat' in meBody.user), 'the registered claims are not the profile');++++ // ── a principal's token is not a person's session ──++ const service = sign({ iss: net.url, sub: 'svc:partner', actor_type: 'service', aud: ['openvibe.tools'], cap: ['tools.net.probe'], iat: t(), exp: t() + 900 });++ const asService = await fetch(`${gw.base}/auth/me`, { headers: { Cookie: `ov_token=${encodeURIComponent(service)}` } });++ assert.deepStrictEqual(await asService.json(), { user: null }, 'a service token is not a session');++++ // ── next is sanitised at the login ──++ const evil = await fetch(`${gw.base}/auth/login?next=${encodeURIComponent('https://evil.example/x')}`, { redirect: 'manual' });++ const evilCookies = cookieMap(evil);++ assert.ok(!evilCookies.ov_oauth_next || ['/', ''].includes(evilCookies.ov_oauth_next.value), 'an outside target is dropped');++ assert.ok(!evilCookies.ov_oauth_next || !evilCookies.ov_oauth_next.value.includes('evil.example'), 'the outside target is never kept');++++ // ── GET /auth/health: the Network accepts this client ──++ const health = await fetch(`${gw.base}/auth/health`);++ assert.strictEqual(health.status, 200);++ assert.deepStrictEqual(await health.json(), { ok: true, client: 'ok' });++++ auth.stop();++++ // ── and 503 when the Network refuses the client ──++ const refused = gateway(net, { clientSecret: 'wrong-secret' });++ const refusedSrv = await serve(refused.app);++ try {++ const h = await fetch(`${refusedSrv.base}/auth/health`);++ assert.strictEqual(h.status, 503, 'the health answers 503 when the client is refused');++ assert.deepStrictEqual(await h.json(), { ok: false, client: 'invalid' });++ } finally {++ refused.auth.stop();++ await refusedSrv.close();++ }++++ console.log('gateway sign-in: PKCE S256, the flow cookies, the zone-wide session cookies, /auth/health');++ } finally {++ if (gw) await gw.close();++ await net.close();++ }++})().catch((e) => { console.error(e); process.exit(1); });
@@ -18,9 +18,11 @@ service is an OAuth2 **client** (`client_id: tools`), not a provider.
@@ -18,9 +18,11 @@ service is an OAuth2 **client** (`client_id: tools`), not a provider.
`/api/pastes/*` is forwarded to OpenVibe.Community (`OV_COMMUNITY_INTERNAL_URL`)
`/api/pastes/*` is forwarded to OpenVibe.Community (`OV_COMMUNITY_INTERNAL_URL`)
with the visitor's `ov_token`.
with the visitor's `ov_token`.
- **Session layer** — `/auth/login`, `/auth/callback`, `/auth/logout`,
- **Session layer** — `/auth/login`, `/auth/callback`, `/auth/logout`,
- `/auth/me`, `/auth/refresh`. Sets the shared `ov_token` cookie on
- `.openvibe.tools` so every satellite subdomain can read it and verify it
- offline against the Network's public key (JWKS).
+ `/auth/me`, `/auth/refresh`, `/auth/health`. The OAuth half (the authorize
+ URL with PKCE S256, the code / refresh / FedCM grants and the client check)
+ is `openvibe-sdk/sso`'s; the cookies stay the gateway's own. Sets the shared
+ `ov_token` cookie on `.openvibe.tools` so every satellite subdomain can read
+ it and verify it offline against the Network's public key (JWKS).
## Architecture
## Architecture
@@ -6,15 +6,24 @@
@@ -6,15 +6,24 @@
// The identity provider is OpenVibe.Network (https://openvibe.network).
// The identity provider is OpenVibe.Network (https://openvibe.network).
// This gateway is registered there as OAuth client `tools`.
// This gateway is registered there as OAuth client `tools`.
//
//
+// The OAuth half is openvibe-sdk/sso's (createSsoClient): the authorize URL
+// with PKCE (S256), the code / refresh / FedCM grants and GET /auth/health.
+// The session half stays this gateway's own, because the cookies belong to
+// the whole *.openvibe.tools zone (the SDK's cookies are host-only) and every
+// satellite reads ov_token from JavaScript.
+//
// GET /auth/login → redirect to Network /oauth/authorize
// GET /auth/login → redirect to Network /oauth/authorize
// (?silent=1 adds prompt=none: no login UI, the
// (?silent=1 adds prompt=none: no login UI, the
// Network answers login_required when nobody is
// Network answers login_required when nobody is
// signed in there). A browser that already holds a
// signed in there). A browser that already holds a
// valid ov_token skips the Network and goes to next.
// valid ov_token skips the Network and goes to next.
-// GET /auth/callback → server-side code exchange, set cookies
+// GET /auth/callback → server-side code exchange (with the PKCE
+// verifier), set cookies
// POST /auth/fedcm → browser-native FedCM sign-in: the page posts the
// POST /auth/fedcm → browser-native FedCM sign-in: the page posts the
// Network's assertion JWT + its nonce, we exchange it
// Network's assertion JWT + its nonce, we exchange it
// (jwt-bearer grant) and set the same cookies
// (jwt-bearer grant) and set the same cookies
+// 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)
// GET /auth/logout → clear cookies (+ best-effort refresh revoke)
// GET /auth/me → offline-verify ov_token, return profile
// GET /auth/me → offline-verify ov_token, return profile
// POST /auth/refresh → rotate tokens via refresh_token grant
// POST /auth/refresh → rotate tokens via refresh_token grant
@@ -28,19 +37,28 @@
@@ -28,19 +37,28 @@
// One year, JS-readable, Domain=.openvibe.tools. The shared
// One year, JS-readable, Domain=.openvibe.tools. The shared
// navbar reads it: only when it says 'account' does a page
// navbar reads it: only when it says 'account' does a page
// with no session attempt ONE silent (prompt=none) login.
// with no session attempt ONE silent (prompt=none) login.
+// ov_oauth_state / _next / _silent / _verifier — the sign-in flow's own
+// cookies: Domain=.openvibe.tools, Path=/auth, httpOnly,
+// 10 minutes. The verifier is the PKCE verifier; the state is
+// compared with it in constant time.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const express = require('express');
const express = require('express');
const crypto = require('crypto');
const crypto = require('crypto');
-const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+const { createSsoClient, failurePage, fedcmNonceMatches, claimsToUser } = require('openvibe-sdk/sso');
+const { verifyUserToken } = require('openvibe-sdk/auth');
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
const REFRESH_COOKIE = 'ov_refresh';
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 SSO_HINT_COOKIE = 'ov_sso_hint';
const SSO_HINT_COOKIE = 'ov_sso_hint';
+// The audience the Network's user tokens carry for this site (the guard checks the same one).
+const AUDIENCE = 'openvibe.tools';
+
// Origin of the identity provider's own "sign you in everywhere" chain. The Network hops
// Origin of the identity provider's own "sign you in everywhere" chain. The Network hops
// through /auth/login?silent=1&next=https://openvibe.network/sso/fanout?… and back, so
// through /auth/login?silent=1&next=https://openvibe.network/sso/fanout?… and back, so
// its URLs are valid post-login targets alongside our own hosts.
// its URLs are valid post-login targets alongside our own hosts.
@@ -93,27 +111,13 @@ function fedcmCors(req, res, next) {
@@ -93,27 +111,13 @@ function fedcmCors(req, res, next) {
}
}
/**
/**
- * Read a JWT's payload WITHOUT checking its signature. Only used to compare the FedCM
- * assertion's nonce with the one the page posted; the Network verifies the signature when
- * the assertion is exchanged. Returns null when the token is not a three-part JWT.
+ * Compare two strings in constant time (the state must match the browser that started the
+ * flow). The length is not secret, so unequal lengths answer false at once.
*/
*/
-function decodeJwtPayload(token) {
- if (typeof token !== 'string') return null;
- const parts = token.split('.');
- if (parts.length !== 3) return null;
- try {
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
- return payload && typeof payload === 'object' ? payload : null;
- } catch {
- return null;
- }
-}
-
-/** Constant-time string comparison; unequal lengths short-circuit (the length is not secret). */
-function safeEqual(a, b) {
- if (typeof a !== 'string' || typeof b !== 'string' || !a || !b) return false;
- if (a.length !== b.length) return false;
- return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
+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);
}
}
/**
/**
@@ -122,14 +126,9 @@ function safeEqual(a, b) {
@@ -122,14 +126,9 @@ function safeEqual(a, b) {
* GET /api/.well-known/jwks and verify JWTs locally on every request.
* GET /api/.well-known/jwks and verify JWTs locally on every request.
*/
*/
function createAuthClient(config) {
function createAuthClient(config) {
- const client = new OpenVibeAuthClient({
- clientId: config.oauth.clientId,
- clientSecret: config.oauth.clientSecret,
- redirectUri: config.oauth.redirectUri,
- publicKey: null, // filled by ensureKey()
- authBase: config.networkUrl,
- internalBase: config.networkInternalUrl,
- });
+ // The Network's RS256 public key (PEM). The guard verifies signed-in visitors with it
+ // (auth.client.publicKey) and /api/ready reports whether it loaded.
+ const client = { publicKey: null };
let lastFetch = 0;
let lastFetch = 0;
const gapMs = config.keyRetryMs || 30_000;
const gapMs = config.keyRetryMs || 30_000;
@@ -156,11 +155,20 @@ function createAuthClient(config) {
@@ -156,11 +155,20 @@ function createAuthClient(config) {
return null;
return null;
}
}
- /** Offline JWT verification. Returns decoded claims or null. */
+ /**
+ * Offline JWT verification (openvibe-sdk/auth): the signature, the issuer, the audience
+ * and the expiry. A service, app, module or node token — and any typed token (a FedCM
+ * assertion, an export ticket) — is never a person's session, whatever it names.
+ */
async function verify(token) {
async function verify(token) {
if (!token) return null;
if (!token) return null;
await ensureKey();
await ensureKey();
- return client.verifyToken(token);
+ if (!client.publicKey) return null;
+ try {
+ return await verifyUserToken(token, { publicKey: client.publicKey, issuer: config.networkUrl, audience: AUDIENCE });
+ } catch {
+ return null;
+ }
}
}
// Warm the key cache at boot, and keep trying every 30 s until it loads (non-fatal if Network is down). After the
// Warm the key cache at boot, and keep trying every 30 s until it loads (non-fatal if Network is down). After the
@@ -187,6 +195,24 @@ function extractToken(req) {
@@ -187,6 +195,24 @@ function extractToken(req) {
function createAuthRoutes(config, auth) {
function createAuthRoutes(config, auth) {
const router = express.Router();
const router = express.Router();
+ // The OAuth half is the SDK's: the authorize URL (PKCE S256 + state), the code, refresh
+ // and FedCM grants, and the client check behind GET /auth/health. The cookies below stay
+ // this gateway's own — they belong to the whole *.openvibe.tools zone.
+ const sso = createSsoClient({
+ site: 'tools',
+ siteName: 'OpenVibe.Tools',
+ baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ networkUrl: config.networkUrl,
+ networkInternalUrl: config.networkInternalUrl,
+ issuer: config.networkUrl,
+ audience: AUDIENCE,
+ secureCookies: config.cookies.secure,
+ });
+
const accessCookieOpts = () => ({
const accessCookieOpts = () => ({
domain: config.cookies.domain || undefined,
domain: config.cookies.domain || undefined,
sameSite: 'lax',
sameSite: 'lax',
@@ -276,66 +302,34 @@ function createAuthRoutes(config, auth) {
@@ -276,66 +302,34 @@ function createAuthRoutes(config, auth) {
return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
}
}
- /** Exchange at the Network — internal URL first, public as fallback. */
- async function tokenGrant(body) {
- let lastErr = null;
- for (const base of [config.networkInternalUrl, config.networkUrl]) {
- if (!base) continue;
- try {
- const res = await fetch(`${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.code = typeof data.error === 'string' ? data.error : null;
- err.description = typeof data.error_description === 'string' ? data.error_description : null;
- throw err;
- }
- return data;
- } catch (err) {
- lastErr = err;
- // 4xx = the grant itself is bad; retrying against another base won't help
- if (err.status && err.status < 500) throw err;
- }
- }
- throw lastErr || new Error('Network unreachable');
- }
-
// ── GET /auth/login ──────────────────────────────────────
// ── GET /auth/login ──────────────────────────────────────
router.get('/login', async (req, res) => {
router.get('/login', async (req, res) => {
// Silent mode (?silent=1): the shared navbar uses it when a page has no session but
// Silent mode (?silent=1): the shared navbar uses it when a page has no session but
// the browser carries an ov_sso_hint=account cookie. prompt=none tells the Network to
// the browser carries an ov_sso_hint=account cookie. prompt=none tells the Network to
// answer without any UI — a code if the user is signed in there, login_required if not.
// answer without any UI — a code if the user is signed in there, login_required if not.
const silent = String(req.query.silent || '') === '1';
const silent = String(req.query.silent || '') === '1';
+ const next = sanitizeNext(req.query.next);
if (silent) {
if (silent) {
// Already signed in here (a valid ov_token rode along)? Then the round trip to the
// Already signed in here (a valid ov_token rode along)? Then the round trip to the
// Network would only hand back the session this browser already has: go straight
// Network would only hand back the session this browser already has: go straight
// to the page that asked.
// to the page that asked.
const existing = req.cookies?.[ACCESS_COOKIE];
const existing = req.cookies?.[ACCESS_COOKIE];
if (existing && await auth.verify(existing)) {
if (existing && await auth.verify(existing)) {
- return res.redirect(sanitizeNext(req.query.next));
+ return res.redirect(next);
}
}
}
}
- let { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);
- if (silent) url += '&prompt=none';
+ // The SDK's authorize URL: state + PKCE (S256). The verifier goes into a flow cookie
+ // of its own and comes back with the code.
+ const started = sso.authorizeUrl({ silent });
// Domain-wide: a login can start on net./dev./pastes. but the OAuth
// Domain-wide: a login can start on net./dev./pastes. but the OAuth
// redirect_uri is pinned to the apex — the callback must see these cookies.
// redirect_uri is pinned to the apex — the callback must see these cookies.
- res.cookie(STATE_COOKIE, state, flowCookieOpts());
- const next = sanitizeNext(req.query.next);
+ res.cookie(STATE_COOKIE, started.state, flowCookieOpts());
+ res.cookie(VERIFIER_COOKIE, started.verifier, flowCookieOpts());
if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());
if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());
else clearFlowCookie(res, NEXT_COOKIE);
else clearFlowCookie(res, NEXT_COOKIE);
if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
else clearFlowCookie(res, SILENT_COOKIE);
else clearFlowCookie(res, SILENT_COOKIE);
- res.redirect(url);
+ res.redirect(started.url);
});
});
// ── GET /auth/callback ───────────────────────────────────
// ── GET /auth/callback ───────────────────────────────────
@@ -350,6 +344,7 @@ function createAuthRoutes(config, auth) {
@@ -350,6 +344,7 @@ function createAuthRoutes(config, auth) {
const next = sanitizeNext(req.cookies?.[NEXT_COOKIE]);
const next = sanitizeNext(req.cookies?.[NEXT_COOKIE]);
clearFlowCookie(res, NEXT_COOKIE);
clearFlowCookie(res, NEXT_COOKIE);
clearFlowCookie(res, STATE_COOKIE);
clearFlowCookie(res, STATE_COOKIE);
+ clearFlowCookie(res, VERIFIER_COOKIE);
return res.redirect(withParam(next, 'sso', 'none'));
return res.redirect(withParam(next, 'sso', 'none'));
}
}
return res.redirect(`/?auth_error=${encodeURIComponent(error)}`);
return res.redirect(`/?auth_error=${encodeURIComponent(error)}`);
@@ -357,19 +352,22 @@ function createAuthRoutes(config, auth) {
@@ -357,19 +352,22 @@ function createAuthRoutes(config, auth) {
if (!code) return res.status(400).send('Missing authorization code');
if (!code) return res.status(400).send('Missing authorization code');
const expectedState = req.cookies?.[STATE_COOKIE];
const expectedState = req.cookies?.[STATE_COOKIE];
+ const verifier = req.cookies?.[VERIFIER_COOKIE];
clearFlowCookie(res, STATE_COOKIE);
clearFlowCookie(res, STATE_COOKIE);
- if (!expectedState || !state || !crypto.timingSafeEqual(
- Buffer.from(String(state).padEnd(64).slice(0, 64)),
- Buffer.from(String(expectedState).padEnd(64).slice(0, 64))
- )) {
+ clearFlowCookie(res, VERIFIER_COOKIE);
+ // The state cookie is required, not only compared when it is there: without it a
+ // crafted link could sign a visitor into somebody else's account. The comparison is
+ // in constant time, and the PKCE verifier must come from the same flow.
+ if (!expectedState || !state || !verifier || !sameString(state, expectedState)) {
return res.status(400).send('OAuth state mismatch — please try signing in again.');
return res.status(400).send('OAuth state mismatch — please try signing in again.');
}
}
try {
try {
- const data = await tokenGrant({
+ const data = await sso.tokenGrant({
grant_type: 'authorization_code',
grant_type: 'authorization_code',
redirect_uri: config.oauth.redirectUri,
redirect_uri: config.oauth.redirectUri,
- code,
+ code: String(code),
+ code_verifier: verifier,
});
});
setSessionCookies(res, data.access_token, data.refresh_token);
setSessionCookies(res, data.access_token, data.refresh_token);
setSsoHint(res, 'account');
setSsoHint(res, 'account');
@@ -377,11 +375,32 @@ function createAuthRoutes(config, auth) {
@@ -377,11 +375,32 @@ function createAuthRoutes(config, auth) {
clearFlowCookie(res, NEXT_COOKIE);
clearFlowCookie(res, NEXT_COOKIE);
return res.redirect(next);
return res.redirect(next);
} catch (err) {
} catch (err) {
+ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
+ // The Network refuses our client (a wrong id or secret): a configuration
+ // problem, not the person's account — say so plainly.
+ console.error(`[Auth] the Network refuses this site's OAuth client (${err.error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);
+ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE]);
+ return res.status(500).type('html').send(failurePage({
+ siteName: 'OpenVibe.Tools',
+ title: "Sign-in isn't working on OpenVibe.Tools 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 && next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`,
+ backHref: next || '/',
+ }));
+ }
console.error('[Auth] Code exchange failed:', err.message);
console.error('[Auth] Code exchange failed:', err.message);
return res.status(502).send('Sign-in failed — could not reach the OpenVibe.Network. Please try again.');
return res.status(502).send('Sign-in failed — could not reach the OpenVibe.Network. Please try again.');
}
}
});
});
+ // ── GET /auth/health ─────────────────────────────────────
+ // Does the Network accept this site's OAuth client? 503 only when it refuses it.
+ router.get('/health', async (_req, res) => {
+ const client = await sso.checkClient();
+ res.set('Cache-Control', 'no-store');
+ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
+ });
+
// ── POST /auth/fedcm ─────────────────────────────────────
// ── POST /auth/fedcm ─────────────────────────────────────
// Browser-native FedCM sign-in. The shared navbar asks the browser for an identity
// Browser-native FedCM sign-in. The shared navbar asks the browser for an identity
// credential from openvibe.network (with a nonce it generated) and posts the resulting
// credential from openvibe.network (with a nonce it generated) and posts the resulting
@@ -403,14 +422,12 @@ function createAuthRoutes(config, auth) {
@@ -403,14 +422,12 @@ function createAuthRoutes(config, auth) {
// Nonce binding: the assertion must carry the very nonce this page generated, or it
// Nonce binding: the assertion must carry the very nonce this page generated, or it
// was minted for a different request. Signature-less decode is enough for that — the
// was minted for a different request. Signature-less decode is enough for that — the
// Network verifies the signature when we exchange it.
// Network verifies the signature when we exchange it.
- const claims = decodeJwtPayload(token);
- if (!claims) return res.status(400).json({ error: 'invalid_request', error_description: 'Token is not a JWT' });
- if (!safeEqual(String(claims.nonce ?? ''), nonce)) {
+ if (!fedcmNonceMatches(token, nonce)) {
return res.status(400).json({ error: 'invalid_request', error_description: 'Nonce mismatch' });
return res.status(400).json({ error: 'invalid_request', error_description: 'Nonce mismatch' });
}
}
try {
try {
- const data = await tokenGrant({ grant_type: JWT_BEARER_GRANT, assertion: token });
+ const data = await sso.tokenGrant({ grant_type: JWT_BEARER_GRANT, assertion: token });
if (!data.access_token) {
if (!data.access_token) {
return res.status(401).json({ error: 'invalid_grant', error_description: 'The OpenVibe.Network issued no access token' });
return res.status(401).json({ error: 'invalid_grant', error_description: 'The OpenVibe.Network issued no access token' });
}
}
@@ -419,18 +436,15 @@ function createAuthRoutes(config, auth) {
@@ -419,18 +436,15 @@ function createAuthRoutes(config, auth) {
let user = data.user || null;
let user = data.user || null;
if (!user) {
if (!user) {
const verified = await auth.verify(data.access_token);
const verified = await auth.verify(data.access_token);
- if (verified) {
- const { iat, exp, aud, iss, ...rest } = verified;
- user = rest;
- }
+ if (verified) user = claimsToUser(verified);
}
}
return res.json({ ok: true, user });
return res.json({ ok: true, user });
} catch (err) {
} catch (err) {
if (err.status && err.status < 500) {
if (err.status && err.status < 500) {
// The Network refused the assertion (expired, wrong audience, unknown user…).
// The Network refused the assertion (expired, wrong audience, unknown user…).
return res.status(401).json({
return res.status(401).json({
- error: err.code || 'invalid_grant',
- error_description: err.description || err.message,
+ error: err.error || 'invalid_grant',
+ error_description: err.error_description || err.message,
});
});
}
}
console.error('[Auth] FedCM exchange failed:', err.message);
console.error('[Auth] FedCM exchange failed:', err.message);
@@ -471,8 +485,7 @@ function createAuthRoutes(config, auth) {
@@ -471,8 +485,7 @@ function createAuthRoutes(config, auth) {
if (!token) return res.json({ user: null }); // signed out: 200 with no user (as Blog, Community, Wiki), not a 401 in every signed-out visitor's console
if (!token) return res.json({ user: null }); // signed out: 200 with no user (as Blog, Community, Wiki), not a 401 in every signed-out visitor's console
const claims = await auth.verify(token);
const claims = await auth.verify(token);
if (!claims) return res.json({ user: null }); // an expired token is signed out too; the navbar refreshes through /auth/refresh
if (!claims) return res.json({ user: null }); // an expired token is signed out too; the navbar refreshes through /auth/refresh
- const { iat, exp, aud, iss, ...user } = claims;
- res.json({ user, expires_at: exp ? exp * 1000 : null });
+ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
});
});
// ── POST /auth/refresh ───────────────────────────────────
// ── POST /auth/refresh ───────────────────────────────────
@@ -481,15 +494,10 @@ function createAuthRoutes(config, auth) {
@@ -481,15 +494,10 @@ function createAuthRoutes(config, auth) {
const refresh = req.cookies?.[REFRESH_COOKIE];
const refresh = 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 sso.tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
setSessionCookies(res, data.access_token, data.refresh_token);
setSessionCookies(res, data.access_token, data.refresh_token);
const claims = await auth.verify(data.access_token);
const claims = await auth.verify(data.access_token);
- let user = null;
- if (claims) {
- const { iat, exp, aud, iss, ...rest } = claims;
- user = rest;
- }
- return res.json({ token: data.access_token, user });
+ return res.json({ token: data.access_token, user: claims ? claimsToUser(claims) : null });
} catch (err) {
} catch (err) {
if (err.status && err.status < 500) {
if (err.status && err.status < 500) {
clearSessionCookies(res);
clearSessionCookies(res);
@@ -216,7 +216,8 @@ app.use(guard.identify);
@@ -216,7 +216,8 @@ app.use(guard.identify);
app.use(require('../../_shared/usage').usagePages({ snapshot: toolRegistry.snapshot }));
app.use(require('../../_shared/usage').usagePages({ snapshot: toolRegistry.snapshot }));
app.use('/api/', guard.apiQuota);
app.use('/api/', guard.apiQuota);
// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.
// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.
-app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, skip: (req) => req.path === '/me', keyGenerator: (req) => guard.caller(req).ipKey }));
+// /auth/health is a monitor's read of the OAuth client, not a sign-in attempt: it must answer even when the limit is spent.
+app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, skip: (req) => req.path === '/me' || req.path === '/health', keyGenerator: (req) => guard.caller(req).ipKey }));
// ── Tool registry (ADR-027, capability tools.tool.read) ──────
// ── Tool registry (ADR-027, capability tools.tool.read) ──────
// GET /api/v1/tools (tools.tool-list@1), /api/v1/tools/:id (tools.tool@1), /api/v1/tools/:id/schema.
// GET /api/v1/tools (tools.tool-list@1), /api/v1/tools/:id (tools.tool@1), /api/v1/tools/:id/schema.
new file mode 100644
new file mode 100644
@@ -0,0 +1,234 @@
@@ -0,0 +1,234 @@
+'use strict';
+// The gateway's sign-in flow (apps/gateway/server/auth/routes.js): the OAuth half is openvibe-sdk/sso's
+// (the authorize URL with PKCE S256, the code / refresh / FedCM grants and GET /auth/health), the
+// session half stays the gateway's own, because its cookies belong to the whole *.openvibe.tools
+// zone. A stand-in Network speaks the token endpoint (JSON, like the SDK posts it) and answers the
+// authorize request with a code bound to the challenge it was given.
+//
+// What this pins down:
+// - the flow cookies and the session cookies keep their names, domains, paths and httpOnly flags
+// - the authorize URL carries a PKCE S256 challenge, and the code is only redeemable with the
+// verifier from the flow cookie (a code with the wrong verifier is refused by the Network)
+// - a state that does not match the flow cookie never sets a session
+// - next is sanitised at the login and again at the callback
+// - GET /auth/health answers 200 while the Network accepts the client and 503 when it refuses it
+// - a service principal's token is not a person's session
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const { createAuthClient, createAuthRoutes } = require('../server/auth/routes');
+
+const CLIENT_ID = 'tools';
+const CLIENT_SECRET = 'tools-secret';
+const REDIRECT = 'https://openvibe.tools/auth/callback';
+const DOMAIN = '.openvibe.tools';
+
+const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+const t = () => Math.floor(Date.now() / 1000);
+const sign = (claims) => {
+ const head = b64({ alg: 'RS256', typ: 'JWT' });
+ const body = b64(claims);
+ return `${head}.${body}.${crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), privateKey).toString('base64url')}`;
+};
+
+async function startNetwork() {
+ const codes = new Map(); // code -> { challenge }
+ const seen = [];
+ let issuer = '';
+ const srv = http.createServer((req, res) => {
+ const raw = [];
+ req.on('data', (c) => raw.push(c));
+ req.on('end', () => {
+ const text = Buffer.concat(raw).toString('utf8');
+ const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
+ const url = new URL(req.url, 'http://x');
+ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicKey, algorithm: 'RS256' });
+ if (url.pathname === '/oauth/authorize') {
+ // The Network's authorize endpoint: a code bound to the challenge it was given.
+ const challenge = url.searchParams.get('code_challenge');
+ const code = crypto.randomBytes(16).toString('hex');
+ codes.set(code, { challenge });
+ return json(200, { code, state: url.searchParams.get('state') });
+ }
+ if (url.pathname === '/oauth/token' && req.method === 'POST') {
+ // The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form.
+ const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
+ seen.push(body);
+ if (body.client_id !== CLIENT_ID || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
+ if (body.grant_type === 'authorization_code') {
+ const entry = codes.get(body.code);
+ if (!entry) return json(400, { error: 'invalid_grant', error_description: 'Invalid authorization code' });
+ codes.delete(body.code);
+ const digest = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
+ if (entry.challenge && digest !== entry.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
+ return json(200, {
+ access_token: sign({ iss: issuer, sub: '1001', id: 1001, subject_id: 'usr_01J0000000000000000000000', username: 'person1', aud: ['openvibe.tools'], iat: t(), exp: t() + 3600 }),
+ refresh_token: 'refresh-x',
+ token_type: 'Bearer',
+ });
+ }
+ if (body.grant_type === 'refresh_token') {
+ return json(200, {
+ access_token: sign({ iss: issuer, sub: '1001', id: 1001, subject_id: 'usr_01J0000000000000000000000', username: 'person1', aud: ['openvibe.tools'], iat: t(), exp: t() + 3600 }),
+ refresh_token: 'refresh-y',
+ token_type: 'Bearer',
+ });
+ }
+ if (body.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
+ return json(200, {
+ access_token: sign({ iss: issuer, sub: '1001', id: 1001, subject_id: 'usr_01J0000000000000000000000', username: 'person1', aud: ['openvibe.tools'], iat: t(), exp: t() + 3600 }),
+ refresh_token: 'refresh-z',
+ token_type: 'Bearer',
+ });
+ }
+ return json(400, { error: 'unsupported_grant_type' });
+ }
+ return json(404, {});
+ });
+ });
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const url = `http://127.0.0.1:${srv.address().port}`;
+ issuer = url;
+ return { url, codes, seen, close: () => new Promise((r) => srv.close(r)) };
+}
+
+/** The Set-Cookie headers of a reply, by cookie name. */
+function cookieMap(res) {
+ const out = {};
+ for (const c of res.headers.getSetCookie()) {
+ const [pair] = c.split(';');
+ const i = pair.indexOf('=');
+ out[pair.slice(0, i)] = { value: decodeURIComponent(pair.slice(i + 1)), raw: c };
+ }
+ return out;
+}
+/** The flow cookies as a Cookie header (the browser's own jar, by hand). */
+function flowCookie(cookies, names) {
+ return names.filter((n) => cookies[n]).map((n) => `${n}=${encodeURIComponent(cookies[n].value)}`).join('; ');
+}
+
+async function serve(app) {
+ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
+ return { base: `http://127.0.0.1:${srv.address().port}`, close: () => new Promise((r) => srv.close(r)) };
+}
+
+/** The gateway's auth routes, mounted the way index.js mounts them. */
+function gateway(net, { clientSecret = CLIENT_SECRET } = {}) {
+ const config = {
+ baseUrl: 'https://openvibe.tools',
+ networkUrl: net.url,
+ networkInternalUrl: net.url,
+ oauth: { clientId: CLIENT_ID, clientSecret, redirectUri: REDIRECT, scope: 'profile theme' },
+ cookies: { domain: DOMAIN, secure: true },
+ keyRetryMs: 100,
+ };
+ const auth = createAuthClient(config);
+ const app = express();
+ app.use(cookieParser());
+ app.use('/auth', createAuthRoutes(config, auth));
+ return { app, auth };
+}
+
+(async () => {
+ const net = await startNetwork();
+ let gw = null;
+ try {
+ const { app, auth } = gateway(net);
+ gw = await serve(app);
+
+ // ── GET /auth/login: the authorize URL with a PKCE S256 challenge ──
+ const login = await fetch(`${gw.base}/auth/login?next=%2Ftools%3Fa%3D1`, { redirect: 'manual' });
+ assert.strictEqual(login.status, 302, 'the login redirects');
+ const location = new URL(login.headers.get('location'));
+ assert.strictEqual(location.pathname, '/oauth/authorize');
+ assert.strictEqual(location.searchParams.get('response_type'), 'code');
+ assert.strictEqual(location.searchParams.get('client_id'), CLIENT_ID);
+ assert.strictEqual(location.searchParams.get('redirect_uri'), REDIRECT);
+ assert.strictEqual(location.searchParams.get('code_challenge_method'), 'S256', 'PKCE S256');
+ assert.ok(location.searchParams.get('code_challenge'), 'a challenge');
+ assert.ok(location.searchParams.get('state'), 'a state');
+
+ const cookies = cookieMap(login);
+ // The flow cookies keep their names and their attributes (zone-wide, /auth, httpOnly).
+ for (const name of ['ov_oauth_state', 'ov_oauth_verifier', 'ov_oauth_next']) {
+ assert.ok(cookies[name], `${name} is set`);
+ assert.match(cookies[name].raw, /Domain=\.openvibe\.tools/, name);
+ assert.match(cookies[name].raw, /Path=\/auth/, name);
+ assert.match(cookies[name].raw, /HttpOnly/, name);
+ }
+ assert.strictEqual(cookies.ov_oauth_next.value, '/tools?a=1', 'the next target is kept');
+ assert.ok(!cookies.ov_token, 'no session yet');
+
+ // ── GET /auth/callback: a state that does not match never sets a session ──
+ const bad = await fetch(`${gw.base}/auth/callback?code=x&state=not-the-state`, { redirect: 'manual', headers: { Cookie: flowCookie(cookies, ['ov_oauth_state', 'ov_oauth_verifier', 'ov_oauth_next']) } });
+ assert.strictEqual(bad.status, 400, 'a wrong state is refused');
+ assert.ok(!cookieMap(bad).ov_token, 'no session from a wrong state');
+
+ // ── the whole flow: the code is only redeemable with the flow's verifier ──
+ const authorized = await fetch(location.href, { redirect: 'manual' });
+ const { code } = await authorized.json();
+ const cb = await fetch(`${gw.base}/auth/callback?code=${code}&state=${encodeURIComponent(location.searchParams.get('state'))}`, { redirect: 'manual', headers: { Cookie: flowCookie(cookies, ['ov_oauth_state', 'ov_oauth_verifier', 'ov_oauth_next']) } });
+ assert.strictEqual(cb.status, 302, 'the callback redirects');
+ assert.strictEqual(cb.headers.get('location'), '/tools?a=1', 'back to the next target');
+ const granted = net.seen.find((b) => b.grant_type === 'authorization_code');
+ assert.ok(granted, 'the code was exchanged');
+ assert.strictEqual(granted.code_verifier, cookies.ov_oauth_verifier.value, 'the PKCE verifier went with it');
+ assert.strictEqual(granted.redirect_uri, REDIRECT, 'the redirect URI is unchanged');
+
+ const session = cookieMap(cb);
+ // The session cookies keep their names, domains, paths and httpOnly flags.
+ assert.match(session.ov_token.raw, /Domain=\.openvibe\.tools/, 'ov_token is zone-wide');
+ assert.match(session.ov_token.raw, /Path=\/;/, 'ov_token is on /');
+ assert.ok(!/HttpOnly/i.test(session.ov_token.raw), 'ov_token stays readable by the satellites');
+ assert.match(session.ov_refresh.raw, /Domain=\.openvibe\.tools/, 'ov_refresh is zone-wide');
+ assert.match(session.ov_refresh.raw, /Path=\/auth/, 'ov_refresh only leaves for /auth');
+ assert.match(session.ov_refresh.raw, /HttpOnly/i, 'ov_refresh is httpOnly');
+ assert.strictEqual(session.ov_sso_hint.value, 'account', 'the hint says account');
+ assert.ok(!/HttpOnly/i.test(session.ov_sso_hint.raw), 'the hint is readable by the navbar');
+
+ // ── GET /auth/me: the session reads back as the person ──
+ const me = await fetch(`${gw.base}/auth/me`, { headers: { Cookie: `ov_token=${encodeURIComponent(session.ov_token.value)}` } });
+ assert.strictEqual(me.status, 200);
+ const meBody = await me.json();
+ assert.strictEqual(meBody.user.username, 'person1', 'the profile');
+ assert.ok(!('aud' in meBody.user) && !('iat' in meBody.user), 'the registered claims are not the profile');
+
+ // ── a principal's token is not a person's session ──
+ const service = sign({ iss: net.url, sub: 'svc:partner', actor_type: 'service', aud: ['openvibe.tools'], cap: ['tools.net.probe'], iat: t(), exp: t() + 900 });
+ const asService = await fetch(`${gw.base}/auth/me`, { headers: { Cookie: `ov_token=${encodeURIComponent(service)}` } });
+ assert.deepStrictEqual(await asService.json(), { user: null }, 'a service token is not a session');
+
+ // ── next is sanitised at the login ──
+ const evil = await fetch(`${gw.base}/auth/login?next=${encodeURIComponent('https://evil.example/x')}`, { redirect: 'manual' });
+ const evilCookies = cookieMap(evil);
+ assert.ok(!evilCookies.ov_oauth_next || ['/', ''].includes(evilCookies.ov_oauth_next.value), 'an outside target is dropped');
+ assert.ok(!evilCookies.ov_oauth_next || !evilCookies.ov_oauth_next.value.includes('evil.example'), 'the outside target is never kept');
+
+ // ── GET /auth/health: the Network accepts this client ──
+ const health = await fetch(`${gw.base}/auth/health`);
+ assert.strictEqual(health.status, 200);
+ assert.deepStrictEqual(await health.json(), { ok: true, client: 'ok' });
+
+ auth.stop();
+
+ // ── and 503 when the Network refuses the client ──
+ const refused = gateway(net, { clientSecret: 'wrong-secret' });
+ const refusedSrv = await serve(refused.app);
+ try {
+ const h = await fetch(`${refusedSrv.base}/auth/health`);
+ assert.strictEqual(h.status, 503, 'the health answers 503 when the client is refused');
+ assert.deepStrictEqual(await h.json(), { ok: false, client: 'invalid' });
+ } finally {
+ refused.auth.stop();
+ await refusedSrv.close();
+ }
+
+ console.log('gateway sign-in: PKCE S256, the flow cookies, the zone-wide session cookies, /auth/health');
+ } finally {
+ if (gw) await gw.close();
+ await net.close();
+ }
+})().catch((e) => { console.error(e); process.exit(1); });