openvibers/openvibe.mail Commits
OpenVibe.Mail: the browser extension may manage the person's masks (#28)
Changes
@@@ -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,@@@ -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 };@@@ -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',@@@ -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 });@@@ -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');@@@ -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 };nnew file mode 100644@@@ -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);++});@@@ -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)),
@@ -105,7 +105,7 @@ async function createApp(opts = {}) {
@@ -105,7 +105,7 @@ async function createApp(opts = {}) {
app.use('/auth', sso.router(express));
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.
// 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({
app.use('/api/v1', rateLimit({
windowMs: 60_000,
windowMs: 60_000,
@@ -58,6 +58,25 @@ function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console })
@@ -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. */
/** The public key of the key that signed this token, for the house's service-token check. */
async function pemForToken(token) {
async function pemForToken(token) {
let kid = null;
let kid = null;
@@ -73,7 +92,7 @@ function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console })
@@ -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 };
module.exports = { createKeyStore, jwksUrl };
@@ -88,6 +88,11 @@ function load(overrides = null) {
@@ -88,6 +88,11 @@ function load(overrides = null) {
prefix: env.VALKEY_PREFIX || 'ov:mail:',
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: {
network: {
url: env.OV_NETWORK_URL || 'https://openvibe.network',
url: env.OV_NETWORK_URL || 'https://openvibe.network',
internalUrl: env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
internalUrl: env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
@@ -191,7 +191,7 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
@@ -191,7 +191,7 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
// ---- masks -------------------------------------------------------------------------------
// ---- 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 owner = ownerOf(req);
const body = req.body || {};
const body = req.body || {};
try {
try {
@@ -211,24 +211,24 @@ function createApi({ db, config, stalwart, events, principal, log = console, smt
@@ -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) });
const list = await masks.listMasks({ db, owner: ownerOf(req) });
return res.json({ masks: list });
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 });
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.');
if (!mask) return fail(req, res, 404, 'mail.not_found', 'That mask does not exist.');
return res.json({ mask });
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 });
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.');
if (!mask) return fail(req, res, 404, 'mail.not_found', 'That mask does not exist.');
return res.json({ mask });
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 });
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.');
if (!mask) return fail(req, res, 404, 'mail.not_found', 'That mask does not exist.');
return res.json({ mask });
return res.json({ mask });
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
const contracts = require('openvibe-contracts');
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}$/;
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;
let cached = null;
/** The https origins of every first-party domain the installed Contracts manifest names. */
/** The https origins of every first-party domain the installed Contracts manifest names. */
@@ -44,7 +46,7 @@ function originOf(url) {
@@ -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
* 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)
* 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 allowed = new Set(manifestOrigins());
const own = originOf(baseUrl);
const own = originOf(baseUrl);
if (own) allowed.add(own);
if (own) allowed.add(own);
@@ -52,10 +54,16 @@ function createCors({ extraOrigins = [], baseUrl = null } = {}) {
@@ -52,10 +54,16 @@ function createCors({ extraOrigins = [], baseUrl = null } = {}) {
const origin = originOf(value);
const origin = originOf(value);
if (origin) allowed.add(origin);
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) {
return function cors(req, res, next) {
const origin = req.get('origin');
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.set('Access-Control-Allow-Origin', origin);
res.vary('Origin');
res.vary('Origin');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, traceparent, X-OpenVibe-Request-Id');
@@ -23,6 +23,8 @@
@@ -23,6 +23,8 @@
const { serviceAuth, http, ids } = require('openvibe-contracts');
const { serviceAuth, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
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 USER_SUB = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@@ -30,6 +32,9 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
@@ -30,6 +32,9 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const CAPABILITIES = {
const CAPABILITIES = {
write: 'mail.addresses.write',
write: 'mail.addresses.write',
read: 'mail.messages.read',
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. */
/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */
@@ -60,6 +65,11 @@ function requesterOfSub(sub) {
@@ -60,6 +65,11 @@ function requesterOfSub(sub) {
return String(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) {
function capabilitiesOf(claims) {
const cap = claims && claims.cap;
const cap = claims && claims.cap;
if (Array.isArray(cap)) return cap.filter((c) => typeof c === 'string');
if (Array.isArray(cap)) return cap.filter((c) => typeof c === 'string');
@@ -75,6 +85,31 @@ function createPrincipal({ config, keys }) {
@@ -75,6 +85,31 @@ function createPrincipal({ config, keys }) {
if (!token) return { error: [401, 'token.invalid', 'the Bearer token is empty'] };
if (!token) return { error: [401, 'token.invalid', 'the Bearer token is empty'] };
const payload = decodePayload(token);
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)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const publicKey = await keys.pemForToken(token);
const publicKey = await keys.pemForToken(token);
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
@@ -162,8 +197,15 @@ function createPrincipal({ config, keys }) {
@@ -162,8 +197,15 @@ function createPrincipal({ config, keys }) {
return null;
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) {
function requirePerson(capability) {
+ const wanted = capability == null ? [] : (Array.isArray(capability) ? capability : [capability]);
return function guard(req, res, next) {
return function guard(req, res, next) {
const principal = req.principal || { kind: 'anonymous' };
const principal = req.principal || { kind: 'anonymous' };
if (principal.kind === 'anonymous') {
if (principal.kind === 'anonymous') {
@@ -180,9 +222,10 @@ function createPrincipal({ config, keys }) {
@@ -180,9 +222,10 @@ function createPrincipal({ config, keys }) {
ctx: req.ov,
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', {
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,
ctx: req.ov,
});
});
}
}
@@ -195,4 +238,4 @@ function createPrincipal({ config, keys }) {
@@ -195,4 +238,4 @@ function createPrincipal({ config, keys }) {
return { middleware, resolve, requirePerson, subjectOf, CAPABILITIES };
return { middleware, resolve, requirePerson, subjectOf, CAPABILITIES };
}
}
-module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
+module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin, dockIssuer };
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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);
+});
@@ -40,6 +40,16 @@ async function startNetwork() {
@@ -40,6 +40,16 @@ async function startNetwork() {
return signJwt(claims, privatePem);
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 server = http.createServer((req, res) => {
const chunks = [];
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('data', (c) => chunks.push(c));
@@ -67,6 +77,7 @@ async function startNetwork() {
@@ -67,6 +77,7 @@ async function startNetwork() {
state: st,
state: st,
addUser,
addUser,
userToken,
userToken,
+ dockToken,
signJwt: (claims) => signJwt(claims, privatePem),
signJwt: (claims) => signJwt(claims, privatePem),
setDown(v) { st.down = v; },
setDown(v) { st.down = v; },
close: () => new Promise((r) => server.close(r)),
close: () => new Promise((r) => server.close(r)),