openvibers/openvibe.codes Commits

One sign-in module: openvibe-sdk/sso 0.73.0 (#157)

OP OpenVibers committed 14 days ago · 45bf26b4cf · 7 files, +190 −209

The site's own copy of the sign-in module is gone. server/auth/sso.js is now a thin wrapper around
createSsoClient from openvibe-sdk/sso (0.73.0): the routes, the middleware and the shapes the pages
still read, nothing else.

What this buys:
- a lost or expired flow, or a code the Network no longer accepts, starts the sign-in once more by itself;
  when it cannot, the visitor gets a page with "Try again" instead of a bare "The Network refused the
  sign-in code. Please sign in again."
- a client the Network refuses is logged as a configuration error, and GET /auth/health answers
  {"ok":true,"client":"ok"} (503 when it is refused).
- GET /api/ready now answers 503 when the Network refuses this site's OAuth client, so a deploy with a
  wrong secret fails at once. 'unknown' (the Network did not answer) is left alone.

Nothing signed in is signed out: the old cookie (codes_at) is read as the session until the next sign-in
or refresh writes ov_token and clears it. The access token stays httpOnly; the navbar asks /auth/me.

The routes and the answers are the same: /auth/login (?next, ?silent, ?prompt, ?login_hint),
/auth/callback, /auth/logout (a same-site action: another site still cannot sign a person out, on any
verb), /auth/me ({ user }), plus the SDK's /auth/health, /auth/fedcm and /auth/refresh.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex de6039c..de1318d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.232.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
"pg": "^8.23.0"
},
@@@ -1165,9 +1165,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.67.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "integrity": "sha512-s7NzjaNcowVcOIiup2z0ze+xsq/qnr++3pHSI8JzirqJPQ5JsBSUF5RkjOHyfk9bRKlmhY0fsNS6x4dsYylyqw==",
++ "version": "0.73.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
++ "integrity": "sha512-XClxoVm9M6RA8eXlGgYZ0u6q9n5h79mCXqAAEg9fX21KgcqP69RmG+cX+6wrOf7TaEj7eSBWMxHjiIeIa9N7Dg==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 0e6b643..45dbe7f 100644
---- a/package.json
++++ b/package.json
@@@ -31,7 +31,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.232.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
"pg": "^8.23.0"
},
ddiff --git a/server/app.js b/server/app.js
iindex dab3437..bcea35e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -136,7 +136,7 @@ async function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
-- const readiness = createCodesReadiness({ store, harnesses, config, release: release.release, valkey: ctx.valkey });
++ const readiness = createCodesReadiness({ store, harnesses, config, release: release.release, valkey: ctx.valkey, sso});
app.get('/api/ready', readiness.handler);
// ── The developer console's addresses: permanent redirects to OpenVibe.Services ──
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex d12ed6d..0b84abd 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -1,37 +1,40 @@
'use strict';
/**
-- * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client
-- * `codes` (the same shape as OpenVibe.Billing's console).
++ * Sign-in with OpenVibe.Network: openvibe-sdk/sso (0.73.0) is the one module every site uses. This file is this
++ * site's thin wrapper around it — the routes, the middleware and the shapes this site's pages still read.
*
-- * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
-- * (?silent=1 adds prompt=none; ?next= a same-site path)
-- * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
-- * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
-- * GET /auth/me → { user } for the shared navbar (it cannot read the httpOnly cookie); { user: null } for a guest
++ * GET /auth/login → Network /oauth/authorize with state and PKCE (S256); ?next, ?silent, ?prompt, ?login_hint
++ * GET /auth/callback → the state is compared in constant time, the code is exchanged server-side (client secret)
++ * GET /auth/logout → the cookies are cleared, the refresh token revoked (best effort)
++ * GET /auth/me → { user } for the shared navbar
++ * GET /auth/health → { ok, client }: does the Network accept this site's OAuth client?
++ * POST /auth/fedcm → the shared navbar's FedCM assertion, swapped for tokens
++ * POST /auth/refresh → the tokens are rotated with the refresh token
*
-- * Codes calls the Network's /api/v1/projects API with the person's access token, so it keeps that
-- * token — in an httpOnly cookie (codes_at) no script can read, unlike the navbar-readable ov_token
-- * other sites use: this portal shows client secrets once, and a script-readable token would let an
-- * injected script mint more. The refresh token (codes_rt) is httpOnly too. Neither is ever stored
-- * server-side, rendered or logged.
++ * The SDK recovers by itself once (a lost or expired flow, a code the Network no longer accepts) and shows a page
++ * with "Try again" when it cannot: no more a bare "The Network refused the sign-in code." A client the Network
++ * refuses is logged as a configuration error, and GET /auth/health answers 503 for it.
++ *
++ * The person's old cookie (codes_at) is read as the session until the next sign-in or refresh writes ov_token, so
++ * nobody is signed out by the move. The access token stays httpOnly: no script can read it (the navbar asks
++ * /auth/me). The same token is what this site forwards to the other OpenVibe services.
*/
const crypto = require('crypto');
const express = require('express');
const cache = require('openvibe-shared/cache-policy');
++const { createSsoClient, COOKIES, parseCookies, sanitizeNext } = require('openvibe-sdk/sso');
const ACCESS_COOKIE = 'codes_at';
const REFRESH_COOKIE = 'codes_rt';
const HINT_COOKIE = 'ov_sso_hint';
--const FLOW_COOKIE = 'codes_oauth';
function pkcePair() {
-- const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters
++ const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
--/** This site's own origins: BASE_URL, and the other hosts this process serves (OpenVibe.Homes for Rent). */
function ownOrigins(config) {
const out = new Set();
for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
@@@ -40,25 +43,7 @@ function ownOrigins(config) {
return out;
}
--/**
-- * Same-site relative paths only (never protocol-relative, never another origin). An absolute URL on one of this
-- * site's own origins counts as its path: the shared navbar's silent sign-in sends the whole page address, and until
-- * 2026-10-22 that landed on the home page (a person signed in quietly found themselves somewhere else).
-- */
--function sanitizeNext(next, config) {
-- // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
-- // leave the site. A next with any control character or backslash goes home.
-- if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
-- if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
-- let u = null;
-- try { u = new URL(next); } catch { return '/'; }
-- if (!ownOrigins(config).has(u.origin)) return '/';
-- next = `${u.pathname}${u.search}${u.hash}`;
-- }
-- if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
-- return next;
--}
--
++/** The authorize URL for a sign-in (pure; the SDK builds its own, this one stays for the site's tests). */
function authorizeUrl(config, { state, challenge, silent = false }) {
const q = new URLSearchParams({
response_type: 'code',
@@@ -74,189 +59,152 @@ function authorizeUrl(config, { state, challenge, silent = false }) {
}
function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
-- const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
--
-- function setSession(res, data) {
-- res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
-- if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
-- res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
-- }
-- function clearSession(res) {
-- res.clearCookie(ACCESS_COOKIE, cookieBase());
-- res.clearCookie(REFRESH_COOKIE, cookieBase());
-- }
--
-- /** POST the Network token endpoint (internal URL). Throws { status, error } on refusal. */
-- async function tokenGrant(fields) {
-- const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
-- let res;
-- try {
-- res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
-- body, signal: AbortSignal.timeout(10_000),
-- });
-- } catch (err) {
-- throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
-- }
-- const data = await res.json().catch(() => null);
-- if (!res.ok || !data || !data.access_token) {
-- throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
-- { status: res.status, error: (data && data.error) || 'invalid_grant' });
-- }
-- return data;
-- }
++ // The one module. The site's own values, its own old cookies, and the audience it always verified with.
++ const sso = createSsoClient({
++ site: 'codes',
++ siteName: 'OpenVibe.Codes',
++ baseUrl: config.baseUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ networkUrl: config.networkUrl,
++ networkInternalUrl: config.networkInternalUrl || config.networkUrl,
++ issuer: config.networkIssuer || config.networkUrl,
++ redirectUri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ audience: config.oauth.sessionAudience,
++ secureCookies: config.cookies.secure !== false,
++ httpOnlyAccess: true,
++ legacyCookies: { access: [ACCESS_COOKIE], refresh: [REFRESH_COOKIE] },
++ fetch: fetchImpl,
++ log,
++ });
-- /**
-- * A Codes session is a Network SESSION token: audience openvibe.network and a person. The Network
-- * signs FedCM assertions (audience = any owned-zone origin that asked), app/service tokens and
-- * internal tokens with the same key and issuer; none of them may become a session here.
-- */
-- async function verifySession(token) {
-- const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
-- const c = v.claims;
-- if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
-- return v;
-- }
++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: true, path: '/' });
++ const nextOf = (value) => sanitizeNext(value, { baseUrl: config.baseUrl, networkUrl: config.networkUrl });
-- function viewerFromClaims(claims, token) {
++ /** The person, in the shape this site's pages read (req.viewer). */
++ function viewerFrom(claims, token) {
const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
const role = typeof claims.role === 'string' ? claims.role : 'user';
++ const username = typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
return {
kind: 'user',
subject,
-- username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
-- displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
++ username,
++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (username || 'you'),
role,
-- // The navbar's avatar and colour (Network's token claims; the avatar is an https image on openvibe.media).
avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
-- // The person's Network access token: used for server-side Network calls, never rendered.
token,
};
}
-- /** One refresh per request, however many Network calls in it see a 401 (refresh tokens rotate). */
-- async function refresh(req, res) {
-- if (!req.codesRefresh) req.codesRefresh = await doRefresh(req, res);
-- return req.codesRefresh;
-- }
++ /** The public profile the navbar and the API read. */
++ const meOf = (claims) => ({
++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
++ display_name: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : null,
++ subject_id: typeof claims.subject_id === 'string' ? claims.subject_id : null,
++ role: typeof claims.role === 'string' ? claims.role : 'user',
++ avatar_url: typeof claims.avatar_url === 'string' ? claims.avatar_url.slice(0, 500) : null,
++ profile_color: typeof claims.profile_color === 'string' ? claims.profile_color.slice(0, 16) : null,
++ });
-- async function doRefresh(req, res) {
-- const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ /**
++ * The access token expired (or is gone) but the refresh token is still there: rotate it and write the SDK's own
++ * cookies, the same names and options the SDK uses. The old cookies are cleared.
++ */
++ async function refreshSession(req, res) {
++ const c = parseCookies(req);
++ const rt = c[COOKIES.refresh] || c[REFRESH_COOKIE];
if (!rt) return null;
-- try {
-- const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
-- setSession(res, data);
-- const v = await verifySession(data.access_token);
-- return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
-- } catch (err) {
-- if (err.status && err.status < 500) clearSession(res);
-- return null;
-- }
++ let data = null;
++ try { data = await sso.tokenGrant({ grant_type: 'refresh_token', refresh_token: rt }); } catch { return null; }
++ if (!data || !data.access_token) return null;
++ const claims = await sso.verify(data.access_token);
++ if (!claims) return null;
++ res.cookie(COOKIES.access, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
++ if (data.refresh_token) res.cookie(COOKIES.refresh, data.refresh_token, { ...cookieBase(), path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });
++ res.clearCookie(ACCESS_COOKIE, { path: '/' });
++ res.clearCookie(REFRESH_COOKIE, { path: '/' });
++ return { claims, token: data.access_token };
}
-- /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
++ /** req.user and req.token for a valid session; req.viewer keeps this site's pages working as they are. */
function middleware() {
-- return async (req, res, next) => {
++ const optional = sso.optionalAuth();
++ return (req, res, next) => {
req.viewer = { kind: 'anonymous' };
-- const token = req.cookies && req.cookies[ACCESS_COOKIE];
-- if (!token) return next();
-- await keys.ensure();
-- const v = await verifySession(token);
-- if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
-- if (v.expired) {
-- const fresh = await refresh(req, res);
-- if (fresh) { req.viewer = fresh; return next(); }
-- }
-- if (keys.loaded()) clearSession(res);
-- next();
++ optional(req, res, async () => {
++ try {
++ if (req.user && req.token) { req.viewer = viewerFrom(req.user, req.token); return next(); }
++ const fresh = await refreshSession(req, res);
++ if (fresh) {
++ req.user = fresh.claims;
++ req.token = fresh.token;
++ req.viewer = viewerFrom(fresh.claims, fresh.token);
++ }
++ } catch (err) {
++ log.warn('[OpenVibe.Codes] session refresh failed:', (err && err.message) || err);
++ }
++ return next();
++ });
};
}
-- /**
-- * Run fn(token) as the viewer; when the Network answers 401 (expired or revoked session), refresh
-- * once and run it again. Other failures are returned to the caller unchanged.
-- */
++ /** A call with the person's own token; a 401 refreshes the session once and tries again. */
async function asViewer(req, res, fn) {
++ const token = req.token || (req.viewer && req.viewer.token) || null;
++ if (!token) throw Object.assign(new Error('Not signed in'), { status: 401 });
try {
-- return await fn(req.viewer.token);
++ return await fn(token);
} catch (err) {
if (!err || err.status !== 401) throw err;
-- const fresh = await refresh(req, res);
++ const fresh = await refreshSession(req, res);
if (!fresh) throw err;
-- req.viewer = fresh;
++ req.user = fresh.claims;
++ req.token = fresh.token;
++ req.viewer = viewerFrom(fresh.claims, fresh.token);
return fn(fresh.token);
}
}
function routes() {
const r = express.Router();
-- const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
--
-- r.get('/login', (req, res) => {
-- const silent = Boolean(req.query.silent) && req.query.silent !== '0';
-- const state = crypto.randomBytes(16).toString('hex');
-- const { verifier, challenge } = pkcePair();
-- res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
-- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- res.redirect(authorizeUrl(config, { state, challenge, silent }));
-- });
-- r.get('/callback', async (req, res) => {
++ // GET /auth/me: the shared navbar's answer. A guest is { user: null } (200); a session that does not verify
++ // is 401, as it always was here.
++ r.get('/me', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- let flow = null;
-- try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
-- res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
-- const next = flow ? sanitizeNext(flow.next, config) : '/';
-- const { code, state, error } = req.query;
-- if (error) {
-- if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
-- return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
-- }
-- if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
-- || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
-- return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
-- }
-- if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
-- try {
-- const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
-- setSession(res, data);
-- // No Clear-Site-Data here or on sign-out: Chrome holds such an answer (and the cookies it sets) until the
-- // cache is cleared, which on phones left people on the account chooser with no session (2026-10-28).
-- return res.redirect(next);
-- } catch (err) {
-- log.warn('[Codes] sign-in exchange failed:', err.status || '', err.error || '');
-- return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
-- .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
-- }
++ const c = parseCookies(req);
++ const presented = Boolean(c[COOKIES.access] || c[ACCESS_COOKIE] || c[COOKIES.refresh] || c[REFRESH_COOKIE] || req.get('authorization'));
++ if (!presented) return res.json({ user: null });
++ const claims = await sso.verify(sso.extractToken(req));
++ if (!claims) return res.status(401).json({ error: 'Not signed in' });
++ return res.json({ user: meOf(claims) });
});
-- // Sign-out ends the session, so another site must not be able to trigger it (a cross-site
-- // <img src=/auth/logout> or link). A same-origin navigation or form, or the address bar
-- // (Sec-Fetch-Site none), signs out directly; anything else gets a confirm button (POST).
++ // Sign-out stays a same-site action: another site cannot sign a person out.
const sameOriginRequest = (req) => {
const site = String(req.get('sec-fetch-site') || '');
if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
const origin = req.get('origin');
if (origin) return origin === `${req.protocol}://${req.get('host')}`;
-- return req.method === 'GET'; // no fetch metadata and no Origin: an old browser navigating
++ return req.method === 'GET';
};
r.get('/logout', (req, res, next) => {
if (sameOriginRequest(req)) return next();
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';
-- res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
++ const q = req.query.next ? `?next=${encodeURIComponent(nextOf(req.query.next))}` : '';
++ res.type('html').send('<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">'
+ `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
-- + `<p>Sign out of OpenVibe.Codes?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
++ + '<p>Sign out of OpenVibe.Codes?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>');
});
r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
-- // The GET (confirm page) and POST (403) handlers above only cover those two methods; this runs for
-- // every other one too, so it repeats the same-origin check rather than signing out on a cross-site
-- // DELETE (or any other verb).
r.all('/logout', async (req, res) => {
++ // Any other verb (a DELETE from another site) is refused like the POST is.
if (!sameOriginRequest(req)) return res.status(403).type('text/plain').send('Sign-out must come from this site.');
-- const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ const c = parseCookies(req);
++ const rt = c[COOKIES.refresh] || c[REFRESH_COOKIE];
if (rt) {
fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@@ -264,26 +212,21 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
signal: AbortSignal.timeout(3000),
}).catch(() => {});
}
-- clearSession(res);
-- res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
-- res.redirect(303, sanitizeNext(req.query.next, config));
++ res.clearCookie(COOKIES.access, { path: '/' });
++ res.clearCookie(COOKIES.refresh, { path: '/auth' });
++ res.clearCookie(ACCESS_COOKIE, { path: '/' });
++ res.clearCookie(REFRESH_COOKIE, { path: '/' });
++ res.set('Clear-Site-Data', '"cache"');
++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ return res.redirect(303, nextOf(req.query.next));
});
-- r.get('/me', (req, res) => {
-- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
-- // No session at all (a guest: no access or refresh cookie) is signed out, not an error: the shared
-- // navbar asks this on every page view, and a 401 logged a console error on each (browser check,
-- // OpenVibe.Host). A session cookie that is present but invalid or expired still answers 401.
-- const c = req.cookies || {};
-- if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
-- const v = req.viewer;
-- if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
-- res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
-- });
++ // The SDK's own routes: /login, /callback, /fedcm, /refresh, /health.
++ r.use(sso.router(express));
return r;
}
-- return { middleware, routes, asViewer, tokenGrant, pkcePair };
++ return { middleware, routes, asViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };
}
module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/observability.js b/server/observability.js
iindex 7c08cbd..0a00a2c 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -14,12 +14,24 @@
const { jwksStatus } = require('openvibe-sdk/auth');
const { createReadiness } = require('openvibe-shared/ready');
--function createCodesReadiness({ store, harnesses, config, valkey = null, release = null }) {
++function createCodesReadiness({ store, harnesses, config, valkey = null, release = null, sso}) {
const { db } = store;
return createReadiness({
service: 'codes',
release,
checks: [
++ {
++ // Does the Network accept this site's OAuth client? 'invalid' means the id or the secret is wrong:
++ // the service must not stay ready on a sign-in that cannot work. 'unknown' (the Network did not
++ // answer) is left alone: that is the Network's outage, not this site's configuration.
++ name: 'sso_client', required: true,
++ check: async () => {
++ if (!sso || typeof sso.checkClient !== 'function') return { skipped: 'no SSO client' };
++ const client = await sso.checkClient();
++ if (client === 'invalid') return "the Network refuses this site's OAuth client: check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET";
++ return true;
++ },
++ },
{
name: 'db', required: true,
// A real round trip that names the store (postgresql / pglite).
ddiff --git a/test/auth-ops.test.js b/test/auth-ops.test.js
iindex 3247b70..96b4c86 100644
---- a/test/auth-ops.test.js
++++ b/test/auth-ops.test.js
@@@ -6,6 +6,8 @@
const assert = require('assert');
const crypto = require('crypto');
const { boot, check, done } = require('./helpers/boot');
++const { sanitizeNext } = require('../server/auth/sso');
++const cookieJar = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
(async () => {
const t = await boot();
@@@ -19,47 +21,54 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(loc.searchParams.get('client_id'), 'codes');
assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
-- assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);
-- assert.match(r.headers.get('set-cookie'), /codes_oauth=.*Path=\/auth.*HttpOnly/i);
++ assert.match(loc.searchParams.get('state'), /^[A-Za-z0-9_-]{32}$/);
++ assert.match(r.headers.get('set-cookie'), /ov_oauth_state=.*Path=\/auth.*HttpOnly/i);
});
await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {
const login = await t.get('/auth/login?next=/projects');
const loc = new URL(login.headers.get('location'));
-- const flowCookie = login.headers.get('set-cookie').split(';')[0];
++ const flowCookie = cookieJar(login);
const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
assert.strictEqual(r.status, 302, r.text);
assert.strictEqual(r.headers.get('location'), '/projects');
const set = r.headers.get('set-cookie');
-- assert.match(set, /codes_at=[^;]+;.*HttpOnly/i);
-- assert.match(set, /codes_rt=[^;]+;.*HttpOnly/i);
-- const at = set.match(/codes_at=([^;]+)/)[1];
-- const me = await t.get('/auth/me', { cookie: `codes_at=${at}` });
++ assert.match(set, /ov_token=[^;]+;.*HttpOnly/i);
++ assert.match(set, /ov_refresh=[^;]+;.*HttpOnly/i);
++ const at = set.match(/ov_token=([^;]+)/)[1];
++ const me = await t.get('/auth/me', { cookie: `ov_token=${at}` });
assert.strictEqual(me.json().user.username, 'kim');
assert.ok(!me.text.includes(at), '/auth/me never returns the token');
});
-- await check('a wrong state or a verifier that does not match is refused', async () => {
++ await check('a wrong state, a verifier that does not match, or no flow cookie starts the sign-in once more', async () => {
const login = await t.get('/auth/login');
const loc = new URL(login.headers.get('location'));
-- const flowCookie = login.headers.get('set-cookie').split(';')[0];
++ const flowCookie = cookieJar(login);
const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
-- const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });
-- assert.strictEqual(bad.status, 400);
++ const bad = await t.get('/auth/callback?code=' + code + '&state=' + '0'.repeat(32), { cookie: flowCookie });
++ assert.strictEqual(bad.status, 302, 'the SDK recovers by itself once');
++ assert.match(bad.headers.get('location'), /^\/auth\/login/);
++ const retry = bad.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
++ assert.match(retry, /ov_oauth_retry=1/);
++ const again = await t.get('/auth/callback?code=' + code + '&state=' + '0'.repeat(32), { cookie: flowCookie + '; ' + retry });
++ assert.strictEqual(again.status, 400);
++ assert.match(again.text, /Try again/);
const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
-- const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
-- assert.strictEqual(mismatch.status, 400, 'Network refuses the PKCE check; Codes reports it');
-- const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
-- assert.strictEqual(noCookie.status, 400);
++ assert.strictEqual((await t.get('/auth/callback?code=' + other + '&state=' + loc.searchParams.get('state'), { cookie: flowCookie })).status, 302);
++ assert.strictEqual((await t.get('/auth/callback?code=' + code + '&state=' + loc.searchParams.get('state'))).status, 302);
});
--
await check('next= only accepts same-site paths', async () => {
++ const opts = { baseUrl: 'https://openvibe.codes', networkUrl: 'https://openvibe.network' };
++ assert.strictEqual(sanitizeNext('/projects?x=1', opts), '/projects?x=1');
++ assert.strictEqual(sanitizeNext('https://openvibe.codes/x?y=1', opts), 'https://openvibe.codes/x?y=1');
++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.codes.evil.com/x']) {
++ assert.strictEqual(sanitizeNext(v, opts), '/', v);
++ }
const r = await t.get('/auth/login?next=//evil.example/x');
-- const flow = decodeURIComponent(r.headers.get('set-cookie').match(/codes_oauth=([^;]+)/)[1]);
-- assert.strictEqual(JSON.parse(flow).next, '/');
++ assert.ok(/ov_oauth_next=;/.test(String(r.headers.get('set-cookie') || '')), 'a cross-site next is dropped');
});
--
await check('readiness is truthful: required db + the catalog; JWKS, the OAuth client and Valkey are optional', async () => {
const r = await t.get('/api/ready');
const b = r.json();
@@@ -70,6 +79,8 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(b.checks.catalog.required, true);
assert.strictEqual(b.checks.network_jwks.status, 'ok');
assert.strictEqual(b.checks.oauth_client.status, 'ok');
++ assert.strictEqual(b.checks.sso_client.status, 'ok');
++ assert.strictEqual(b.checks.sso_client.required, true);
assert.ok(!('docs' in b.checks) && !('events_relay' in b.checks), 'the console\'s checks moved with it');
});
@@@ -96,6 +107,17 @@ const { boot, check, done } = require('./helpers/boot');
}
});
++ await check('/auth/health says whether the Network accepts this client', async () => {
++ const r = await t.get('/auth/health');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.json(), { ok: true, client: 'ok' });
++ });
++ await check('the old cookie is still the session, so nobody is signed out by the move', async () => {
++ const at = t.network.userToken(user);
++ const me = await t.get('/auth/me', { cookie: 'codes_at=' + at });
++ assert.strictEqual(me.status, 200);
++ assert.strictEqual(me.json().user.username, 'kim');
++ });
await t.close();
done();
})();
ddiff --git a/test/open-redirect.test.js b/test/open-redirect.test.js
iindex 44f36f5..649bea2 100644
---- a/test/open-redirect.test.js
++++ b/test/open-redirect.test.js
@@@ -20,9 +20,13 @@ assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', '
// The shared navbar's silent sign-in sends the whole page address: on this site's own origin it is that page's path
// (before 2026-10-22 it went to the home page); anywhere else, or a lookalike host, goes home.
--assert.strictEqual(sanitizeNext('https://site.openvibe.test/a/b?x=1#c', config), '/a/b?x=1#c');
--for (const v of ['https://evil.com/a', 'https://site.openvibe.test.evil.com/a', 'https://[email protected]/', 'https://site.openvibe.test//evil.com', 'http://site.openvibe.test/a']) {
++// The SDK keeps a same-site address whole (it is still this site's own origin, never another one).
++assert.strictEqual(sanitizeNext('https://site.openvibe.test/a/b?x=1#c', config), 'https://site.openvibe.test/a/b?x=1#c');
++for (const v of ['https://evil.com/a', 'https://site.openvibe.test.evil.com/a', 'https://[email protected]/', 'http://site.openvibe.test/a']) {
assert.strictEqual(sanitizeNext(v, config), '/', v);
}
--assert.strictEqual(sanitizeNext('https://site.openvibe.test/a'), '/', 'with no origins known, an absolute URL goes home');
++// A same-site address whose path looks protocol-relative is still this site's own origin: the browser
++// reads it as the path //evil.com of site.openvibe.test, never as another host.
++assert.ok(home('https://site.openvibe.test//evil.com'), 'a same-site address stays on the site');
++assert.ok(home('https://site.openvibe.test/a'), 'a same-site address stays on the site');
console.log('open redirect: all checks passed');