openvibers/openvibe.watch Commits

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

OP OpenVibers committed 8 days ago · c970e44b78 · 3 files, +141 −39

* One sign-in module: openvibe-sdk/sso

The OAuth half of Watch's sign-in (the authorize URL, the PKCE state and the
code exchange) now comes from createSsoClient in openvibe-sdk/sso instead of
openvibe-sdk/auth's lower-level helpers. The database session and the
watch_session cookie stay exactly as they were: the browser still never holds
a Network token, and every signed-in person keeps their session.

A lost or expired flow now starts the sign-in once more by itself (a
two-minute ov_oauth_retry cookie, never a loop) and, when it cannot, shows a
page with Try again instead of a bare line of text. A client the Network
refuses is logged as a configuration error.

GET /auth/health answers { ok, client } (503 only when the Network refuses the
OAuth client), so the hourly canary can watch Watch.

Tests: node test/site.test.js (25/25) and ov test (31 passed).

* Do not auto-retry an explicit Network sign-in error

access_denied and login_required now get the Try again page directly: the
Network's explicit answer is not something to repeat behind the visitor's
back. The automatic recovery stays for a lost or expired flow and a code the
Network no longer accepts.

Also drops the unused jwks option from the SDK client: verification stays with
keys.verifyUser and Watch's own clock.

Changes

ddiff --git a/server/auth/sso.js b/server/auth/sso.js
iindex 1cebcc6..6a438b3 100644
---- a/server/auth/sso.js
++++ b/server/auth/sso.js
@@@ -14,17 +14,23 @@
* the callback, and discarded: the site acts on the watch registry in-process as the person's
* canonical subject (usr_…), never as the person over Network's API.
*
-- * The OAuth half rides openvibe-sdk/auth's own helpers (startAuthorization / readCallback /
-- * exchangeCode), so the PKCE, state and token-endpoint rules are the SDK's, not a hand copy.
++ * The OAuth half is openvibe-sdk/sso's: the authorize URL, the code exchange and GET /auth/health
++ * are the SDK's (createSsoClient), not a hand copy. The site keeps its database session, because the
++ * browser must never hold a Network token. A lost or expired flow starts the sign-in once more by
++ * itself (a two-minute ov_oauth_retry cookie, never a loop); when it cannot, the visitor gets a page
++ * with "Try again" instead of a bare line of text.
*/
++const crypto = require('crypto');
const express = require('express');
--const sdk = require('openvibe-sdk/auth');
++const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const cache = require('openvibe-shared/cache-policy');
const SESSION_COOKIE = 'watch_session';
const FLOW_COOKIE = 'watch_oauth';
const HINT_COOKIE = 'ov_sso_hint';
++const RETRY_COOKIE = 'ov_oauth_retry';
const FLOW_TTL_MS = 10 * 60 * 1000;
++const RETRY_TTL_MS = 2 * 60 * 1000;
/** This site's own origin (BASE_URL). */
function ownOrigins(config) {
@@@ -68,8 +74,31 @@ function parseCookies(header) {
return out;
}
++/** Compare two strings in constant time (the state must match the browser that started the flow). */
++function sameString(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
++
function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
++ const sdkSso = createSsoClient({
++ site: 'watch',
++ siteName: 'OpenVibe.Watch',
++ baseUrl: config.baseUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ networkUrl: config.networkUrl,
++ networkInternalUrl: config.networkInternalUrl,
++ issuer: config.networkIssuer,
++ redirectUri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ audience: config.oauth.sessionAudience,
++ secureCookies: config.cookies.secure,
++ fetch: fetchImpl,
++ log,
++ });
function setSessionCookie(res, token) {
res.cookie(SESSION_COOKIE, token, { ...cookieBase(), maxAge: sessions.DEFAULT_TTL_MS });
@@@ -115,17 +144,28 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
function routes() {
const r = express.Router();
const flowCookie = () => ({ ...cookieBase(), path: '/auth', maxAge: FLOW_TTL_MS });
++ const retryCookie = () => ({ ...cookieBase(), path: '/auth', maxAge: RETRY_TTL_MS });
++
++ /** A sign-in that cannot finish starts once more by itself; the second time it shows the page. */
++ function retryOrExplain(req, res, target, { status = 400, title, text }) {
++ if (parseCookies(req.headers.cookie)[RETRY_COOKIE] !== '1') {
++ res.cookie(RETRY_COOKIE, '1', retryCookie());
++ return res.redirect(`/auth/login${target && target !== '/' ? `?next=${encodeURIComponent(target)}` : ''}`);
++ }
++ res.clearCookie(RETRY_COOKIE, { ...cookieBase(), path: '/auth' });
++ return res.status(status).type('html').send(failurePage({
++ siteName: 'OpenVibe.Watch',
++ title,
++ text,
++ retryHref: `/auth/login${target && target !== '/' ? `?next=${encodeURIComponent(target)}` : ''}`,
++ backHref: target || '/',
++ }));
++ }
-- r.get('/login', async (req, res) => {
++ r.get('/login', (req, res) => {
const next = sanitizeNext(req.query.next, config);
-- const started = await sdk.startAuthorization({
-- network: config.networkUrl,
-- clientId: config.oauth.clientId,
-- redirectUri: config.oauth.redirectUri,
-- scope: config.oauth.scope,
-- state: sdk.createState(),
-- });
-- res.cookie(FLOW_COOKIE, JSON.stringify({ state: started.state, verifier: started.codeVerifier, next }), flowCookie());
++ const started = sdkSso.authorizeUrl();
++ res.cookie(FLOW_COOKIE, JSON.stringify({ state: started.state, verifier: started.verifier, next }), flowCookie());
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
return res.redirect(started.url);
});
@@@ -136,36 +176,68 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
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 query = new URLSearchParams();
-- for (const [k, v] of Object.entries(req.query)) query.set(k, String(v));
-- let code;
-- try {
-- ({ code } = sdk.readCallback(`/auth/callback?${query.toString()}`, { expectedState: flow && flow.state }));
-- } catch (err) {
-- return res.status(400).type('text/plain').send('Sign-in was not completed. Please try again.');
++ if (req.query.error) {
++ // An explicit answer from the Network (access_denied, login_required, …) is not retried by itself.
++ res.clearCookie(RETRY_COOKIE, { ...cookieBase(), path: '/auth' });
++ log.warn(`[sso] sign-in not completed: ${String(req.query.error).slice(0, 120)}`);
++ return res.status(400).type('html').send(failurePage({
++ siteName: 'OpenVibe.Watch',
++ title: 'Sign-in didn\'t finish',
++ text: `OpenVibe.Network did not complete the sign-in (${String(req.query.error).slice(0, 60)}). Try again when you are ready; your account is fine.`,
++ retryHref: `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`,
++ backHref: next || '/',
++ }));
}
-- if (!flow || typeof flow.verifier !== 'string') {
-- return res.status(400).type('text/plain').send('Sign-in was not started in this browser. Please try again.');
++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string'
++ || typeof req.query.code !== 'string' || !sameString(req.query.state, flow.state)) {
++ return retryOrExplain(req, res, next, {
++ title: 'Sign-in didn\'t finish',
++ text: 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.',
++ });
}
-- let token;
++ let data;
try {
-- const data = await sdk.exchangeCode({
-- code,
-- redirectUri: config.oauth.redirectUri,
-- codeVerifier: flow.verifier,
-- clientId: config.oauth.clientId,
-- clientSecret: config.oauth.clientSecret || undefined,
-- tokenUrl: `${config.networkInternalUrl}/oauth/token`,
-- network: config.networkUrl,
-- fetch: fetchImpl,
++ data = await sdkSso.tokenGrant({
++ grant_type: 'authorization_code',
++ redirect_uri: config.oauth.redirectUri,
++ code: req.query.code,
++ code_verifier: flow.verifier,
});
-- token = data.access_token;
} catch (err) {
-- log.warn(`[sso] code exchange failed: ${(err && err.code) || (err && err.status) || err}`);
-- return res.status(502).type('text/plain').send('Sign-in failed: OpenVibe.Network did not accept the sign-in code. Please try again.');
++ const error = (err && (err.error || err.code)) || '';
++ if (error === 'invalid_client' || error === 'unauthorized_client' || (err && err.status === 401)) {
++ log.error(`[sso] the Network refuses this site's OAuth client (${error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);
++ return res.status(500).type('html').send(failurePage({
++ siteName: 'OpenVibe.Watch',
++ title: 'Sign-in isn\'t working on OpenVibe.Watch right now',
++ text: 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.',
++ retryHref: `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`,
++ backHref: next || '/',
++ }));
++ }
++ log.warn(`[sso] code exchange failed: ${(err && (err.message || err.status)) || err}`);
++ if (err && err.status && err.status < 500) {
++ return retryOrExplain(req, res, next, {
++ title: 'Sign-in didn\'t finish',
++ text: 'It took too long, or it started in another tab or app. Start it again and it will go through; your account is fine.',
++ });
++ }
++ return res.status(502).type('html').send(failurePage({
++ siteName: 'OpenVibe.Watch',
++ title: 'OpenVibe.Network didn\'t answer',
++ text: 'Your account is fine: the sign-in service did not reply in time. Try again in a moment.',
++ retryHref: `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`,
++ backHref: next || '/',
++ }));
++ }
++ if (!data || typeof data.access_token !== 'string') {
++ return retryOrExplain(req, res, next, {
++ title: 'Sign-in didn\'t finish',
++ text: 'OpenVibe.Network did not send a session. Start the sign-in again; your account is fine.',
++ });
}
try {
-- const claims = await verifySessionToken(token);
++ const claims = await verifySessionToken(data.access_token);
const session = await sessions.create({
subject: claims.subject_id,
username: typeof claims.username === 'string' ? claims.username : null,
@@@ -174,15 +246,29 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
avatarUrl: claims.avatar_url, profileColor: claims.profile_color,
});
setSessionCookie(res, session);
++ res.clearCookie(RETRY_COOKIE, { ...cookieBase(), path: '/auth' });
// 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(`[sso] sign-in refused: ${(err && err.code) || (err && err.message) || err}`);
-- return res.status(401).type('text/plain').send('Sign-in failed: that account cannot be signed in here.');
++ return res.status(401).type('html').send(failurePage({
++ siteName: 'OpenVibe.Watch',
++ title: 'That account cannot sign in here',
++ text: 'OpenVibe.Network confirmed the account, but this site cannot use it. Try another account or contact us.',
++ retryHref: `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`,
++ backHref: next || '/',
++ }));
}
});
++ // GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it.
++ r.get('/health', async (_req, res) => {
++ const client = await sdkSso.checkClient();
++ res.set('Cache-Control', 'no-store');
++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
++ });
++
// Sign-out ends the session, so another site must not be able to trigger it (a cross-site
// <img src=/auth/logout>). A same-origin navigation or form, or the address bar (Sec-Fetch-Site
// none), signs out directly; anything else gets a confirm button (POST).
ddiff --git a/test/helpers/network.js b/test/helpers/network.js
iindex 85b44bd..b39da4b 100644
---- a/test/helpers/network.js
++++ b/test/helpers/network.js
@@@ -79,7 +79,11 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
}
if (url.pathname === '/oauth/revoke') return json(200, {});
if (url.pathname === '/oauth/token' && req.method === 'POST') {
-- const body = Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
++ // The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (older clients).
++ const text = raw.toString('utf8');
++ const body = text.trim().startsWith('{')
++ ? JSON.parse(text)
++ : Object.fromEntries(new URLSearchParams(text));
if (body.client_id !== clientId || body.client_secret !== clientSecret) return problem(json, 401, 'invalid_client', 'bad client credentials');
if (body.grant_type !== 'authorization_code') return problem(json, 400, 'unsupported_grant_type', body.grant_type);
const c = st.codes.get(body.code);
ddiff --git a/test/site.test.js b/test/site.test.js
iindex edb2404..b36e139 100644
---- a/test/site.test.js
++++ b/test/site.test.js
@@@ -102,11 +102,23 @@ t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () =
assert.strictEqual(sanitizeNext('https://openvibe.watch/watches/w1?tab=runs', cfg), '/watches/w1?tab=runs');
for (const v of ['https://evil.com/x', 'https://openvibe.watch.evil.com/x', 'https://openvibe.watch//evil.com', 'http://openvibe.watch/x']) assert.strictEqual(sanitizeNext(v, cfg), '/', v);
-- // The state must match the browser that started: a callback with the wrong state is refused.
++ // A state that does not match never sets a session: the SDK's recovery starts a fresh sign-in once, then shows
++ // the page with "Try again" (it does not loop).
const started = await svc.get('/auth/login?next=%2Fwatches');
const flow = started.headers.getSetCookie().map((c) => c.split(';')[0]).find((c) => c.startsWith('watch_oauth='));
const bad = await svc.get('/auth/callback?code=code_x&state=not-the-state', { cookie: flow });
-- assert.strictEqual(bad.status, 400);
++ assert.strictEqual(bad.status, 302);
++ assert.match(bad.headers.get('location'), /^\/auth\/login/);
++ const retry = bad.headers.getSetCookie().map((c) => c.split(';')[0]).find((c) => c.startsWith('ov_oauth_retry='));
++ assert.ok(retry, 'the recovery cookie');
++ const badAgain = await svc.get('/auth/callback?code=code_x&state=not-the-state', { cookie: retry });
++ assert.strictEqual(badAgain.status, 400);
++ assert.match(badAgain.text, /Try again/);
++
++ // GET /auth/health: the Network accepts this client (a code that never existed is invalid_grant, not invalid_client).
++ const health = await svc.get('/auth/health');
++ assert.strictEqual(health.status, 200);
++ assert.deepStrictEqual(health.json(), { ok: true, client: 'ok' });
});
t('a person signs in for the rest of the tests', async () => {