openvibers/openrestream Commits
Enable silent first-visit sign-in on OpenRestream (#236)
Changes
@@@ -13,7 +13,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 ACCESS = 'ov_token';const REFRESH = 'ov_refresh';@@@ -72,7 +72,9 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {}router.get('/login', (req, res) => {-- const started = sdkSso.authorizeUrl();++ const silent = !!req.query.silent && req.query.silent !== '0';++ const started = sdkSso.authorizeUrl({ silent });++ if (silent) res.cookie('ov_oauth_silent', '1', flow); else res.clearCookie('ov_oauth_silent', { path: '/auth' });res.cookie(STATE, started.state, flow);res.cookie(VERIFIER, started.verifier, flow);res.cookie(NEXT, sanitizeNext(req.query.next, config), flow);@@@ -87,6 +89,9 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {res.clearCookie(STATE, { path: '/auth' });res.clearCookie(VERIFIER, { path: '/auth' });res.clearCookie(NEXT, { path: '/auth' });++ const silent = req.cookies && req.cookies.ov_oauth_silent === '1';++ res.clearCookie('ov_oauth_silent', { path: '/auth' });++ if (error && silent && sameString(state, expected)) return res.redirect(withParam(next, 'sso', 'none'));if (error) return res.redirect(`/?auth_error=${encodeURIComponent(String(error))}`);const retryHref = `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`;const failed = (status, title, text) => res.status(status).type('html').send(failurePage({@@@ -147,6 +147,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,{ label: 'Sessions', href: '/sessions', icon: 'fa-clock-rotate-left' },],sessionUrl: '/auth/me',++ silentLogin: '/auth/login?silent=1&next={url}',loginUrl: `/auth/login?next=${encodeURIComponent(canonicalPath)}`,logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session toonotificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++const assert = require('node:assert/strict');++const express = require('express');++const { createSsoRoutes } = require('../server/auth/sso');++(async () => {++ const app = express(); app.use(require('cookie-parser')());++ app.use('/auth', createSsoRoutes({ config: {++ baseUrl: 'https://openre.stream', networkUrl: 'https://network.test', networkInternalUrl: 'https://network.test',++ oauth: { clientId: 'openre', clientSecret: 'test-secret', redirectUri: 'https://openre.stream/auth/callback', scope: 'profile' }, cookies: { secure: true },++ }, auth: { verifyUser: async () => null }, fetchImpl: () => { throw Error('no token exchange for a guest'); } }));++ const server = app.listen(0); await new Promise(resolve => server.once('listening', resolve));++ const get = (path, cookie) => fetch(`http://127.0.0.1:${server.address().port}${path}`, { redirect: 'manual', headers: cookie ? { Cookie: cookie } : {} });++ try {++ const start = await get('/auth/login?silent=1&next=%2Fsessions%3Ftab%3Dmine%23latest');++ const u = new URL(start.headers.get('location'));++ assert.equal(u.searchParams.get('prompt'), 'none');++ assert.equal(u.searchParams.get('code_challenge_method'), 'S256');++ const cookie = start.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');++ const guest = await get('/auth/callback?error=login_required&state='+u.searchParams.get('state'), cookie);++ assert.equal(guest.status, 302);++ assert.equal(guest.headers.get('location'), '/sessions?tab=mine&sso=none#latest');++ assert.ok(!guest.headers.getSetCookie().some(c => c.startsWith('ov_token=')));++ const bad = await get('/auth/callback?code=unissued&state=wrong', cookie);++ assert.equal(bad.status, 400);++ const normal = await get('/auth/login?silent=0');++ assert.equal(new URL(normal.headers.get('location')).searchParams.get('prompt'), null);++ assert.ok(normal.headers.getSetCookie().some(c => c.startsWith('ov_oauth_silent=;')));++ console.log('silent signin: prompt, PKCE, guest return and state checks passed');++ } finally { await new Promise(resolve => server.close(resolve)); }++})().catch(err => { console.error(err); process.exitCode = 1; });
@@ -13,7 +13,7 @@
@@ -13,7 +13,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 ACCESS = 'ov_token';
const ACCESS = 'ov_token';
const REFRESH = 'ov_refresh';
const REFRESH = 'ov_refresh';
@@ -72,7 +72,9 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
@@ -72,7 +72,9 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
}
}
router.get('/login', (req, res) => {
router.get('/login', (req, res) => {
- const started = sdkSso.authorizeUrl();
+ const silent = !!req.query.silent && req.query.silent !== '0';
+ const started = sdkSso.authorizeUrl({ silent });
+ if (silent) res.cookie('ov_oauth_silent', '1', flow); else res.clearCookie('ov_oauth_silent', { path: '/auth' });
res.cookie(STATE, started.state, flow);
res.cookie(STATE, started.state, flow);
res.cookie(VERIFIER, started.verifier, flow);
res.cookie(VERIFIER, started.verifier, flow);
res.cookie(NEXT, sanitizeNext(req.query.next, config), flow);
res.cookie(NEXT, sanitizeNext(req.query.next, config), flow);
@@ -87,6 +89,9 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
@@ -87,6 +89,9 @@ function createSsoRoutes({ config, auth, fetchImpl = globalThis.fetch }) {
res.clearCookie(STATE, { path: '/auth' });
res.clearCookie(STATE, { path: '/auth' });
res.clearCookie(VERIFIER, { path: '/auth' });
res.clearCookie(VERIFIER, { path: '/auth' });
res.clearCookie(NEXT, { path: '/auth' });
res.clearCookie(NEXT, { path: '/auth' });
+ const silent = req.cookies && req.cookies.ov_oauth_silent === '1';
+ res.clearCookie('ov_oauth_silent', { path: '/auth' });
+ if (error && silent && sameString(state, expected)) return res.redirect(withParam(next, 'sso', 'none'));
if (error) return res.redirect(`/?auth_error=${encodeURIComponent(String(error))}`);
if (error) return res.redirect(`/?auth_error=${encodeURIComponent(String(error))}`);
const retryHref = `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`;
const retryHref = `/auth/login${next !== '/' ? `?next=${encodeURIComponent(next)}` : ''}`;
const failed = (status, title, text) => res.status(status).type('html').send(failurePage({
const failed = (status, title, text) => res.status(status).type('html').send(failurePage({
@@ -147,6 +147,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
@@ -147,6 +147,7 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
{ label: 'Sessions', href: '/sessions', icon: 'fa-clock-rotate-left' },
{ label: 'Sessions', href: '/sessions', icon: 'fa-clock-rotate-left' },
],
],
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
+ silentLogin: '/auth/login?silent=1&next={url}',
loginUrl: `/auth/login?next=${encodeURIComponent(canonicalPath)}`,
loginUrl: `/auth/login?next=${encodeURIComponent(canonicalPath)}`,
logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+const assert = require('node:assert/strict');
+const express = require('express');
+const { createSsoRoutes } = require('../server/auth/sso');
+(async () => {
+ const app = express(); app.use(require('cookie-parser')());
+ app.use('/auth', createSsoRoutes({ config: {
+ baseUrl: 'https://openre.stream', networkUrl: 'https://network.test', networkInternalUrl: 'https://network.test',
+ oauth: { clientId: 'openre', clientSecret: 'test-secret', redirectUri: 'https://openre.stream/auth/callback', scope: 'profile' }, cookies: { secure: true },
+ }, auth: { verifyUser: async () => null }, fetchImpl: () => { throw Error('no token exchange for a guest'); } }));
+ const server = app.listen(0); await new Promise(resolve => server.once('listening', resolve));
+ const get = (path, cookie) => fetch(`http://127.0.0.1:${server.address().port}${path}`, { redirect: 'manual', headers: cookie ? { Cookie: cookie } : {} });
+ try {
+ const start = await get('/auth/login?silent=1&next=%2Fsessions%3Ftab%3Dmine%23latest');
+ const u = new URL(start.headers.get('location'));
+ assert.equal(u.searchParams.get('prompt'), 'none');
+ assert.equal(u.searchParams.get('code_challenge_method'), 'S256');
+ const cookie = start.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
+ const guest = await get('/auth/callback?error=login_required&state='+u.searchParams.get('state'), cookie);
+ assert.equal(guest.status, 302);
+ assert.equal(guest.headers.get('location'), '/sessions?tab=mine&sso=none#latest');
+ assert.ok(!guest.headers.getSetCookie().some(c => c.startsWith('ov_token=')));
+ const bad = await get('/auth/callback?code=unissued&state=wrong', cookie);
+ assert.equal(bad.status, 400);
+ const normal = await get('/auth/login?silent=0');
+ assert.equal(new URL(normal.headers.get('location')).searchParams.get('prompt'), null);
+ assert.ok(normal.headers.getSetCookie().some(c => c.startsWith('ov_oauth_silent=;')));
+ console.log('silent signin: prompt, PKCE, guest return and state checks passed');
+ } finally { await new Promise(resolve => server.close(resolve)); }
+})().catch(err => { console.error(err); process.exitCode = 1; });