openvibers/openvibe.tools Commits

Move the gateway's sign-in onto openvibe-sdk/sso (#216)

OP OpenVibers committed 7 days ago · 1de36768f3 · 4 files, +346 −101

The OAuth half of OpenVibe.Tools' sign-in is openvibe-sdk/sso's now: the
authorize URL with PKCE S256, the code / refresh / FedCM grants and the client
check. The session half stays the gateway's own, because its cookies belong to
the whole *.openvibe.tools zone and every satellite reads ov_token from
JavaScript.

- the cookies keep their names, domains, paths and httpOnly flags: ov_token and
  ov_sso_hint stay JS-readable and zone-wide, ov_refresh stays httpOnly on
  /auth. The flow cookies keep theirs and gain ov_oauth_verifier (the PKCE
  verifier, same attributes as the other flow cookies).
- the state is required and compared with the flow cookie in constant time; the
  code is only redeemable with the verifier from the same flow.
- the tokens are verified with openvibe-sdk/auth: signature, issuer, audience
  and expiry. A service, app, module or node token, and any typed token, is
  never a person's session.
- next is sanitised at the login and again at the callback (the gateway's own
  sanitizer, unchanged).
- GET /auth/health: 503 only when the Network refuses the OAuth client.
- the redirect URI does not change (https://openvibe.tools/auth/callback): the
  Network needs no new registration.

No permission check changed (apps/_shared/guard is untouched).

Changes

ddiff --git a/apps/gateway/README.md b/apps/gateway/README.md
iindex 9f417cc..7f24e2e 100644
---- a/apps/gateway/README.md
++++ b/apps/gateway/README.md
@@@ -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
ddiff --git a/apps/gateway/server/auth/routes.js b/apps/gateway/server/auth/routes.js
iindex 0a34fb9..a2d4119 100644
---- a/apps/gateway/server/auth/routes.js
++++ b/apps/gateway/server/auth/routes.js
@@@ -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 console
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
-- 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);
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 15bbd41..ad0ce88 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -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.
ddiff --git a/apps/gateway/test/auth-sso.test.js b/apps/gateway/test/auth-sso.test.js
nnew file mode 100644
iindex 0000000..4c18a37
---- /dev/null
++++ b/apps/gateway/test/auth-sso.test.js
@@@ -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); });