openvibers/openvibe.ai Commits
Operator console sign-in through openvibe-sdk/sso (#298)
Changes
@@@ -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, eif (!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.';@@@ -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 };@@@ -34,7 +34,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, headasync 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' });@@@ -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, headasync 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; writassert.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);@@@ -43,7 +43,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, headasync 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' });
@@ -45,7 +45,7 @@ const { AiError, resolveSecret, iso } = require('../util');
@@ -45,7 +45,7 @@ const { AiError, resolveSecret, iso } = require('../util');
const usageSamples = require('../usage-samples');
const usageSamples = require('../usage-samples');
const { CAPS } = require('../auth');
const { CAPS } = require('../auth');
const { staff: staffMap } = require('openvibe-contracts');
const { staff: staffMap } = require('openvibe-contracts');
-const { createSessions, sameString, random } = require('./session');
+const { createSessions, sameString } = require('./session');
const sso = require('./sso');
const sso = require('./sso');
const q = require('./queries');
const q = require('./queries');
const catalog = require('../providers/catalog');
const catalog = require('../providers/catalog');
@@ -131,6 +131,8 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -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 (!clientSecret()) unavailable = unavailable || 'OV_OAUTH_CLIENT_SECRET is not set';
if (unavailable && config.nodeEnv !== 'test') log.warn(`[console] operator console disabled: ${unavailable}`);
if (unavailable && config.nodeEnv !== 'test') log.warn(`[console] operator console disabled: ${unavailable}`);
const sessions = createSessions({ db, config, clock, secret: secret || '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). */
/** Every secret value this process can resolve, so that no page can ever carry one (belt and braces). */
async function secretValues() {
async function secretValues() {
@@ -220,21 +222,26 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -220,21 +222,26 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
// ── 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);
});
});
// A person's sign-in for one run receipt. It uses the same OAuth client and redirect URI, but the
// 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.
// 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) => {
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) => {
r.get('/auth/callback', wrap(async (req, res) => {
const flow = sessions.takeFlow(req, res);
const flow = sessions.takeFlow(req, res);
const personFlow = flow && flow.kind === 'person';
const personFlow = flow && flow.kind === 'person';
@@ -248,7 +255,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
@@ -248,7 +255,7 @@ function consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, e
}
}
let person;
let person;
try {
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) {
} catch (e) {
log.warn(`[console] sign-in failed: ${e.message}`);
log.warn(`[console] sign-in failed: ${e.message}`);
const text = 'OpenVibe.Network did not confirm the sign-in. Please try again.';
const text = 'OpenVibe.Network did not confirm the sign-in. Please try again.';
@@ -1,45 +1,29 @@
@@ -1,45 +1,29 @@
'use strict';
'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
* 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
* 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
* 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).
* token it is handed is revoked straight away (best effort).
*/
*/
-const crypto = require('crypto');
const { ids, staff } = require('openvibe-contracts');
const { ids, staff } = require('openvibe-contracts');
const { verifyUserToken } = require('openvibe-sdk/auth');
const { verifyUserToken } = require('openvibe-sdk/auth');
+const { createSsoClient } = require('openvibe-sdk/sso');
const PRINCIPAL_SUB = /^(svc|app|mod):/;
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
* 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
* module principals and FedCM assertions are not people; a person needs a usr_ subject. `staff` is
@@ -63,42 +47,62 @@ function personFromClaims(claims) {
@@ -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 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 };
@@ -34,7 +34,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
@@ -34,7 +34,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
async function fakeFetch(url, opts = {}) {
async function fakeFetch(url, opts = {}) {
const u = String(url);
const u = String(url);
if (u === `${NETWORK}/oauth/token`) {
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' });
if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
const c = codes.get(body.code);
const c = codes.get(body.code);
if (!c) return json(400, { error: 'invalid_grant' });
if (!c) return json(400, { error: 'invalid_grant' });
@@ -15,6 +15,7 @@ const path = require('path');
@@ -15,6 +15,7 @@ const path = require('path');
const { serviceAuth, ids } = require('openvibe-contracts');
const { serviceAuth, ids } = require('openvibe-contracts');
const { boot, request, token, suite, seamServer, privateKey, ISSUER, ALL } = require('./helpers');
const { boot, request, token, suite, seamServer, privateKey, ISSUER, ALL } = require('./helpers');
const { sanitizeNext, consoleCapabilities } = require('../server/console');
const { sanitizeNext, consoleCapabilities } = require('../server/console');
+const { personFromClaims } = require('../server/console/sso');
const { preview } = require('../server/console/queries');
const { preview } = require('../server/console/queries');
const t = suite('console');
const t = suite('console');
@@ -53,7 +54,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
@@ -53,7 +54,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
async function fakeFetch(url, opts = {}) {
async function fakeFetch(url, opts = {}) {
const u = String(url);
const u = String(url);
if (u === `${NETWORK}/oauth/token`) {
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);
grants.push(body);
if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
const c = codes.get(body.code);
const c = codes.get(body.code);
@@ -190,6 +193,24 @@ t.test('anonymous: console pages answer the sign-in page (401), never data; writ
@@ -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');
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 () => {
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');
const login = await get('/auth/login?next=/console/runs');
assert.strictEqual(login.status, 302);
assert.strictEqual(login.status, 302);
@@ -43,7 +43,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
@@ -43,7 +43,9 @@ const json = (status, body) => new Response(JSON.stringify(body), { status, head
async function fakeFetch(url, opts = {}) {
async function fakeFetch(url, opts = {}) {
const u = String(url);
const u = String(url);
if (u === `${NETWORK}/oauth/token`) {
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' });
if (body.client_id !== 'ai' || body.client_secret !== CLIENT_SECRET) return json(401, { error: 'invalid_client' });
const c = codes.get(body.code);
const c = codes.get(body.code);
if (!c) return json(400, { error: 'invalid_grant' });
if (!c) return json(400, { error: 'invalid_grant' });