openvibers/openvibe.quest Commits
Quest: sign-in on openvibe-sdk/sso 0.73.0 (#132)
Changes
@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.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"},@@@ -1181,9 +1181,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"@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.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"},@@@ -120,7 +120,7 @@ async function createApp(opts = {}) {// ── Machine endpoints ───────────────────────────────────app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-quest', version: VERSION, events: eventsConsumer.stats() }));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,34 +1,32 @@'use strict';/**-- * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `quest`.++ * Sign-in with OpenVibe.Network, on openvibe-sdk/sso (0.73.0) instead of this site's own copy of the OAuth client.*-- * 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++ * The SDK owns the flow: GET /auth/login (PKCE S256, ?next, ?silent, ?prompt, ?login_hint), GET /auth/callback++ * (recovering by itself once from a lost flow or a used code), GET /auth/logout, GET /auth/me, POST /auth/fedcm,++ * POST /auth/refresh and GET /auth/health. This file keeps OpenVibe.Quest's own surface on top of it:*-- * The person's Network access token lives in an httpOnly cookie (quest_at) no script can read: with it, a script-- * could act as them. The refresh token (quest_rt) is httpOnly too. Neither is ever stored server-side, rendered-- * or logged.++ * middleware() req.viewer (the page renderer's shape), from sso.optionalAuth()++ * routes() the SDK's /auth router, plus this site's /auth/me body and the cross-site sign-out guard++ * asViewer(req,res,fn) run a Network call as the person, refreshing once on 401++ * tokenGrant(fields) a token request with this site's client++ *++ * The old cookie names (quest_at, quest_rt) are handed to the SDK as legacyCookies, so nobody is signed out by the++ * move: they are read as the session until the next sign-in or refresh writes ov_token / ov_refresh and clears them.*/const crypto = require('crypto');const express = require('express');const cache = require('openvibe-shared/cache-policy');++const { createSsoClient } = require('openvibe-sdk/sso');const ACCESS_COOKIE = 'quest_at';const REFRESH_COOKIE = 'quest_rt';const HINT_COOKIE = 'ov_sso_hint';--const FLOW_COOKIE = 'quest_oauth';----function pkcePair() {-- const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters-- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');-- return { verifier, challenge };--}++const SDK_ACCESS_COOKIE = 'ov_token';++const SDK_REFRESH_COOKIE = 'ov_refresh';--/** This site's own origins: BASE_URL, and the other hosts this process serves (OpenVibe.Homes for Rent). */++/** This site's own origins: BASE_URL, and the other hosts this process serves. */function ownOrigins(config) {const out = new Set();for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {@@@ -43,8 +41,6 @@ function ownOrigins(config) {* 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;@@@ -56,64 +52,51 @@ function sanitizeNext(next, config) {return next;}--function authorizeUrl(config, { state, challenge, silent = false }) {-- const q = new URLSearchParams({-- response_type: 'code',-- client_id: config.oauth.clientId,-- redirect_uri: config.oauth.redirectUri,-- scope: config.oauth.scope,-- state,-- code_challenge: challenge,-- code_challenge_method: 'S256',-- });-- if (silent) q.set('prompt', 'none');-- return `${config.networkUrl}/oauth/authorize?${q.toString()}`;++function pkcePair() {++ const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');++ return { verifier, challenge };}function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {-- const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });++ const secure = config.cookies.secure;++ const sso = createSsoClient({++ site: 'quest',++ siteName: 'OpenVibe.Quest',++ baseUrl: config.baseUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ networkUrl: config.networkUrl,++ networkInternalUrl: config.networkInternalUrl,++ redirectUri: config.oauth.redirectUri,++ issuer: config.networkIssuer,++ scope: config.oauth.scope,++ audience: config.oauth.sessionAudience,++ secureCookies: secure,++ httpOnlyAccess: true,++ legacyCookies: { access: [ACCESS_COOKIE], refresh: [REFRESH_COOKIE] },++ fetch: fetchImpl,++ log,++ });++++ const cookieBase = (opts) => ({ sameSite: 'lax', secure, httpOnly: true, path: '/', ...opts });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 });++ res.clearCookie(ACCESS_COOKIE, { path: '/' });++ res.clearCookie(REFRESH_COOKIE, { path: '/' });++ res.cookie(SDK_ACCESS_COOKIE, data.access_token, cookieBase({ maxAge: 24 * 60 * 60 * 1000 }));++ if (data.refresh_token) res.cookie(SDK_REFRESH_COOKIE, data.refresh_token, cookieBase({ path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 }));++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', 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;++ res.clearCookie(ACCESS_COOKIE, { path: '/' });++ res.clearCookie(REFRESH_COOKIE, { path: '/' });++ res.clearCookie(SDK_ACCESS_COOKIE, { path: '/' });++ res.clearCookie(SDK_REFRESH_COOKIE, { path: '/auth' });}---- /**-- * A OpenVibe.Quest 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;++ function readRefresh(req) {++ const c = req.cookies || {};++ return c[SDK_REFRESH_COOKIE] || c[REFRESH_COOKIE] || null;}function viewerFromClaims(claims, token) {@@@ -140,35 +123,26 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat}async function doRefresh(req, res) {-- const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ const rt = readRefresh(req);if (!rt) return null;try {-- const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });++ const data = await sso.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;++ const claims = await sso.verify(data.access_token);++ return claims ? viewerFromClaims(claims, data.access_token) : null;} catch (err) {if (err.status && err.status < 500) clearSession(res);return null;}}-- /** req.viewer for every page: the signed-in person, refreshed when their token expired. */++ /** req.viewer for every page: the signed-in person (sso.optionalAuth puts req.user and req.token there). */function middleware() {-- return async (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);++ const auth = sso.optionalAuth();++ return (req, res, next) => auth(req, res, () => {++ req.viewer = req.user ? viewerFromClaims(req.user, req.token) : { kind: 'anonymous' };next();-- };++ });}/**@@@ -177,7 +151,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat*/async function asViewer(req, res, fn) {try {-- return await fn(req.viewer.token);++ return await fn(req.viewer && req.viewer.token);} catch (err) {if (!err || err.status !== 401) throw err;const fresh = await refresh(req, res);@@@ -187,58 +161,38 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat}}++ // 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).++ 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++ };++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 }));++ // The shared navbar's silent sign-in sends the whole page address: keep its path before the SDK sees it.++ r.use((req, _res, next) => {++ if (req.method === 'GET' && req.path === '/login') {++ const target = sanitizeNext(req.query.next, config);++ if (target === '/') delete req.query.next; else req.query.next = target;++ }++ next();});-- r.get('/callback', async (req, res) => {++ // The navbar's { user } shape and the no-store header the shared pages expect.++ 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('[OpenVibe.Quest] 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 claims = await sso.verify(sso.extractToken(req));++ if (!claims) return res.json({ user: null });++ const v = viewerFromClaims(claims, null);++ return 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 } });});-- // 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).-- 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-- };r.get('/logout', (req, res, next) => {if (sameOriginRequest(req)) return next();res.set('Cache-Control', cache.htmlHeaders({ private: true }));@@@ -249,7 +203,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat});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];++ const rt = readRefresh(req);if (rt) {fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {method: 'POST', headers: { 'Content-Type': 'application/json' },@@@ -258,25 +212,15 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat}).catch(() => {});}clearSession(res);-- res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });res.redirect(303, sanitizeNext(req.query.next, config));});-- 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 } });-- });++ r.use(sso.router(express));return r;}-- return { middleware, routes, asViewer, tokenGrant, pkcePair };++ return { middleware, routes, asViewer, tokenGrant: sso.tokenGrant, pkcePair, client: sso };}--module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, ACCESS_COOKIE, REFRESH_COOKIE };@@@ -14,7 +14,7 @@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 = null }) {return createReadiness({service: 'quest',release,@@@ -37,7 +37,17 @@ function createServiceReadiness({ s, config, valkey = null, release = null }) {return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };},},-- { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },++ {++ name: 'oauth_client', required: true,++ check: async () => {++ if (!config.oauth.clientSecret) return 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete';++ if (!sso) return true;++ // 'invalid': the Network refuses this client, so a deploy with a wrong secret fails at once.++ // 'unknown' (the Network did not answer the probe) leaves readiness alone.++ const client = await sso.client.checkClient();++ return client === 'invalid' ? 'the Network refuses this OAuth client: check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET' : true;++ },++ },],});}@@@ -125,7 +125,8 @@ function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg:assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(readyText),`/api/ready leaks the JWKS URL or the fetch error: ${readyText.slice(0, 400)}`);const me = await t.get('/auth/me', { cookie: `quest_at=${token}` });-- assert.strictEqual(me.status, 401, `an unverifiable session token is 401: ${me.status} ${me.text.slice(0, 200)}`);++ assert.strictEqual(me.status, 200, `an unverifiable session token is signed out: ${me.status} ${me.text.slice(0, 200)}`);++ assert.strictEqual(me.json().user, null);assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(me.text),`the sign-in answer leaks the JWKS URL or the fetch error: ${me.text.slice(0, 300)}`);await t.close();@@@ -137,7 +138,8 @@ function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg:// Signed by the Network, but exp is an hour in the past: the SDK's Math.floor(now / 1000) must see it.const expired = t.network.userToken(u, { ttl: -3600 });const me = await t.get('/auth/me', { cookie: `quest_at=${expired}` });-- assert.strictEqual(me.status, 401, `an expired cookie is not a session: ${me.status} ${me.text.slice(0, 200)}`);++ assert.strictEqual(me.status, 200, `an expired cookie is signed out: ${me.status} ${me.text.slice(0, 200)}`);++ assert.strictEqual(me.json().user, null, 'an expired cookie is not a session');const page = await t.get('/', { cookie: `quest_at=${expired}` });assert.ok(page.status < 400, `a page still renders: ${page.status}`);assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');@@@ -1,13 +1,16 @@'use strict';/**-- * Sign-in with PKCE S256 (client id quest, cookies quest_at/quest_rt/quest_oauth), truthful readiness-- * (the database is required; JWKS, the OAuth client and Valkey are optional), /release.json, loopback-only-- * /metrics, and pages useful without JavaScript.++ * Sign-in on openvibe-sdk/sso (client id quest, legacy cookies quest_at/quest_rt, SDK flow cookies ov_oauth_*),++ * truthful readiness (the database and the OAuth client are required; JWKS and Valkey are optional),++ * /release.json, loopback-only /metrics, and pages useful without JavaScript.*/const assert = require('assert');const crypto = require('crypto');const { boot, check, done } = require('./helpers/boot');++/** Every Set-Cookie of a response as a Cookie header (the SDK's flow spans several cookies). */++const cookieHeader = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');++(async () => {const t = await boot();const user = t.network.addUser('kim');@@@ -20,48 +23,66 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(loc.searchParams.get('client_id'), 'quest');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'), /quest_oauth=.*Path=\/auth.*HttpOnly/i);++ assert.match(loc.searchParams.get('state'), /^[A-Za-z0-9_-]{32}$/);++ const set = r.headers.getSetCookie().join('\n');++ assert.match(set, /ov_oauth_state=[^;]+;.*Path=\/auth.*HttpOnly/i);++ assert.match(set, /ov_oauth_verifier=[^;]+;.*Path=\/auth.*HttpOnly/i);++ assert.match(set, /ov_oauth_next=%2Fupdates|ov_oauth_next=\/updates/);});await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {const login = await t.get('/auth/login?next=/updates');const loc = new URL(login.headers.get('location'));-- const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const flowCookie = cookieHeader(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'), '/updates');const set = r.headers.get('set-cookie');-- assert.match(set, /quest_at=[^;]+;.*HttpOnly/i);-- assert.match(set, /quest_rt=[^;]+;.*HttpOnly/i);-- const at = set.match(/quest_at=([^;]+)/)[1];-- const me = await t.get('/auth/me', { cookie: `quest_at=${at}` });++ assert.match(set, /ov_token=[^;]+;.*HttpOnly/i);++ assert.match(set, /ov_refresh=[^;]+;.*HttpOnly/i);++ assert.match(set, /quest_at=;/, 'the legacy access cookie is cleared once the SDK session is written');++ 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');++ // quest_at is read as the session until the SDK writes its own: nobody is signed out by the move.++ const legacy = await t.get('/auth/me', { cookie: `quest_at=${t.network.userToken(user)}` });++ assert.strictEqual(legacy.json().user.username, 'kim');});-- await check('a wrong state or a verifier that does not match is refused', async () => {++ await check('a lost flow or a used code recovers once, then shows the page (never a bare line)', 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 = cookieHeader(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);++ // A lost flow (no state cookie) starts the sign-in again by itself instead of failing.++ const lost = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);++ assert.strictEqual(lost.status, 302);++ assert.strictEqual(lost.headers.get('location'), '/auth/login');++ assert.match(lost.headers.get('set-cookie'), /ov_oauth_retry=1/);++ // A second failure shows the page.++ const again = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: 'ov_oauth_retry=1' });++ assert.strictEqual(again.status, 400);++ assert.match(again.text, /Sign-in didn't finish|Sign-in didn't finish/);++ assert.match(again.text, /Try again/);++ // A verifier that does not match is a used/bad code: the same one recovery.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; the service reports it');-- const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);-- assert.strictEqual(noCookie.status, 400);++ assert.strictEqual(mismatch.status, 302, 'a code the Network refuses starts the sign-in once more');++ const mismatchAgain = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: `${flowCookie}; ov_oauth_retry=1` });++ assert.strictEqual(mismatchAgain.status, 400, 'the second failure shows the page');});await check('next= only accepts same-site paths', async () => {const r = await t.get('/auth/login?next=//evil.example/x');-- const flow = decodeURIComponent(r.headers.get('set-cookie').match(/quest_oauth=([^;]+)/)[1]);-- assert.strictEqual(JSON.parse(flow).next, '/');++ const nextCookie = r.headers.getSetCookie().find((c) => c.startsWith('ov_oauth_next='));++ assert.ok(!nextCookie || /^ov_oauth_next=;/.test(nextCookie), 'nothing off-site is remembered as next');++ const loc = new URL(r.headers.get('location'));++ assert.strictEqual(loc.searchParams.get('client_id'), 'quest');});-- await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {++ await check('readiness is truthful: db and the OAuth client required; JWKS and Valkey optional', async () => {const r = await t.get('/api/ready');const b = r.json();assert.strictEqual(r.status, 200);@@@ -70,7 +91,7 @@ const { boot, check, done } = require('./helpers/boot');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.oauth_client.required, false);++ assert.strictEqual(b.checks.oauth_client.required, true);assert.ok('valkey' in b.checks, 'the optional Valkey check is listed');});@@@ -24,7 +24,8 @@ const { signJwt } = require('./helpers/mocks');await check('a FedCM assertion is not a session (it signs nobody in)', async () => {const cookie = `quest_at=${fedcm()}`;const me = await s.get('/auth/me', { cookie });-- assert.strictEqual(me.status, 401, 'an assertion for another site is not a session here');++ assert.strictEqual(me.status, 200, 'a signed-out answer, not an error');++ assert.strictEqual(JSON.parse(me.text).user, null, 'an assertion for another site is not a session here');const page = await s.get('/', { cookie });assert.ok(!page.text.includes(`Signed in as <strong>${staff.username}`), 'the page shows nobody signed in');});@@@ -32,16 +33,20 @@ const { signJwt } = require('./helpers/mocks');await check('an app token is not a session', async () => {const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);const me = await s.get('/auth/me', { cookie: `quest_at=${token}` });-- assert.strictEqual(me.status, 401);++ assert.strictEqual(me.status, 200);++ assert.strictEqual(JSON.parse(me.text).user, null);});-- await check('/auth/me: a guest is signed out (200 { user: null }); a session cookie that fails is 401', async () => {++ await check('/auth/me: a guest and a session that fails are both 200 { user: null } (the SDK shape)', async () => {const guest = await s.get('/auth/me');assert.strictEqual(guest.status, 200, 'no session cookie at all: not an error');assert.deepStrictEqual(JSON.parse(guest.text), { user: null });assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');-- assert.strictEqual((await s.get('/auth/me', { cookie: 'quest_at=garbage' })).status, 401, 'a present but invalid access cookie');-- assert.strictEqual((await s.get('/auth/me', { cookie: 'quest_rt=stale' })).status, 401, 'a refresh cookie alone is a session that did not resolve');++ for (const cookie of ['quest_at=garbage', 'quest_rt=stale']) {++ const r = await s.get('/auth/me', { cookie });++ assert.strictEqual(r.status, 200, `${cookie}: a signed-out answer, not an error`);++ assert.strictEqual(JSON.parse(r.text).user, null, `${cookie} signs nobody in`);++ }assert.strictEqual((await s.get('/auth/me', { as: staff })).status, 200);});
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1181,9 +1181,9 @@
@@ -1181,9 +1181,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -120,7 +120,7 @@ async function createApp(opts = {}) {
@@ -120,7 +120,7 @@ async function createApp(opts = {}) {
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-quest', version: VERSION, events: eventsConsumer.stats() }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-quest', version: VERSION, events: eventsConsumer.stats() }));
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,34 +1,32 @@
@@ -1,34 +1,32 @@
'use strict';
'use strict';
/**
/**
- * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `quest`.
+ * Sign-in with OpenVibe.Network, on openvibe-sdk/sso (0.73.0) instead of this site's own copy of the OAuth client.
*
*
- * 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
+ * The SDK owns the flow: GET /auth/login (PKCE S256, ?next, ?silent, ?prompt, ?login_hint), GET /auth/callback
+ * (recovering by itself once from a lost flow or a used code), GET /auth/logout, GET /auth/me, POST /auth/fedcm,
+ * POST /auth/refresh and GET /auth/health. This file keeps OpenVibe.Quest's own surface on top of it:
*
*
- * The person's Network access token lives in an httpOnly cookie (quest_at) no script can read: with it, a script
- * could act as them. The refresh token (quest_rt) is httpOnly too. Neither is ever stored server-side, rendered
- * or logged.
+ * middleware() req.viewer (the page renderer's shape), from sso.optionalAuth()
+ * routes() the SDK's /auth router, plus this site's /auth/me body and the cross-site sign-out guard
+ * asViewer(req,res,fn) run a Network call as the person, refreshing once on 401
+ * tokenGrant(fields) a token request with this site's client
+ *
+ * The old cookie names (quest_at, quest_rt) are handed to the SDK as legacyCookies, so nobody is signed out by the
+ * move: they are read as the session until the next sign-in or refresh writes ov_token / ov_refresh and clears them.
*/
*/
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 } = require('openvibe-sdk/sso');
const ACCESS_COOKIE = 'quest_at';
const ACCESS_COOKIE = 'quest_at';
const REFRESH_COOKIE = 'quest_rt';
const REFRESH_COOKIE = 'quest_rt';
const HINT_COOKIE = 'ov_sso_hint';
const HINT_COOKIE = 'ov_sso_hint';
-const FLOW_COOKIE = 'quest_oauth';
-
-function pkcePair() {
- const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters
- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
- return { verifier, challenge };
-}
+const SDK_ACCESS_COOKIE = 'ov_token';
+const SDK_REFRESH_COOKIE = 'ov_refresh';
-/** This site's own origins: BASE_URL, and the other hosts this process serves (OpenVibe.Homes for Rent). */
+/** This site's own origins: BASE_URL, and the other hosts this process serves. */
function ownOrigins(config) {
function ownOrigins(config) {
const out = new Set();
const out = new Set();
for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
@@ -43,8 +41,6 @@ function ownOrigins(config) {
@@ -43,8 +41,6 @@ function ownOrigins(config) {
* 2026-10-22 that landed on the home page (a person signed in quietly found themselves somewhere else).
* 2026-10-22 that landed on the home page (a person signed in quietly found themselves somewhere else).
*/
*/
function sanitizeNext(next, config) {
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' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
let u = null;
let u = null;
@@ -56,64 +52,51 @@ function sanitizeNext(next, config) {
@@ -56,64 +52,51 @@ function sanitizeNext(next, config) {
return next;
return next;
}
}
-function authorizeUrl(config, { state, challenge, silent = false }) {
- const q = new URLSearchParams({
- response_type: 'code',
- client_id: config.oauth.clientId,
- redirect_uri: config.oauth.redirectUri,
- scope: config.oauth.scope,
- state,
- code_challenge: challenge,
- code_challenge_method: 'S256',
- });
- if (silent) q.set('prompt', 'none');
- return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
+function pkcePair() {
+ const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ return { verifier, challenge };
}
}
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: '/' });
+ const secure = config.cookies.secure;
+ const sso = createSsoClient({
+ site: 'quest',
+ siteName: 'OpenVibe.Quest',
+ baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ networkUrl: config.networkUrl,
+ networkInternalUrl: config.networkInternalUrl,
+ redirectUri: config.oauth.redirectUri,
+ issuer: config.networkIssuer,
+ scope: config.oauth.scope,
+ audience: config.oauth.sessionAudience,
+ secureCookies: secure,
+ httpOnlyAccess: true,
+ legacyCookies: { access: [ACCESS_COOKIE], refresh: [REFRESH_COOKIE] },
+ fetch: fetchImpl,
+ log,
+ });
+
+ const cookieBase = (opts) => ({ sameSite: 'lax', secure, httpOnly: true, path: '/', ...opts });
function setSession(res, data) {
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 });
+ res.clearCookie(ACCESS_COOKIE, { path: '/' });
+ res.clearCookie(REFRESH_COOKIE, { path: '/' });
+ res.cookie(SDK_ACCESS_COOKIE, data.access_token, cookieBase({ maxAge: 24 * 60 * 60 * 1000 }));
+ if (data.refresh_token) res.cookie(SDK_REFRESH_COOKIE, data.refresh_token, cookieBase({ path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 }));
+ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
}
}
function clearSession(res) {
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;
+ res.clearCookie(ACCESS_COOKIE, { path: '/' });
+ res.clearCookie(REFRESH_COOKIE, { path: '/' });
+ res.clearCookie(SDK_ACCESS_COOKIE, { path: '/' });
+ res.clearCookie(SDK_REFRESH_COOKIE, { path: '/auth' });
}
}
-
- /**
- * A OpenVibe.Quest 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;
+ function readRefresh(req) {
+ const c = req.cookies || {};
+ return c[SDK_REFRESH_COOKIE] || c[REFRESH_COOKIE] || null;
}
}
function viewerFromClaims(claims, token) {
function viewerFromClaims(claims, token) {
@@ -140,35 +123,26 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -140,35 +123,26 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}
}
async function doRefresh(req, res) {
async function doRefresh(req, res) {
- const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ const rt = readRefresh(req);
if (!rt) return null;
if (!rt) return null;
try {
try {
- const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
+ const data = await sso.tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
setSession(res, data);
setSession(res, data);
- const v = await verifySession(data.access_token);
- return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
+ const claims = await sso.verify(data.access_token);
+ return claims ? viewerFromClaims(claims, data.access_token) : null;
} catch (err) {
} catch (err) {
if (err.status && err.status < 500) clearSession(res);
if (err.status && err.status < 500) clearSession(res);
return null;
return null;
}
}
}
}
- /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
+ /** req.viewer for every page: the signed-in person (sso.optionalAuth puts req.user and req.token there). */
function middleware() {
function middleware() {
- return async (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);
+ const auth = sso.optionalAuth();
+ return (req, res, next) => auth(req, res, () => {
+ req.viewer = req.user ? viewerFromClaims(req.user, req.token) : { kind: 'anonymous' };
next();
next();
- };
+ });
}
}
/**
/**
@@ -177,7 +151,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -177,7 +151,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
*/
*/
async function asViewer(req, res, fn) {
async function asViewer(req, res, fn) {
try {
try {
- return await fn(req.viewer.token);
+ return await fn(req.viewer && req.viewer.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 refresh(req, res);
@@ -187,58 +161,38 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -187,58 +161,38 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}
}
}
}
+ // 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).
+ 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
+ };
+
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());
- res.set('Cache-Control', cache.htmlHeaders({ private: true }));
- res.redirect(authorizeUrl(config, { state, challenge, silent }));
+ // The shared navbar's silent sign-in sends the whole page address: keep its path before the SDK sees it.
+ r.use((req, _res, next) => {
+ if (req.method === 'GET' && req.path === '/login') {
+ const target = sanitizeNext(req.query.next, config);
+ if (target === '/') delete req.query.next; else req.query.next = target;
+ }
+ next();
});
});
- r.get('/callback', async (req, res) => {
+ // The navbar's { user } shape and the no-store header the shared pages expect.
+ r.get('/me', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
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('[OpenVibe.Quest] 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 claims = await sso.verify(sso.extractToken(req));
+ if (!claims) return res.json({ user: null });
+ const v = viewerFromClaims(claims, null);
+ return 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 } });
});
});
- // 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).
- 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
- };
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 }));
@@ -249,7 +203,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -249,7 +203,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
});
});
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];
+ const rt = readRefresh(req);
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' },
@@ -258,25 +212,15 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -258,25 +212,15 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
}).catch(() => {});
}).catch(() => {});
}
}
clearSession(res);
clearSession(res);
- res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
res.redirect(303, sanitizeNext(req.query.next, config));
res.redirect(303, sanitizeNext(req.query.next, config));
});
});
- 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 } });
- });
+ r.use(sso.router(express));
return r;
return r;
}
}
- return { middleware, routes, asViewer, tokenGrant, pkcePair };
+ return { middleware, routes, asViewer, tokenGrant: sso.tokenGrant, pkcePair, client: sso };
}
}
-module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
+module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, ACCESS_COOKIE, REFRESH_COOKIE };
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
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 = null }) {
return createReadiness({
return createReadiness({
service: 'quest',
service: 'quest',
release,
release,
@@ -37,7 +37,17 @@ function createServiceReadiness({ s, config, valkey = null, release = null }) {
@@ -37,7 +37,17 @@ function createServiceReadiness({ s, config, valkey = null, release = null }) {
return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
},
},
},
},
- { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
+ {
+ name: 'oauth_client', required: true,
+ check: async () => {
+ if (!config.oauth.clientSecret) return 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete';
+ if (!sso) return true;
+ // 'invalid': the Network refuses this client, so a deploy with a wrong secret fails at once.
+ // 'unknown' (the Network did not answer the probe) leaves readiness alone.
+ const client = await sso.client.checkClient();
+ return client === 'invalid' ? 'the Network refuses this OAuth client: check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET' : true;
+ },
+ },
],
],
});
});
}
}
@@ -125,7 +125,8 @@ function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg:
@@ -125,7 +125,8 @@ function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg:
assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(readyText),
assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(readyText),
`/api/ready leaks the JWKS URL or the fetch error: ${readyText.slice(0, 400)}`);
`/api/ready leaks the JWKS URL or the fetch error: ${readyText.slice(0, 400)}`);
const me = await t.get('/auth/me', { cookie: `quest_at=${token}` });
const me = await t.get('/auth/me', { cookie: `quest_at=${token}` });
- assert.strictEqual(me.status, 401, `an unverifiable session token is 401: ${me.status} ${me.text.slice(0, 200)}`);
+ assert.strictEqual(me.status, 200, `an unverifiable session token is signed out: ${me.status} ${me.text.slice(0, 200)}`);
+ assert.strictEqual(me.json().user, null);
assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(me.text),
assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(me.text),
`the sign-in answer leaks the JWKS URL or the fetch error: ${me.text.slice(0, 300)}`);
`the sign-in answer leaks the JWKS URL or the fetch error: ${me.text.slice(0, 300)}`);
await t.close();
await t.close();
@@ -137,7 +138,8 @@ function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg:
@@ -137,7 +138,8 @@ function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg:
// Signed by the Network, but exp is an hour in the past: the SDK's Math.floor(now / 1000) must see it.
// Signed by the Network, but exp is an hour in the past: the SDK's Math.floor(now / 1000) must see it.
const expired = t.network.userToken(u, { ttl: -3600 });
const expired = t.network.userToken(u, { ttl: -3600 });
const me = await t.get('/auth/me', { cookie: `quest_at=${expired}` });
const me = await t.get('/auth/me', { cookie: `quest_at=${expired}` });
- assert.strictEqual(me.status, 401, `an expired cookie is not a session: ${me.status} ${me.text.slice(0, 200)}`);
+ assert.strictEqual(me.status, 200, `an expired cookie is signed out: ${me.status} ${me.text.slice(0, 200)}`);
+ assert.strictEqual(me.json().user, null, 'an expired cookie is not a session');
const page = await t.get('/', { cookie: `quest_at=${expired}` });
const page = await t.get('/', { cookie: `quest_at=${expired}` });
assert.ok(page.status < 400, `a page still renders: ${page.status}`);
assert.ok(page.status < 400, `a page still renders: ${page.status}`);
assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');
assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');
@@ -1,13 +1,16 @@
@@ -1,13 +1,16 @@
'use strict';
'use strict';
/**
/**
- * Sign-in with PKCE S256 (client id quest, cookies quest_at/quest_rt/quest_oauth), truthful readiness
- * (the database is required; JWKS, the OAuth client and Valkey are optional), /release.json, loopback-only
- * /metrics, and pages useful without JavaScript.
+ * Sign-in on openvibe-sdk/sso (client id quest, legacy cookies quest_at/quest_rt, SDK flow cookies ov_oauth_*),
+ * truthful readiness (the database and the OAuth client are required; JWKS and Valkey are optional),
+ * /release.json, loopback-only /metrics, and pages useful without JavaScript.
*/
*/
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');
+/** Every Set-Cookie of a response as a Cookie header (the SDK's flow spans several cookies). */
+const cookieHeader = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
+
(async () => {
(async () => {
const t = await boot();
const t = await boot();
const user = t.network.addUser('kim');
const user = t.network.addUser('kim');
@@ -20,48 +23,66 @@ const { boot, check, done } = require('./helpers/boot');
@@ -20,48 +23,66 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(loc.searchParams.get('client_id'), 'quest');
assert.strictEqual(loc.searchParams.get('client_id'), 'quest');
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'), /quest_oauth=.*Path=\/auth.*HttpOnly/i);
+ assert.match(loc.searchParams.get('state'), /^[A-Za-z0-9_-]{32}$/);
+ const set = r.headers.getSetCookie().join('\n');
+ assert.match(set, /ov_oauth_state=[^;]+;.*Path=\/auth.*HttpOnly/i);
+ assert.match(set, /ov_oauth_verifier=[^;]+;.*Path=\/auth.*HttpOnly/i);
+ assert.match(set, /ov_oauth_next=%2Fupdates|ov_oauth_next=\/updates/);
});
});
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=/updates');
const login = await t.get('/auth/login?next=/updates');
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 = cookieHeader(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'), '/updates');
assert.strictEqual(r.headers.get('location'), '/updates');
const set = r.headers.get('set-cookie');
const set = r.headers.get('set-cookie');
- assert.match(set, /quest_at=[^;]+;.*HttpOnly/i);
- assert.match(set, /quest_rt=[^;]+;.*HttpOnly/i);
- const at = set.match(/quest_at=([^;]+)/)[1];
- const me = await t.get('/auth/me', { cookie: `quest_at=${at}` });
+ assert.match(set, /ov_token=[^;]+;.*HttpOnly/i);
+ assert.match(set, /ov_refresh=[^;]+;.*HttpOnly/i);
+ assert.match(set, /quest_at=;/, 'the legacy access cookie is cleared once the SDK session is written');
+ 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');
+ // quest_at is read as the session until the SDK writes its own: nobody is signed out by the move.
+ const legacy = await t.get('/auth/me', { cookie: `quest_at=${t.network.userToken(user)}` });
+ assert.strictEqual(legacy.json().user.username, 'kim');
});
});
- await check('a wrong state or a verifier that does not match is refused', async () => {
+ await check('a lost flow or a used code recovers once, then shows the page (never a bare line)', 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 = cookieHeader(login);
const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
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);
+ // A lost flow (no state cookie) starts the sign-in again by itself instead of failing.
+ const lost = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
+ assert.strictEqual(lost.status, 302);
+ assert.strictEqual(lost.headers.get('location'), '/auth/login');
+ assert.match(lost.headers.get('set-cookie'), /ov_oauth_retry=1/);
+ // A second failure shows the page.
+ const again = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: 'ov_oauth_retry=1' });
+ assert.strictEqual(again.status, 400);
+ assert.match(again.text, /Sign-in didn't finish|Sign-in didn't finish/);
+ assert.match(again.text, /Try again/);
+ // A verifier that does not match is a used/bad code: the same one recovery.
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'));
const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
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; the service reports it');
- const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
- assert.strictEqual(noCookie.status, 400);
+ assert.strictEqual(mismatch.status, 302, 'a code the Network refuses starts the sign-in once more');
+ const mismatchAgain = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: `${flowCookie}; ov_oauth_retry=1` });
+ assert.strictEqual(mismatchAgain.status, 400, 'the second failure shows the page');
});
});
await check('next= only accepts same-site paths', async () => {
await check('next= only accepts same-site paths', async () => {
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(/quest_oauth=([^;]+)/)[1]);
- assert.strictEqual(JSON.parse(flow).next, '/');
+ const nextCookie = r.headers.getSetCookie().find((c) => c.startsWith('ov_oauth_next='));
+ assert.ok(!nextCookie || /^ov_oauth_next=;/.test(nextCookie), 'nothing off-site is remembered as next');
+ const loc = new URL(r.headers.get('location'));
+ assert.strictEqual(loc.searchParams.get('client_id'), 'quest');
});
});
- await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {
+ await check('readiness is truthful: db and the OAuth client required; JWKS 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();
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
@@ -70,7 +91,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -70,7 +91,7 @@ const { boot, check, done } = require('./helpers/boot');
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.oauth_client.required, false);
+ assert.strictEqual(b.checks.oauth_client.required, true);
assert.ok('valkey' in b.checks, 'the optional Valkey check is listed');
assert.ok('valkey' in b.checks, 'the optional Valkey check is listed');
});
});
@@ -24,7 +24,8 @@ const { signJwt } = require('./helpers/mocks');
@@ -24,7 +24,8 @@ const { signJwt } = require('./helpers/mocks');
await check('a FedCM assertion is not a session (it signs nobody in)', async () => {
await check('a FedCM assertion is not a session (it signs nobody in)', async () => {
const cookie = `quest_at=${fedcm()}`;
const cookie = `quest_at=${fedcm()}`;
const me = await s.get('/auth/me', { cookie });
const me = await s.get('/auth/me', { cookie });
- assert.strictEqual(me.status, 401, 'an assertion for another site is not a session here');
+ assert.strictEqual(me.status, 200, 'a signed-out answer, not an error');
+ assert.strictEqual(JSON.parse(me.text).user, null, 'an assertion for another site is not a session here');
const page = await s.get('/', { cookie });
const page = await s.get('/', { cookie });
assert.ok(!page.text.includes(`Signed in as <strong>${staff.username}`), 'the page shows nobody signed in');
assert.ok(!page.text.includes(`Signed in as <strong>${staff.username}`), 'the page shows nobody signed in');
});
});
@@ -32,16 +33,20 @@ const { signJwt } = require('./helpers/mocks');
@@ -32,16 +33,20 @@ const { signJwt } = require('./helpers/mocks');
await check('an app token is not a session', async () => {
await check('an app token is not a session', async () => {
const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);
const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);
const me = await s.get('/auth/me', { cookie: `quest_at=${token}` });
const me = await s.get('/auth/me', { cookie: `quest_at=${token}` });
- assert.strictEqual(me.status, 401);
+ assert.strictEqual(me.status, 200);
+ assert.strictEqual(JSON.parse(me.text).user, null);
});
});
- await check('/auth/me: a guest is signed out (200 { user: null }); a session cookie that fails is 401', async () => {
+ await check('/auth/me: a guest and a session that fails are both 200 { user: null } (the SDK shape)', async () => {
const guest = await s.get('/auth/me');
const guest = await s.get('/auth/me');
assert.strictEqual(guest.status, 200, 'no session cookie at all: not an error');
assert.strictEqual(guest.status, 200, 'no session cookie at all: not an error');
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 s.get('/auth/me', { cookie: 'quest_at=garbage' })).status, 401, 'a present but invalid access cookie');
- assert.strictEqual((await s.get('/auth/me', { cookie: 'quest_rt=stale' })).status, 401, 'a refresh cookie alone is a session that did not resolve');
+ for (const cookie of ['quest_at=garbage', 'quest_rt=stale']) {
+ const r = await s.get('/auth/me', { cookie });
+ assert.strictEqual(r.status, 200, `${cookie}: a signed-out answer, not an error`);
+ assert.strictEqual(JSON.parse(r.text).user, null, `${cookie} signs nobody in`);
+ }
assert.strictEqual((await s.get('/auth/me', { as: staff })).status, 200);
assert.strictEqual((await s.get('/auth/me', { as: staff })).status, 200);
});
});