openvibers/openvibe.mail Commits

OpenVibe.Mail: the browser extension may manage the person's masks (#28)

OP OpenVibers committed 5 days ago · f9e6b1e94f · 8 files, +197 −13

The Companion (the browser extension) holds the Network's Dock token for the public
client 'companion'. Mail accepts that token as a person-scoped principal when it
carries the scope 'mail.mask.manage', and gives it that person's own masks and
nothing else: no message, no address, no burner.

The mask routes now take a list of the capabilities that open them, so the
person's own token, an app with the address capability and the Companion (which
holds the mask capability alone) all keep working.

CORS learns the extension's origin: an exact chrome-extension://<id>, or the
literal pattern moz-extension://* for Firefox's random origins, driven by
MAIL_EXTENSION_ORIGINS. Empty by default: no extension may call this API.

Tests: the Companion manages the person's masks, sees nobody else's, is refused
without the scope, is refused when the token is another client's, and its origin
is allowed by CORS while another origin is not.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 26d68f0..92e658b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -105,7 +105,7 @@ async function createApp(opts = {}) {
app.use('/auth', sso.router(express));
// CORS: a Bearer token may be sent from any first-party origin in the manifests; a cookie may not cross sites.
-- app.use('/api/v1', createCors({ baseUrl: config.baseUrl }));
++ app.use('/api/v1', createCors({ baseUrl: config.baseUrl, extensionOrigins: config.extensionOrigins }));
app.use('/api/v1', rateLimit({
windowMs: 60_000,
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
iindex fc19e97..66694a5 100644
---- a/server/auth/keys.js
++++ b/server/auth/keys.js
@@@ -58,6 +58,25 @@ function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console })
}
}
++ /**
++ * The Companion's own token: the Network's Dock token (issuer <network issuer>/dock, audience
++ * openvibe.network), the one the browser extension holds after its one-click sign-in. It is a
++ * person's token with a scope ceiling, so it is verified like a session token against the dock
++ * issuer — the same signature, the same key, a different issuer.
++ */
++ async function verifyDock(token, { issuer, audience, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[OpenVibe.Mail] dock token rejected (${code || 'error'})`);
++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
++ }
++ }
++
/** The public key of the key that signed this token, for the house's service-token check. */
async function pemForToken(token) {
let kid = null;
@@@ -73,7 +92,7 @@ function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console })
}
}
-- return { ensure, get, loaded, status: client.status, verifyUser, pemForToken, client };
++ return { ensure, get, loaded, status: client.status, verifyUser, verifyDock, pemForToken, client };
}
module.exports = { createKeyStore, jwksUrl };
ddiff --git a/server/config.js b/server/config.js
iindex 237ded5..afbb2a9 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -88,6 +88,11 @@ function load(overrides = null) {
prefix: env.VALKEY_PREFIX || 'ov:mail:',
},
++ // The browser extension's origins, allowed by CORS on the mask routes: exact
++ // chrome-extension://<id> values, or the literal pattern moz-extension://* (Firefox gives every
++ // install a random origin). Empty: no extension may call this API from a page.
++ extensionOrigins: list('MAIL_EXTENSION_ORIGINS'),
++
network: {
url: env.OV_NETWORK_URL || 'https://openvibe.network',
internalUrl: env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 091112e..4e14a2c 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -191,7 +191,7 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
// ---- masks -------------------------------------------------------------------------------
-- router.post('/api/v1/masks', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {
++ router.post('/api/v1/masks', requirePerson([principal.CAPABILITIES.write, principal.CAPABILITIES.masks]), wrap(async (req, res) => {
const owner = ownerOf(req);
const body = req.body || {};
try {
@@@ -211,24 +211,24 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
}
}));
-- router.get('/api/v1/masks', requirePerson(principal.CAPABILITIES.read), wrap(async (req, res) => {
++ router.get('/api/v1/masks', requirePerson([principal.CAPABILITIES.read, principal.CAPABILITIES.masks]), wrap(async (req, res) => {
const list = await masks.listMasks({ db, owner: ownerOf(req) });
return res.json({ masks: list });
}));
-- router.get('/api/v1/masks/:id', requirePerson(principal.CAPABILITIES.read), wrap(async (req, res) => {
++ router.get('/api/v1/masks/:id', requirePerson([principal.CAPABILITIES.read, principal.CAPABILITIES.masks]), wrap(async (req, res) => {
const mask = await masks.findMask({ db, owner: ownerOf(req), id: req.params.id });
if (!mask) return fail(req, res, 404, 'mail.not_found', 'That mask does not exist.');
return res.json({ mask });
}));
-- router.post('/api/v1/masks/:id/disable', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {
++ router.post('/api/v1/masks/:id/disable', requirePerson([principal.CAPABILITIES.write, principal.CAPABILITIES.masks]), wrap(async (req, res) => {
const mask = await masks.disableMask({ db, stalwart, owner: ownerOf(req), id: req.params.id });
if (!mask) return fail(req, res, 404, 'mail.not_found', 'That mask does not exist.');
return res.json({ mask });
}));
-- router.delete('/api/v1/masks/:id', requirePerson(principal.CAPABILITIES.write), wrap(async (req, res) => {
++ router.delete('/api/v1/masks/:id', requirePerson([principal.CAPABILITIES.write, principal.CAPABILITIES.masks]), wrap(async (req, res) => {
const mask = await masks.disableMask({ db, stalwart, owner: ownerOf(req), id: req.params.id });
if (!mask) return fail(req, res, 404, 'mail.not_found', 'That mask does not exist.');
return res.json({ mask });
ddiff --git a/server/http/cors.js b/server/http/cors.js
iindex 4b9a82d..430c17f 100644
---- a/server/http/cors.js
++++ b/server/http/cors.js
@@@ -13,6 +13,8 @@
const contracts = require('openvibe-contracts');
const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
++// Firefox gives every install of an extension a different origin, so the wildcard is a pattern, not a list.
++const MOZ_EXTENSION_RE = /^moz-extension:\/\/[0-9a-f-]{36}$/;
let cached = null;
/** The https origins of every first-party domain the installed Contracts manifest names. */
@@@ -44,7 +46,7 @@ function originOf(url) {
* opts.baseUrl this site's origin, always allowed (the same-origin API calls need no CORS, but a page that
* names its own origin explicitly is still first-party)
*/
--function createCors({ extraOrigins = [], baseUrl = null } = {}) {
++function createCors({ extraOrigins = [], baseUrl = null, extensionOrigins = [] } = {}) {
const allowed = new Set(manifestOrigins());
const own = originOf(baseUrl);
if (own) allowed.add(own);
@@@ -52,10 +54,16 @@ function createCors({ extraOrigins = [], baseUrl = null } = {}) {
const origin = originOf(value);
if (origin) allowed.add(origin);
}
++ // The Companion (the browser extension) calls this API from its own origin: an exact
++ // chrome-extension://<id> or the literal pattern moz-extension://* for Firefox. It is allowed
++ // to read the person's masks with their own token, and nothing else is opened to it.
++ const extensions = new Set((extensionOrigins || []).filter((v) => v && v !== 'moz-extension://*'));
++ const anyMoz = (extensionOrigins || []).includes('moz-extension://*');
return function cors(req, res, next) {
const origin = req.get('origin');
-- if (origin && allowed.has(origin)) {
++ const fromExtension = Boolean(origin) && (extensions.has(origin) || (anyMoz && MOZ_EXTENSION_RE.test(origin)));
++ if (origin && (allowed.has(origin) || fromExtension)) {
res.set('Access-Control-Allow-Origin', origin);
res.vary('Origin');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 5d916e6..d1083cd 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -23,6 +23,8 @@
const { serviceAuth, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
++// The scope the browser extension holds, and the only one Mail gives it: its own masks.
++const MAIL_MASK_SCOPE = 'mail.mask.manage';
const USER_SUB = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@@@ -30,6 +32,9 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const CAPABILITIES = {
write: 'mail.addresses.write',
read: 'mail.messages.read',
++ // masks is what the browser extension holds: the Companion's own scoped token may manage the
++ // person's masks and nothing else (it never reads a message, never makes a burner).
++ masks: 'mail.mask.manage',
};
/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */
@@@ -60,6 +65,11 @@ function requesterOfSub(sub) {
return String(sub);
}
++/** The issuer the Network's Dock tokens carry: the network issuer with /dock behind it. */
++function dockIssuer(config) {
++ return String(config.network.issuer || '').replace(/\/+$/, '') + '/dock';
++}
++
function capabilitiesOf(claims) {
const cap = claims && claims.cap;
if (Array.isArray(cap)) return cap.filter((c) => typeof c === 'string');
@@@ -75,6 +85,31 @@ function createPrincipal({ config, keys }) {
if (!token) return { error: [401, 'token.invalid', 'the Bearer token is empty'] };
const payload = decodePayload(token);
++
++ // The Companion (the browser extension): the Network's Dock token for the public client
++ // 'companion', scoped to 'mail.mask.manage'. It acts for the person who signed in, so it
++ // gets that person's own masks and nothing else.
++ if (payload && payload.client_id === 'companion') {
++ const v = await keys.verifyDock(token, { issuer: dockIssuer(config), audience: config.network.sessionAudience });
++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
++ const scope = String(v.claims.scope || '').split(/\s+/).filter(Boolean);
++ if (!scope.includes(MAIL_MASK_SCOPE)) {
++ return { error: [403, 'mail.capability_missing', 'this token may not manage masks'] };
++ }
++ if (!ids.isSubjectId('user', v.claims.subject_id)) {
++ return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
++ }
++ return {
++ principal: {
++ kind: 'app',
++ requester: 'app:companion',
++ subject: v.claims.subject_id,
++ capabilities: [CAPABILITIES.masks],
++ claims: v.claims,
++ },
++ };
++ }
++
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const publicKey = await keys.pemForToken(token);
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
@@@ -162,8 +197,15 @@ function createPrincipal({ config, keys }) {
return null;
}
-- /** A route for a person: their token, or an app acting for them with the right capability. */
++ /**
++ * A route for a person: their token, or an app acting for them with the right capability.
++ *
++ * 'capability' is the capability the route needs, or a list of them when more than one opens it
++ * (the mask routes are open to the person's own token, to an app with the address capability, and
++ * to the Companion, which holds the mask capability alone).
++ */
function requirePerson(capability) {
++ const wanted = capability == null ? [] : (Array.isArray(capability) ? capability : [capability]);
return function guard(req, res, next) {
const principal = req.principal || { kind: 'anonymous' };
if (principal.kind === 'anonymous') {
@@@ -180,9 +222,10 @@ function createPrincipal({ config, keys }) {
ctx: req.ov,
});
}
-- if (capability && !(principal.capabilities || []).includes(capability)) {
++ const held = principal.capabilities || [];
++ if (wanted.length && !wanted.some((c) => held.includes(c))) {
return http.sendProblem(res, 403, 'mail.capability_missing', {
-- detail: `The app needs the ${capability} capability for this route.`,
++ detail: `The app needs the ${wanted.join(' or ')} capability for this route.`,
ctx: req.ov,
});
}
@@@ -195,4 +238,4 @@ function createPrincipal({ config, keys }) {
return { middleware, resolve, requirePerson, subjectOf, CAPABILITIES };
}
--module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin, dockIssuer };
ddiff --git a/test/companion-masks.test.js b/test/companion-masks.test.js
nnew file mode 100644
iindex 0000000..819483d
---- /dev/null
++++ b/test/companion-masks.test.js
@@@ -0,0 +1,98 @@
++'use strict';
++/**
++ * The Companion (the browser extension) and the person's masks (Seven Domains plan R2 item 12).
++ *
++ * The rules these tests hold: the Companion holds the Network's Dock token for the public client
++ * 'companion', scoped to 'mail.mask.manage'; with that scope it manages that person's own masks and
++ * nothing else; without it, nothing; and its origin is allowed by CORS so the extension can ask.
++ */
++const test = require('node:test');
++const assert = require('node:assert');
++const { boot } = require('./helpers/boot');
++
++const EXTENSION_ORIGIN = 'chrome-extension://bjcjcfcecpkkmfndegkjekoipdmbgakd';
++
++test("the Companion manages the person's masks with the mask scope", async (t) => {
++ const s = await boot({ env: { MAIL_EXTENSION_ORIGINS: EXTENSION_ORIGIN } });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const token = s.network.dockToken(alice);
++
++ const made = await s.post('/api/v1/masks', { bearer: token, json: { label: 'acme-shop.com', purpose: 'companion' } });
++ assert.strictEqual(made.status, 201, JSON.stringify(made.body));
++ assert.match(made.body.mask.address, /@mask\.openvibe\.email$/);
++ assert.strictEqual(made.body.mask.label, 'acme-shop.com');
++
++ const list = await s.get('/api/v1/masks', { bearer: token });
++ assert.strictEqual(list.status, 200);
++ assert.strictEqual(list.body.masks.length, 1);
++
++ const id = made.body.mask.id;
++ const off = await s.post(`/api/v1/masks/${id}/disable`, { bearer: token });
++ assert.strictEqual(off.status, 200);
++ assert.strictEqual(off.body.mask.enabled, false);
++});
++
++test("the Companion sees the person's masks and nobody else's", async (t) => {
++ const s = await boot({ env: { MAIL_EXTENSION_ORIGINS: EXTENSION_ORIGIN } });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const bob = s.network.addUser('bob');
++ await s.post('/api/v1/masks', { as: alice, json: { label: 'acme-shop.com' } });
++ await s.post('/api/v1/masks', { as: bob, json: { label: 'other-shop.com' } });
++
++ const seen = await s.get('/api/v1/masks', { bearer: s.network.dockToken(alice) });
++ assert.strictEqual(seen.status, 200);
++ assert.deepStrictEqual(seen.body.masks.map((m) => m.label), ['acme-shop.com']);
++});
++
++test('a Companion token without the mask scope may not manage masks', async (t) => {
++ const s = await boot({ env: { MAIL_EXTENSION_ORIGINS: EXTENSION_ORIGIN } });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const without = s.network.dockToken(alice, { scope: 'dock activity' });
++ const made = await s.post('/api/v1/masks', { bearer: without, json: { label: 'acme-shop.com' } });
++ assert.strictEqual(made.status, 403);
++ assert.strictEqual(made.body.code, 'mail.capability_missing');
++ assert.strictEqual((await s.get('/api/v1/masks', { bearer: without })).status, 403);
++});
++
++test("a Dock token for another client is not the Companion's", async (t) => {
++ const s = await boot({ env: { MAIL_EXTENSION_ORIGINS: EXTENSION_ORIGIN } });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const other = s.network.dockToken(alice, { scope: 'mail.mask.manage', clientId: 'some-other-client' });
++ const made = await s.post('/api/v1/masks', { bearer: other, json: { label: 'acme-shop.com' } });
++ assert.strictEqual(made.status, 401);
++});
++
++test("the extension's origin is allowed by CORS, and another origin is not", async (t) => {
++ const s = await boot({ env: { MAIL_EXTENSION_ORIGINS: EXTENSION_ORIGIN } });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const token = s.network.dockToken(alice);
++
++ const ok = await s.get('/api/v1/masks', { bearer: token, origin: EXTENSION_ORIGIN });
++ assert.strictEqual(ok.status, 200);
++ assert.strictEqual(ok.headers.get('access-control-allow-origin'), EXTENSION_ORIGIN);
++
++ const stranger = await s.get('/api/v1/masks', { bearer: token, origin: 'https://evil.example.net' });
++ assert.strictEqual(stranger.headers.get('access-control-allow-origin'), null);
++});
++
++test('Firefox installs (moz-extension://*) are allowed when the service is told', async (t) => {
++ const s = await boot({ env: { MAIL_EXTENSION_ORIGINS: 'moz-extension://*' } });
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const origin = 'moz-extension://0f9e2b7c-4a1d-4d5e-9c3b-2f7a6d1e8b44';
++ const ok = await s.get('/api/v1/masks', { bearer: s.network.dockToken(alice), origin: origin });
++ assert.strictEqual(ok.headers.get('access-control-allow-origin'), origin);
++});
++
++test('without the setting, no extension origin is allowed', async (t) => {
++ const s = await boot();
++ t.after(() => s.close());
++ const alice = s.network.addUser('alice');
++ const ok = await s.get('/api/v1/masks', { bearer: s.network.dockToken(alice), origin: EXTENSION_ORIGIN });
++ assert.strictEqual(ok.headers.get('access-control-allow-origin'), null);
++});
ddiff --git a/test/helpers/network.js b/test/helpers/network.js
iindex c50d633..7451bde 100644
---- a/test/helpers/network.js
++++ b/test/helpers/network.js
@@@ -40,6 +40,16 @@ async function startNetwork() {
return signJwt(claims, privatePem);
}
++ /** The Companion's own token: the Network's Dock token (issuer <issuer>/dock) for the public client 'companion'. */
++ function dockToken(u, { scope = 'mail.mask.manage', ttl = 3600, clientId = 'companion' } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ const claims = {
++ iss: `${issuer}/dock`, sub: u.id, subject_id: u.subject, username: u.username,
++ client_id: clientId, scope, aud: ['openvibe.network', 'openvibe.chat'], iat: now, exp: now + ttl,
++ };
++ return signJwt(claims, privatePem);
++ }
++
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
@@@ -67,6 +77,7 @@ async function startNetwork() {
state: st,
addUser,
userToken,
++ dockToken,
signJwt: (claims) => signJwt(claims, privatePem),
setDown(v) { st.down = v; },
close: () => new Promise((r) => server.close(r)),