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.
- package/CHANGELOG.md +13 -0
- package/README.md +38 -7
- package/dist/cli/Provider.d.ts +12 -0
- package/dist/cli/Provider.d.ts.map +1 -0
- package/dist/cli/Provider.js +19 -0
- package/dist/cli/Provider.js.map +1 -0
- package/dist/cli/adapter.d.ts +24 -0
- package/dist/cli/adapter.d.ts.map +1 -0
- package/dist/cli/adapter.js +173 -0
- package/dist/cli/adapter.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core/Dialog.d.ts.map +1 -1
- package/dist/core/Dialog.js +25 -1
- package/dist/core/Dialog.js.map +1 -1
- package/dist/core/IntersectionObserver.d.ts +3 -0
- package/dist/core/IntersectionObserver.d.ts.map +1 -0
- package/dist/core/IntersectionObserver.js +6 -0
- package/dist/core/IntersectionObserver.js.map +1 -0
- package/dist/core/Messenger.d.ts +14 -3
- package/dist/core/Messenger.d.ts.map +1 -1
- package/dist/core/Messenger.js +4 -4
- package/dist/core/Messenger.js.map +1 -1
- package/dist/core/Remote.d.ts +6 -3
- package/dist/core/Remote.d.ts.map +1 -1
- package/dist/core/Remote.js +3 -6
- package/dist/core/Remote.js.map +1 -1
- package/dist/core/adapters/local.d.ts.map +1 -1
- package/dist/core/adapters/local.js +2 -2
- package/dist/core/adapters/local.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/react/Remote.d.ts +21 -0
- package/dist/react/Remote.d.ts.map +1 -0
- package/dist/react/Remote.js +51 -0
- package/dist/react/Remote.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -0
- package/dist/server/CliAuth.d.ts +553 -0
- package/dist/server/CliAuth.d.ts.map +1 -0
- package/dist/server/CliAuth.js +446 -0
- package/dist/server/CliAuth.js.map +1 -0
- package/dist/server/Handler.d.ts +36 -2
- package/dist/server/Handler.d.ts.map +1 -1
- package/dist/server/Handler.js +84 -0
- package/dist/server/Handler.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/package.json +16 -54
- package/src/cli/Provider.test-d.ts +28 -0
- package/src/cli/Provider.test.ts +235 -0
- package/src/cli/Provider.ts +26 -0
- package/src/cli/adapter.ts +229 -0
- package/src/cli/index.ts +2 -0
- package/src/core/Dialog.ts +31 -1
- package/src/core/IntersectionObserver.ts +6 -0
- package/src/core/Messenger.ts +18 -8
- package/src/core/Provider.test.ts +12 -2
- package/src/core/Remote.ts +9 -10
- package/src/core/adapters/local.ts +7 -2
- package/src/index.ts +1 -0
- package/src/react/Remote.ts +94 -0
- package/src/react/index.ts +1 -0
- package/src/server/CliAuth.test-d.ts +56 -0
- package/src/server/CliAuth.test.ts +800 -0
- package/src/server/CliAuth.ts +634 -0
- package/src/server/Handler.ts +123 -1
- 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
|
+
}
|