openvibers/openvibe.watch Commits
Enable silent first-visit sign-in on OpenVibe.Watch (#240)
Changes
@@@ -22,7 +22,7 @@*/const crypto = require('crypto');const express = require('express');--const { createSsoClient, failurePage } = require('openvibe-sdk/sso');++const { createSsoClient, failurePage, withParam } = require('openvibe-sdk/sso');const cache = require('openvibe-shared/cache-policy');const SESSION_COOKIE = 'watch_session';@@@ -164,8 +164,9 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =r.get('/login', (req, res) => {const next = sanitizeNext(req.query.next, config);-- const started = sdkSso.authorizeUrl();-- res.cookie(FLOW_COOKIE, JSON.stringify({ state: started.state, verifier: started.verifier, next }), flowCookie());++ const silent = !!req.query.silent && req.query.silent !== '0';++ const started = sdkSso.authorizeUrl({ silent });++ res.cookie(FLOW_COOKIE, JSON.stringify({ state: started.state, verifier: started.verifier, next, silent }), flowCookie());res.set('Cache-Control', cache.htmlHeaders({ private: true }));return res.redirect(started.url);});@@@ -179,6 +180,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =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' });++ if (flow?.silent && sameString(req.query.state, flow.state)) return res.redirect(withParam(next, 'sso', 'none'));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',@@@ -57,6 +57,7 @@ function renderPage(o) {appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Watches', href: '/watches', icon: 'fa-bell' }, { label: 'How it checks', href: '/how-it-works', icon: 'fa-circle-info' }], // phones: the bottom tab bar (Shared 4.20)history: { type: 'page', title: o.title || SITE_NAME },sessionUrl: '/auth/me',++ silentLogin: '/auth/login?silent=1&next={url}',loginUrl: '/auth/login?next={path}',logoutUrl: '/auth/logout?next={path}',notificationsRealtime: true,@@@ -65,6 +65,22 @@ t('the signed-out pages say what Watch is and ask for a sign-in', async () => {assert.doesNotMatch(how.text, /Not built yet/);});++t('silent first visit: prompt=none and a guest returns without a login form', async () => {++ const started = await svc.get('/auth/login?silent=1&next=%2Fwatches%3Fview%3Dall%23latest');++ const u = new URL(started.location);++ assert.strictEqual(u.searchParams.get('prompt'), 'none');++ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');++ const flow = started.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');++ const guest = await svc.get('/auth/callback?error=login_required&state='+u.searchParams.get('state'), { cookie: flow });++ assert.strictEqual(guest.status, 302);++ assert.strictEqual(guest.location, '/watches?view=all&sso=none#latest');++ assert.ok(!guest.headers.getSetCookie().some(c => c.startsWith('watch_session=')));++ const badState = await svc.get('/auth/callback?error=login_required&state=wrong', { cookie: flow });++ assert.strictEqual(badState.status, 400);++ const html = require('../server/render/layout').renderPage({ config: svc.config, body: '' });++ assert.ok(html.includes('/auth/login?silent=1&next={url}'));++});++t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () => {const who = svc.network.addUser('ada'); // same username, distinct subject for this flow testwho.avatar_url = 'https://openvibe.media/o/med_01JABCDEFGHJKMNPQRSTVWXYZ0';
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const express = require('express');
const express = require('express');
-const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
+const { createSsoClient, failurePage, withParam } = 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';
@@ -164,8 +164,9 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -164,8 +164,9 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
r.get('/login', (req, res) => {
r.get('/login', (req, res) => {
const next = sanitizeNext(req.query.next, config);
const next = sanitizeNext(req.query.next, config);
- const started = sdkSso.authorizeUrl();
- res.cookie(FLOW_COOKIE, JSON.stringify({ state: started.state, verifier: started.verifier, next }), flowCookie());
+ const silent = !!req.query.silent && req.query.silent !== '0';
+ const started = sdkSso.authorizeUrl({ silent });
+ res.cookie(FLOW_COOKIE, JSON.stringify({ state: started.state, verifier: started.verifier, next, silent }), 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);
});
});
@@ -179,6 +180,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
@@ -179,6 +180,7 @@ function createSso({ config, keys, sessions, fetchImpl = globalThis.fetch, now =
if (req.query.error) {
if (req.query.error) {
// An explicit answer from the Network (access_denied, login_required, …) is not retried by itself.
// An explicit answer from the Network (access_denied, login_required, …) is not retried by itself.
res.clearCookie(RETRY_COOKIE, { ...cookieBase(), path: '/auth' });
res.clearCookie(RETRY_COOKIE, { ...cookieBase(), path: '/auth' });
+ if (flow?.silent && sameString(req.query.state, flow.state)) return res.redirect(withParam(next, 'sso', 'none'));
log.warn(`[sso] sign-in not completed: ${String(req.query.error).slice(0, 120)}`);
log.warn(`[sso] sign-in not completed: ${String(req.query.error).slice(0, 120)}`);
return res.status(400).type('html').send(failurePage({
return res.status(400).type('html').send(failurePage({
siteName: 'OpenVibe.Watch',
siteName: 'OpenVibe.Watch',
@@ -57,6 +57,7 @@ function renderPage(o) {
@@ -57,6 +57,7 @@ function renderPage(o) {
appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Watches', href: '/watches', icon: 'fa-bell' }, { label: 'How it checks', href: '/how-it-works', icon: 'fa-circle-info' }], // phones: the bottom tab bar (Shared 4.20)
appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Watches', href: '/watches', icon: 'fa-bell' }, { label: 'How it checks', href: '/how-it-works', icon: 'fa-circle-info' }], // phones: the bottom tab bar (Shared 4.20)
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
+ silentLogin: '/auth/login?silent=1&next={url}',
loginUrl: '/auth/login?next={path}',
loginUrl: '/auth/login?next={path}',
logoutUrl: '/auth/logout?next={path}',
logoutUrl: '/auth/logout?next={path}',
notificationsRealtime: true,
notificationsRealtime: true,
@@ -65,6 +65,22 @@ t('the signed-out pages say what Watch is and ask for a sign-in', async () => {
@@ -65,6 +65,22 @@ t('the signed-out pages say what Watch is and ask for a sign-in', async () => {
assert.doesNotMatch(how.text, /Not built yet/);
assert.doesNotMatch(how.text, /Not built yet/);
});
});
+t('silent first visit: prompt=none and a guest returns without a login form', async () => {
+ const started = await svc.get('/auth/login?silent=1&next=%2Fwatches%3Fview%3Dall%23latest');
+ const u = new URL(started.location);
+ assert.strictEqual(u.searchParams.get('prompt'), 'none');
+ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
+ const flow = started.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
+ const guest = await svc.get('/auth/callback?error=login_required&state='+u.searchParams.get('state'), { cookie: flow });
+ assert.strictEqual(guest.status, 302);
+ assert.strictEqual(guest.location, '/watches?view=all&sso=none#latest');
+ assert.ok(!guest.headers.getSetCookie().some(c => c.startsWith('watch_session=')));
+ const badState = await svc.get('/auth/callback?error=login_required&state=wrong', { cookie: flow });
+ assert.strictEqual(badState.status, 400);
+ const html = require('../server/render/layout').renderPage({ config: svc.config, body: '' });
+ assert.ok(html.includes('/auth/login?silent=1&next={url}'));
+});
+
t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () => {
t('sign-in with the stand-in Network: PKCE, a session, and /auth/me', async () => {
const who = svc.network.addUser('ada'); // same username, distinct subject for this flow test
const who = svc.network.addUser('ada'); // same username, distinct subject for this flow test
who.avatar_url = 'https://openvibe.media/o/med_01JABCDEFGHJKMNPQRSTVWXYZ0';
who.avatar_url = 'https://openvibe.media/o/med_01JABCDEFGHJKMNPQRSTVWXYZ0';