@@ -8,7 +8,7 @@ import { MnemonicHandler } from './handlers/mnemonic.js'
88import { OtpHandler } from './handlers/otp.js'
99import { Shared } from './manager.js'
1010import { Device } from './types/device.js'
11- import { Action , Module } from './types/index.js'
11+ import { Action , Actions , Module } from './types/index.js'
1212import { Kinds , SignerWithKind , WitnessExtraSignerKind } from './types/signer.js'
1313import { Wallet , WalletSelectionUiHandler } from './types/wallet.js'
1414import { 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+
6066export 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+
115134export 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