@atcute/xrpc-server 0.1.12 → 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.
Files changed (37) hide show
  1. package/README.md +81 -20
  2. package/dist/auth/jwt-creator.d.ts +4 -2
  3. package/dist/auth/jwt-creator.d.ts.map +1 -1
  4. package/dist/auth/jwt-creator.js +1 -1
  5. package/dist/auth/jwt-creator.js.map +1 -1
  6. package/dist/auth/jwt-verifier.d.ts +69 -8
  7. package/dist/auth/jwt-verifier.d.ts.map +1 -1
  8. package/dist/auth/jwt-verifier.js +130 -19
  9. package/dist/auth/jwt-verifier.js.map +1 -1
  10. package/dist/auth/jwt.d.ts +7 -2
  11. package/dist/auth/jwt.d.ts.map +1 -1
  12. package/dist/auth/jwt.js +14 -7
  13. package/dist/auth/jwt.js.map +1 -1
  14. package/dist/main/router.d.ts +32 -4
  15. package/dist/main/router.d.ts.map +1 -1
  16. package/dist/main/router.js +63 -10
  17. package/dist/main/router.js.map +1 -1
  18. package/dist/main/types/operation.d.ts +8 -0
  19. package/dist/main/types/operation.d.ts.map +1 -1
  20. package/dist/main/types/websocket.d.ts +8 -0
  21. package/dist/main/types/websocket.d.ts.map +1 -1
  22. package/dist/main/utils/websocket-mock.d.ts.map +1 -1
  23. package/dist/main/utils/websocket-mock.js +3 -0
  24. package/dist/main/utils/websocket-mock.js.map +1 -1
  25. package/dist/main/xrpc-error.d.ts +55 -15
  26. package/dist/main/xrpc-error.d.ts.map +1 -1
  27. package/dist/main/xrpc-error.js +66 -26
  28. package/dist/main/xrpc-error.js.map +1 -1
  29. package/lib/auth/jwt-creator.ts +5 -3
  30. package/lib/auth/jwt-verifier.ts +205 -25
  31. package/lib/auth/jwt.ts +21 -10
  32. package/lib/main/router.ts +95 -14
  33. package/lib/main/types/operation.ts +8 -0
  34. package/lib/main/types/websocket.ts +8 -0
  35. package/lib/main/utils/websocket-mock.ts +3 -0
  36. package/lib/main/xrpc-error.ts +107 -44
  37. package/package.json +19 -15
@@ -1,72 +1,112 @@
1
+ /**
2
+ * formats one or more WWW-Authenticate challenges into a single header value.
3
+ *
4
+ * each challenge is emitted as `<scheme>` followed by its params or token68.
5
+ * multiple challenges are joined with `, `. auth-param values are quoted using
6
+ * `JSON.stringify` (RFC 7230 quoted-string semantics for ASCII content).
7
+ *
8
+ * @param challenges one challenge, or an ordered array of challenges
9
+ * @returns the formatted header value
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * formatWWWAuthenticate({ scheme: 'Bearer', params: { error: 'BadJwtSignature' } })
14
+ * // => `Bearer error="BadJwtSignature"`
15
+ * ```
16
+ */
17
+ export const formatWWWAuthenticate = (challenges) => {
18
+ const list = Array.isArray(challenges) ? challenges : [challenges];
19
+ return list.map(formatChallenge).join(', ');
20
+ };
21
+ const formatChallenge = (challenge) => {
22
+ if (challenge.token68 !== undefined) {
23
+ return `${challenge.scheme} ${challenge.token68}`;
24
+ }
25
+ if (challenge.params !== undefined) {
26
+ const parts = [];
27
+ for (const name in challenge.params) {
28
+ const value = challenge.params[name];
29
+ if (value !== undefined) {
30
+ parts.push(`${name}=${JSON.stringify(value)}`);
31
+ }
32
+ }
33
+ if (parts.length > 0) {
34
+ return `${challenge.scheme} ${parts.join(', ')}`;
35
+ }
36
+ }
37
+ return challenge.scheme;
38
+ };
1
39
  export class XRPCError extends Error {
2
40
  /** response status */
3
41
  status;
4
42
  /** error name */
5
43
  error;
6
- /** error message */
7
- description;
8
44
  /** response headers */
9
45
  headers;
10
- constructor({ status, error, description, headers }) {
11
- super(`${error} > ${description ?? '(unspecified description)'}`);
46
+ constructor({ status, error, message, headers }) {
47
+ super(message);
12
48
  this.status = status;
13
49
  this.error = error;
14
- this.description = description;
15
50
  this.headers = headers;
16
51
  }
17
52
  toResponse() {
18
- return Response.json({ error: this.error, message: this.description }, { status: this.status, headers: this.headers });
53
+ return Response.json({ error: this.error, message: this.message || undefined }, { status: this.status, headers: this.headers });
19
54
  }
20
55
  }
21
56
  export class InvalidRequestError extends XRPCError {
22
- constructor({ status = 400, error = 'InvalidRequest', description, headers, } = {}) {
23
- super({ status, error, description, headers });
57
+ constructor({ status = 400, error = 'InvalidRequest', message, headers } = {}) {
58
+ super({ status, error, message, headers });
24
59
  }
25
60
  }
26
61
  export class AuthRequiredError extends XRPCError {
27
- constructor({ status = 401, error = 'AuthenticationRequired', description, headers, } = {}) {
28
- super({ status, error, description, headers });
62
+ constructor({ status = 401, error = 'AuthenticationRequired', message, headers, wwwAuthenticate, } = {}) {
63
+ let mergedHeaders = headers;
64
+ if (wwwAuthenticate !== undefined) {
65
+ const target = new Headers(headers);
66
+ target.set('www-authenticate', formatWWWAuthenticate(wwwAuthenticate));
67
+ target.append('access-control-expose-headers', 'www-authenticate');
68
+ mergedHeaders = target;
69
+ }
70
+ super({ status, error, message, headers: mergedHeaders });
29
71
  }
30
72
  }
31
73
  export class ForbiddenError extends XRPCError {
32
- constructor({ status = 403, error = 'Forbidden', description, headers } = {}) {
33
- super({ status, error, description, headers });
74
+ constructor({ status = 403, error = 'Forbidden', message, headers } = {}) {
75
+ super({ status, error, message, headers });
34
76
  }
35
77
  }
36
78
  export class RateLimitExceededError extends XRPCError {
37
- constructor({ status = 429, error = 'RateLimitExceeded', description, headers, } = {}) {
38
- super({ status, error, description, headers });
79
+ constructor({ status = 429, error = 'RateLimitExceeded', message, headers, } = {}) {
80
+ super({ status, error, message, headers });
39
81
  }
40
82
  }
41
83
  export class InternalServerError extends XRPCError {
42
- constructor({ status = 500, error = 'InternalServerError', description, headers, } = {}) {
43
- super({ status, error, description, headers });
84
+ constructor({ status = 500, error = 'InternalServerError', message, headers, } = {}) {
85
+ super({ status, error, message, headers });
44
86
  }
45
87
  }
46
88
  export class UpstreamFailureError extends XRPCError {
47
- constructor({ status = 502, error = 'UpstreamFailure', description, headers, } = {}) {
48
- super({ status, error, description, headers });
89
+ constructor({ status = 502, error = 'UpstreamFailure', message, headers } = {}) {
90
+ super({ status, error, message, headers });
49
91
  }
50
92
  }
51
93
  export class NotEnoughResourcesError extends XRPCError {
52
- constructor({ status = 503, error = 'NotEnoughResources', description, headers, } = {}) {
53
- super({ status, error, description, headers });
94
+ constructor({ status = 503, error = 'NotEnoughResources', message, headers, } = {}) {
95
+ super({ status, error, message, headers });
54
96
  }
55
97
  }
56
98
  export class UpstreamTimeoutError extends XRPCError {
57
- constructor({ status = 504, error = 'UpstreamTimeout', description, headers, } = {}) {
58
- super({ status, error, description, headers });
99
+ constructor({ status = 504, error = 'UpstreamTimeout', message, headers } = {}) {
100
+ super({ status, error, message, headers });
59
101
  }
60
102
  }
61
103
  export class XRPCSubscriptionError extends Error {
62
104
  closeCode;
63
105
  error;
64
- description;
65
- constructor({ closeCode = 1008, error, description }) {
66
- super(`Subscription error: ${error}${description ? ` - ${description}` : ''}`);
106
+ constructor({ closeCode = 1008, error, message }) {
107
+ super(message);
67
108
  this.closeCode = closeCode;
68
109
  this.error = error;
69
- this.description = description;
70
110
  }
71
111
  }
72
112
  //# sourceMappingURL=xrpc-error.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"xrpc-error.js","sourceRoot":"","sources":["../../lib/main/xrpc-error.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"xrpc-error.js","sourceRoot":"","sources":["../../lib/main/xrpc-error.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  import type { PrivateKey } from '@atcute/crypto';
2
2
  import type { Did, Nsid } from '@atcute/lexicons';
3
+ import type { AtprotoAudience } from '@atcute/lexicons/syntax';
3
4
  import { toBase64Url } from '@atcute/multibase';
4
5
  import { encodeUtf8 } from '@atcute/uint8array';
5
6
 
@@ -10,8 +11,9 @@ import type { JwtHeader, JwtPayload } from './jwt.ts';
10
11
  export interface CreateServiceJwtOptions {
11
12
  keypair: PrivateKey;
12
13
  issuer: Did;
13
- audience: Did;
14
- lxm: Nsid | null;
14
+ /** audience is either a bare DID or a DID with service fragment (e.g. `did:web:x.example#svc`) */
15
+ audience: Did | AtprotoAudience;
16
+ lxm: Nsid;
15
17
  issuedAt?: number;
16
18
  expiresIn?: number;
17
19
  }
@@ -33,7 +35,7 @@ export const createServiceJwt = async (options: CreateServiceJwtOptions): Promis
33
35
  iat: issuedAt,
34
36
  iss: options.issuer,
35
37
  jti: nanoid(24),
36
- lxm: options.lxm ?? undefined,
38
+ lxm: options.lxm,
37
39
  };
38
40
 
39
41
  const headerB64 = encodeJwtPortion(header);
@@ -1,61 +1,161 @@
1
1
  import { getPublicKeyFromDidController, verifySig, type FoundPublicKey } from '@atcute/crypto';
2
- import { getAtprotoVerificationMaterial, type DidDocument } from '@atcute/identity';
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
- serviceDid: Did | null;
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[] | null;
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: string | undefined;
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
- serviceDid: Did | null;
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.serviceDid = options.serviceDid;
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(issuer: Did, noCache: boolean): Promise<Result<FoundPublicKey, AuthError>> {
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: 'UnresolvedDidDocument',
146
+ error: 'DidResolutionFailed',
47
147
  description: `failed to retrieve did document for ${issuer}`,
48
148
  },
49
149
  };
50
150
  }
51
151
 
52
- const controller = getAtprotoVerificationMaterial(didDocument);
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 an atproto verification material`,
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 atproto verification material`,
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 verify(jwtString: string, options?: VerifyJwtOptions): Promise<Result<VerifiedJwt, AuthError>> {
96
- const parsed = parseJwt(jwtString);
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
- if (Date.now() / 1_000 > payload.exp) {
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
- if (this.serviceDid !== null && this.serviceDid !== payload.aud) {
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: 'BadJwtAudience',
132
- description: `jwt audience does not match (expected ${this.serviceDid})`,
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
- const key = await this.#getSigningKey(payload.iss, false);
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
  }
@@ -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
- lxm?: Nsid;
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: didString,
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
- /** xrpc operation being invoked */
47
- lxm: nsidString.optional(),
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: `MalformedJwt`,
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: `MalformedJwt`,
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: `MalformedJwt`,
114
+ error: `BadJwt`,
104
115
  description: `jwt is malformed`,
105
116
  },
106
117
  };