openvibers/openvibe.billing Commits
Route signed-in nonstaff Billing arrivals to Wallet (#272)
Changes
@@@ -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,@@@ -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. */@@@ -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);
@@ -38,7 +38,7 @@ const admin = require('../ops/admin');
@@ -38,7 +38,7 @@ const admin = require('../ops/admin');
const providers = require('../providers');
const providers = require('../providers');
const { reconcile } = require('../reconcile');
const { reconcile } = require('../reconcile');
const { CAP } = require('../api/v1');
const { CAP } = require('../api/v1');
-const { createSessions, sameString, random } = require('./session');
+const { createSessions, parseCookies, sameString, random } = require('./session');
const sso = require('./sso');
const sso = require('./sso');
const audit = require('./audit');
const audit = require('./audit');
const q = require('./queries');
const q = require('./queries');
@@ -191,6 +191,7 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -191,6 +191,7 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
r.get('/auth/callback', wrap(async (req, res) => {
r.get('/auth/callback', wrap(async (req, res) => {
const flow = sessions.takeFlow(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 (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)) {
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.' }));
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 }) {
@@ -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',
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),
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.' }));
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 money(db, async () => {
await sessions.create(res, { subject: who.subject, username: who.username, role: who.role, ip: req.ip });
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) });
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 || '/');
return res.redirect(303, flow.n || '/');
}));
}));
@@ -226,12 +229,21 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -226,12 +229,21 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
await record(req, { action: 'session.sign_out' });
await record(req, { action: 'session.sign_out' });
});
});
sessions.clear(res);
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, '/');
res.redirect(303, '/');
});
});
// ── Dashboard ────────────────────────────────────────────
// ── Dashboard ────────────────────────────────────────────
r.get('/', async (req, res) => {
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({
return send(res, 200, pages.dashboard({
...await common(req), freeze: await admin.freezeState(db), last: await q.lastReconciliation(db), totals: await q.outstanding(db),
...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,
counts: await q.counts(db, ctx.now()), rates: ctx.rates,
@@ -45,8 +45,8 @@ function createConsoleSso({ ctx, keys, fetchImpl = globalThis.fetch }) {
@@ -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 } */
/** 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. */
/** Redeem the code with the SDK, verify the person's token, revoke the refresh token Billing will not use. */
@@ -143,6 +143,25 @@ const DAY = 86_400_000;
@@ -143,6 +143,25 @@ const DAY = 86_400_000;
assert.strictEqual((await staffEvents('session.sign_in')).length, 0, 'refusals are not published');
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 () => {
await check('staff sign in: host-only, HttpOnly, Secure, SameSite=Strict, short-lived session cookie', async () => {
const r = await signIn(staff, { username: 'goosely' });
const r = await signIn(staff, { username: 'goosely' });
assert.strictEqual(r.cb.status, 303);
assert.strictEqual(r.cb.status, 303);