openvibers/openvibe.ai Commits

Operator console sign-in through openvibe-sdk/sso (#298)

OP OpenVibers committed 8 days ago · 562a55fc47 · 5 files, +110 −74

The 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, CSRF token, audit rows and the
staff.can() checks are unchanged: session.js and the permission paths in
index.js are not touched, so owner/staff access is exactly as before.

The Network token is still verified with openvibe-sdk/auth verifyUserToken with
the console's own clock and audience (no change for a skewed clock). AI still
revokes the refresh token at once.

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

Tests: node test/console.test.js (27/27), test/run-page.test.js (11/11),
test/console-exchange.test.js (7/7) and ov test (1 passed, 3 unchanged green).
The test Networks now accept 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 1c75945..7ee6efa 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -45,7 +45,7 @@ const { AiError, resolveSecret, iso } = require('../util');
const usageSamples = require('../usage-samples');
const { CAPS } = require('../auth');
const { staff: staffMap } = require('openvibe-contracts');
--const { createSessions, sameString, random } = require('./session');
++const { createSessions, sameString } = require('./session');
const sso = require('./sso');
const q = require('./queries');
const catalog = require('../providers/catalog');
@@@ -131,6 +131,8 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
if (!clientSecret()) unavailable = unavailable || 'OV_OAUTH_CLIENT_SECRET is not set';
if (unavailable && config.nodeEnv !== 'test') log.warn(`[console] operator console disabled: ${unavailable}`);
const sessions = createSessions({ db, config, clock, 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({ config, clientSecret: clientSecret(), fetchImpl, log });
/** Every secret value this process can resolve, so that no page can ever carry one (belt and braces). */
async function secretValues() {
@@@ -220,21 +222,26 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
// ── 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);
});
// A person's sign-in for one run receipt. It uses the same OAuth client and redirect URI, but the
// flow cookie says which callback branch to take, so a non-staff person never receives a staff session.
r.get('/auth/person/login', (req, res) => {
-- const state = random(32);
-- const { verifier, challenge } = sso.pkcePair();
-- sessions.setFlow(res, { s: state, v: verifier, n: sanitizeRunNext(req.query.next), kind: 'person' });
-- res.redirect(302, sso.authorizeUrl(config, { state, challenge }));
++ const started = consoleSso.start();
++ sessions.setFlow(res, { s: started.state, v: started.verifier, n: sanitizeRunNext(req.query.next), kind: 'person' });
++ 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);
const personFlow = flow && flow.kind === 'person';
@@@ -248,7 +255,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
}
let person;
try {
-- person = await sso.exchange({ config, clientSecret: clientSecret(), code: req.query.code, verifier: flow.v, now: clock.now(), fetchImpl, log });
++ person = await consoleSso.exchange({ code: req.query.code, verifier: flow.v, now: clock.now() });
} catch (e) {
log.warn(`[console] sign-in failed: ${e.message}`);
const text = 'OpenVibe.Network did not confirm the sign-in. Please try again.';
ddiff --git a/server/console/sso.js b/server/console/sso.js
iindex 98ff63b..fff08dd 100644
---- a/server/console/sso.js
++++ b/server/console/sso.js
@@@ -1,45 +1,29 @@
'use strict';
/**
-- * OpenVibe.Network single sign-on for the operator console: OAuth 2 authorization code with PKCE
-- * (S256), as OAuth client `ai` (the client AI already uses for its service tokens).
++ * OpenVibe.Network single sign-on for the operator console: the OAuth half comes from
++ * openvibe-sdk/sso (createSsoClient), as OAuth client `ai` (the client AI already uses for its
++ * service tokens). The console keeps its own short staff session, CSRF token and staff checks in
++ * session.js / index.js; this module owns only the handshake:
*
-- * authorizeUrl() <OV_NETWORK_URL>/oauth/authorize?response_type=code&client_id=ai
-- * &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 with openvibe-sdk/auth verifyUserToken
-- * against the Network's JWKS (or the pinned OV_NETWORK_PUBLIC_KEY), as a person
++ * start() <OV_NETWORK_URL>/oauth/authorize?response_type=code&client_id=ai
++ * &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 with openvibe-sdk/auth verifyUserToken against the Network's
++ * JWKS (or the pinned OV_NETWORK_PUBLIC_KEY), as a person
++ * checkClient() the SDK's health check: does the Network accept this client? (/auth/health)
*
* The Network's user access token carries `role`, `is_owner` and `subject_id` (and, once the Network
* issues them, `staff_caps` / `staff_map`), minted at the code exchange, so what the console reads
* is current as of sign-in; the session is short. AI keeps none of the Network's tokens: the refresh
* token it is handed is revoked straight away (best effort).
*/
--const crypto = require('crypto');
const { ids, staff } = require('openvibe-contracts');
const { verifyUserToken } = require('openvibe-sdk/auth');
++const { createSsoClient } = require('openvibe-sdk/sso');
const PRINCIPAL_SUB = /^(svc|app|mod):/;
--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.console.clientId,
-- redirect_uri: config.console.redirectUri,
-- scope: 'profile',
-- state,
-- code_challenge: challenge,
-- code_challenge_method: 'S256',
-- });
-- return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
--}
--
/**
* The person a verified token names: { subject, username, staff } or { error }. Service, app and
* module principals and FedCM assertions are not people; a person needs a usr_ subject. `staff` is
@@@ -63,42 +47,62 @@ function personFromClaims(claims) {
};
}
--async function exchange({ config, clientSecret, code, verifier, now = Date.now(), fetchImpl = globalThis.fetch, log = null }) {
++function createConsoleSso({ config, clientSecret, fetchImpl = globalThis.fetch, log = console }) {
const cc = config.console;
-- const body = new URLSearchParams({
-- grant_type: 'authorization_code',
-- code: String(code),
-- redirect_uri: cc.redirectUri,
-- client_id: cc.clientId,
-- client_secret: clientSecret,
-- code_verifier: verifier,
-- });
-- const res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
-- body,
-- signal: AbortSignal.timeout(10_000),
++ const client = createSsoClient({
++ site: 'ai',
++ siteName: 'OpenVibe.AI',
++ baseUrl: config.baseUrl,
++ clientId: cc.clientId,
++ clientSecret,
++ networkUrl: config.networkUrl,
++ networkInternalUrl: config.networkInternalUrl,
++ issuer: config.issuer,
++ redirectUri: cc.redirectUri,
++ scope: 'profile',
++ audience: cc.ssoAudience,
++ fetch: fetchImpl,
++ log,
});
-- 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();
}
-- // AI never uses the Network session again: drop the refresh token now.
-- if (data.refresh_token) {
-- Promise.resolve(fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({ client_id: cc.clientId, client_secret: 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 AI will not use. */
++ async function exchange({ code, verifier, now = Date.now() }) {
++ const data = await client.tokenGrant({
++ grant_type: 'authorization_code',
++ redirect_uri: cc.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 });
++ }
++ // AI never uses the Network session again: drop the refresh token now.
++ if (data.refresh_token) {
++ Promise.resolve(fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: cc.clientId, client_secret: clientSecret, token: data.refresh_token }),
++ signal: AbortSignal.timeout(3000),
++ })).catch(() => {});
++ }
++ const claims = await verifyUserToken(data.access_token, {
++ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),
++ issuer: config.issuer, audience: cc.ssoAudience, now, log,
++ });
++ return personFromClaims(claims);
}
-- const claims = await verifyUserToken(data.access_token, {
-- ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),
-- issuer: config.issuer, audience: cc.ssoAudience, now, log,
-- });
-- return personFromClaims(claims);
++
++ /** '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, verifyUserToken, personFromClaims, exchange };
++module.exports = { createConsoleSso, personFromClaims, verifyUserToken };
ddiff --git a/test/console-exchange.test.js b/test/console-exchange.test.js
iindex 9b119fe..5d62f75 100644
---- a/test/console-exchange.test.js
++++ b/test/console-exchange.test.js
@@@ -34,7 +34,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
async function fakeFetch(url, opts = {}) {
const u = String(url);
if (u === `${NETWORK}/oauth/token`) {
-- const body = Object.fromEntries(new URLSearchParams(String(opts.body)));
++ // openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).
++ const text = String(opts.body || '');
++ const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
const c = codes.get(body.code);
if (!c) return json(400, { error: 'invalid_grant' });
ddiff --git a/test/console.test.js b/test/console.test.js
iindex ab53082..d6b6899 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -15,6 +15,7 @@ const path = require('path');
const { serviceAuth, ids } = require('openvibe-contracts');
const { boot, request, token, suite, seamServer, privateKey, ISSUER, ALL } = require('./helpers');
const { sanitizeNext, consoleCapabilities } = require('../server/console');
++const { personFromClaims } = require('../server/console/sso');
const { preview } = require('../server/console/queries');
const t = suite('console');
@@@ -53,7 +54,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
async function fakeFetch(url, opts = {}) {
const u = String(url);
if (u === `${NETWORK}/oauth/token`) {
-- const body = Object.fromEntries(new URLSearchParams(String(opts.body)));
++ // openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).
++ const text = String(opts.body || '');
++ const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
grants.push(body);
if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
const c = codes.get(body.code);
@@@ -190,6 +193,24 @@ t.test('anonymous: console pages answer the sign-in page (401), never data; writ
assert.strictEqual((await h.registry.getProvider('okseam')).status, 'active');
});
++t.test('the sign-in module takes a Network person only: no service/app principal, no FedCM assertion', () => {
++ assert.strictEqual(personFromClaims({ sub: 'svc:live', subject_id: people.admin }).error, 'not a person\'s token');
++ assert.strictEqual(personFromClaims({ sub: 42, actor_type: 'app', subject_id: people.admin }).error, 'not a person\'s token');
++ assert.strictEqual(personFromClaims({ sub: 42, typ: 'fedcm', subject_id: people.admin }).error, 'a FedCM assertion is not a sign-in');
++ assert.strictEqual(personFromClaims({ sub: 42, username: people.admin }).error, 'the token carries no network subject');
++ const person = personFromClaims({ sub: 42, subject_id: people.admin, username: 'ada', role: 'admin', is_owner: true, staff_caps: ['staff.site.view'], staff_map: '1.1.0' });
++ assert.strictEqual(person.subject, people.admin);
++ assert.strictEqual(person.username, 'ada');
++ assert.deepStrictEqual(person.staff, { role: 'admin', is_owner: true, staff_caps: ['staff.site.view'], staff_map: '1.1.0' });
++});
++
++t.test('GET /auth/health answers whether the Network accepts the ai 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');
++});
++
t.test('sign-in redirects to the Network with PKCE S256 as client ai, with a signed host-only flow cookie', async () => {
const login = await get('/auth/login?next=/console/runs');
assert.strictEqual(login.status, 302);
ddiff --git a/test/run-page.test.js b/test/run-page.test.js
iindex 04e5b6c..c67e48c 100644
---- a/test/run-page.test.js
++++ b/test/run-page.test.js
@@@ -43,7 +43,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
async function fakeFetch(url, opts = {}) {
const u = String(url);
if (u === `${NETWORK}/oauth/token`) {
-- const body = Object.fromEntries(new URLSearchParams(String(opts.body)));
++ // openvibe-sdk/sso posts the grant as JSON; the real Network takes that (and this stub keeps the form shape too).
++ const text = String(opts.body || '');
++ const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
const c = codes.get(body.code);
if (!c) return json(400, { error: 'invalid_grant' });