@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.
@@ -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<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
65
- * @param {(did: string) => Promise<any>} ctx.resolveDid
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 (spaceRow.policy === 'public') {
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
- * Verify a space credential presented to a repo host.
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 {(did: string) => Promise<any>} opts.resolveDid
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
- return { iss: payload.iss };
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 {(did: string) => Promise<any>} opts.resolveDid
392
- * @param {() => Promise<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} opts.getSigner
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
  */
@@ -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<{sign: (bytes: Uint8Array) => Promise<Uint8Array>}>} ctx.getSigner
6
- * @param {(did: string) => Promise<any>} ctx.resolveDid
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
- sign: (bytes: Uint8Array) => Promise<Uint8Array>;
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;