@pdsjs/spaces 2.0.1 → 2.0.3
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 +4 -3
- package/src/admin.d.ts +10 -0
- package/src/admin.js +115 -0
- package/src/authority.d.ts +13 -3
- package/src/authority.js +22 -4
- package/src/car.d.ts +13 -4
- package/src/car.js +17 -6
- package/src/dpop.d.ts +31 -0
- package/src/dpop.js +121 -0
- package/src/handlers/auth.d.ts +66 -15
- package/src/handlers/auth.js +146 -38
- package/src/handlers/manage.d.ts +4 -6
- package/src/handlers/manage.js +346 -65
- package/src/handlers/read.d.ts +6 -6
- package/src/handlers/read.js +150 -65
- package/src/handlers/write.d.ts +19 -6
- package/src/handlers/write.js +163 -26
- package/src/memory-storage.d.ts +14 -0
- package/src/memory-storage.js +79 -12
- package/src/notify.d.ts +36 -0
- package/src/notify.js +87 -0
- package/src/routes.d.ts +14 -6
- package/src/routes.js +15 -2
- package/src/service-auth.d.ts +4 -6
- package/src/service-auth.js +49 -27
- package/src/token.d.ts +44 -8
- package/src/token.js +98 -19
- package/src/writer.d.ts +11 -0
- package/src/writer.js +10 -0
package/src/memory-storage.js
CHANGED
|
@@ -1,9 +1,54 @@
|
|
|
1
|
-
// @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort
|
|
1
|
+
// @pdsjs/spaces/memory-storage - in-memory SpaceStoragePort, and the blob side
|
|
2
|
+
// of the route context.
|
|
2
3
|
//
|
|
3
4
|
// Backs unit tests and gives handler tests a store with no database. Keyed
|
|
4
5
|
// exactly like the SQL adapters so the shared conformance suite exercises the
|
|
5
6
|
// same behaviour everywhere.
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The blob store and blob link table createSpaceRoutes wants, in memory. A real
|
|
10
|
+
* deployment passes the account's own store and link table; here they are two
|
|
11
|
+
* Maps that answer the same way.
|
|
12
|
+
*
|
|
13
|
+
* @returns {{blobs: import('@pdsjs/core/ports').BlobPort, linkBlob: (blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>, unlinkBlobs: (recordUri: string) => Promise<void>, links: Map<string, Set<string>>, put: (cid: string, data: Uint8Array, mimeType?: string) => void}}
|
|
14
|
+
*/
|
|
15
|
+
export function createMemoryBlobContext() {
|
|
16
|
+
/** @type {Map<string, {data: Uint8Array, mimeType: string}>} */
|
|
17
|
+
const stored = new Map();
|
|
18
|
+
/** @type {Map<string, Set<string>>} record uri -> blob cids */
|
|
19
|
+
const links = new Map();
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
blobs: {
|
|
23
|
+
async get(_did, cid) {
|
|
24
|
+
const blob = stored.get(cid);
|
|
25
|
+
return blob ? { ...blob, data: new Uint8Array(blob.data) } : null;
|
|
26
|
+
},
|
|
27
|
+
async put(_did, cid, data, mimeType) {
|
|
28
|
+
stored.set(cid, { data: new Uint8Array(data), mimeType });
|
|
29
|
+
},
|
|
30
|
+
async delete(_did, cid) {
|
|
31
|
+
stored.delete(cid);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
async linkBlob(blobCid, recordUri) {
|
|
35
|
+
let forRecord = links.get(recordUri);
|
|
36
|
+
if (!forRecord) {
|
|
37
|
+
forRecord = new Set();
|
|
38
|
+
links.set(recordUri, forRecord);
|
|
39
|
+
}
|
|
40
|
+
forRecord.add(blobCid);
|
|
41
|
+
},
|
|
42
|
+
async unlinkBlobs(recordUri) {
|
|
43
|
+
links.delete(recordUri);
|
|
44
|
+
},
|
|
45
|
+
links,
|
|
46
|
+
put(cid, data, mimeType = 'application/octet-stream') {
|
|
47
|
+
stored.set(cid, { data, mimeType });
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
7
52
|
/**
|
|
8
53
|
* Keyset pagination over a sorted key list.
|
|
9
54
|
* @template T
|
|
@@ -117,17 +162,31 @@ export function createMemorySpaceStorage() {
|
|
|
117
162
|
return records.get(space)?.get(`${collection}/${rkey}`)?.cid ?? null;
|
|
118
163
|
},
|
|
119
164
|
|
|
120
|
-
async listSpaceRecords(
|
|
165
|
+
async listSpaceRecords(
|
|
166
|
+
space,
|
|
167
|
+
{ collection, cursor, limit, reverse, excludeValues },
|
|
168
|
+
) {
|
|
121
169
|
const inSpace = records.get(space) ?? new Map();
|
|
122
|
-
|
|
123
|
-
.filter((r) => r.collection === collection)
|
|
124
|
-
.map((r) => r.rkey)
|
|
170
|
+
let keys = [...inSpace.values()]
|
|
171
|
+
.filter((r) => !collection || r.collection === collection)
|
|
172
|
+
.map((r) => `${r.collection}/${r.rkey}`)
|
|
125
173
|
.sort();
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
174
|
+
// Descending unless the caller asks otherwise, matching the reference.
|
|
175
|
+
if (!reverse) keys.reverse();
|
|
176
|
+
if (cursor) {
|
|
177
|
+
keys = keys.filter((k) => (reverse ? k > cursor : k < cursor));
|
|
178
|
+
}
|
|
179
|
+
const items = keys.slice(0, limit).map((key) => {
|
|
180
|
+
const row = inSpace.get(key);
|
|
181
|
+
const { value, ...rest } = row;
|
|
182
|
+
return excludeValues
|
|
183
|
+
? { ...rest }
|
|
184
|
+
: { ...rest, value: new Uint8Array(value) };
|
|
129
185
|
});
|
|
130
|
-
return {
|
|
186
|
+
return {
|
|
187
|
+
records: items,
|
|
188
|
+
cursor: items.length === limit ? keys[limit - 1] : null,
|
|
189
|
+
};
|
|
131
190
|
},
|
|
132
191
|
|
|
133
192
|
async listAllSpaceRecords(space) {
|
|
@@ -226,19 +285,27 @@ export function createMemorySpaceStorage() {
|
|
|
226
285
|
space,
|
|
227
286
|
serviceDid,
|
|
228
287
|
serviceEndpoint,
|
|
229
|
-
|
|
288
|
+
expiresAt,
|
|
230
289
|
) {
|
|
231
290
|
let inSpace = recipients.get(space);
|
|
232
291
|
if (!inSpace) {
|
|
233
292
|
inSpace = new Map();
|
|
234
293
|
recipients.set(space, inSpace);
|
|
235
294
|
}
|
|
236
|
-
inSpace.set(serviceDid, { serviceDid, serviceEndpoint,
|
|
295
|
+
inSpace.set(serviceDid, { serviceDid, serviceEndpoint, expiresAt });
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
async deleteCredentialRecipient(space, serviceDid) {
|
|
299
|
+
recipients.get(space)?.delete(serviceDid);
|
|
237
300
|
},
|
|
238
301
|
|
|
239
302
|
async listCredentialRecipients(space) {
|
|
240
303
|
const inSpace = recipients.get(space) ?? new Map();
|
|
241
|
-
|
|
304
|
+
const now = new Date().toISOString();
|
|
305
|
+
return [...inSpace.keys()]
|
|
306
|
+
.sort()
|
|
307
|
+
.map((k) => ({ ...inSpace.get(k) }))
|
|
308
|
+
.filter((r) => r.expiresAt > now);
|
|
242
309
|
},
|
|
243
310
|
};
|
|
244
311
|
}
|
package/src/notify.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forward a write notice to every service registered for this space.
|
|
3
|
+
*
|
|
4
|
+
* Best effort, and deliberately so: sync correctness rests on comparing set
|
|
5
|
+
* hashes, so a syncer that misses a notice finds out on the next one or on its
|
|
6
|
+
* own next walk. One unreachable recipient must not fail the others, and none of
|
|
7
|
+
* them may fail the write that produced the notice.
|
|
8
|
+
*
|
|
9
|
+
* The caller awaits this, which puts one round trip per recipient in front of
|
|
10
|
+
* the response. A Worker cancels a promise still running when it answers, and
|
|
11
|
+
* this package holds no execution context to hand the work to, so backgrounding
|
|
12
|
+
* it here would mean dropping notifications on Cloudflare.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} ctx
|
|
15
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
16
|
+
* @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
|
|
17
|
+
* @param {typeof fetch} [ctx.fetch]
|
|
18
|
+
* @param {Object} notice
|
|
19
|
+
* @param {string} notice.authorityDid - the space's authority, which signs
|
|
20
|
+
* @param {string} notice.space
|
|
21
|
+
* @param {string} notice.repo - the account whose repo advanced
|
|
22
|
+
* @param {string} notice.rev
|
|
23
|
+
* @param {Uint8Array} notice.hash - the repo's commit hash after the write
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
export declare function forwardToSyncers(ctx: {
|
|
27
|
+
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
28
|
+
getSigner: () => Promise<import('./token.js').SpaceSigner>;
|
|
29
|
+
fetch?: typeof fetch;
|
|
30
|
+
}, { authorityDid, space, repo, rev, hash }: {
|
|
31
|
+
authorityDid: string;
|
|
32
|
+
space: string;
|
|
33
|
+
repo: string;
|
|
34
|
+
rev: string;
|
|
35
|
+
hash: Uint8Array;
|
|
36
|
+
}): Promise<void>;
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @pdsjs/spaces/notify - telling a space's syncers that a repo advanced.
|
|
2
|
+
//
|
|
3
|
+
// A space's writes are never broadcast, which is the point of one. So a service
|
|
4
|
+
// that wants to follow a space registers with the authority through
|
|
5
|
+
// registerNotify, and the authority forwards each write notice it learns of. A
|
|
6
|
+
// syncer that receives one holds a session and can read; the notice itself
|
|
7
|
+
// carries no records, only the rev the repo reached.
|
|
8
|
+
|
|
9
|
+
import { createServiceAuth } from './service-auth.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Forward a write notice to every service registered for this space.
|
|
13
|
+
*
|
|
14
|
+
* Best effort, and deliberately so: sync correctness rests on comparing set
|
|
15
|
+
* hashes, so a syncer that misses a notice finds out on the next one or on its
|
|
16
|
+
* own next walk. One unreachable recipient must not fail the others, and none of
|
|
17
|
+
* them may fail the write that produced the notice.
|
|
18
|
+
*
|
|
19
|
+
* The caller awaits this, which puts one round trip per recipient in front of
|
|
20
|
+
* the response. A Worker cancels a promise still running when it answers, and
|
|
21
|
+
* this package holds no execution context to hand the work to, so backgrounding
|
|
22
|
+
* it here would mean dropping notifications on Cloudflare.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} ctx
|
|
25
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
26
|
+
* @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
|
|
27
|
+
* @param {typeof fetch} [ctx.fetch]
|
|
28
|
+
* @param {Object} notice
|
|
29
|
+
* @param {string} notice.authorityDid - the space's authority, which signs
|
|
30
|
+
* @param {string} notice.space
|
|
31
|
+
* @param {string} notice.repo - the account whose repo advanced
|
|
32
|
+
* @param {string} notice.rev
|
|
33
|
+
* @param {Uint8Array} notice.hash - the repo's commit hash after the write
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
export async function forwardToSyncers(
|
|
37
|
+
ctx,
|
|
38
|
+
{ authorityDid, space, repo, rev, hash },
|
|
39
|
+
) {
|
|
40
|
+
let recipients;
|
|
41
|
+
try {
|
|
42
|
+
recipients = await ctx.spaceStorage.listCredentialRecipients(space);
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (recipients.length === 0) return;
|
|
47
|
+
|
|
48
|
+
const body = JSON.stringify({ space, repo, rev, hash: toJsonBytes(hash) });
|
|
49
|
+
const doFetch = ctx.fetch ?? fetch;
|
|
50
|
+
const signer = await ctx.getSigner();
|
|
51
|
+
|
|
52
|
+
await Promise.all(
|
|
53
|
+
recipients.map(async (recipient) => {
|
|
54
|
+
try {
|
|
55
|
+
const token = await createServiceAuth({
|
|
56
|
+
iss: authorityDid,
|
|
57
|
+
aud: recipient.serviceDid,
|
|
58
|
+
lxm: 'com.atproto.space.notifyWrite',
|
|
59
|
+
signer,
|
|
60
|
+
});
|
|
61
|
+
await doFetch(
|
|
62
|
+
`${recipient.serviceEndpoint}/xrpc/com.atproto.space.notifyWrite`,
|
|
63
|
+
{
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: {
|
|
66
|
+
'content-type': 'application/json',
|
|
67
|
+
authorization: `Bearer ${token}`,
|
|
68
|
+
},
|
|
69
|
+
body,
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
} catch {
|
|
73
|
+
// Best effort.
|
|
74
|
+
}
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {Uint8Array} bytes
|
|
81
|
+
* @returns {{$bytes: string}}
|
|
82
|
+
*/
|
|
83
|
+
function toJsonBytes(bytes) {
|
|
84
|
+
let binary = '';
|
|
85
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
86
|
+
return { $bytes: btoa(binary).replace(/=+$/, '') };
|
|
87
|
+
}
|
package/src/routes.d.ts
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @param {Object} ctx
|
|
3
3
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
4
|
+
* @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs - blobs referenced
|
|
5
|
+
* from space records live in the account's ordinary blob store
|
|
6
|
+
* @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
|
|
7
|
+
* @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
|
|
4
8
|
* @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
|
|
5
|
-
* @param {() => Promise<
|
|
9
|
+
* @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
|
|
6
10
|
* - the account's signing key, used to sign repo commits and space tokens
|
|
7
|
-
* @param {(
|
|
11
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid - DID document lookup,
|
|
8
12
|
* injected so this package stays dependency-free
|
|
9
13
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
14
|
+
* @param {import('@pdsjs/core/ports').RecordWriteGuardPort} [ctx.recordGuard]
|
|
15
|
+
* - a veto over writes in its named collections, e.g. git branch protection
|
|
10
16
|
* @param {typeof fetch} [ctx.fetch] - injected for the outbound notification
|
|
11
17
|
* calls, so tests can observe them without a network
|
|
12
18
|
* @returns {import('@pdsjs/core/pds').Routes}
|
|
13
19
|
*/
|
|
14
20
|
export declare function createSpaceRoutes(ctx: {
|
|
15
21
|
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
22
|
+
blobs: import('@pdsjs/core/ports').BlobPort;
|
|
23
|
+
linkBlob: (blobCid: string, recordUri: string, recordTime: number | null) => Promise<void>;
|
|
24
|
+
unlinkBlobs: (recordUri: string) => Promise<void>;
|
|
16
25
|
getDid: () => Promise<string | null>;
|
|
17
|
-
getSigner: () => Promise<
|
|
18
|
-
|
|
19
|
-
}>;
|
|
20
|
-
resolveDid: (did: string) => Promise<any>;
|
|
26
|
+
getSigner: () => Promise<import('./token.js').SpaceSigner>;
|
|
27
|
+
resolveDid: import('@pdsjs/core/ports').DidResolverPort;
|
|
21
28
|
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
29
|
+
recordGuard?: import('@pdsjs/core/ports').RecordWriteGuardPort;
|
|
22
30
|
fetch?: typeof fetch;
|
|
23
31
|
}): import('@pdsjs/core/pds').Routes;
|
package/src/routes.js
CHANGED
|
@@ -13,12 +13,18 @@ import { createWriteRoutes } from './handlers/write.js';
|
|
|
13
13
|
/**
|
|
14
14
|
* @param {Object} ctx
|
|
15
15
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
16
|
+
* @param {import('@pdsjs/core/ports').BlobPort} ctx.blobs - blobs referenced
|
|
17
|
+
* from space records live in the account's ordinary blob store
|
|
18
|
+
* @param {(blobCid: string, recordUri: string, recordTime: number|null) => Promise<void>} ctx.linkBlob
|
|
19
|
+
* @param {(recordUri: string) => Promise<void>} ctx.unlinkBlobs
|
|
16
20
|
* @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
|
|
17
|
-
* @param {() => Promise<
|
|
21
|
+
* @param {() => Promise<import('./token.js').SpaceSigner>} ctx.getSigner
|
|
18
22
|
* - the account's signing key, used to sign repo commits and space tokens
|
|
19
|
-
* @param {(
|
|
23
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid - DID document lookup,
|
|
20
24
|
* injected so this package stays dependency-free
|
|
21
25
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
26
|
+
* @param {import('@pdsjs/core/ports').RecordWriteGuardPort} [ctx.recordGuard]
|
|
27
|
+
* - a veto over writes in its named collections, e.g. git branch protection
|
|
22
28
|
* @param {typeof fetch} [ctx.fetch] - injected for the outbound notification
|
|
23
29
|
* calls, so tests can observe them without a network
|
|
24
30
|
* @returns {import('@pdsjs/core/pds').Routes}
|
|
@@ -33,6 +39,13 @@ export function createSpaceRoutes(ctx) {
|
|
|
33
39
|
if (!ctx.resolveDid || !ctx.verifier) {
|
|
34
40
|
throw new Error('createSpaceRoutes requires resolveDid and a verifier');
|
|
35
41
|
}
|
|
42
|
+
// Refused rather than defaulted: without the link callbacks a space record's
|
|
43
|
+
// blobs look unreferenced, and orphan cleanup deletes them.
|
|
44
|
+
if (!ctx.blobs || !ctx.linkBlob || !ctx.unlinkBlobs) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'createSpaceRoutes requires blobs, linkBlob and unlinkBlobs',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
36
49
|
return {
|
|
37
50
|
...createWriteRoutes(ctx),
|
|
38
51
|
...createReadRoutes(ctx),
|
package/src/service-auth.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @param {string} opts.jwt
|
|
6
6
|
* @param {string} opts.aud - our own DID; the token must be addressed to us
|
|
7
7
|
* @param {string} opts.lxm - the method being called
|
|
8
|
-
* @param {(
|
|
8
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
|
|
9
9
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
10
10
|
* @returns {Promise<{iss: string}>}
|
|
11
11
|
*/
|
|
@@ -13,7 +13,7 @@ export declare function verifyServiceAuth({ jwt, aud, lxm, resolveDid, verifier,
|
|
|
13
13
|
jwt: string;
|
|
14
14
|
aud: string;
|
|
15
15
|
lxm: string;
|
|
16
|
-
resolveDid: (
|
|
16
|
+
resolveDid: import('@pdsjs/core/ports').DidResolverPort;
|
|
17
17
|
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
18
18
|
}): Promise<{
|
|
19
19
|
iss: string;
|
|
@@ -29,14 +29,12 @@ export declare function verifyServiceAuth({ jwt, aud, lxm, resolveDid, verifier,
|
|
|
29
29
|
* @param {string} opts.iss - our DID
|
|
30
30
|
* @param {string} opts.aud - the service we are calling
|
|
31
31
|
* @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
|
|
32
|
-
* @param {
|
|
32
|
+
* @param {import('./token.js').SpaceSigner} opts.signer
|
|
33
33
|
* @returns {Promise<string>}
|
|
34
34
|
*/
|
|
35
35
|
export declare function createServiceAuth({ iss, aud, lxm, signer }: {
|
|
36
36
|
iss: string;
|
|
37
37
|
aud: string;
|
|
38
38
|
lxm: string;
|
|
39
|
-
signer:
|
|
40
|
-
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
41
|
-
};
|
|
39
|
+
signer: import('./token.js').SpaceSigner;
|
|
42
40
|
}): Promise<string>;
|
package/src/service-auth.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { base64UrlEncode, bytesToHex } from '@pdsjs/core/crypto';
|
|
9
9
|
import { atprotoSigningKey } from './authority.js';
|
|
10
|
-
import { SpaceTokenError } from './token.js';
|
|
10
|
+
import { assertKeyAlg, SpaceTokenError, signerAlg } from './token.js';
|
|
11
11
|
|
|
12
12
|
const CLOCK_SKEW_SEC = 5;
|
|
13
13
|
|
|
@@ -43,7 +43,7 @@ function decodeSig(b64) {
|
|
|
43
43
|
* @param {string} opts.jwt
|
|
44
44
|
* @param {string} opts.aud - our own DID; the token must be addressed to us
|
|
45
45
|
* @param {string} opts.lxm - the method being called
|
|
46
|
-
* @param {(
|
|
46
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
|
|
47
47
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
48
48
|
* @returns {Promise<{iss: string}>}
|
|
49
49
|
*/
|
|
@@ -60,14 +60,19 @@ export async function verifyServiceAuth({
|
|
|
60
60
|
}
|
|
61
61
|
const [headerB64, payloadB64, sigB64] = parts;
|
|
62
62
|
|
|
63
|
+
/** @type {any} */
|
|
64
|
+
let header;
|
|
63
65
|
/** @type {any} */
|
|
64
66
|
let payload;
|
|
65
67
|
try {
|
|
66
|
-
decodePart(headerB64);
|
|
68
|
+
header = decodePart(headerB64);
|
|
67
69
|
payload = decodePart(payloadB64);
|
|
68
70
|
} catch {
|
|
69
71
|
throw new SpaceTokenError('could not parse service token', 'BadJwt');
|
|
70
72
|
}
|
|
73
|
+
if (typeof header?.alg !== 'string' || !header.alg) {
|
|
74
|
+
throw new SpaceTokenError('missing service token "alg"', 'BadJwt');
|
|
75
|
+
}
|
|
71
76
|
|
|
72
77
|
const now = Math.floor(Date.now() / 1000);
|
|
73
78
|
if (typeof payload.exp !== 'number' || now - CLOCK_SKEW_SEC >= payload.exp) {
|
|
@@ -91,30 +96,47 @@ export async function verifyServiceAuth({
|
|
|
91
96
|
);
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
const didDoc = await resolveDid(payload.iss);
|
|
95
|
-
if (!didDoc) {
|
|
96
|
-
throw new SpaceTokenError(
|
|
97
|
-
`could not resolve ${payload.iss}`,
|
|
98
|
-
'DidNotFound',
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
const didKey = atprotoSigningKey(didDoc, payload.iss);
|
|
102
99
|
const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
|
|
100
|
+
const sig = decodeSig(sigB64);
|
|
103
101
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
102
|
+
/**
|
|
103
|
+
* @param {boolean} forceRefresh
|
|
104
|
+
* @returns {Promise<{didKey: string, valid: boolean}>}
|
|
105
|
+
*/
|
|
106
|
+
const matchesSignature = async (forceRefresh) => {
|
|
107
|
+
const didDoc = await resolveDid(payload.iss, { forceRefresh });
|
|
108
|
+
if (!didDoc) {
|
|
109
|
+
throw new SpaceTokenError(
|
|
110
|
+
`could not resolve ${payload.iss}`,
|
|
111
|
+
'DidNotFound',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const didKey = atprotoSigningKey(didDoc, payload.iss);
|
|
115
|
+
assertKeyAlg(didKey, header.alg);
|
|
116
|
+
try {
|
|
117
|
+
return {
|
|
118
|
+
didKey,
|
|
119
|
+
valid: await verifier.verify(didKey, signingInput, sig),
|
|
120
|
+
};
|
|
121
|
+
} catch (err) {
|
|
122
|
+
throw new SpaceTokenError(
|
|
123
|
+
`could not verify service token: ${err instanceof Error ? err.message : String(err)}`,
|
|
124
|
+
'BadJwtSignature',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const first = await matchesSignature(false);
|
|
130
|
+
if (!first.valid) {
|
|
131
|
+
// A resolver that holds its answers can name a key the caller has rotated
|
|
132
|
+
// away from, and the token is signed by the current one.
|
|
133
|
+
const fresh = await matchesSignature(true);
|
|
134
|
+
if (fresh.didKey === first.didKey || !fresh.valid) {
|
|
135
|
+
throw new SpaceTokenError(
|
|
136
|
+
'invalid service token signature',
|
|
137
|
+
'BadJwtSignature',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
118
140
|
}
|
|
119
141
|
|
|
120
142
|
return { iss: payload.iss };
|
|
@@ -131,12 +153,12 @@ export async function verifyServiceAuth({
|
|
|
131
153
|
* @param {string} opts.iss - our DID
|
|
132
154
|
* @param {string} opts.aud - the service we are calling
|
|
133
155
|
* @param {string} opts.lxm - the method, so the token cannot be replayed elsewhere
|
|
134
|
-
* @param {
|
|
156
|
+
* @param {import('./token.js').SpaceSigner} opts.signer
|
|
135
157
|
* @returns {Promise<string>}
|
|
136
158
|
*/
|
|
137
159
|
export async function createServiceAuth({ iss, aud, lxm, signer }) {
|
|
138
160
|
const now = Math.floor(Date.now() / 1000);
|
|
139
|
-
const header = { typ: 'JWT', alg:
|
|
161
|
+
const header = { typ: 'JWT', alg: signerAlg(signer) };
|
|
140
162
|
const payload = {
|
|
141
163
|
iss,
|
|
142
164
|
aud,
|
package/src/token.d.ts
CHANGED
|
@@ -4,20 +4,38 @@ export declare const SPACE_TOKEN_TYPES: {
|
|
|
4
4
|
kid: string;
|
|
5
5
|
expiresInSec: number;
|
|
6
6
|
requireAud: boolean;
|
|
7
|
+
requireCnf: boolean;
|
|
7
8
|
};
|
|
8
9
|
credential: {
|
|
9
10
|
typ: string;
|
|
10
11
|
kid: string;
|
|
11
12
|
expiresInSec: number;
|
|
12
13
|
requireAud: boolean;
|
|
14
|
+
requireCnf: boolean;
|
|
13
15
|
};
|
|
14
16
|
clientAttestation: {
|
|
15
17
|
typ: string;
|
|
16
18
|
kid: undefined;
|
|
17
19
|
expiresInSec: number;
|
|
18
20
|
requireAud: boolean;
|
|
21
|
+
requireCnf: boolean;
|
|
19
22
|
};
|
|
20
23
|
};
|
|
24
|
+
export type SpaceSigner = {
|
|
25
|
+
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
26
|
+
/**
|
|
27
|
+
* - p256 when absent
|
|
28
|
+
*/
|
|
29
|
+
curve?: 'p256' | 'secp256k1';
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The JWT `alg` for a signer's curve. A verifier resolves the issuer's key,
|
|
33
|
+
* reads its type, and refuses a header that names another algorithm before it
|
|
34
|
+
* checks the signature. So a secp256k1 key must stamp ES256K.
|
|
35
|
+
* @param {SpaceSigner} signer
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export declare function signerAlg(signer: SpaceSigner): string;
|
|
21
39
|
export declare class SpaceTokenError extends Error {
|
|
22
40
|
code: string;
|
|
23
41
|
/**
|
|
@@ -26,6 +44,18 @@ export declare class SpaceTokenError extends Error {
|
|
|
26
44
|
*/
|
|
27
45
|
constructor(message: string, code?: string);
|
|
28
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Refuse a token whose header names an algorithm the issuer's key cannot
|
|
49
|
+
* produce. The signature would verify anyway, since the curve comes from the
|
|
50
|
+
* resolved key rather than the header. The reference implementation rejects
|
|
51
|
+
* the mismatch, so a token this server accepts has to be one the rest of the
|
|
52
|
+
* ecosystem accepts too.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} didKey
|
|
55
|
+
* @param {string} alg - the token header's `alg`
|
|
56
|
+
* @throws {SpaceTokenError}
|
|
57
|
+
*/
|
|
58
|
+
export declare function assertKeyAlg(didKey: string, alg: string): void;
|
|
29
59
|
export type SpaceTokenPayload = {
|
|
30
60
|
iss: string;
|
|
31
61
|
/**
|
|
@@ -36,6 +66,12 @@ export type SpaceTokenPayload = {
|
|
|
36
66
|
iat: number;
|
|
37
67
|
exp: number;
|
|
38
68
|
jti: string;
|
|
69
|
+
/**
|
|
70
|
+
* - the key the holder must prove possession of
|
|
71
|
+
*/
|
|
72
|
+
cnf?: {
|
|
73
|
+
jkt: string;
|
|
74
|
+
};
|
|
39
75
|
};
|
|
40
76
|
export type SpaceTokenHeader = {
|
|
41
77
|
alg: string;
|
|
@@ -44,20 +80,19 @@ export type SpaceTokenHeader = {
|
|
|
44
80
|
};
|
|
45
81
|
/**
|
|
46
82
|
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
47
|
-
* @param {{iss: string, sub: string, aud?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
48
|
-
* @param {
|
|
83
|
+
* @param {{iss: string, sub: string, aud?: string, dpopJkt?: string, expiresInSec?: number, kid?: string, alg?: string}} opts
|
|
84
|
+
* @param {SpaceSigner} signer
|
|
49
85
|
* @returns {Promise<string>}
|
|
50
86
|
*/
|
|
51
87
|
export declare function createSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, opts: {
|
|
52
88
|
iss: string;
|
|
53
89
|
sub: string;
|
|
54
90
|
aud?: string;
|
|
91
|
+
dpopJkt?: string;
|
|
55
92
|
expiresInSec?: number;
|
|
56
93
|
kid?: string;
|
|
57
94
|
alg?: string;
|
|
58
|
-
}, signer:
|
|
59
|
-
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
60
|
-
}): Promise<string>;
|
|
95
|
+
}, signer: SpaceSigner): Promise<string>;
|
|
61
96
|
/**
|
|
62
97
|
* Structural validation only, no signature check. This is as far as we go for
|
|
63
98
|
* client attestations, whose key comes from the client's JWKS rather than a DID
|
|
@@ -77,15 +112,16 @@ export declare function parseSpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, jw
|
|
|
77
112
|
* @param {keyof typeof SPACE_TOKEN_TYPES} type
|
|
78
113
|
* @param {string} jwt
|
|
79
114
|
* @param {Object} opts
|
|
80
|
-
* @param {(iss: string, kid?: string) => Promise<string>} opts.getSigningKey
|
|
81
|
-
* - resolves the issuer to a did:key. Given `kid` so it can honour the key
|
|
115
|
+
* @param {(iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>} opts.getSigningKey
|
|
116
|
+
* - resolves the issuer to a did:key. Given `kid` so it can honour the key
|
|
117
|
+
* id, and `forceRefresh` to read past a held answer after a signature fails.
|
|
82
118
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
83
119
|
* @param {string} [opts.aud] - required audience, when the caller knows it
|
|
84
120
|
* @param {string} [opts.sub] - required subject, when the caller knows it
|
|
85
121
|
* @returns {Promise<{header: SpaceTokenHeader, payload: SpaceTokenPayload}>}
|
|
86
122
|
*/
|
|
87
123
|
export declare function verifySpaceToken(type: keyof typeof SPACE_TOKEN_TYPES, jwt: string, opts: {
|
|
88
|
-
getSigningKey: (iss: string, kid?: string) => Promise<string>;
|
|
124
|
+
getSigningKey: (iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>;
|
|
89
125
|
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
90
126
|
aud?: string;
|
|
91
127
|
sub?: string;
|