@atproto/lex-server 0.1.4 → 0.1.5

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.
@@ -1,87 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
- import { serviceAuth } from './service-auth.js'
3
-
4
- describe('serviceAuth - lxm validation', () => {
5
- const audience = 'did:web:api.example.com'
6
- const issuer = 'did:web:caller.example.com'
7
- const nsid = 'io.example.test'
8
-
9
- function makeJwt(payload: Record<string, unknown>): string {
10
- const header = Buffer.from(
11
- JSON.stringify({ alg: 'ES256K', typ: 'JWT' }),
12
- ).toString('base64url')
13
- const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
14
- const sig = Buffer.from([0]).toString('base64url')
15
- return `${header}.${body}.${sig}`
16
- }
17
-
18
- function basePayload(overrides: Record<string, unknown> = {}) {
19
- const now = Math.floor(Date.now() / 1000)
20
- return { iss: issuer, aud: audience, iat: now, exp: now + 60, ...overrides }
21
- }
22
-
23
- function setup() {
24
- const resolve = vi.fn(async () => {
25
- throw new Error('stop after lxm check')
26
- })
27
- const auth = serviceAuth({
28
- audience,
29
- unique: async () => true,
30
- didResolver: { resolve },
31
- })
32
- return { auth, resolve }
33
- }
34
-
35
- it('rejects with BadJwtLexiconMethod when lxm does not match method.nsid', async () => {
36
- const { auth, resolve } = setup()
37
- const jwt = makeJwt(basePayload({ lxm: 'io.example.different' }))
38
- const request = new Request(`https://api.example.com/xrpc/${nsid}`, {
39
- headers: { authorization: `Bearer ${jwt}` },
40
- })
41
-
42
- await expect(
43
- auth({ request, method: { nsid } as any, params: {} }),
44
- ).rejects.toThrow('Invalid JWT lexicon method ("lxm")')
45
- expect(resolve).not.toHaveBeenCalled()
46
- })
47
-
48
- it('passes lxm check when payload.lxm matches method.nsid', async () => {
49
- const { auth, resolve } = setup()
50
- const jwt = makeJwt(basePayload({ lxm: nsid }))
51
- const request = new Request(`https://api.example.com/xrpc/${nsid}`, {
52
- headers: { authorization: `Bearer ${jwt}` },
53
- })
54
-
55
- await expect(
56
- auth({ request, method: { nsid } as any, params: {} }),
57
- ).rejects.toThrow()
58
- // The DID resolver isn't called unless "lxm" validation succeeded
59
- expect(resolve).toHaveBeenCalled()
60
- })
61
-
62
- it('skips lxm check when payload has no lxm claim', async () => {
63
- const { auth, resolve } = setup()
64
- const jwt = makeJwt(basePayload())
65
- const request = new Request(`https://api.example.com/xrpc/${nsid}`, {
66
- headers: { authorization: `Bearer ${jwt}` },
67
- })
68
-
69
- await expect(
70
- auth({ request, method: { nsid } as any, params: {} }),
71
- ).rejects.toThrow()
72
- expect(resolve).toHaveBeenCalled()
73
- })
74
-
75
- it('rejects an empty-string lxm claim against a real NSID', async () => {
76
- const { auth, resolve } = setup()
77
- const jwt = makeJwt(basePayload({ lxm: '' }))
78
- const request = new Request(`https://api.example.com/xrpc/${nsid}`, {
79
- headers: { authorization: `Bearer ${jwt}` },
80
- })
81
-
82
- await expect(
83
- auth({ request, method: { nsid } as any, params: {} }),
84
- ).rejects.toThrow('Invalid JWT lexicon method ("lxm")')
85
- expect(resolve).not.toHaveBeenCalled()
86
- })
87
- })
@@ -1,517 +0,0 @@
1
- import * as crypto from '@atproto/crypto'
2
- import {
3
- AtprotoDid,
4
- AtprotoDidDocument,
5
- Did,
6
- matchesIdentifier,
7
- } from '@atproto/did'
8
- import { fromBase64, isPlainObject, utf8FromBase64 } from '@atproto/lex-data'
9
- import { DidString, isDidString } from '@atproto/lex-schema'
10
- import {
11
- CreateDidResolverOptions,
12
- createDidResolver,
13
- } from '@atproto-labs/did-resolver'
14
- import { LexServerAuthError } from './errors.js'
15
- import type { LexRouterAuth } from './lex-router.js'
16
-
17
- const BEARER_PREFIX = 'Bearer '
18
-
19
- /**
20
- * Callback function to check and record nonce uniqueness.
21
- *
22
- * Used to prevent replay attacks by ensuring each nonce is only used once.
23
- * The implementation must track nonces for at least the `maxAge` duration
24
- * (default 5 minutes before and after the current time).
25
- *
26
- * @param nonce - The nonce string from the JWT token
27
- * @returns Promise resolving to `true` if the nonce is unique (first time seen),
28
- * `false` if it has been seen before
29
- *
30
- * @example
31
- * ```typescript
32
- * // Using Redis for nonce tracking
33
- * const checkNonce: UniqueNonceChecker = async (nonce) => {
34
- * const key = `nonce:${nonce}`
35
- * const result = await redis.setnx(key, '1')
36
- * if (result === 1) {
37
- * await redis.expire(key, 600) // 10 minutes TTL
38
- * return true
39
- * }
40
- * return false
41
- * }
42
- * ```
43
- */
44
- export type UniqueNonceChecker = (nonce: string) => Promise<boolean>
45
-
46
- /**
47
- * Configuration options for AT Protocol service authentication.
48
- *
49
- * Service auth is used for server-to-server communication in the AT Protocol,
50
- * where one service authenticates to another using signed JWT tokens tied to
51
- * the caller's DID.
52
- *
53
- * @example
54
- * ```typescript
55
- * const options: ServiceAuthOptions = {
56
- * audience: 'did:web:api.example.com',
57
- * unique: async (nonce) => nonceStore.checkAndAdd(nonce),
58
- * maxAge: 300, // 5 minutes
59
- * // Optional DID resolver options
60
- * plcDirectoryUrl: 'https://plc.directory'
61
- * }
62
- * ```
63
- */
64
- export type ServiceAuthOptions = CreateDidResolverOptions & {
65
- /**
66
- * Expected audience ("aud") claim in the JWT token.
67
- *
68
- * This should be the DID of your service. The token must include this
69
- * value in its `aud` claim to be accepted. Set to `null` to skip
70
- * audience verification (not recommended for production).
71
- */
72
- audience: null | DidString
73
- /**
74
- * Function to check and record nonce uniqueness.
75
- *
76
- * This is critical for preventing replay attacks. The value checked here
77
- * must be unique within `maxAge` seconds before and after the current time.
78
- *
79
- * @param nonce - The nonce to check
80
- * @returns Promise resolving to `true` if unique, `false` if seen before
81
- */
82
- unique: UniqueNonceChecker
83
- /**
84
- * Maximum age of the JWT token in seconds.
85
- *
86
- * Tokens with `iat` (issued at) or `exp` (expiry) timestamps outside
87
- * this window from the current time will be rejected.
88
- *
89
- * @default 300 (5 minutes)
90
- */
91
- maxAge?: number
92
- }
93
-
94
- /**
95
- * Credentials returned after successful service authentication.
96
- *
97
- * Contains the verified DID, resolved DID document, and parsed JWT token.
98
- * These are available in handler context as `ctx.credentials`.
99
- *
100
- * @example
101
- * ```typescript
102
- * router.add(protectedMethod, {
103
- * handler: async (ctx) => {
104
- * const { did, didDocument, jwt } = ctx.credentials
105
- * console.log('Request from:', did)
106
- * console.log('Token expires:', new Date(jwt.payload.exp * 1000))
107
- * return { body: { callerDid: did } }
108
- * },
109
- * auth: serviceAuth({ audience: myDid, unique: checkNonce })
110
- * })
111
- * ```
112
- */
113
- export type ServiceAuthCredentials = {
114
- /** The verified AT Protocol DID of the caller. */
115
- did: AtprotoDid
116
- /** The resolved DID document of the caller. */
117
- didDocument: AtprotoDidDocument
118
- /** The parsed and validated JWT token. */
119
- jwt: ParsedJwt
120
- }
121
-
122
- /**
123
- * Creates an authentication handler for verifying AT Protocol service auth JWTs.
124
- *
125
- * Service auth is the standard authentication mechanism for server-to-server
126
- * communication in the AT Protocol. It uses JWT bearer tokens signed by the
127
- * caller's DID signing key, with the signature verified against the public
128
- * key in the caller's DID document.
129
- *
130
- * The handler performs the following validations:
131
- * - Extracts and parses the Bearer token from the Authorization header
132
- * - Validates JWT structure and claims (aud, exp, iat, lxm, nonce)
133
- * - Resolves the issuer's DID document
134
- * - Verifies the JWT signature against the `#atproto` verification method
135
- * - Checks nonce uniqueness to prevent replay attacks
136
- *
137
- * @param options - Configuration options for service auth
138
- * @returns An auth handler function for use with {@link LexRouter.add}
139
- *
140
- * @example Basic usage
141
- * ```typescript
142
- * import { LexRouter, serviceAuth } from '@atproto/lex-server'
143
- *
144
- * const router = new LexRouter()
145
- *
146
- * const auth = serviceAuth({
147
- * audience: 'did:web:api.example.com',
148
- * unique: async (nonce) => {
149
- * // Check if nonce has been seen, return true if unique
150
- * const isNew = await redis.setnx(`nonce:${nonce}`, '1')
151
- * if (isNew) await redis.expire(`nonce:${nonce}`, 600)
152
- * return isNew
153
- * }
154
- * })
155
- *
156
- * router.add(myMethod, {
157
- * handler: async (ctx) => {
158
- * console.log('Authenticated as:', ctx.credentials.did)
159
- * return { body: { success: true } }
160
- * },
161
- * auth
162
- * })
163
- * ```
164
- */
165
- export function serviceAuth({
166
- audience,
167
- maxAge = 5 * 60,
168
- unique,
169
- ...options
170
- }: ServiceAuthOptions): LexRouterAuth<ServiceAuthCredentials> {
171
- const didResolver = createDidResolver(options)
172
-
173
- return async ({ request, method }) => {
174
- const { signal } = request
175
- const jwt = await parseJwtBearer(request, {
176
- lxm: method.nsid,
177
- maxAge,
178
- audience,
179
- unique,
180
- })
181
-
182
- let didDocument: AtprotoDidDocument = await didResolver
183
- .resolve(jwt.payload.iss, { signal })
184
- .catch((cause) => {
185
- throw new LexServerAuthError(
186
- 'AuthenticationRequired',
187
- 'Could not resolve DID document',
188
- { Bearer: { error: 'DidResolutionFailed' } },
189
- { cause },
190
- )
191
- })
192
-
193
- const key = getAtprotoSigningKey(didDocument)
194
-
195
- if (!key || !(await verifyJwt(jwt, key))) {
196
- signal.throwIfAborted()
197
-
198
- // Try refreshing the DID document in case it was updated
199
- didDocument = await didResolver
200
- .resolve(jwt.payload.iss, { signal, noCache: true })
201
- .catch((cause) => {
202
- throw new LexServerAuthError(
203
- 'AuthenticationRequired',
204
- 'Could not resolve DID document',
205
- { Bearer: { error: 'DidResolutionFailed' } },
206
- { cause },
207
- )
208
- })
209
-
210
- // Verify again with the fresh key (if it changed)
211
- const keyFresh = getAtprotoSigningKey(didDocument)
212
- if (!keyFresh || key === keyFresh || !(await verifyJwt(jwt, keyFresh))) {
213
- throw new LexServerAuthError(
214
- 'AuthenticationRequired',
215
- 'Invalid JWT signature',
216
- { Bearer: { error: 'BadJwtSignature' } },
217
- )
218
- }
219
- }
220
-
221
- return {
222
- did: didDocument.id,
223
- didDocument,
224
- jwt,
225
- }
226
- }
227
- }
228
-
229
- async function verifyJwt(jwt: ParsedJwt, key: Did<'key'>) {
230
- try {
231
- return await crypto.verifySignature(key, jwt.message, jwt.signature, {
232
- jwtAlg: jwt.header.alg,
233
- allowMalleableSig: true,
234
- })
235
- } catch (cause) {
236
- throw new LexServerAuthError(
237
- 'AuthenticationRequired',
238
- 'Could not verify JWT signature',
239
- { Bearer: { error: 'BadJwtSignature' } },
240
- { cause },
241
- )
242
- }
243
- }
244
-
245
- function getAtprotoSigningKey(
246
- didDocument: AtprotoDidDocument,
247
- ): null | Did<'key'> {
248
- try {
249
- const key = didDocument.verificationMethod?.find(
250
- isAtprotoVerificationMethod,
251
- didDocument,
252
- )
253
-
254
- if (key?.publicKeyMultibase) {
255
- if (key.type === 'EcdsaSecp256r1VerificationKey2019') {
256
- const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase)
257
- return crypto.formatDidKey(crypto.P256_JWT_ALG, keyBytes)
258
- } else if (key.type === 'EcdsaSecp256k1VerificationKey2019') {
259
- const keyBytes = crypto.multibaseToBytes(key.publicKeyMultibase)
260
- return crypto.formatDidKey(crypto.SECP256K1_JWT_ALG, keyBytes)
261
- } else if (key.type === 'Multikey') {
262
- const parsed = crypto.parseMultikey(key.publicKeyMultibase)
263
- return crypto.formatDidKey(parsed.jwtAlg, parsed.keyBytes)
264
- }
265
- }
266
- } catch {
267
- // Invalid key, ignore
268
- }
269
-
270
- return null
271
- }
272
-
273
- function isAtprotoVerificationMethod<
274
- V extends string | { id: string; type: string; publicKeyMultibase?: string },
275
- >(
276
- this: AtprotoDidDocument,
277
- vm: V,
278
- ): vm is Exclude<V, string> & {
279
- id: `${string}#atproto`
280
- } {
281
- return typeof vm === 'object' && matchesIdentifier(this.id, 'atproto', vm.id)
282
- }
283
-
284
- async function parseJwtBearer(
285
- request: Request,
286
- options: ParseJwtOptions,
287
- ): Promise<ParsedJwt> {
288
- const authorization = request.headers.get('authorization')
289
- if (!authorization?.startsWith(BEARER_PREFIX)) {
290
- throw new LexServerAuthError(
291
- 'AuthenticationRequired',
292
- 'Bearer token required',
293
- { Bearer: { error: 'MissingBearer' } },
294
- )
295
- }
296
-
297
- const token = authorization.slice(BEARER_PREFIX.length).trim()
298
-
299
- return parseJwt(token, options)
300
- }
301
-
302
- /**
303
- * Options for parsing and validating a JWT token.
304
- */
305
- export type ParseJwtOptions = {
306
- /** Maximum age in seconds for token validity window. */
307
- maxAge: number
308
- /** Expected audience claim, or null to skip audience verification. */
309
- audience: null | DidString
310
- /** Function to check nonce uniqueness. */
311
- unique: UniqueNonceChecker
312
- /** Expected lexicon method NSID for the `lxm` claim. */
313
- lxm: string
314
- }
315
-
316
- /**
317
- * A parsed and partially validated JWT token.
318
- *
319
- * Contains the decoded header and payload, along with the raw bytes
320
- * needed for signature verification.
321
- *
322
- * @example
323
- * ```typescript
324
- * const jwt: ParsedJwt = {
325
- * header: { alg: 'ES256K', typ: 'JWT' },
326
- * payload: {
327
- * iss: 'did:plc:abc123',
328
- * aud: 'did:web:api.example.com',
329
- * exp: 1704067200,
330
- * iat: 1704066900,
331
- * lxm: 'com.atproto.sync.getBlob'
332
- * },
333
- * message: new Uint8Array([...]),
334
- * signature: new Uint8Array([...])
335
- * }
336
- * ```
337
- */
338
- export type ParsedJwt = {
339
- /** The decoded JWT header containing algorithm and type. */
340
- header: HeaderObject
341
- /** The decoded JWT payload containing claims. */
342
- payload: PayloadObject
343
- /** The raw header.payload bytes for signature verification. */
344
- message: Uint8Array
345
- /** The decoded signature bytes. */
346
- signature: Uint8Array
347
- }
348
-
349
- async function parseJwt(
350
- token: string,
351
- options: ParseJwtOptions,
352
- ): Promise<ParsedJwt> {
353
- const {
354
- length,
355
- 0: headerB64,
356
- 1: payloadB64,
357
- 2: signatureB64,
358
- } = token.split('.')
359
- if (length !== 3) {
360
- throw new LexServerAuthError(
361
- 'AuthenticationRequired',
362
- 'Invalid JWT token',
363
- { Bearer: { error: 'BadJwt' } },
364
- )
365
- }
366
-
367
- let header: HeaderObject
368
- try {
369
- header = jsonFromBase64(headerB64, isHeaderObject)
370
- } catch (cause) {
371
- throw new LexServerAuthError(
372
- 'AuthenticationRequired',
373
- 'Invalid JWT token',
374
- { Bearer: { error: 'BadJwt' } },
375
- { cause },
376
- )
377
- }
378
-
379
- if (
380
- header.alg === 'none' ||
381
- // service tokens are not OAuth 2.0 access tokens
382
- // https://datatracker.ietf.org/doc/html/rfc9068
383
- header.typ === 'at+jwt' ||
384
- // "refresh+jwt" is a non-standard type used by the @atproto packages
385
- header.typ === 'refresh+jwt' ||
386
- // "DPoP" proofs are not meant to be used as service tokens
387
- // https://datatracker.ietf.org/doc/html/rfc9449
388
- header.typ === 'dpop+jwt'
389
- ) {
390
- throw new LexServerAuthError(
391
- 'AuthenticationRequired',
392
- 'Invalid JWT token',
393
- { Bearer: { error: 'BadJwt' } },
394
- )
395
- }
396
-
397
- let payload: PayloadObject
398
- try {
399
- payload = jsonFromBase64(payloadB64, isPayloadObject)
400
- } catch (cause) {
401
- throw new LexServerAuthError(
402
- 'AuthenticationRequired',
403
- 'Invalid JWT token',
404
- { Bearer: { error: 'BadJwt' } },
405
- { cause },
406
- )
407
- }
408
-
409
- if (options.audience !== null && options.audience !== payload.aud) {
410
- throw new LexServerAuthError('AuthenticationRequired', 'Invalid audience', {
411
- Bearer: { error: 'InvalidAudience' },
412
- })
413
- }
414
-
415
- const now = Math.floor(Date.now() / 1000)
416
-
417
- if (payload.nbf != null && now < payload.nbf) {
418
- throw new LexServerAuthError(
419
- 'AuthenticationRequired',
420
- 'JWT token not yet valid',
421
- { Bearer: { error: 'JwtNotYetValid' } },
422
- )
423
- }
424
-
425
- if (now > payload.exp) {
426
- throw new LexServerAuthError(
427
- 'AuthenticationRequired',
428
- 'JWT token expired',
429
- { Bearer: { error: 'JwtExpired' } },
430
- )
431
- }
432
-
433
- // Prevent issuer from generating very long-lived tokens
434
- if (
435
- timeDiff(now, payload.exp) > options.maxAge ||
436
- timeDiff(now, payload.iat) > options.maxAge
437
- ) {
438
- throw new LexServerAuthError(
439
- 'AuthenticationRequired',
440
- 'JWT token too old',
441
- { Bearer: { error: 'JwtTooOld' } },
442
- )
443
- }
444
-
445
- if (payload.lxm != null && payload.lxm !== options.lxm) {
446
- throw new LexServerAuthError(
447
- 'AuthenticationRequired',
448
- 'Invalid JWT lexicon method ("lxm")',
449
- { Bearer: { error: 'BadJwtLexiconMethod' } },
450
- )
451
- }
452
-
453
- if (payload.nonce != null && !(await (0, options.unique)(payload.nonce))) {
454
- throw new LexServerAuthError(
455
- 'AuthenticationRequired',
456
- 'Replay attack detected: nonce is not unique',
457
- { Bearer: { error: 'NonceNotUnique' } },
458
- )
459
- }
460
-
461
- return {
462
- header,
463
- payload,
464
- message: textEncoder.encode(`${headerB64}.${payloadB64}`),
465
- signature: fromBase64(signatureB64, 'base64url'),
466
- }
467
- }
468
-
469
- const textEncoder = /*#__PURE__*/ new TextEncoder()
470
-
471
- type HeaderObject = { alg: string; typ?: string }
472
- function isHeaderObject(obj: unknown): obj is HeaderObject {
473
- return (
474
- isPlainObject(obj) &&
475
- typeof obj.alg === 'string' &&
476
- (obj.typ === undefined || typeof obj.typ === 'string')
477
- )
478
- }
479
-
480
- type PayloadObject = {
481
- iss: DidString
482
- aud: DidString
483
- exp: number
484
- iat?: number
485
- nbf?: number
486
- lxm?: string
487
- nonce?: string
488
- }
489
- export function isPayloadObject(obj: unknown): obj is PayloadObject {
490
- return (
491
- isPlainObject(obj) &&
492
- typeof obj.iss === 'string' &&
493
- typeof obj.aud === 'string' &&
494
- (obj.lxm === undefined || typeof obj.lxm === 'string') &&
495
- (obj.nonce === undefined || typeof obj.nonce === 'string') &&
496
- (obj.iat === undefined || isPositiveInt(obj.iat)) &&
497
- (obj.nbf === undefined || isPositiveInt(obj.nbf)) &&
498
- isPositiveInt(obj.exp) &&
499
- isDidString(obj.iss) &&
500
- isDidString(obj.aud)
501
- )
502
- }
503
-
504
- function timeDiff(t1: number, t2?: number): number {
505
- if (t2 === undefined) return 0
506
- return Math.abs(t1 - t2)
507
- }
508
-
509
- function isPositiveInt(value: unknown): value is number {
510
- return typeof value === 'number' && Number.isInteger(value) && value > 0
511
- }
512
-
513
- function jsonFromBase64<T>(b64: string, isType: (obj: unknown) => obj is T): T {
514
- const obj = JSON.parse(utf8FromBase64(b64, 'base64url'))
515
- if (isType(obj)) return obj
516
- throw new Error('Invalid type')
517
- }
@@ -1,12 +0,0 @@
1
- {
2
- "extends": ["../../../tsconfig/isomorphic.json"],
3
- "include": ["./src"],
4
- "exclude": ["**/*.test.ts"],
5
- "compilerOptions": {
6
- "noImplicitAny": true,
7
- "importHelpers": true,
8
- "target": "ES2023",
9
- "rootDir": "./src",
10
- "outDir": "./dist",
11
- },
12
- }
package/tsconfig.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "include": [],
3
- "references": [
4
- { "path": "./tsconfig.build.json" },
5
- { "path": "./tsconfig.examples.json" },
6
- { "path": "./tsconfig.tests.json" },
7
- ],
8
- }
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "../../../tsconfig/vitest.json",
3
- "include": ["./tests", "./src/**/*.test.ts"],
4
- "compilerOptions": {
5
- "noImplicitAny": true,
6
- "rootDir": "./",
7
- },
8
- }