Skip to content

Commit e81b800

Browse files
committed
wdk: account federation
1 parent ac996d4 commit e81b800

6 files changed

Lines changed: 270 additions & 3 deletions

File tree

packages/wallet/wdk/src/dbs/auth-commitments.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type AuthCommitment = {
1212
target: string
1313
isSignUp: boolean
1414
signer?: string
15+
wallet?: string
1516
}
1617

1718
export class AuthCommitments extends Generic<AuthCommitment, 'id'> {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class AuthCodePkceHandler extends AuthCodeHandler implements Handler {
2222
super(signupKind, issuer, oauthUrl, audience, nitro, signatures, commitments, authKeys, env)
2323
}
2424

25-
public async commitAuth(target: string, isSignUp: boolean, state?: string, signer?: string) {
25+
public async commitAuth(target: string, isSignUp: boolean, state?: string, signer?: string, wallet?: string) {
2626
let challenge = new Identity.AuthCodePkceChallenge(this.issuer, this.audience, this.redirectUri)
2727
if (signer) {
2828
challenge = challenge.withSigner({ address: signer, keyType: Identity.KeyType.Ethereum_Secp256k1 })
@@ -40,6 +40,7 @@ export class AuthCodePkceHandler extends AuthCodeHandler implements Handler {
4040
target,
4141
metadata: {},
4242
isSignUp,
43+
wallet,
4344
})
4445

4546
const searchParams = this.serializeQuery({

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export class AuthCodeHandler extends IdentityHandler implements Handler {
3939
this.redirectUri = redirectUri
4040
}
4141

42-
public async commitAuth(target: string, isSignUp: boolean, state?: string, signer?: string) {
42+
public async commitAuth(target: string, isSignUp: boolean, state?: string, signer?: string, wallet?: string) {
4343
if (!state) {
4444
state = Hex.fromBytes(Bytes.random(32))
4545
}
@@ -51,6 +51,7 @@ export class AuthCodeHandler extends IdentityHandler implements Handler {
5151
target,
5252
metadata: {},
5353
isSignUp,
54+
wallet,
5455
})
5556

5657
const searchParams = this.serializeQuery({

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ export { Sessions } from './sessions.js'
99
export { Signatures } from './signatures.js'
1010
export type {
1111
StartSignUpWithRedirectArgs,
12+
StartAddLoginSignerWithRedirectArgs,
1213
CommonSignupArgs,
1314
PasskeySignupArgs,
1415
MnemonicSignupArgs,
1516
EmailOtpSignupArgs,
1617
CompleteRedirectArgs,
1718
SignupArgs,
19+
AddLoginSignerArgs,
20+
RemoveLoginSignerArgs,
1821
LoginToWalletArgs,
1922
LoginToMnemonicArgs,
2023
LoginToPasskeyArgs,

packages/wallet/wdk/src/sequence/types/signature-request.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type ActionToPayload = {
1313
[Actions.Recovery]: Payload.Recovery<Payload.Calls>
1414
[Actions.AddRecoverySigner]: Payload.ConfigUpdate
1515
[Actions.RemoveRecoverySigner]: Payload.ConfigUpdate
16+
[Actions.AddLoginSigner]: Payload.ConfigUpdate
17+
[Actions.RemoveLoginSigner]: Payload.ConfigUpdate
1618
[Actions.SessionImplicitAuthorize]: Payload.SessionImplicitAuthorize
1719
}
1820

@@ -26,6 +28,8 @@ export const Actions = {
2628
Recovery: 'recovery',
2729
AddRecoverySigner: 'add-recovery-signer',
2830
RemoveRecoverySigner: 'remove-recovery-signer',
31+
AddLoginSigner: 'add-login-signer',
32+
RemoveLoginSigner: 'remove-login-signer',
2933
SessionImplicitAuthorize: 'session-implicit-authorize',
3034
} as const
3135

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

Lines changed: 258 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { MnemonicHandler } from './handlers/mnemonic.js'
88
import { OtpHandler } from './handlers/otp.js'
99
import { Shared } from './manager.js'
1010
import { Device } from './types/device.js'
11-
import { Action, Module } from './types/index.js'
11+
import { Action, Actions, Module } from './types/index.js'
1212
import { Kinds, SignerWithKind, WitnessExtraSignerKind } from './types/signer.js'
1313
import { Wallet, WalletSelectionUiHandler } from './types/wallet.js'
1414
import { PasskeysHandler } from './handlers/passkeys.js'
@@ -57,6 +57,12 @@ export type StartSignUpWithRedirectArgs = {
5757
metadata: { [key: string]: string }
5858
}
5959

60+
export type StartAddLoginSignerWithRedirectArgs = {
61+
wallet: Address.Address
62+
kind: 'google-pkce' | 'apple' | `custom-${string}`
63+
target: string
64+
}
65+
6066
export type SignupStatus =
6167
| { type: 'login-signer-created'; address: Address.Address }
6268
| { type: 'device-signer-created'; address: Address.Address }
@@ -112,6 +118,19 @@ export type SignupArgs =
112118
| IdTokenSignupArgs
113119
| AuthCodeSignupArgs
114120

121+
export type AddLoginSignerArgs = {
122+
wallet: Address.Address
123+
} & (
124+
| { kind: 'mnemonic'; mnemonic: string }
125+
| { kind: 'email-otp'; email: string }
126+
| { kind: 'google-id-token' | 'apple-id-token' | `custom-${string}`; idToken: string }
127+
)
128+
129+
export type RemoveLoginSignerArgs = {
130+
wallet: Address.Address
131+
signerAddress: Address.Address
132+
}
133+
115134
export type LoginToWalletArgs = {
116135
wallet: Address.Address
117136
}
@@ -292,6 +311,60 @@ export interface WalletsInterface {
292311
*/
293312
completeLogin(requestId: string): Promise<void>
294313

314+
/**
315+
* Adds a new login signer to an existing wallet, enabling account federation.
316+
*
317+
* This allows a user to link a new login method (e.g., Google, email OTP, mnemonic) to a wallet
318+
* that was originally created with a different credential. After federation, the wallet can be
319+
* discovered and accessed via any of its linked login methods.
320+
*
321+
* @param args The arguments specifying the wallet and the new login credential to add.
322+
* @returns A promise that resolves to a `requestId` for the configuration update signature request.
323+
* @see {completeAddLoginSigner}
324+
*/
325+
addLoginSigner(args: AddLoginSignerArgs): Promise<string>
326+
327+
/**
328+
* Completes the add-login-signer process after the configuration update has been signed.
329+
*
330+
* @param requestId The ID of the completed signature request returned by `addLoginSigner`.
331+
* @returns A promise that resolves when the configuration update has been submitted.
332+
*/
333+
completeAddLoginSigner(requestId: string): Promise<void>
334+
335+
/**
336+
* Initiates an add-login-signer process that involves an OAuth redirect.
337+
*
338+
* This is the first step for adding a social login signer (e.g., Google, Apple) to an existing wallet
339+
* via a redirect-based OAuth flow. It validates the wallet, generates the necessary challenges and state,
340+
* stores them locally, and returns a URL. Your application should redirect the user to this URL.
341+
*
342+
* @param args Arguments specifying the wallet, provider (`kind`), and the `target` URL for the redirect callback.
343+
* @returns A promise that resolves to the full OAuth URL to which the user should be redirected.
344+
* @see {completeRedirect} for the second step of this flow.
345+
*/
346+
startAddLoginSignerWithRedirect(args: StartAddLoginSignerWithRedirectArgs): Promise<string>
347+
348+
/**
349+
* Removes a login signer from an existing wallet, enabling account defederation.
350+
*
351+
* This allows a user to unlink a login method from a wallet. A safety guard ensures
352+
* at least one login signer always remains.
353+
*
354+
* @param args The arguments specifying the wallet and the signer address to remove.
355+
* @returns A promise that resolves to a `requestId` for the configuration update signature request.
356+
* @see {completeRemoveLoginSigner}
357+
*/
358+
removeLoginSigner(args: RemoveLoginSignerArgs): Promise<string>
359+
360+
/**
361+
* Completes the remove-login-signer process after the configuration update has been signed.
362+
*
363+
* @param requestId The ID of the completed signature request returned by `removeLoginSigner`.
364+
* @returns A promise that resolves when the configuration update has been submitted.
365+
*/
366+
completeRemoveLoginSigner(requestId: string): Promise<void>
367+
295368
/**
296369
* Logs out from a given wallet, ending the current session.
297370
*
@@ -810,12 +883,66 @@ export class Wallets implements WalletsInterface {
810883
return handler.commitAuth(args.target, true)
811884
}
812885

886+
async startAddLoginSignerWithRedirect(args: StartAddLoginSignerWithRedirectArgs) {
887+
const walletEntry = await this.get(args.wallet)
888+
if (!walletEntry) {
889+
throw new Error('wallet-not-found')
890+
}
891+
if (walletEntry.status !== 'ready') {
892+
throw new Error('wallet-not-ready')
893+
}
894+
895+
const kind = getSignupHandlerKey(args.kind)
896+
const handler = this.shared.handlers.get(kind)
897+
if (!handler) {
898+
throw new Error('handler-not-registered')
899+
}
900+
if (!(handler instanceof AuthCodeHandler)) {
901+
throw new Error('handler-does-not-support-redirect')
902+
}
903+
return handler.commitAuth(args.target, true, undefined, undefined, args.wallet)
904+
}
905+
813906
async completeRedirect(args: CompleteRedirectArgs): Promise<string> {
814907
const commitment = await this.shared.databases.authCommitments.get(args.state)
815908
if (!commitment) {
816909
throw new Error('invalid-state')
817910
}
818911

912+
// If commitment has a wallet, this is an add-login-signer redirect
913+
if (commitment.wallet) {
914+
const handlerKind = getSignupHandlerKey(commitment.kind)
915+
const handler = this.shared.handlers.get(handlerKind)
916+
if (!handler) {
917+
throw new Error('handler-not-registered')
918+
}
919+
if (!(handler instanceof AuthCodeHandler)) {
920+
throw new Error('handler-does-not-support-redirect')
921+
}
922+
923+
const walletAddress = commitment.wallet as Address.Address
924+
const walletEntry = await this.get(walletAddress)
925+
if (!walletEntry) {
926+
throw new Error('wallet-not-found')
927+
}
928+
if (walletEntry.status !== 'ready') {
929+
throw new Error('wallet-not-ready')
930+
}
931+
932+
const [signer] = await handler.completeAuth(commitment, args.code)
933+
const signerKind = getSignerKindForSignup(commitment.kind)
934+
935+
await this.addLoginSignerFromPrepared(walletAddress, {
936+
signer,
937+
extra: { signerKind },
938+
})
939+
940+
if (!commitment.target) {
941+
throw new Error('invalid-state')
942+
}
943+
return commitment.target
944+
}
945+
819946
// commitment.isSignUp and signUp also mean 'signIn' from wallet's perspective
820947
if (commitment.isSignUp) {
821948
await this.signUp({
@@ -1273,6 +1400,86 @@ export class Wallets implements WalletsInterface {
12731400
})
12741401
}
12751402

1403+
async addLoginSigner(args: AddLoginSignerArgs): Promise<string> {
1404+
const walletEntry = await this.get(args.wallet)
1405+
if (!walletEntry) {
1406+
throw new Error('wallet-not-found')
1407+
}
1408+
if (walletEntry.status !== 'ready') {
1409+
throw new Error('wallet-not-ready')
1410+
}
1411+
1412+
const loginSigner = await this.prepareSignUp(args as unknown as SignupArgs)
1413+
return this.addLoginSignerFromPrepared(args.wallet, loginSigner)
1414+
}
1415+
1416+
async completeAddLoginSigner(requestId: string): Promise<void> {
1417+
const request = await this.shared.modules.signatures.get(requestId)
1418+
if (request.action !== Actions.AddLoginSigner) {
1419+
throw new Error('invalid-request-action')
1420+
}
1421+
await this.completeConfigurationUpdate(requestId)
1422+
}
1423+
1424+
async removeLoginSigner(args: RemoveLoginSignerArgs): Promise<string> {
1425+
const walletEntry = await this.get(args.wallet)
1426+
if (!walletEntry) {
1427+
throw new Error('wallet-not-found')
1428+
}
1429+
if (walletEntry.status !== 'ready') {
1430+
throw new Error('wallet-not-ready')
1431+
}
1432+
1433+
const { loginTopology, modules } = await this.getConfigurationParts(args.wallet)
1434+
1435+
const existingSigners = Config.getSigners(loginTopology)
1436+
const allExistingAddresses = [...existingSigners.signers, ...existingSigners.sapientSigners.map((s) => s.address)]
1437+
1438+
if (!allExistingAddresses.some((addr) => Address.isEqual(addr, args.signerAddress))) {
1439+
throw new Error('signer-not-found')
1440+
}
1441+
1442+
const remainingMembers = [
1443+
...existingSigners.signers
1444+
.filter((x) => x !== Constants.ZeroAddress && !Address.isEqual(x, args.signerAddress))
1445+
.map((x) => ({ address: x })),
1446+
...existingSigners.sapientSigners
1447+
.filter((x) => !Address.isEqual(x.address, args.signerAddress))
1448+
.map((x) => ({ address: x.address, imageHash: x.imageHash })),
1449+
]
1450+
1451+
if (remainingMembers.length < 1) {
1452+
throw new Error('cannot-remove-last-login-signer')
1453+
}
1454+
1455+
const nextLoginTopology = buildCappedTree(remainingMembers)
1456+
1457+
if (this.shared.modules.sessions.hasSessionModule(modules)) {
1458+
await this.shared.modules.sessions.removeIdentitySignerFromModules(modules, args.signerAddress)
1459+
}
1460+
1461+
if (this.shared.modules.recovery.hasRecoveryModule(modules)) {
1462+
await this.shared.modules.recovery.removeRecoverySignerFromModules(modules, args.signerAddress)
1463+
}
1464+
1465+
const requestId = await this.requestConfigurationUpdate(
1466+
args.wallet,
1467+
{ loginTopology: nextLoginTopology, modules },
1468+
Actions.RemoveLoginSigner,
1469+
'wallet-webapp',
1470+
)
1471+
1472+
return requestId
1473+
}
1474+
1475+
async completeRemoveLoginSigner(requestId: string): Promise<void> {
1476+
const request = await this.shared.modules.signatures.get(requestId)
1477+
if (request.action !== Actions.RemoveLoginSigner) {
1478+
throw new Error('invalid-request-action')
1479+
}
1480+
await this.completeConfigurationUpdate(requestId)
1481+
}
1482+
12761483
async logout<T extends { skipRemoveDevice?: boolean } | undefined = undefined>(
12771484
wallet: Address.Address,
12781485
options?: T,
@@ -1503,4 +1710,54 @@ export class Wallets implements WalletsInterface {
15031710

15041711
return requestId
15051712
}
1713+
1714+
private async addLoginSignerFromPrepared(
1715+
wallet: Address.Address,
1716+
loginSigner: {
1717+
signer: (Signers.Signer | Signers.SapientSigner) & Signers.Witnessable
1718+
extra: WitnessExtraSignerKind
1719+
},
1720+
): Promise<string> {
1721+
const newSignerAddress = await loginSigner.signer.address
1722+
1723+
const { loginTopology, modules } = await this.getConfigurationParts(wallet)
1724+
1725+
// Check for duplicate signer
1726+
const existingSigners = Config.getSigners(loginTopology)
1727+
const allExistingAddresses = [...existingSigners.signers, ...existingSigners.sapientSigners.map((s) => s.address)]
1728+
if (allExistingAddresses.some((addr) => Address.isEqual(addr, newSignerAddress))) {
1729+
throw new Error('signer-already-exists')
1730+
}
1731+
1732+
// Build new login topology with the additional signer
1733+
const existingMembers = [
1734+
...existingSigners.signers.filter((x) => x !== Constants.ZeroAddress).map((x) => ({ address: x })),
1735+
...existingSigners.sapientSigners.map((x) => ({ address: x.address, imageHash: x.imageHash })),
1736+
]
1737+
const newMember = {
1738+
address: newSignerAddress,
1739+
imageHash: Signers.isSapientSigner(loginSigner.signer) ? await loginSigner.signer.imageHash : undefined,
1740+
}
1741+
const nextLoginTopology = buildCappedTree([...existingMembers, newMember])
1742+
1743+
// Add non-sapient login signer to sessions module identity signers
1744+
if (!Signers.isSapientSigner(loginSigner.signer) && this.shared.modules.sessions.hasSessionModule(modules)) {
1745+
await this.shared.modules.sessions.addIdentitySignerToModules(modules, newSignerAddress)
1746+
}
1747+
1748+
// Add to recovery module if present
1749+
if (this.shared.modules.recovery.hasRecoveryModule(modules)) {
1750+
await this.shared.modules.recovery.addRecoverySignerToModules(modules, newSignerAddress)
1751+
}
1752+
1753+
// Witness so the wallet becomes discoverable via the new credential
1754+
await loginSigner.signer.witness(this.shared.sequence.stateProvider, wallet, loginSigner.extra)
1755+
1756+
return this.requestConfigurationUpdate(
1757+
wallet,
1758+
{ loginTopology: nextLoginTopology, modules },
1759+
Actions.AddLoginSigner,
1760+
'wallet-webapp',
1761+
)
1762+
}
15061763
}

0 commit comments

Comments
 (0)