accounts 0.4.0 → 0.4.2

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 (76) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +38 -7
  3. package/dist/cli/Provider.d.ts +12 -0
  4. package/dist/cli/Provider.d.ts.map +1 -0
  5. package/dist/cli/Provider.js +19 -0
  6. package/dist/cli/Provider.js.map +1 -0
  7. package/dist/cli/adapter.d.ts +24 -0
  8. package/dist/cli/adapter.d.ts.map +1 -0
  9. package/dist/cli/adapter.js +173 -0
  10. package/dist/cli/adapter.js.map +1 -0
  11. package/dist/cli/index.d.ts +3 -0
  12. package/dist/cli/index.d.ts.map +1 -0
  13. package/dist/cli/index.js +3 -0
  14. package/dist/cli/index.js.map +1 -0
  15. package/dist/core/Dialog.d.ts.map +1 -1
  16. package/dist/core/Dialog.js +25 -1
  17. package/dist/core/Dialog.js.map +1 -1
  18. package/dist/core/IntersectionObserver.d.ts +3 -0
  19. package/dist/core/IntersectionObserver.d.ts.map +1 -0
  20. package/dist/core/IntersectionObserver.js +6 -0
  21. package/dist/core/IntersectionObserver.js.map +1 -0
  22. package/dist/core/Messenger.d.ts +14 -3
  23. package/dist/core/Messenger.d.ts.map +1 -1
  24. package/dist/core/Messenger.js +4 -4
  25. package/dist/core/Messenger.js.map +1 -1
  26. package/dist/core/Remote.d.ts +6 -3
  27. package/dist/core/Remote.d.ts.map +1 -1
  28. package/dist/core/Remote.js +3 -6
  29. package/dist/core/Remote.js.map +1 -1
  30. package/dist/core/adapters/local.d.ts.map +1 -1
  31. package/dist/core/adapters/local.js +2 -2
  32. package/dist/core/adapters/local.js.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/react/Remote.d.ts +21 -0
  38. package/dist/react/Remote.d.ts.map +1 -0
  39. package/dist/react/Remote.js +51 -0
  40. package/dist/react/Remote.js.map +1 -0
  41. package/dist/react/index.d.ts +2 -0
  42. package/dist/react/index.d.ts.map +1 -0
  43. package/dist/react/index.js +2 -0
  44. package/dist/react/index.js.map +1 -0
  45. package/dist/server/CliAuth.d.ts +553 -0
  46. package/dist/server/CliAuth.d.ts.map +1 -0
  47. package/dist/server/CliAuth.js +446 -0
  48. package/dist/server/CliAuth.js.map +1 -0
  49. package/dist/server/Handler.d.ts +36 -2
  50. package/dist/server/Handler.d.ts.map +1 -1
  51. package/dist/server/Handler.js +84 -0
  52. package/dist/server/Handler.js.map +1 -1
  53. package/dist/server/index.d.ts +1 -0
  54. package/dist/server/index.d.ts.map +1 -1
  55. package/dist/server/index.js +1 -0
  56. package/dist/server/index.js.map +1 -1
  57. package/package.json +16 -54
  58. package/src/cli/Provider.test-d.ts +28 -0
  59. package/src/cli/Provider.test.ts +235 -0
  60. package/src/cli/Provider.ts +26 -0
  61. package/src/cli/adapter.ts +229 -0
  62. package/src/cli/index.ts +2 -0
  63. package/src/core/Dialog.ts +31 -1
  64. package/src/core/IntersectionObserver.ts +6 -0
  65. package/src/core/Messenger.ts +18 -8
  66. package/src/core/Provider.test.ts +12 -2
  67. package/src/core/Remote.ts +9 -10
  68. package/src/core/adapters/local.ts +7 -2
  69. package/src/index.ts +1 -0
  70. package/src/react/Remote.ts +94 -0
  71. package/src/react/index.ts +1 -0
  72. package/src/server/CliAuth.test-d.ts +56 -0
  73. package/src/server/CliAuth.test.ts +800 -0
  74. package/src/server/CliAuth.ts +634 -0
  75. package/src/server/Handler.ts +123 -1
  76. package/src/server/index.ts +1 -0
@@ -0,0 +1,800 @@
1
+ import { Base64 } from 'ox'
2
+ import { KeyAuthorization } from 'ox/tempo'
3
+ import { Account as TempoAccount } from 'viem/tempo'
4
+ import { describe, expect, test } from 'vp/test'
5
+ import * as z from 'zod/mini'
6
+
7
+ import { accounts, chain, privateKeys, webAuthnAccounts } from '../../test/config.js'
8
+ import * as CliAuth from './CliAuth.js'
9
+ import * as Handler from './Handler.js'
10
+
11
+ const root = accounts[0]!
12
+ const webAuthnRoot = webAuthnAccounts[0]!
13
+ const accessKey = TempoAccount.fromP256(privateKeys[1]!)
14
+ const secpAccessKey = accounts[1]!
15
+ const expiry = Math.floor(Date.now() / 1000) + 3_600
16
+ const limits = [
17
+ {
18
+ limit: 1_000n,
19
+ token: '0x20c0000000000000000000000000000000000001' as const,
20
+ },
21
+ ] as const
22
+
23
+ async function authorize(
24
+ code: string,
25
+ options: {
26
+ accessKey?:
27
+ | {
28
+ address: `0x${string}`
29
+ keyType: 'secp256k1' | 'p256' | 'webAuthn'
30
+ }
31
+ | undefined
32
+ accessKeyAddress?: `0x${string}` | undefined
33
+ expiry?: number | undefined
34
+ limits?: readonly { token: `0x${string}`; limit: bigint }[] | undefined
35
+ } = {},
36
+ ) {
37
+ const key = options.accessKey ?? accessKey
38
+ const signed = await root.signKeyAuthorization(
39
+ {
40
+ accessKeyAddress: options.accessKeyAddress ?? key.address,
41
+ keyType: key.keyType,
42
+ },
43
+ {
44
+ chainId: BigInt(chain.id),
45
+ expiry: options.expiry ?? expiry,
46
+ limits: options.limits ?? limits,
47
+ },
48
+ )
49
+ const keyAuthorization = KeyAuthorization.toRpc(signed)
50
+
51
+ return {
52
+ accountAddress: root.address,
53
+ code,
54
+ keyAuthorization: z.decode(CliAuth.keyAuthorization, {
55
+ ...keyAuthorization,
56
+ address: keyAuthorization.keyId,
57
+ }),
58
+ } satisfies z.output<typeof CliAuth.authorizeRequest>
59
+ }
60
+
61
+ async function authorizeWebAuthn(
62
+ code: string,
63
+ options: {
64
+ accessKey?:
65
+ | {
66
+ address: `0x${string}`
67
+ keyType: 'secp256k1' | 'p256' | 'webAuthn'
68
+ }
69
+ | undefined
70
+ expiry?: number | undefined
71
+ limits?: readonly { token: `0x${string}`; limit: bigint }[] | undefined
72
+ } = {},
73
+ ) {
74
+ const key = options.accessKey ?? secpAccessKey
75
+ const signed = await webAuthnRoot.signKeyAuthorization(
76
+ {
77
+ accessKeyAddress: key.address,
78
+ keyType: key.keyType,
79
+ },
80
+ {
81
+ chainId: BigInt(chain.id),
82
+ expiry: options.expiry ?? expiry,
83
+ limits: options.limits ?? limits,
84
+ },
85
+ )
86
+ const keyAuthorization = KeyAuthorization.toRpc(signed)
87
+
88
+ return {
89
+ accountAddress: webAuthnRoot.address,
90
+ code,
91
+ keyAuthorization: z.decode(CliAuth.keyAuthorization, {
92
+ ...keyAuthorization,
93
+ address: keyAuthorization.keyId,
94
+ }),
95
+ } satisfies z.output<typeof CliAuth.authorizeRequest>
96
+ }
97
+
98
+ async function createRequest(
99
+ codeVerifier = 'device-code-verifier',
100
+ options: {
101
+ accessKey?:
102
+ | {
103
+ keyType: 'secp256k1' | 'p256' | 'webAuthn'
104
+ publicKey: `0x${string}`
105
+ }
106
+ | undefined
107
+ expiry?: number | undefined
108
+ keyType?: 'secp256k1' | 'p256' | 'webAuthn' | undefined
109
+ limits?: readonly { token: `0x${string}`; limit: bigint }[] | undefined
110
+ } = {},
111
+ ) {
112
+ const key = options.accessKey ?? accessKey
113
+ return {
114
+ codeVerifier,
115
+ request: {
116
+ codeChallenge: await createCodeChallenge(codeVerifier),
117
+ ...('expiry' in options
118
+ ? typeof options.expiry !== 'undefined'
119
+ ? { expiry: options.expiry }
120
+ : {}
121
+ : { expiry }),
122
+ ...('keyType' in options
123
+ ? options.keyType
124
+ ? { keyType: options.keyType }
125
+ : {}
126
+ : { keyType: key.keyType }),
127
+ ...('limits' in options ? (options.limits ? { limits: options.limits } : {}) : { limits }),
128
+ pubKey: key.publicKey,
129
+ } satisfies z.output<typeof CliAuth.createRequest>,
130
+ }
131
+ }
132
+
133
+ async function post<request extends z.ZodMiniType, response extends z.ZodMiniType>(
134
+ handler: Handler.Handler,
135
+ options: {
136
+ body: z.output<request>
137
+ request: request
138
+ response?: response | undefined
139
+ url: string
140
+ },
141
+ ) {
142
+ const result = await handler.fetch(
143
+ new Request(options.url, {
144
+ body: JSON.stringify(z.encode(options.request, options.body)),
145
+ headers: { 'content-type': 'application/json' },
146
+ method: 'POST',
147
+ }),
148
+ )
149
+ const json = (await result.json().catch(() => ({}))) as z.input<response>
150
+
151
+ return {
152
+ body: options.response ? z.decode(options.response, json) : json,
153
+ status: result.status,
154
+ }
155
+ }
156
+
157
+ async function get<response extends z.ZodMiniType>(
158
+ handler: Handler.Handler,
159
+ options: {
160
+ response?: response | undefined
161
+ url: string
162
+ },
163
+ ) {
164
+ const result = await handler.fetch(new Request(options.url))
165
+ const json = (await result.json().catch(() => ({}))) as z.input<response>
166
+
167
+ return {
168
+ body: options.response ? z.decode(options.response, json) : json,
169
+ status: result.status,
170
+ }
171
+ }
172
+
173
+ describe('createDeviceCode', () => {
174
+ test('default: creates a pending device code', async () => {
175
+ const store = CliAuth.Store.memory()
176
+ const now = () => 1_000
177
+ const { request } = await createRequest()
178
+
179
+ const result = await CliAuth.createDeviceCode({
180
+ chainId: chain.id,
181
+ now,
182
+ random: () => new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
183
+ request,
184
+ store,
185
+ ttlMs: 30_000,
186
+ })
187
+ const entry = await store.get(result.code)
188
+
189
+ expect(result).toMatchInlineSnapshot(`
190
+ {
191
+ "code": "ABCDEFGH",
192
+ }
193
+ `)
194
+ expect(entry).toMatchInlineSnapshot(`
195
+ {
196
+ "chainId": 1337n,
197
+ "code": "ABCDEFGH",
198
+ "codeChallenge": "NUwjc1h8PuXcsvSOG44Rp4bMayBXnOkriHEJ19CaSQM",
199
+ "createdAt": 1000,
200
+ "expiresAt": 31000,
201
+ "expiry": ${expiry},
202
+ "keyType": "p256",
203
+ "limits": [
204
+ {
205
+ "limit": 1000n,
206
+ "token": "0x20c0000000000000000000000000000000000001",
207
+ },
208
+ ],
209
+ "pubKey": "${accessKey.publicKey}",
210
+ "status": "pending",
211
+ }
212
+ `)
213
+ })
214
+
215
+ test('behavior: policy rejection returns an error from the handler', async () => {
216
+ const { request } = await createRequest()
217
+ const handler = Handler.codeAuth({
218
+ policy: {
219
+ validate() {
220
+ throw new Error('Expiry exceeds policy.')
221
+ },
222
+ },
223
+ })
224
+
225
+ const result = await post(handler, {
226
+ body: request,
227
+ request: CliAuth.createRequest,
228
+ url: 'http://localhost/auth/pkce/code',
229
+ })
230
+
231
+ expect(result).toMatchInlineSnapshot(`
232
+ {
233
+ "body": {
234
+ "error": "Expiry exceeds policy.",
235
+ },
236
+ "status": 400,
237
+ }
238
+ `)
239
+ })
240
+
241
+ test('behavior: invalid input returns 400', async () => {
242
+ const handler = Handler.codeAuth()
243
+ const response = await handler.fetch(
244
+ new Request('http://localhost/auth/pkce/code', {
245
+ body: JSON.stringify({ expiry }),
246
+ headers: { 'content-type': 'application/json' },
247
+ method: 'POST',
248
+ }),
249
+ )
250
+
251
+ const body = await response.json()
252
+
253
+ expect(body.error).toMatchInlineSnapshot(`
254
+ "[\n {\n "expected": "string",\n "code": "invalid_type",\n "path": [\n "codeChallenge"\n ],\n "message": "Invalid input"\n },\n {\n "expected": "string",\n "code": "invalid_type",\n "path": [\n "pubKey"\n ],\n "message": "Expected hex value"\n }\n]"
255
+ `)
256
+ expect(response.status).toMatchInlineSnapshot(`400`)
257
+ })
258
+
259
+ test('behavior: supports pubkey-only requests with server defaults', async () => {
260
+ const store = CliAuth.Store.memory()
261
+ const now = () => 1_000
262
+ const defaultExpiry = 4_600
263
+ const { request } = await createRequest('device-code-verifier', {
264
+ accessKey: secpAccessKey,
265
+ expiry: undefined,
266
+ keyType: undefined,
267
+ limits: undefined,
268
+ })
269
+
270
+ const result = await CliAuth.createDeviceCode({
271
+ chainId: chain.id,
272
+ now,
273
+ policy: {
274
+ validate({ expiry, limits }) {
275
+ return {
276
+ expiry: expiry ?? defaultExpiry,
277
+ ...(limits ? { limits } : {}),
278
+ }
279
+ },
280
+ },
281
+ random: () => new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
282
+ request,
283
+ store,
284
+ ttlMs: 30_000,
285
+ })
286
+ const entry = await store.get(result.code)
287
+
288
+ expect(entry).toMatchInlineSnapshot(`
289
+ {
290
+ "chainId": 1337n,
291
+ "code": "ABCDEFGH",
292
+ "codeChallenge": "NUwjc1h8PuXcsvSOG44Rp4bMayBXnOkriHEJ19CaSQM",
293
+ "createdAt": 1000,
294
+ "expiresAt": 31000,
295
+ "expiry": 4600,
296
+ "keyType": "secp256k1",
297
+ "pubKey": "${secpAccessKey.publicKey}",
298
+ "status": "pending",
299
+ }
300
+ `)
301
+ })
302
+ })
303
+
304
+ describe('pending', () => {
305
+ test('default: returns request details for a pending entry', async () => {
306
+ const store = CliAuth.Store.memory()
307
+ const { request } = await createRequest()
308
+ const { code } = await CliAuth.createDeviceCode({
309
+ chainId: chain.id,
310
+ random: () => new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
311
+ request,
312
+ store,
313
+ })
314
+
315
+ const result = await CliAuth.pending({
316
+ code,
317
+ store,
318
+ })
319
+
320
+ expect(result).toMatchInlineSnapshot(`
321
+ {
322
+ "accessKeyAddress": "${accessKey.address.toLowerCase()}",
323
+ "chainId": 1337n,
324
+ "code": "ABCDEFGH",
325
+ "expiry": ${expiry},
326
+ "keyType": "p256",
327
+ "limits": [
328
+ {
329
+ "limit": 1000n,
330
+ "token": "0x20c0000000000000000000000000000000000001",
331
+ },
332
+ ],
333
+ "pubKey": "${accessKey.publicKey}",
334
+ "status": "pending",
335
+ }
336
+ `)
337
+ })
338
+
339
+ test('behavior: handler returns 404 for an unknown code', async () => {
340
+ const handler = Handler.codeAuth()
341
+
342
+ const result = await get(handler, {
343
+ url: 'http://localhost/auth/pkce/pending/ABCDEFGH',
344
+ })
345
+
346
+ expect(result).toMatchInlineSnapshot(`
347
+ {
348
+ "body": {
349
+ "error": "Unknown device code.",
350
+ },
351
+ "status": 404,
352
+ }
353
+ `)
354
+ })
355
+
356
+ test('behavior: handler returns 400 for a completed code', async () => {
357
+ const store = CliAuth.Store.memory()
358
+ const handler = Handler.codeAuth({
359
+ chainId: chain.id,
360
+ store,
361
+ })
362
+ const { codeVerifier, request } = await createRequest()
363
+ const { code } = await CliAuth.createDeviceCode({
364
+ chainId: chain.id,
365
+ random: () => new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
366
+ request,
367
+ store,
368
+ })
369
+
370
+ await CliAuth.authorize({
371
+ chainId: chain.id,
372
+ request: await authorize(code),
373
+ store,
374
+ })
375
+ await CliAuth.poll({
376
+ code,
377
+ request: {
378
+ codeVerifier: codeVerifier,
379
+ },
380
+ store,
381
+ })
382
+
383
+ const result = await get(handler, {
384
+ url: `http://localhost/auth/pkce/pending/${code}`,
385
+ })
386
+
387
+ expect(result).toMatchInlineSnapshot(`
388
+ {
389
+ "body": {
390
+ "error": "Device code already completed.",
391
+ },
392
+ "status": 400,
393
+ }
394
+ `)
395
+ })
396
+
397
+ test('behavior: handler accepts a hyphenated code', async () => {
398
+ const store = CliAuth.Store.memory()
399
+ const handler = Handler.codeAuth({
400
+ chainId: chain.id,
401
+ store,
402
+ })
403
+ const { request } = await createRequest()
404
+ const { code } = await CliAuth.createDeviceCode({
405
+ chainId: chain.id,
406
+ random: () => new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]),
407
+ request,
408
+ store,
409
+ })
410
+
411
+ const result = await get(handler, {
412
+ response: CliAuth.pendingResponse,
413
+ url: `http://localhost/auth/pkce/pending/${code.slice(0, 4)}-${code.slice(4)}`,
414
+ })
415
+
416
+ expect(result).toMatchInlineSnapshot(`
417
+ {
418
+ "body": {
419
+ "accessKeyAddress": "${accessKey.address.toLowerCase()}",
420
+ "chainId": 1337n,
421
+ "code": "ABCDEFGH",
422
+ "expiry": ${expiry},
423
+ "keyType": "p256",
424
+ "limits": [
425
+ {
426
+ "limit": 1000n,
427
+ "token": "0x20c0000000000000000000000000000000000001",
428
+ },
429
+ ],
430
+ "pubKey": "${accessKey.publicKey}",
431
+ "status": "pending",
432
+ },
433
+ "status": 200,
434
+ }
435
+ `)
436
+ })
437
+ })
438
+
439
+ describe('poll', () => {
440
+ test('default: returns pending while awaiting authorization', async () => {
441
+ const store = CliAuth.Store.memory()
442
+ const { codeVerifier, request } = await createRequest()
443
+ const { code } = await CliAuth.createDeviceCode({
444
+ chainId: chain.id,
445
+ request,
446
+ store,
447
+ })
448
+
449
+ const result = await CliAuth.poll({
450
+ code,
451
+ request: {
452
+ codeVerifier: codeVerifier,
453
+ },
454
+ store,
455
+ })
456
+
457
+ expect(result).toMatchInlineSnapshot(`
458
+ {
459
+ "status": "pending",
460
+ }
461
+ `)
462
+ })
463
+
464
+ test('behavior: rejects a PKCE mismatch', async () => {
465
+ const handler = Handler.codeAuth({
466
+ chainId: chain.id,
467
+ store: CliAuth.Store.memory(),
468
+ })
469
+ const { request } = await createRequest()
470
+ const created = await post(handler, {
471
+ body: request,
472
+ request: CliAuth.createRequest,
473
+ response: CliAuth.createResponse,
474
+ url: 'http://localhost/auth/pkce/code',
475
+ })
476
+
477
+ const result = await post(handler, {
478
+ body: {
479
+ codeVerifier: 'wrong',
480
+ },
481
+ request: CliAuth.pollRequest,
482
+ url: `http://localhost/auth/pkce/poll/${(created.body as z.output<typeof CliAuth.createResponse>).code}`,
483
+ })
484
+
485
+ expect(result).toMatchInlineSnapshot(`
486
+ {
487
+ "body": {
488
+ "error": "Invalid code verifier.",
489
+ },
490
+ "status": 400,
491
+ }
492
+ `)
493
+ })
494
+
495
+ test('behavior: consumes an authorization exactly once', async () => {
496
+ const store = CliAuth.Store.memory()
497
+ const { codeVerifier, request } = await createRequest()
498
+ const { code } = await CliAuth.createDeviceCode({
499
+ chainId: chain.id,
500
+ request,
501
+ store,
502
+ })
503
+
504
+ await CliAuth.authorize({
505
+ chainId: chain.id,
506
+ request: await authorize(code),
507
+ store,
508
+ })
509
+
510
+ const first = await CliAuth.poll({
511
+ code,
512
+ request: {
513
+ codeVerifier: codeVerifier,
514
+ },
515
+ store,
516
+ })
517
+ const second = await CliAuth.poll({
518
+ code,
519
+ request: {
520
+ codeVerifier: codeVerifier,
521
+ },
522
+ store,
523
+ })
524
+
525
+ const first_ =
526
+ first.status === 'authorized'
527
+ ? {
528
+ ...first,
529
+ keyAuthorization: {
530
+ ...first.keyAuthorization,
531
+ signature: {
532
+ type: first.keyAuthorization.signature.type,
533
+ },
534
+ },
535
+ }
536
+ : first
537
+
538
+ expect({ first: first_, second }).toMatchInlineSnapshot(`
539
+ {
540
+ "first": {
541
+ "accountAddress": "${root.address}",
542
+ "keyAuthorization": {
543
+ "address": "${accessKey.address}",
544
+ "chainId": 1337n,
545
+ "expiry": ${expiry},
546
+ "keyId": "${accessKey.address}",
547
+ "keyType": "p256",
548
+ "limits": [
549
+ {
550
+ "limit": 1000n,
551
+ "token": "0x20c0000000000000000000000000000000000001",
552
+ },
553
+ ],
554
+ "signature": {
555
+ "type": "secp256k1",
556
+ },
557
+ },
558
+ "status": "authorized",
559
+ },
560
+ "second": {
561
+ "status": "expired",
562
+ },
563
+ }
564
+ `)
565
+ })
566
+
567
+ test('behavior: accepts a hyphenated code when polling', async () => {
568
+ const store = CliAuth.Store.memory()
569
+ const { codeVerifier, request } = await createRequest()
570
+ const { code } = await CliAuth.createDeviceCode({
571
+ chainId: chain.id,
572
+ request,
573
+ store,
574
+ })
575
+
576
+ const result = await CliAuth.poll({
577
+ code: `${code.slice(0, 4)}-${code.slice(4)}`,
578
+ request: {
579
+ codeVerifier: codeVerifier,
580
+ },
581
+ store,
582
+ })
583
+
584
+ expect(result).toMatchInlineSnapshot(`
585
+ {
586
+ "status": "pending",
587
+ }
588
+ `)
589
+ })
590
+
591
+ test('behavior: expires after TTL elapses', async () => {
592
+ let time = 10_000
593
+ const now = () => time
594
+ const store = CliAuth.Store.memory()
595
+ const { codeVerifier, request } = await createRequest()
596
+ const { code } = await CliAuth.createDeviceCode({
597
+ chainId: chain.id,
598
+ now,
599
+ request,
600
+ store,
601
+ ttlMs: 10,
602
+ })
603
+
604
+ time += 11
605
+
606
+ const result = await CliAuth.poll({
607
+ code,
608
+ now,
609
+ request: {
610
+ codeVerifier: codeVerifier,
611
+ },
612
+ store,
613
+ })
614
+
615
+ expect(result).toMatchInlineSnapshot(`
616
+ {
617
+ "status": "expired",
618
+ }
619
+ `)
620
+ })
621
+ })
622
+
623
+ describe('authorize', () => {
624
+ test('default: authorizes and returns the signed key authorization', async () => {
625
+ const store = CliAuth.Store.memory()
626
+ const { codeVerifier, request } = await createRequest()
627
+ const { code } = await CliAuth.createDeviceCode({
628
+ chainId: chain.id,
629
+ request,
630
+ store,
631
+ })
632
+
633
+ const authorized = await CliAuth.authorize({
634
+ chainId: chain.id,
635
+ request: await authorize(code),
636
+ store,
637
+ })
638
+ const polled = await CliAuth.poll({
639
+ code,
640
+ request: {
641
+ codeVerifier: codeVerifier,
642
+ },
643
+ store,
644
+ })
645
+
646
+ expect(authorized).toMatchInlineSnapshot(`
647
+ {
648
+ "status": "authorized",
649
+ }
650
+ `)
651
+ expect(polled.status).toMatchInlineSnapshot(`"authorized"`)
652
+ })
653
+
654
+ test('behavior: rejects a mismatched key authorization', async () => {
655
+ const store = CliAuth.Store.memory()
656
+ const { request } = await createRequest()
657
+ const { code } = await CliAuth.createDeviceCode({
658
+ chainId: chain.id,
659
+ request,
660
+ store,
661
+ })
662
+
663
+ await expect(
664
+ CliAuth.authorize({
665
+ chainId: chain.id,
666
+ request: await authorize(code, { expiry: expiry + 1 }),
667
+ store,
668
+ }),
669
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
670
+ `[Error: Key authorization expiry does not match the device-code request.]`,
671
+ )
672
+ })
673
+
674
+ test('behavior: accepts a hyphenated code when authorizing', async () => {
675
+ const store = CliAuth.Store.memory()
676
+ const { codeVerifier, request } = await createRequest()
677
+ const { code } = await CliAuth.createDeviceCode({
678
+ chainId: chain.id,
679
+ request,
680
+ store,
681
+ })
682
+ const displayCode = `${code.slice(0, 4)}-${code.slice(4)}`
683
+
684
+ const authorized = await CliAuth.authorize({
685
+ chainId: chain.id,
686
+ request: await authorize(displayCode),
687
+ store,
688
+ })
689
+ const polled = await CliAuth.poll({
690
+ code,
691
+ request: {
692
+ codeVerifier: codeVerifier,
693
+ },
694
+ store,
695
+ })
696
+
697
+ expect(authorized).toMatchInlineSnapshot(`
698
+ {
699
+ "status": "authorized",
700
+ }
701
+ `)
702
+ expect(polled.status).toMatchInlineSnapshot(`"authorized"`)
703
+ })
704
+
705
+ test('behavior: accepts a WebAuthn signature envelope from RPC', async () => {
706
+ const store = CliAuth.Store.memory()
707
+ const { codeVerifier, request } = await createRequest('device-code-verifier', {
708
+ accessKey: secpAccessKey,
709
+ keyType: secpAccessKey.keyType,
710
+ })
711
+ const { code } = await CliAuth.createDeviceCode({
712
+ chainId: chain.id,
713
+ request,
714
+ store,
715
+ })
716
+
717
+ const authorized = await CliAuth.authorize({
718
+ chainId: chain.id,
719
+ request: await authorizeWebAuthn(code),
720
+ store,
721
+ })
722
+ const polled = await CliAuth.poll({
723
+ code,
724
+ request: {
725
+ codeVerifier: codeVerifier,
726
+ },
727
+ store,
728
+ })
729
+
730
+ const keyAuthorization =
731
+ polled.status === 'authorized'
732
+ ? {
733
+ ...polled.keyAuthorization,
734
+ signature: {
735
+ type: polled.keyAuthorization.signature.type,
736
+ },
737
+ }
738
+ : undefined
739
+
740
+ expect({
741
+ authorized,
742
+ polled:
743
+ polled.status === 'authorized'
744
+ ? {
745
+ ...polled,
746
+ keyAuthorization: keyAuthorization,
747
+ }
748
+ : polled,
749
+ }).toMatchInlineSnapshot(`
750
+ {
751
+ "authorized": {
752
+ "status": "authorized",
753
+ },
754
+ "polled": {
755
+ "accountAddress": "${webAuthnRoot.address}",
756
+ "keyAuthorization": {
757
+ "address": "${secpAccessKey.address}",
758
+ "chainId": 1337n,
759
+ "expiry": ${expiry},
760
+ "keyId": "${secpAccessKey.address}",
761
+ "keyType": "secp256k1",
762
+ "limits": [
763
+ {
764
+ "limit": 1000n,
765
+ "token": "0x20c0000000000000000000000000000000000001",
766
+ },
767
+ ],
768
+ "signature": {
769
+ "type": "webAuthn",
770
+ },
771
+ },
772
+ "status": "authorized",
773
+ },
774
+ }
775
+ `)
776
+ })
777
+ })
778
+
779
+ describe('Store.kv', () => {
780
+ test('default: persists encoded entries through KV', async () => {
781
+ const store = CliAuth.Store.kv({
782
+ async delete() {},
783
+ async get<_value = unknown>(_key: string) {
784
+ return undefined as never
785
+ },
786
+ async set() {},
787
+ })
788
+
789
+ expect(typeof store.create).toMatchInlineSnapshot(`"function"`)
790
+ expect(typeof store.get).toMatchInlineSnapshot(`"function"`)
791
+ expect(typeof store.authorize).toMatchInlineSnapshot(`"function"`)
792
+ expect(typeof store.consume).toMatchInlineSnapshot(`"function"`)
793
+ expect(typeof store.delete).toMatchInlineSnapshot(`"function"`)
794
+ })
795
+ })
796
+
797
+ async function createCodeChallenge(codeVerifier: string) {
798
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier))
799
+ return Base64.fromBytes(new Uint8Array(hash), { pad: false, url: true })
800
+ }