openvibers/openvibe.network Commits
Build the third-party OAuth consent screen: listed capabilities, sensitive marked, absent scope consents to nothing (#54)
Changes
@@@ -2,7 +2,7 @@Roadmap Wave 20 foundation. The binding decision is ADR-014 in OpenVibe.Contracts. Network owns developer projects: who may build on OpenVibe, which apps they run, what those apps may do, and how much they may use. OpenVibe.Codes (the portal, the next step) is a client of this API. It never enforces grants or quotas itself.--What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). What does not exist yet: a UI (Codes), a consent screen that lists capabilities, and refresh tokens for apps. Owning services read a project through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Quotas are enforced by the owning services, not here.++What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). The backend of the consent screen is built (`GET /oauth/client-info` with `scope`), the page that renders it is next (see [Consent screen](#known-gaps-next-steps)); what does not exist yet: a UI (Codes), the consent-screen page, and refresh tokens for apps. Owning services read a project through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Quotas are enforced by the owning services, not here.## Model@@@ -307,7 +307,7 @@ Until the relay is turned on in an environment, nobody else receives these event## Known gaps (next steps)--- **Consent screen.** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live, and it must list the capability ids the app asks for, marking the ones `capability@1` marks `sensitive`; a request without a `scope` must not consent to capabilities the screen did not name. This screen is the app path's compensation for not using the agent confirmation flow — an authorization-code app acts for a person who is present and authorizes it capability by capability, so it carries no `cap_confirm` (decided 2026-10-04, `docs/t2-projects-and-grants.md` section 9).++- **Consent screen (backend built; page next).** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live, and it must list the capability ids the app asks for, marking the ones `capability@1` marks `sensitive`; a request without a `scope` must not consent to capabilities the screen did not name. This screen is the app path's compensation for not using the agent confirmation flow — an authorization-code app acts for a person who is present and authorizes it capability by capability, so it carries no `cap_confirm` (decided 2026-10-04, `docs/t2-projects-and-grants.md` section 9). **The backend half is built:** `GET /oauth/client-info` with the authorize request's `scope` returns `capabilities: [{ id, name, description, sensitive }]` — the app-grantable requested ids, in the order requested, with `description` and `sensitive` from the `capability@1` catalog (`name` is the catalog id, since `capability@1` has no separate display name) — and `refused: [id]` for the unknown or ungrantable ones the page must not name; without a `scope` it returns `capabilities: []`. The authorization code an app receives now carries the explicit consented set (`tokens.issueCode`), so an absent or empty `scope` consents to nothing: the code stores an empty set and the token exchange mints a token with `cap: []` (the person is still identified by `on_behalf_of`), and asking the token endpoint for an id outside that set is `invalid_scope`. Apps have no refresh tokens, so no refreshed token can widen the set either. What remains is the API design session's page: render `capabilities` from `client-info`, mark the `sensitive` ones, and say the `refused` ids will not be granted (`test/consent-screen.test.js`).- **Service-side project reads.** The route is built, but no service calls it yet. Host holds the grant. Contracts still has to make `network.project.read` `active`.- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).@@@ -647,7 +647,14 @@ notification shipped with `openvibe-shared` v2.7.0 (`TYPES.CONFIRMATION_REQUESTEand revocation exist for, and if it proves too loose for the two app-grantable sensitive capabilities the narrower fixis to leave sensitive capabilities out of refreshed tokens (the person consents again), not the agent inbox. Nocontract delta: `cap_confirm` and the confirmation flow stay-- agent-only in `identity.service-token-claims@1`, and Network builds nothing for apps here.++ agent-only in `identity.service-token-claims@1`, and Network builds nothing for apps here. **The consent screen's++ backend half is now built** (2026-10-04): `GET /oauth/client-info` with the authorize request's `scope` returns the++ requested app-grantable ids with `capability@1`'s `description`/`sensitive` (`name` is the catalog id, `capability@1` having no display name) and the `refused` ids, and++ `tokens.issueCode` stores an explicit consented set so an absent or empty `scope` consents to nothing — the code++ carries an empty set and `handleTokenRequest` mints a token with `cap: []` (the person stays identified via++ `on_behalf_of`); asking the token endpoint for an id outside the set is `invalid_scope`, and an app has no refresh++ token to widen it. The account-chooser page renders that list next (`docs/developer-projects.md`, "Consent screen";++ `test/consent-screen.test.js`).### Unresolved@@@ -12,16 +12,57 @@ let clientName = '';let clientHost = '';if (isOAuth) {fetch(`${API_BASE}/oauth/client-info?${new URLSearchParams({ client_id: params.get('client_id'), redirect_uri: params.get('redirect_uri'),-- code_challenge: params.get('code_challenge') || '', code_challenge_method: params.get('code_challenge_method') || '' })}`, { credentials: 'omit' })++ code_challenge: params.get('code_challenge') || '', code_challenge_method: params.get('code_challenge_method') || '',++ scope: params.get('scope') || '' })}`, { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then((info) => {if (!info || !info.name) return;clientName = info.third_party ? `${info.name} (third-party app)` : info.name;clientHost = info.third_party ? info.redirect_host || '' : '';renderChooserSubtitle();++ renderConsent(info);}).catch(() => {});}++++/**++ * The consent screen (plan T2, docs/t2-projects-and-grants.md §9): what a third-party app may do if you continue,++ * from the Network's own answer (the capability catalog's names and descriptions, sensitive ones marked), never from++ * this page's URL. A request that names nothing gets nothing: the app only learns which account you are. Built from++ * DOM nodes: names and descriptions come from the catalog, the app's name from its developer.++ */++function renderConsent(info) {++ const box = document.getElementById('oauth-consent');++ if (!box || !info || !info.third_party) return;++ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };++ box.replaceChildren();++ const head = el('p', 'consent-head');++ head.append(el('b', null, info.name), ' ');++ if (info.redirect_host) head.append(el('span', 'consent-host', `(${info.redirect_host})`), ' ');++ const caps = Array.isArray(info.capabilities) ? info.capabilities : [];++ head.append(caps.length ? 'is a third-party app. If you continue, it can:' : 'is a third-party app.');++ box.append(head);++ if (caps.length) {++ const list = el('ul');++ for (const c of caps) {++ const li = el('li', c.sensitive ? 'sensitive' : null);++ li.append(el('span', 'consent-name', c.name || c.id));++ if (c.sensitive) li.append(el('span', 'consent-badge', 'Sensitive'));++ if (c.description) li.append(el('small', null, c.description));++ list.append(li);++ }++ box.append(list);++ } else {++ box.append(el('p', 'consent-none', 'It asks for nothing else: it will only learn which OpenVibe account you are.'));++ }++ const refused = Array.isArray(info.refused) ? info.refused : [];++ if (refused.length) {++ const p = el('p', 'consent-refused', 'It also asked for what it cannot be given, so it will not get: ');++ refused.forEach((id, i) => { if (i) p.append(', '); p.append(el('code', null, id)); });++ box.append(p);++ }++ box.hidden = false;++}let oauthConfirmInFlight = false;// Generate particles@@@ -176,6 +176,19 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter/* ── Account Chooser ───────────────────────────────────── */.chooser-subtitle{font-size:13px;color:var(--text-muted);text-align:center;margin-bottom:20px;line-height:1.5}.chooser-subtitle .app-name{color:var(--accent);font-weight:700}++.oauth-consent{margin:0 0 20px;padding:14px 16px;border:1px solid var(--border);border-radius:12px;background:var(--bg-input);font-size:13px;line-height:1.5;text-align:left}++.oauth-consent .consent-head{margin:0 0 10px;color:var(--text)}++.oauth-consent .consent-head b{color:var(--accent)}++.oauth-consent .consent-host{color:var(--text-muted)}++.oauth-consent ul{list-style:none;margin:0;padding:0;display:grid;gap:8px}++.oauth-consent li{padding:8px 10px;border-left:3px solid var(--border);border-radius:6px;background:rgba(255,255,255,0.02)}++.oauth-consent li.sensitive{border-left-color:#f59e0b}++.oauth-consent .consent-name{font-weight:600;color:var(--text)}++.oauth-consent .consent-badge{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;font-size:11px;font-weight:700;color:#1a1204;background:#f59e0b;vertical-align:1px}++.oauth-consent small{display:block;margin-top:2px;color:var(--text-muted);font-size:12px}++.oauth-consent .consent-none,.oauth-consent .consent-refused{margin:0;color:var(--text-muted)}++.oauth-consent .consent-refused{margin-top:10px;font-size:12px}++.oauth-consent code{font-size:11.5px}.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}.account-item{@@@ -254,6 +267,9 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter<p>One account. All of OpenVibe.</p></div>++ <!-- What a third-party app asks for (GET /oauth/client-info), above every way of signing in -->++ <section class="oauth-consent" id="oauth-consent" aria-label="What this app asks for" hidden></section>++<!-- Tab bar (hidden when chooser is shown) --><noscript><p class="panel-note">Signing in and creating an account need JavaScript: turn it on for openvibe.network and reload this page. You can use almost everything on OpenVibe as a guest without signing in.</p></noscript><div class="tab-bar" id="auth-tabs">@@@ -128,10 +128,13 @@ router.get('/authorize', async (req, res) => {// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted// link could claim to be any app. redirect_host is shown beside it: where the code will go.++// With the authorize request's `scope`, a third-party app also gets the consent screen's capability++// list: the requested ids the app can be granted (catalog metadata, `sensitive` marked) and the++// refused ids the screen must not name. Never another app, never a secret.router.get('/client-info', async (req, res) => {const db = await getDb(req);res.set('Cache-Control', 'no-store');-- const { client_id, redirect_uri } = req.query;++ const { client_id, redirect_uri, scope } = req.query;if (!client_id || !redirect_uri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });let host = '';try { host = new URL(String(redirect_uri)).host; } catch { return res.status(400).json({ error: 'Invalid redirect_uri' }); }@@@ -140,13 +143,14 @@ router.get('/client-info', async (req, res) => {if (found.error && !found.pkce) return res.status(404).json({ error: found.error });const app = found.app || null;if (!app) return res.status(404).json({ error: 'Unknown client_id' });-- return res.json({ name: app.name, third_party: true, redirect_host: host });++ const { capabilities, refused } = devTokens.consentView(scope);++ return res.json({ name: app.name, third_party: true, redirect_host: host, capabilities, refused });}const client = await db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));if (!client) return res.status(404).json({ error: 'Unknown client_id' });const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });-- res.json({ name: client.name || String(client_id), third_party: false, redirect_host: host });++ res.json({ name: client.name || String(client_id), third_party: false, redirect_host: host, capabilities: [], refused: [] });});// ── POST /oauth/confirm ─────────────────────────────────────@@@ -59,7 +59,41 @@ function projectNamespaces(projectId) {return [projectId, `app.${projectId}.*`];}--async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {++/**++ * The capability ids a requested `scope` consents to: the app-grantable ids, in the order requested,++ * duplicates dropped. This is the explicit set `issueCode` stores on the code — an absent or empty++ * scope consents to nothing — and the ceiling every token exchange narrows from.++ */++function consentedCapabilities(scope) {++ const seen = new Set(); const out = [];++ for (const id of String(scope || '').split(/\s+/).filter(Boolean)) {++ if (seen.has(id)) continue;++ seen.add(id);++ if (policy.isGrantable(id)) out.push(id);++ }++ return out;++}++++/**++ * The consent screen's view of a requested scope. `capabilities` are the app-grantable ids with the++ * catalog's metadata, in the order requested; `name` is the catalog id (capability@1 has no display++ * name of its own). `refused` are the requested ids the screen cannot name because they are unknown++ * or not grantable to apps — the same filter `issueCode` applies, so the screen lists exactly what a++ * code from this request can carry. Nothing here reveals another app or a secret.++ */++function consentView(scope) {++ const capabilities = []; const refused = []; const seen = new Set();++ for (const id of String(scope || '').split(/\s+/).filter(Boolean)) {++ if (seen.has(id)) continue;++ seen.add(id);++ const g = policy.grantability(id);++ if (g.grantable) capabilities.push({ id, name: g.capability.id, description: g.capability.description || '', sensitive: !!g.capability.sensitive });++ else refused.push(id);++ }++ return { capabilities, refused };++}++++async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db, allowEmpty }) {const aud = String(audience || '').trim();if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');if (app.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {@@@ -70,7 +104,10 @@ async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKconst missing = wanted ? wanted.filter(w => !held.includes(w)) : [];if (missing.length) return oauthError(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);const cap = wanted || held;-- if (!cap.length) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);++ // An authorization code that consented to nothing still mints an identifying token, with `cap: []`:++ // the person is named (on_behalf_of) and the app can do nothing until it consents to a scope. Other++ // grants (client_credentials) still refuse an empty set.++ if (!cap.length && !allowEmpty) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);const now = Math.floor(Date.now() / 1000);const claims = {iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),@@@ -188,11 +225,13 @@ async function handleTokenRequest(db, body, { privateKey, issuer, config, ctx })if (!verifierMatches(body.code_verifier, row.code_challenge)) return oauthError(400, 'invalid_grant', 'PKCE verification failed');const user = await db.prepare('SELECT subject_id, is_banned FROM users WHERE subject_id = ?').get(row.user_subject);if (!user || user.is_banned) return oauthError(400, 'invalid_grant', 'User not found or banned');-- // The exchange may narrow what was authorized, never widen it.-- const authorized = row.scope ? row.scope.split(' ') : null;++ // The consent the code carries is explicit: `row.scope` is exactly the grantable ids the++ // authorize requested, and an empty set means the person consented to nothing. The exchange++ // may narrow it, never widen it, and a no-consent code still yields an identifying token.++ const consented = String(row.scope || '').split(/\s+/).filter(Boolean);const asked = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : null;-- if (authorized && asked && asked.some(s => !authorized.includes(s))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');-- return await mint({ app, project, audience: body.audience, scope: body.scope, limit: authorized, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });++ if (asked && asked.some(s => !consented.includes(s))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');++ return await mint({ app, project, audience: body.audience, scope: body.scope, limit: consented, allowEmpty: consented.length === 0, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });}return oauthError(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');}@@@ -233,12 +272,13 @@ async function issueCode(db, { app, project, user, redirectUri, scope, challengeconst code = crypto.randomBytes(32).toString('hex');await db.prepare(`INSERT INTO dev_auth_codes (code_hash, app_id, user_subject, redirect_uri, scope, code_challenge, expires_at)VALUES (?, ?, ?, ?, ?, ?, ?)`).run(store.hashSecret(code), app.id, subject, redirectUri,-- String(scope || '').split(/\s+/).filter(s => policy.isGrantable(s)).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());++ consentedCapabilities(scope).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());await db.prepare('DELETE FROM dev_auth_codes WHERE expires_at < ?').run(new Date(Date.now() - 3600 * 1000).toISOString());return { code };}module.exports = {isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, projectNamespaces, TOKEN_TTL_S,++ consentedCapabilities, consentView,mintExportToken, exportSubject, EXPORT_CAPS, EXPORT_PURPOSE,};nnew file mode 100644@@@ -0,0 +1,151 @@++'use strict';++// The backend of the third-party OAuth consent screen (plan T2; docs/t2-projects-and-grants.md §9).++// GET /oauth/client-info carries the requested capability ids with their catalog metadata (sensitive++// marked) and refuses the rest; an authorization code carries an explicit consented set (an absent or++// empty scope = nothing), and the token exchange can only narrow it, never widen it.++// node test/consent-screen.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { serviceAuth } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++++(async () => {++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-consent-'));++const log = console.log; console.log = () => {};++const db = getDb();++console.log = log;++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES++ (1, 'owner', 'x', 'user'), (2, 'ops', 'x', 'admin')`).run();++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const ISSUER = 'https://openvibe.network';++const app = express();++app.use(express.json());++app.use(express.urlencoded({ extended: true }));++app.locals.db = db;++app.locals.config = {++ baseUrl: ISSUER, loginUrl: ISSUER,++ jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },++ // A staff-set allowance only: the sandbox default is switched off, so the grants below are the++ // whole story (test/developer-defaults.test.js covers the code default).++ developer: { sandboxAudiences: 'openvibe.media', sandboxAllowance: '', credentialOverlapS: 60 },++};++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++app.use('/oauth', require('../server/auth/oauth-routes'));++app.use('/api/v1/projects', require('../server/developer/routes').router());++const server = http.createServer(app);++++const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++const T = { owner: userToken(1), ops: userToken(2) };++++(async () => {++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const api = async (who, method, p, body) => {++ const h = { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) };++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: h, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ return { status: r.status, text, body: text ? JSON.parse(text) : null };++ };++ const token = (form) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form) })++ .then(async r => ({ status: r.status, body: await r.json() }));++ const verify = (t) => serviceAuth.verifyServiceToken(t, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media', acceptSandbox: true });++++ // ── A sandbox project with two approved, app-grantable capabilities ──++ let r = await api('owner', 'POST', '', { name: 'Consent Demo' });++ assert.strictEqual(r.status, 201, r.text);++ const P = r.body.id;++ r = await api('ops', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.delete', 'media.object.read'] });++ assert.strictEqual(r.status, 200, r.text);++ const REDIRECT = 'https://dev.example.com/cb';++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Consent Demo App', environment: 'sandbox', type: 'public', redirect_uris: [REDIRECT] });++ assert.strictEqual(r.status, 201, r.text);++ const A = r.body.id;++ for (const cap of ['media.object.delete', 'media.object.read']) {++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: cap });++ assert.strictEqual(r.body.status, 'approved', `${cap}: ${r.text}`);++ }++++ const verifier = crypto.randomBytes(32).toString('base64url');++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');++ const info = (q) => fetch(`${base}/oauth/client-info?${new URLSearchParams({ client_id: A, redirect_uri: REDIRECT, code_challenge: challenge, code_challenge_method: 'S256', ...q })}`)++ .then(async x => ({ status: x.status, body: await x.json() }));++ const confirm = (scope) => fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' },++ body: JSON.stringify({ token: T.owner, client_id: A, redirect_uri: REDIRECT, state: 's', code_challenge: challenge, code_challenge_method: 'S256', ...(scope === undefined ? {} : { scope }) }) })++ .then(async x => ({ status: x.status, body: await x.json() }));++ const exchange = (code, extra = {}) => token({ grant_type: 'authorization_code', client_id: A, code, redirect_uri: REDIRECT, audience: 'openvibe.media', code_verifier: verifier, ...extra });++ const codeFor = async (scope) => new URL((await confirm(scope)).body.redirect).searchParams.get('code');++++ // ── client-info names the capability ids the app asks for; a scope-less call gets none ──++ let ci = await info({});++ assert.strictEqual(ci.status, 200, JSON.stringify(ci.body));++ assert.deepStrictEqual(ci.body.capabilities, []);++ assert.deepStrictEqual(ci.body.refused, []);++ assert.strictEqual(ci.body.client_secret, undefined, 'nothing about the app\'s secret');++++ ci = await info({ scope: 'media.object.delete media.object.read nope.not.real network.coins.credit media.object.delete' });++ assert.strictEqual(ci.status, 200);++ assert.deepStrictEqual(ci.body.capabilities.map(c => c.id), ['media.object.delete', 'media.object.read'], 'app-grantable ids, in the order requested, deduped');++ const del = ci.body.capabilities.find(c => c.id === 'media.object.delete');++ assert.strictEqual(del.sensitive, true, 'the sensitive capability is marked');++ assert.strictEqual(del.name, 'media.object.delete');++ assert.match(del.description, /Delete an object/);++ const read = ci.body.capabilities.find(c => c.id === 'media.object.read');++ assert.strictEqual(read.sensitive, false);++ assert.deepStrictEqual(ci.body.refused, ['nope.not.real', 'network.coins.credit'], 'unknown/ungrantable ids the screen must not name');++++ // ── A scope-less authorize consents to nothing: no code can obtain media.object.delete ──++ let code = await codeFor(undefined);++ let t = await exchange(code);++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));++ let v = verify(t.body.access_token);++ assert.strictEqual(v.ok, true, v.reason);++ assert.deepStrictEqual(v.claims.cap, [], 'an absent scope consents to nothing');++ assert.ok(v.claims.on_behalf_of, 'the person is still identified');++ assert.strictEqual(v.claims.project_id, P);++ assert.strictEqual(t.body.scope, '');++ assert.strictEqual(t.body.refresh_token, undefined, 'app tokens have no refresh token to widen with');++++ // The same code, asking for a capability at the token endpoint, is refused.++ code = await codeFor(undefined);++ t = await exchange(code, { scope: 'media.object.delete' });++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope');++++ // An empty scope string is the same as absent.++ code = await codeFor('');++ t = await exchange(code);++ assert.strictEqual(t.status, 200); assert.deepStrictEqual(verify(t.body.access_token).claims.cap, []);++++ // A refresh_token request never widens: apps do not support the grant at all.++ t = await token({ grant_type: 'refresh_token', client_id: A, refresh_token: 'x'.repeat(96) });++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'unsupported_grant_type');++++ // ── A scope-bearing authorize consents to exactly the named grantable ids ──++ code = await codeFor('media.object.read media.object.delete nope.not.real');++ t = await exchange(code);++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));++ v = verify(t.body.access_token);++ assert.deepStrictEqual(v.claims.cap.slice().sort(), ['media.object.delete', 'media.object.read'], 'the named grantable ids only');++ assert.deepStrictEqual(t.body.scope.split(' ').sort(), ['media.object.delete', 'media.object.read'], 'exactly the consented ids');++++ // The exchange narrows, never widens.++ code = await codeFor('media.object.read');++ t = await exchange(code, { scope: 'media.object.read media.object.delete' });++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope');++++ code = await codeFor('media.object.read media.object.delete');++ t = await exchange(code, { scope: 'media.object.read' });++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));++ assert.deepStrictEqual(verify(t.body.access_token).claims.cap, ['media.object.read'], 'narrowed to the asked subset');++++ console.log('consent screen: all checks passed');++ server.close();++})().catch(err => { console.error(err); process.exit(1); });++})().catch(err => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++// The consent screen on /login (public/js/login.js renderConsent, plan T2 §9): a third-party app's requested++// capabilities from GET /oauth/client-info, sensitive ones marked, refused ids named, nothing parsed as HTML, and++// a first-party app shows no box. Runs the function itself against a minimal DOM.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'login.html'), 'utf8');++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'login.js'), 'utf8');++++assert.match(html, /<section class="oauth-consent" id="oauth-consent" aria-label="What this app asks for" hidden><\/section>/, 'the box is on the page, hidden until the app is known');++assert.ok(html.indexOf('id="oauth-consent"') < html.indexOf('id="auth-tabs"'), 'above every way of signing in');++assert.match(js, /scope: params\.get\('scope'\) \|\| ''/, 'client-info is asked about the requested scope');++++// Pull renderConsent out of login.js and run it.++const start = js.indexOf('function renderConsent(info) {');++assert.ok(start > 0, 'renderConsent exists');++let depth = 0, end = start;++for (let i = js.indexOf('{', start); i < js.length; i++) {++ if (js[i] === '{') depth++;++ else if (js[i] === '}' && --depth === 0) { end = i + 1; break; }++}++const source = js.slice(start, end);++assert.ok(!/innerHTML/.test(source), 'built from DOM nodes only');++++class Node {++ constructor(tag) { this.tagName = tag; this.children = []; this.className = ''; this.hidden = true; this._text = ''; }++ set textContent(v) { this._text = String(v); this.children = []; }++ get textContent() { return this._text + this.children.map((c) => (typeof c === 'string' ? c : c.textContent)).join(''); }++ append(...kids) { this.children.push(...kids); }++ replaceChildren() { this.children = []; this._text = ''; }++ findAll(pred, out = []) { for (const c of this.children) if (typeof c !== 'string') { if (pred(c)) out.push(c); c.findAll(pred, out); } return out; }++}++function run(info) {++ const box = new Node('section');++ const document = { getElementById: (id) => (id === 'oauth-consent' ? box : null), createElement: (t) => new Node(t) };++ vm.runInNewContext(`${source}; renderConsent(info);`, { document, info });++ return box;++}++++{++ const box = run({ name: 'Clip<b>Tool</b>', third_party: true, redirect_host: 'cliptool.example', capabilities: [++ { id: 'media.object.read', name: 'Read your media', description: 'See the videos and files in your library.', sensitive: false },++ { id: 'media.object.delete', name: 'Delete your media', description: 'Remove videos and files for good.', sensitive: true },++ ], refused: ['network.admin'] });++ assert.strictEqual(box.hidden, false, 'shown for a third-party app');++ const text = box.textContent;++ assert.ok(text.includes('Clip<b>Tool</b>') && text.includes('(cliptool.example)'), 'the app name is text, never markup');++ assert.ok(text.includes('If you continue, it can:'));++ const items = box.findAll((n) => n.tagName === 'li');++ assert.strictEqual(items.length, 2);++ assert.strictEqual(items[1].className, 'sensitive', 'the sensitive capability is marked');++ assert.ok(items[1].textContent.includes('Sensitive') && !items[0].textContent.includes('Sensitive'));++ assert.ok(text.includes('it will not get: ') && box.findAll((n) => n.tagName === 'code')[0].textContent === 'network.admin', 'refused ids are named');++}++{++ const box = run({ name: 'Quiet', third_party: true, capabilities: [], refused: [] });++ assert.strictEqual(box.hidden, false);++ assert.ok(box.textContent.includes('it will only learn which OpenVibe account you are'), 'nothing asked, nothing given');++ assert.strictEqual(box.findAll((n) => n.tagName === 'li').length, 0);++}++{++ const box = run({ name: 'OpenVibe.Live', third_party: false, capabilities: [] });++ assert.strictEqual(box.hidden, true, 'a first-party site shows no consent box');++}++console.log('consent ui: all checks passed');@@@ -269,7 +269,7 @@ const secretsSeen = [];assert.strictEqual(c.status, 400, 'confirm without PKCE refused');c = await confirm('stranger', { code_challenge: challenge, code_challenge_method: 'S256' });assert.strictEqual(c.status, 403, 'sandbox apps are authorized only by project members');-- const codeFor = async () => new URL((await confirm('dev', { code_challenge: challenge, code_challenge_method: 'S256' })).body.redirect).searchParams.get('code');++ const codeFor = async (scope = 'media.object.upload') => new URL((await confirm('dev', { code_challenge: challenge, code_challenge_method: 'S256', scope })).body.redirect).searchParams.get('code');const exchange = (code, extra = {}) => token({ grant_type: 'authorization_code', client_id: B, code, redirect_uri: REDIRECT, audience: 'openvibe.media', ...extra });t = await cc(B, '');assert.strictEqual(t.body.error, 'unauthorized_client', 'public apps cannot use client_credentials');
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
Roadmap Wave 20 foundation. The binding decision is ADR-014 in OpenVibe.Contracts. Network owns developer projects: who may build on OpenVibe, which apps they run, what those apps may do, and how much they may use. OpenVibe.Codes (the portal, the next step) is a client of this API. It never enforces grants or quotas itself.
Roadmap Wave 20 foundation. The binding decision is ADR-014 in OpenVibe.Contracts. Network owns developer projects: who may build on OpenVibe, which apps they run, what those apps may do, and how much they may use. OpenVibe.Codes (the portal, the next step) is a client of this API. It never enforces grants or quotas itself.
-What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). What does not exist yet: a UI (Codes), a consent screen that lists capabilities, and refresh tokens for apps. Owning services read a project through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Quotas are enforced by the owning services, not here.
+What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). The backend of the consent screen is built (`GET /oauth/client-info` with `scope`), the page that renders it is next (see [Consent screen](#known-gaps-next-steps)); what does not exist yet: a UI (Codes), the consent-screen page, and refresh tokens for apps. Owning services read a project through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Quotas are enforced by the owning services, not here.
## Model
## Model
@@ -307,7 +307,7 @@ Until the relay is turned on in an environment, nobody else receives these event
@@ -307,7 +307,7 @@ Until the relay is turned on in an environment, nobody else receives these event
## Known gaps (next steps)
## Known gaps (next steps)
-- **Consent screen.** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live, and it must list the capability ids the app asks for, marking the ones `capability@1` marks `sensitive`; a request without a `scope` must not consent to capabilities the screen did not name. This screen is the app path's compensation for not using the agent confirmation flow — an authorization-code app acts for a person who is present and authorizes it capability by capability, so it carries no `cap_confirm` (decided 2026-10-04, `docs/t2-projects-and-grants.md` section 9).
+- **Consent screen (backend built; page next).** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live, and it must list the capability ids the app asks for, marking the ones `capability@1` marks `sensitive`; a request without a `scope` must not consent to capabilities the screen did not name. This screen is the app path's compensation for not using the agent confirmation flow — an authorization-code app acts for a person who is present and authorizes it capability by capability, so it carries no `cap_confirm` (decided 2026-10-04, `docs/t2-projects-and-grants.md` section 9). **The backend half is built:** `GET /oauth/client-info` with the authorize request's `scope` returns `capabilities: [{ id, name, description, sensitive }]` — the app-grantable requested ids, in the order requested, with `description` and `sensitive` from the `capability@1` catalog (`name` is the catalog id, since `capability@1` has no separate display name) — and `refused: [id]` for the unknown or ungrantable ones the page must not name; without a `scope` it returns `capabilities: []`. The authorization code an app receives now carries the explicit consented set (`tokens.issueCode`), so an absent or empty `scope` consents to nothing: the code stores an empty set and the token exchange mints a token with `cap: []` (the person is still identified by `on_behalf_of`), and asking the token endpoint for an id outside that set is `invalid_scope`. Apps have no refresh tokens, so no refreshed token can widen the set either. What remains is the API design session's page: render `capabilities` from `client-info`, mark the `sensitive` ones, and say the `refused` ids will not be granted (`test/consent-screen.test.js`).
- **Service-side project reads.** The route is built, but no service calls it yet. Host holds the grant. Contracts still has to make `network.project.read` `active`.
- **Service-side project reads.** The route is built, but no service calls it yet. Host holds the grant. Contracts still has to make `network.project.read` `active`.
- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.
- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.
- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).
- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).
@@ -647,7 +647,14 @@ notification shipped with `openvibe-shared` v2.7.0 (`TYPES.CONFIRMATION_REQUESTE
@@ -647,7 +647,14 @@ notification shipped with `openvibe-shared` v2.7.0 (`TYPES.CONFIRMATION_REQUESTE
and revocation exist for, and if it proves too loose for the two app-grantable sensitive capabilities the narrower fix
and revocation exist for, and if it proves too loose for the two app-grantable sensitive capabilities the narrower fix
is to leave sensitive capabilities out of refreshed tokens (the person consents again), not the agent inbox. No
is to leave sensitive capabilities out of refreshed tokens (the person consents again), not the agent inbox. No
contract delta: `cap_confirm` and the confirmation flow stay
contract delta: `cap_confirm` and the confirmation flow stay
- agent-only in `identity.service-token-claims@1`, and Network builds nothing for apps here.
+ agent-only in `identity.service-token-claims@1`, and Network builds nothing for apps here. **The consent screen's
+ backend half is now built** (2026-10-04): `GET /oauth/client-info` with the authorize request's `scope` returns the
+ requested app-grantable ids with `capability@1`'s `description`/`sensitive` (`name` is the catalog id, `capability@1` having no display name) and the `refused` ids, and
+ `tokens.issueCode` stores an explicit consented set so an absent or empty `scope` consents to nothing — the code
+ carries an empty set and `handleTokenRequest` mints a token with `cap: []` (the person stays identified via
+ `on_behalf_of`); asking the token endpoint for an id outside the set is `invalid_scope`, and an app has no refresh
+ token to widen it. The account-chooser page renders that list next (`docs/developer-projects.md`, "Consent screen";
+ `test/consent-screen.test.js`).
### Unresolved
### Unresolved
@@ -12,16 +12,57 @@ let clientName = '';
@@ -12,16 +12,57 @@ let clientName = '';
let clientHost = '';
let clientHost = '';
if (isOAuth) {
if (isOAuth) {
fetch(`${API_BASE}/oauth/client-info?${new URLSearchParams({ client_id: params.get('client_id'), redirect_uri: params.get('redirect_uri'),
fetch(`${API_BASE}/oauth/client-info?${new URLSearchParams({ client_id: params.get('client_id'), redirect_uri: params.get('redirect_uri'),
- code_challenge: params.get('code_challenge') || '', code_challenge_method: params.get('code_challenge_method') || '' })}`, { credentials: 'omit' })
+ code_challenge: params.get('code_challenge') || '', code_challenge_method: params.get('code_challenge_method') || '',
+ scope: params.get('scope') || '' })}`, { credentials: 'omit' })
.then(r => (r.ok ? r.json() : null))
.then(r => (r.ok ? r.json() : null))
.then((info) => {
.then((info) => {
if (!info || !info.name) return;
if (!info || !info.name) return;
clientName = info.third_party ? `${info.name} (third-party app)` : info.name;
clientName = info.third_party ? `${info.name} (third-party app)` : info.name;
clientHost = info.third_party ? info.redirect_host || '' : '';
clientHost = info.third_party ? info.redirect_host || '' : '';
renderChooserSubtitle();
renderChooserSubtitle();
+ renderConsent(info);
})
})
.catch(() => {});
.catch(() => {});
}
}
+
+/**
+ * The consent screen (plan T2, docs/t2-projects-and-grants.md §9): what a third-party app may do if you continue,
+ * from the Network's own answer (the capability catalog's names and descriptions, sensitive ones marked), never from
+ * this page's URL. A request that names nothing gets nothing: the app only learns which account you are. Built from
+ * DOM nodes: names and descriptions come from the catalog, the app's name from its developer.
+ */
+function renderConsent(info) {
+ const box = document.getElementById('oauth-consent');
+ if (!box || !info || !info.third_party) return;
+ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+ box.replaceChildren();
+ const head = el('p', 'consent-head');
+ head.append(el('b', null, info.name), ' ');
+ if (info.redirect_host) head.append(el('span', 'consent-host', `(${info.redirect_host})`), ' ');
+ const caps = Array.isArray(info.capabilities) ? info.capabilities : [];
+ head.append(caps.length ? 'is a third-party app. If you continue, it can:' : 'is a third-party app.');
+ box.append(head);
+ if (caps.length) {
+ const list = el('ul');
+ for (const c of caps) {
+ const li = el('li', c.sensitive ? 'sensitive' : null);
+ li.append(el('span', 'consent-name', c.name || c.id));
+ if (c.sensitive) li.append(el('span', 'consent-badge', 'Sensitive'));
+ if (c.description) li.append(el('small', null, c.description));
+ list.append(li);
+ }
+ box.append(list);
+ } else {
+ box.append(el('p', 'consent-none', 'It asks for nothing else: it will only learn which OpenVibe account you are.'));
+ }
+ const refused = Array.isArray(info.refused) ? info.refused : [];
+ if (refused.length) {
+ const p = el('p', 'consent-refused', 'It also asked for what it cannot be given, so it will not get: ');
+ refused.forEach((id, i) => { if (i) p.append(', '); p.append(el('code', null, id)); });
+ box.append(p);
+ }
+ box.hidden = false;
+}
let oauthConfirmInFlight = false;
let oauthConfirmInFlight = false;
// Generate particles
// Generate particles
@@ -176,6 +176,19 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -176,6 +176,19 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
/* ── Account Chooser ───────────────────────────────────── */
/* ── Account Chooser ───────────────────────────────────── */
.chooser-subtitle{font-size:13px;color:var(--text-muted);text-align:center;margin-bottom:20px;line-height:1.5}
.chooser-subtitle{font-size:13px;color:var(--text-muted);text-align:center;margin-bottom:20px;line-height:1.5}
.chooser-subtitle .app-name{color:var(--accent);font-weight:700}
.chooser-subtitle .app-name{color:var(--accent);font-weight:700}
+.oauth-consent{margin:0 0 20px;padding:14px 16px;border:1px solid var(--border);border-radius:12px;background:var(--bg-input);font-size:13px;line-height:1.5;text-align:left}
+.oauth-consent .consent-head{margin:0 0 10px;color:var(--text)}
+.oauth-consent .consent-head b{color:var(--accent)}
+.oauth-consent .consent-host{color:var(--text-muted)}
+.oauth-consent ul{list-style:none;margin:0;padding:0;display:grid;gap:8px}
+.oauth-consent li{padding:8px 10px;border-left:3px solid var(--border);border-radius:6px;background:rgba(255,255,255,0.02)}
+.oauth-consent li.sensitive{border-left-color:#f59e0b}
+.oauth-consent .consent-name{font-weight:600;color:var(--text)}
+.oauth-consent .consent-badge{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;font-size:11px;font-weight:700;color:#1a1204;background:#f59e0b;vertical-align:1px}
+.oauth-consent small{display:block;margin-top:2px;color:var(--text-muted);font-size:12px}
+.oauth-consent .consent-none,.oauth-consent .consent-refused{margin:0;color:var(--text-muted)}
+.oauth-consent .consent-refused{margin-top:10px;font-size:12px}
+.oauth-consent code{font-size:11.5px}
.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}
.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}
.account-item{
.account-item{
@@ -254,6 +267,9 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -254,6 +267,9 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
<p>One account. All of OpenVibe.</p>
<p>One account. All of OpenVibe.</p>
</div>
</div>
+ <!-- What a third-party app asks for (GET /oauth/client-info), above every way of signing in -->
+ <section class="oauth-consent" id="oauth-consent" aria-label="What this app asks for" hidden></section>
+
<!-- Tab bar (hidden when chooser is shown) -->
<!-- Tab bar (hidden when chooser is shown) -->
<noscript><p class="panel-note">Signing in and creating an account need JavaScript: turn it on for openvibe.network and reload this page. You can use almost everything on OpenVibe as a guest without signing in.</p></noscript>
<noscript><p class="panel-note">Signing in and creating an account need JavaScript: turn it on for openvibe.network and reload this page. You can use almost everything on OpenVibe as a guest without signing in.</p></noscript>
<div class="tab-bar" id="auth-tabs">
<div class="tab-bar" id="auth-tabs">
@@ -128,10 +128,13 @@ router.get('/authorize', async (req, res) => {
@@ -128,10 +128,13 @@ router.get('/authorize', async (req, res) => {
// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a
// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a
// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted
// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted
// link could claim to be any app. redirect_host is shown beside it: where the code will go.
// link could claim to be any app. redirect_host is shown beside it: where the code will go.
+// With the authorize request's `scope`, a third-party app also gets the consent screen's capability
+// list: the requested ids the app can be granted (catalog metadata, `sensitive` marked) and the
+// refused ids the screen must not name. Never another app, never a secret.
router.get('/client-info', async (req, res) => {
router.get('/client-info', async (req, res) => {
const db = await getDb(req);
const db = await getDb(req);
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
- const { client_id, redirect_uri } = req.query;
+ const { client_id, redirect_uri, scope } = req.query;
if (!client_id || !redirect_uri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });
if (!client_id || !redirect_uri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });
let host = '';
let host = '';
try { host = new URL(String(redirect_uri)).host; } catch { return res.status(400).json({ error: 'Invalid redirect_uri' }); }
try { host = new URL(String(redirect_uri)).host; } catch { return res.status(400).json({ error: 'Invalid redirect_uri' }); }
@@ -140,13 +143,14 @@ router.get('/client-info', async (req, res) => {
@@ -140,13 +143,14 @@ router.get('/client-info', async (req, res) => {
if (found.error && !found.pkce) return res.status(404).json({ error: found.error });
if (found.error && !found.pkce) return res.status(404).json({ error: found.error });
const app = found.app || null;
const app = found.app || null;
if (!app) return res.status(404).json({ error: 'Unknown client_id' });
if (!app) return res.status(404).json({ error: 'Unknown client_id' });
- return res.json({ name: app.name, third_party: true, redirect_host: host });
+ const { capabilities, refused } = devTokens.consentView(scope);
+ return res.json({ name: app.name, third_party: true, redirect_host: host, capabilities, refused });
}
}
const client = await db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));
const client = await db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));
if (!client) return res.status(404).json({ error: 'Unknown client_id' });
if (!client) return res.status(404).json({ error: 'Unknown client_id' });
const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());
const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());
if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });
if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });
- res.json({ name: client.name || String(client_id), third_party: false, redirect_host: host });
+ res.json({ name: client.name || String(client_id), third_party: false, redirect_host: host, capabilities: [], refused: [] });
});
});
// ── POST /oauth/confirm ─────────────────────────────────────
// ── POST /oauth/confirm ─────────────────────────────────────
@@ -59,7 +59,41 @@ function projectNamespaces(projectId) {
@@ -59,7 +59,41 @@ function projectNamespaces(projectId) {
return [projectId, `app.${projectId}.*`];
return [projectId, `app.${projectId}.*`];
}
}
-async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {
+/**
+ * The capability ids a requested `scope` consents to: the app-grantable ids, in the order requested,
+ * duplicates dropped. This is the explicit set `issueCode` stores on the code — an absent or empty
+ * scope consents to nothing — and the ceiling every token exchange narrows from.
+ */
+function consentedCapabilities(scope) {
+ const seen = new Set(); const out = [];
+ for (const id of String(scope || '').split(/\s+/).filter(Boolean)) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ if (policy.isGrantable(id)) out.push(id);
+ }
+ return out;
+}
+
+/**
+ * The consent screen's view of a requested scope. `capabilities` are the app-grantable ids with the
+ * catalog's metadata, in the order requested; `name` is the catalog id (capability@1 has no display
+ * name of its own). `refused` are the requested ids the screen cannot name because they are unknown
+ * or not grantable to apps — the same filter `issueCode` applies, so the screen lists exactly what a
+ * code from this request can carry. Nothing here reveals another app or a secret.
+ */
+function consentView(scope) {
+ const capabilities = []; const refused = []; const seen = new Set();
+ for (const id of String(scope || '').split(/\s+/).filter(Boolean)) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ const g = policy.grantability(id);
+ if (g.grantable) capabilities.push({ id, name: g.capability.id, description: g.capability.description || '', sensitive: !!g.capability.sensitive });
+ else refused.push(id);
+ }
+ return { capabilities, refused };
+}
+
+async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db, allowEmpty }) {
const aud = String(audience || '').trim();
const aud = String(audience || '').trim();
if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');
if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');
if (app.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {
if (app.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {
@@ -70,7 +104,10 @@ async function mint({ app, project, audience, scope, limit, onBehalfOf, privateK
@@ -70,7 +104,10 @@ async function mint({ app, project, audience, scope, limit, onBehalfOf, privateK
const missing = wanted ? wanted.filter(w => !held.includes(w)) : [];
const missing = wanted ? wanted.filter(w => !held.includes(w)) : [];
if (missing.length) return oauthError(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);
if (missing.length) return oauthError(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);
const cap = wanted || held;
const cap = wanted || held;
- if (!cap.length) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);
+ // An authorization code that consented to nothing still mints an identifying token, with `cap: []`:
+ // the person is named (on_behalf_of) and the app can do nothing until it consents to a scope. Other
+ // grants (client_credentials) still refuse an empty set.
+ if (!cap.length && !allowEmpty) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
const claims = {
const claims = {
iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),
iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),
@@ -188,11 +225,13 @@ async function handleTokenRequest(db, body, { privateKey, issuer, config, ctx })
@@ -188,11 +225,13 @@ async function handleTokenRequest(db, body, { privateKey, issuer, config, ctx })
if (!verifierMatches(body.code_verifier, row.code_challenge)) return oauthError(400, 'invalid_grant', 'PKCE verification failed');
if (!verifierMatches(body.code_verifier, row.code_challenge)) return oauthError(400, 'invalid_grant', 'PKCE verification failed');
const user = await db.prepare('SELECT subject_id, is_banned FROM users WHERE subject_id = ?').get(row.user_subject);
const user = await db.prepare('SELECT subject_id, is_banned FROM users WHERE subject_id = ?').get(row.user_subject);
if (!user || user.is_banned) return oauthError(400, 'invalid_grant', 'User not found or banned');
if (!user || user.is_banned) return oauthError(400, 'invalid_grant', 'User not found or banned');
- // The exchange may narrow what was authorized, never widen it.
- const authorized = row.scope ? row.scope.split(' ') : null;
+ // The consent the code carries is explicit: `row.scope` is exactly the grantable ids the
+ // authorize requested, and an empty set means the person consented to nothing. The exchange
+ // may narrow it, never widen it, and a no-consent code still yields an identifying token.
+ const consented = String(row.scope || '').split(/\s+/).filter(Boolean);
const asked = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : null;
const asked = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : null;
- if (authorized && asked && asked.some(s => !authorized.includes(s))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');
- return await mint({ app, project, audience: body.audience, scope: body.scope, limit: authorized, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });
+ if (asked && asked.some(s => !consented.includes(s))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');
+ return await mint({ app, project, audience: body.audience, scope: body.scope, limit: consented, allowEmpty: consented.length === 0, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });
}
}
return oauthError(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');
return oauthError(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');
}
}
@@ -233,12 +272,13 @@ async function issueCode(db, { app, project, user, redirectUri, scope, challenge
@@ -233,12 +272,13 @@ async function issueCode(db, { app, project, user, redirectUri, scope, challenge
const code = crypto.randomBytes(32).toString('hex');
const code = crypto.randomBytes(32).toString('hex');
await db.prepare(`INSERT INTO dev_auth_codes (code_hash, app_id, user_subject, redirect_uri, scope, code_challenge, expires_at)
await db.prepare(`INSERT INTO dev_auth_codes (code_hash, app_id, user_subject, redirect_uri, scope, code_challenge, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(store.hashSecret(code), app.id, subject, redirectUri,
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(store.hashSecret(code), app.id, subject, redirectUri,
- String(scope || '').split(/\s+/).filter(s => policy.isGrantable(s)).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());
+ consentedCapabilities(scope).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());
await db.prepare('DELETE FROM dev_auth_codes WHERE expires_at < ?').run(new Date(Date.now() - 3600 * 1000).toISOString());
await db.prepare('DELETE FROM dev_auth_codes WHERE expires_at < ?').run(new Date(Date.now() - 3600 * 1000).toISOString());
return { code };
return { code };
}
}
module.exports = {
module.exports = {
isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, projectNamespaces, TOKEN_TTL_S,
isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, projectNamespaces, TOKEN_TTL_S,
+ consentedCapabilities, consentView,
mintExportToken, exportSubject, EXPORT_CAPS, EXPORT_PURPOSE,
mintExportToken, exportSubject, EXPORT_CAPS, EXPORT_PURPOSE,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+'use strict';
+// The backend of the third-party OAuth consent screen (plan T2; docs/t2-projects-and-grants.md §9).
+// GET /oauth/client-info carries the requested capability ids with their catalog metadata (sensitive
+// marked) and refuses the rest; an authorization code carries an explicit consented set (an absent or
+// empty scope = nothing), and the token exchange can only narrow it, never widen it.
+// node test/consent-screen.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { serviceAuth } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+
+(async () => {
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-consent-'));
+const log = console.log; console.log = () => {};
+const db = getDb();
+console.log = log;
+await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
+ (1, 'owner', 'x', 'user'), (2, 'ops', 'x', 'admin')`).run();
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const ISSUER = 'https://openvibe.network';
+const app = express();
+app.use(express.json());
+app.use(express.urlencoded({ extended: true }));
+app.locals.db = db;
+app.locals.config = {
+ baseUrl: ISSUER, loginUrl: ISSUER,
+ jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },
+ // A staff-set allowance only: the sandbox default is switched off, so the grants below are the
+ // whole story (test/developer-defaults.test.js covers the code default).
+ developer: { sandboxAudiences: 'openvibe.media', sandboxAllowance: '', credentialOverlapS: 60 },
+};
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+app.use('/oauth', require('../server/auth/oauth-routes'));
+app.use('/api/v1/projects', require('../server/developer/routes').router());
+const server = http.createServer(app);
+
+const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+const T = { owner: userToken(1), ops: userToken(2) };
+
+(async () => {
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const api = async (who, method, p, body) => {
+ const h = { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) };
+ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ return { status: r.status, text, body: text ? JSON.parse(text) : null };
+ };
+ const token = (form) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form) })
+ .then(async r => ({ status: r.status, body: await r.json() }));
+ const verify = (t) => serviceAuth.verifyServiceToken(t, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media', acceptSandbox: true });
+
+ // ── A sandbox project with two approved, app-grantable capabilities ──
+ let r = await api('owner', 'POST', '', { name: 'Consent Demo' });
+ assert.strictEqual(r.status, 201, r.text);
+ const P = r.body.id;
+ r = await api('ops', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.delete', 'media.object.read'] });
+ assert.strictEqual(r.status, 200, r.text);
+ const REDIRECT = 'https://dev.example.com/cb';
+ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Consent Demo App', environment: 'sandbox', type: 'public', redirect_uris: [REDIRECT] });
+ assert.strictEqual(r.status, 201, r.text);
+ const A = r.body.id;
+ for (const cap of ['media.object.delete', 'media.object.read']) {
+ r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: cap });
+ assert.strictEqual(r.body.status, 'approved', `${cap}: ${r.text}`);
+ }
+
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ const info = (q) => fetch(`${base}/oauth/client-info?${new URLSearchParams({ client_id: A, redirect_uri: REDIRECT, code_challenge: challenge, code_challenge_method: 'S256', ...q })}`)
+ .then(async x => ({ status: x.status, body: await x.json() }));
+ const confirm = (scope) => fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ token: T.owner, client_id: A, redirect_uri: REDIRECT, state: 's', code_challenge: challenge, code_challenge_method: 'S256', ...(scope === undefined ? {} : { scope }) }) })
+ .then(async x => ({ status: x.status, body: await x.json() }));
+ const exchange = (code, extra = {}) => token({ grant_type: 'authorization_code', client_id: A, code, redirect_uri: REDIRECT, audience: 'openvibe.media', code_verifier: verifier, ...extra });
+ const codeFor = async (scope) => new URL((await confirm(scope)).body.redirect).searchParams.get('code');
+
+ // ── client-info names the capability ids the app asks for; a scope-less call gets none ──
+ let ci = await info({});
+ assert.strictEqual(ci.status, 200, JSON.stringify(ci.body));
+ assert.deepStrictEqual(ci.body.capabilities, []);
+ assert.deepStrictEqual(ci.body.refused, []);
+ assert.strictEqual(ci.body.client_secret, undefined, 'nothing about the app\'s secret');
+
+ ci = await info({ scope: 'media.object.delete media.object.read nope.not.real network.coins.credit media.object.delete' });
+ assert.strictEqual(ci.status, 200);
+ assert.deepStrictEqual(ci.body.capabilities.map(c => c.id), ['media.object.delete', 'media.object.read'], 'app-grantable ids, in the order requested, deduped');
+ const del = ci.body.capabilities.find(c => c.id === 'media.object.delete');
+ assert.strictEqual(del.sensitive, true, 'the sensitive capability is marked');
+ assert.strictEqual(del.name, 'media.object.delete');
+ assert.match(del.description, /Delete an object/);
+ const read = ci.body.capabilities.find(c => c.id === 'media.object.read');
+ assert.strictEqual(read.sensitive, false);
+ assert.deepStrictEqual(ci.body.refused, ['nope.not.real', 'network.coins.credit'], 'unknown/ungrantable ids the screen must not name');
+
+ // ── A scope-less authorize consents to nothing: no code can obtain media.object.delete ──
+ let code = await codeFor(undefined);
+ let t = await exchange(code);
+ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
+ let v = verify(t.body.access_token);
+ assert.strictEqual(v.ok, true, v.reason);
+ assert.deepStrictEqual(v.claims.cap, [], 'an absent scope consents to nothing');
+ assert.ok(v.claims.on_behalf_of, 'the person is still identified');
+ assert.strictEqual(v.claims.project_id, P);
+ assert.strictEqual(t.body.scope, '');
+ assert.strictEqual(t.body.refresh_token, undefined, 'app tokens have no refresh token to widen with');
+
+ // The same code, asking for a capability at the token endpoint, is refused.
+ code = await codeFor(undefined);
+ t = await exchange(code, { scope: 'media.object.delete' });
+ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope');
+
+ // An empty scope string is the same as absent.
+ code = await codeFor('');
+ t = await exchange(code);
+ assert.strictEqual(t.status, 200); assert.deepStrictEqual(verify(t.body.access_token).claims.cap, []);
+
+ // A refresh_token request never widens: apps do not support the grant at all.
+ t = await token({ grant_type: 'refresh_token', client_id: A, refresh_token: 'x'.repeat(96) });
+ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'unsupported_grant_type');
+
+ // ── A scope-bearing authorize consents to exactly the named grantable ids ──
+ code = await codeFor('media.object.read media.object.delete nope.not.real');
+ t = await exchange(code);
+ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
+ v = verify(t.body.access_token);
+ assert.deepStrictEqual(v.claims.cap.slice().sort(), ['media.object.delete', 'media.object.read'], 'the named grantable ids only');
+ assert.deepStrictEqual(t.body.scope.split(' ').sort(), ['media.object.delete', 'media.object.read'], 'exactly the consented ids');
+
+ // The exchange narrows, never widens.
+ code = await codeFor('media.object.read');
+ t = await exchange(code, { scope: 'media.object.read media.object.delete' });
+ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope');
+
+ code = await codeFor('media.object.read media.object.delete');
+ t = await exchange(code, { scope: 'media.object.read' });
+ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
+ assert.deepStrictEqual(verify(t.body.access_token).claims.cap, ['media.object.read'], 'narrowed to the asked subset');
+
+ console.log('consent screen: all checks passed');
+ server.close();
+})().catch(err => { console.error(err); process.exit(1); });
+})().catch(err => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+// The consent screen on /login (public/js/login.js renderConsent, plan T2 §9): a third-party app's requested
+// capabilities from GET /oauth/client-info, sensitive ones marked, refused ids named, nothing parsed as HTML, and
+// a first-party app shows no box. Runs the function itself against a minimal DOM.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'login.html'), 'utf8');
+const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'login.js'), 'utf8');
+
+assert.match(html, /<section class="oauth-consent" id="oauth-consent" aria-label="What this app asks for" hidden><\/section>/, 'the box is on the page, hidden until the app is known');
+assert.ok(html.indexOf('id="oauth-consent"') < html.indexOf('id="auth-tabs"'), 'above every way of signing in');
+assert.match(js, /scope: params\.get\('scope'\) \|\| ''/, 'client-info is asked about the requested scope');
+
+// Pull renderConsent out of login.js and run it.
+const start = js.indexOf('function renderConsent(info) {');
+assert.ok(start > 0, 'renderConsent exists');
+let depth = 0, end = start;
+for (let i = js.indexOf('{', start); i < js.length; i++) {
+ if (js[i] === '{') depth++;
+ else if (js[i] === '}' && --depth === 0) { end = i + 1; break; }
+}
+const source = js.slice(start, end);
+assert.ok(!/innerHTML/.test(source), 'built from DOM nodes only');
+
+class Node {
+ constructor(tag) { this.tagName = tag; this.children = []; this.className = ''; this.hidden = true; this._text = ''; }
+ set textContent(v) { this._text = String(v); this.children = []; }
+ get textContent() { return this._text + this.children.map((c) => (typeof c === 'string' ? c : c.textContent)).join(''); }
+ append(...kids) { this.children.push(...kids); }
+ replaceChildren() { this.children = []; this._text = ''; }
+ findAll(pred, out = []) { for (const c of this.children) if (typeof c !== 'string') { if (pred(c)) out.push(c); c.findAll(pred, out); } return out; }
+}
+function run(info) {
+ const box = new Node('section');
+ const document = { getElementById: (id) => (id === 'oauth-consent' ? box : null), createElement: (t) => new Node(t) };
+ vm.runInNewContext(`${source}; renderConsent(info);`, { document, info });
+ return box;
+}
+
+{
+ const box = run({ name: 'Clip<b>Tool</b>', third_party: true, redirect_host: 'cliptool.example', capabilities: [
+ { id: 'media.object.read', name: 'Read your media', description: 'See the videos and files in your library.', sensitive: false },
+ { id: 'media.object.delete', name: 'Delete your media', description: 'Remove videos and files for good.', sensitive: true },
+ ], refused: ['network.admin'] });
+ assert.strictEqual(box.hidden, false, 'shown for a third-party app');
+ const text = box.textContent;
+ assert.ok(text.includes('Clip<b>Tool</b>') && text.includes('(cliptool.example)'), 'the app name is text, never markup');
+ assert.ok(text.includes('If you continue, it can:'));
+ const items = box.findAll((n) => n.tagName === 'li');
+ assert.strictEqual(items.length, 2);
+ assert.strictEqual(items[1].className, 'sensitive', 'the sensitive capability is marked');
+ assert.ok(items[1].textContent.includes('Sensitive') && !items[0].textContent.includes('Sensitive'));
+ assert.ok(text.includes('it will not get: ') && box.findAll((n) => n.tagName === 'code')[0].textContent === 'network.admin', 'refused ids are named');
+}
+{
+ const box = run({ name: 'Quiet', third_party: true, capabilities: [], refused: [] });
+ assert.strictEqual(box.hidden, false);
+ assert.ok(box.textContent.includes('it will only learn which OpenVibe account you are'), 'nothing asked, nothing given');
+ assert.strictEqual(box.findAll((n) => n.tagName === 'li').length, 0);
+}
+{
+ const box = run({ name: 'OpenVibe.Live', third_party: false, capabilities: [] });
+ assert.strictEqual(box.hidden, true, 'a first-party site shows no consent box');
+}
+console.log('consent ui: all checks passed');
@@ -269,7 +269,7 @@ const secretsSeen = [];
@@ -269,7 +269,7 @@ const secretsSeen = [];
assert.strictEqual(c.status, 400, 'confirm without PKCE refused');
assert.strictEqual(c.status, 400, 'confirm without PKCE refused');
c = await confirm('stranger', { code_challenge: challenge, code_challenge_method: 'S256' });
c = await confirm('stranger', { code_challenge: challenge, code_challenge_method: 'S256' });
assert.strictEqual(c.status, 403, 'sandbox apps are authorized only by project members');
assert.strictEqual(c.status, 403, 'sandbox apps are authorized only by project members');
- const codeFor = async () => new URL((await confirm('dev', { code_challenge: challenge, code_challenge_method: 'S256' })).body.redirect).searchParams.get('code');
+ const codeFor = async (scope = 'media.object.upload') => new URL((await confirm('dev', { code_challenge: challenge, code_challenge_method: 'S256', scope })).body.redirect).searchParams.get('code');
const exchange = (code, extra = {}) => token({ grant_type: 'authorization_code', client_id: B, code, redirect_uri: REDIRECT, audience: 'openvibe.media', ...extra });
const exchange = (code, extra = {}) => token({ grant_type: 'authorization_code', client_id: B, code, redirect_uri: REDIRECT, audience: 'openvibe.media', ...extra });
t = await cc(B, '');
t = await cc(B, '');
assert.strictEqual(t.body.error, 'unauthorized_client', 'public apps cannot use client_credentials');
assert.strictEqual(t.body.error, 'unauthorized_client', 'public apps cannot use client_credentials');