@zerodev/wallet-react 0.0.1-alpha.1 → 0.0.1-alpha.11
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 +74 -0
- package/README.md +30 -17
- package/dist/_cjs/actions.js +62 -24
- package/dist/_cjs/connector.js +82 -48
- package/dist/_cjs/hooks/useExportPrivateKey.js +18 -0
- package/dist/_cjs/hooks/useGetUserEmail.js +19 -0
- package/dist/_cjs/index.js +8 -1
- package/dist/_cjs/oauth.js +60 -55
- package/dist/_cjs/provider.js +5 -2
- package/dist/_cjs/store.js +4 -9
- package/dist/_esm/actions.js +74 -27
- package/dist/_esm/connector.js +98 -55
- package/dist/_esm/hooks/useExportPrivateKey.js +18 -0
- package/dist/_esm/hooks/useGetUserEmail.js +19 -0
- package/dist/_esm/index.js +3 -1
- package/dist/_esm/oauth.js +71 -53
- package/dist/_esm/provider.js +5 -2
- package/dist/_esm/store.js +4 -10
- package/dist/_types/actions.d.ts +35 -6
- package/dist/_types/actions.d.ts.map +1 -1
- package/dist/_types/connector.d.ts +0 -2
- package/dist/_types/connector.d.ts.map +1 -1
- package/dist/_types/hooks/useExportPrivateKey.d.ts +18 -0
- package/dist/_types/hooks/useExportPrivateKey.d.ts.map +1 -0
- package/dist/_types/hooks/useGetUserEmail.d.ts +18 -0
- package/dist/_types/hooks/useGetUserEmail.d.ts.map +1 -0
- package/dist/_types/index.d.ts +4 -2
- package/dist/_types/index.d.ts.map +1 -1
- package/dist/_types/oauth.d.ts +25 -12
- package/dist/_types/oauth.d.ts.map +1 -1
- package/dist/_types/provider.d.ts.map +1 -1
- package/dist/_types/store.d.ts +11 -7
- package/dist/_types/store.d.ts.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/actions.test.ts +895 -0
- package/src/actions.ts +124 -44
- package/src/connector.ts +117 -65
- package/src/hooks/useExportPrivateKey.ts +57 -0
- package/src/hooks/useGetUserEmail.ts +52 -0
- package/src/index.ts +9 -2
- package/src/oauth.test.ts +445 -0
- package/src/oauth.ts +97 -78
- package/src/provider.ts +5 -2
- package/src/store.ts +15 -16
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildBackendOAuthUrl,
|
|
4
|
+
generateOAuthNonce,
|
|
5
|
+
handleOAuthCallback,
|
|
6
|
+
listenForOAuthMessage,
|
|
7
|
+
OAUTH_PROVIDERS,
|
|
8
|
+
openOAuthPopup,
|
|
9
|
+
} from './oauth.js'
|
|
10
|
+
|
|
11
|
+
describe('OAuth utilities', () => {
|
|
12
|
+
describe('OAUTH_PROVIDERS', () => {
|
|
13
|
+
it('has google provider', () => {
|
|
14
|
+
expect(OAUTH_PROVIDERS.GOOGLE).toBe('google')
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('generateOAuthNonce', () => {
|
|
19
|
+
it('generates consistent nonce from public key', () => {
|
|
20
|
+
const publicKey = '0x1234567890abcdef'
|
|
21
|
+
|
|
22
|
+
const nonce1 = generateOAuthNonce(publicKey)
|
|
23
|
+
const nonce2 = generateOAuthNonce(publicKey)
|
|
24
|
+
|
|
25
|
+
expect(nonce1).toBe(nonce2)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns nonce without 0x prefix', () => {
|
|
29
|
+
const publicKey =
|
|
30
|
+
'0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
|
|
31
|
+
|
|
32
|
+
const nonce = generateOAuthNonce(publicKey)
|
|
33
|
+
|
|
34
|
+
expect(nonce).not.toMatch(/^0x/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('generates different nonces for different keys', () => {
|
|
38
|
+
const key1 =
|
|
39
|
+
'0x1111111111111111111111111111111111111111111111111111111111111111'
|
|
40
|
+
const key2 =
|
|
41
|
+
'0x2222222222222222222222222222222222222222222222222222222222222222'
|
|
42
|
+
|
|
43
|
+
const nonce1 = generateOAuthNonce(key1)
|
|
44
|
+
const nonce2 = generateOAuthNonce(key2)
|
|
45
|
+
|
|
46
|
+
expect(nonce1).not.toBe(nonce2)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('buildBackendOAuthUrl', () => {
|
|
51
|
+
it('builds correct URL for Google OAuth', () => {
|
|
52
|
+
const url = buildBackendOAuthUrl({
|
|
53
|
+
provider: 'google',
|
|
54
|
+
backendUrl: 'https://api.example.com',
|
|
55
|
+
projectId: 'proj-123',
|
|
56
|
+
publicKey: '0xabcdef1234567890',
|
|
57
|
+
returnTo: 'https://app.example.com/callback',
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const parsedUrl = new URL(url)
|
|
61
|
+
expect(parsedUrl.origin).toBe('https://api.example.com')
|
|
62
|
+
expect(parsedUrl.pathname).toBe('/oauth/google/login')
|
|
63
|
+
expect(parsedUrl.searchParams.get('project_id')).toBe('proj-123')
|
|
64
|
+
expect(parsedUrl.searchParams.get('pub_key')).toBe('abcdef1234567890') // 0x stripped
|
|
65
|
+
expect(parsedUrl.searchParams.get('return_to')).toBe(
|
|
66
|
+
'https://app.example.com/callback',
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('strips 0x prefix from public key', () => {
|
|
71
|
+
const url = buildBackendOAuthUrl({
|
|
72
|
+
provider: 'google',
|
|
73
|
+
backendUrl: 'https://api.example.com',
|
|
74
|
+
projectId: 'proj-123',
|
|
75
|
+
publicKey: '0x0123456789',
|
|
76
|
+
returnTo: 'https://app.example.com',
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const parsedUrl = new URL(url)
|
|
80
|
+
expect(parsedUrl.searchParams.get('pub_key')).toBe('0123456789')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('handles public key without 0x prefix', () => {
|
|
84
|
+
const url = buildBackendOAuthUrl({
|
|
85
|
+
provider: 'google',
|
|
86
|
+
backendUrl: 'https://api.example.com',
|
|
87
|
+
projectId: 'proj-123',
|
|
88
|
+
publicKey: 'abcdef123456',
|
|
89
|
+
returnTo: 'https://app.example.com',
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const parsedUrl = new URL(url)
|
|
93
|
+
expect(parsedUrl.searchParams.get('pub_key')).toBe('abcdef123456')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('throws on unsupported provider', () => {
|
|
97
|
+
expect(() =>
|
|
98
|
+
buildBackendOAuthUrl({
|
|
99
|
+
provider: 'facebook' as 'google',
|
|
100
|
+
backendUrl: 'https://api.example.com',
|
|
101
|
+
projectId: 'proj-123',
|
|
102
|
+
publicKey: '0xabcdef',
|
|
103
|
+
returnTo: 'https://app.example.com',
|
|
104
|
+
}),
|
|
105
|
+
).toThrow('Unsupported OAuth provider: facebook')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('encodes special characters in return URL', () => {
|
|
109
|
+
const url = buildBackendOAuthUrl({
|
|
110
|
+
provider: 'google',
|
|
111
|
+
backendUrl: 'https://api.example.com',
|
|
112
|
+
projectId: 'proj-123',
|
|
113
|
+
publicKey: '0xabcdef',
|
|
114
|
+
returnTo: 'https://app.example.com?foo=bar&baz=qux',
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const parsedUrl = new URL(url)
|
|
118
|
+
expect(parsedUrl.searchParams.get('return_to')).toBe(
|
|
119
|
+
'https://app.example.com?foo=bar&baz=qux',
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('openOAuthPopup', () => {
|
|
125
|
+
const originalOpen = window.open
|
|
126
|
+
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
window.open = vi.fn()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
afterEach(() => {
|
|
132
|
+
window.open = originalOpen
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('opens a popup with about:blank initially', () => {
|
|
136
|
+
const mockWindow = { location: { href: '' } }
|
|
137
|
+
vi.mocked(window.open).mockReturnValue(mockWindow as Window)
|
|
138
|
+
|
|
139
|
+
openOAuthPopup('https://oauth.example.com')
|
|
140
|
+
|
|
141
|
+
expect(window.open).toHaveBeenCalledWith(
|
|
142
|
+
'about:blank',
|
|
143
|
+
'_blank',
|
|
144
|
+
expect.stringContaining('width=500,height=600'),
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('sets the URL after opening', () => {
|
|
149
|
+
const mockWindow = { location: { href: '' } }
|
|
150
|
+
vi.mocked(window.open).mockReturnValue(mockWindow as Window)
|
|
151
|
+
|
|
152
|
+
openOAuthPopup('https://oauth.example.com/login')
|
|
153
|
+
|
|
154
|
+
expect(mockWindow.location.href).toBe('https://oauth.example.com/login')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('returns the window object', () => {
|
|
158
|
+
const mockWindow = { location: { href: '' } }
|
|
159
|
+
vi.mocked(window.open).mockReturnValue(mockWindow as Window)
|
|
160
|
+
|
|
161
|
+
const result = openOAuthPopup('https://oauth.example.com')
|
|
162
|
+
|
|
163
|
+
expect(result).toBe(mockWindow)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('returns null if popup was blocked', () => {
|
|
167
|
+
vi.mocked(window.open).mockReturnValue(null)
|
|
168
|
+
|
|
169
|
+
const result = openOAuthPopup('https://oauth.example.com')
|
|
170
|
+
|
|
171
|
+
expect(result).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('listenForOAuthMessage', () => {
|
|
176
|
+
let mockWindow: { closed: boolean }
|
|
177
|
+
let onSuccessMock: ReturnType<typeof vi.fn>
|
|
178
|
+
let onErrorMock: ReturnType<typeof vi.fn>
|
|
179
|
+
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
vi.useFakeTimers()
|
|
182
|
+
mockWindow = { closed: false }
|
|
183
|
+
onSuccessMock = vi.fn()
|
|
184
|
+
onErrorMock = vi.fn()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
afterEach(() => {
|
|
188
|
+
vi.useRealTimers()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('calls onSuccess when oauth_success message received', () => {
|
|
192
|
+
const cleanup = listenForOAuthMessage(
|
|
193
|
+
mockWindow as Window,
|
|
194
|
+
'https://app.example.com',
|
|
195
|
+
onSuccessMock as () => void,
|
|
196
|
+
onErrorMock as (error: Error) => void,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
// Simulate receiving a message
|
|
200
|
+
const event = new MessageEvent('message', {
|
|
201
|
+
data: { type: 'oauth_success' },
|
|
202
|
+
origin: 'https://app.example.com',
|
|
203
|
+
})
|
|
204
|
+
window.dispatchEvent(event)
|
|
205
|
+
|
|
206
|
+
expect(onSuccessMock).toHaveBeenCalledOnce()
|
|
207
|
+
expect(onErrorMock).not.toHaveBeenCalled()
|
|
208
|
+
cleanup()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('calls onError when oauth_error message received', () => {
|
|
212
|
+
const cleanup = listenForOAuthMessage(
|
|
213
|
+
mockWindow as Window,
|
|
214
|
+
'https://app.example.com',
|
|
215
|
+
onSuccessMock as () => void,
|
|
216
|
+
onErrorMock as (error: Error) => void,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const event = new MessageEvent('message', {
|
|
220
|
+
data: { type: 'oauth_error', error: 'Access denied' },
|
|
221
|
+
origin: 'https://app.example.com',
|
|
222
|
+
})
|
|
223
|
+
window.dispatchEvent(event)
|
|
224
|
+
|
|
225
|
+
expect(onErrorMock).toHaveBeenCalledOnce()
|
|
226
|
+
expect(onErrorMock).toHaveBeenCalledWith(new Error('Access denied'))
|
|
227
|
+
expect(onSuccessMock).not.toHaveBeenCalled()
|
|
228
|
+
cleanup()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('ignores messages from wrong origin', () => {
|
|
232
|
+
const cleanup = listenForOAuthMessage(
|
|
233
|
+
mockWindow as Window,
|
|
234
|
+
'https://app.example.com',
|
|
235
|
+
onSuccessMock as () => void,
|
|
236
|
+
onErrorMock as (error: Error) => void,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
const event = new MessageEvent('message', {
|
|
240
|
+
data: { type: 'oauth_success' },
|
|
241
|
+
origin: 'https://malicious.example.com',
|
|
242
|
+
})
|
|
243
|
+
window.dispatchEvent(event)
|
|
244
|
+
|
|
245
|
+
expect(onSuccessMock).not.toHaveBeenCalled()
|
|
246
|
+
expect(onErrorMock).not.toHaveBeenCalled()
|
|
247
|
+
cleanup()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('calls onError when window is closed', () => {
|
|
251
|
+
const cleanup = listenForOAuthMessage(
|
|
252
|
+
mockWindow as Window,
|
|
253
|
+
'https://app.example.com',
|
|
254
|
+
onSuccessMock as () => void,
|
|
255
|
+
onErrorMock as (error: Error) => void,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
mockWindow.closed = true
|
|
259
|
+
vi.advanceTimersByTime(600)
|
|
260
|
+
|
|
261
|
+
expect(onErrorMock).toHaveBeenCalledWith(
|
|
262
|
+
new Error('Authentication window was closed'),
|
|
263
|
+
)
|
|
264
|
+
cleanup()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('uses fallback error message when oauth_error has no error field', () => {
|
|
268
|
+
const cleanup = listenForOAuthMessage(
|
|
269
|
+
mockWindow as Window,
|
|
270
|
+
'https://app.example.com',
|
|
271
|
+
onSuccessMock as () => void,
|
|
272
|
+
onErrorMock as (error: Error) => void,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
const event = new MessageEvent('message', {
|
|
276
|
+
data: { type: 'oauth_error' },
|
|
277
|
+
origin: 'https://app.example.com',
|
|
278
|
+
})
|
|
279
|
+
window.dispatchEvent(event)
|
|
280
|
+
|
|
281
|
+
expect(onErrorMock).toHaveBeenCalledWith(
|
|
282
|
+
new Error('OAuth authentication failed'),
|
|
283
|
+
)
|
|
284
|
+
cleanup()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('ignores messages with non-object data', () => {
|
|
288
|
+
const cleanup = listenForOAuthMessage(
|
|
289
|
+
mockWindow as Window,
|
|
290
|
+
'https://app.example.com',
|
|
291
|
+
onSuccessMock as () => void,
|
|
292
|
+
onErrorMock as (error: Error) => void,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
// null data
|
|
296
|
+
window.dispatchEvent(
|
|
297
|
+
new MessageEvent('message', {
|
|
298
|
+
data: null,
|
|
299
|
+
origin: 'https://app.example.com',
|
|
300
|
+
}),
|
|
301
|
+
)
|
|
302
|
+
// string data
|
|
303
|
+
window.dispatchEvent(
|
|
304
|
+
new MessageEvent('message', {
|
|
305
|
+
data: 'some-string',
|
|
306
|
+
origin: 'https://app.example.com',
|
|
307
|
+
}),
|
|
308
|
+
)
|
|
309
|
+
// number data
|
|
310
|
+
window.dispatchEvent(
|
|
311
|
+
new MessageEvent('message', {
|
|
312
|
+
data: 42,
|
|
313
|
+
origin: 'https://app.example.com',
|
|
314
|
+
}),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
expect(onSuccessMock).not.toHaveBeenCalled()
|
|
318
|
+
expect(onErrorMock).not.toHaveBeenCalled()
|
|
319
|
+
cleanup()
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('calling cleanup multiple times is safe', () => {
|
|
323
|
+
const cleanup = listenForOAuthMessage(
|
|
324
|
+
mockWindow as Window,
|
|
325
|
+
'https://app.example.com',
|
|
326
|
+
onSuccessMock as () => void,
|
|
327
|
+
onErrorMock as (error: Error) => void,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
cleanup()
|
|
331
|
+
cleanup()
|
|
332
|
+
cleanup()
|
|
333
|
+
|
|
334
|
+
// Should not throw and subsequent messages should be ignored
|
|
335
|
+
const event = new MessageEvent('message', {
|
|
336
|
+
data: { type: 'oauth_success' },
|
|
337
|
+
origin: 'https://app.example.com',
|
|
338
|
+
})
|
|
339
|
+
window.dispatchEvent(event)
|
|
340
|
+
|
|
341
|
+
expect(onSuccessMock).not.toHaveBeenCalled()
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('returns cleanup function that stops listening', () => {
|
|
345
|
+
const cleanup = listenForOAuthMessage(
|
|
346
|
+
mockWindow as Window,
|
|
347
|
+
'https://app.example.com',
|
|
348
|
+
onSuccessMock as () => void,
|
|
349
|
+
onErrorMock as (error: Error) => void,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
cleanup()
|
|
353
|
+
|
|
354
|
+
// Message after cleanup should be ignored
|
|
355
|
+
const event = new MessageEvent('message', {
|
|
356
|
+
data: { type: 'oauth_success' },
|
|
357
|
+
origin: 'https://app.example.com',
|
|
358
|
+
})
|
|
359
|
+
window.dispatchEvent(event)
|
|
360
|
+
|
|
361
|
+
expect(onSuccessMock).not.toHaveBeenCalled()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
describe('handleOAuthCallback', () => {
|
|
366
|
+
const originalLocation = window.location
|
|
367
|
+
const originalOpener = window.opener
|
|
368
|
+
const originalClose = window.close
|
|
369
|
+
|
|
370
|
+
beforeEach(() => {
|
|
371
|
+
// @ts-expect-error - Mocking location
|
|
372
|
+
delete window.location
|
|
373
|
+
// @ts-expect-error - Mocking location with partial properties
|
|
374
|
+
window.location = {
|
|
375
|
+
...originalLocation,
|
|
376
|
+
search: '',
|
|
377
|
+
origin: 'https://app.example.com',
|
|
378
|
+
} as Location
|
|
379
|
+
|
|
380
|
+
window.opener = {
|
|
381
|
+
postMessage: vi.fn(),
|
|
382
|
+
} as unknown as Window
|
|
383
|
+
|
|
384
|
+
window.close = vi.fn()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
afterEach(() => {
|
|
388
|
+
// @ts-expect-error - Restoring original location
|
|
389
|
+
window.location = originalLocation
|
|
390
|
+
window.opener = originalOpener
|
|
391
|
+
window.close = originalClose
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('returns true and posts success message on oauth_success=true', () => {
|
|
395
|
+
window.location.search = '?oauth_success=true'
|
|
396
|
+
|
|
397
|
+
const result = handleOAuthCallback()
|
|
398
|
+
|
|
399
|
+
expect(result).toBe(true)
|
|
400
|
+
expect(window.opener?.postMessage).toHaveBeenCalledWith(
|
|
401
|
+
{ type: 'oauth_success' },
|
|
402
|
+
'https://app.example.com',
|
|
403
|
+
)
|
|
404
|
+
expect(window.close).toHaveBeenCalled()
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('returns false and posts error message on error param', () => {
|
|
408
|
+
window.location.search = '?error=access_denied'
|
|
409
|
+
|
|
410
|
+
const result = handleOAuthCallback()
|
|
411
|
+
|
|
412
|
+
expect(result).toBe(false)
|
|
413
|
+
expect(window.opener?.postMessage).toHaveBeenCalledWith(
|
|
414
|
+
{ type: 'oauth_error', error: 'access_denied' },
|
|
415
|
+
'https://app.example.com',
|
|
416
|
+
)
|
|
417
|
+
expect(window.close).toHaveBeenCalled()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('supports custom success param name', () => {
|
|
421
|
+
window.location.search = '?custom_param=true'
|
|
422
|
+
|
|
423
|
+
const result = handleOAuthCallback('custom_param')
|
|
424
|
+
|
|
425
|
+
expect(result).toBe(true)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('returns false when no opener', () => {
|
|
429
|
+
window.opener = null
|
|
430
|
+
window.location.search = '?oauth_success=true'
|
|
431
|
+
|
|
432
|
+
const result = handleOAuthCallback()
|
|
433
|
+
|
|
434
|
+
expect(result).toBe(false)
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('returns false when no relevant params', () => {
|
|
438
|
+
window.location.search = '?some_other_param=value'
|
|
439
|
+
|
|
440
|
+
const result = handleOAuthCallback()
|
|
441
|
+
|
|
442
|
+
expect(result).toBe(false)
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
})
|
package/src/oauth.ts
CHANGED
|
@@ -7,56 +7,17 @@ export const OAUTH_PROVIDERS = {
|
|
|
7
7
|
export type OAuthProvider =
|
|
8
8
|
(typeof OAUTH_PROVIDERS)[keyof typeof OAUTH_PROVIDERS]
|
|
9
9
|
|
|
10
|
-
export type
|
|
11
|
-
googleClientId?: string
|
|
12
|
-
redirectUri: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type OAuthFlowParams = {
|
|
10
|
+
export type BackendOAuthFlowParams = {
|
|
16
11
|
provider: OAuthProvider
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
backendUrl: string
|
|
13
|
+
projectId: string
|
|
14
|
+
publicKey: string
|
|
15
|
+
returnTo: string
|
|
21
16
|
}
|
|
22
17
|
|
|
23
|
-
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
24
|
-
|
|
25
18
|
const POPUP_WIDTH = 500
|
|
26
19
|
const POPUP_HEIGHT = 600
|
|
27
20
|
|
|
28
|
-
export function buildOAuthUrl(params: OAuthFlowParams): string {
|
|
29
|
-
const { provider, clientId, redirectUri, nonce, state } = params
|
|
30
|
-
|
|
31
|
-
if (provider !== OAUTH_PROVIDERS.GOOGLE) {
|
|
32
|
-
throw new Error(`Unsupported OAuth provider: ${provider}`)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const authUrl = new URL(GOOGLE_AUTH_URL)
|
|
36
|
-
authUrl.searchParams.set('client_id', clientId)
|
|
37
|
-
authUrl.searchParams.set('redirect_uri', redirectUri)
|
|
38
|
-
authUrl.searchParams.set('response_type', 'id_token')
|
|
39
|
-
authUrl.searchParams.set('scope', 'openid email profile')
|
|
40
|
-
authUrl.searchParams.set('nonce', nonce)
|
|
41
|
-
authUrl.searchParams.set('prompt', 'select_account')
|
|
42
|
-
|
|
43
|
-
let stateParam = `provider=${provider}`
|
|
44
|
-
if (state) {
|
|
45
|
-
const additionalState = Object.entries(state)
|
|
46
|
-
.map(
|
|
47
|
-
([key, value]) =>
|
|
48
|
-
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
|
|
49
|
-
)
|
|
50
|
-
.join('&')
|
|
51
|
-
if (additionalState) {
|
|
52
|
-
stateParam += `&${additionalState}`
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
authUrl.searchParams.set('state', stateParam)
|
|
56
|
-
|
|
57
|
-
return authUrl.toString()
|
|
58
|
-
}
|
|
59
|
-
|
|
60
21
|
export function openOAuthPopup(url: string): Window | null {
|
|
61
22
|
const width = POPUP_WIDTH
|
|
62
23
|
const height = POPUP_HEIGHT
|
|
@@ -76,49 +37,107 @@ export function openOAuthPopup(url: string): Window | null {
|
|
|
76
37
|
return authWindow
|
|
77
38
|
}
|
|
78
39
|
|
|
79
|
-
export function
|
|
80
|
-
|
|
81
|
-
|
|
40
|
+
export function generateOAuthNonce(publicKey: string): string {
|
|
41
|
+
return sha256(publicKey as Hex).replace(/^0x/, '')
|
|
42
|
+
}
|
|
82
43
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Build OAuth URL that redirects to backend's OAuth endpoint
|
|
46
|
+
* The backend handles PKCE, client credentials, and token exchange
|
|
47
|
+
*/
|
|
48
|
+
export function buildBackendOAuthUrl(params: BackendOAuthFlowParams): string {
|
|
49
|
+
const { provider, backendUrl, projectId, publicKey, returnTo } = params
|
|
50
|
+
|
|
51
|
+
if (provider !== OAUTH_PROVIDERS.GOOGLE) {
|
|
52
|
+
throw new Error(`Unsupported OAuth provider: ${provider}`)
|
|
86
53
|
}
|
|
87
54
|
|
|
88
|
-
|
|
55
|
+
const oauthUrl = new URL(`${backendUrl}/oauth/google/login`)
|
|
56
|
+
oauthUrl.searchParams.set('project_id', projectId)
|
|
57
|
+
oauthUrl.searchParams.set('pub_key', publicKey.replace(/^0x/, ''))
|
|
58
|
+
oauthUrl.searchParams.set('return_to', returnTo)
|
|
59
|
+
|
|
60
|
+
return oauthUrl.toString()
|
|
89
61
|
}
|
|
90
62
|
|
|
91
|
-
export
|
|
63
|
+
export type OAuthMessageData = {
|
|
64
|
+
type: 'oauth_success' | 'oauth_error'
|
|
65
|
+
error?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Listen for OAuth completion via postMessage from popup
|
|
70
|
+
* The popup sends a message when it detects a successful redirect
|
|
71
|
+
*/
|
|
72
|
+
export function listenForOAuthMessage(
|
|
92
73
|
authWindow: Window,
|
|
93
|
-
|
|
94
|
-
onSuccess: (
|
|
74
|
+
expectedOrigin: string,
|
|
75
|
+
onSuccess: () => void,
|
|
95
76
|
onError: (error: Error) => void,
|
|
96
|
-
): void {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// Ignore cross-origin errors
|
|
77
|
+
): () => void {
|
|
78
|
+
let cleaned = false
|
|
79
|
+
|
|
80
|
+
const handleMessage = (event: MessageEvent<OAuthMessageData>) => {
|
|
81
|
+
// Only trust messages from expected origin
|
|
82
|
+
if (event.origin !== expectedOrigin) return
|
|
83
|
+
if (!event.data || typeof event.data !== 'object') return
|
|
84
|
+
|
|
85
|
+
if (event.data.type === 'oauth_success') {
|
|
86
|
+
cleanup()
|
|
87
|
+
onSuccess()
|
|
88
|
+
} else if (event.data.type === 'oauth_error') {
|
|
89
|
+
cleanup()
|
|
90
|
+
onError(new Error(event.data.error || 'OAuth authentication failed'))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const checkWindowClosed = setInterval(() => {
|
|
95
|
+
if (authWindow.closed) {
|
|
96
|
+
cleanup()
|
|
97
|
+
onError(new Error('Authentication window was closed'))
|
|
118
98
|
}
|
|
119
99
|
}, 500)
|
|
100
|
+
|
|
101
|
+
const cleanup = () => {
|
|
102
|
+
if (cleaned) return
|
|
103
|
+
cleaned = true
|
|
104
|
+
window.removeEventListener('message', handleMessage)
|
|
105
|
+
clearInterval(checkWindowClosed)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
window.addEventListener('message', handleMessage)
|
|
109
|
+
|
|
110
|
+
return cleanup
|
|
120
111
|
}
|
|
121
112
|
|
|
122
|
-
|
|
123
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Handle OAuth callback on the return page
|
|
115
|
+
* Call this on the page that receives the OAuth redirect
|
|
116
|
+
* It sends a postMessage to the opener and closes the window
|
|
117
|
+
*/
|
|
118
|
+
export function handleOAuthCallback(successParam = 'oauth_success'): boolean {
|
|
119
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
120
|
+
const isSuccess = urlParams.get(successParam) === 'true'
|
|
121
|
+
const error = urlParams.get('error')
|
|
122
|
+
|
|
123
|
+
if (window.opener) {
|
|
124
|
+
if (isSuccess) {
|
|
125
|
+
window.opener.postMessage(
|
|
126
|
+
{ type: 'oauth_success' } satisfies OAuthMessageData,
|
|
127
|
+
window.location.origin,
|
|
128
|
+
)
|
|
129
|
+
window.close()
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
if (error) {
|
|
133
|
+
window.opener.postMessage(
|
|
134
|
+
{ type: 'oauth_error', error } satisfies OAuthMessageData,
|
|
135
|
+
window.location.origin,
|
|
136
|
+
)
|
|
137
|
+
window.close()
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return false
|
|
124
143
|
}
|
package/src/provider.ts
CHANGED
|
@@ -115,7 +115,10 @@ export function createProvider({
|
|
|
115
115
|
|
|
116
116
|
async request({ method, params }: { method: string; params?: any[] }) {
|
|
117
117
|
const state = store.getState()
|
|
118
|
-
const activeChainId = state.
|
|
118
|
+
const activeChainId = state.activeChainId
|
|
119
|
+
if (!activeChainId) {
|
|
120
|
+
throw new Error('No active chain')
|
|
121
|
+
}
|
|
119
122
|
|
|
120
123
|
switch (method) {
|
|
121
124
|
case 'eth_accounts': {
|
|
@@ -219,7 +222,7 @@ export function createProvider({
|
|
|
219
222
|
const chainId_number = parseInt(chainId, 16)
|
|
220
223
|
|
|
221
224
|
// Update active chain
|
|
222
|
-
store.getState().
|
|
225
|
+
store.getState().setActiveChainId(chainId_number)
|
|
223
226
|
|
|
224
227
|
// Emit chainChanged event
|
|
225
228
|
emitter.emit('chainChanged', chainId)
|