openvibers/openvibe.billing Commits
Staff console sign-in through openvibe-sdk/sso (#194)
Changes
@@@ -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.' }));@@@ -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 };@@@ -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);@@@ -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') {
@@ -102,6 +102,8 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -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 (!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}`);
if (unavailable) log.warn(`[Billing] console disabled: ${unavailable}`);
const sessions = createSessions(ctx, { secret: secret || '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 send = (res, status, htmlText) => res.status(status).type('html').send(htmlText);
const ipHash = (req) => sessions.ipHash(req.ip);
const ipHash = (req) => sessions.ipHash(req.ip);
@@ -175,12 +177,18 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
@@ -175,12 +177,18 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
// ── Sign-in ──────────────────────────────────────────────
// ── Sign-in ──────────────────────────────────────────────
r.get('/auth/login', (req, res) => {
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) => {
r.get('/auth/callback', wrap(async (req, res) => {
const flow = sessions.takeFlow(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)}).` }));
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 }) {
@@ -191,7 +199,7 @@ function consoleRouter({ ctx, adapters, keys, fetchImpl = globalThis.fetch }) {
try {
try {
if (!keys.loaded()) await keys.refresh();
if (!keys.loaded()) await keys.refresh();
if (!keys.loaded()) throw new Error('the Network key is not loaded');
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) {
} catch (e) {
log.warn(`[Billing] console sign-in failed: ${e.message}`);
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.' }));
return send(res, e.status && e.status < 500 ? 400 : 502, pages.signIn({ message: 'OpenVibe.Network did not confirm the sign-in. Please try again.' }));
@@ -1,17 +1,22 @@
@@ -1,17 +1,22 @@
'use strict';
'use strict';
const { staff: staffMap } = require('openvibe-contracts');
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
* 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
* 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');
@@ -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
* every request. Billing keeps none of the Network's tokens: the refresh token it is handed is
* revoked straight away (best effort).
* 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',
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 };
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
const assert = require('assert');
const assert = require('assert');
const { boot, fund, check, done } = require('./helpers/app');
const { boot, fund, check, done } = require('./helpers/app');
const { startNetwork } = require('./helpers/stubs');
const { startNetwork } = require('./helpers/stubs');
+const { createConsoleSso } = require('../server/console/sso');
const SESSION_SECRET = 'console-test-secret-0123456789abcdef-0123456789';
const SESSION_SECRET = 'console-test-secret-0123456789abcdef-0123456789';
const DAY = 86_400_000;
const DAY = 86_400_000;
@@ -68,6 +69,26 @@ const DAY = 86_400_000;
@@ -68,6 +69,26 @@ const DAY = 86_400_000;
let s; // the staff session cookie
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 () => {
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');
const login = await get('/auth/login?next=/cashouts');
assert.strictEqual(login.status, 302);
assert.strictEqual(login.status, 302);
@@ -99,7 +99,9 @@ async function startNetwork() {
@@ -99,7 +99,9 @@ async function startNetwork() {
const raw = await readBody(req);
const raw = await readBody(req);
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/oauth/token' && req.method === 'POST') {
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);
grants.push(body);
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
if (body.grant_type === 'authorization_code') {
if (body.grant_type === 'authorization_code') {