Skip to content

Commit 9685e53

Browse files
Add WDK Google ID token auth flow (#977)
* Add WDK Google ID token auth flow * Unify Google WDK auth kinds * Refine WDK Google id token flow * Fix id-token auth key cleanup on signer mismatch * Restore guard error logging * Unify Google WDK signer kind * Fix WDK auth flow cleanup and implicit session metadata
1 parent 79bf317 commit 9685e53

20 files changed

Lines changed: 1024 additions & 49 deletions

packages/services/guard/src/sequence.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class Guard implements Types.Guard {
5050
throw new Types.AuthRequiredError('PIN')
5151
}
5252
console.error(error)
53-
throw new Error('Error signing with guard')
53+
throw new Error('Error signing with guard', { cause: error })
5454
}
5555
}
5656
}

packages/services/guard/test/sequence.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,21 @@ describe('Sequence', () => {
116116
).rejects.toThrow('Error signing with guard')
117117
})
118118

119+
it('Should preserve the original guard failure as cause', async () => {
120+
mockFetch.mockRejectedValueOnce(new Error('Network error'))
121+
122+
try {
123+
await guard.signPayload(testWallet, 42161, PayloadType.ConfigUpdate, testMessageDigest, testMessage)
124+
throw new Error('Expected signPayload to throw')
125+
} catch (error) {
126+
expect(error).toBeInstanceOf(Error)
127+
expect((error as Error).message).toBe('Error signing with guard')
128+
expect((error as Error & { cause?: unknown }).cause).toBeInstanceOf(Error)
129+
expect((error as Error & { cause?: Error }).cause?.name).toBe('WebrpcRequestFailed')
130+
expect((error as Error & { cause?: Error }).cause?.message).toBe('request failed')
131+
}
132+
})
133+
119134
it('Should include proper headers and signer address in request', async () => {
120135
const mockGuardAddress = '0x9876543210987654321098765432109876543210' as Address.Address
121136
const customGuard = new Guard('https://guard.sequence.app', mockGuardAddress, fetch)

packages/wallet/wdk/src/sequence/handlers/authcode.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as Identity from '@0xsequence/identity-instrument'
66
import { SignerUnavailable, SignerReady, SignerActionable, BaseSignatureRequest } from '../types/signature-request.js'
77
import { IdentitySigner } from '../../identity/signer.js'
88
import { IdentityHandler } from './identity.js'
9+
import { Kinds } from '../types/signer.js'
910
import type { NavigationLike, WdkEnv } from '../../env.js'
1011

1112
export class AuthCodeHandler extends IdentityHandler implements Handler {
@@ -26,6 +27,11 @@ export class AuthCodeHandler extends IdentityHandler implements Handler {
2627
}
2728

2829
public get kind() {
30+
if (this.signupKind === 'google-pkce') {
31+
// Keep Google PKCE on the canonical kind so Google signers created before
32+
// canonicalization still resolve as `login-google`.
33+
return Kinds.LoginGoogle
34+
}
2935
return 'login-' + this.signupKind
3036
}
3137

packages/wallet/wdk/src/sequence/handlers/identity.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export class IdentityHandler {
8181
return new IdentitySigner(this.nitro, authKey, this.env.crypto)
8282
}
8383

84+
protected async clearAuthKeySigner(address: string): Promise<void> {
85+
await this.authKeys.delBySigner(address)
86+
}
87+
8488
private async getAuthKey(signer: string): Promise<Db.AuthKey | undefined> {
8589
let authKey = await this.authKeys.getBySigner(signer)
8690
if (!signer && !authKey) {
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { Address, Hex } from 'ox'
2+
import { Signers } from '@0xsequence/wallet-core'
3+
import { Handler } from './handler.js'
4+
import * as Identity from '@0xsequence/identity-instrument'
5+
import * as Db from '../../dbs/index.js'
6+
import { Signatures } from '../signatures.js'
7+
import { SignerActionable, SignerReady, SignerUnavailable, BaseSignatureRequest } from '../types/signature-request.js'
8+
import { IdentitySigner } from '../../identity/signer.js'
9+
import { IdentityHandler } from './identity.js'
10+
import { Kinds } from '../types/signer.js'
11+
import type { WdkEnv } from '../../env.js'
12+
13+
type RespondFn = (idToken: string) => Promise<void>
14+
15+
export type PromptIdTokenHandler = (kind: 'google-id-token' | `custom-${string}`, respond: RespondFn) => Promise<void>
16+
17+
export class IdTokenHandler extends IdentityHandler implements Handler {
18+
private onPromptIdToken: undefined | PromptIdTokenHandler
19+
20+
constructor(
21+
public readonly signupKind: 'google-id-token' | `custom-${string}`,
22+
public readonly issuer: string,
23+
public readonly audience: string,
24+
nitro: Identity.IdentityInstrument,
25+
signatures: Signatures,
26+
authKeys: Db.AuthKeys,
27+
env?: WdkEnv,
28+
) {
29+
super(nitro, authKeys, signatures, Identity.IdentityType.OIDC, env)
30+
}
31+
32+
public get kind() {
33+
if (this.signupKind === 'google-id-token') {
34+
return Kinds.LoginGoogle
35+
}
36+
return 'login-' + this.signupKind
37+
}
38+
39+
public registerUI(onPromptIdToken: PromptIdTokenHandler) {
40+
this.onPromptIdToken = onPromptIdToken
41+
return () => {
42+
this.onPromptIdToken = undefined
43+
}
44+
}
45+
46+
public unregisterUI() {
47+
this.onPromptIdToken = undefined
48+
}
49+
50+
public async completeAuth(idToken: string): Promise<[IdentitySigner, { [key: string]: string }]> {
51+
const challenge = new Identity.IdTokenChallenge(this.issuer, this.audience, idToken)
52+
await this.nitroCommitVerifier(challenge)
53+
const { signer: identitySigner, email } = await this.nitroCompleteAuth(challenge)
54+
55+
return [identitySigner, { email }]
56+
}
57+
58+
public async getSigner(): Promise<{ signer: Signers.Signer & Signers.Witnessable; email: string }> {
59+
const onPromptIdToken = this.onPromptIdToken
60+
if (!onPromptIdToken) {
61+
throw new Error('id-token-handler-ui-not-registered')
62+
}
63+
64+
return await this.handleAuth(onPromptIdToken)
65+
}
66+
67+
async status(
68+
address: Address.Address,
69+
_imageHash: Hex.Hex | undefined,
70+
request: BaseSignatureRequest,
71+
): Promise<SignerUnavailable | SignerReady | SignerActionable> {
72+
const signer = await this.getAuthKeySigner(address)
73+
if (signer) {
74+
return {
75+
address,
76+
handler: this,
77+
status: 'ready',
78+
handle: async () => {
79+
await this.sign(signer, request)
80+
return true
81+
},
82+
}
83+
}
84+
85+
const onPromptIdToken = this.onPromptIdToken
86+
if (!onPromptIdToken) {
87+
return {
88+
address,
89+
handler: this,
90+
reason: 'ui-not-registered',
91+
status: 'unavailable',
92+
}
93+
}
94+
95+
return {
96+
address,
97+
handler: this,
98+
status: 'actionable',
99+
message: 'request-id-token',
100+
handle: async () => {
101+
try {
102+
const { signer } = await this.handleAuth(onPromptIdToken)
103+
const signerAddress = (await signer.address) as Address.Address
104+
if (!Address.isEqual(signerAddress, address)) {
105+
// ID-token auth prompts are keyed by provider kind, not the requested signer address.
106+
// For example, a user can pick a different Google account in the account picker and
107+
// return a token for a different identity than this request expects.
108+
await this.clearAuthKeySigner(signerAddress)
109+
throw new Error('id-token-signer-mismatch')
110+
}
111+
return true
112+
} catch {
113+
return false
114+
}
115+
},
116+
}
117+
}
118+
119+
private handleAuth(
120+
onPromptIdToken: PromptIdTokenHandler,
121+
): Promise<{ signer: Signers.Signer & Signers.Witnessable; email: string }> {
122+
// eslint-disable-next-line no-async-promise-executor
123+
return new Promise(async (resolve, reject) => {
124+
try {
125+
const respond: RespondFn = async (idToken) => {
126+
try {
127+
const [signer, metadata] = await this.completeAuth(idToken)
128+
resolve({ signer, email: metadata.email || '' })
129+
} catch (error) {
130+
reject(error)
131+
}
132+
}
133+
134+
await onPromptIdToken(this.signupKind, respond)
135+
} catch (error) {
136+
reject(error)
137+
}
138+
})
139+
}
140+
}

packages/wallet/wdk/src/sequence/handlers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export { DevicesHandler } from './devices.js'
33
export { PasskeysHandler } from './passkeys.js'
44
export { OtpHandler } from './otp.js'
55
export { AuthCodePkceHandler } from './authcode-pkce.js'
6+
export { IdTokenHandler } from './idtoken.js'
67
export { MnemonicHandler } from './mnemonic.js'

packages/wallet/wdk/src/sequence/manager.ts

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
AuthCodePkceHandler,
1515
DevicesHandler,
1616
Handler,
17+
IdTokenHandler,
1718
MnemonicHandler,
1819
OtpHandler,
1920
PasskeysHandler,
@@ -31,9 +32,25 @@ import { Wallets, WalletsInterface } from './wallets.js'
3132
import { GuardHandler, PromptCodeHandler } from './handlers/guard.js'
3233
import { PasskeyCredential } from '../dbs/index.js'
3334
import { PromptMnemonicHandler } from './handlers/mnemonic.js'
35+
import { PromptIdTokenHandler } from './handlers/idtoken.js'
3436
import { PromptOtpHandler } from './handlers/otp.js'
3537
import { defaultPasskeyProvider, type PasskeyProvider } from './passkeys-provider.js'
3638

39+
type CustomIdentityProvider =
40+
| {
41+
kind: `custom-${string}`
42+
authMethod: 'id-token'
43+
issuer: string
44+
clientId: string
45+
}
46+
| {
47+
kind: `custom-${string}`
48+
authMethod: 'authcode' | 'authcode-pkce'
49+
issuer: string
50+
oauthUrl: string
51+
clientId: string
52+
}
53+
3754
export type ManagerOptions = {
3855
verbose?: boolean
3956

@@ -85,18 +102,13 @@ export type ManagerOptions = {
85102
google?: {
86103
enabled: boolean
87104
clientId: string
105+
authMethod?: 'authcode-pkce' | 'id-token'
88106
}
89107
apple?: {
90108
enabled: boolean
91109
clientId: string
92110
}
93-
customProviders?: {
94-
kind: `custom-${string}`
95-
authMethod: 'id-token' | 'authcode' | 'authcode-pkce'
96-
issuer: string
97-
oauthUrl: string
98-
clientId: string
99-
}[]
111+
customProviders?: CustomIdentityProvider[]
100112
}
101113
}
102114

@@ -112,18 +124,13 @@ export type ResolvedIdentityOptions = {
112124
google: {
113125
enabled: boolean
114126
clientId: string
127+
authMethod: 'authcode-pkce' | 'id-token'
115128
}
116129
apple: {
117130
enabled: boolean
118131
clientId: string
119132
}
120-
customProviders?: {
121-
kind: `custom-${string}`
122-
authMethod: 'id-token' | 'authcode' | 'authcode-pkce'
123-
issuer: string
124-
oauthUrl: string
125-
clientId: string
126-
}[]
133+
customProviders?: CustomIdentityProvider[]
127134
}
128135

129136
export type ResolvedManagerOptions = {
@@ -248,6 +255,7 @@ export const ManagerOptionsDefaults = {
248255
google: {
249256
enabled: false,
250257
clientId: '',
258+
authMethod: 'authcode-pkce' as const,
251259
},
252260
apple: {
253261
enabled: false,
@@ -677,20 +685,35 @@ export class Manager {
677685
shared.handlers.set(Kinds.LoginEmailOtp, this.otpHandler)
678686
}
679687
if (ops.identity.google?.enabled) {
680-
shared.handlers.set(
681-
Kinds.LoginGooglePkce,
682-
new AuthCodePkceHandler(
683-
'google-pkce',
684-
'https://accounts.google.com',
685-
'https://accounts.google.com/o/oauth2/v2/auth',
686-
ops.identity.google.clientId,
687-
identityInstrument,
688-
modules.signatures,
689-
shared.databases.authCommitments,
690-
shared.databases.authKeys,
691-
shared.env,
692-
),
693-
)
688+
if (ops.identity.google.authMethod === 'id-token') {
689+
shared.handlers.set(
690+
Kinds.LoginGoogle,
691+
new IdTokenHandler(
692+
'google-id-token',
693+
'https://accounts.google.com',
694+
ops.identity.google.clientId,
695+
identityInstrument,
696+
modules.signatures,
697+
shared.databases.authKeys,
698+
shared.env,
699+
),
700+
)
701+
} else {
702+
shared.handlers.set(
703+
Kinds.LoginGoogle,
704+
new AuthCodePkceHandler(
705+
'google-pkce',
706+
'https://accounts.google.com',
707+
'https://accounts.google.com/o/oauth2/v2/auth',
708+
ops.identity.google.clientId,
709+
identityInstrument,
710+
modules.signatures,
711+
shared.databases.authCommitments,
712+
shared.databases.authKeys,
713+
shared.env,
714+
),
715+
)
716+
}
694717
}
695718
if (ops.identity.apple?.enabled) {
696719
shared.handlers.set(
@@ -712,7 +735,19 @@ export class Manager {
712735
for (const provider of ops.identity.customProviders) {
713736
switch (provider.authMethod) {
714737
case 'id-token':
715-
throw new Error('id-token is not supported yet')
738+
shared.handlers.set(
739+
provider.kind,
740+
new IdTokenHandler(
741+
provider.kind,
742+
provider.issuer,
743+
provider.clientId,
744+
identityInstrument,
745+
modules.signatures,
746+
shared.databases.authKeys,
747+
shared.env,
748+
),
749+
)
750+
break
716751
case 'authcode':
717752
shared.handlers.set(
718753
provider.kind,
@@ -770,6 +805,20 @@ export class Manager {
770805
return this.otpHandler?.registerUI(onPromptOtp) || (() => {})
771806
}
772807

808+
public registerIdTokenUI(onPromptIdToken: PromptIdTokenHandler) {
809+
const unregisters: (() => void)[] = []
810+
811+
this.shared.handlers.forEach((handler) => {
812+
if (handler instanceof IdTokenHandler) {
813+
unregisters.push(handler.registerUI(onPromptIdToken))
814+
}
815+
})
816+
817+
return () => {
818+
unregisters.forEach((unregister) => unregister())
819+
}
820+
}
821+
773822
public registerGuardUI(onPromptCode: PromptCodeHandler) {
774823
return this.guardHandler?.registerUI(onPromptCode) || (() => {})
775824
}

0 commit comments

Comments
 (0)