openvibers/openvibe.run Commits

Enable silent first-visit sign-in on OpenVibe.Run (#205)

OP OpenVibers committed 5 days ago · d81a005000 · 4 files, +91 −3

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 1e746ec..d2ea264 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -99,6 +99,7 @@ function createApp(opts = {}) {
app.use(http.middleware());
app.use(cookieParser());
++ app.use('/auth', require('./web-sso').webSso({ config, fetchImpl, log }));
// Liveness: the process answers. What it can and cannot do is /api/ready's job, and the counts are
// /metrics' and /api/ready's — this route says nothing it cannot back with a value it just read.
ddiff --git a/server/web-sso.js b/server/web-sso.js
nnew file mode 100644
iindex 0000000..4daf995
---- /dev/null
++++ b/server/web-sso.js
@@@ -0,0 +1,19 @@
++'use strict';
++// Browser sessions use the shared SDK. API/service-token authorization remains in its own middleware.
++const express = require('express');
++const { createSsoClient } = require('openvibe-sdk/sso');
++function webSso({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const client = createSsoClient({
++ site: 'run', siteName: 'OpenVibe.Run', baseUrl: config.baseUrl,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ redirectUri: `${config.baseUrl}/auth/callback`, scope: 'profile theme',
++ networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,
++ issuer: config.network.issuer, publicKey: config.network.publicKey, audience: ['openvibe.network'],
++ secureCookies: config.isProduction, httpOnlyAccess: true, fetch: fetchImpl, log,
++ });
++ const router = express.Router();
++ router.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ router.use(client.router(express));
++ return router;
++}
++module.exports = { webSso };
ddiff --git a/server/web/home.js b/server/web/home.js
iindex fa2b34e..0428885 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -72,7 +72,7 @@ const SUBMIT_EXAMPLE = `curl -X POST https://openvibe.run/api/v1/jobs \\
function renderHome({ config }) {
const nav = {
service: 'run',
-- apiBase: NETWORK_URL,
++ apiBase: NETWORK_URL, sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={url}', silentLogin: '/auth/login?silent=1&next={url}',
links: [{ label: 'Your machine', href: '#machine' }, { label: 'How it works', href: '#how' }, { label: 'API', href: '#api' }],
history: { type: 'page', title: SITE_NAME },
};
@@@ -105,7 +105,7 @@ function renderUpdates({ config }) {
siteName: SITE_NAME, description: `Every change deployed to ${SITE_NAME}, newest first.`,
canonical: `${config.baseUrl}/updates`, robots: 'index, follow',
agentLayer: true, llms: `${config.baseUrl}/llms.txt`,
-- navbar: { service: 'run', apiBase: NETWORK_URL, links, history: { type: 'page', title: SITE_NAME } },
++ navbar: { service: 'run', apiBase: NETWORK_URL, sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={url}', silentLogin: '/auth/login?silent=1&next={url}', links, history: { type: 'page', title: SITE_NAME } },
footer: FOOTER, home: '/', navLinks: links,
head: [appIcon.headTags({ site: 'run' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('run', 192)}">`, UPDATES_FONT].join('\n'),
body: `<div id="navbar-mount"></div>\n<main id="main" class="page">\n${frame.updatesBody({ service: 'run', siteName: SITE_NAME })}\n</main>\n<script src="${ovServe.url('shipped.js')}" defer></script>`,
@@@ -121,7 +121,7 @@ function renderNotFound({ config }) {
title: `Page not found · ${SITE_NAME}`,
siteName: SITE_NAME, description: `There is no page at this address on ${host}.`,
robots: 'noindex, follow',
-- navbar: { service: 'run', apiBase: NETWORK_URL, links, history: { type: 'page', title: SITE_NAME } },
++ navbar: { service: 'run', apiBase: NETWORK_URL, sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={url}', silentLogin: '/auth/login?silent=1&next={url}', links, history: { type: 'page', title: SITE_NAME } },
footer: FOOTER, home: '/', navLinks: links,
head: [appIcon.headTags({ site: 'run' }), '<link rel="manifest" href="/manifest.webmanifest">',
`<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('run', 192)}">`,
ddiff --git a/test/web-sso.test.js b/test/web-sso.test.js
nnew file mode 100644
iindex 0000000..fa29805
---- /dev/null
++++ b/test/web-sso.test.js
@@@ -0,0 +1,68 @@
++'use strict';
++const assert = require('node:assert/strict');
++const express = require('express');
++const { webSso } = require('../server/web-sso');
++const config = require('../server/config').loadConfig({ NODE_ENV: 'production', BASE_URL: 'https://openvibe.run', OV_OAUTH_CLIENT_ID: 'run', OV_OAUTH_CLIENT_SECRET: 'test-only-secret' });
++(async () => {
++ const grants = [];
++ const crypto = require('node:crypto');
++ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'sso-test', alg: 'RS256', use: 'sig' };
++ let challenge;
++ const app = express();
++ app.use('/auth', webSso({ config, log: { warn() {}, error() {} }, fetchImpl: async (_url, options) => {
++ if (_url.endsWith('/api/.well-known/jwks')) return new Response(JSON.stringify({ keys: [jwk] }));
++ const grant = JSON.parse(options.body);
++ grants.push(grant);
++ if (grant.code === 'issued') {
++ assert.equal(crypto.createHash('sha256').update(grant.code_verifier).digest('base64url'), challenge);
++ const token = require('jsonwebtoken').sign({ sub: '12', type: 'person', username: 'sso-test' }, privateKey,
++ { algorithm: 'RS256', keyid: jwk.kid, issuer: 'https://openvibe.network', audience: 'openvibe.network', expiresIn: '1h' });
++ return new Response(JSON.stringify({ access_token: token, refresh_token: 'test-refresh' }));
++ }
++ return new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 });
++ } }));
++ const server = app.listen(0, '127.0.0.1');
++ 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 } : {} });
++ const cookies = r => r.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
++ try {
++ const start = await get('/auth/login?silent=1&next=%2F%3Ffrom%3Dnav%23details');
++ assert.equal(start.status, 302);
++ const u = new URL(start.headers.get('location'));
++ assert.equal(u.origin, 'https://openvibe.network');
++ assert.equal(u.searchParams.get('client_id'), 'run');
++ assert.equal(u.searchParams.get('redirect_uri'), 'https://openvibe.run/auth/callback');
++ assert.equal(u.searchParams.get('prompt'), 'none');
++ assert.equal(u.searchParams.get('code_challenge_method'), 'S256');
++ assert.ok(u.searchParams.get('code_challenge'));
++ assert.ok(start.headers.getSetCookie().every(c => !/Domain=/i.test(c)), 'flow cookies are host-only');
++ const guest = await get('/auth/callback?error=login_required&state='+u.searchParams.get('state'), cookies(start));
++ assert.equal(guest.status, 302);
++ assert.equal(guest.headers.get('location'), '/?from=nav&sso=none#details');
++ assert.ok(!cookies(guest).includes('ov_token='));
++ const mismatch = await get('/auth/callback?code=unissued&state=wrong', cookies(start));
++ assert.ok(!cookies(mismatch).includes('ov_token='));
++ assert.equal(grants.length, 0, 'a mismatched state never reaches the token endpoint');
++ const me = await get('/auth/me');
++ assert.deepEqual(await me.json(), { user: null });
++ assert.equal(me.headers.get('cache-control'), 'no-store');
++ const health = await get('/auth/health');
++ assert.deepEqual(await health.json(), { ok: true, client: 'ok' });
++ assert.equal(grants[0].client_id, 'run');
++ const external = await get('/auth/login?silent=1&next=https%3A%2F%2Fevil.test');
++ const back = await get('/auth/callback?error=login_required', cookies(external));
++ assert.equal(back.headers.get('location'), '/?sso=none');
++ challenge = u.searchParams.get('code_challenge');
++ const signedIn = await get('/auth/callback?code=issued&state='+u.searchParams.get('state'), cookies(start));
++ assert.equal(signedIn.status, 302);
++ assert.equal(signedIn.headers.get('location'), '/?from=nav#details');
++ const access = signedIn.headers.getSetCookie().find(c => c.startsWith('ov_token='));
++ assert.ok(access && /HttpOnly/i.test(access) && /Secure/i.test(access) && /SameSite=Lax/i.test(access));
++ assert.ok(!/Domain=/i.test(access));
++ const person = await get('/auth/me', cookies(signedIn));
++ assert.equal((await person.json()).user.username, 'sso-test');
++
++ console.log('web SSO: SDK silent login, PKCE, state, guest return, host-only cookies and no-store passed');
++ } finally { await new Promise(resolve => server.close(resolve)); }
++})().catch(err => { console.error(err); process.exitCode = 1; });