@atcute/xrpc-server 0.1.11 → 1.0.0
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/README.md +81 -20
- package/dist/auth/jwt-creator.d.ts +4 -2
- package/dist/auth/jwt-creator.d.ts.map +1 -1
- package/dist/auth/jwt-creator.js +1 -1
- package/dist/auth/jwt-creator.js.map +1 -1
- package/dist/auth/jwt-verifier.d.ts +69 -8
- package/dist/auth/jwt-verifier.d.ts.map +1 -1
- package/dist/auth/jwt-verifier.js +131 -20
- package/dist/auth/jwt-verifier.js.map +1 -1
- package/dist/auth/jwt.d.ts +7 -2
- package/dist/auth/jwt.d.ts.map +1 -1
- package/dist/auth/jwt.js +14 -7
- package/dist/auth/jwt.js.map +1 -1
- package/dist/main/response.js.map +1 -1
- package/dist/main/router.d.ts +32 -4
- package/dist/main/router.d.ts.map +1 -1
- package/dist/main/router.js +66 -11
- package/dist/main/router.js.map +1 -1
- package/dist/main/types/operation.d.ts +8 -0
- package/dist/main/types/operation.d.ts.map +1 -1
- package/dist/main/types/websocket.d.ts +8 -0
- package/dist/main/types/websocket.d.ts.map +1 -1
- package/dist/main/utils/frames.d.ts +2 -2
- package/dist/main/utils/frames.d.ts.map +1 -1
- package/dist/main/utils/frames.js.map +1 -1
- package/dist/main/utils/middlewares.d.ts.map +1 -1
- package/dist/main/utils/middlewares.js.map +1 -1
- package/dist/main/utils/namespaced.d.ts.map +1 -1
- package/dist/main/utils/namespaced.js.map +1 -1
- package/dist/main/utils/request-input.d.ts +1 -1
- package/dist/main/utils/request-input.d.ts.map +1 -1
- package/dist/main/utils/request-input.js.map +1 -1
- package/dist/main/utils/request-params.d.ts +1 -1
- package/dist/main/utils/request-params.d.ts.map +1 -1
- package/dist/main/utils/request-params.js.map +1 -1
- package/dist/main/utils/response.d.ts +1 -1
- package/dist/main/utils/response.d.ts.map +1 -1
- package/dist/main/utils/response.js.map +1 -1
- package/dist/main/utils/websocket-mock.d.ts +8 -9
- package/dist/main/utils/websocket-mock.d.ts.map +1 -1
- package/dist/main/utils/websocket-mock.js +11 -6
- package/dist/main/utils/websocket-mock.js.map +1 -1
- package/dist/main/xrpc-error.d.ts +55 -15
- package/dist/main/xrpc-error.d.ts.map +1 -1
- package/dist/main/xrpc-error.js +66 -26
- package/dist/main/xrpc-error.js.map +1 -1
- package/dist/main/xrpc-handler.js.map +1 -1
- package/dist/middlewares/cors.d.ts.map +1 -1
- package/dist/middlewares/cors.js.map +1 -1
- package/lib/auth/jwt-creator.ts +5 -3
- package/lib/auth/jwt-verifier.ts +206 -26
- package/lib/auth/jwt.ts +21 -10
- package/lib/main/router.ts +98 -15
- package/lib/main/types/operation.ts +8 -0
- package/lib/main/types/websocket.ts +8 -0
- package/lib/main/utils/websocket-mock.ts +20 -14
- package/lib/main/xrpc-error.ts +107 -44
- package/package.json +20 -15
- package/dist/main/utils/event-emitter.d.ts +0 -37
- package/dist/main/utils/event-emitter.d.ts.map +0 -1
- package/dist/main/utils/event-emitter.js +0 -96
- package/dist/main/utils/event-emitter.js.map +0 -1
- package/lib/main/utils/event-emitter.ts +0 -116
package/lib/auth/jwt-verifier.ts
CHANGED
|
@@ -1,61 +1,161 @@
|
|
|
1
1
|
import { getPublicKeyFromDidController, verifySig, type FoundPublicKey } from '@atcute/crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { getVerificationMaterial, type DidDocument } from '@atcute/identity';
|
|
3
3
|
import { type DidDocumentResolver } from '@atcute/identity-resolver';
|
|
4
4
|
import type { Did, Nsid } from '@atcute/lexicons';
|
|
5
|
+
import type { AtprotoAudience } from '@atcute/lexicons/syntax';
|
|
5
6
|
import * as uint8arrays from '@atcute/uint8array';
|
|
6
7
|
|
|
8
|
+
import { AuthRequiredError } from '../main/xrpc-error.ts';
|
|
7
9
|
import type { Result } from '../types/misc.ts';
|
|
8
10
|
|
|
9
11
|
import { parseJwt, type ParsedJwt } from './jwt.ts';
|
|
10
12
|
import type { AuthError } from './types.ts';
|
|
11
13
|
|
|
14
|
+
type SupportedKid = `#${string}`;
|
|
15
|
+
/** only `#atproto` is accepted as a signing key identifier for now */
|
|
16
|
+
const DEFAULT_KID: SupportedKid = '#atproto';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* replay-protection store for service JWTs. when configured on a verifier,
|
|
20
|
+
* tokens must carry a `jti` claim and the verifier consults this store to
|
|
21
|
+
* reject duplicates.
|
|
22
|
+
*/
|
|
23
|
+
export interface ReplayStore {
|
|
24
|
+
/**
|
|
25
|
+
* record a `(iss, jti)` pair seen now.
|
|
26
|
+
*
|
|
27
|
+
* @param key issuer + token identifier; implementations decide how to
|
|
28
|
+
* encode this into a storage key.
|
|
29
|
+
* @param ttlSeconds how long the entry must be retained. implementations
|
|
30
|
+
* are free to retain it for longer.
|
|
31
|
+
* @returns `true` if the pair was previously unseen (token is unique),
|
|
32
|
+
* `false` if the pair has been recorded before (replay).
|
|
33
|
+
*/
|
|
34
|
+
check(key: { iss: Did; jti: string }, ttlSeconds: number): Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
export interface ServiceJwtVerifierOptions {
|
|
13
|
-
|
|
38
|
+
/**
|
|
39
|
+
* list of `aud` values accepted by this service; each entry is a bare DID or a DID with
|
|
40
|
+
* service fragment (e.g. `did:web:x.example#svc`), and incoming tokens must exact-match any entry.
|
|
41
|
+
*
|
|
42
|
+
* pass `null` to skip audience validation (accept any audience). an empty array rejects every
|
|
43
|
+
* audience, which is useful when a service wants to fail closed until configured.
|
|
44
|
+
*/
|
|
45
|
+
acceptAudiences: (Did | AtprotoAudience)[] | null;
|
|
14
46
|
resolver: DidDocumentResolver;
|
|
47
|
+
/**
|
|
48
|
+
* maximum token lifetime window in seconds. rejects tokens whose `exp` is
|
|
49
|
+
* more than this far in the future or whose `iat` is more than this far in
|
|
50
|
+
* the past. defaults to 300 (5 minutes), matching atproto convention.
|
|
51
|
+
*/
|
|
52
|
+
maxAge?: number;
|
|
53
|
+
/**
|
|
54
|
+
* clock-skew leeway in seconds applied to `exp` and `nbf` comparisons.
|
|
55
|
+
* defaults to 5 seconds.
|
|
56
|
+
*/
|
|
57
|
+
clockLeeway?: number;
|
|
58
|
+
/**
|
|
59
|
+
* optional replay-protection store. when provided, tokens must carry a
|
|
60
|
+
* `jti` claim and the verifier rejects any `(iss, jti)` the store reports
|
|
61
|
+
* as previously seen.
|
|
62
|
+
*/
|
|
63
|
+
replayStore?: ReplayStore;
|
|
15
64
|
}
|
|
16
65
|
|
|
17
66
|
export interface VerifyJwtOptions {
|
|
18
|
-
lxm: Nsid | Nsid[]
|
|
67
|
+
lxm: Nsid | Nsid[];
|
|
68
|
+
/** abort signal forwarded to DID resolution; falls back to `request.signal` in `verifyRequest`. */
|
|
69
|
+
signal?: AbortSignal;
|
|
19
70
|
}
|
|
20
71
|
|
|
21
72
|
export interface VerifiedJwt {
|
|
22
73
|
issuer: Did;
|
|
23
|
-
audience: Did;
|
|
24
|
-
lxm:
|
|
74
|
+
audience: Did | AtprotoAudience;
|
|
75
|
+
lxm: Nsid;
|
|
25
76
|
}
|
|
26
77
|
|
|
78
|
+
const BEARER_PREFIX = 'Bearer ';
|
|
79
|
+
|
|
27
80
|
export class ServiceJwtVerifier {
|
|
28
81
|
didDocResolver: DidDocumentResolver;
|
|
29
|
-
|
|
82
|
+
acceptAudiences: (Did | AtprotoAudience)[] | null;
|
|
83
|
+
maxAge: number;
|
|
84
|
+
clockLeeway: number;
|
|
85
|
+
replayStore?: ReplayStore;
|
|
30
86
|
|
|
31
87
|
constructor(options: ServiceJwtVerifierOptions) {
|
|
32
88
|
this.didDocResolver = options.resolver;
|
|
33
|
-
this.
|
|
89
|
+
this.acceptAudiences = options.acceptAudiences;
|
|
90
|
+
this.maxAge = options.maxAge ?? 5 * 60;
|
|
91
|
+
this.clockLeeway = options.clockLeeway ?? 5;
|
|
92
|
+
this.replayStore = options.replayStore;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* parse the Authorization header, verify the bearer token, and return the
|
|
97
|
+
* validated claims. throws {@link AuthRequiredError} with a populated
|
|
98
|
+
* `WWW-Authenticate: Bearer` challenge on every failure path.
|
|
99
|
+
*
|
|
100
|
+
* @param request incoming request; `request.signal` is forwarded to DID
|
|
101
|
+
* resolution unless `options.signal` overrides it.
|
|
102
|
+
* @param options verification options; `lxm` restricts which lexicon
|
|
103
|
+
* methods the token is allowed to invoke.
|
|
104
|
+
* @throws {AuthRequiredError} on missing header, malformed token,
|
|
105
|
+
* signature mismatch, audience/lxm rejection, replay, or expiry.
|
|
106
|
+
*/
|
|
107
|
+
async verifyRequest(request: Request, options: VerifyJwtOptions): Promise<VerifiedJwt> {
|
|
108
|
+
const authorization = request.headers.get('authorization');
|
|
109
|
+
if (authorization === null) {
|
|
110
|
+
throw new AuthRequiredError({
|
|
111
|
+
message: 'authorization header required',
|
|
112
|
+
wwwAuthenticate: { scheme: 'Bearer' },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!authorization.startsWith(BEARER_PREFIX)) {
|
|
117
|
+
throw authError({ error: 'MissingBearer', description: 'expected a bearer token' });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const token = authorization.slice(BEARER_PREFIX.length).trim();
|
|
121
|
+
const signal = options.signal ?? request.signal;
|
|
122
|
+
|
|
123
|
+
const result = await this.#verifyToken(token, { lxm: options.lxm, signal });
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
throw authError(result.error);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return result.value;
|
|
34
129
|
}
|
|
35
130
|
|
|
36
|
-
async #getSigningKey(
|
|
131
|
+
async #getSigningKey(
|
|
132
|
+
issuer: Did,
|
|
133
|
+
kid: SupportedKid,
|
|
134
|
+
noCache: boolean,
|
|
135
|
+
signal: AbortSignal,
|
|
136
|
+
): Promise<Result<FoundPublicKey, AuthError>> {
|
|
37
137
|
let didDocument: DidDocument;
|
|
38
138
|
let key: FoundPublicKey;
|
|
39
139
|
|
|
40
140
|
try {
|
|
41
|
-
didDocument = await this.didDocResolver.resolve(issuer, { noCache });
|
|
141
|
+
didDocument = await this.didDocResolver.resolve(issuer, { noCache, signal });
|
|
42
142
|
} catch {
|
|
43
143
|
return {
|
|
44
144
|
ok: false,
|
|
45
145
|
error: {
|
|
46
|
-
error: '
|
|
146
|
+
error: 'DidResolutionFailed',
|
|
47
147
|
description: `failed to retrieve did document for ${issuer}`,
|
|
48
148
|
},
|
|
49
149
|
};
|
|
50
150
|
}
|
|
51
151
|
|
|
52
|
-
const controller =
|
|
152
|
+
const controller = getVerificationMaterial(didDocument, kid);
|
|
53
153
|
if (!controller) {
|
|
54
154
|
return {
|
|
55
155
|
ok: false,
|
|
56
156
|
error: {
|
|
57
157
|
error: 'BadJwtIssuer',
|
|
58
|
-
description: `${issuer} does not have
|
|
158
|
+
description: `${issuer} does not have a ${kid} verification material`,
|
|
59
159
|
},
|
|
60
160
|
};
|
|
61
161
|
}
|
|
@@ -67,7 +167,7 @@ export class ServiceJwtVerifier {
|
|
|
67
167
|
ok: false,
|
|
68
168
|
error: {
|
|
69
169
|
error: 'BadJwtIssuer',
|
|
70
|
-
description: `${issuer} has invalid
|
|
170
|
+
description: `${issuer} has invalid ${kid} verification material`,
|
|
71
171
|
},
|
|
72
172
|
};
|
|
73
173
|
}
|
|
@@ -92,8 +192,11 @@ export class ServiceJwtVerifier {
|
|
|
92
192
|
}
|
|
93
193
|
}
|
|
94
194
|
|
|
95
|
-
async
|
|
96
|
-
|
|
195
|
+
async #verifyToken(
|
|
196
|
+
token: string,
|
|
197
|
+
options: { lxm: Nsid | Nsid[]; signal: AbortSignal },
|
|
198
|
+
): Promise<Result<VerifiedJwt, AuthError>> {
|
|
199
|
+
const parsed = parseJwt(token);
|
|
97
200
|
if (!parsed.ok) {
|
|
98
201
|
return parsed;
|
|
99
202
|
}
|
|
@@ -114,7 +217,33 @@ export class ServiceJwtVerifier {
|
|
|
114
217
|
}
|
|
115
218
|
}
|
|
116
219
|
|
|
117
|
-
|
|
220
|
+
// resolve the `kid` header (defaulting to `#atproto`) and restrict to the set of
|
|
221
|
+
// identifiers this verifier knows how to look up in the issuer's DID document.
|
|
222
|
+
// matches proposal 0014's "safe default" for SDKs.
|
|
223
|
+
const kid: string = header.kid ?? DEFAULT_KID;
|
|
224
|
+
if (kid !== DEFAULT_KID) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: {
|
|
228
|
+
error: 'BadJwtIssuer',
|
|
229
|
+
description: `unsupported signing key identifier (${kid})`,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const now = Math.floor(Date.now() / 1_000);
|
|
235
|
+
|
|
236
|
+
if (payload.nbf !== undefined && now < payload.nbf - this.clockLeeway) {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
error: {
|
|
240
|
+
error: 'JwtNotYetValid',
|
|
241
|
+
description: `jwt is not yet valid`,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (now > payload.exp + this.clockLeeway) {
|
|
118
247
|
return {
|
|
119
248
|
ok: false,
|
|
120
249
|
error: {
|
|
@@ -124,20 +253,33 @@ export class ServiceJwtVerifier {
|
|
|
124
253
|
};
|
|
125
254
|
}
|
|
126
255
|
|
|
127
|
-
|
|
256
|
+
// prevent issuers from minting very long-lived tokens: the configured max-age
|
|
257
|
+
// window bounds how far `exp` can be in the future and how far `iat` can be in
|
|
258
|
+
// the past.
|
|
259
|
+
if (payload.exp - now > this.maxAge || (payload.iat !== undefined && now - payload.iat > this.maxAge)) {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
error: {
|
|
263
|
+
error: 'JwtTooOld',
|
|
264
|
+
description: `jwt exceeds maximum age (${this.maxAge}s)`,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (this.acceptAudiences !== null && !this.acceptAudiences.includes(payload.aud)) {
|
|
128
270
|
return {
|
|
129
271
|
ok: false,
|
|
130
272
|
error: {
|
|
131
|
-
error: '
|
|
132
|
-
description:
|
|
273
|
+
error: 'InvalidAudience',
|
|
274
|
+
description:
|
|
275
|
+
this.acceptAudiences.length === 0
|
|
276
|
+
? `jwt audience does not match (no audiences accepted)`
|
|
277
|
+
: `jwt audience does not match (expected one of: ${this.acceptAudiences.join(', ')})`,
|
|
133
278
|
},
|
|
134
279
|
};
|
|
135
280
|
}
|
|
136
281
|
|
|
137
|
-
if (
|
|
138
|
-
options?.lxm != null &&
|
|
139
|
-
(typeof options.lxm === 'string' ? options.lxm !== payload.lxm : !options.lxm.includes(payload.lxm!))
|
|
140
|
-
) {
|
|
282
|
+
if (typeof options.lxm === 'string' ? options.lxm !== payload.lxm : !options.lxm.includes(payload.lxm)) {
|
|
141
283
|
return {
|
|
142
284
|
ok: false,
|
|
143
285
|
error: {
|
|
@@ -147,7 +289,22 @@ export class ServiceJwtVerifier {
|
|
|
147
289
|
};
|
|
148
290
|
}
|
|
149
291
|
|
|
150
|
-
|
|
292
|
+
let jti: string | undefined;
|
|
293
|
+
if (this.replayStore !== undefined) {
|
|
294
|
+
if (payload.jti === undefined) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
error: {
|
|
298
|
+
error: 'BadJwt',
|
|
299
|
+
description: `jwt is missing the jti claim (required for replay protection)`,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
jti = payload.jti;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const key = await this.#getSigningKey(payload.iss, kid, false, options.signal);
|
|
151
308
|
if (!key.ok) {
|
|
152
309
|
return key;
|
|
153
310
|
}
|
|
@@ -165,7 +322,7 @@ export class ServiceJwtVerifier {
|
|
|
165
322
|
|
|
166
323
|
if (!isValid) {
|
|
167
324
|
// try again, uncached
|
|
168
|
-
const freshKey = await this.#getSigningKey(payload.iss, true);
|
|
325
|
+
const freshKey = await this.#getSigningKey(payload.iss, kid, true, options.signal);
|
|
169
326
|
if (!freshKey.ok) {
|
|
170
327
|
return freshKey;
|
|
171
328
|
}
|
|
@@ -183,7 +340,7 @@ export class ServiceJwtVerifier {
|
|
|
183
340
|
|
|
184
341
|
// only revalidate if it's a different key
|
|
185
342
|
if (!uint8arrays.equals(freshKey.value.publicKeyBytes, key.value.publicKeyBytes)) {
|
|
186
|
-
const result = await this.#verifySignature(
|
|
343
|
+
const result = await this.#verifySignature(freshKey.value, parsed.value);
|
|
187
344
|
if (!result.ok) {
|
|
188
345
|
return result;
|
|
189
346
|
}
|
|
@@ -203,6 +360,22 @@ export class ServiceJwtVerifier {
|
|
|
203
360
|
};
|
|
204
361
|
}
|
|
205
362
|
|
|
363
|
+
// replay-store check runs after signature verification so forged tokens
|
|
364
|
+
// can't burn entries (memory dos) or evict legitimate `(iss, jti)` pairs
|
|
365
|
+
// before the real request lands.
|
|
366
|
+
if (this.replayStore !== undefined && jti !== undefined) {
|
|
367
|
+
const unique = await this.replayStore.check({ iss: payload.iss, jti }, this.maxAge);
|
|
368
|
+
if (!unique) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
error: {
|
|
372
|
+
error: 'NonceNotUnique',
|
|
373
|
+
description: `jwt has been used before`,
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
206
379
|
return {
|
|
207
380
|
ok: true,
|
|
208
381
|
value: {
|
|
@@ -213,3 +386,10 @@ export class ServiceJwtVerifier {
|
|
|
213
386
|
};
|
|
214
387
|
}
|
|
215
388
|
}
|
|
389
|
+
|
|
390
|
+
const authError = (err: AuthError): AuthRequiredError => {
|
|
391
|
+
return new AuthRequiredError({
|
|
392
|
+
message: err.description,
|
|
393
|
+
wwwAuthenticate: { scheme: 'Bearer', params: { error: err.error } },
|
|
394
|
+
});
|
|
395
|
+
};
|
package/lib/auth/jwt.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { isAtprotoAudience } from '@atcute/identity';
|
|
1
2
|
import type { Did, Nsid } from '@atcute/lexicons';
|
|
2
|
-
import { isDid, isNsid } from '@atcute/lexicons/syntax';
|
|
3
|
+
import { isDid, isNsid, type AtprotoAudience } from '@atcute/lexicons/syntax';
|
|
3
4
|
import { fromBase64Url } from '@atcute/multibase';
|
|
4
5
|
import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array';
|
|
5
6
|
|
|
@@ -10,6 +11,9 @@ import type { Result } from '../types/misc.ts';
|
|
|
10
11
|
import type { AuthError } from './types.ts';
|
|
11
12
|
|
|
12
13
|
const didString = v.string().assert(isDid, `must be a did`);
|
|
14
|
+
const audienceString = v
|
|
15
|
+
.string()
|
|
16
|
+
.assert((input) => isAtprotoAudience(input) || isDid(input), `must be a did or atproto audience`);
|
|
13
17
|
const nsidString = v.string().assert(isNsid, `must be an nsid`);
|
|
14
18
|
|
|
15
19
|
const integer = v.number().assert((input) => input >= 0 && Number.isSafeInteger(input), `must be an integer`);
|
|
@@ -17,19 +21,24 @@ const integer = v.number().assert((input) => input >= 0 && Number.isSafeInteger(
|
|
|
17
21
|
export interface JwtHeader {
|
|
18
22
|
typ?: string;
|
|
19
23
|
alg: string;
|
|
24
|
+
/** signing key identifier; a DID fragment, defaults to `#atproto` when absent */
|
|
25
|
+
kid?: string;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
const jwtHeader: v.Type<JwtHeader> = v.object({
|
|
23
29
|
typ: v.string().optional(),
|
|
24
30
|
alg: v.string(),
|
|
31
|
+
kid: v.string().optional(),
|
|
25
32
|
});
|
|
26
33
|
|
|
27
34
|
export interface JwtPayload {
|
|
28
35
|
iss: Did;
|
|
29
|
-
aud: Did;
|
|
36
|
+
aud: Did | AtprotoAudience;
|
|
30
37
|
exp: number;
|
|
31
38
|
iat?: number;
|
|
32
|
-
|
|
39
|
+
/** not-before time; token is invalid before this unix timestamp */
|
|
40
|
+
nbf?: number;
|
|
41
|
+
lxm: Nsid;
|
|
33
42
|
jti?: string;
|
|
34
43
|
}
|
|
35
44
|
|
|
@@ -37,14 +46,16 @@ const jwtPayload: v.Type<JwtPayload> = v
|
|
|
37
46
|
.object({
|
|
38
47
|
/** issuer */
|
|
39
48
|
iss: didString,
|
|
40
|
-
/** target audience */
|
|
41
|
-
aud:
|
|
49
|
+
/** target audience; a bare DID or a DID with service fragment (e.g. `did:web:x.example#svc`) */
|
|
50
|
+
aud: audienceString,
|
|
42
51
|
/** expiration time */
|
|
43
52
|
exp: integer,
|
|
44
53
|
/** creation time */
|
|
45
54
|
iat: integer.optional(),
|
|
46
|
-
/**
|
|
47
|
-
|
|
55
|
+
/** not-before time */
|
|
56
|
+
nbf: integer.optional(),
|
|
57
|
+
/** xrpc operation being invoked; required per atproto service auth spec */
|
|
58
|
+
lxm: nsidString,
|
|
48
59
|
/** unique identifier */
|
|
49
60
|
jti: v.string().optional(),
|
|
50
61
|
})
|
|
@@ -74,7 +85,7 @@ const readJwtPortion = <T>(schema: v.Type<T>, input: string): Result<T, AuthErro
|
|
|
74
85
|
return {
|
|
75
86
|
ok: false,
|
|
76
87
|
error: {
|
|
77
|
-
error: `
|
|
88
|
+
error: `BadJwt`,
|
|
78
89
|
description: `jwt is malformed`,
|
|
79
90
|
},
|
|
80
91
|
};
|
|
@@ -88,7 +99,7 @@ const readJwtSignature = (input: string): Result<Uint8Array<ArrayBuffer>, AuthEr
|
|
|
88
99
|
return {
|
|
89
100
|
ok: false,
|
|
90
101
|
error: {
|
|
91
|
-
error: `
|
|
102
|
+
error: `BadJwt`,
|
|
92
103
|
description: `jwt is malformed`,
|
|
93
104
|
},
|
|
94
105
|
};
|
|
@@ -100,7 +111,7 @@ export const parseJwt = (jwtString: string): Result<ParsedJwt, AuthError> => {
|
|
|
100
111
|
return {
|
|
101
112
|
ok: false,
|
|
102
113
|
error: {
|
|
103
|
-
error: `
|
|
114
|
+
error: `BadJwt`,
|
|
104
115
|
description: `jwt is malformed`,
|
|
105
116
|
},
|
|
106
117
|
};
|
package/lib/main/router.ts
CHANGED
|
@@ -38,8 +38,13 @@ type InternalRouteData = {
|
|
|
38
38
|
export type FetchMiddleware = Middleware<[request: Request], Promise<Response>>;
|
|
39
39
|
|
|
40
40
|
export type NotFoundHandler = (request: Request) => Promisable<Response>;
|
|
41
|
+
export type HealthCheckHandler = (request: Request) => Promisable<Response>;
|
|
41
42
|
export type ExceptionHandler = (error: unknown, request: Request) => Promisable<Response>;
|
|
42
|
-
|
|
43
|
+
|
|
44
|
+
/** telemetry hook invoked for unexpected HTTP handler errors; fire-and-forget. */
|
|
45
|
+
export type ErrorObserver = (ctx: { error: unknown; request: Request }) => void;
|
|
46
|
+
/** telemetry hook invoked for unexpected subscription errors; fire-and-forget. */
|
|
47
|
+
export type SocketErrorObserver = (ctx: { error: unknown; request: Request }) => void;
|
|
43
48
|
|
|
44
49
|
export const defaultExceptionHandler: ExceptionHandler = (error: unknown) => {
|
|
45
50
|
if (error instanceof XRPCError) {
|
|
@@ -60,23 +65,40 @@ export const defaultNotFoundHandler: NotFoundHandler = () => {
|
|
|
60
65
|
return new Response('Not Found', { status: 404 });
|
|
61
66
|
};
|
|
62
67
|
|
|
63
|
-
export const defaultSubscriptionExceptionHandler: SubscriptionExceptionHandler = (error: unknown) => {
|
|
64
|
-
throw error;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
68
|
export interface XRPCRouterOptions {
|
|
68
69
|
middlewares?: FetchMiddleware[];
|
|
69
70
|
handleNotFound?: NotFoundHandler;
|
|
71
|
+
/**
|
|
72
|
+
* optional handler for `/xrpc/_health`. when provided, the router answers
|
|
73
|
+
* health-check requests by invoking this handler; when absent, the path
|
|
74
|
+
* falls through to `handleNotFound`. `_health` is not part of the atproto
|
|
75
|
+
* XRPC spec, so callers opt in explicitly.
|
|
76
|
+
*/
|
|
77
|
+
handleHealthCheck?: HealthCheckHandler;
|
|
78
|
+
/** translates a thrown error into an HTTP response. */
|
|
70
79
|
handleException?: ExceptionHandler;
|
|
71
|
-
|
|
80
|
+
/**
|
|
81
|
+
* fire-and-forget telemetry hook for unexpected HTTP errors. not invoked for
|
|
82
|
+
* client-induced errors (aborted requests, `XRPCError` subclasses, thrown
|
|
83
|
+
* `Response` objects).
|
|
84
|
+
*/
|
|
85
|
+
onError?: ErrorObserver;
|
|
86
|
+
/**
|
|
87
|
+
* fire-and-forget telemetry hook for unexpected subscription errors. not
|
|
88
|
+
* invoked for aborted signals or `XRPCSubscriptionError` (which is
|
|
89
|
+
* translated to an error frame).
|
|
90
|
+
*/
|
|
91
|
+
onSocketError?: SocketErrorObserver;
|
|
72
92
|
websocket?: WebSocketAdapter;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
export class XRPCRouter {
|
|
76
96
|
#handlers: Record<string, InternalRouteData> = {};
|
|
77
97
|
#handleNotFound: NotFoundHandler;
|
|
98
|
+
#handleHealthCheck?: HealthCheckHandler;
|
|
78
99
|
#handleException: ExceptionHandler;
|
|
79
|
-
#
|
|
100
|
+
#onError?: ErrorObserver;
|
|
101
|
+
#onSocketError?: SocketErrorObserver;
|
|
80
102
|
#websocket?: WebSocketAdapter;
|
|
81
103
|
|
|
82
104
|
fetch: (request: Request) => Promise<Response>;
|
|
@@ -85,7 +107,9 @@ export class XRPCRouter {
|
|
|
85
107
|
middlewares = [],
|
|
86
108
|
handleException = defaultExceptionHandler,
|
|
87
109
|
handleNotFound = defaultNotFoundHandler,
|
|
88
|
-
|
|
110
|
+
handleHealthCheck,
|
|
111
|
+
onError,
|
|
112
|
+
onSocketError,
|
|
89
113
|
websocket,
|
|
90
114
|
}: XRPCRouterOptions = {}) {
|
|
91
115
|
const runner = createAsyncMiddlewareRunner([...middlewares, (request) => this.#dispatch(request)]);
|
|
@@ -93,10 +117,46 @@ export class XRPCRouter {
|
|
|
93
117
|
this.fetch = (request) => runner(request);
|
|
94
118
|
this.#handleException = handleException;
|
|
95
119
|
this.#handleNotFound = handleNotFound;
|
|
96
|
-
this.#
|
|
120
|
+
this.#handleHealthCheck = handleHealthCheck;
|
|
121
|
+
this.#onError = onError;
|
|
122
|
+
this.#onSocketError = onSocketError;
|
|
97
123
|
this.#websocket = websocket;
|
|
98
124
|
}
|
|
99
125
|
|
|
126
|
+
#observeError(error: unknown, request: Request): void {
|
|
127
|
+
// client-induced errors are not bugs; skip telemetry
|
|
128
|
+
if (request.signal.aborted) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (error instanceof XRPCError) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (error instanceof Response) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
this.#onError?.({ error, request });
|
|
140
|
+
} catch {
|
|
141
|
+
// observer threw; swallow to keep response path deterministic
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#observeSocketError(error: unknown, request: Request): void {
|
|
146
|
+
if (request.signal.aborted) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (error instanceof XRPCSubscriptionError) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
this.#onSocketError?.({ error, request });
|
|
155
|
+
} catch {
|
|
156
|
+
// observer threw; swallow to keep socket close path deterministic
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
100
160
|
async #dispatch(request: Request): Promise<Response> {
|
|
101
161
|
const url = new URL(request.url);
|
|
102
162
|
const pathname = url.pathname;
|
|
@@ -107,15 +167,31 @@ export class XRPCRouter {
|
|
|
107
167
|
|
|
108
168
|
const nsid = pathname.slice('/xrpc/'.length);
|
|
109
169
|
|
|
170
|
+
if (nsid === '_health' && this.#handleHealthCheck !== undefined) {
|
|
171
|
+
try {
|
|
172
|
+
return await this.#handleHealthCheck(request);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (request.signal.aborted) {
|
|
175
|
+
return new Response(null, { status: 499 });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.#observeError(err, request);
|
|
179
|
+
return this.#handleException(err, request);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
110
183
|
const route = this.#handlers[nsid];
|
|
111
184
|
if (route === undefined) {
|
|
112
185
|
return this.#handleNotFound(request);
|
|
113
186
|
}
|
|
114
187
|
|
|
115
|
-
|
|
188
|
+
// allow HEAD alongside GET; the runtime is responsible for stripping the
|
|
189
|
+
// response body per the Fetch API.
|
|
190
|
+
const allowed = request.method === route.method || (route.method === 'GET' && request.method === 'HEAD');
|
|
191
|
+
if (!allowed) {
|
|
116
192
|
return Response.json(
|
|
117
|
-
{ error: '
|
|
118
|
-
{ status: 405, headers: { allow:
|
|
193
|
+
{ error: 'InvalidRequest', message: `invalid http method (expected ${route.method})` },
|
|
194
|
+
{ status: 405, headers: { allow: route.method === 'GET' ? 'GET, HEAD' : route.method } },
|
|
119
195
|
);
|
|
120
196
|
}
|
|
121
197
|
|
|
@@ -131,6 +207,7 @@ export class XRPCRouter {
|
|
|
131
207
|
return new Response(null, { status: 499 });
|
|
132
208
|
}
|
|
133
209
|
|
|
210
|
+
this.#observeError(err, request);
|
|
134
211
|
return this.#handleException(err, request);
|
|
135
212
|
}
|
|
136
213
|
}
|
|
@@ -350,23 +427,29 @@ export class XRPCRouter {
|
|
|
350
427
|
|
|
351
428
|
const frame = encodeMessageFrame(body, type);
|
|
352
429
|
await ws.send(frame);
|
|
430
|
+
const drained = ws.drain();
|
|
431
|
+
if (drained) {
|
|
432
|
+
await drained;
|
|
433
|
+
}
|
|
353
434
|
}
|
|
354
435
|
|
|
355
436
|
ws.close(1000);
|
|
356
437
|
} catch (err) {
|
|
357
438
|
if (err instanceof XRPCSubscriptionError) {
|
|
358
|
-
const frame = encodeErrorFrame(err.error, err.
|
|
439
|
+
const frame = encodeErrorFrame(err.error, err.message || undefined);
|
|
359
440
|
|
|
360
441
|
try {
|
|
361
442
|
await ws.send(frame);
|
|
362
|
-
} catch {
|
|
443
|
+
} catch {
|
|
444
|
+
// best-effort, socket may already be closed
|
|
445
|
+
}
|
|
363
446
|
|
|
364
447
|
ws.close(err.closeCode, err.error);
|
|
365
448
|
return;
|
|
366
449
|
}
|
|
367
450
|
|
|
368
451
|
ws.close(1011, `internal server error`);
|
|
369
|
-
this.#
|
|
452
|
+
this.#observeSocketError(err, request);
|
|
370
453
|
}
|
|
371
454
|
});
|
|
372
455
|
|
|
@@ -12,6 +12,14 @@ import type {
|
|
|
12
12
|
import type { Literal, Promisable } from '../../types/misc.ts';
|
|
13
13
|
import type { JSONResponse } from '../response.ts';
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* untyped variant of {@link QueryContext} / {@link ProcedureContext}.
|
|
17
|
+
*
|
|
18
|
+
* `input` is set only when the lexicon declares a `lex` input body and the
|
|
19
|
+
* request JSON parsed successfully. for blob inputs (and for procedures that
|
|
20
|
+
* declare no input at all) it is `undefined`; handlers that expect a blob
|
|
21
|
+
* should stream from `request.body` directly.
|
|
22
|
+
*/
|
|
15
23
|
export type UnknownOperationContext = {
|
|
16
24
|
request: Request;
|
|
17
25
|
signal: AbortSignal;
|
|
@@ -3,6 +3,14 @@ import type { Promisable } from '../../types/misc.ts';
|
|
|
3
3
|
export interface WebSocketConnection {
|
|
4
4
|
signal: AbortSignal;
|
|
5
5
|
send(data: Uint8Array): void | Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* backpressure hook invoked by the router after every frame it sends.
|
|
8
|
+
* adapters that can observe the outgoing send buffer (Node `ws`, Bun, Deno)
|
|
9
|
+
* should resolve only once the buffer has drained below a healthy threshold.
|
|
10
|
+
* adapters without that visibility (e.g. Cloudflare Workers) should return
|
|
11
|
+
* synchronously.
|
|
12
|
+
*/
|
|
13
|
+
drain(): void | Promise<void>;
|
|
6
14
|
close(code?: number, reason?: string): void;
|
|
7
15
|
}
|
|
8
16
|
|