openvibers/openvibe.events Commits
Enable silent first-visit sign-in on OpenVibe.Events (#265)
Changes
@@@ -213,6 +213,20 @@ server {proxy_set_header CF-Connecting-IP $remote_addr;}++ # Browser OAuth also needs POST for refresh and logout; the public-page fallback is GET-only.++ location ^~ /auth/ {++ limit_except GET POST { deny all; }++ limit_req zone=ovevents_apex_api burst=20 nodelay;++ limit_req_status 429;++ client_max_body_size 16k;++ proxy_pass http://127.0.0.1:4300;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++# ── Everything else: the app answers GET/HEAD (its /favicon.ico, and its own 404 page for browsers or a# problem+json for API clients, both with lang and the Frame); other methods are refused here ──location / {@@@ -26,6 +26,7 @@ const pkg = require('../package.json');function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, clock, log = console, valkey = null, policies = null, planner = null, carriers = null, indexnow = null, accountData = null, accountSend = null }) {const app = express();++ app.use('/auth', require('./web-sso').webSso({ config, log }));app.disable('x-powered-by');app.set('trust proxy', 'loopback');const release = createRelease({ service: 'events', root: path.join(__dirname, '..') });@@@ -430,7 +430,7 @@ function renderUpdates({ siteUrl }) {agentLayer: true,llms: '/llms.txt',robots: 'index, follow',-- navbar: { service: 'events', apiBase: NETWORK_URL, links, history: { type: 'page', title: SITE_NAME } },++ navbar: { service: 'events', 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: 'events' }), UPDATES_FONT].join('\n'),body: `<div id="navbar-mount"></div>@@@ -444,7 +444,7 @@ ${frame.updatesBody({ service: 'events', siteName: SITE_NAME })}function renderHome({ siteUrl, apiUrl, stats = null }) {const nav = {service: 'events',-- 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: 'Guide', href: `${SOURCE}#readme` }],history: { type: 'page', title: SITE_NAME },};@@@ -495,7 +495,7 @@ function renderNotFound({ siteUrl = 'https://openvibe.events' } = {}) {siteName: SITE_NAME,description: `There is no page at this address on ${host}.`,robots: 'noindex, follow',-- navbar: { service: 'events', apiBase: NETWORK_URL, links, history: { type: 'page', title: SITE_NAME } },++ navbar: { service: 'events', 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: 'events' }),nnew file mode 100644@@@ -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: 'events', siteName: 'OpenVibe.Events', baseUrl: config.baseUrl,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ redirectUri: `${config.baseUrl}/auth/callback`, scope: 'profile theme',++ networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl,++ issuer: config.issuer, publicKey: config.networkPublicKey, 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 };nnew file mode 100644@@@ -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').load({ NODE_ENV: 'production', BASE_URL: 'https://openvibe.events', OV_OAUTH_CLIENT_ID: 'events', 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'), 'events');++ assert.equal(u.searchParams.get('redirect_uri'), 'https://openvibe.events/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, 'events');++ 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; });
@@ -213,6 +213,20 @@ server {
@@ -213,6 +213,20 @@ server {
proxy_set_header CF-Connecting-IP $remote_addr;
proxy_set_header CF-Connecting-IP $remote_addr;
}
}
+ # Browser OAuth also needs POST for refresh and logout; the public-page fallback is GET-only.
+ location ^~ /auth/ {
+ limit_except GET POST { deny all; }
+ limit_req zone=ovevents_apex_api burst=20 nodelay;
+ limit_req_status 429;
+ client_max_body_size 16k;
+ proxy_pass http://127.0.0.1:4300;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
# ── Everything else: the app answers GET/HEAD (its /favicon.ico, and its own 404 page for browsers or a
# ── Everything else: the app answers GET/HEAD (its /favicon.ico, and its own 404 page for browsers or a
# problem+json for API clients, both with lang and the Frame); other methods are refused here ──
# problem+json for API clients, both with lang and the Frame); other methods are refused here ──
location / {
location / {
@@ -26,6 +26,7 @@ const pkg = require('../package.json');
@@ -26,6 +26,7 @@ const pkg = require('../package.json');
function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, clock, log = console, valkey = null, policies = null, planner = null, carriers = null, indexnow = null, accountData = null, accountSend = null }) {
function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLookup, clock, log = console, valkey = null, policies = null, planner = null, carriers = null, indexnow = null, accountData = null, accountSend = null }) {
const app = express();
const app = express();
+ app.use('/auth', require('./web-sso').webSso({ config, log }));
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
const release = createRelease({ service: 'events', root: path.join(__dirname, '..') });
const release = createRelease({ service: 'events', root: path.join(__dirname, '..') });
@@ -430,7 +430,7 @@ function renderUpdates({ siteUrl }) {
@@ -430,7 +430,7 @@ function renderUpdates({ siteUrl }) {
agentLayer: true,
agentLayer: true,
llms: '/llms.txt',
llms: '/llms.txt',
robots: 'index, follow',
robots: 'index, follow',
- navbar: { service: 'events', apiBase: NETWORK_URL, links, history: { type: 'page', title: SITE_NAME } },
+ navbar: { service: 'events', 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,
footer: FOOTER, home: '/', navLinks: links,
head: [appIcon.headTags({ site: 'events' }), UPDATES_FONT].join('\n'),
head: [appIcon.headTags({ site: 'events' }), UPDATES_FONT].join('\n'),
body: `<div id="navbar-mount"></div>
body: `<div id="navbar-mount"></div>
@@ -444,7 +444,7 @@ ${frame.updatesBody({ service: 'events', siteName: SITE_NAME })}
@@ -444,7 +444,7 @@ ${frame.updatesBody({ service: 'events', siteName: SITE_NAME })}
function renderHome({ siteUrl, apiUrl, stats = null }) {
function renderHome({ siteUrl, apiUrl, stats = null }) {
const nav = {
const nav = {
service: 'events',
service: 'events',
- 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: 'Guide', href: `${SOURCE}#readme` }],
links: [{ label: 'Guide', href: `${SOURCE}#readme` }],
history: { type: 'page', title: SITE_NAME },
history: { type: 'page', title: SITE_NAME },
};
};
@@ -495,7 +495,7 @@ function renderNotFound({ siteUrl = 'https://openvibe.events' } = {}) {
@@ -495,7 +495,7 @@ function renderNotFound({ siteUrl = 'https://openvibe.events' } = {}) {
siteName: SITE_NAME,
siteName: SITE_NAME,
description: `There is no page at this address on ${host}.`,
description: `There is no page at this address on ${host}.`,
robots: 'noindex, follow',
robots: 'noindex, follow',
- navbar: { service: 'events', apiBase: NETWORK_URL, links, history: { type: 'page', title: SITE_NAME } },
+ navbar: { service: 'events', 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,
footer: FOOTER, home: '/', navLinks: links,
head: [
head: [
appIcon.headTags({ site: 'events' }),
appIcon.headTags({ site: 'events' }),
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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: 'events', siteName: 'OpenVibe.Events', baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ redirectUri: `${config.baseUrl}/auth/callback`, scope: 'profile theme',
+ networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl,
+ issuer: config.issuer, publicKey: config.networkPublicKey, 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -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').load({ NODE_ENV: 'production', BASE_URL: 'https://openvibe.events', OV_OAUTH_CLIENT_ID: 'events', 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'), 'events');
+ assert.equal(u.searchParams.get('redirect_uri'), 'https://openvibe.events/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, 'events');
+ 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; });