openvibers/openvibe.billing Commits

Route signed-in nonstaff Billing arrivals to Wallet (#272)

OP OpenVibers committed 2 days ago · 751c2af65c · 3 files, +35 −4

Changes

ddiff --git a/server/console/index.js b/server/console/index.js
iindex 68f27de..26373d7 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -38,7 +38,7 @@ const admin = require('../ops/admin');
const providers = require('../providers');
const { reconcile } = require('../reconcile');
const { CAP } = require('../api/v1');
--const { createSessions, sameString, random } = require('./session');
++const { createSessions, parseCookies, sameString, random } = require('./session');
const sso = require('./sso');
const audit = require('./audit');
const q = require('./queries');
@@@ -191,6 +191,7 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
r.get('/auth/callback', wrap(async (req, res) => {
const flow = sessions.takeFlow(req, res);
++ if (req.query.error && flow && flow.q === 1) return res.redirect(303, '/?sso=none');
if (req.query.error) return send(res, 400, pages.signIn({ message: `Sign-in was not completed (${String(req.query.error).slice(0, 60)}).` }));
if (!flow || !req.query.code || !sameString(req.query.state, flow.s)) {
return send(res, 400, pages.signIn({ message: 'The sign-in could not be verified (expired or mismatched state). Please try again.' }));
@@@ -211,12 +212,14 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
reason: !who.subject ? 'token carries no subject' : !who.money ? `the token does not hold ${cc.staffCapability} (role ${who.role || 'none'})` : 'not listed in BILLING_STAFF_SUBJECTS',
requestId: requestId(req), ipHash: ipHash(req),
});
++ if (flow.q === 1) return res.redirect(303, 'https://openvibe.network/wallet');
return send(res, 403, pages.message({ title: 'Not authorized', text: 'This console is only for OpenVibe.Network admins who are Billing staff. Your sign-in was recorded.' }));
}
await money(db, async () => {
await sessions.create(res, { subject: who.subject, username: who.username, role: who.role, ip: req.ip });
await audit.record(ctx, { actor: { subject: who.subject, username: who.username }, action: 'session.sign_in', requestId: requestId(req), ipHash: ipHash(req), traceId: trace(req) });
});
++ res.clearCookie('ovb_sso_hint', { path: '/', sameSite: 'Lax', secure: cc.cookieSecure, httpOnly: true });
return res.redirect(303, flow.n || '/');
}));
@@@ -226,12 +229,21 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
await record(req, { action: 'session.sign_out' });
});
sessions.clear(res);
++ res.cookie('ovb_sso_hint', 'guest', { path: '/', sameSite: 'Lax', secure: cc.cookieSecure, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
res.redirect(303, '/');
});
// ── Dashboard ────────────────────────────────────────────
r.get('/', async (req, res) => {
-- if (!req.staff) return send(res, 200, pages.signIn({}));
++ if (!req.staff) {
++ const browserArrival = /\btext\/html\b/.test(String(req.get('accept') || ''));
++ if (browserArrival && req.query.sso !== 'none' && parseCookies(req).ovb_sso_hint !== 'guest') {
++ const started = consoleSso.start({ silent: true });
++ sessions.setFlow(res, { s: started.state, v: started.verifier, n: '/', q: 1 });
++ return res.redirect(302, started.url);
++ }
++ return send(res, 200, pages.signIn({}));
++ }
return send(res, 200, pages.dashboard({
...await common(req), freeze: await admin.freezeState(db), last: await q.lastReconciliation(db), totals: await q.outstanding(db),
counts: await q.counts(db, ctx.now()), rates: ctx.rates,
ddiff --git a/server/console/sso.js b/server/console/sso.js
iindex 60c8c91..dbc0a8e 100644
---- a/server/console/sso.js
++++ b/server/console/sso.js
@@@ -45,8 +45,8 @@ function createConsoleSso({ ctx, keys, fetchImpl = globalThis.fetch }) {
});
/** The authorize URL and the state/verifier the flow cookie must keep. → { url, state, verifier } */
-- function start() {
-- return client.authorizeUrl();
++ function start({ silent = false } = {}) {
++ return client.authorizeUrl({ silent });
}
/** Redeem the code with the SDK, verify the person's token, revoke the refresh token Billing will not use. */
ddiff --git a/test/console.test.js b/test/console.test.js
iindex 88b9503..f0878ba 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -143,6 +143,25 @@ const DAY = 86_400_000;
assert.strictEqual((await staffEvents('session.sign_in')).length, 0, 'refusals are not published');
});
++ await check('browser arrival silently checks Network, sending a verified non-staff account to its Wallet', async () => {
++ const nav = { Accept: 'text/html,application/xhtml+xml', 'Sec-Fetch-Mode': 'navigate' };
++ const arrival = await get('/', null, nav);
++ assert.strictEqual(arrival.status, 302);
++ const loc = new URL(arrival.headers.get('location'));
++ assert.strictEqual(loc.searchParams.get('prompt'), 'none');
++ const flow = arrival.cookies.find((c) => c.startsWith('ovb_flow=')).split(';')[0];
++ const code = network.authorize({ subject_id: listedNonAdmin, role: 'user', username: 'visitor', challenge: loc.searchParams.get('code_challenge'), redirect_uri: loc.searchParams.get('redirect_uri') });
++ const callback = await get(`/auth/callback?code=${code}&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow);
++ assert.strictEqual(callback.status, 303);
++ assert.strictEqual(callback.headers.get('location'), 'https://openvibe.network/wallet');
++ assert.ok(!callback.cookies.some((c) => c.startsWith('__Host-ovb_staff=') && !c.startsWith('__Host-ovb_staff=;')));
++
++ const noNetwork = await get(`/auth/callback?error=login_required&state=${encodeURIComponent(loc.searchParams.get('state'))}`, flow);
++ assert.strictEqual(noNetwork.headers.get('location'), '/?sso=none');
++ assert.strictEqual((await get('/?sso=none', null, nav)).status, 200, 'a guest has one bounded silent attempt');
++ assert.strictEqual((await get('/', 'ovb_sso_hint=guest', nav)).status, 200, 'explicit guest hint skips Network');
++ });
++
await check('staff sign in: host-only, HttpOnly, Secure, SameSite=Strict, short-lived session cookie', async () => {
const r = await signIn(staff, { username: 'goosely' });
assert.strictEqual(r.cb.status, 303);