@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pdsjs/spaces",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
"./car": "./src/car.js",
|
|
21
21
|
"./token": "./src/token.js",
|
|
22
22
|
"./authority": "./src/authority.js",
|
|
23
|
-
"./service-auth": "./src/service-auth.js"
|
|
23
|
+
"./service-auth": "./src/service-auth.js",
|
|
24
|
+
"./admin": "./src/admin.js"
|
|
24
25
|
},
|
|
25
26
|
"dependencies": {
|
|
26
|
-
"@pdsjs/core": "2.
|
|
27
|
+
"@pdsjs/core": "2.3.0"
|
|
27
28
|
},
|
|
28
29
|
"publishConfig": {
|
|
29
30
|
"access": "public"
|
package/src/admin.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {Object} ctx
|
|
3
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
4
|
+
* @param {() => Promise<string|null>} ctx.getDid
|
|
5
|
+
* @returns {import('@pdsjs/core/ports').SpaceAdminPort}
|
|
6
|
+
*/
|
|
7
|
+
export declare function createSpaceAdmin({ spaceStorage, getDid }: {
|
|
8
|
+
spaceStorage: import('@pdsjs/core/ports').SpaceStoragePort;
|
|
9
|
+
getDid: () => Promise<string | null>;
|
|
10
|
+
}): import('@pdsjs/core/ports').SpaceAdminPort;
|
package/src/admin.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @pdsjs/spaces/admin - space management for the account page.
|
|
2
|
+
//
|
|
3
|
+
// The account page runs behind the owner's own session, so these skip the
|
|
4
|
+
// scope checks the XRPC management endpoints apply to third-party callers.
|
|
5
|
+
// They mutate the same storage the same way com.atproto.simplespace.* does.
|
|
6
|
+
|
|
7
|
+
import { cborDecode, createTid } from '@pdsjs/core/repo';
|
|
8
|
+
import { LtHash } from './lthash.js';
|
|
9
|
+
import { makeSpaceRow } from './space-row.js';
|
|
10
|
+
import { formatSpaceUri } from './uri.js';
|
|
11
|
+
import { applyWrites } from './writer.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} ctx
|
|
15
|
+
* @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
|
|
16
|
+
* @param {() => Promise<string|null>} ctx.getDid
|
|
17
|
+
* @returns {import('@pdsjs/core/ports').SpaceAdminPort}
|
|
18
|
+
*/
|
|
19
|
+
export function createSpaceAdmin({ spaceStorage, getDid }) {
|
|
20
|
+
/** @returns {Promise<string>} */
|
|
21
|
+
async function requireDid() {
|
|
22
|
+
const did = await getDid();
|
|
23
|
+
if (!did) throw new Error('server not initialised');
|
|
24
|
+
return did;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} space
|
|
29
|
+
* @returns {Promise<void>}
|
|
30
|
+
*/
|
|
31
|
+
async function requireOwnedSpace(space) {
|
|
32
|
+
const row = await spaceStorage.getSpace(space);
|
|
33
|
+
if (!row?.isOwner || row.deletedAt) {
|
|
34
|
+
throw new Error(`no such space: ${space}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
async createSpace({ type, skey }) {
|
|
40
|
+
if (!type.includes('.')) {
|
|
41
|
+
throw new Error(`space type must be an NSID: ${type}`);
|
|
42
|
+
}
|
|
43
|
+
if (skey && skey.length > 512) {
|
|
44
|
+
throw new Error('skey is too long');
|
|
45
|
+
}
|
|
46
|
+
const did = await requireDid();
|
|
47
|
+
const uri = formatSpaceUri({
|
|
48
|
+
spaceDid: did,
|
|
49
|
+
spaceType: type,
|
|
50
|
+
skey: skey || createTid(),
|
|
51
|
+
});
|
|
52
|
+
if (await spaceStorage.getSpace(uri)) {
|
|
53
|
+
throw new Error(`space already exists: ${uri}`);
|
|
54
|
+
}
|
|
55
|
+
await spaceStorage.putSpace(makeSpaceRow(uri, { isOwner: true }));
|
|
56
|
+
// The owner joins their own space: a member-list space checks membership
|
|
57
|
+
// before it issues a credential, and that is the default policy.
|
|
58
|
+
await spaceStorage.addMember(uri, did);
|
|
59
|
+
return { uri };
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async addMember(space, did) {
|
|
63
|
+
if (!did.startsWith('did:')) {
|
|
64
|
+
throw new Error(`not a DID: ${did}`);
|
|
65
|
+
}
|
|
66
|
+
await requireOwnedSpace(space);
|
|
67
|
+
await spaceStorage.addMember(space, did);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async removeMember(space, did) {
|
|
71
|
+
const owner = await requireDid();
|
|
72
|
+
if (did === owner) {
|
|
73
|
+
throw new Error('the owner cannot leave their own space');
|
|
74
|
+
}
|
|
75
|
+
await requireOwnedSpace(space);
|
|
76
|
+
await spaceStorage.removeMember(space, did);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async getRecord(space, collection, rkey) {
|
|
80
|
+
await requireOwnedSpace(space);
|
|
81
|
+
const row = await spaceStorage.getSpaceRecord(space, collection, rkey);
|
|
82
|
+
return row ? /** @type {Object} */ (cborDecode(row.value)) : null;
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async createRecord(space, collection, rkey, record) {
|
|
86
|
+
await writeRecord(space, 'create', collection, rkey, record);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async putRecord(space, collection, rkey, record) {
|
|
90
|
+
await writeRecord(space, 'put', collection, rkey, record);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} space
|
|
96
|
+
* @param {'create'|'put'} action
|
|
97
|
+
* @param {string} collection
|
|
98
|
+
* @param {string} rkey
|
|
99
|
+
* @param {Object} record
|
|
100
|
+
* @returns {Promise<void>}
|
|
101
|
+
*/
|
|
102
|
+
async function writeRecord(space, action, collection, rkey, record) {
|
|
103
|
+
const did = await requireDid();
|
|
104
|
+
await requireOwnedSpace(space);
|
|
105
|
+
const commit = await applyWrites(spaceStorage, {
|
|
106
|
+
space,
|
|
107
|
+
writes: [{ action, collection, rkey, record }],
|
|
108
|
+
});
|
|
109
|
+
// What fireNotifyWrite does after an XRPC write into the owner's own
|
|
110
|
+
// space: record the owner in the writer set, so listRepos knows this
|
|
111
|
+
// repo before its first push.
|
|
112
|
+
const hash = await new LtHash(commit.setHash).digest();
|
|
113
|
+
await spaceStorage.putSpaceWriter(space, did, commit.rev, hash);
|
|
114
|
+
}
|
|
115
|
+
}
|
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
|
|
@@ -41,7 +51,7 @@ export declare function spaceHostAudience(spaceDid: string): string;
|
|
|
41
51
|
/**
|
|
42
52
|
* Build the `getSigningKey` callback verifySpaceToken expects.
|
|
43
53
|
*
|
|
44
|
-
* @param {(
|
|
45
|
-
* @returns {(iss: string, kid?: string) => Promise<string>}
|
|
54
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} resolveDid
|
|
55
|
+
* @returns {(iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>}
|
|
46
56
|
*/
|
|
47
|
-
export declare function didKeyResolver(resolveDid: (
|
|
57
|
+
export declare function didKeyResolver(resolveDid: import('@pdsjs/core/ports').DidResolverPort): (iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>;
|
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
|
|
@@ -132,12 +150,12 @@ export function spaceHostAudience(spaceDid) {
|
|
|
132
150
|
/**
|
|
133
151
|
* Build the `getSigningKey` callback verifySpaceToken expects.
|
|
134
152
|
*
|
|
135
|
-
* @param {(
|
|
136
|
-
* @returns {(iss: string, kid?: string) => Promise<string>}
|
|
153
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} resolveDid
|
|
154
|
+
* @returns {(iss: string, kid?: string, forceRefresh?: boolean) => Promise<string>}
|
|
137
155
|
*/
|
|
138
156
|
export function didKeyResolver(resolveDid) {
|
|
139
|
-
return async (iss, kid) => {
|
|
140
|
-
const didDoc = await resolveDid(iss);
|
|
157
|
+
return async (iss, kid, forceRefresh = false) => {
|
|
158
|
+
const didDoc = await resolveDid(iss, { forceRefresh });
|
|
141
159
|
if (!didDoc) {
|
|
142
160
|
throw new SpaceAuthorityError(`Could not resolve ${iss}`, 'DidNotFound');
|
|
143
161
|
}
|
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,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The key an application asks a credential to be bound to, read from the proof
|
|
3
|
+
* it presents when it asks.
|
|
4
|
+
*
|
|
5
|
+
* A thumbprint named in the request body is a claim: any holder of the
|
|
6
|
+
* delegation token can make it about a key somebody else controls. A proof
|
|
7
|
+
* demonstrates possession of the key it names. The proof carries no `ath`,
|
|
8
|
+
* because a delegation token is an authorization grant rather than an access
|
|
9
|
+
* token, so there is no bound token to hash.
|
|
10
|
+
*
|
|
11
|
+
* @param {Request} request
|
|
12
|
+
* @returns {Promise<string|null>} the proof's thumbprint, or null when the
|
|
13
|
+
* request carries no proof
|
|
14
|
+
* @throws {SpaceTokenError} when a proof is present but invalid or replayed
|
|
15
|
+
*/
|
|
16
|
+
export declare function credentialBindingFromProof(request: Request): Promise<string | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Check the DPoP proof presented with a space credential.
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} opts
|
|
21
|
+
* @param {Request} opts.request - the request the proof must cover
|
|
22
|
+
* @param {string} opts.credential - the raw credential, hashed into `ath`
|
|
23
|
+
* @param {string} opts.jkt - the thumbprint from the credential's `cnf.jkt`
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
* @throws {SpaceTokenError} when the proof is absent, invalid, or replayed
|
|
26
|
+
*/
|
|
27
|
+
export declare function verifyCredentialProof({ request, credential, jkt }: {
|
|
28
|
+
request: Request;
|
|
29
|
+
credential: string;
|
|
30
|
+
jkt: string;
|
|
31
|
+
}): Promise<void>;
|
package/src/dpop.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
* The key an application asks a credential to be bound to, read from the proof
|
|
48
|
+
* it presents when it asks.
|
|
49
|
+
*
|
|
50
|
+
* A thumbprint named in the request body is a claim: any holder of the
|
|
51
|
+
* delegation token can make it about a key somebody else controls. A proof
|
|
52
|
+
* demonstrates possession of the key it names. The proof carries no `ath`,
|
|
53
|
+
* because a delegation token is an authorization grant rather than an access
|
|
54
|
+
* token, so there is no bound token to hash.
|
|
55
|
+
*
|
|
56
|
+
* @param {Request} request
|
|
57
|
+
* @returns {Promise<string|null>} the proof's thumbprint, or null when the
|
|
58
|
+
* request carries no proof
|
|
59
|
+
* @throws {SpaceTokenError} when a proof is present but invalid or replayed
|
|
60
|
+
*/
|
|
61
|
+
export async function credentialBindingFromProof(request) {
|
|
62
|
+
const proof = request.headers.get('dpop');
|
|
63
|
+
if (!proof) return null;
|
|
64
|
+
|
|
65
|
+
/** @type {import('@pdsjs/core/oauth').DpopProofResult} */
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = await parseDpopProof(proof, request.method, request.url);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
throw new SpaceTokenError(
|
|
71
|
+
err instanceof Error ? err.message : String(err),
|
|
72
|
+
'BadDpopProof',
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!firstUse(parsed.jti, Date.now())) {
|
|
77
|
+
throw new SpaceTokenError('DPoP proof replayed', 'BadDpopProof');
|
|
78
|
+
}
|
|
79
|
+
return parsed.jkt;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check the DPoP proof presented with a space credential.
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} opts
|
|
86
|
+
* @param {Request} opts.request - the request the proof must cover
|
|
87
|
+
* @param {string} opts.credential - the raw credential, hashed into `ath`
|
|
88
|
+
* @param {string} opts.jkt - the thumbprint from the credential's `cnf.jkt`
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
* @throws {SpaceTokenError} when the proof is absent, invalid, or replayed
|
|
91
|
+
*/
|
|
92
|
+
export async function verifyCredentialProof({ request, credential, jkt }) {
|
|
93
|
+
const proof = request.headers.get('dpop');
|
|
94
|
+
if (!proof) {
|
|
95
|
+
throw new SpaceTokenError(
|
|
96
|
+
'A space credential requires a DPoP proof',
|
|
97
|
+
'MissingDpopProof',
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @type {import('@pdsjs/core/oauth').DpopProofResult} */
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = await parseDpopProof(
|
|
105
|
+
proof,
|
|
106
|
+
request.method,
|
|
107
|
+
request.url,
|
|
108
|
+
jkt,
|
|
109
|
+
credential,
|
|
110
|
+
);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new SpaceTokenError(
|
|
113
|
+
err instanceof Error ? err.message : String(err),
|
|
114
|
+
'BadDpopProof',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!firstUse(parsed.jti, Date.now())) {
|
|
119
|
+
throw new SpaceTokenError('DPoP proof replayed', 'BadDpopProof');
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/handlers/auth.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,30 +11,83 @@
|
|
|
11
11
|
export declare function createAuthRoutes(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;
|
|
21
19
|
/**
|
|
22
|
-
*
|
|
20
|
+
* Build the read check the space endpoints share: the hosted account's own
|
|
21
|
+
* session, or a space credential for the space being read.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} ctx
|
|
24
|
+
* @param {() => Promise<string|null>} ctx.getDid
|
|
25
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
|
|
26
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
|
|
27
|
+
* @returns {(request: Request, space: string, auth: {did: string}|null) => Promise<Response|null>}
|
|
28
|
+
* a Response to return, or null to proceed
|
|
29
|
+
*/
|
|
30
|
+
export declare function createReadAuthorizer({ getDid, resolveDid, verifier }: {
|
|
31
|
+
getDid: () => Promise<string | null>;
|
|
32
|
+
resolveDid: import('@pdsjs/core/ports').DidResolverPort;
|
|
33
|
+
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
34
|
+
}): (request: Request, space: string, auth: {
|
|
35
|
+
did: string;
|
|
36
|
+
} | null) => Promise<Response | null>;
|
|
37
|
+
/**
|
|
38
|
+
* The space credential a request presents, or null.
|
|
39
|
+
*
|
|
40
|
+
* `DPoP`, never `Bearer`: a credential names the key it is bound to, and a
|
|
41
|
+
* presentation that proves nothing about that key is what the binding exists to
|
|
42
|
+
* refuse.
|
|
43
|
+
*
|
|
44
|
+
* @param {Request} request
|
|
45
|
+
* @returns {string|null}
|
|
46
|
+
*/
|
|
47
|
+
export declare function credentialFromRequest(request: Request): string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Check a presented space credential: the token itself, then the caller's
|
|
50
|
+
* possession of the key it names.
|
|
51
|
+
*
|
|
52
|
+
* The two questions are one function so that no endpoint accepting a credential
|
|
53
|
+
* can answer the first and forget the second.
|
|
23
54
|
*
|
|
24
55
|
* @param {Object} opts
|
|
56
|
+
* @param {Request} opts.request - the request the DPoP proof must cover
|
|
25
57
|
* @param {string} opts.credential - the raw JWT
|
|
26
58
|
* @param {string} opts.space - the space the request targets
|
|
27
|
-
* @param {(
|
|
59
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
|
|
28
60
|
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
29
61
|
* @returns {Promise<{iss: string}>}
|
|
30
62
|
*/
|
|
63
|
+
export declare function verifyPresentedCredential({ request, credential, space, resolveDid, verifier, }: {
|
|
64
|
+
request: Request;
|
|
65
|
+
credential: string;
|
|
66
|
+
space: string;
|
|
67
|
+
resolveDid: import('@pdsjs/core/ports').DidResolverPort;
|
|
68
|
+
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
iss: string;
|
|
71
|
+
}>;
|
|
72
|
+
/**
|
|
73
|
+
* Verify a space credential presented to a repo host.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} opts
|
|
76
|
+
* @param {string} opts.credential - the raw JWT
|
|
77
|
+
* @param {string} opts.space - the space the request targets
|
|
78
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
|
|
79
|
+
* @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
|
|
80
|
+
* @returns {Promise<{iss: string, jkt: string}>} the authority, and the key the
|
|
81
|
+
* holder must prove possession of
|
|
82
|
+
*/
|
|
31
83
|
export declare function verifySpaceCredential({ credential, space, resolveDid, verifier, }: {
|
|
32
84
|
credential: string;
|
|
33
85
|
space: string;
|
|
34
|
-
resolveDid: (
|
|
86
|
+
resolveDid: import('@pdsjs/core/ports').DidResolverPort;
|
|
35
87
|
verifier: import('@pdsjs/core/ports').SignatureVerifierPort;
|
|
36
88
|
}): Promise<{
|
|
37
89
|
iss: string;
|
|
90
|
+
jkt: string;
|
|
38
91
|
}>;
|
|
39
92
|
/**
|
|
40
93
|
* Ask a space's managing app whether to authorize a user.
|
|
@@ -48,8 +101,8 @@ export declare function verifySpaceCredential({ credential, space, resolveDid, v
|
|
|
48
101
|
* @param {string} opts.space
|
|
49
102
|
* @param {string} opts.userDid
|
|
50
103
|
* @param {string|undefined} opts.clientId
|
|
51
|
-
* @param {(
|
|
52
|
-
* @param {() => Promise<
|
|
104
|
+
* @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
|
|
105
|
+
* @param {() => Promise<import('../token.js').SpaceSigner>} opts.getSigner
|
|
53
106
|
* @param {typeof fetch} [opts.fetch]
|
|
54
107
|
* @returns {Promise<boolean>}
|
|
55
108
|
*/
|
|
@@ -59,9 +112,7 @@ export declare function checkUserAccess({ managingApp, authorityDid, space, user
|
|
|
59
112
|
space: string;
|
|
60
113
|
userDid: string;
|
|
61
114
|
clientId: string | undefined;
|
|
62
|
-
resolveDid: (
|
|
63
|
-
getSigner: () => Promise<
|
|
64
|
-
sign: (bytes: Uint8Array) => Promise<Uint8Array>;
|
|
65
|
-
}>;
|
|
115
|
+
resolveDid: import('@pdsjs/core/ports').DidResolverPort;
|
|
116
|
+
getSigner: () => Promise<import('../token.js').SpaceSigner>;
|
|
66
117
|
fetch?: typeof fetch;
|
|
67
118
|
}): Promise<boolean>;
|