accounts 0.16.0 → 0.16.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 (40) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/core/Adapter.d.ts +2 -2
  3. package/dist/core/Adapter.d.ts.map +1 -1
  4. package/dist/core/Provider.d.ts.map +1 -1
  5. package/dist/core/Provider.js +9 -2
  6. package/dist/core/Provider.js.map +1 -1
  7. package/dist/core/Schema.d.ts +2 -2
  8. package/dist/core/WebAuthnCeremony.d.ts +2 -4
  9. package/dist/core/WebAuthnCeremony.d.ts.map +1 -1
  10. package/dist/core/WebAuthnCeremony.js +8 -4
  11. package/dist/core/WebAuthnCeremony.js.map +1 -1
  12. package/dist/core/adapters/webAuthn.js +1 -1
  13. package/dist/core/adapters/webAuthn.js.map +1 -1
  14. package/dist/core/zod/rpc.d.ts +4 -4
  15. package/dist/core/zod/rpc.js +2 -2
  16. package/dist/core/zod/rpc.js.map +1 -1
  17. package/dist/server/internal/handlers/utils.d.ts.map +1 -1
  18. package/dist/server/internal/handlers/utils.js +10 -0
  19. package/dist/server/internal/handlers/utils.js.map +1 -1
  20. package/dist/server/internal/handlers/webAuthn.d.ts +7 -1
  21. package/dist/server/internal/handlers/webAuthn.d.ts.map +1 -1
  22. package/dist/server/internal/handlers/webAuthn.js +21 -12
  23. package/dist/server/internal/handlers/webAuthn.js.map +1 -1
  24. package/package.json +3 -3
  25. package/src/core/Adapter.ts +2 -2
  26. package/src/core/Provider.browser.test.ts +30 -0
  27. package/src/core/Provider.localnet.test.ts +14 -0
  28. package/src/core/Provider.test.ts +32 -0
  29. package/src/core/Provider.ts +10 -2
  30. package/src/core/Schema.test-d.ts +1 -1
  31. package/src/core/WebAuthnCeremony.test.ts +21 -0
  32. package/src/core/WebAuthnCeremony.ts +11 -8
  33. package/src/core/adapters/webAuthn.ts +1 -1
  34. package/src/core/mppx.localnet.test.ts +53 -2
  35. package/src/core/zod/rpc.test.ts +19 -0
  36. package/src/core/zod/rpc.ts +2 -2
  37. package/src/server/internal/handlers/utils.test.ts +53 -0
  38. package/src/server/internal/handlers/utils.ts +16 -0
  39. package/src/server/internal/handlers/webAuthn.test.ts +76 -0
  40. package/src/server/internal/handlers/webAuthn.ts +28 -14
@@ -54,12 +54,10 @@ export declare namespace verifyRegistration {
54
54
 
55
55
  export declare namespace getAuthenticationOptions {
56
56
  type Parameters = {
57
- /** Credential IDs to allow (restricts which credentials can be used). */
58
- allowCredentialIds?: readonly string[] | undefined
59
57
  /** Challenge to use. */
60
58
  challenge?: `0x${string}` | undefined
61
- /** Credential ID to restrict authentication to a specific credential. */
62
- credentialId?: string | undefined
59
+ /** Credential ID or IDs to restrict authentication to. */
60
+ credentialId?: string | readonly string[] | undefined
63
61
  /** Mediation hint for passkey autofill / conditional UI. */
64
62
  mediation?: 'conditional' | 'optional' | 'required' | 'silent' | undefined
65
63
  }
@@ -124,10 +122,15 @@ export function local(options: local.Options = {}): WebAuthnCeremony {
124
122
  },
125
123
 
126
124
  async getAuthenticationOptions(parameters = {}) {
127
- const { allowCredentialIds, challenge, credentialId } = parameters
125
+ const { challenge, credentialId } = parameters
128
126
  const { options } = Authentication.getOptions({
129
127
  challenge,
130
- credentialId: (allowCredentialIds as string[] | undefined) ?? credentialId,
128
+ credentialId:
129
+ typeof credentialId === 'string'
130
+ ? credentialId
131
+ : credentialId
132
+ ? [...credentialId]
133
+ : undefined,
131
134
  rpId,
132
135
  })
133
136
  return { options }
@@ -189,8 +192,8 @@ export function server(options: server.Options): WebAuthnCeremony {
189
192
  },
190
193
 
191
194
  async getAuthenticationOptions(parameters = {}) {
192
- const { allowCredentialIds, challenge, credentialId, mediation } = parameters
193
- return request('/login/options', { allowCredentialIds, challenge, credentialId, mediation })
195
+ const { challenge, credentialId, mediation } = parameters
196
+ return request('/login/options', { challenge, credentialId, mediation })
194
197
  },
195
198
 
196
199
  async verifyAuthentication(response) {
@@ -77,7 +77,7 @@ export function webAuthn(options: webAuthn.Options = {}): Adapter.Adapter {
77
77
  }
78
78
  },
79
79
  async loadAccounts(parameters = {}) {
80
- const { selectAccount, digest } = parameters
80
+ const { digest, selectAccount } = parameters
81
81
 
82
82
  const credentialId = selectAccount
83
83
  ? undefined
@@ -25,6 +25,19 @@ const payment = ServerMppx.create({
25
25
  secretKey: 'test-secret-key-test-secret-key-32',
26
26
  })
27
27
 
28
+ const sponsoredPayment = ServerMppx.create({
29
+ methods: [
30
+ tempo({
31
+ account: accounts[1]!,
32
+ currency: Addresses.pathUsd,
33
+ feePayer: accounts[0]!,
34
+ getClient: () => client,
35
+ }),
36
+ ],
37
+ realm: 'mppx-sponsored-test',
38
+ secretKey: 'sponsored-test-secret-key-32-bytes',
39
+ })
40
+
28
41
  let server: Server
29
42
 
30
43
  beforeAll(async () => {
@@ -90,6 +103,44 @@ describe('mppx integration', () => {
90
103
  expect(res.status).toMatchInlineSnapshot(`200`)
91
104
  })
92
105
 
106
+ test('pull mode preserves server-sponsored transactions', async () => {
107
+ const sponsoredServer = await createServer(async (req, res) => {
108
+ const result = await ServerMppx.toNodeListener(sponsoredPayment.charge({ amount: '1' }))(
109
+ req,
110
+ res,
111
+ )
112
+ if (result.status === 402) return
113
+ res.writeHead(200)
114
+ res.end()
115
+ })
116
+
117
+ try {
118
+ const provider = Provider.create({
119
+ adapter: headlessWebAuthn(),
120
+ chains: [chain],
121
+ mpp: false,
122
+ })
123
+ const address = await connect(provider)
124
+ await fund(address, '1')
125
+
126
+ const mppx = ClientMppx.create({
127
+ methods: [
128
+ clientTempo({
129
+ account: provider.getAccount(),
130
+ ...provider.getMppxParameters(),
131
+ mode: 'pull',
132
+ }),
133
+ ],
134
+ polyfill: false,
135
+ })
136
+
137
+ const res = await mppx.fetch(`${sponsoredServer.url}/fortune`)
138
+ expect(res.status).toMatchInlineSnapshot(`200`)
139
+ } finally {
140
+ await sponsoredServer.closeAsync()
141
+ }
142
+ })
143
+
93
144
  test('mppx global front door uses provider access keys', async () => {
94
145
  const provider = Provider.create({
95
146
  adapter: headlessWebAuthn(),
@@ -267,12 +318,12 @@ async function connect(provider: ReturnType<typeof Provider.create>) {
267
318
  return register.accounts[0]!.address
268
319
  }
269
320
 
270
- async function fund(address: `0x${string}`) {
321
+ async function fund(address: `0x${string}`, amount = '10') {
271
322
  await Actions.token.transferSync(client, {
272
323
  account: accounts[0]!,
273
324
  feeToken: Addresses.pathUsd,
274
325
  to: address,
275
326
  token: Addresses.pathUsd,
276
- amount: parseUnits('10', 6),
327
+ amount: parseUnits(amount, 6),
277
328
  })
278
329
  }
@@ -132,6 +132,25 @@ describe('transactionRequest.keyAuthorization', () => {
132
132
  })
133
133
  })
134
134
 
135
+ describe('wallet_connect.capabilities.request: credentials', () => {
136
+ test('accepts multiple credential IDs for login', () => {
137
+ expect(
138
+ z.parse(Rpc.wallet_connect.capabilities.request, {
139
+ credentialId: ['cred-1', 'cred-2'],
140
+ method: 'login',
141
+ }),
142
+ ).toMatchInlineSnapshot(`
143
+ {
144
+ "credentialId": [
145
+ "cred-1",
146
+ "cred-2",
147
+ ],
148
+ "method": "login",
149
+ }
150
+ `)
151
+ })
152
+ })
153
+
135
154
  describe('wallet_connect.capabilities.request: auth', () => {
136
155
  test('accepts string shorthand', () => {
137
156
  expect(
@@ -710,7 +710,7 @@ export namespace wallet_connect {
710
710
  }),
711
711
  z.object({
712
712
  digest: z.optional(u.hex()),
713
- credentialId: z.optional(z.string()),
713
+ credentialId: z.optional(z.union([z.string(), z.array(z.string())])),
714
714
  authorizeAccessKey,
715
715
  auth,
716
716
  identity,
@@ -829,7 +829,7 @@ export namespace wallet_connect_strict {
829
829
  }),
830
830
  z.object({
831
831
  digest: z.optional(u.hex()),
832
- credentialId: z.optional(z.string()),
832
+ credentialId: z.optional(z.union([z.string(), z.array(z.string())])),
833
833
  authorizeAccessKey,
834
834
  auth,
835
835
  identity,
@@ -0,0 +1,53 @@
1
+ import { RpcRequest } from 'ox'
2
+ import { describe, expect, test } from 'vp/test'
3
+
4
+ import * as Utils from './utils.js'
5
+
6
+ const request = RpcRequest.from({ id: 1, method: 'eth_sendRawTransaction', params: ['0x00'] })
7
+
8
+ describe('rpcErrorJson', () => {
9
+ test('maps upstream expired transactions to a transaction-rejected error', () => {
10
+ const error = {
11
+ cause: {
12
+ code: -32603,
13
+ message: 'Revm error: transaction expired',
14
+ },
15
+ }
16
+
17
+ expect(Utils.rpcErrorJson(request, error)).toMatchInlineSnapshot(`
18
+ {
19
+ "error": {
20
+ "code": -32003,
21
+ "data": {
22
+ "code": "transaction_expired",
23
+ },
24
+ "message": "Transaction expired.",
25
+ },
26
+ "id": 1,
27
+ "jsonrpc": "2.0",
28
+ }
29
+ `)
30
+ })
31
+
32
+ test('preserves unrelated upstream internal errors', () => {
33
+ const error = {
34
+ code: -32603,
35
+ data: { reason: 'database unavailable' },
36
+ message: 'Revm error: database unavailable',
37
+ }
38
+
39
+ expect(Utils.rpcErrorJson(request, error)).toMatchInlineSnapshot(`
40
+ {
41
+ "error": {
42
+ "code": -32603,
43
+ "data": {
44
+ "reason": "database unavailable",
45
+ },
46
+ "message": "Revm error: database unavailable",
47
+ },
48
+ "id": 1,
49
+ "jsonrpc": "2.0",
50
+ }
51
+ `)
52
+ })
53
+ })
@@ -97,6 +97,22 @@ export function rpcErrorJson(request: RpcRequest.RpcRequest, error: unknown) {
97
97
  )
98
98
 
99
99
  const inner = resolveError(error)
100
+ if (
101
+ inner.code === RpcResponse.InternalError.code &&
102
+ inner.message !== undefined &&
103
+ /^Revm error: transaction expired\.?$/.test(inner.message)
104
+ )
105
+ return RpcResponse.from(
106
+ {
107
+ error: {
108
+ code: -32003,
109
+ data: { code: 'transaction_expired' },
110
+ message: 'Transaction expired.',
111
+ },
112
+ },
113
+ { request },
114
+ )
115
+
100
116
  const message = inner.message ?? (error as Error).message
101
117
  const code = inner.code ?? -32603
102
118
  const data = inner.data
@@ -1,10 +1,14 @@
1
+ import { vi } from 'vitest'
1
2
  import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vp/test'
3
+ import { Registration } from 'webauthx/server'
2
4
 
3
5
  import { createServer, type Server } from '../../../../test/utils.js'
4
6
  import * as WebAuthnCeremony from '../../../core/WebAuthnCeremony.js'
5
7
  import * as Kv from '../../Kv.js'
6
8
  import { type SessionPayload, webAuthn } from './webAuthn.js'
7
9
 
10
+ vi.mock('webauthx/server', { spy: true })
11
+
8
12
  let server: Server
9
13
  let ceremony: WebAuthnCeremony.WebAuthnCeremony
10
14
 
@@ -52,6 +56,25 @@ describe('POST /login/options', () => {
52
56
  const { options: b } = await ceremony.getAuthenticationOptions()
53
57
  expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge)
54
58
  })
59
+
60
+ test('behavior: restricts authentication to multiple credentials', async () => {
61
+ const { options } = await ceremony.getAuthenticationOptions({
62
+ credentialId: ['Y3JlZC0x', 'Y3JlZC0y'],
63
+ })
64
+
65
+ expect(options.publicKey?.allowCredentials).toMatchInlineSnapshot(`
66
+ [
67
+ {
68
+ "id": "Y3JlZC0x",
69
+ "type": "public-key",
70
+ },
71
+ {
72
+ "id": "Y3JlZC0y",
73
+ "type": "public-key",
74
+ },
75
+ ]
76
+ `)
77
+ })
55
78
  })
56
79
 
57
80
  describe('POST /register', () => {
@@ -152,6 +175,59 @@ describe('hooks', () => {
152
175
  await hookServer.closeAsync()
153
176
  })
154
177
 
178
+ test('behavior: onRegister rejection removes the credential', async () => {
179
+ const kv = Kv.memory()
180
+ await kv.set('challenge:0x01', { created: Date.now(), name: 'Test' })
181
+ const publicKey =
182
+ '0x04ab891400140fc4f8e941ce0ff90e419de9470acaca613bbd717a4775435031a7d884318e919fd3b3e5a631d866d8a380b44063e70f0c381ee16e0652f7f97554'
183
+ vi.mocked(Registration.verify).mockReturnValueOnce({
184
+ aaguid: 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd',
185
+ credential: { publicKey },
186
+ } as never)
187
+ const hookServer = await createServer(
188
+ webAuthn({
189
+ kv,
190
+ origin: 'http://localhost',
191
+ rpId: 'localhost',
192
+ onRegister() {
193
+ throw new Error('Authenticator not allowed')
194
+ },
195
+ }).listener,
196
+ )
197
+
198
+ try {
199
+ const clientDataJSON = 'eyJjaGFsbGVuZ2UiOiJBUSJ9'
200
+ const response = await fetch(`${hookServer.url}/register`, {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({
204
+ attestationObject: 'AA',
205
+ clientDataJSON,
206
+ id: 'credential',
207
+ publicKey,
208
+ raw: {
209
+ authenticatorAttachment: null,
210
+ id: 'credential',
211
+ rawId: 'Y3JlZGVudGlhbA',
212
+ response: { clientDataJSON },
213
+ type: 'public-key',
214
+ },
215
+ }),
216
+ })
217
+
218
+ expect(response.status).toMatchInlineSnapshot(`400`)
219
+ expect(await response.json()).toMatchInlineSnapshot(`
220
+ {
221
+ "error": "Authenticator not allowed",
222
+ }
223
+ `)
224
+ expect(await kv.get('credential:credential')).toBeUndefined()
225
+ expect(await kv.get('challenge:0x01')).toBeUndefined()
226
+ } finally {
227
+ await hookServer.closeAsync()
228
+ }
229
+ })
230
+
155
231
  test('behavior: onAuthenticate error does not call hook', async () => {
156
232
  let called = false
157
233
  const hookServer = await createServer(
@@ -152,6 +152,7 @@ export function webAuthn(options: webAuthn.Options): webAuthn.ReturnType {
152
152
  rpId,
153
153
  })
154
154
 
155
+ const { aaguid } = result
155
156
  const { publicKey } = result.credential
156
157
  const credentialId = credential.id
157
158
  // Base64url-encode the userId we registered with so it matches
@@ -160,22 +161,29 @@ export function webAuthn(options: webAuthn.Options): webAuthn.ReturnType {
160
161
  const userId = stored.userId
161
162
  ? Base64.fromBytes(Bytes.fromString(stored.userId), { pad: false, url: true })
162
163
  : undefined
163
- const created = await createCredential(kv, `credential:${credentialId}`, {
164
+ const key = `credential:${credentialId}`
165
+ const created = await createCredential(kv, key, {
164
166
  publicKey,
165
167
  ...(userId ? { userId } : {}),
166
168
  })
167
169
  if (!created) throw new Error('Credential already exists')
168
170
 
169
- const [hook] = await Promise.all([
170
- onRegister?.({
171
- credentialId,
172
- name: stored.name,
173
- publicKey,
174
- request: c.req.raw,
175
- ...(userId ? { userId } : {}),
176
- }),
177
- kv.delete(`challenge:${challenge}`),
178
- ])
171
+ const hook = await (async () => {
172
+ try {
173
+ return await onRegister?.({
174
+ ...(aaguid ? { aaguid } : {}),
175
+ credentialId,
176
+ name: stored.name,
177
+ publicKey,
178
+ request: c.req.raw,
179
+ ...(userId ? { userId } : {}),
180
+ })
181
+ } catch (error) {
182
+ await Promise.all([kv.delete(key), kv.delete(`challenge:${challenge}`)])
183
+ throw error
184
+ }
185
+ })()
186
+ await kv.delete(`challenge:${challenge}`)
179
187
 
180
188
  // Successful registration is also a successful authentication for
181
189
  // the freshly-minted credential. Issue a session here so the
@@ -231,13 +239,13 @@ export function webAuthn(options: webAuthn.Options): webAuthn.ReturnType {
231
239
  } = body as {
232
240
  allowCredentialIds?: string[]
233
241
  challenge?: Hex.Hex
234
- credentialId?: string
242
+ credentialId?: string | string[]
235
243
  mediation?: string
236
244
  }
237
245
 
238
246
  const { challenge, options: authOptions } = Authentication.getOptions({
239
247
  challenge: requestChallenge,
240
- credentialId: allowCredentialIds ?? credentialId,
248
+ credentialId: credentialId ?? allowCredentialIds,
241
249
  rpId,
242
250
  })
243
251
  const options = mediation ? { ...authOptions, mediation } : authOptions
@@ -414,8 +422,14 @@ export declare namespace webAuthn {
414
422
  * best-effort `get` then `set` storage.
415
423
  */
416
424
  kv: Kv.Kv
417
- /** Called after a successful registration. The returned response is merged onto the default JSON response. */
425
+ /**
426
+ * Called after a successful registration. The returned response is merged
427
+ * onto the default JSON response. Throwing rejects the request and removes
428
+ * the newly stored credential.
429
+ */
418
430
  onRegister?: (parameters: {
431
+ /** Authenticator model identifier, when the authenticator reports one. */
432
+ aaguid?: string | undefined
419
433
  credentialId: string
420
434
  /** The name provided during `/register/options` (e.g. user email). */
421
435
  name: string