@volter/teams 0.1.59 → 0.1.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/core.mjs +5 -3
- package/server/delegated.mjs +12 -8
- package/server/machine-link.mjs +2 -2
- package/server/publications.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/teams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Volter Teams, the neutral team plane: machine identity and daemon core, the local door and server link, admission from machine grants, product doors, and the Teams server's neutral routes (company RFC 0008).",
|
|
6
6
|
"license": "MIT",
|
package/server/core.mjs
CHANGED
|
@@ -14,20 +14,22 @@ const productionIds={next(kind){return `${prefix[kind]??kind}_${randomBytes(16).
|
|
|
14
14
|
const productionClock={nowMs:()=>Date.now()};
|
|
15
15
|
export { productionIds, productionClock };
|
|
16
16
|
|
|
17
|
-
export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,identityIssuer=null,identityOrganizations=null,delegatedIssuer=null,uploadLimitBytes=256*1024*1024}={}) {
|
|
17
|
+
export function createTeamsApi({clock=productionClock,ids=productionIds,publicOrigin=null,identityIssuer=null,identityOrganizations=null,delegatedIssuer=null,delegatedIssuers=[],uploadLimitBytes=256*1024*1024}={}) {
|
|
18
18
|
// An identity service people may sign in with (protocol P02 federation); its tokens name this server's public origin.
|
|
19
19
|
identityIssuer=normalizePublicOrigin(identityIssuer);
|
|
20
20
|
if(identityIssuer&&!publicOrigin)throw new TypeError('an identity issuer needs the public origin its tokens name');
|
|
21
21
|
// A workplace whose delegated tokens admit a caller to one pane or folder (server/delegated.mjs); they name this origin too.
|
|
22
22
|
delegatedIssuer=normalizePublicOrigin(delegatedIssuer);
|
|
23
|
-
if(
|
|
23
|
+
if(!Array.isArray(delegatedIssuers)||delegatedIssuers.some(origin=>typeof origin!=='string'||!origin.trim()))throw new TypeError('delegatedIssuers must be an array of origins');
|
|
24
|
+
delegatedIssuers=Object.freeze([...new Set([delegatedIssuer,...delegatedIssuers.map(normalizePublicOrigin)].filter(Boolean))]);
|
|
25
|
+
if(delegatedIssuers.length&&!publicOrigin)throw new TypeError('a delegated issuer needs the public origin its tokens name');
|
|
24
26
|
if(typeof clock?.nowMs!=='function'||typeof ids?.next!=='function')throw new TypeError('clock.nowMs and ids.next are required');
|
|
25
27
|
if(!Number.isSafeInteger(uploadLimitBytes)||uploadLimitBytes<1||uploadLimitBytes>256*1024*1024)throw new TypeError('uploadLimitBytes must be between 1 and 268435456');
|
|
26
28
|
let store=null,serverId=null,localOrigin=null,harnessRoutes=null;
|
|
27
29
|
const events=new TeamEvents();
|
|
28
30
|
// Teams linked to organizations the identity service keeps for this server (identity-orgs.mjs).
|
|
29
31
|
if(identityOrganizations&&!identityIssuer)throw new TypeError('organizations need the identity issuer that keeps them');
|
|
30
|
-
return {clock,ids,events,publicOrigin,identityIssuer,identityOrganizations,delegatedIssuer,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
|
|
32
|
+
return {clock,ids,events,publicOrigin,identityIssuer,identityOrganizations,delegatedIssuer,delegatedIssuers,uploadLimitBytes,rateState:new Map(),openStreams:new Map(),securityEvents:[],
|
|
31
33
|
get store(){return store;},get serverId(){return serverId;},get localOrigin(){return localOrigin;},get harnessRoutes(){return harnessRoutes;},
|
|
32
34
|
attach(next){store=next.store??store;if(next.serverId!==undefined)serverId=next.serverId;if(next.localOrigin!==undefined)localOrigin=next.localOrigin;if(next.harnessRoutes!==undefined)harnessRoutes=next.harnessRoutes;},
|
|
33
35
|
};
|
package/server/delegated.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Delegated access (RH2 RFC 0013, panes and folders): a workplace that decides who may see or drive a pane or a
|
|
2
2
|
// folder signs a short-lived token saying so, and this server admits exactly that, as it admits a machine grant.
|
|
3
|
-
// The workplace is an issuer this server is configured to trust (`
|
|
3
|
+
// The workplace is an issuer this server is configured to trust (`delegatedIssuers`, exact origins); its keys are at
|
|
4
4
|
// `<issuer>/.well-known/jwks.json` (Ed25519, EdDSA). A token names the principal, the Surface (its kind, and in its
|
|
5
5
|
// config this server's team, the machine and the target) and `view` or `control`; its audience is this server's
|
|
6
6
|
// public origin. Verified with WebCrypto, so the same code runs in Node and in a Worker.
|
|
@@ -38,13 +38,15 @@ export function looksDelegated(token) {
|
|
|
38
38
|
return typeof token === 'string' && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
async function issuerKey(api, kid) {
|
|
42
|
-
const
|
|
41
|
+
async function issuerKey(api, issuer, kid) {
|
|
42
|
+
const caches = api.delegatedKeys ??= new Map();
|
|
43
|
+
let cache = caches.get(issuer);
|
|
44
|
+
if (!cache) caches.set(issuer, cache = { at: -Infinity, keys: new Map() });
|
|
43
45
|
const age = api.clock.nowMs() - cache.at;
|
|
44
46
|
// The set is re-read when it is old, or when it lacks the kid and was last read over a minute ago (a key rotated
|
|
45
47
|
// in); so a forged kid drives at most one fetch a minute.
|
|
46
48
|
if (age >= KEYS_TTL_MS || (!cache.keys.has(kid) && age > 60 * 1000)) {
|
|
47
|
-
const response = await fetch(`${
|
|
49
|
+
const response = await fetch(`${issuer}/.well-known/jwks.json`, { headers: { accept: 'application/json' } });
|
|
48
50
|
if (!response.ok) throw new Error(`the issuer's keys did not load (${response.status})`);
|
|
49
51
|
const { keys } = await response.json();
|
|
50
52
|
const loaded = new Map();
|
|
@@ -124,18 +126,20 @@ export async function delegatedRoomNoticeOf(api, token) {
|
|
|
124
126
|
|
|
125
127
|
/** A token's claims when it is one this server trusts: signed by the issuer's key, for this server, in its lifetime. */
|
|
126
128
|
async function verifiedClaims(api, token) {
|
|
127
|
-
if (!api.
|
|
129
|
+
if (!api.delegatedIssuers.length || !api.publicOrigin || !looksDelegated(token)) return null;
|
|
128
130
|
try {
|
|
129
131
|
const [head, body, signature] = token.split('.');
|
|
130
132
|
const header = json(head);
|
|
131
133
|
if (header.alg !== 'EdDSA' || typeof header.kid !== 'string') return null;
|
|
132
|
-
|
|
134
|
+
// Only configured origins may select keys; an untrusted claim never triggers a fetch.
|
|
135
|
+
// Cache by issuer as well as kid so staging cannot reuse a production key identifier.
|
|
136
|
+
const claims = json(body);
|
|
137
|
+
if (!api.delegatedIssuers.includes(claims.iss) || claims.aud !== api.publicOrigin) return null;
|
|
138
|
+
const key = await issuerKey(api, claims.iss, header.kid);
|
|
133
139
|
if (!key) return null;
|
|
134
140
|
const signed = new TextEncoder().encode(`${head}.${body}`);
|
|
135
141
|
if (!await crypto.subtle.verify({ name: 'Ed25519' }, key, fromBase64Url(signature), signed)) return null;
|
|
136
|
-
const claims = json(body);
|
|
137
142
|
const now = Math.floor(api.clock.nowMs() / 1000);
|
|
138
|
-
if (claims.iss !== api.delegatedIssuer || claims.aud !== api.publicOrigin) return null;
|
|
139
143
|
if (typeof claims.exp !== 'number' || claims.exp <= now - SKEW_S || (typeof claims.nbf === 'number' && claims.nbf > now + SKEW_S)) return null;
|
|
140
144
|
if (typeof claims.sub !== 'string' || !claims.sub) return null;
|
|
141
145
|
return claims;
|
package/server/machine-link.mjs
CHANGED
|
@@ -157,11 +157,11 @@ export async function authorizeMachineUpgrade(api, { authenticate, url, headers
|
|
|
157
157
|
const presented = bearerFrom(headers);
|
|
158
158
|
// A Room member typing into a session bound to their Room: the session names the machine, the server finds it.
|
|
159
159
|
const session = /^\/api\/v1\/teams\/([^/]+)\/sessions\/([^/]+)\/channel$/.exec(pathname);
|
|
160
|
-
if (session) return api.
|
|
160
|
+
if (session) return api.delegatedIssuers.length && looksDelegated(presented) ? await delegatedSessionChannel(api, session[1], decodeURIComponent(session[2]), presented) : { status: 404, code: 'not_found', message: 'not found' };
|
|
161
161
|
const match = /^\/api\/v1\/teams\/([^/]+)\/machines\/([^/]+)\/(link|channel)$/.exec(pathname);
|
|
162
162
|
if (!match) return { status: 404, code: 'not_found', message: 'not found' };
|
|
163
163
|
const [, teamId, machineId, door] = match;
|
|
164
|
-
if (door === 'channel' && api.
|
|
164
|
+
if (door === 'channel' && api.delegatedIssuers.length && looksDelegated(presented)) return await delegatedChannel(api, teamId, machineId, presented);
|
|
165
165
|
const auth = await authenticate(api, presented);
|
|
166
166
|
if (!auth) return { status: 401, code: 'authentication_required', message: 'authentication required' };
|
|
167
167
|
if (door === 'link') {
|
package/server/publications.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export async function publicationRoute({ api, req, res, auth, machineId, publica
|
|
|
80
80
|
if (!publishableTarget(body.kind, body.target)) fail(400, 'invalid_request', body.kind === 'pane' ? 'target must be one pane, pane:<key> (no wildcard)' : 'target must be one absolute folder, root:/path (not root:/, no wildcard, normalized)');
|
|
81
81
|
const workplace = typeof body.workplace === 'string' ? originOf(body.workplace) : null;
|
|
82
82
|
if (!workplace) fail(400, 'invalid_request', 'workplace must be an origin (https://host)');
|
|
83
|
-
if (
|
|
83
|
+
if (!api.delegatedIssuers.includes(workplace)) fail(400, 'invalid_request', `this server does not trust ${workplace} as a delegated issuer`);
|
|
84
84
|
if (typeof body.room !== 'string' || !body.room || body.room.length > 256) fail(400, 'invalid_request', 'room must name the workplace Room');
|
|
85
85
|
if (body.surface_key !== undefined && (typeof body.surface_key !== 'string' || !body.surface_key || body.surface_key.length > 256)) fail(400, 'invalid_request', 'surface_key must be a non-empty string');
|
|
86
86
|
const existing = await api.store.one('SELECT * FROM machine_publications WHERE team_id=? AND machine_id=? AND target=? AND workplace=? AND room=?', [auth.team_id, machineId, body.target, workplace, body.room]);
|