openvibers/openvibe.billing Commits

Staff console sign-in through openvibe-sdk/sso (#194)

OP OpenVibers committed 8 days ago · 194f7574cb · 4 files, +113 −77

The Billing console's OAuth handshake (authorize URL, PKCE state, code exchange)
now comes from createSsoClient in openvibe-sdk/sso instead of the hand-kept copy
in server/console/sso.js. The short staff session, its CSRF token, the audit
rows and the staff list / staff.money.cashouts checks are unchanged:
session.js and the permission paths in index.js are not touched.

The Network token is still verified with openvibe-sdk/auth verifyUserToken with
the console's own clock, keys and audience (a skewed clock behaves as before),
and Billing still revokes the refresh token at once.

GET /auth/health answers { ok, client } (503 only when the Network refuses the
billing client), so the hourly canary can cover the console.

Tests: node test/console.test.js (all passed) and ov test (1 passed, 34
unchanged green). The test Network accepts the SDK's JSON token grant as the
real Network does, as well as the old form post.

Changes

ddiff --git a/server/console/index.js b/server/console/index.js
iindex caca117..68f27de 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -102,6 +102,8 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
if (!cc.staffSubjects.length && config.nodeEnv !== 'test') log.warn('[Billing] console: BILLING_STAFF_SUBJECTS is empty — nobody can sign in');
if (unavailable) log.warn(`[Billing] console disabled: ${unavailable}`);
const sessions = createSessions(ctx, { secret: secret || 'unavailable' });
++ // openvibe-sdk/sso owns the OAuth handshake and /auth/health; the staff session below stays this site's.
++ const consoleSso = sso.createConsoleSso({ ctx, keys, fetchImpl });
const send = (res, status, htmlText) => res.status(status).type('html').send(htmlText);
const ipHash = (req) => sessions.ipHash(req.ip);
@@@ -175,12 +177,18 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
// ── Sign-in ──────────────────────────────────────────────
r.get('/auth/login', (req, res) => {
-- const state = random(32);
-- const { verifier, challenge } = sso.pkcePair();
-- sessions.setFlow(res, { s: state, v: verifier, n: sanitizeNext(req.query.next) });
-- res.redirect(302, sso.authorizeUrl(config, { state, challenge }));
++ const started = consoleSso.start();
++ sessions.setFlow(res, { s: started.state, v: started.verifier, n: sanitizeNext(req.query.next) });
++ res.redirect(302, started.url);
});
++ // GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it.
++ r.get('/auth/health', wrap(async (req, res) => {
++ const client = await consoleSso.checkClient();
++ res.setHeader('Cache-Control', 'no-store');
++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
++ }));
++
r.get('/auth/callback', wrap(async (req, res) => {
const flow = sessions.takeFlow(req, res);
if (req.query.error) return send(res, 400, pages.signIn({ message: `Sign-in was not completed (${String(req.query.error).slice(0, 60)}).` }));
@@@ -191,7 +199,7 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
try {
if (!keys.loaded()) await keys.refresh();
if (!keys.loaded()) throw new Error('the Network key is not loaded');
-- who = await sso.exchange(ctx, { code: req.query.code, verifier: flow.v, keys, fetchImpl });
++ who = await consoleSso.exchange({ code: req.query.code, verifier: flow.v });
} catch (e) {
log.warn(`[Billing] console sign-in failed: ${e.message}`);
return send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
ddiff --git a/server/console/sso.js b/server/console/sso.js
iindex 894700c..60c8c91 100644
---- a/server/console/sso.js
++++ b/server/console/sso.js
@@@ -1,17 +1,22 @@
'use strict';
const { staff: staffMap } = require('openvibe-contracts');
++const { verifyUserToken } = require('openvibe-sdk/auth');
++const { createSsoClient } = require('openvibe-sdk/sso');
/**
-- * OpenVibe.Network single sign-on for the staff console: OAuth 2 authorization code with PKCE
-- * (S256), as OAuth client `billing`.
++ * OpenVibe.Network single sign-on for the staff console: the OAuth half comes from
++ * openvibe-sdk/sso (createSsoClient), as OAuth client `billing`. The console keeps its own short
++ * staff session, CSRF token and staff list checks in session.js / index.js; this module owns only
++ * the handshake:
*
-- * authorizeUrl() https://openvibe.network/oauth/authorize?response_type=code&client_id=billing
-- * &redirect_uri=<BASE_URL>/auth/callback&scope=profile&state=…&code_challenge=…
-- * &code_challenge_method=S256
-- * exchange() POST <OV_NETWORK_INTERNAL_URL>/oauth/token (client secret + code_verifier),
-- * server to server; the access token is then verified offline with the Network's
-- * keys Billing already holds (openvibe-sdk/auth verifyUserToken: issuer, audience,
-- * expiry, never a service principal or a typed token)
++ * start() https://openvibe.network/oauth/authorize?response_type=code&client_id=billing
++ * &redirect_uri=<BASE_URL>/auth/callback&scope=profile&state=…&code_challenge=…
++ * &code_challenge_method=S256
++ * exchange() the SDK's token grant (client secret + code_verifier), server to server; the access
++ * token is then verified offline with the Network's keys Billing already holds
++ * (openvibe-sdk/auth verifyUserToken: issuer, audience, expiry, never a service
++ * principal or a typed token)
++ * checkClient() the SDK's health check: does the Network accept this client? (/auth/health)
*
* The Network's user access token carries `role` and `subject_id` (server/auth/oauth-routes.js
* issueTokenPair), minted from the users row at the moment of the code exchange, so the role read
@@@ -19,72 +24,72 @@ const { staff: staffMap } = require('openvibe-contracts');
* every request. Billing keeps none of the Network's tokens: the refresh token it is handed is
* revoked straight away (best effort).
*/
--const crypto = require('crypto');
--const { verifyUserToken } = require('openvibe-sdk/auth');
--
--function pkcePair() {
-- const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
-- const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
-- return { verifier, challenge };
--}
--
--function authorizeUrl(config, { state, challenge }) {
-- const q = new URLSearchParams({
-- response_type: 'code',
-- client_id: config.oauth.clientId,
-- redirect_uri: config.console.redirectUri,
++function createConsoleSso({ ctx, keys, fetchImpl = globalThis.fetch }) {
++ const { config } = ctx;
++ const log = ctx.log || console;
++ const client = createSsoClient({
++ site: 'billing',
++ siteName: 'OpenVibe.Billing',
++ baseUrl: config.baseUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ networkUrl: config.network.url,
++ networkInternalUrl: config.network.internalUrl,
++ issuer: config.network.issuer,
++ redirectUri: config.console.redirectUri,
scope: 'profile',
-- state,
-- code_challenge: challenge,
-- code_challenge_method: 'S256',
++ audience: config.console.ssoAudience,
++ publicKey: config.network.publicKey || undefined,
++ fetch: fetchImpl,
++ log,
});
-- return `${config.network.url}/oauth/authorize?${q.toString()}`;
--}
--async function exchange(ctx, { code, verifier, keys, fetchImpl = globalThis.fetch }) {
-- const { config } = ctx;
-- const body = new URLSearchParams({
-- grant_type: 'authorization_code',
-- code: String(code),
-- redirect_uri: config.console.redirectUri,
-- client_id: config.oauth.clientId,
-- client_secret: config.oauth.clientSecret,
-- code_verifier: verifier,
-- });
-- const res = await fetchImpl(`${config.network.internalUrl}/oauth/token`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
-- body,
-- signal: AbortSignal.timeout(10_000),
-- });
-- const data = await res.json().catch(() => null);
-- if (!res.ok || !data || !data.access_token) {
-- const err = new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`);
-- err.status = res.status;
-- throw err;
++ /** The authorize URL and the state/verifier the flow cookie must keep. → { url, state, verifier } */
++ function start() {
++ return client.authorizeUrl();
}
-- // Billing never uses the Network session again: drop the refresh token now.
-- if (data.refresh_token) {
-- fetchImpl(`${config.network.internalUrl}/oauth/revoke`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: data.refresh_token }),
-- signal: AbortSignal.timeout(3000),
-- }).catch(() => {});
++
++ /** Redeem the code with the SDK, verify the person's token, revoke the refresh token Billing will not use. */
++ async function exchange({ code, verifier }) {
++ const data = await client.tokenGrant({
++ grant_type: 'authorization_code',
++ redirect_uri: config.console.redirectUri,
++ code: String(code),
++ code_verifier: verifier,
++ });
++ if (!data || !data.access_token) {
++ throw Object.assign(new Error('the Network sent no access token'), { status: 502 });
++ }
++ // Billing never uses the Network session again: drop the refresh token now.
++ if (data.refresh_token) {
++ Promise.resolve(fetchImpl(`${config.network.internalUrl}/oauth/revoke`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: data.refresh_token }),
++ signal: AbortSignal.timeout(3000),
++ })).catch(() => {});
++ }
++ // openvibe-sdk/auth: RS256 with Network's keys, expiry and issuer, the console's audience; never a service principal
++ // or a typed token.
++ const claims = await verifyUserToken(data.access_token, {
++ ...keys.verifyOptions, issuer: config.network.issuer, audience: config.console.ssoAudience, now: ctx.now(),
++ });
++ return {
++ subject: typeof claims.subject_id === 'string' ? claims.subject_id : null,
++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
++ // The effective role ('owner' for role admin with is_owner) and whether the staff map lets
++ // this token act on money (staff.money.cashouts; ADR-022).
++ role: staffMap.effectiveRole(claims),
++ money: staffMap.can(claims, 'staff.money.cashouts'),
++ };
}
-- // openvibe-sdk/auth: RS256 with Network's keys, expiry and issuer, the console's audience; never a service principal
-- // or a typed token.
-- const claims = await verifyUserToken(data.access_token, {
-- ...keys.verifyOptions, issuer: config.network.issuer, audience: config.console.ssoAudience, now: ctx.now(),
-- });
-- return {
-- subject: typeof claims.subject_id === 'string' ? claims.subject_id : null,
-- username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
-- // The effective role ('owner' for role admin with is_owner) and whether the staff map lets
-- // this token act on money (staff.money.cashouts; ADR-022).
-- role: staffMap.effectiveRole(claims),
-- money: staffMap.can(claims, 'staff.money.cashouts'),
-- };
++
++ /** 'ok' | 'invalid' | 'unknown': does the Network accept this site's OAuth client? */
++ function checkClient() {
++ return client.checkClient();
++ }
++
++ return { start, exchange, checkClient };
}
--module.exports = { pkcePair, authorizeUrl, exchange };
++module.exports = { createConsoleSso };
ddiff --git a/test/console.test.js b/test/console.test.js
iindex 06c3905..2c7649e 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -9,6 +9,7 @@
const assert = require('assert');
const { boot, fund, check, done } = require('./helpers/app');
const { startNetwork } = require('./helpers/stubs');
++const { createConsoleSso } = require('../server/console/sso');
const SESSION_SECRET = 'console-test-secret-0123456789abcdef-0123456789';
const DAY = 86_400_000;
@@@ -68,6 +69,26 @@ const DAY = 86_400_000;
let s; // the staff session cookie
++ await check('the SDK sign-in module builds the billing authorize URL', () => {
++ const client = createConsoleSso({ ctx: { config: t.config }, keys: {}, fetchImpl: async () => { throw new Error('no fetch in this test'); } });
++ const started = client.start();
++ const u = new URL(started.url);
++ assert.strictEqual(u.origin + u.pathname, 'https://openvibe.network/oauth/authorize');
++ assert.strictEqual(u.searchParams.get('client_id'), 'billing');
++ assert.strictEqual(u.searchParams.get('redirect_uri'), 'https://billing.test/auth/callback');
++ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
++ assert.match(u.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
++ assert.match(started.state, /^[A-Za-z0-9_-]{32}$/);
++ assert.match(started.verifier, /^[A-Za-z0-9_-]{43}$/);
++ });
++
++ await check('GET /auth/health answers whether the Network accepts the billing client', async () => {
++ const r = await get('/auth/health');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(JSON.parse(r.text), { ok: true, client: 'ok' });
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ });
++
await check('sign-in redirects to the Network with PKCE S256 and a signed, host-only flow cookie', async () => {
const login = await get('/auth/login?next=/cashouts');
assert.strictEqual(login.status, 302);
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex 21b0d93..ce30f93 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -99,7 +99,9 @@ async function startNetwork() {
const raw = await readBody(req);
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/oauth/token' && req.method === 'POST') {
-- const body = Object.fromEntries(new URLSearchParams(raw));
++ // openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).
++ const text = String(raw || '');
++ const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
grants.push(body);
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
if (body.grant_type === 'authorization_code') {