openvibers/openvibe.watch Commits
One sign-in module: openvibe-sdk/sso (#208)
Changes
@@@ -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).@@@ -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);@@@ -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 () => {
@@ -14,17 +14,23 @@
@@ -14,17 +14,23 @@
* the callback, and discarded: the site acts on the watch registry in-process as the person's
* 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.
* 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 express = require('express');
-const sdk = require('openvibe-sdk/auth');
+const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const SESSION_COOKIE = 'watch_session';
const SESSION_COOKIE = 'watch_session';
const FLOW_COOKIE = 'watch_oauth';
const FLOW_COOKIE = 'watch_oauth';
const HINT_COOKIE = 'ov_sso_hint';
const HINT_COOKIE = 'ov_sso_hint';
+const RETRY_COOKIE = 'ov_oauth_retry';
const FLOW_TTL_MS = 10 * 60 * 1000;
const FLOW_TTL_MS = 10 * 60 * 1000;
+const RETRY_TTL_MS = 2 * 60 * 1000;
/** This site's own origin (BASE_URL). */
/** This site's own origin (BASE_URL). */
function ownOrigins(config) {
function ownOrigins(config) {
@@ -68,8 +74,31 @@ function parseCookies(header) {
@@ -68,8 +74,31 @@ function parseCookies(header) {
return out;
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 }) {
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 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) {
function setSessionCookie(res, token) {
res.cookie(SESSION_COOKIE, token, { ...cookieBase(), maxAge: sessions.DEFAULT_TTL_MS });
res.cookie(SESSION_COOKIE, token, { ...cookieBase(), maxAge: sessions.DEFAULT_TTL_MS });
@@ -115,17 +144,28 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -115,17 +144,28 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
function routes() {
function routes() {
const r = express.Router();
const r = express.Router();
const flowCookie = () => ({ ...cookieBase(), path: '/auth', maxAge: FLOW_TTL_MS });
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 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 }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
return res.redirect(started.url);
return res.redirect(started.url);
});
});
@@ -136,36 +176,68 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -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; }
try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
const next = flow ? sanitizeNext(flow.next, config) : '/';
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 {
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) {
} 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 {
try {
- const claims = await verifySessionToken(token);
+ const claims = await verifySessionToken(data.access_token);
const session = await sessions.create({
const session = await sessions.create({
subject: claims.subject_id,
subject: claims.subject_id,
username: typeof claims.username === 'string' ? claims.username : null,
username: typeof claims.username === 'string' ? claims.username : null,
@@ -174,15 +246,29 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -174,15 +246,29 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
avatarUrl: claims.avatar_url, profileColor: claims.profile_color,
avatarUrl: claims.avatar_url, profileColor: claims.profile_color,
});
});
setSessionCookie(res, session);
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
// 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).
// cache is cleared, which on phones left people on the account chooser with no session (2026-10-28).
return res.redirect(next);
return res.redirect(next);
} catch (err) {
} catch (err) {
log.warn(`[sso] sign-in refused: ${(err && err.code) || (err && err.message) || 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
// 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
// <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).
// none), signs out directly; anything else gets a confirm button (POST).
@@ -79,7 +79,11 @@ async function startNetwork({ privateKey, issuer = 'https://openvibe.network', c
@@ -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/revoke') return json(200, {});
if (url.pathname === '/oauth/token' && req.method === 'POST') {
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.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);
if (body.grant_type !== 'authorization_code') return problem(json, 400, 'unsupported_grant_type', body.grant_type);
const c = st.codes.get(body.code);
const c = st.codes.get(body.code);
@@ -102,11 +102,23 @@ t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () =
@@ -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');
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);
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 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 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 });
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 () => {
t('a person signs in for the rest of the tests', async () => {