@atproto/xrpc-server 0.11.4 → 0.11.6

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/tests/_util.ts DELETED
@@ -1,124 +0,0 @@
1
- import { once } from 'node:events'
2
- import * as http from 'node:http'
3
- import express from 'express'
4
- import {
5
- LexiconDocument,
6
- LexiconIterableIndexer,
7
- LexiconSchemaBuilder,
8
- } from '@atproto/lex-document'
9
- import { LexiconDoc } from '@atproto/lexicon'
10
- import {
11
- AuthRequiredError,
12
- MethodConfigOrHandler,
13
- Options,
14
- Server,
15
- StreamConfigOrHandler,
16
- } from '../src/index.js'
17
-
18
- // @ts-expect-error
19
- Symbol.asyncDispose ??= Symbol.for('nodejs.asyncDispose')
20
-
21
- export async function createServer({ router }: Server): Promise<http.Server> {
22
- const app = express()
23
- app.use(router)
24
- const httpServer = app.listen(0)
25
- await once(httpServer, 'listening')
26
- // @NOTE Types define `http.Server` as `AsyncDisposable`, but not all
27
- // environments seem to support it.
28
- if (!(Symbol.asyncDispose in httpServer)) {
29
- Object.defineProperty(httpServer, Symbol.asyncDispose, {
30
- value: async function (this: http.Server) {
31
- return closeServer(this)
32
- },
33
- })
34
- }
35
- return httpServer
36
- }
37
-
38
- export async function closeServer(httpServer: http.Server) {
39
- await new Promise((r) => {
40
- httpServer.close(() => r(undefined))
41
- })
42
- }
43
-
44
- export function createBasicAuth(allowed: {
45
- username: string
46
- password: string
47
- }) {
48
- return function (ctx: { req: http.IncomingMessage }) {
49
- const header = ctx.req.headers.authorization ?? ''
50
- if (!header.startsWith('Basic ')) {
51
- throw new AuthRequiredError()
52
- }
53
- const original = header.replace('Basic ', '')
54
- const [username, password] = Buffer.from(original, 'base64')
55
- .toString()
56
- .split(':')
57
- if (username !== allowed.username || password !== allowed.password) {
58
- throw new AuthRequiredError()
59
- }
60
- return {
61
- credentials: { username },
62
- artifacts: { original },
63
- }
64
- }
65
- }
66
-
67
- export function basicAuthHeaders(creds: {
68
- username: string
69
- password: string
70
- }) {
71
- return {
72
- authorization:
73
- 'Basic ' +
74
- Buffer.from(`${creds.username}:${creds.password}`).toString('base64'),
75
- }
76
- }
77
-
78
- /**
79
- * Builds a lexicon server based on an `@atproto/lexicon`
80
- * {@link import('@atproto/lexicon').Lexicons} instance. Validation will be
81
- * performed by {@link import('@atproto/lexicon').Lexicons}'s various assertion
82
- * methods. This allows for testing the server's integration with
83
- * `@atproto/lexicon`.
84
- */
85
- export async function buildMethodLexicons(
86
- lexicons: LexiconDoc[],
87
- handlers: Record<string, MethodConfigOrHandler | StreamConfigOrHandler>,
88
- options?: Options,
89
- ) {
90
- const server = new Server(structuredClone(lexicons), options)
91
- for (const [id, handler] of Object.entries(handlers)) {
92
- const def = server.lex.getDef(id)
93
- if (def?.type === 'subscription') {
94
- server.addStreamMethod(id, handler as StreamConfigOrHandler)
95
- } else {
96
- server.method(id, handler as MethodConfigOrHandler)
97
- }
98
- }
99
- return server
100
- }
101
-
102
- /**
103
- * Builds a lexicon server based on `@atproto/lex`'s
104
- * {@link import('@atproto/lex').Query},
105
- * {@link import('@atproto/lex').Procedure}, and
106
- * {@link import('@atproto/lex').Subscription} method definitions. Validation
107
- * will be performed through built schema verifiers created by
108
- * {@link LexiconSchemaBuilder}. This helper allows for testing the server's
109
- * integration with `@atproto/lex`.
110
- */
111
- export async function buildAddLexicons(
112
- lexicons: LexiconDocument[],
113
- handlers: Record<string, MethodConfigOrHandler | StreamConfigOrHandler>,
114
- options?: Options,
115
- ) {
116
- const server = new Server(undefined, options)
117
- await using indexer = new LexiconIterableIndexer(structuredClone(lexicons))
118
- await using builder = new LexiconSchemaBuilder(indexer)
119
- for (const [id, handler] of Object.entries(handlers)) {
120
- const schema = await builder.buildFullRef(`${id}#main`)
121
- server.add(schema as any, handler as any)
122
- }
123
- return server
124
- }
@@ -1,333 +0,0 @@
1
- import { KeyObject, createPrivateKey } from 'node:crypto'
2
- import * as http from 'node:http'
3
- import { AddressInfo } from 'node:net'
4
- import * as jose from 'jose'
5
- import KeyEncoderModule from 'key-encoder'
6
- import { MINUTE } from '@atproto/common'
7
- import { Secp256k1Keypair } from '@atproto/crypto'
8
- import { LexiconDoc } from '@atproto/lexicon'
9
- import { XRPCError, XrpcClient } from '@atproto/xrpc'
10
- import * as xrpcServer from '../src/index.js'
11
- import {
12
- basicAuthHeaders,
13
- closeServer,
14
- createBasicAuth,
15
- createServer,
16
- } from './_util.js'
17
-
18
- // key-encoder is CJS with exports.default; Node ESM interop wraps it as { default: Class }
19
- const KeyEncoder = ((m) => m.default ?? m)(KeyEncoderModule)
20
-
21
- const LEXICONS: LexiconDoc[] = [
22
- {
23
- lexicon: 1,
24
- id: 'io.example.authTest',
25
- defs: {
26
- main: {
27
- type: 'procedure',
28
- input: {
29
- encoding: 'application/json',
30
- schema: {
31
- type: 'object',
32
- properties: {
33
- present: { type: 'boolean', const: true },
34
- },
35
- },
36
- },
37
- output: {
38
- encoding: 'application/json',
39
- schema: {
40
- type: 'object',
41
- properties: {
42
- username: { type: 'string' },
43
- original: { type: 'string' },
44
- },
45
- },
46
- },
47
- },
48
- },
49
- },
50
- ]
51
-
52
- describe('Auth', () => {
53
- let s: http.Server
54
- const server = xrpcServer.createServer(LEXICONS)
55
- server.method('io.example.authTest', {
56
- auth: createBasicAuth({ username: 'admin', password: 'password' }),
57
- handler: ({ auth }) => {
58
- return {
59
- encoding: 'application/json',
60
- body: {
61
- username: auth.credentials.username,
62
- original: auth.artifacts.original,
63
- },
64
- }
65
- },
66
- })
67
-
68
- let client: XrpcClient
69
- beforeAll(async () => {
70
- s = await createServer(server)
71
- const { port } = s.address() as AddressInfo
72
- client = new XrpcClient(`http://localhost:${port}`, LEXICONS)
73
- })
74
-
75
- afterAll(async () => {
76
- await closeServer(s)
77
- })
78
-
79
- it('creates and validates service auth headers', async () => {
80
- const keypair = await Secp256k1Keypair.create()
81
- const iss = 'did:example:alice'
82
- const aud = 'did:example:bob'
83
- const token = await xrpcServer.createServiceJwt({
84
- iss,
85
- aud,
86
- keypair,
87
- lxm: null,
88
- })
89
- const validated = await xrpcServer.verifyJwt(token, null, null, async () =>
90
- keypair.did(),
91
- )
92
- expect(validated.iss).toEqual(iss)
93
- expect(validated.aud).toEqual(aud)
94
- // should expire within the minute when no exp is provided
95
- expect(validated.exp).toBeGreaterThan(Date.now() / 1000)
96
- expect(validated.exp).toBeLessThan(Date.now() / 1000 + 60)
97
- expect(typeof validated.jti).toBe('string')
98
- expect(validated.lxm).toBeUndefined()
99
- })
100
-
101
- it('creates and validates service auth headers bound to a particular method', async () => {
102
- const keypair = await Secp256k1Keypair.create()
103
- const iss = 'did:example:alice'
104
- const aud = 'did:example:bob'
105
- const lxm = 'com.atproto.repo.createRecord'
106
- const token = await xrpcServer.createServiceJwt({
107
- iss,
108
- aud,
109
- keypair,
110
- lxm,
111
- })
112
- const validated = await xrpcServer.verifyJwt(token, null, lxm, async () =>
113
- keypair.did(),
114
- )
115
- expect(validated.iss).toEqual(iss)
116
- expect(validated.aud).toEqual(aud)
117
- expect(validated.lxm).toEqual(lxm)
118
- })
119
-
120
- it('fails on bad auth before invalid request payload.', async () => {
121
- try {
122
- await client.call(
123
- 'io.example.authTest',
124
- {},
125
- { present: false },
126
- {
127
- headers: basicAuthHeaders({
128
- username: 'admin',
129
- password: 'wrong',
130
- }),
131
- },
132
- )
133
- throw new Error('Didnt throw')
134
- } catch (e: any) {
135
- expect(e).toBeInstanceOf(XRPCError)
136
- expect(e.success).toBeFalsy()
137
- expect(e.error).toBe('AuthenticationRequired')
138
- expect(e.message).toBe('Authentication Required')
139
- expect(e.status).toBe(401)
140
- }
141
- })
142
-
143
- it('fails on invalid request payload after good auth.', async () => {
144
- try {
145
- await client.call(
146
- 'io.example.authTest',
147
- {},
148
- { present: false },
149
- {
150
- headers: basicAuthHeaders({
151
- username: 'admin',
152
- password: 'password',
153
- }),
154
- },
155
- )
156
- throw new Error('Didnt throw')
157
- } catch (e: any) {
158
- expect(e).toBeInstanceOf(XRPCError)
159
- expect(e.success).toBeFalsy()
160
- expect(e.error).toBe('InvalidRequest')
161
- expect(e.message).toBe('Input/present must be true')
162
- expect(e.status).toBe(400)
163
- }
164
- })
165
-
166
- it('succeeds on good auth and payload.', async () => {
167
- const res = await client.call(
168
- 'io.example.authTest',
169
- {},
170
- { present: true },
171
- {
172
- headers: basicAuthHeaders({
173
- username: 'admin',
174
- password: 'password',
175
- }),
176
- },
177
- )
178
- expect(res.success).toBe(true)
179
- expect(res.data).toEqual({
180
- username: 'admin',
181
- original: 'YWRtaW46cGFzc3dvcmQ=',
182
- })
183
- })
184
-
185
- describe('verifyJwt()', () => {
186
- it('fails on expired jwt.', async () => {
187
- const keypair = await Secp256k1Keypair.create()
188
- const jwt = await xrpcServer.createServiceJwt({
189
- aud: 'did:example:aud',
190
- iss: 'did:example:iss',
191
- keypair,
192
- exp: Math.floor((Date.now() - MINUTE) / 1000),
193
- lxm: null,
194
- })
195
- const tryVerify = xrpcServer.verifyJwt(
196
- jwt,
197
- 'did:example:aud',
198
- null,
199
- async () => {
200
- return keypair.did()
201
- },
202
- )
203
- await expect(tryVerify).rejects.toThrow('jwt expired')
204
- })
205
-
206
- it('fails on bad audience.', async () => {
207
- const keypair = await Secp256k1Keypair.create()
208
- const jwt = await xrpcServer.createServiceJwt({
209
- aud: 'did:example:aud1',
210
- iss: 'did:example:iss',
211
- keypair,
212
- lxm: null,
213
- })
214
- const tryVerify = xrpcServer.verifyJwt(
215
- jwt,
216
- 'did:example:aud2',
217
- null,
218
- async () => {
219
- return keypair.did()
220
- },
221
- )
222
- await expect(tryVerify).rejects.toThrow(
223
- 'jwt audience does not match service did',
224
- )
225
- })
226
-
227
- it('fails on bad lxm', async () => {
228
- const keypair = await Secp256k1Keypair.create()
229
- const jwt = await xrpcServer.createServiceJwt({
230
- aud: 'did:example:aud1',
231
- iss: 'did:example:iss',
232
- keypair,
233
- lxm: 'com.atproto.repo.createRecord',
234
- })
235
- const tryVerify = xrpcServer.verifyJwt(
236
- jwt,
237
- 'did:example:aud1',
238
- 'com.atproto.repo.putRecord',
239
- async () => {
240
- return keypair.did()
241
- },
242
- )
243
- await expect(tryVerify).rejects.toThrow(/bad jwt lexicon method/)
244
- })
245
-
246
- it('fails on null lxm when lxm is required', async () => {
247
- const keypair = await Secp256k1Keypair.create()
248
- const jwt = await xrpcServer.createServiceJwt({
249
- aud: 'did:example:aud1',
250
- iss: 'did:example:iss',
251
- keypair,
252
- lxm: null,
253
- })
254
- const tryVerify = xrpcServer.verifyJwt(
255
- jwt,
256
- 'did:example:aud1',
257
- 'com.atproto.repo.putRecord',
258
- async () => {
259
- return keypair.did()
260
- },
261
- )
262
- await expect(tryVerify).rejects.toThrow(/missing jwt lexicon method/)
263
- })
264
-
265
- it('refreshes key on verification failure.', async () => {
266
- const keypair1 = await Secp256k1Keypair.create()
267
- const keypair2 = await Secp256k1Keypair.create()
268
- const jwt = await xrpcServer.createServiceJwt({
269
- aud: 'did:example:aud',
270
- iss: 'did:example:iss',
271
- keypair: keypair2,
272
- lxm: null,
273
- })
274
- let usedKeypair1 = false
275
- let usedKeypair2 = false
276
- const tryVerify = xrpcServer.verifyJwt(
277
- jwt,
278
- 'did:example:aud',
279
- null,
280
- async (_did, forceRefresh) => {
281
- if (forceRefresh) {
282
- usedKeypair2 = true
283
- return keypair2.did()
284
- } else {
285
- usedKeypair1 = true
286
- return keypair1.did()
287
- }
288
- },
289
- )
290
- await expect(tryVerify).resolves.toMatchObject({
291
- aud: 'did:example:aud',
292
- iss: 'did:example:iss',
293
- })
294
- expect(usedKeypair1).toBe(true)
295
- expect(usedKeypair2).toBe(true)
296
- })
297
-
298
- it('interoperates with jwts signed by other libraries.', async () => {
299
- const keypair = await Secp256k1Keypair.create({ exportable: true })
300
- const signingKey = await createPrivateKeyObject(keypair)
301
- const payload = {
302
- aud: 'did:example:aud',
303
- iss: 'did:example:iss',
304
- exp: Math.floor((Date.now() + MINUTE) / 1000),
305
- }
306
- const jwt = await new jose.SignJWT(payload)
307
- .setProtectedHeader({ typ: 'JWT', alg: keypair.jwtAlg })
308
- .sign(signingKey)
309
- const tryVerify = xrpcServer.verifyJwt(
310
- jwt,
311
- 'did:example:aud',
312
- null,
313
- async () => {
314
- return keypair.did()
315
- },
316
- )
317
- await expect(tryVerify).resolves.toEqual(payload)
318
- })
319
- })
320
- })
321
-
322
- const createPrivateKeyObject = async (
323
- privateKey: Secp256k1Keypair,
324
- ): Promise<KeyObject> => {
325
- const raw = await privateKey.export()
326
- const encoder = new KeyEncoder('secp256k1')
327
- const key = encoder.encodePrivate(
328
- Buffer.from(raw).toString('hex'),
329
- 'raw',
330
- 'pem',
331
- )
332
- return createPrivateKey({ format: 'pem', key })
333
- }