@0xsequence/wallet-wdk 3.0.1 → 3.0.3

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 (48) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +1 -1
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +24 -0
  5. package/dist/sequence/handlers/authcode.d.ts.map +1 -1
  6. package/dist/sequence/handlers/authcode.js +6 -0
  7. package/dist/sequence/handlers/identity.d.ts +1 -0
  8. package/dist/sequence/handlers/identity.d.ts.map +1 -1
  9. package/dist/sequence/handlers/identity.js +3 -0
  10. package/dist/sequence/handlers/idtoken.d.ts +33 -0
  11. package/dist/sequence/handlers/idtoken.d.ts.map +1 -0
  12. package/dist/sequence/handlers/idtoken.js +110 -0
  13. package/dist/sequence/handlers/index.d.ts +1 -0
  14. package/dist/sequence/handlers/index.d.ts.map +1 -1
  15. package/dist/sequence/handlers/index.js +1 -0
  16. package/dist/sequence/manager.d.ts +20 -14
  17. package/dist/sequence/manager.d.ts.map +1 -1
  18. package/dist/sequence/manager.js +21 -3
  19. package/dist/sequence/sessions.d.ts.map +1 -1
  20. package/dist/sequence/sessions.js +5 -1
  21. package/dist/sequence/signers.d.ts.map +1 -1
  22. package/dist/sequence/signers.js +4 -0
  23. package/dist/sequence/types/signer.d.ts +1 -1
  24. package/dist/sequence/types/signer.js +1 -1
  25. package/dist/sequence/types/wallet.d.ts +1 -1
  26. package/dist/sequence/wallets.d.ts +7 -1
  27. package/dist/sequence/wallets.d.ts.map +1 -1
  28. package/dist/sequence/wallets.js +69 -7
  29. package/package.json +6 -6
  30. package/src/sequence/handlers/authcode.ts +6 -0
  31. package/src/sequence/handlers/identity.ts +4 -0
  32. package/src/sequence/handlers/idtoken.ts +140 -0
  33. package/src/sequence/handlers/index.ts +1 -0
  34. package/src/sequence/manager.ts +78 -29
  35. package/src/sequence/sessions.ts +7 -1
  36. package/src/sequence/signers.ts +5 -0
  37. package/src/sequence/types/signer.ts +1 -1
  38. package/src/sequence/types/wallet.ts +1 -1
  39. package/src/sequence/wallets.ts +88 -9
  40. package/test/authcode-pkce.test.ts +1 -1
  41. package/test/authcode.test.ts +2 -2
  42. package/test/identity-auth-dbs.test.ts +86 -2
  43. package/test/identity-signer.test.ts +1 -1
  44. package/test/idtoken.test.ts +327 -0
  45. package/test/sessions-idtoken.test.ts +97 -0
  46. package/test/signers-kindof.test.ts +22 -0
  47. package/test/transactions.test.ts +19 -0
  48. package/test/wallets.test.ts +141 -1
@@ -3,6 +3,7 @@ import { Config, Constants, Payload } from '@0xsequence/wallet-primitives'
3
3
  import { Address, Hex, Provider, RpcTransport } from 'ox'
4
4
  import { AuthCommitment } from '../dbs/auth-commitments.js'
5
5
  import { AuthCodeHandler } from './handlers/authcode.js'
6
+ import { IdTokenHandler } from './handlers/idtoken.js'
6
7
  import { MnemonicHandler } from './handlers/mnemonic.js'
7
8
  import { OtpHandler } from './handlers/otp.js'
8
9
  import { Shared } from './manager.js'
@@ -13,6 +14,37 @@ import { Wallet, WalletSelectionUiHandler } from './types/wallet.js'
13
14
  import { PasskeysHandler } from './handlers/passkeys.js'
14
15
  import type { PasskeySigner } from './passkeys-provider.js'
15
16
 
17
+ function getSignupHandlerKey(kind: SignupArgs['kind'] | StartSignUpWithRedirectArgs['kind'] | AuthCommitment['kind']) {
18
+ if (kind === 'google-pkce') {
19
+ return Kinds.LoginGoogle
20
+ }
21
+ if (kind.startsWith('custom-')) {
22
+ return kind
23
+ }
24
+ return 'login-' + kind
25
+ }
26
+
27
+ function getSignerKindForSignup(kind: SignupArgs['kind'] | AuthCommitment['kind']) {
28
+ if (kind === 'google-id-token' || kind === 'google-pkce') {
29
+ return Kinds.LoginGoogle
30
+ }
31
+ if (kind.startsWith('custom-')) {
32
+ return kind
33
+ }
34
+ return ('login-' + kind) as string
35
+ }
36
+
37
+ function getIdTokenSignupHandler(shared: Shared, kind: typeof Kinds.LoginGoogle | `custom-${string}`): IdTokenHandler {
38
+ const handler = shared.handlers.get(kind)
39
+ if (!handler) {
40
+ throw new Error('handler-not-registered')
41
+ }
42
+ if (!(handler instanceof IdTokenHandler)) {
43
+ throw new Error('handler-does-not-support-id-token')
44
+ }
45
+ return handler
46
+ }
47
+
16
48
  export type StartSignUpWithRedirectArgs = {
17
49
  kind: 'google-pkce' | 'apple' | `custom-${string}`
18
50
  target: string
@@ -49,6 +81,11 @@ export type EmailOtpSignupArgs = CommonSignupArgs & {
49
81
  email: string
50
82
  }
51
83
 
84
+ export type IdTokenSignupArgs = CommonSignupArgs & {
85
+ kind: 'google-id-token' | `custom-${string}`
86
+ idToken: string
87
+ }
88
+
52
89
  export type CompleteRedirectArgs = CommonSignupArgs & {
53
90
  state: string
54
91
  code: string
@@ -62,7 +99,12 @@ export type AuthCodeSignupArgs = CommonSignupArgs & {
62
99
  isRedirect: boolean
63
100
  }
64
101
 
65
- export type SignupArgs = PasskeySignupArgs | MnemonicSignupArgs | EmailOtpSignupArgs | AuthCodeSignupArgs
102
+ export type SignupArgs =
103
+ | PasskeySignupArgs
104
+ | MnemonicSignupArgs
105
+ | EmailOtpSignupArgs
106
+ | IdTokenSignupArgs
107
+ | AuthCodeSignupArgs
66
108
 
67
109
  export type LoginToWalletArgs = {
68
110
  wallet: Address.Address
@@ -180,6 +222,7 @@ export interface WalletsInterface {
180
222
  * - `kind: 'mnemonic'`: Uses a mnemonic phrase as the login credential.
181
223
  * - `kind: 'passkey'`: Prompts the user to create a WebAuthn passkey.
182
224
  * - `kind: 'email-otp'`: Initiates an OTP flow to the user's email.
225
+ * - `kind: 'google-id-token'`: Completes an OIDC ID token flow when Google is configured with `authMethod: 'id-token'`.
183
226
  * - `kind: 'google-pkce' | 'apple'`: Completes an OAuth redirect flow.
184
227
  * Common options like `noGuard` or `noRecovery` can customize the wallet's security features.
185
228
  * @returns A promise that resolves to the address of the newly created wallet, or `undefined` if the sign-up was aborted.
@@ -361,7 +404,11 @@ export function isLoginToPasskeyArgs(args: LoginArgs): args is LoginToPasskeyArg
361
404
  }
362
405
 
363
406
  export function isAuthCodeArgs(args: SignupArgs): args is AuthCodeSignupArgs {
364
- return 'kind' in args && (args.kind === 'google-pkce' || args.kind === 'apple')
407
+ return 'code' in args && 'commitment' in args
408
+ }
409
+
410
+ export function isIdTokenArgs(args: SignupArgs): args is IdTokenSignupArgs {
411
+ return 'idToken' in args
365
412
  }
366
413
 
367
414
  function buildCappedTree(members: { address: Address.Address; imageHash?: Hex.Hex }[]): Config.Topology {
@@ -674,9 +721,24 @@ export class Wallets implements WalletsInterface {
674
721
  }
675
722
  }
676
723
 
724
+ case 'google-id-token': {
725
+ const handler = getIdTokenSignupHandler(this.shared, Kinds.LoginGoogle)
726
+ const [signer, metadata] = await handler.completeAuth(args.idToken)
727
+ const loginEmail = metadata.email
728
+ this.shared.modules.logger.log('Created new id token signer:', signer.address)
729
+
730
+ return {
731
+ signer,
732
+ extra: {
733
+ signerKind: Kinds.LoginGoogle,
734
+ },
735
+ loginEmail,
736
+ }
737
+ }
738
+
677
739
  case 'google-pkce':
678
740
  case 'apple': {
679
- const handler = this.shared.handlers.get('login-' + args.kind) as AuthCodeHandler
741
+ const handler = this.shared.handlers.get(getSignupHandlerKey(args.kind)) as AuthCodeHandler
680
742
  if (!handler) {
681
743
  throw new Error('handler-not-registered')
682
744
  }
@@ -688,7 +750,7 @@ export class Wallets implements WalletsInterface {
688
750
  return {
689
751
  signer,
690
752
  extra: {
691
- signerKind: 'login-' + args.kind,
753
+ signerKind: getSignerKindForSignup(args.kind),
692
754
  },
693
755
  loginEmail,
694
756
  }
@@ -696,7 +758,18 @@ export class Wallets implements WalletsInterface {
696
758
  }
697
759
 
698
760
  if (args.kind.startsWith('custom-')) {
699
- // TODO: support other custom auth methods (e.g. id-token)
761
+ if (isIdTokenArgs(args)) {
762
+ const handler = getIdTokenSignupHandler(this.shared, args.kind)
763
+ const [signer, metadata] = await handler.completeAuth(args.idToken)
764
+ return {
765
+ signer,
766
+ extra: {
767
+ signerKind: args.kind,
768
+ },
769
+ loginEmail: metadata.email,
770
+ }
771
+ }
772
+
700
773
  const handler = this.shared.handlers.get(args.kind) as AuthCodeHandler
701
774
  if (!handler) {
702
775
  throw new Error('handler-not-registered')
@@ -716,11 +789,14 @@ export class Wallets implements WalletsInterface {
716
789
  }
717
790
 
718
791
  async startSignUpWithRedirect(args: StartSignUpWithRedirectArgs) {
719
- const kind = args.kind.startsWith('custom-') ? args.kind : 'login-' + args.kind
720
- const handler = this.shared.handlers.get(kind) as AuthCodeHandler
792
+ const kind = getSignupHandlerKey(args.kind)
793
+ const handler = this.shared.handlers.get(kind)
721
794
  if (!handler) {
722
795
  throw new Error('handler-not-registered')
723
796
  }
797
+ if (!(handler instanceof AuthCodeHandler)) {
798
+ throw new Error('handler-does-not-support-redirect')
799
+ }
724
800
  return handler.commitAuth(args.target, true)
725
801
  }
726
802
 
@@ -742,11 +818,14 @@ export class Wallets implements WalletsInterface {
742
818
  use4337: args.use4337,
743
819
  })
744
820
  } else {
745
- const kind = commitment.kind.startsWith('custom-') ? commitment.kind : 'login-' + commitment.kind
746
- const handler = this.shared.handlers.get(kind) as AuthCodeHandler
821
+ const handlerKind = getSignupHandlerKey(commitment.kind)
822
+ const handler = this.shared.handlers.get(handlerKind)
747
823
  if (!handler) {
748
824
  throw new Error('handler-not-registered')
749
825
  }
826
+ if (!(handler instanceof AuthCodeHandler)) {
827
+ throw new Error('handler-does-not-support-redirect')
828
+ }
750
829
 
751
830
  await handler.completeAuth(commitment, args.code)
752
831
  }
@@ -326,7 +326,7 @@ describe('AuthCodePkceHandler', () => {
326
326
 
327
327
  describe('Integration and Edge Cases', () => {
328
328
  it('Should have correct kind property', () => {
329
- expect(handler.kind).toBe('login-google-pkce')
329
+ expect(handler.kind).toBe('login-google')
330
330
  })
331
331
 
332
332
  it('Should handle redirect URI configuration', () => {
@@ -188,7 +188,7 @@ describe('AuthCodeHandler', () => {
188
188
  // === KIND GETTER ===
189
189
 
190
190
  describe('kind getter', () => {
191
- it('Should return login-google-pkce for Google PKCE handler', () => {
191
+ it('Should return login-google for Google PKCE handler', () => {
192
192
  const googleHandler = new AuthCodeHandler(
193
193
  'google-pkce',
194
194
  'https://accounts.google.com',
@@ -200,7 +200,7 @@ describe('AuthCodeHandler', () => {
200
200
  mockAuthKeys,
201
201
  )
202
202
 
203
- expect(googleHandler.kind).toBe('login-google-pkce')
203
+ expect(googleHandler.kind).toBe('login-google')
204
204
  })
205
205
 
206
206
  it('Should return login-apple for Apple handler', () => {
@@ -351,9 +351,48 @@ describe('Identity Authentication Databases', () => {
351
351
  },
352
352
  })
353
353
 
354
- // Verify that Google handler is registered and uses our databases
354
+ // Verify that Google is registered under the canonical signer kind while
355
+ // still using the PKCE flow by default.
355
356
  const handlers = (manager as any).shared.handlers
356
- expect(handlers.has('login-google-pkce')).toBe(true)
357
+ expect(handlers.has('login-google')).toBe(true)
358
+ expect(handlers.has('login-google-pkce')).toBe(false)
359
+ })
360
+
361
+ it('Should register the Google ID token handler when configured explicitly', async () => {
362
+ manager = new Manager({
363
+ stateProvider: new State.Local.Provider(new State.Local.IndexedDbStore(`manager-google-idtoken-${Date.now()}`)),
364
+ networks: [
365
+ {
366
+ name: 'Test Network',
367
+ type: Network.NetworkType.MAINNET,
368
+ rpcUrl: LOCAL_RPC_URL,
369
+ chainId: Network.ChainId.ARBITRUM,
370
+ blockExplorer: { url: 'https://arbiscan.io' },
371
+ nativeCurrency: {
372
+ name: 'Ether',
373
+ symbol: 'ETH',
374
+ decimals: 18,
375
+ },
376
+ },
377
+ ],
378
+ relayers: [],
379
+ authCommitmentsDb,
380
+ authKeysDb,
381
+ identity: {
382
+ url: 'https://dev-identity.sequence-dev.app',
383
+ fetch: window.fetch,
384
+ google: {
385
+ enabled: true,
386
+ clientId: 'test-google-client-id',
387
+ authMethod: 'id-token',
388
+ },
389
+ },
390
+ })
391
+
392
+ const handlers = (manager as any).shared.handlers
393
+ expect(handlers.has('login-google-id-token')).toBe(false)
394
+ expect(handlers.has('login-google')).toBe(true)
395
+ expect(handlers.has('login-google-pkce')).toBe(false)
357
396
  })
358
397
 
359
398
  it('Should use auth databases when email authentication is enabled', async () => {
@@ -424,5 +463,50 @@ describe('Identity Authentication Databases', () => {
424
463
  const handlers = (manager as any).shared.handlers
425
464
  expect(handlers.has('login-apple')).toBe(true)
426
465
  })
466
+
467
+ it('Should register custom ID token providers without enabling redirect flow for them', async () => {
468
+ manager = new Manager({
469
+ stateProvider: new State.Local.Provider(new State.Local.IndexedDbStore(`manager-custom-idtoken-${Date.now()}`)),
470
+ networks: [
471
+ {
472
+ name: 'Test Network',
473
+ type: Network.NetworkType.MAINNET,
474
+ rpcUrl: LOCAL_RPC_URL,
475
+ chainId: Network.ChainId.ARBITRUM,
476
+ blockExplorer: { url: 'https://arbiscan.io' },
477
+ nativeCurrency: {
478
+ name: 'Ether',
479
+ symbol: 'ETH',
480
+ decimals: 18,
481
+ },
482
+ },
483
+ ],
484
+ relayers: [],
485
+ authCommitmentsDb,
486
+ authKeysDb,
487
+ identity: {
488
+ url: 'https://dev-identity.sequence-dev.app',
489
+ fetch: window.fetch,
490
+ customProviders: [
491
+ {
492
+ kind: 'custom-google-native',
493
+ authMethod: 'id-token',
494
+ issuer: 'https://accounts.google.com',
495
+ clientId: 'test-google-client-id',
496
+ },
497
+ ],
498
+ },
499
+ })
500
+
501
+ const handlers = (manager as any).shared.handlers
502
+ expect(handlers.has('custom-google-native')).toBe(true)
503
+ await expect(
504
+ manager.wallets.startSignUpWithRedirect({
505
+ kind: 'custom-google-native',
506
+ target: '/home',
507
+ metadata: {},
508
+ }),
509
+ ).rejects.toThrow('handler-does-not-support-redirect')
510
+ })
427
511
  })
428
512
  })
@@ -13,7 +13,7 @@ const mockCryptoSubtle = {
13
13
  exportKey: vi.fn(),
14
14
  }
15
15
 
16
- Object.defineProperty(global, 'window', {
16
+ Object.defineProperty(globalThis, 'window', {
17
17
  value: {
18
18
  crypto: {
19
19
  subtle: mockCryptoSubtle,
@@ -0,0 +1,327 @@
1
+ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'
2
+ import { Address, Hex } from 'ox'
3
+ import { Network, Payload } from '@0xsequence/wallet-primitives'
4
+ import { IdentityInstrument, IdentityType } from '@0xsequence/identity-instrument'
5
+ import { IdTokenHandler, PromptIdTokenHandler } from '../src/sequence/handlers/idtoken.js'
6
+ import { Signatures } from '../src/sequence/signatures.js'
7
+ import * as Db from '../src/dbs/index.js'
8
+ import { IdentitySigner } from '../src/identity/signer.js'
9
+ import { BaseSignatureRequest } from '../src/sequence/types/signature-request.js'
10
+ import { Kinds } from '../src/sequence/types/signer.js'
11
+
12
+ describe('IdTokenHandler', () => {
13
+ let idTokenHandler: IdTokenHandler
14
+ let mockNitroInstrument: IdentityInstrument
15
+ let mockSignatures: Signatures
16
+ let mockAuthKeys: Db.AuthKeys
17
+ let mockIdentitySigner: IdentitySigner
18
+ let testWallet: Address.Address
19
+ let testRequest: BaseSignatureRequest
20
+ let mockPromptIdToken: Mock<PromptIdTokenHandler>
21
+
22
+ beforeEach(() => {
23
+ vi.clearAllMocks()
24
+
25
+ testWallet = '0x1234567890123456789012345678901234567890' as Address.Address
26
+
27
+ mockNitroInstrument = {
28
+ commitVerifier: vi.fn(),
29
+ completeAuth: vi.fn(),
30
+ } as unknown as IdentityInstrument
31
+
32
+ mockSignatures = {
33
+ addSignature: vi.fn(),
34
+ } as unknown as Signatures
35
+
36
+ mockAuthKeys = {
37
+ set: vi.fn(),
38
+ get: vi.fn(),
39
+ del: vi.fn(),
40
+ delBySigner: vi.fn(),
41
+ getBySigner: vi.fn(),
42
+ addListener: vi.fn(),
43
+ } as unknown as Db.AuthKeys
44
+
45
+ mockIdentitySigner = {
46
+ address: testWallet,
47
+ sign: vi.fn(),
48
+ } as unknown as IdentitySigner
49
+
50
+ testRequest = {
51
+ id: 'test-request-id',
52
+ envelope: {
53
+ wallet: testWallet,
54
+ chainId: Network.ChainId.ARBITRUM,
55
+ payload: Payload.fromMessage(Hex.fromString('Test message')),
56
+ },
57
+ } as BaseSignatureRequest
58
+
59
+ mockPromptIdToken = vi.fn()
60
+
61
+ idTokenHandler = new IdTokenHandler(
62
+ 'google-id-token',
63
+ 'https://accounts.google.com',
64
+ 'test-google-client-id',
65
+ mockNitroInstrument,
66
+ mockSignatures,
67
+ mockAuthKeys,
68
+ )
69
+
70
+ vi.spyOn(idTokenHandler as any, 'nitroCommitVerifier').mockResolvedValue({
71
+ verifier: 'unused-verifier',
72
+ loginHint: '',
73
+ challenge: '',
74
+ })
75
+
76
+ vi.spyOn(idTokenHandler as any, 'nitroCompleteAuth').mockResolvedValue({
77
+ signer: mockIdentitySigner,
78
+ email: 'user@example.com',
79
+ })
80
+ })
81
+
82
+ afterEach(() => {
83
+ vi.resetAllMocks()
84
+ })
85
+
86
+ describe('Constructor', () => {
87
+ it('Should create IdTokenHandler with correct properties', () => {
88
+ const handler = new IdTokenHandler(
89
+ 'google-id-token',
90
+ 'https://accounts.google.com',
91
+ 'test-google-client-id',
92
+ mockNitroInstrument,
93
+ mockSignatures,
94
+ mockAuthKeys,
95
+ )
96
+
97
+ expect(handler.signupKind).toBe('google-id-token')
98
+ expect(handler.issuer).toBe('https://accounts.google.com')
99
+ expect(handler.audience).toBe('test-google-client-id')
100
+ expect(handler.identityType).toBe(IdentityType.OIDC)
101
+ expect(handler.kind).toBe(Kinds.LoginGoogle)
102
+ })
103
+
104
+ it('Should initialize without a registered UI callback', () => {
105
+ expect(idTokenHandler['onPromptIdToken']).toBeUndefined()
106
+ })
107
+ })
108
+
109
+ describe('UI Registration', () => {
110
+ it('Should register ID token UI callback', () => {
111
+ const unregister = idTokenHandler.registerUI(mockPromptIdToken)
112
+
113
+ expect(idTokenHandler['onPromptIdToken']).toBe(mockPromptIdToken)
114
+ expect(typeof unregister).toBe('function')
115
+ })
116
+
117
+ it('Should unregister UI callback when returned function is called', () => {
118
+ const unregister = idTokenHandler.registerUI(mockPromptIdToken)
119
+ expect(idTokenHandler['onPromptIdToken']).toBe(mockPromptIdToken)
120
+
121
+ unregister()
122
+
123
+ expect(idTokenHandler['onPromptIdToken']).toBeUndefined()
124
+ })
125
+
126
+ it('Should unregister UI callback directly', () => {
127
+ idTokenHandler.registerUI(mockPromptIdToken)
128
+ expect(idTokenHandler['onPromptIdToken']).toBe(mockPromptIdToken)
129
+
130
+ idTokenHandler.unregisterUI()
131
+
132
+ expect(idTokenHandler['onPromptIdToken']).toBeUndefined()
133
+ })
134
+
135
+ it('Should allow multiple registrations by overwriting the previous callback', () => {
136
+ const secondCallback = vi.fn()
137
+
138
+ idTokenHandler.registerUI(mockPromptIdToken)
139
+ expect(idTokenHandler['onPromptIdToken']).toBe(mockPromptIdToken)
140
+
141
+ idTokenHandler.registerUI(secondCallback)
142
+
143
+ expect(idTokenHandler['onPromptIdToken']).toBe(secondCallback)
144
+ })
145
+ })
146
+
147
+ describe('completeAuth()', () => {
148
+ it('Should complete auth using an OIDC ID token challenge', async () => {
149
+ const idToken = 'eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9.'
150
+
151
+ const [signer, metadata] = await idTokenHandler.completeAuth(idToken)
152
+
153
+ expect(idTokenHandler['nitroCommitVerifier']).toHaveBeenCalledWith(
154
+ expect.objectContaining({
155
+ issuer: 'https://accounts.google.com',
156
+ audience: 'test-google-client-id',
157
+ idToken,
158
+ }),
159
+ )
160
+ expect(idTokenHandler['nitroCompleteAuth']).toHaveBeenCalledWith(
161
+ expect.objectContaining({
162
+ issuer: 'https://accounts.google.com',
163
+ audience: 'test-google-client-id',
164
+ idToken,
165
+ }),
166
+ )
167
+ expect(signer).toBe(mockIdentitySigner)
168
+ expect(metadata).toEqual({ email: 'user@example.com' })
169
+ })
170
+ })
171
+
172
+ describe('getSigner()', () => {
173
+ it('Should throw when UI is not registered', async () => {
174
+ await expect(idTokenHandler.getSigner()).rejects.toThrow('id-token-handler-ui-not-registered')
175
+ })
176
+
177
+ it('Should acquire a signer by prompting for a fresh ID token', async () => {
178
+ const idToken = 'header.payload.signature'
179
+ const completeAuthSpy = vi
180
+ .spyOn(idTokenHandler, 'completeAuth')
181
+ .mockResolvedValue([mockIdentitySigner, { email: 'user@example.com' }])
182
+
183
+ mockPromptIdToken.mockImplementation(async (kind, respond) => {
184
+ expect(kind).toBe('google-id-token')
185
+ await respond(idToken)
186
+ })
187
+
188
+ idTokenHandler.registerUI(mockPromptIdToken)
189
+
190
+ const result = await idTokenHandler.getSigner()
191
+
192
+ expect(result).toEqual({
193
+ signer: mockIdentitySigner,
194
+ email: 'user@example.com',
195
+ })
196
+ expect(completeAuthSpy).toHaveBeenCalledWith(idToken)
197
+ expect(mockPromptIdToken).toHaveBeenCalledWith('google-id-token', expect.any(Function))
198
+ })
199
+
200
+ it('Should surface authentication failures from completeAuth', async () => {
201
+ const error = new Error('Authentication failed')
202
+ vi.spyOn(idTokenHandler, 'completeAuth').mockRejectedValue(error)
203
+
204
+ mockPromptIdToken.mockImplementation(async (_kind, respond) => {
205
+ await respond('header.payload.signature')
206
+ })
207
+
208
+ idTokenHandler.registerUI(mockPromptIdToken)
209
+
210
+ await expect(idTokenHandler.getSigner()).rejects.toThrow('Authentication failed')
211
+ })
212
+
213
+ it('Should surface UI callback errors', async () => {
214
+ mockPromptIdToken.mockRejectedValue(new Error('UI callback failed'))
215
+ idTokenHandler.registerUI(mockPromptIdToken)
216
+
217
+ await expect(idTokenHandler.getSigner()).rejects.toThrow('UI callback failed')
218
+ })
219
+ })
220
+
221
+ describe('status()', () => {
222
+ it('Should return ready status when an auth key signer is available', async () => {
223
+ vi.spyOn(idTokenHandler as any, 'getAuthKeySigner').mockResolvedValue(mockIdentitySigner)
224
+
225
+ const status = await idTokenHandler.status(testWallet, undefined, testRequest)
226
+
227
+ expect(status.status).toBe('ready')
228
+ expect(status.handler).toBe(idTokenHandler)
229
+ })
230
+
231
+ it('Should sign the request when ready handle is invoked', async () => {
232
+ vi.spyOn(idTokenHandler as any, 'getAuthKeySigner').mockResolvedValue(mockIdentitySigner)
233
+ const signSpy = vi.spyOn(idTokenHandler as any, 'sign').mockResolvedValue(undefined)
234
+
235
+ const status = await idTokenHandler.status(testWallet, undefined, testRequest)
236
+ const handled = await (status as any).handle()
237
+
238
+ expect(handled).toBe(true)
239
+ expect(signSpy).toHaveBeenCalledWith(mockIdentitySigner, testRequest)
240
+ })
241
+
242
+ it('Should return unavailable when no auth key signer exists and UI is not registered', async () => {
243
+ vi.spyOn(idTokenHandler as any, 'getAuthKeySigner').mockResolvedValue(undefined)
244
+
245
+ const status = await idTokenHandler.status(testWallet, undefined, testRequest)
246
+
247
+ expect(status).toMatchObject({
248
+ address: testWallet,
249
+ handler: idTokenHandler,
250
+ status: 'unavailable',
251
+ reason: 'ui-not-registered',
252
+ })
253
+ })
254
+
255
+ it('Should return actionable when no auth key signer exists and UI is registered', async () => {
256
+ vi.spyOn(idTokenHandler as any, 'getAuthKeySigner').mockResolvedValue(undefined)
257
+ idTokenHandler.registerUI(mockPromptIdToken)
258
+
259
+ const status = await idTokenHandler.status(testWallet, undefined, testRequest)
260
+
261
+ expect(status.status).toBe('actionable')
262
+ expect(status.address).toBe(testWallet)
263
+ expect(status.handler).toBe(idTokenHandler)
264
+ expect((status as any).message).toBe('request-id-token')
265
+ expect(typeof (status as any).handle).toBe('function')
266
+ })
267
+
268
+ it('Should reacquire the signer when actionable handle is invoked', async () => {
269
+ vi.spyOn(idTokenHandler as any, 'getAuthKeySigner').mockResolvedValue(undefined)
270
+ const completeAuthSpy = vi
271
+ .spyOn(idTokenHandler, 'completeAuth')
272
+ .mockResolvedValue([mockIdentitySigner, { email: 'user@example.com' }])
273
+
274
+ mockPromptIdToken.mockImplementation(async (_kind, respond) => {
275
+ await respond('header.payload.signature')
276
+ })
277
+
278
+ idTokenHandler.registerUI(mockPromptIdToken)
279
+
280
+ const status = await idTokenHandler.status(testWallet, undefined, testRequest)
281
+ const handled = await (status as any).handle()
282
+
283
+ expect(handled).toBe(true)
284
+ expect(completeAuthSpy).toHaveBeenCalledWith('header.payload.signature')
285
+ })
286
+
287
+ it('Should return false when actionable handle authentication fails', async () => {
288
+ vi.spyOn(idTokenHandler as any, 'getAuthKeySigner').mockResolvedValue(undefined)
289
+ vi.spyOn(idTokenHandler, 'completeAuth').mockRejectedValue(new Error('Authentication failed'))
290
+
291
+ mockPromptIdToken.mockImplementation(async (_kind, respond) => {
292
+ await respond('header.payload.signature')
293
+ })
294
+
295
+ idTokenHandler.registerUI(mockPromptIdToken)
296
+
297
+ const status = await idTokenHandler.status(testWallet, undefined, testRequest)
298
+ const handled = await (status as any).handle()
299
+
300
+ expect(handled).toBe(false)
301
+ })
302
+
303
+ it('Should return false when actionable handle authenticates the wrong signer', async () => {
304
+ vi.spyOn(idTokenHandler as any, 'getAuthKeySigner').mockResolvedValue(undefined)
305
+ const wrongSigner = '0x9999999999999999999999999999999999999999' as Address.Address
306
+ vi.spyOn(idTokenHandler, 'completeAuth').mockResolvedValue([
307
+ {
308
+ ...mockIdentitySigner,
309
+ address: wrongSigner,
310
+ } as unknown as IdentitySigner,
311
+ { email: 'other-user@example.com' },
312
+ ])
313
+
314
+ mockPromptIdToken.mockImplementation(async (_kind, respond) => {
315
+ await respond('header.payload.signature')
316
+ })
317
+
318
+ idTokenHandler.registerUI(mockPromptIdToken)
319
+
320
+ const status = await idTokenHandler.status(testWallet, undefined, testRequest)
321
+ const handled = await (status as any).handle()
322
+
323
+ expect(handled).toBe(false)
324
+ expect(mockAuthKeys.delBySigner).toHaveBeenCalledWith(wrongSigner)
325
+ })
326
+ })
327
+ })