@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/handlers/auth.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
spaceHostEndpoint,
|
|
16
16
|
spaceSigningKey,
|
|
17
17
|
} from '../authority.js';
|
|
18
|
+
import { credentialBindingFromProof, verifyCredentialProof } from '../dpop.js';
|
|
18
19
|
import { createServiceAuth } from '../service-auth.js';
|
|
19
20
|
import {
|
|
20
21
|
createSpaceToken,
|
|
@@ -61,8 +62,8 @@ async function readJson(request) {
|
|
|
61
62
|
* @param {Object} ctx
|
|
62
63
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
63
64
|
* @param {() => Promise<string|null>} ctx.getDid
|
|
64
|
-
* @param {() => Promise<
|
|
65
|
-
* @param {(
|
|
65
|
+
* @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
|
|
66
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
|
|
66
67
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
67
68
|
* @param {typeof fetch} [ctx.fetch]
|
|
68
69
|
* @returns {import('@pdsjs/core/pds').Routes}
|
|
@@ -142,7 +143,7 @@ export function createAuthRoutes(ctx) {
|
|
|
142
143
|
handler: async (request) => {
|
|
143
144
|
const body = await readJson(request);
|
|
144
145
|
if (!body) return errorResponse('InvalidRequest', 'Invalid JSON body');
|
|
145
|
-
const { space, clientAttestation } = body;
|
|
146
|
+
const { space, dpopJkt, clientAttestation } = body;
|
|
146
147
|
if (typeof space !== 'string') {
|
|
147
148
|
return errorResponse('InvalidRequest', 'space is required');
|
|
148
149
|
}
|
|
@@ -181,6 +182,38 @@ export function createAuthRoutes(ctx) {
|
|
|
181
182
|
return tokenErrorResponse(err);
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
// Checked after the token, so a caller with no credentials at all reads
|
|
186
|
+
// 401 rather than a complaint about their request. Required, because an
|
|
187
|
+
// unbound credential is a bearer token for the whole space and every
|
|
188
|
+
// repo host in it would accept a replay of it.
|
|
189
|
+
//
|
|
190
|
+
// The key arrives two ways. A DPoP proof demonstrates it, which is what
|
|
191
|
+
// the Rust implementation sends; a `dpopJkt` body field asserts it,
|
|
192
|
+
// which is what the reference implementation reads. Both are accepted,
|
|
193
|
+
// and a caller sending both must agree with itself.
|
|
194
|
+
/** @type {string|null} */
|
|
195
|
+
let boundJkt;
|
|
196
|
+
try {
|
|
197
|
+
boundJkt = await credentialBindingFromProof(request);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return tokenErrorResponse(err);
|
|
200
|
+
}
|
|
201
|
+
if (typeof dpopJkt === 'string' && dpopJkt) {
|
|
202
|
+
if (boundJkt && boundJkt !== dpopJkt) {
|
|
203
|
+
return errorResponse(
|
|
204
|
+
'InvalidRequest',
|
|
205
|
+
'dpopJkt names a different key than the DPoP proof proves',
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
boundJkt ??= dpopJkt;
|
|
209
|
+
}
|
|
210
|
+
if (!boundJkt) {
|
|
211
|
+
return errorResponse(
|
|
212
|
+
'InvalidRequest',
|
|
213
|
+
'A DPoP proof or a dpopJkt is required',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
184
217
|
// Structural validation only. Full verification means resolving the
|
|
185
218
|
// client_id to its client-metadata.json, fetching the published JWKS,
|
|
186
219
|
// and checking the signature against the key named by `kid`. Until then
|
|
@@ -209,9 +242,12 @@ export function createAuthRoutes(ctx) {
|
|
|
209
242
|
return errorResponse('SpaceDeleted', 'Space has been deleted');
|
|
210
243
|
}
|
|
211
244
|
|
|
212
|
-
// User perimeter.
|
|
245
|
+
// User perimeter. The authority is the only party who can reconfigure
|
|
246
|
+
// the space, so no policy may lock it out of its own space.
|
|
213
247
|
let userAuthorized;
|
|
214
|
-
if (
|
|
248
|
+
if (userDid === authorityDid) {
|
|
249
|
+
userAuthorized = true;
|
|
250
|
+
} else if (spaceRow.policy === 'public') {
|
|
215
251
|
userAuthorized = true;
|
|
216
252
|
} else if (spaceRow.policy === 'member-list') {
|
|
217
253
|
userAuthorized = await spaceStorage.isMember(space, userDid);
|
|
@@ -253,40 +289,13 @@ export function createAuthRoutes(ctx) {
|
|
|
253
289
|
|
|
254
290
|
const credential = await createSpaceToken(
|
|
255
291
|
'credential',
|
|
256
|
-
{ iss: authorityDid, sub: space },
|
|
292
|
+
{ iss: authorityDid, sub: space, dpopJkt: boundJkt },
|
|
257
293
|
await getSigner(),
|
|
258
294
|
);
|
|
259
295
|
return Response.json({ credential });
|
|
260
296
|
},
|
|
261
297
|
},
|
|
262
298
|
|
|
263
|
-
// Authority role: describe a space.
|
|
264
|
-
'/xrpc/com.atproto.space.getSpace': {
|
|
265
|
-
handler: async (_request, url) => {
|
|
266
|
-
const space = url.searchParams.get('space');
|
|
267
|
-
if (!space) return errorResponse('InvalidRequest', 'space is required');
|
|
268
|
-
const row = await spaceStorage.getSpace(space);
|
|
269
|
-
if (!row?.isOwner) {
|
|
270
|
-
return errorResponse('SpaceNotFound', 'Space not found', 404);
|
|
271
|
-
}
|
|
272
|
-
return Response.json({
|
|
273
|
-
uri: row.uri,
|
|
274
|
-
config: {
|
|
275
|
-
$type: 'com.atproto.simplespace.defs#spaceConfig',
|
|
276
|
-
policy: row.policy,
|
|
277
|
-
...(row.managingApp ? { managingApp: row.managingApp } : {}),
|
|
278
|
-
appAccess:
|
|
279
|
-
row.appAccessType === 'allowList'
|
|
280
|
-
? {
|
|
281
|
-
$type: 'com.atproto.simplespace.defs#allowList',
|
|
282
|
-
allowed: row.appAllowed,
|
|
283
|
-
}
|
|
284
|
-
: { $type: 'com.atproto.simplespace.defs#open' },
|
|
285
|
-
},
|
|
286
|
-
});
|
|
287
|
-
},
|
|
288
|
-
},
|
|
289
|
-
|
|
290
299
|
// Authority role: the writer set, which is the sync boundary. Accounts that
|
|
291
300
|
// have written at least one record — not the broader set allowed to write,
|
|
292
301
|
// which the authority may not even track.
|
|
@@ -338,15 +347,107 @@ function toJsonBytes(bytes) {
|
|
|
338
347
|
}
|
|
339
348
|
|
|
340
349
|
/**
|
|
341
|
-
*
|
|
350
|
+
* Build the read check the space endpoints share: the hosted account's own
|
|
351
|
+
* session, or a space credential for the space being read.
|
|
352
|
+
*
|
|
353
|
+
* @param {Object} ctx
|
|
354
|
+
* @param {() => Promise<string|null>} ctx.getDid
|
|
355
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
|
|
356
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
357
|
+
* @returns {(request: Request, space: string, auth: {did: string}|null) => Promise<Response|null>}
|
|
358
|
+
* a Response to return, or null to proceed
|
|
359
|
+
*/
|
|
360
|
+
export function createReadAuthorizer({ getDid, resolveDid, verifier }) {
|
|
361
|
+
return async (request, space, auth) => {
|
|
362
|
+
const hosted = await getDid();
|
|
363
|
+
if (auth && auth.did === hosted) return null;
|
|
364
|
+
|
|
365
|
+
const credential = credentialFromRequest(request);
|
|
366
|
+
if (!credential) {
|
|
367
|
+
return errorResponse(
|
|
368
|
+
'AuthenticationRequired',
|
|
369
|
+
'A session or space credential is required',
|
|
370
|
+
401,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
await verifyPresentedCredential({
|
|
375
|
+
request,
|
|
376
|
+
credential,
|
|
377
|
+
space,
|
|
378
|
+
resolveDid,
|
|
379
|
+
verifier,
|
|
380
|
+
});
|
|
381
|
+
return null;
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if (err instanceof SpaceTokenError) {
|
|
384
|
+
return errorResponse(err.code, err.message, 401);
|
|
385
|
+
}
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The space credential a request presents, or null.
|
|
393
|
+
*
|
|
394
|
+
* `DPoP`, never `Bearer`: a credential names the key it is bound to, and a
|
|
395
|
+
* presentation that proves nothing about that key is what the binding exists to
|
|
396
|
+
* refuse.
|
|
397
|
+
*
|
|
398
|
+
* @param {Request} request
|
|
399
|
+
* @returns {string|null}
|
|
400
|
+
*/
|
|
401
|
+
export function credentialFromRequest(request) {
|
|
402
|
+
const match = (request.headers.get('authorization') ?? '').match(
|
|
403
|
+
/^DPoP\s+(.+)$/i,
|
|
404
|
+
);
|
|
405
|
+
return match ? match[1] : null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Check a presented space credential: the token itself, then the caller's
|
|
410
|
+
* possession of the key it names.
|
|
411
|
+
*
|
|
412
|
+
* The two questions are one function so that no endpoint accepting a credential
|
|
413
|
+
* can answer the first and forget the second.
|
|
342
414
|
*
|
|
343
415
|
* @param {Object} opts
|
|
416
|
+
* @param {Request} opts.request - the request the DPoP proof must cover
|
|
344
417
|
* @param {string} opts.credential - the raw JWT
|
|
345
418
|
* @param {string} opts.space - the space the request targets
|
|
346
|
-
* @param {(
|
|
419
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
|
|
347
420
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
348
421
|
* @returns {Promise<{iss: string}>}
|
|
349
422
|
*/
|
|
423
|
+
export async function verifyPresentedCredential({
|
|
424
|
+
request,
|
|
425
|
+
credential,
|
|
426
|
+
space,
|
|
427
|
+
resolveDid,
|
|
428
|
+
verifier,
|
|
429
|
+
}) {
|
|
430
|
+
const { iss, jkt } = await verifySpaceCredential({
|
|
431
|
+
credential,
|
|
432
|
+
space,
|
|
433
|
+
resolveDid,
|
|
434
|
+
verifier,
|
|
435
|
+
});
|
|
436
|
+
await verifyCredentialProof({ request, credential, jkt });
|
|
437
|
+
return { iss };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Verify a space credential presented to a repo host.
|
|
442
|
+
*
|
|
443
|
+
* @param {Object} opts
|
|
444
|
+
* @param {string} opts.credential - the raw JWT
|
|
445
|
+
* @param {string} opts.space - the space the request targets
|
|
446
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
|
|
447
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
448
|
+
* @returns {Promise<{iss: string, jkt: string}>} the authority, and the key the
|
|
449
|
+
* holder must prove possession of
|
|
450
|
+
*/
|
|
350
451
|
export async function verifySpaceCredential({
|
|
351
452
|
credential,
|
|
352
453
|
space,
|
|
@@ -373,7 +474,14 @@ export async function verifySpaceCredential({
|
|
|
373
474
|
'BadJwtIss',
|
|
374
475
|
);
|
|
375
476
|
}
|
|
376
|
-
|
|
477
|
+
// parseSpaceToken refuses a credential without one, so this is reachable only
|
|
478
|
+
// if that check is relaxed. It stays because the cost of it being wrong is a
|
|
479
|
+
// credential for a whole space that anyone holding a copy may present.
|
|
480
|
+
const jkt = payload.cnf?.jkt;
|
|
481
|
+
if (!jkt) {
|
|
482
|
+
throw new SpaceTokenError('Credential is not bound to a key', 'BadJwtCnf');
|
|
483
|
+
}
|
|
484
|
+
return { iss: payload.iss, jkt };
|
|
377
485
|
}
|
|
378
486
|
|
|
379
487
|
/**
|
|
@@ -388,8 +496,8 @@ export async function verifySpaceCredential({
|
|
|
388
496
|
* @param {string} opts.space
|
|
389
497
|
* @param {string} opts.userDid
|
|
390
498
|
* @param {string|undefined} opts.clientId
|
|
391
|
-
* @param {(
|
|
392
|
-
* @param {() => Promise<
|
|
499
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
|
|
500
|
+
* @param {() => Promise<import('../token.js').SpaceSigner>} opts.getSigner
|
|
393
501
|
* @param {typeof fetch} [opts.fetch]
|
|
394
502
|
* @returns {Promise<boolean>}
|
|
395
503
|
*/
|
package/src/handlers/manage.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* @param {Object} ctx
|
|
3
3
|
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
4
4
|
* @param {() => Promise<string|null>} ctx.getDid
|
|
5
|
-
* @param {() => Promise<
|
|
6
|
-
* @param {(
|
|
5
|
+
* @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
|
|
6
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
|
|
7
7
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
8
8
|
* @param {typeof fetch} [ctx.fetch]
|
|
9
9
|
* @returns {import('@pdsjs/core/pds').Routes}
|
|
@@ -11,10 +11,8 @@
|
|
|
11
11
|
export declare function createManageRoutes(ctx: {
|
|
12
12
|
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
13
13
|
getDid: () => Promise<string | null>;
|
|
14
|
-
getSigner: () => Promise<
|
|
15
|
-
|
|
16
|
-
}>;
|
|
17
|
-
resolveDid: (did: string) => Promise<any>;
|
|
14
|
+
getSigner: () => Promise<import('../token.js').SpaceSigner>;
|
|
15
|
+
resolveDid: import('@pdsjs/core/ports').DidResolverPort;
|
|
18
16
|
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
19
17
|
fetch?: typeof fetch;
|
|
20
18
|
}): import('@pdsjs/core/pds').Routes;
|