openvibers/openvibe.exchange Commits
One sign-in module: openvibe-sdk/sso 0.73.0 (#11)
Changes
@@@ -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.235.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.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.45.0","pg": "^8.23.0"},@@@ -1166,9 +1166,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.70.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",++ "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"@@@ -24,7 +24,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.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.45.0","pg": "^8.23.0"},@@@ -109,7 +109,7 @@ async function createApp(opts = {}) {// ── Machine endpoints ───────────────────────────────────app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-exchange', name: 'OpenVibe.Exchange', version: VERSION }));release.mount(app, { registry: metrics.registry });-- const readiness = createServiceReadiness({ s, config, release: release.release, valkey });++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey, sso});app.get('/api/ready', readiness.handler);// ── Who is asking (verified offline; refreshed when expired) ──@@@ -1,26 +1,33 @@'use strict';/**-- * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `exchange`.++ * 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; { 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*-- * This is Meme's SSO module with the client id and cookie names changed. The person's Network access token lives-- * in an httpOnly cookie (exchange_at) no script can read; the refresh token (exchange_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 (exchange_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 = 'exchange_at';const REFRESH_COOKIE = 'exchange_rt';const HINT_COOKIE = 'ov_sso_hint';--const FLOW_COOKIE = 'exchange_oauth';function pkcePair() {const verifier = crypto.randomBytes(32).toString('base64url');@@@ -36,19 +43,7 @@ function ownOrigins(config) {return out;}--/** Same-site relative paths only (never protocol-relative, never another origin). */--function sanitizeNext(next, config) {-- 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',@@@ -64,57 +59,39 @@ 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());-- }---- 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: 'exchange',++ siteName: 'OpenVibe.Exchange',++ baseUrl: config.baseUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ networkUrl: config.networkPublicUrl || config.networkUrl,++ networkInternalUrl: config.networkInternalUrl || config.networkUrl,++ issuer: config.networkIssuer || config.networkPublicUrl || 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,++ });-- /**-- * An Exchange session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,-- * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session.-- */-- 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,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,@@@ -122,96 +99,91 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat};}-- async function refresh(req, res) {-- if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);-- return req.sessionRefresh;-- }++ /** 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.Exchange] session refresh failed:', (err && err.message) || err);++ }++ return next();++ });};}++ /** 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());++ // 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 }));-- res.redirect(authorizeUrl(config, { state, challenge, silent }));-- });---- r.get('/callback', 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);-- res.set('Clear-Site-Data', '"cache"');-- return res.redirect(next);-- } catch (err) {-- log.warn('[OpenVibe.Exchange] 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 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');@@@ -222,14 +194,17 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Datr.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.Exchange?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ + '<p>Sign out of OpenVibe.Exchange?</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.')));r.all('/logout', async (req, res) => {-- const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ // 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 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' },@@@ -237,24 +212,21 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Datsignal: AbortSignal.timeout(3000),}).catch(() => {});}-- clearSession(res);++ 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, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });-- res.redirect(303, sanitizeNext(req.query.next, config));++ 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 }));-- 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 };@@@ -8,11 +8,23 @@const { jwksStatus } = require('openvibe-sdk/auth');const { createReadiness } = require('openvibe-shared/ready');--function createServiceReadiness({ s, config, valkey = null, release = null }) {++function createServiceReadiness({ s, config, valkey = null, release = null, sso}) {return createReadiness({service: 'exchange',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,check: async () => {@@@ -7,6 +7,7 @@const assert = require('assert');const crypto = require('crypto');const { boot, check, done } = require('./helpers/boot');++const cookieJar = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');const { sanitizeNext } = require('../server/auth/sso');(async () => {@@@ -21,49 +22,55 @@ const { sanitizeNext } = require('../server/auth/sso');assert.strictEqual(loc.searchParams.get('client_id'), 'exchange');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'), /exchange_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=/listings');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'), '/listings');const set = r.headers.get('set-cookie');-- assert.match(set, /exchange_at=[^;]+;.*HttpOnly/i);-- assert.match(set, /exchange_rt=[^;]+;.*HttpOnly/i);-- assert.match(set, /exchange_oauth=;/);-- const at = set.match(/exchange_at=([^;]+)/)[1];-- const me = await t.get('/auth/me', { cookie: `exchange_at=${at}` });++ assert.match(set, /ov_token=[^;]+;.*HttpOnly/i);++ assert.match(set, /ov_refresh=[^;]+;.*HttpOnly/i);++ assert.match(set, /ov_oauth_state=;/);++ 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, a verifier that does not match, or no flow cookie 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'));-- assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie })).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'));-- assert.strictEqual((await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie })).status, 400);-- assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).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.exchange', networkUrl: 'https://openvibe.network' };++ assert.strictEqual(sanitizeNext('/listings?x=1', opts), '/listings?x=1');++ assert.strictEqual(sanitizeNext('https://openvibe.exchange/x?y=1', opts), 'https://openvibe.exchange/x?y=1');++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.exchange.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(/exchange_oauth=([^;]+)/)[1]);-- assert.strictEqual(JSON.parse(flow).next, '/');-- const config = { baseUrl: 'https://openvibe.exchange' };-- assert.strictEqual(sanitizeNext('/listings?x=1', config), '/listings?x=1');-- assert.strictEqual(sanitizeNext('https://openvibe.exchange/listings/x?y=1', config), '/listings/x?y=1');-- for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.exchange.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);++ assert.ok(/ov_oauth_next=;/.test(String(r.headers.get('set-cookie') || '')), 'a cross-site next is dropped');});--await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {const r = await t.get('/api/ready');const b = r.json();@@@ -73,6 +80,8 @@ const { sanitizeNext } = require('../server/auth/sso');assert.strictEqual(b.checks.network_jwks.status, 'ok');assert.strictEqual(b.checks.network_jwks.required, false);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.strictEqual(b.checks.oauth_client.required, false);assert.ok('valkey' in b.checks);});@@@ -112,11 +121,11 @@ const { sanitizeNext } = require('../server/auth/sso');await check('a FedCM assertion and an app token are not sessions', async () => {const now = Math.floor(Date.now() / 1000);const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });-- assert.strictEqual((await t.get('/auth/me', { cookie: `exchange_at=${fedcm}` })).status, 401);-- const page = await t.get('/', { cookie: `exchange_at=${fedcm}` });++ assert.strictEqual((await t.get('/auth/me', { cookie: `ov_token=${fedcm}` })).status, 401);++ const page = await t.get('/', { cookie: `ov_token=${fedcm}` });assert.ok(!page.text.includes('Signed in as <strong>kim'));const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: user.subject, iat: now, exp: now + 300 });-- assert.strictEqual((await t.get('/auth/me', { cookie: `exchange_at=${app}` })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { cookie: `ov_token=${app}` })).status, 401);});await check('/auth/me: a guest is signed out (200 { user: null }); a failing session cookie is 401', async () => {@@@ -124,7 +133,7 @@ const { sanitizeNext } = require('../server/auth/sso');assert.strictEqual(guest.status, 200);assert.deepStrictEqual(JSON.parse(guest.text), { user: null });assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');-- assert.strictEqual((await t.get('/auth/me', { cookie: 'exchange_at=garbage' })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { cookie: 'ov_token=garbage' })).status, 401);assert.strictEqual((await t.get('/auth/me', { cookie: 'exchange_rt=stale' })).status, 401);assert.strictEqual((await t.get('/auth/me', { as: user })).status, 200);});@@@ -142,6 +151,17 @@ const { sanitizeNext } = require('../server/auth/sso');assert.match(String(same.headers.get('set-cookie') || ''), /exchange_at=;/);});++ 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: 'exchange_at=' + at });++ assert.strictEqual(me.status, 200);++ assert.strictEqual(me.json().user.username, 'kim');++ });await t.close();done();})();
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.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.45.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.45.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1166,9 +1166,9 @@
@@ -1166,9 +1166,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.70.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
+ "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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.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.45.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.45.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -109,7 +109,7 @@ async function createApp(opts = {}) {
@@ -109,7 +109,7 @@ async function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-exchange', name: 'OpenVibe.Exchange', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-exchange', name: 'OpenVibe.Exchange', version: VERSION }));
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
- const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
+ const readiness = createServiceReadiness({ s, config, release: release.release, valkey, sso});
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
// ── Who is asking (verified offline; refreshed when expired) ──
// ── Who is asking (verified offline; refreshed when expired) ──
@@ -1,26 +1,33 @@
@@ -1,26 +1,33 @@
'use strict';
'use strict';
/**
/**
- * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `exchange`.
+ * 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; { 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
*
*
- * This is Meme's SSO module with the client id and cookie names changed. The person's Network access token lives
- * in an httpOnly cookie (exchange_at) no script can read; the refresh token (exchange_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 (exchange_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 crypto = require('crypto');
const express = require('express');
const express = require('express');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const { createSsoClient, COOKIES, parseCookies, sanitizeNext } = require('openvibe-sdk/sso');
const ACCESS_COOKIE = 'exchange_at';
const ACCESS_COOKIE = 'exchange_at';
const REFRESH_COOKIE = 'exchange_rt';
const REFRESH_COOKIE = 'exchange_rt';
const HINT_COOKIE = 'ov_sso_hint';
const HINT_COOKIE = 'ov_sso_hint';
-const FLOW_COOKIE = 'exchange_oauth';
function pkcePair() {
function pkcePair() {
const verifier = crypto.randomBytes(32).toString('base64url');
const verifier = crypto.randomBytes(32).toString('base64url');
@@ -36,19 +43,7 @@ function ownOrigins(config) {
@@ -36,19 +43,7 @@ function ownOrigins(config) {
return out;
return out;
}
}
-/** Same-site relative paths only (never protocol-relative, never another origin). */
-function sanitizeNext(next, config) {
- 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 }) {
function authorizeUrl(config, { state, challenge, silent = false }) {
const q = new URLSearchParams({
const q = new URLSearchParams({
response_type: 'code',
response_type: 'code',
@@ -64,57 +59,39 @@ function authorizeUrl(config, { state, challenge, silent = false }) {
@@ -64,57 +59,39 @@ function authorizeUrl(config, { state, challenge, silent = false }) {
}
}
function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
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());
- }
-
- 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: 'exchange',
+ siteName: 'OpenVibe.Exchange',
+ baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ networkUrl: config.networkPublicUrl || config.networkUrl,
+ networkInternalUrl: config.networkInternalUrl || config.networkUrl,
+ issuer: config.networkIssuer || config.networkPublicUrl || 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,
+ });
- /**
- * An Exchange session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,
- * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session.
- */
- 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 subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
const role = typeof claims.role === 'string' ? claims.role : 'user';
const role = typeof claims.role === 'string' ? claims.role : 'user';
+ const username = typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
return {
return {
kind: 'user',
kind: 'user',
subject,
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,
role,
avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
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,
profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
@@ -122,96 +99,91 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -122,96 +99,91 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
};
};
}
}
- async function refresh(req, res) {
- if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
- return req.sessionRefresh;
- }
+ /** 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;
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() {
function middleware() {
- return async (req, res, next) => {
+ const optional = sso.optionalAuth();
+ return (req, res, next) => {
req.viewer = { kind: 'anonymous' };
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.Exchange] session refresh failed:', (err && err.message) || err);
+ }
+ return next();
+ });
};
};
}
}
+ /** A call with the person's own token; a 401 refreshes the session once and tries again. */
async function asViewer(req, res, fn) {
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 {
try {
- return await fn(req.viewer.token);
+ return await fn(token);
} catch (err) {
} catch (err) {
if (!err || err.status !== 401) throw err;
if (!err || err.status !== 401) throw err;
- const fresh = await refresh(req, res);
+ const fresh = await refreshSession(req, res);
if (!fresh) throw err;
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);
return fn(fresh.token);
}
}
}
}
function routes() {
function routes() {
const r = express.Router();
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());
+ // 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 }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
- res.redirect(authorizeUrl(config, { state, challenge, silent }));
- });
-
- r.get('/callback', 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);
- res.set('Clear-Site-Data', '"cache"');
- return res.redirect(next);
- } catch (err) {
- log.warn('[OpenVibe.Exchange] 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 stays a same-site action: another site cannot sign a person out.
const sameOriginRequest = (req) => {
const sameOriginRequest = (req) => {
const site = String(req.get('sec-fetch-site') || '');
const site = String(req.get('sec-fetch-site') || '');
if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
@@ -222,14 +194,17 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -222,14 +194,17 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
r.get('/logout', (req, res, next) => {
r.get('/logout', (req, res, next) => {
if (sameOriginRequest(req)) return next();
if (sameOriginRequest(req)) return next();
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
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">`
+ `<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.Exchange?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ + '<p>Sign out of OpenVibe.Exchange?</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.')));
r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
r.all('/logout', async (req, res) => {
r.all('/logout', async (req, res) => {
- const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ // 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 c = parseCookies(req);
+ const rt = c[COOKIES.refresh] || c[REFRESH_COOKIE];
if (rt) {
if (rt) {
fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -237,24 +212,21 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -237,24 +212,21 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
signal: AbortSignal.timeout(3000),
signal: AbortSignal.timeout(3000),
}).catch(() => {});
}).catch(() => {});
}
}
- clearSession(res);
+ 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.set('Clear-Site-Data', '"cache"');
- 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.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 }));
- 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 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 };
module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
@@ -8,11 +8,23 @@
@@ -8,11 +8,23 @@
const { jwksStatus } = require('openvibe-sdk/auth');
const { jwksStatus } = require('openvibe-sdk/auth');
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
-function createServiceReadiness({ s, config, valkey = null, release = null }) {
+function createServiceReadiness({ s, config, valkey = null, release = null, sso}) {
return createReadiness({
return createReadiness({
service: 'exchange',
service: 'exchange',
release,
release,
checks: [
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,
name: 'db', required: true,
check: async () => {
check: async () => {
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
+const cookieJar = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
const { sanitizeNext } = require('../server/auth/sso');
const { sanitizeNext } = require('../server/auth/sso');
(async () => {
(async () => {
@@ -21,49 +22,55 @@ const { sanitizeNext } = require('../server/auth/sso');
@@ -21,49 +22,55 @@ const { sanitizeNext } = require('../server/auth/sso');
assert.strictEqual(loc.searchParams.get('client_id'), 'exchange');
assert.strictEqual(loc.searchParams.get('client_id'), 'exchange');
assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
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('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
- assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);
- assert.match(r.headers.get('set-cookie'), /exchange_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 () => {
await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {
const login = await t.get('/auth/login?next=/listings');
const login = await t.get('/auth/login?next=/listings');
const loc = new URL(login.headers.get('location'));
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 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 });
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.status, 302, r.text);
assert.strictEqual(r.headers.get('location'), '/listings');
assert.strictEqual(r.headers.get('location'), '/listings');
const set = r.headers.get('set-cookie');
const set = r.headers.get('set-cookie');
- assert.match(set, /exchange_at=[^;]+;.*HttpOnly/i);
- assert.match(set, /exchange_rt=[^;]+;.*HttpOnly/i);
- assert.match(set, /exchange_oauth=;/);
- const at = set.match(/exchange_at=([^;]+)/)[1];
- const me = await t.get('/auth/me', { cookie: `exchange_at=${at}` });
+ assert.match(set, /ov_token=[^;]+;.*HttpOnly/i);
+ assert.match(set, /ov_refresh=[^;]+;.*HttpOnly/i);
+ assert.match(set, /ov_oauth_state=;/);
+ 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.strictEqual(me.json().user.username, 'kim');
assert.ok(!me.text.includes(at), '/auth/me never returns the token');
assert.ok(!me.text.includes(at), '/auth/me never returns the token');
});
});
- await check('a wrong state, a verifier that does not match, or no flow cookie 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 login = await t.get('/auth/login');
const loc = new URL(login.headers.get('location'));
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 code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
- assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie })).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 other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
- assert.strictEqual((await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie })).status, 400);
- assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).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 () => {
await check('next= only accepts same-site paths', async () => {
+ const opts = { baseUrl: 'https://openvibe.exchange', networkUrl: 'https://openvibe.network' };
+ assert.strictEqual(sanitizeNext('/listings?x=1', opts), '/listings?x=1');
+ assert.strictEqual(sanitizeNext('https://openvibe.exchange/x?y=1', opts), 'https://openvibe.exchange/x?y=1');
+ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.exchange.evil.com/x']) {
+ assert.strictEqual(sanitizeNext(v, opts), '/', v);
+ }
const r = await t.get('/auth/login?next=//evil.example/x');
const r = await t.get('/auth/login?next=//evil.example/x');
- const flow = decodeURIComponent(r.headers.get('set-cookie').match(/exchange_oauth=([^;]+)/)[1]);
- assert.strictEqual(JSON.parse(flow).next, '/');
- const config = { baseUrl: 'https://openvibe.exchange' };
- assert.strictEqual(sanitizeNext('/listings?x=1', config), '/listings?x=1');
- assert.strictEqual(sanitizeNext('https://openvibe.exchange/listings/x?y=1', config), '/listings/x?y=1');
- for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.exchange.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);
+ assert.ok(/ov_oauth_next=;/.test(String(r.headers.get('set-cookie') || '')), 'a cross-site next is dropped');
});
});
-
await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {
await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {
const r = await t.get('/api/ready');
const r = await t.get('/api/ready');
const b = r.json();
const b = r.json();
@@ -73,6 +80,8 @@ const { sanitizeNext } = require('../server/auth/sso');
@@ -73,6 +80,8 @@ const { sanitizeNext } = require('../server/auth/sso');
assert.strictEqual(b.checks.network_jwks.status, 'ok');
assert.strictEqual(b.checks.network_jwks.status, 'ok');
assert.strictEqual(b.checks.network_jwks.required, false);
assert.strictEqual(b.checks.network_jwks.required, false);
assert.strictEqual(b.checks.oauth_client.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.strictEqual(b.checks.oauth_client.required, false);
assert.strictEqual(b.checks.oauth_client.required, false);
assert.ok('valkey' in b.checks);
assert.ok('valkey' in b.checks);
});
});
@@ -112,11 +121,11 @@ const { sanitizeNext } = require('../server/auth/sso');
@@ -112,11 +121,11 @@ const { sanitizeNext } = require('../server/auth/sso');
await check('a FedCM assertion and an app token are not sessions', async () => {
await check('a FedCM assertion and an app token are not sessions', async () => {
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });
const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });
- assert.strictEqual((await t.get('/auth/me', { cookie: `exchange_at=${fedcm}` })).status, 401);
- const page = await t.get('/', { cookie: `exchange_at=${fedcm}` });
+ assert.strictEqual((await t.get('/auth/me', { cookie: `ov_token=${fedcm}` })).status, 401);
+ const page = await t.get('/', { cookie: `ov_token=${fedcm}` });
assert.ok(!page.text.includes('Signed in as <strong>kim'));
assert.ok(!page.text.includes('Signed in as <strong>kim'));
const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: user.subject, iat: now, exp: now + 300 });
const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: user.subject, iat: now, exp: now + 300 });
- assert.strictEqual((await t.get('/auth/me', { cookie: `exchange_at=${app}` })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { cookie: `ov_token=${app}` })).status, 401);
});
});
await check('/auth/me: a guest is signed out (200 { user: null }); a failing session cookie is 401', async () => {
await check('/auth/me: a guest is signed out (200 { user: null }); a failing session cookie is 401', async () => {
@@ -124,7 +133,7 @@ const { sanitizeNext } = require('../server/auth/sso');
@@ -124,7 +133,7 @@ const { sanitizeNext } = require('../server/auth/sso');
assert.strictEqual(guest.status, 200);
assert.strictEqual(guest.status, 200);
assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');
- assert.strictEqual((await t.get('/auth/me', { cookie: 'exchange_at=garbage' })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { cookie: 'ov_token=garbage' })).status, 401);
assert.strictEqual((await t.get('/auth/me', { cookie: 'exchange_rt=stale' })).status, 401);
assert.strictEqual((await t.get('/auth/me', { cookie: 'exchange_rt=stale' })).status, 401);
assert.strictEqual((await t.get('/auth/me', { as: user })).status, 200);
assert.strictEqual((await t.get('/auth/me', { as: user })).status, 200);
});
});
@@ -142,6 +151,17 @@ const { sanitizeNext } = require('../server/auth/sso');
@@ -142,6 +151,17 @@ const { sanitizeNext } = require('../server/auth/sso');
assert.match(String(same.headers.get('set-cookie') || ''), /exchange_at=;/);
assert.match(String(same.headers.get('set-cookie') || ''), /exchange_at=;/);
});
});
+ 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: 'exchange_at=' + at });
+ assert.strictEqual(me.status, 200);
+ assert.strictEqual(me.json().user.username, 'kim');
+ });
await t.close();
await t.close();
done();
done();
})();
})();