accounts 0.3.0 → 0.4.1
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/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/core/AccessKey.d.ts +55 -0
- package/dist/core/AccessKey.d.ts.map +1 -0
- package/dist/core/AccessKey.js +69 -0
- package/dist/core/AccessKey.js.map +1 -0
- package/dist/core/Account.d.ts +91 -0
- package/dist/core/Account.d.ts.map +1 -0
- package/dist/core/Account.js +64 -0
- package/dist/core/Account.js.map +1 -0
- package/dist/core/Adapter.d.ts +187 -0
- package/dist/core/Adapter.d.ts.map +1 -0
- package/dist/core/Adapter.js +7 -0
- package/dist/core/Adapter.js.map +1 -0
- package/dist/core/Ceremony.d.ts +109 -0
- package/dist/core/Ceremony.d.ts.map +1 -0
- package/dist/core/Ceremony.js +104 -0
- package/dist/core/Ceremony.js.map +1 -0
- package/dist/core/Client.d.ts +16 -0
- package/dist/core/Client.d.ts.map +1 -0
- package/dist/core/Client.js +18 -0
- package/dist/core/Client.js.map +1 -0
- package/dist/core/Dialog.d.ts +52 -0
- package/dist/core/Dialog.d.ts.map +1 -0
- package/dist/core/Dialog.js +342 -0
- package/dist/core/Dialog.js.map +1 -0
- package/dist/core/Expiry.d.ts +15 -0
- package/dist/core/Expiry.d.ts.map +1 -0
- package/dist/core/Expiry.js +29 -0
- package/dist/core/Expiry.js.map +1 -0
- package/dist/core/Messenger.d.ts +86 -0
- package/dist/core/Messenger.d.ts.map +1 -0
- package/dist/core/Messenger.js +127 -0
- package/dist/core/Messenger.js.map +1 -0
- package/dist/core/Provider.d.ts +69 -0
- package/dist/core/Provider.d.ts.map +1 -0
- package/dist/core/Provider.js +401 -0
- package/dist/core/Provider.js.map +1 -0
- package/dist/core/Remote.d.ts +114 -0
- package/dist/core/Remote.d.ts.map +1 -0
- package/dist/core/Remote.js +116 -0
- package/dist/core/Remote.js.map +1 -0
- package/dist/core/Schema.d.ts +805 -0
- package/dist/core/Schema.d.ts.map +1 -0
- package/dist/core/Schema.js +43 -0
- package/dist/core/Schema.js.map +1 -0
- package/dist/core/Storage.d.ts +42 -0
- package/dist/core/Storage.d.ts.map +1 -0
- package/dist/core/Storage.js +173 -0
- package/dist/core/Storage.js.map +1 -0
- package/dist/core/Store.d.ts +58 -0
- package/dist/core/Store.d.ts.map +1 -0
- package/dist/core/Store.js +58 -0
- package/dist/core/Store.js.map +1 -0
- package/dist/core/adapters/dangerous_secp256k1.d.ts +30 -0
- package/dist/core/adapters/dangerous_secp256k1.d.ts.map +1 -0
- package/dist/core/adapters/dangerous_secp256k1.js +39 -0
- package/dist/core/adapters/dangerous_secp256k1.js.map +1 -0
- package/dist/core/adapters/dialog.d.ts +31 -0
- package/dist/core/adapters/dialog.d.ts.map +1 -0
- package/dist/core/adapters/dialog.js +306 -0
- package/dist/core/adapters/dialog.js.map +1 -0
- package/dist/core/adapters/local.d.ts +33 -0
- package/dist/core/adapters/local.d.ts.map +1 -0
- package/dist/core/adapters/local.js +227 -0
- package/dist/core/adapters/local.js.map +1 -0
- package/dist/core/adapters/webAuthn.d.ts +36 -0
- package/dist/core/adapters/webAuthn.d.ts.map +1 -0
- package/dist/core/adapters/webAuthn.js +93 -0
- package/dist/core/adapters/webAuthn.js.map +1 -0
- package/dist/core/internal/withDedupe.d.ts +12 -0
- package/dist/core/internal/withDedupe.d.ts.map +1 -0
- package/dist/core/internal/withDedupe.js +12 -0
- package/dist/core/internal/withDedupe.js.map +1 -0
- package/dist/core/zod/request.d.ts +31 -0
- package/dist/core/zod/request.d.ts.map +1 -0
- package/dist/core/zod/request.js +41 -0
- package/dist/core/zod/request.js.map +1 -0
- package/dist/core/zod/rpc.d.ts +603 -0
- package/dist/core/zod/rpc.d.ts.map +1 -0
- package/dist/core/zod/rpc.js +293 -0
- package/dist/core/zod/rpc.js.map +1 -0
- package/dist/core/zod/utils.d.ts +18 -0
- package/dist/core/zod/utils.d.ts.map +1 -0
- package/dist/core/zod/utils.js +21 -0
- package/dist/core/zod/utils.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/types.d.ts +284 -0
- package/dist/internal/types.d.ts.map +1 -0
- package/dist/internal/types.js +2 -0
- package/dist/internal/types.js.map +1 -0
- package/dist/server/Handler.d.ts +257 -0
- package/dist/server/Handler.d.ts.map +1 -0
- package/dist/server/Handler.js +433 -0
- package/dist/server/Handler.js.map +1 -0
- package/dist/server/Kv.d.ts +16 -0
- package/dist/server/Kv.d.ts.map +1 -0
- package/dist/server/Kv.js +30 -0
- package/dist/server/Kv.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +3 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/internal/requestListener.d.ts +124 -0
- package/dist/server/internal/requestListener.d.ts.map +1 -0
- package/dist/server/internal/requestListener.js +173 -0
- package/dist/server/internal/requestListener.js.map +1 -0
- package/dist/wagmi/Connector.d.ts +93 -0
- package/dist/wagmi/Connector.d.ts.map +1 -0
- package/dist/wagmi/Connector.js +238 -0
- package/dist/wagmi/Connector.js.map +1 -0
- package/dist/wagmi/index.d.ts +3 -0
- package/dist/wagmi/index.d.ts.map +1 -0
- package/dist/wagmi/index.js +3 -0
- package/dist/wagmi/index.js.map +1 -0
- package/package.json +56 -2
- package/src/core/AccessKey.test.ts +257 -0
- package/src/core/AccessKey.ts +123 -0
- package/src/core/Account.test.ts +309 -0
- package/src/core/Account.ts +152 -0
- package/src/core/Adapter.ts +238 -0
- package/src/core/Ceremony.browser.test.ts +239 -0
- package/src/core/Ceremony.test.ts +151 -0
- package/src/core/Ceremony.ts +203 -0
- package/src/core/Client.ts +36 -0
- package/src/core/Dialog.browser.test.ts +309 -0
- package/src/core/Dialog.test-d.ts +19 -0
- package/src/core/Dialog.ts +442 -0
- package/src/core/Expiry.ts +34 -0
- package/src/core/Messenger.ts +206 -0
- package/src/core/Provider.browser.test.ts +774 -0
- package/src/core/Provider.connect.browser.test.ts +415 -0
- package/src/core/Provider.test-d.ts +53 -0
- package/src/core/Provider.test.ts +1566 -0
- package/src/core/Provider.ts +559 -0
- package/src/core/Remote.ts +262 -0
- package/src/core/Schema.test-d.ts +211 -0
- package/src/core/Schema.ts +143 -0
- package/src/core/Storage.ts +213 -0
- package/src/core/Store.test.ts +287 -0
- package/src/core/Store.ts +129 -0
- package/src/core/adapters/dangerous_secp256k1.ts +53 -0
- package/src/core/adapters/dialog.ts +379 -0
- package/src/core/adapters/local.test.ts +97 -0
- package/src/core/adapters/local.ts +277 -0
- package/src/core/adapters/webAuthn.ts +129 -0
- package/src/core/internal/withDedupe.test.ts +116 -0
- package/src/core/internal/withDedupe.ts +20 -0
- package/src/core/mppx.test.ts +83 -0
- package/src/core/zod/request.test.ts +121 -0
- package/src/core/zod/request.ts +70 -0
- package/src/core/zod/rpc.ts +374 -0
- package/src/core/zod/utils.test.ts +69 -0
- package/src/core/zod/utils.ts +40 -0
- package/src/index.ts +14 -0
- package/src/internal/types.ts +378 -0
- package/src/server/Handler.test.ts +1014 -0
- package/src/server/Handler.ts +605 -0
- package/src/server/Kv.ts +46 -0
- package/src/server/index.ts +2 -0
- package/src/server/internal/requestListener.ts +273 -0
- package/src/tsconfig.json +9 -0
- package/src/wagmi/Connector.ts +287 -0
- package/src/wagmi/index.ts +2 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vp/test'
|
|
2
|
+
|
|
3
|
+
import * as Ceremony from './Ceremony.js'
|
|
4
|
+
|
|
5
|
+
describe('from', () => {
|
|
6
|
+
test('default: returns the ceremony', () => {
|
|
7
|
+
const ceremony = Ceremony.from({
|
|
8
|
+
getRegistrationOptions: async () => ({ options: {} as any }),
|
|
9
|
+
verifyRegistration: async () => ({ credentialId: 'cred-1', publicKey: '0x1234' }),
|
|
10
|
+
getAuthenticationOptions: async () => ({ options: {} as any }),
|
|
11
|
+
verifyAuthentication: async () => ({ credentialId: 'cred-1', publicKey: '0x1234' }),
|
|
12
|
+
})
|
|
13
|
+
expect(ceremony).toBeDefined()
|
|
14
|
+
expect(typeof ceremony.getRegistrationOptions).toMatchInlineSnapshot(`"function"`)
|
|
15
|
+
expect(typeof ceremony.verifyRegistration).toMatchInlineSnapshot(`"function"`)
|
|
16
|
+
expect(typeof ceremony.getAuthenticationOptions).toMatchInlineSnapshot(`"function"`)
|
|
17
|
+
expect(typeof ceremony.verifyAuthentication).toMatchInlineSnapshot(`"function"`)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('local', () => {
|
|
22
|
+
test('default: creates a ceremony', () => {
|
|
23
|
+
const ceremony = Ceremony.local({ rpId: 'example.com' })
|
|
24
|
+
expect(ceremony).toBeDefined()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('behavior: getRegistrationOptions returns serialized options', async () => {
|
|
28
|
+
const ceremony = Ceremony.local({ rpId: 'example.com' })
|
|
29
|
+
|
|
30
|
+
const { options } = await ceremony.getRegistrationOptions({ name: 'Test' })
|
|
31
|
+
expect(options.publicKey).toBeDefined()
|
|
32
|
+
expect(options.publicKey!.rp.id).toMatchInlineSnapshot(`"example.com"`)
|
|
33
|
+
expect(options.publicKey!.rp.name).toMatchInlineSnapshot(`"example.com"`)
|
|
34
|
+
expect(typeof options.publicKey!.challenge).toMatchInlineSnapshot(`"string"`)
|
|
35
|
+
expect(options.publicKey!.authenticatorSelection).toBeDefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('behavior: getAuthenticationOptions returns serialized options', async () => {
|
|
39
|
+
const ceremony = Ceremony.local({ rpId: 'example.com' })
|
|
40
|
+
|
|
41
|
+
const { options } = await ceremony.getAuthenticationOptions()
|
|
42
|
+
expect(options.publicKey).toBeDefined()
|
|
43
|
+
expect(options.publicKey!.rpId).toMatchInlineSnapshot(`"example.com"`)
|
|
44
|
+
expect(typeof options.publicKey!.challenge).toMatchInlineSnapshot(`"string"`)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('behavior: verifyRegistration stores credential and returns publicKey', async () => {
|
|
48
|
+
const ceremony = Ceremony.local({ rpId: 'example.com' })
|
|
49
|
+
|
|
50
|
+
const result = await ceremony.verifyRegistration({
|
|
51
|
+
attestationObject: 'mock',
|
|
52
|
+
clientDataJSON: 'mock',
|
|
53
|
+
id: 'cred-1',
|
|
54
|
+
publicKey: '0xabcd',
|
|
55
|
+
raw: {
|
|
56
|
+
id: 'cred-1',
|
|
57
|
+
type: 'public-key',
|
|
58
|
+
authenticatorAttachment: null,
|
|
59
|
+
rawId: 'cred-1',
|
|
60
|
+
response: { clientDataJSON: 'mock' },
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
expect(result).toMatchInlineSnapshot(`
|
|
65
|
+
{
|
|
66
|
+
"credentialId": "cred-1",
|
|
67
|
+
"publicKey": "0xabcd",
|
|
68
|
+
}
|
|
69
|
+
`)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('behavior: verifyAuthentication returns stored publicKey', async () => {
|
|
73
|
+
const ceremony = Ceremony.local({ rpId: 'example.com' })
|
|
74
|
+
|
|
75
|
+
// Register first to store the credential
|
|
76
|
+
await ceremony.verifyRegistration({
|
|
77
|
+
attestationObject: 'mock',
|
|
78
|
+
clientDataJSON: 'mock',
|
|
79
|
+
id: 'cred-1',
|
|
80
|
+
publicKey: '0xabcd',
|
|
81
|
+
raw: {
|
|
82
|
+
id: 'cred-1',
|
|
83
|
+
type: 'public-key',
|
|
84
|
+
authenticatorAttachment: null,
|
|
85
|
+
rawId: 'cred-1',
|
|
86
|
+
response: { clientDataJSON: 'mock' },
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const result = await ceremony.verifyAuthentication({
|
|
91
|
+
id: 'cred-1',
|
|
92
|
+
metadata: {
|
|
93
|
+
authenticatorData: '0x00',
|
|
94
|
+
clientDataJSON: '{}',
|
|
95
|
+
},
|
|
96
|
+
raw: {
|
|
97
|
+
id: 'cred-1',
|
|
98
|
+
type: 'public-key',
|
|
99
|
+
authenticatorAttachment: null,
|
|
100
|
+
rawId: 'cred-1',
|
|
101
|
+
response: { clientDataJSON: 'mock' },
|
|
102
|
+
},
|
|
103
|
+
signature: '0x00',
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(result).toMatchInlineSnapshot(`
|
|
107
|
+
{
|
|
108
|
+
"credentialId": "cred-1",
|
|
109
|
+
"publicKey": "0xabcd",
|
|
110
|
+
}
|
|
111
|
+
`)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('error: verifyAuthentication throws for unknown credential', async () => {
|
|
115
|
+
const ceremony = Ceremony.local({ rpId: 'example.com' })
|
|
116
|
+
|
|
117
|
+
await expect(
|
|
118
|
+
ceremony.verifyAuthentication({
|
|
119
|
+
id: 'unknown',
|
|
120
|
+
metadata: {
|
|
121
|
+
authenticatorData: '0x00',
|
|
122
|
+
clientDataJSON: '{}',
|
|
123
|
+
},
|
|
124
|
+
raw: {
|
|
125
|
+
id: 'unknown',
|
|
126
|
+
type: 'public-key',
|
|
127
|
+
authenticatorAttachment: null,
|
|
128
|
+
rawId: 'unknown',
|
|
129
|
+
response: { clientDataJSON: 'mock' },
|
|
130
|
+
},
|
|
131
|
+
signature: '0x00',
|
|
132
|
+
}),
|
|
133
|
+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Unknown credential: unknown]`)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('behavior: each call to getRegistrationOptions generates a unique challenge', async () => {
|
|
137
|
+
const ceremony = Ceremony.local({ rpId: 'example.com' })
|
|
138
|
+
|
|
139
|
+
const { options: a } = await ceremony.getRegistrationOptions({ name: 'Test' })
|
|
140
|
+
const { options: b } = await ceremony.getRegistrationOptions({ name: 'Test' })
|
|
141
|
+
expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('behavior: each call to getAuthenticationOptions generates a unique challenge', async () => {
|
|
145
|
+
const ceremony = Ceremony.local({ rpId: 'example.com' })
|
|
146
|
+
|
|
147
|
+
const { options: a } = await ceremony.getAuthenticationOptions()
|
|
148
|
+
const { options: b } = await ceremony.getAuthenticationOptions()
|
|
149
|
+
expect(a.publicKey!.challenge).not.toBe(b.publicKey!.challenge)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { Bytes } from 'ox'
|
|
2
|
+
import type { Hex } from 'viem'
|
|
3
|
+
import { Authentication, Registration } from 'webauthx/server'
|
|
4
|
+
|
|
5
|
+
import * as Storage from './Storage.js'
|
|
6
|
+
|
|
7
|
+
/** Pluggable strategy for WebAuthn registration and authentication ceremonies. */
|
|
8
|
+
export type Ceremony = {
|
|
9
|
+
/** Get credential creation options for `navigator.credentials.create()`. */
|
|
10
|
+
getRegistrationOptions: (
|
|
11
|
+
params: getRegistrationOptions.Parameters,
|
|
12
|
+
) => Promise<getRegistrationOptions.ReturnType>
|
|
13
|
+
/** Verify a registration response and extract the public key. */
|
|
14
|
+
verifyRegistration: (
|
|
15
|
+
credential: Registration.Credential,
|
|
16
|
+
options?: verifyRegistration.Options | undefined,
|
|
17
|
+
) => Promise<verifyRegistration.ReturnType>
|
|
18
|
+
/** Get credential request options for `navigator.credentials.get()`. */
|
|
19
|
+
getAuthenticationOptions: (
|
|
20
|
+
params?: getAuthenticationOptions.Parameters | undefined,
|
|
21
|
+
) => Promise<getAuthenticationOptions.ReturnType>
|
|
22
|
+
/** Verify an authentication response and extract the public key. */
|
|
23
|
+
verifyAuthentication: (
|
|
24
|
+
response: Authentication.Response,
|
|
25
|
+
) => Promise<verifyAuthentication.ReturnType>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export declare namespace getRegistrationOptions {
|
|
29
|
+
type Parameters = {
|
|
30
|
+
/** Credential IDs to exclude (prevents re-registering existing credentials). */
|
|
31
|
+
excludeCredentialIds?: readonly string[] | undefined
|
|
32
|
+
/** Credential display name (e.g. `"alice"`). */
|
|
33
|
+
name: string
|
|
34
|
+
/** Opaque user identifier. Encoded as `user.id` in the WebAuthn creation options. */
|
|
35
|
+
userId?: string | undefined
|
|
36
|
+
}
|
|
37
|
+
type ReturnType = { options: Registration.Options }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export declare namespace verifyRegistration {
|
|
41
|
+
type Options = {
|
|
42
|
+
/** Display name for the credential (e.g. user's email). */
|
|
43
|
+
name?: string | undefined
|
|
44
|
+
}
|
|
45
|
+
type ReturnType = {
|
|
46
|
+
/** The registered credential's ID. */
|
|
47
|
+
credentialId: string
|
|
48
|
+
/** The credential's public key (uncompressed P256, hex-encoded). */
|
|
49
|
+
publicKey: Hex
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export declare namespace getAuthenticationOptions {
|
|
54
|
+
type Parameters = {
|
|
55
|
+
/** Credential IDs to allow (restricts which credentials can be used). */
|
|
56
|
+
allowCredentialIds?: readonly string[] | undefined
|
|
57
|
+
/** Challenge to use. */
|
|
58
|
+
challenge?: `0x${string}` | undefined
|
|
59
|
+
/** Credential ID to restrict authentication to a specific credential. */
|
|
60
|
+
credentialId?: string | undefined
|
|
61
|
+
/** Mediation hint for passkey autofill / conditional UI. */
|
|
62
|
+
mediation?: 'conditional' | 'optional' | 'required' | 'silent' | undefined
|
|
63
|
+
}
|
|
64
|
+
type ReturnType = { options: Authentication.Options }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export declare namespace verifyAuthentication {
|
|
68
|
+
type ReturnType = {
|
|
69
|
+
/** The authenticated credential's ID. */
|
|
70
|
+
credentialId: string
|
|
71
|
+
/** The credential's public key (uncompressed P256, hex-encoded). */
|
|
72
|
+
publicKey: Hex
|
|
73
|
+
/** User identifier from the authenticator's `userHandle` (discoverable/conditional flows). */
|
|
74
|
+
userId?: string | undefined
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Creates a {@link Ceremony} from a custom implementation. */
|
|
79
|
+
export function from(ceremony: Ceremony): Ceremony {
|
|
80
|
+
return ceremony
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Creates a pure client-side ceremony for development and prototyping.
|
|
85
|
+
*
|
|
86
|
+
* Generates challenges and verifies responses locally using `webauthx/server`.
|
|
87
|
+
* Stores credentials in memory. No external server needed.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* import { Ceremony } from 'accounts'
|
|
92
|
+
*
|
|
93
|
+
* const ceremony = Ceremony.local()
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function local(options: local.Options = {}): Ceremony {
|
|
97
|
+
const rpId = options.rpId ?? (typeof location !== 'undefined' ? location.hostname : 'localhost')
|
|
98
|
+
const storage =
|
|
99
|
+
options.storage ?? (typeof window !== 'undefined' ? Storage.idb() : Storage.memory())
|
|
100
|
+
const storageKey = 'credentials'
|
|
101
|
+
|
|
102
|
+
return from({
|
|
103
|
+
async getRegistrationOptions(parameters) {
|
|
104
|
+
const { excludeCredentialIds, name, userId } = parameters
|
|
105
|
+
const { options } = Registration.getOptions({
|
|
106
|
+
excludeCredentialIds: excludeCredentialIds as string[] | undefined,
|
|
107
|
+
name,
|
|
108
|
+
rp: { id: rpId, name: rpId },
|
|
109
|
+
user: userId ? { id: Bytes.fromString(userId), name } : undefined,
|
|
110
|
+
})
|
|
111
|
+
return { options }
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async verifyRegistration(credential) {
|
|
115
|
+
const publicKey = credential.publicKey
|
|
116
|
+
const credentials = (await storage.getItem<Record<string, Hex>>(storageKey)) ?? {}
|
|
117
|
+
credentials[credential.id] = publicKey
|
|
118
|
+
await storage.setItem(storageKey, credentials)
|
|
119
|
+
return { credentialId: credential.id, publicKey }
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async getAuthenticationOptions(parameters = {}) {
|
|
123
|
+
const { allowCredentialIds, challenge, credentialId } = parameters
|
|
124
|
+
const { options } = Authentication.getOptions({
|
|
125
|
+
challenge,
|
|
126
|
+
credentialId: (allowCredentialIds as string[] | undefined) ?? credentialId,
|
|
127
|
+
rpId,
|
|
128
|
+
})
|
|
129
|
+
return { options }
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async verifyAuthentication(response) {
|
|
133
|
+
const credentials = (await storage.getItem<Record<string, Hex>>(storageKey)) ?? {}
|
|
134
|
+
const publicKey = credentials[response.id]
|
|
135
|
+
if (!publicKey) throw new Error(`Unknown credential: ${response.id}`)
|
|
136
|
+
return { credentialId: response.id, publicKey }
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export declare namespace local {
|
|
142
|
+
type Options = {
|
|
143
|
+
/** Relying Party ID (e.g. `"example.com"`). @default location.hostname */
|
|
144
|
+
rpId?: string | undefined
|
|
145
|
+
/** Storage adapter for credential persistence. @default Storage.idb() in browser, Storage.memory() otherwise. */
|
|
146
|
+
storage?: Storage.Storage | undefined
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Creates a server-backed ceremony that delegates to a remote {@link Handler.webauthn} endpoint.
|
|
152
|
+
*
|
|
153
|
+
* All challenge generation, verification, and credential storage happen server-side.
|
|
154
|
+
* The client uses `fetch()` to communicate with 4 POST endpoints derived from the base URL.
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```ts
|
|
158
|
+
* import { Ceremony } from 'accounts'
|
|
159
|
+
*
|
|
160
|
+
* const ceremony = Ceremony.server({ url: 'https://example.com/webauthn' })
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
export function server(options: server.Options): Ceremony {
|
|
164
|
+
const { url } = options
|
|
165
|
+
|
|
166
|
+
async function request<returnType>(path: string, body: unknown): Promise<returnType> {
|
|
167
|
+
const response = await fetch(`${url}${path}`, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: JSON.stringify(body),
|
|
171
|
+
})
|
|
172
|
+
const json = await response.json()
|
|
173
|
+
if (!response.ok) throw new Error((json as { error?: string }).error ?? 'Request failed')
|
|
174
|
+
return json as returnType
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return from({
|
|
178
|
+
async getRegistrationOptions(parameters) {
|
|
179
|
+
const { excludeCredentialIds, name, userId } = parameters
|
|
180
|
+
return request('/register/options', { excludeCredentialIds, name, userId })
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async verifyRegistration(credential) {
|
|
184
|
+
return request('/register', credential)
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async getAuthenticationOptions(parameters = {}) {
|
|
188
|
+
const { allowCredentialIds, challenge, credentialId, mediation } = parameters
|
|
189
|
+
return request('/login/options', { allowCredentialIds, challenge, credentialId, mediation })
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async verifyAuthentication(response) {
|
|
193
|
+
return request('/login', response)
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export declare namespace server {
|
|
199
|
+
type Options = {
|
|
200
|
+
/** Base URL of the WebAuthn handler (e.g. `"https://example.com/webauthn"`). */
|
|
201
|
+
url: string
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Chain, createClient, type Client, http, type Transport } from 'viem'
|
|
2
|
+
import type { tempo } from 'viem/chains'
|
|
3
|
+
import { withFeePayer } from 'viem/tempo'
|
|
4
|
+
|
|
5
|
+
import type * as Store from './Store.js'
|
|
6
|
+
|
|
7
|
+
const clients = new Map<string, Client>()
|
|
8
|
+
|
|
9
|
+
/** Resolves a viem Client for a given chain ID (cached). */
|
|
10
|
+
export function fromChainId(
|
|
11
|
+
chainId: number | undefined,
|
|
12
|
+
options: fromChainId.Options,
|
|
13
|
+
): Client<Transport, typeof tempo> {
|
|
14
|
+
const { chains, feePayer, store } = options
|
|
15
|
+
const id = chainId ?? store.getState().chainId
|
|
16
|
+
const key = `${id}:${feePayer ?? ''}`
|
|
17
|
+
let client = clients.get(key)
|
|
18
|
+
if (!client) {
|
|
19
|
+
const chain = chains.find((c) => c.id === id) ?? chains[0]!
|
|
20
|
+
const transport = feePayer ? withFeePayer(http(), http(feePayer)) : http()
|
|
21
|
+
client = createClient({ chain, transport, pollingInterval: 1000 })
|
|
22
|
+
clients.set(key, client)
|
|
23
|
+
}
|
|
24
|
+
return client as never
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export declare namespace fromChainId {
|
|
28
|
+
type Options = {
|
|
29
|
+
/** Supported chains. */
|
|
30
|
+
chains: readonly [Chain, ...Chain[]]
|
|
31
|
+
/** Fee payer service URL. When set, the transport routes fee-payer RPC calls to this URL. */
|
|
32
|
+
feePayer?: string | undefined
|
|
33
|
+
/** Reactive state store. */
|
|
34
|
+
store: Store.Store
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vp/test'
|
|
2
|
+
|
|
3
|
+
import * as Dialog from './Dialog.js'
|
|
4
|
+
import * as Storage from './Storage.js'
|
|
5
|
+
import * as Store from './Store.js'
|
|
6
|
+
|
|
7
|
+
const host = 'https://wallet.tempo.xyz/embed'
|
|
8
|
+
|
|
9
|
+
function setup() {
|
|
10
|
+
const store = Store.create({
|
|
11
|
+
chainId: 1,
|
|
12
|
+
storage: Storage.memory({ key: 'dialog-test' }),
|
|
13
|
+
})
|
|
14
|
+
const dialog = Dialog.iframe()
|
|
15
|
+
const handle = dialog({ host, store })
|
|
16
|
+
return { handle, store }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
document.querySelectorAll('dialog[data-tempo-wallet]').forEach((el) => el.remove())
|
|
21
|
+
document.body.style.overflow = ''
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('Dialog.iframe', () => {
|
|
25
|
+
test('default: appends dialog and iframe to document.body', () => {
|
|
26
|
+
setup()
|
|
27
|
+
const dialog = document.querySelector('dialog[data-tempo-wallet]')
|
|
28
|
+
expect(dialog).not.toBeNull()
|
|
29
|
+
const iframe = dialog!.querySelector('iframe')
|
|
30
|
+
expect(iframe).not.toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('behavior: iframe has correct sandbox attributes', () => {
|
|
34
|
+
setup()
|
|
35
|
+
const iframe = document.querySelector('dialog[data-tempo-wallet] iframe')!
|
|
36
|
+
expect(iframe.getAttribute('sandbox')).toMatchInlineSnapshot(
|
|
37
|
+
`"allow-forms allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`,
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('behavior: iframe has correct allow attributes', () => {
|
|
42
|
+
setup()
|
|
43
|
+
const iframe = document.querySelector('dialog[data-tempo-wallet] iframe')!
|
|
44
|
+
const allow = iframe.getAttribute('allow')!
|
|
45
|
+
expect(allow).toContain('publickey-credentials-get')
|
|
46
|
+
expect(allow).toContain('publickey-credentials-create')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('behavior: iframe src points to host', () => {
|
|
50
|
+
setup()
|
|
51
|
+
const iframe = document.querySelector('dialog[data-tempo-wallet] iframe') as HTMLIFrameElement
|
|
52
|
+
expect(iframe.src).toMatchInlineSnapshot(`"https://wallet.tempo.xyz/embed/"`)
|
|
53
|
+
expect(iframe.src).toContain(host)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('behavior: open shows dialog', () => {
|
|
57
|
+
const { handle } = setup()
|
|
58
|
+
handle.open()
|
|
59
|
+
const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
|
|
60
|
+
expect(dialog.open).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('behavior: close hides dialog', () => {
|
|
64
|
+
const { handle } = setup()
|
|
65
|
+
handle.open()
|
|
66
|
+
handle.close()
|
|
67
|
+
const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
|
|
68
|
+
expect(dialog.open).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('behavior: destroy removes dialog from DOM', () => {
|
|
72
|
+
const { handle } = setup()
|
|
73
|
+
handle.destroy()
|
|
74
|
+
expect(document.querySelector('dialog[data-tempo-wallet]')).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('behavior: body scroll locked on open', () => {
|
|
78
|
+
const { handle } = setup()
|
|
79
|
+
handle.open()
|
|
80
|
+
expect(document.body.style.overflow).toBe('hidden')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('behavior: body scroll restored on close', () => {
|
|
84
|
+
const { handle } = setup()
|
|
85
|
+
document.body.style.overflow = 'auto'
|
|
86
|
+
handle.open()
|
|
87
|
+
handle.close()
|
|
88
|
+
expect(document.body.style.overflow).toBe('auto')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('behavior: open is idempotent', () => {
|
|
92
|
+
const { handle } = setup()
|
|
93
|
+
handle.open()
|
|
94
|
+
expect(() => handle.open()).not.toThrow()
|
|
95
|
+
const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
|
|
96
|
+
expect(dialog.open).toBe(true)
|
|
97
|
+
handle.close()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('behavior: close without open does not throw', () => {
|
|
101
|
+
const { handle } = setup()
|
|
102
|
+
expect(() => handle.close()).not.toThrow()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('behavior: destroy restores body scroll', () => {
|
|
106
|
+
const { handle } = setup()
|
|
107
|
+
document.body.style.overflow = 'auto'
|
|
108
|
+
handle.open()
|
|
109
|
+
handle.destroy()
|
|
110
|
+
expect(document.body.style.overflow).toBe('auto')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('behavior: destroy closes open dialog', () => {
|
|
114
|
+
const { handle } = setup()
|
|
115
|
+
handle.open()
|
|
116
|
+
handle.destroy()
|
|
117
|
+
expect(document.querySelector('dialog[data-tempo-wallet]')).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('behavior: cancel event rejects pending requests', () => {
|
|
121
|
+
const { handle, store } = setup()
|
|
122
|
+
handle.open()
|
|
123
|
+
const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
|
|
124
|
+
dialog.dispatchEvent(new Event('cancel'))
|
|
125
|
+
const queue = store.getState().requestQueue
|
|
126
|
+
for (const q of queue) expect(q.status).toBe('error')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('behavior: focus restored to previous element on close', () => {
|
|
130
|
+
const button = document.createElement('button')
|
|
131
|
+
document.body.appendChild(button)
|
|
132
|
+
button.focus()
|
|
133
|
+
expect(document.activeElement).toBe(button)
|
|
134
|
+
|
|
135
|
+
const { handle } = setup()
|
|
136
|
+
handle.open()
|
|
137
|
+
handle.close()
|
|
138
|
+
|
|
139
|
+
expect(document.activeElement).toBe(button)
|
|
140
|
+
button.remove()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('behavior: backdrop click rejects pending requests', () => {
|
|
144
|
+
const { handle, store } = setup()
|
|
145
|
+
handle.open()
|
|
146
|
+
const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
|
|
147
|
+
|
|
148
|
+
dialog.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
149
|
+
|
|
150
|
+
const queue = store.getState().requestQueue
|
|
151
|
+
for (const q of queue) expect(q.status).toBe('error')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('behavior: click inside iframe does not close dialog', () => {
|
|
155
|
+
const { handle } = setup()
|
|
156
|
+
handle.open()
|
|
157
|
+
const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
|
|
158
|
+
const iframe = dialog.querySelector('iframe')!
|
|
159
|
+
|
|
160
|
+
iframe.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
161
|
+
|
|
162
|
+
expect(dialog.open).toBe(true)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('behavior: 1Password inert attribute stripped from dialog', async () => {
|
|
166
|
+
const { handle } = setup()
|
|
167
|
+
handle.open()
|
|
168
|
+
const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
|
|
169
|
+
|
|
170
|
+
dialog.setAttribute('inert', '')
|
|
171
|
+
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
173
|
+
|
|
174
|
+
expect(dialog.hasAttribute('inert')).toBe(false)
|
|
175
|
+
handle.close()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('behavior: iframe has accessibility attributes', () => {
|
|
179
|
+
setup()
|
|
180
|
+
const dialog = document.querySelector('dialog[data-tempo-wallet]') as HTMLDialogElement
|
|
181
|
+
expect(dialog.getAttribute('role')).toBe('dialog')
|
|
182
|
+
expect(dialog.getAttribute('aria-label')).toBe('Tempo Auth')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe('Dialog.popup', () => {
|
|
187
|
+
test('default: window.open called with correct URL', () => {
|
|
188
|
+
const openSpy = vi.spyOn(window, 'open').mockReturnValue({
|
|
189
|
+
closed: false,
|
|
190
|
+
close: vi.fn(),
|
|
191
|
+
} as unknown as Window)
|
|
192
|
+
|
|
193
|
+
const store = Store.create({
|
|
194
|
+
chainId: 1,
|
|
195
|
+
storage: Storage.memory({ key: 'popup-test' }),
|
|
196
|
+
})
|
|
197
|
+
const dialog = Dialog.popup()
|
|
198
|
+
const handle = dialog({ host, store })
|
|
199
|
+
handle.open()
|
|
200
|
+
|
|
201
|
+
expect(openSpy).toHaveBeenCalledOnce()
|
|
202
|
+
expect(openSpy.mock.calls[0]![0]).toBe(host)
|
|
203
|
+
|
|
204
|
+
handle.destroy()
|
|
205
|
+
openSpy.mockRestore()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('behavior: window.open called with centered position', () => {
|
|
209
|
+
const openSpy = vi.spyOn(window, 'open').mockReturnValue({
|
|
210
|
+
closed: false,
|
|
211
|
+
close: vi.fn(),
|
|
212
|
+
} as unknown as Window)
|
|
213
|
+
|
|
214
|
+
const store = Store.create({
|
|
215
|
+
chainId: 1,
|
|
216
|
+
storage: Storage.memory({ key: 'popup-test' }),
|
|
217
|
+
})
|
|
218
|
+
const dialog = Dialog.popup()
|
|
219
|
+
const handle = dialog({ host, store })
|
|
220
|
+
handle.open()
|
|
221
|
+
|
|
222
|
+
const features = openSpy.mock.calls[0]![2] as string
|
|
223
|
+
expect(features).toContain('width=')
|
|
224
|
+
expect(features).toContain('height=')
|
|
225
|
+
expect(features).toContain('left=')
|
|
226
|
+
expect(features).toContain('top=')
|
|
227
|
+
|
|
228
|
+
handle.destroy()
|
|
229
|
+
openSpy.mockRestore()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('behavior: close calls popup.close()', () => {
|
|
233
|
+
const popupClose = vi.fn()
|
|
234
|
+
const openSpy = vi.spyOn(window, 'open').mockReturnValue({
|
|
235
|
+
closed: false,
|
|
236
|
+
close: popupClose,
|
|
237
|
+
} as unknown as Window)
|
|
238
|
+
|
|
239
|
+
const store = Store.create({
|
|
240
|
+
chainId: 1,
|
|
241
|
+
storage: Storage.memory({ key: 'popup-test' }),
|
|
242
|
+
})
|
|
243
|
+
const dialog = Dialog.popup()
|
|
244
|
+
const handle = dialog({ host, store })
|
|
245
|
+
handle.open()
|
|
246
|
+
handle.close()
|
|
247
|
+
|
|
248
|
+
expect(popupClose).toHaveBeenCalledOnce()
|
|
249
|
+
|
|
250
|
+
handle.destroy()
|
|
251
|
+
openSpy.mockRestore()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('behavior: open throws if popup blocked', () => {
|
|
255
|
+
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
|
256
|
+
|
|
257
|
+
const store = Store.create({
|
|
258
|
+
chainId: 1,
|
|
259
|
+
storage: Storage.memory({ key: 'popup-test' }),
|
|
260
|
+
})
|
|
261
|
+
const dialog = Dialog.popup()
|
|
262
|
+
const handle = dialog({ host, store })
|
|
263
|
+
|
|
264
|
+
expect(() => handle.open()).toThrow('Failed to open popup')
|
|
265
|
+
|
|
266
|
+
openSpy.mockRestore()
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('behavior: destroy cleans up', () => {
|
|
270
|
+
const popupClose = vi.fn()
|
|
271
|
+
const openSpy = vi.spyOn(window, 'open').mockReturnValue({
|
|
272
|
+
closed: false,
|
|
273
|
+
close: popupClose,
|
|
274
|
+
} as unknown as Window)
|
|
275
|
+
|
|
276
|
+
const store = Store.create({
|
|
277
|
+
chainId: 1,
|
|
278
|
+
storage: Storage.memory({ key: 'popup-test' }),
|
|
279
|
+
})
|
|
280
|
+
const dialog = Dialog.popup()
|
|
281
|
+
const handle = dialog({ host, store })
|
|
282
|
+
handle.open()
|
|
283
|
+
handle.destroy()
|
|
284
|
+
|
|
285
|
+
expect(popupClose).toHaveBeenCalled()
|
|
286
|
+
|
|
287
|
+
openSpy.mockRestore()
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe('Dialog.noop', () => {
|
|
292
|
+
test('default: open, close, destroy are callable without error', () => {
|
|
293
|
+
const store = Store.create({
|
|
294
|
+
chainId: 1,
|
|
295
|
+
storage: Storage.memory({ key: 'noop-test' }),
|
|
296
|
+
})
|
|
297
|
+
const dialog = Dialog.noop()
|
|
298
|
+
const handle = dialog({ host, store })
|
|
299
|
+
expect(() => handle.open()).not.toThrow()
|
|
300
|
+
expect(() => handle.close()).not.toThrow()
|
|
301
|
+
expect(() => handle.destroy()).not.toThrow()
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('isSafari', () => {
|
|
306
|
+
test('default: returns false in non-Safari environment', () => {
|
|
307
|
+
expect(Dialog.isSafari()).toBe(false)
|
|
308
|
+
})
|
|
309
|
+
})
|