@pdsjs/spaces 2.0.0 → 2.0.2
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 +2 -2
- package/src/authority.d.ts +10 -0
- package/src/authority.js +18 -0
- package/src/car.d.ts +13 -4
- package/src/car.js +17 -6
- package/src/dpop.d.ts +15 -0
- package/src/dpop.js +85 -0
- package/src/handlers/auth.d.ts +56 -1
- package/src/handlers/auth.js +114 -33
- package/src/handlers/manage.js +344 -63
- package/src/handlers/read.d.ts +2 -0
- package/src/handlers/read.js +148 -63
- package/src/handlers/write.d.ts +4 -0
- package/src/handlers/write.js +88 -24
- package/src/memory-storage.d.ts +14 -0
- package/src/memory-storage.js +79 -12
- package/src/notify.d.ts +38 -0
- package/src/notify.js +87 -0
- package/src/routes.d.ts +7 -0
- package/src/routes.js +11 -0
- package/src/token.d.ts +11 -1
- package/src/token.js +23 -4
- package/src/verifier.d.ts +1 -15
- package/src/verifier.js +5 -71
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pdsjs/spaces",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"./service-auth": "./src/service-auth.js"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@pdsjs/core": "2.
|
|
26
|
+
"@pdsjs/core": "2.2.0"
|
|
27
27
|
},
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|
package/src/authority.d.ts
CHANGED
|
@@ -32,6 +32,16 @@ export declare function atprotoSigningKey(didDoc: any, did: string): string;
|
|
|
32
32
|
* @returns {string}
|
|
33
33
|
*/
|
|
34
34
|
export declare function spaceHostEndpoint(didDoc: any, did: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Where a service identifier points. A service identifier is a DID with an
|
|
37
|
+
* optional fragment naming the entry in its DID document. Without a fragment
|
|
38
|
+
* the account's ordinary PDS entry answers.
|
|
39
|
+
*
|
|
40
|
+
* @param {any} didDoc
|
|
41
|
+
* @param {string} service - e.g. 'did:web:syncer.example#atproto_space_syncer'
|
|
42
|
+
* @returns {string|null}
|
|
43
|
+
*/
|
|
44
|
+
export declare function namedServiceEndpoint(didDoc: any, service: string): string | null;
|
|
35
45
|
/**
|
|
36
46
|
* The `aud` a delegation token must carry for a given authority.
|
|
37
47
|
* @param {string} spaceDid
|
package/src/authority.js
CHANGED
|
@@ -120,6 +120,24 @@ export function spaceHostEndpoint(didDoc, did) {
|
|
|
120
120
|
return endpoint;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Where a service identifier points. A service identifier is a DID with an
|
|
125
|
+
* optional fragment naming the entry in its DID document. Without a fragment
|
|
126
|
+
* the account's ordinary PDS entry answers.
|
|
127
|
+
*
|
|
128
|
+
* @param {any} didDoc
|
|
129
|
+
* @param {string} service - e.g. 'did:web:syncer.example#atproto_space_syncer'
|
|
130
|
+
* @returns {string|null}
|
|
131
|
+
*/
|
|
132
|
+
export function namedServiceEndpoint(didDoc, service) {
|
|
133
|
+
const [did, fragment] = service.split('#');
|
|
134
|
+
return serviceEndpoint(
|
|
135
|
+
didDoc,
|
|
136
|
+
did,
|
|
137
|
+
fragment ? `#${fragment}` : ATPROTO_PDS_ID,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
123
141
|
/**
|
|
124
142
|
* The `aud` a delegation token must carry for a given authority.
|
|
125
143
|
* @param {string} spaceDid
|
package/src/car.d.ts
CHANGED
|
@@ -15,16 +15,18 @@ export type SerializedRecord = {
|
|
|
15
15
|
rkey: string;
|
|
16
16
|
cid: string;
|
|
17
17
|
/**
|
|
18
|
-
* - DAG-CBOR encoded record
|
|
18
|
+
* - DAG-CBOR encoded record, absent under
|
|
19
|
+
* excludeValues
|
|
19
20
|
*/
|
|
20
|
-
value
|
|
21
|
+
value?: Uint8Array;
|
|
21
22
|
};
|
|
22
23
|
/**
|
|
23
24
|
* @typedef {Object} SerializedRecord
|
|
24
25
|
* @property {string} collection
|
|
25
26
|
* @property {string} rkey
|
|
26
27
|
* @property {string} cid
|
|
27
|
-
* @property {Uint8Array} value - DAG-CBOR encoded record
|
|
28
|
+
* @property {Uint8Array} [value] - DAG-CBOR encoded record, absent under
|
|
29
|
+
* excludeValues
|
|
28
30
|
*/
|
|
29
31
|
/**
|
|
30
32
|
* Serialize a repo as a CAR: two roots in order — the signed commit, then the
|
|
@@ -34,8 +36,15 @@ export type SerializedRecord = {
|
|
|
34
36
|
* Records are collected up front because the index has to precede the blocks it
|
|
35
37
|
* describes.
|
|
36
38
|
*
|
|
39
|
+
* Under `excludeValues` the CAR carries the two roots and no other block. The
|
|
40
|
+
* index still folds into the same set hash, so a syncer authenticates it against
|
|
41
|
+
* the commit and then fetches only the records it lacks.
|
|
42
|
+
*
|
|
37
43
|
* @param {import('./commit.js').SignedCommit} commit
|
|
38
44
|
* @param {Iterable<SerializedRecord>} records
|
|
45
|
+
* @param {{excludeValues?: boolean}} [opts]
|
|
39
46
|
* @returns {Promise<Uint8Array>}
|
|
40
47
|
*/
|
|
41
|
-
export declare function serializeRepo(commit: import('./commit.js').SignedCommit, records: Iterable<SerializedRecord
|
|
48
|
+
export declare function serializeRepo(commit: import('./commit.js').SignedCommit, records: Iterable<SerializedRecord>, opts?: {
|
|
49
|
+
excludeValues?: boolean;
|
|
50
|
+
}): Promise<Uint8Array>;
|
package/src/car.js
CHANGED
|
@@ -31,7 +31,8 @@ export function byCanonicalKey(a, b) {
|
|
|
31
31
|
* @property {string} collection
|
|
32
32
|
* @property {string} rkey
|
|
33
33
|
* @property {string} cid
|
|
34
|
-
* @property {Uint8Array} value - DAG-CBOR encoded record
|
|
34
|
+
* @property {Uint8Array} [value] - DAG-CBOR encoded record, absent under
|
|
35
|
+
* excludeValues
|
|
35
36
|
*/
|
|
36
37
|
|
|
37
38
|
/**
|
|
@@ -42,11 +43,16 @@ export function byCanonicalKey(a, b) {
|
|
|
42
43
|
* Records are collected up front because the index has to precede the blocks it
|
|
43
44
|
* describes.
|
|
44
45
|
*
|
|
46
|
+
* Under `excludeValues` the CAR carries the two roots and no other block. The
|
|
47
|
+
* index still folds into the same set hash, so a syncer authenticates it against
|
|
48
|
+
* the commit and then fetches only the records it lacks.
|
|
49
|
+
*
|
|
45
50
|
* @param {import('./commit.js').SignedCommit} commit
|
|
46
51
|
* @param {Iterable<SerializedRecord>} records
|
|
52
|
+
* @param {{excludeValues?: boolean}} [opts]
|
|
47
53
|
* @returns {Promise<Uint8Array>}
|
|
48
54
|
*/
|
|
49
|
-
export async function serializeRepo(commit, records) {
|
|
55
|
+
export async function serializeRepo(commit, records, opts = {}) {
|
|
50
56
|
/** @type {Map<string, SerializedRecord>} */
|
|
51
57
|
const byPath = new Map();
|
|
52
58
|
for (const record of records) {
|
|
@@ -71,10 +77,15 @@ export async function serializeRepo(commit, records) {
|
|
|
71
77
|
const blocks = [
|
|
72
78
|
{ cid: commitRoot, data: commitBytes },
|
|
73
79
|
{ cid: indexRoot, data: indexBytes },
|
|
74
|
-
...
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
...(opts.excludeValues
|
|
81
|
+
? []
|
|
82
|
+
: paths.map((path) => {
|
|
83
|
+
const record = /** @type {SerializedRecord} */ (byPath.get(path));
|
|
84
|
+
return {
|
|
85
|
+
cid: record.cid,
|
|
86
|
+
data: /** @type {Uint8Array} */ (record.value),
|
|
87
|
+
};
|
|
88
|
+
})),
|
|
78
89
|
];
|
|
79
90
|
|
|
80
91
|
return buildCarFile([commitRoot, indexRoot], blocks);
|
package/src/dpop.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check the DPoP proof presented with a space credential.
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} opts
|
|
5
|
+
* @param {Request} opts.request - the request the proof must cover
|
|
6
|
+
* @param {string} opts.credential - the raw credential, hashed into `ath`
|
|
7
|
+
* @param {string} opts.jkt - the thumbprint from the credential's `cnf.jkt`
|
|
8
|
+
* @returns {Promise<void>}
|
|
9
|
+
* @throws {SpaceTokenError} when the proof is absent, invalid, or replayed
|
|
10
|
+
*/
|
|
11
|
+
export declare function verifyCredentialProof({ request, credential, jkt }: {
|
|
12
|
+
request: Request;
|
|
13
|
+
credential: string;
|
|
14
|
+
jkt: string;
|
|
15
|
+
}): Promise<void>;
|
package/src/dpop.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @pdsjs/spaces/dpop - the key a space credential is bound to (RFC 9449).
|
|
2
|
+
//
|
|
3
|
+
// One credential reads a whole space, and the holder presents it to every repo
|
|
4
|
+
// host in that space. A bearer credential would therefore be a shared secret: a
|
|
5
|
+
// host handed one to serve its own repo could replay it against every other host
|
|
6
|
+
// in the space. So the authority binds the credential to a key the application
|
|
7
|
+
// names as a thumbprint, and each request carries a fresh proof signed by that
|
|
8
|
+
// key.
|
|
9
|
+
//
|
|
10
|
+
// The proof itself is verified by @pdsjs/core/oauth, which does the same work
|
|
11
|
+
// for OAuth access tokens. What this module adds is the space rules: the proof
|
|
12
|
+
// is mandatory, and a jti is accepted once.
|
|
13
|
+
|
|
14
|
+
import { parseDpopProof } from '@pdsjs/core/oauth';
|
|
15
|
+
import { SpaceTokenError } from './token.js';
|
|
16
|
+
|
|
17
|
+
// parseDpopProof accepts an `iat` up to 300 seconds old, so a jti has to be
|
|
18
|
+
// remembered for longer than that. Otherwise a proof outlives the memory of it
|
|
19
|
+
// and the last few seconds of its life are replayable.
|
|
20
|
+
const JTI_RETENTION_MS = 360_000;
|
|
21
|
+
|
|
22
|
+
/** @type {Map<string, number>} jti -> the instant it may be forgotten */
|
|
23
|
+
const seenJtis = new Map();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Record a jti, answering whether this is the first time it was presented.
|
|
27
|
+
*
|
|
28
|
+
* Held in memory, which is sound for the same reason the write queue in
|
|
29
|
+
* writer.js is: one space is served by exactly one process, a Node PDS or a
|
|
30
|
+
* single Durable Object. Expired entries are dropped on each call, so the map
|
|
31
|
+
* holds one request's worth of traffic rather than growing without bound.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} jti
|
|
34
|
+
* @param {number} now
|
|
35
|
+
* @returns {boolean}
|
|
36
|
+
*/
|
|
37
|
+
function firstUse(jti, now) {
|
|
38
|
+
for (const [seen, expiresAt] of seenJtis) {
|
|
39
|
+
if (expiresAt <= now) seenJtis.delete(seen);
|
|
40
|
+
}
|
|
41
|
+
if (seenJtis.has(jti)) return false;
|
|
42
|
+
seenJtis.set(jti, now + JTI_RETENTION_MS);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check the DPoP proof presented with a space credential.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} opts
|
|
50
|
+
* @param {Request} opts.request - the request the proof must cover
|
|
51
|
+
* @param {string} opts.credential - the raw credential, hashed into `ath`
|
|
52
|
+
* @param {string} opts.jkt - the thumbprint from the credential's `cnf.jkt`
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
* @throws {SpaceTokenError} when the proof is absent, invalid, or replayed
|
|
55
|
+
*/
|
|
56
|
+
export async function verifyCredentialProof({ request, credential, jkt }) {
|
|
57
|
+
const proof = request.headers.get('dpop');
|
|
58
|
+
if (!proof) {
|
|
59
|
+
throw new SpaceTokenError(
|
|
60
|
+
'A space credential requires a DPoP proof',
|
|
61
|
+
'MissingDpopProof',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @type {import('@pdsjs/core/oauth').DpopProofResult} */
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = await parseDpopProof(
|
|
69
|
+
proof,
|
|
70
|
+
request.method,
|
|
71
|
+
request.url,
|
|
72
|
+
jkt,
|
|
73
|
+
credential,
|
|
74
|
+
);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new SpaceTokenError(
|
|
77
|
+
err instanceof Error ? err.message : String(err),
|
|
78
|
+
'BadDpopProof',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!firstUse(parsed.jti, Date.now())) {
|
|
83
|
+
throw new SpaceTokenError('DPoP proof replayed', 'BadDpopProof');
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/handlers/auth.d.ts
CHANGED
|
@@ -19,15 +19,69 @@ export declare function createAuthRoutes(ctx: {
|
|
|
19
19
|
fetch?: typeof fetch;
|
|
20
20
|
}): import('@pdsjs/core/pds').Routes;
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Build the read check the space endpoints share: the hosted account's own
|
|
23
|
+
* session, or a space credential for the space being read.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} ctx
|
|
26
|
+
* @param {() => Promise<string|null>} ctx.getDid
|
|
27
|
+
* @param {(did: string) => Promise<any>} ctx.resolveDid
|
|
28
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
29
|
+
* @returns {(request: Request, space: string, auth: {did: string}|null) => Promise<Response|null>}
|
|
30
|
+
* a Response to return, or null to proceed
|
|
31
|
+
*/
|
|
32
|
+
export declare function createReadAuthorizer({ getDid, resolveDid, verifier }: {
|
|
33
|
+
getDid: () => Promise<string | null>;
|
|
34
|
+
resolveDid: (did: string) => Promise<any>;
|
|
35
|
+
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
36
|
+
}): (request: Request, space: string, auth: {
|
|
37
|
+
did: string;
|
|
38
|
+
} | null) => Promise<Response | null>;
|
|
39
|
+
/**
|
|
40
|
+
* The space credential a request presents, or null.
|
|
41
|
+
*
|
|
42
|
+
* `DPoP`, never `Bearer`: a credential names the key it is bound to, and a
|
|
43
|
+
* presentation that proves nothing about that key is what the binding exists to
|
|
44
|
+
* refuse.
|
|
45
|
+
*
|
|
46
|
+
* @param {Request} request
|
|
47
|
+
* @returns {string|null}
|
|
48
|
+
*/
|
|
49
|
+
export declare function credentialFromRequest(request: Request): string | null;
|
|
50
|
+
/**
|
|
51
|
+
* Check a presented space credential: the token itself, then the caller's
|
|
52
|
+
* possession of the key it names.
|
|
53
|
+
*
|
|
54
|
+
* The two questions are one function so that no endpoint accepting a credential
|
|
55
|
+
* can answer the first and forget the second.
|
|
23
56
|
*
|
|
24
57
|
* @param {Object} opts
|
|
58
|
+
* @param {Request} opts.request - the request the DPoP proof must cover
|
|
25
59
|
* @param {string} opts.credential - the raw JWT
|
|
26
60
|
* @param {string} opts.space - the space the request targets
|
|
27
61
|
* @param {(did: string) => Promise<any>} opts.resolveDid
|
|
28
62
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
29
63
|
* @returns {Promise<{iss: string}>}
|
|
30
64
|
*/
|
|
65
|
+
export declare function verifyPresentedCredential({ request, credential, space, resolveDid, verifier, }: {
|
|
66
|
+
request: Request;
|
|
67
|
+
credential: string;
|
|
68
|
+
space: string;
|
|
69
|
+
resolveDid: (did: string) => Promise<any>;
|
|
70
|
+
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
71
|
+
}): Promise<{
|
|
72
|
+
iss: string;
|
|
73
|
+
}>;
|
|
74
|
+
/**
|
|
75
|
+
* Verify a space credential presented to a repo host.
|
|
76
|
+
*
|
|
77
|
+
* @param {Object} opts
|
|
78
|
+
* @param {string} opts.credential - the raw JWT
|
|
79
|
+
* @param {string} opts.space - the space the request targets
|
|
80
|
+
* @param {(did: string) => Promise<any>} opts.resolveDid
|
|
81
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
82
|
+
* @returns {Promise<{iss: string, jkt: string}>} the authority, and the key the
|
|
83
|
+
* holder must prove possession of
|
|
84
|
+
*/
|
|
31
85
|
export declare function verifySpaceCredential({ credential, space, resolveDid, verifier, }: {
|
|
32
86
|
credential: string;
|
|
33
87
|
space: string;
|
|
@@ -35,6 +89,7 @@ export declare function verifySpaceCredential({ credential, space, resolveDid, v
|
|
|
35
89
|
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
36
90
|
}): Promise<{
|
|
37
91
|
iss: string;
|
|
92
|
+
jkt: string;
|
|
38
93
|
}>;
|
|
39
94
|
/**
|
|
40
95
|
* Ask a space's managing app whether to authorize a user.
|
package/src/handlers/auth.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
spaceHostEndpoint,
|
|
16
16
|
spaceSigningKey,
|
|
17
17
|
} from '../authority.js';
|
|
18
|
+
import { verifyCredentialProof } from '../dpop.js';
|
|
18
19
|
import { createServiceAuth } from '../service-auth.js';
|
|
19
20
|
import {
|
|
20
21
|
createSpaceToken,
|
|
@@ -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,14 @@ 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 body. 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
|
+
if (typeof dpopJkt !== 'string' || !dpopJkt) {
|
|
190
|
+
return errorResponse('InvalidRequest', 'dpopJkt is required');
|
|
191
|
+
}
|
|
192
|
+
|
|
184
193
|
// Structural validation only. Full verification means resolving the
|
|
185
194
|
// client_id to its client-metadata.json, fetching the published JWKS,
|
|
186
195
|
// and checking the signature against the key named by `kid`. Until then
|
|
@@ -202,7 +211,7 @@ export function createAuthRoutes(ctx) {
|
|
|
202
211
|
}
|
|
203
212
|
|
|
204
213
|
const spaceRow = await spaceStorage.getSpace(space);
|
|
205
|
-
if (!spaceRow
|
|
214
|
+
if (!spaceRow?.isOwner) {
|
|
206
215
|
return errorResponse('SpaceNotFound', 'Space not found', 404);
|
|
207
216
|
}
|
|
208
217
|
if (spaceRow.deletedAt) {
|
|
@@ -253,40 +262,13 @@ export function createAuthRoutes(ctx) {
|
|
|
253
262
|
|
|
254
263
|
const credential = await createSpaceToken(
|
|
255
264
|
'credential',
|
|
256
|
-
{ iss: authorityDid, sub: space },
|
|
265
|
+
{ iss: authorityDid, sub: space, dpopJkt },
|
|
257
266
|
await getSigner(),
|
|
258
267
|
);
|
|
259
268
|
return Response.json({ credential });
|
|
260
269
|
},
|
|
261
270
|
},
|
|
262
271
|
|
|
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 || !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
272
|
// Authority role: the writer set, which is the sync boundary. Accounts that
|
|
291
273
|
// have written at least one record — not the broader set allowed to write,
|
|
292
274
|
// which the authority may not even track.
|
|
@@ -305,7 +287,7 @@ export function createAuthRoutes(ctx) {
|
|
|
305
287
|
}
|
|
306
288
|
|
|
307
289
|
const row = await spaceStorage.getSpace(space);
|
|
308
|
-
if (!row
|
|
290
|
+
if (!row?.isOwner) {
|
|
309
291
|
return errorResponse('SpaceNotFound', 'Space not found', 404);
|
|
310
292
|
}
|
|
311
293
|
|
|
@@ -338,15 +320,107 @@ function toJsonBytes(bytes) {
|
|
|
338
320
|
}
|
|
339
321
|
|
|
340
322
|
/**
|
|
341
|
-
*
|
|
323
|
+
* Build the read check the space endpoints share: the hosted account's own
|
|
324
|
+
* session, or a space credential for the space being read.
|
|
325
|
+
*
|
|
326
|
+
* @param {Object} ctx
|
|
327
|
+
* @param {() => Promise<string|null>} ctx.getDid
|
|
328
|
+
* @param {(did: string) => Promise<any>} ctx.resolveDid
|
|
329
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
330
|
+
* @returns {(request: Request, space: string, auth: {did: string}|null) => Promise<Response|null>}
|
|
331
|
+
* a Response to return, or null to proceed
|
|
332
|
+
*/
|
|
333
|
+
export function createReadAuthorizer({ getDid, resolveDid, verifier }) {
|
|
334
|
+
return async (request, space, auth) => {
|
|
335
|
+
const hosted = await getDid();
|
|
336
|
+
if (auth && auth.did === hosted) return null;
|
|
337
|
+
|
|
338
|
+
const credential = credentialFromRequest(request);
|
|
339
|
+
if (!credential) {
|
|
340
|
+
return errorResponse(
|
|
341
|
+
'AuthenticationRequired',
|
|
342
|
+
'A session or space credential is required',
|
|
343
|
+
401,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
await verifyPresentedCredential({
|
|
348
|
+
request,
|
|
349
|
+
credential,
|
|
350
|
+
space,
|
|
351
|
+
resolveDid,
|
|
352
|
+
verifier,
|
|
353
|
+
});
|
|
354
|
+
return null;
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (err instanceof SpaceTokenError) {
|
|
357
|
+
return errorResponse(err.code, err.message, 401);
|
|
358
|
+
}
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The space credential a request presents, or null.
|
|
366
|
+
*
|
|
367
|
+
* `DPoP`, never `Bearer`: a credential names the key it is bound to, and a
|
|
368
|
+
* presentation that proves nothing about that key is what the binding exists to
|
|
369
|
+
* refuse.
|
|
370
|
+
*
|
|
371
|
+
* @param {Request} request
|
|
372
|
+
* @returns {string|null}
|
|
373
|
+
*/
|
|
374
|
+
export function credentialFromRequest(request) {
|
|
375
|
+
const match = (request.headers.get('authorization') ?? '').match(
|
|
376
|
+
/^DPoP\s+(.+)$/i,
|
|
377
|
+
);
|
|
378
|
+
return match ? match[1] : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Check a presented space credential: the token itself, then the caller's
|
|
383
|
+
* possession of the key it names.
|
|
384
|
+
*
|
|
385
|
+
* The two questions are one function so that no endpoint accepting a credential
|
|
386
|
+
* can answer the first and forget the second.
|
|
342
387
|
*
|
|
343
388
|
* @param {Object} opts
|
|
389
|
+
* @param {Request} opts.request - the request the DPoP proof must cover
|
|
344
390
|
* @param {string} opts.credential - the raw JWT
|
|
345
391
|
* @param {string} opts.space - the space the request targets
|
|
346
392
|
* @param {(did: string) => Promise<any>} opts.resolveDid
|
|
347
393
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
348
394
|
* @returns {Promise<{iss: string}>}
|
|
349
395
|
*/
|
|
396
|
+
export async function verifyPresentedCredential({
|
|
397
|
+
request,
|
|
398
|
+
credential,
|
|
399
|
+
space,
|
|
400
|
+
resolveDid,
|
|
401
|
+
verifier,
|
|
402
|
+
}) {
|
|
403
|
+
const { iss, jkt } = await verifySpaceCredential({
|
|
404
|
+
credential,
|
|
405
|
+
space,
|
|
406
|
+
resolveDid,
|
|
407
|
+
verifier,
|
|
408
|
+
});
|
|
409
|
+
await verifyCredentialProof({ request, credential, jkt });
|
|
410
|
+
return { iss };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Verify a space credential presented to a repo host.
|
|
415
|
+
*
|
|
416
|
+
* @param {Object} opts
|
|
417
|
+
* @param {string} opts.credential - the raw JWT
|
|
418
|
+
* @param {string} opts.space - the space the request targets
|
|
419
|
+
* @param {(did: string) => Promise<any>} opts.resolveDid
|
|
420
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
421
|
+
* @returns {Promise<{iss: string, jkt: string}>} the authority, and the key the
|
|
422
|
+
* holder must prove possession of
|
|
423
|
+
*/
|
|
350
424
|
export async function verifySpaceCredential({
|
|
351
425
|
credential,
|
|
352
426
|
space,
|
|
@@ -373,7 +447,14 @@ export async function verifySpaceCredential({
|
|
|
373
447
|
'BadJwtIss',
|
|
374
448
|
);
|
|
375
449
|
}
|
|
376
|
-
|
|
450
|
+
// parseSpaceToken refuses a credential without one, so this is reachable only
|
|
451
|
+
// if that check is relaxed. It stays because the cost of it being wrong is a
|
|
452
|
+
// credential for a whole space that anyone holding a copy may present.
|
|
453
|
+
const jkt = payload.cnf?.jkt;
|
|
454
|
+
if (!jkt) {
|
|
455
|
+
throw new SpaceTokenError('Credential is not bound to a key', 'BadJwtCnf');
|
|
456
|
+
}
|
|
457
|
+
return { iss: payload.iss, jkt };
|
|
377
458
|
}
|
|
378
459
|
|
|
379
460
|
/**
|