From 645de0b292640da98f97758dac2b5a988d7c4819 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Mon, 16 Feb 2026 20:48:21 +0500 Subject: [PATCH 1/9] feat: add mfa events to oauth flows --- packages/@magic-ext/oauth2/src/index.ts | 119 ++++++++++++------ packages/@magic-ext/oauth2/src/types.ts | 2 + .../types/src/modules/intermediary-types.ts | 4 +- .../types/src/modules/oauth-types.ts | 40 +++++- 4 files changed, 123 insertions(+), 42 deletions(-) diff --git a/packages/@magic-ext/oauth2/src/index.ts b/packages/@magic-ext/oauth2/src/index.ts index e7ddde91c..08f32c4a8 100644 --- a/packages/@magic-ext/oauth2/src/index.ts +++ b/packages/@magic-ext/oauth2/src/index.ts @@ -9,7 +9,14 @@ import { OAuthPopupConfiguration, OAuthVerificationConfiguration, } from './types'; -import { OAuthPopupEventEmit, OAuthPopupEventHandlers, OAuthPopupEventOnReceived } from '@magic-sdk/types'; +import { + OAuthMFAEventEmit, + OAuthMFAEventOnReceived, + OAuthPopupEventEmit, + OAuthPopupEventHandlers, + OAuthPopupEventOnReceived, + OAuthRedirectEventHandlers, +} from '@magic-sdk/types'; declare global { interface Window { @@ -37,42 +44,62 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { } public loginWithRedirect(configuration: OAuthRedirectConfiguration) { - return this.utils.createPromiEvent(async (resolve, reject) => { - const parseRedirectResult = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Start, [ - { - ...configuration, - apiKey: this.sdk.apiKey, - platform: 'web', - }, - ]); - - const result = await this.request(parseRedirectResult); - const successResult = result as OAuthRedirectStartResult; - const errorResult = result as OAuthRedirectError; - - if (errorResult.error) { - reject( - this.createError(errorResult.error, errorResult.error_description ?? 'An error occurred.', { - errorURI: errorResult.error_uri, - provider: errorResult.provider, - }), - ); - } + const { showUI } = configuration; + const requestPayload = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Start, [ + { + ...configuration, + apiKey: this.sdk.apiKey, + platform: 'web', + }, + ]); - if (successResult?.oauthAuthoriationURI) { - const redirectURI = successResult.useMagicServerCallback - ? // @ts-ignore - this.sdk.endpoint is marked protected but we need to access it. - new URL(successResult.oauthAuthoriationURI, this.sdk.endpoint).href - : successResult.oauthAuthoriationURI; + const promiEvent = createPromiEvent(async (resolve, reject) => { + try { + const oauthRedirectRequest = this.request< + OAuthRedirectStartResult | OAuthRedirectError, + OAuthRedirectEventHandlers + >(requestPayload); - if (successResult?.shouldReturnURI) { - resolve(redirectURI); - } else { - window.location.href = redirectURI; + if (!showUI && oauthRedirectRequest) { + this.proxyMFAReceivedEvents(oauthRedirectRequest, promiEvent); + } + + const result = await oauthRedirectRequest; + const successResult = result as OAuthRedirectStartResult; + const errorResult = result as OAuthRedirectError; + + if (errorResult.error) { + reject( + this.createError(errorResult.error, errorResult.error_description ?? 'An error occurred.', { + errorURI: errorResult.error_uri, + provider: errorResult.provider, + }), + ); + return; + } + + if (successResult?.oauthAuthoriationURI) { + const redirectURI = successResult.useMagicServerCallback + ? // @ts-ignore - this.sdk.endpoint is marked protected but we need to access it. + new URL(successResult.oauthAuthoriationURI, this.sdk.endpoint).href + : successResult.oauthAuthoriationURI; + + if (successResult?.shouldReturnURI) { + resolve(redirectURI); + return; + } else { + window.location.href = redirectURI; + } } + resolve(null); + } catch (error) { + reject(error); } - resolve(null); }); + + this.attachMFAEmitHandlers(promiEvent, requestPayload.id as string); + + return promiEvent; } public getRedirectResult(configuration: OAuthVerificationConfiguration = {}) { @@ -87,6 +114,7 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { } public loginWithPopup(configuration: OAuthPopupConfiguration) { + const { showUI } = configuration; const requestPayload = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Popup, [ { ...configuration, @@ -102,17 +130,24 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { requestPayload, ); + /** + * Attach Event listeners + */ const redirectEvent = (event: MessageEvent) => { this.createIntermediaryEvent(OAuthPopupEventEmit.PopupEvent, requestPayload.id as string)(event.data); }; - if (configuration.shouldReturnURI) { + if (configuration.shouldReturnURI && oauthPopupRequest) { oauthPopupRequest.on(OAuthPopupEventOnReceived.PopupUrl, popupUrl => { window.addEventListener('message', redirectEvent); promiEvent.emit(OAuthPopupEventOnReceived.PopupUrl, popupUrl); }); } + if (!showUI && oauthPopupRequest) { + this.proxyMFAReceivedEvents(oauthPopupRequest, promiEvent); + } + const result = await oauthPopupRequest; window.removeEventListener('message', redirectEvent); @@ -126,13 +161,25 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { } }); - promiEvent.on(OAuthPopupEventEmit.Cancel, () => { - this.createIntermediaryEvent(OAuthPopupEventEmit.Cancel, requestPayload.id as string)(); - }); + this.attachMFAEmitHandlers(promiEvent, requestPayload.id as string); return promiEvent; } + private proxyMFAReceivedEvents(source: { on: Function }, target: { emit: Function }) { + Object.values(OAuthMFAEventOnReceived).forEach(event => { + source.on(event, () => target.emit(event)); + }); + } + + private attachMFAEmitHandlers(promiEvent: { on: Function }, requestId: string) { + Object.values(OAuthMFAEventEmit).forEach(event => { + promiEvent.on(event, (...args: unknown[]) => { + this.createIntermediaryEvent(event, requestId)(...args); + }); + }); + } + protected seamlessTelegramLogin() { try { const hash = window.location.hash.toString(); diff --git a/packages/@magic-ext/oauth2/src/types.ts b/packages/@magic-ext/oauth2/src/types.ts index 7afa83b85..6c3042978 100644 --- a/packages/@magic-ext/oauth2/src/types.ts +++ b/packages/@magic-ext/oauth2/src/types.ts @@ -103,6 +103,7 @@ export interface OAuthRedirectConfiguration { customData?: string; providerParams?: Record; loginHint?: string; + showUI?: boolean; } export interface OAuthVerificationConfiguration { @@ -117,6 +118,7 @@ export interface OAuthPopupConfiguration { loginHint?: string; providerParams?: Record; shouldReturnURI?: boolean; + showUI?: boolean; } export enum OAuthErrorCode { diff --git a/packages/@magic-sdk/types/src/modules/intermediary-types.ts b/packages/@magic-sdk/types/src/modules/intermediary-types.ts index 021d1c267..4f04c80da 100644 --- a/packages/@magic-sdk/types/src/modules/intermediary-types.ts +++ b/packages/@magic-sdk/types/src/modules/intermediary-types.ts @@ -29,7 +29,7 @@ import { RecoveryFactorEventEmit, RecoveryFactorEventOnReceived, } from './user-types'; -import { OAuthPopupEventEmit, OAuthPopupEventOnReceived } from './oauth-types'; +import { OAuthMFAEventEmit, OAuthMFAEventOnReceived, OAuthPopupEventEmit, OAuthPopupEventOnReceived } from './oauth-types'; export type IntermediaryEvents = // EmailOTP @@ -76,5 +76,7 @@ export type IntermediaryEvents = | `${RecoverAccountEventOnReceived}` | `${RecoverAccountEventEmit}` // OAuth Events + | `${OAuthMFAEventEmit}` + | `${OAuthMFAEventOnReceived}` | `${OAuthPopupEventEmit}` | `${OAuthPopupEventOnReceived}`; diff --git a/packages/@magic-sdk/types/src/modules/oauth-types.ts b/packages/@magic-sdk/types/src/modules/oauth-types.ts index fa09880b9..4cd840c30 100644 --- a/packages/@magic-sdk/types/src/modules/oauth-types.ts +++ b/packages/@magic-sdk/types/src/modules/oauth-types.ts @@ -1,16 +1,46 @@ +// Shared MFA events reused by both popup and redirect flows +export enum OAuthMFAEventEmit { + Cancel = 'cancel', + VerifyMFACode = 'verify-mfa-code', + LostDevice = 'lost-device', + VerifyRecoveryCode = 'verify-recovery-code', +} + +export enum OAuthMFAEventOnReceived { + MfaSentHandle = 'mfa-sent-handle', + InvalidMfaOtp = 'invalid-mfa-otp', + RecoveryCodeSentHandle = 'recovery-code-sent-handle', + InvalidRecoveryCode = 'invalid-recovery-code', + RecoveryCodeSuccess = 'recovery-code-success', +} + +type OAuthMFAEventHandlers = { + // Event sent + [OAuthMFAEventEmit.Cancel]: () => void; + [OAuthMFAEventEmit.VerifyMFACode]: (mfa: string) => void; + [OAuthMFAEventEmit.LostDevice]: () => void; + [OAuthMFAEventEmit.VerifyRecoveryCode]: (recoveryCode: string) => void; + // Event Received + [OAuthMFAEventOnReceived.MfaSentHandle]: () => void; + [OAuthMFAEventOnReceived.InvalidMfaOtp]: () => void; + [OAuthMFAEventOnReceived.RecoveryCodeSentHandle]: () => void; + [OAuthMFAEventOnReceived.InvalidRecoveryCode]: () => void; + [OAuthMFAEventOnReceived.RecoveryCodeSuccess]: () => void; +}; + +// Popup-specific events export enum OAuthPopupEventOnReceived { PopupUrl = 'popup-url', } export enum OAuthPopupEventEmit { PopupEvent = 'popup-event', - Cancel = 'cancel', } export type OAuthPopupEventHandlers = { - // Event sent [OAuthPopupEventEmit.PopupEvent]: (eventData: unknown) => void; - [OAuthPopupEventEmit.Cancel]: () => void; - // Event Received [OAuthPopupEventOnReceived.PopupUrl]: (event: { popupUrl: string; provider: string }) => void; -}; +} & OAuthMFAEventHandlers; + +// Redirect-specific handler type +export type OAuthRedirectEventHandlers = OAuthMFAEventHandlers; From 81e26e44fb3d48e094a87202d5c3d36b731f8703 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Wed, 18 Feb 2026 17:55:21 +0500 Subject: [PATCH 2/9] feat: implement mfa events in wallet kit --- packages/@magic-ext/oauth2/src/index.ts | 225 ++++++++++-------- packages/@magic-ext/oauth2/src/types.ts | 2 +- .../@magic-ext/wallet-kit/src/MagicWidget.tsx | 15 +- .../src/context/OAuthLoginContext.tsx | 166 +++++++++++++ .../wallet-kit/src/context/index.ts | 1 + .../@magic-ext/wallet-kit/src/extension.ts | 32 ++- .../@magic-ext/wallet-kit/src/hooks/useMfa.ts | 32 +++ .../wallet-kit/src/hooks/useOAuthLogin.ts | 59 ----- .../wallet-kit/src/views/MfaView.tsx | 4 +- .../wallet-kit/src/views/OAuthPendingView.tsx | 48 +--- .../wallet-kit/src/views/RecoveryCode.tsx | 4 +- .../types/src/modules/oauth-types.ts | 2 +- 12 files changed, 376 insertions(+), 214 deletions(-) create mode 100644 packages/@magic-ext/wallet-kit/src/context/OAuthLoginContext.tsx create mode 100644 packages/@magic-ext/wallet-kit/src/hooks/useMfa.ts delete mode 100644 packages/@magic-ext/wallet-kit/src/hooks/useOAuthLogin.ts diff --git a/packages/@magic-ext/oauth2/src/index.ts b/packages/@magic-ext/oauth2/src/index.ts index 08f32c4a8..992f9625e 100644 --- a/packages/@magic-ext/oauth2/src/index.ts +++ b/packages/@magic-ext/oauth2/src/index.ts @@ -15,7 +15,7 @@ import { OAuthPopupEventEmit, OAuthPopupEventHandlers, OAuthPopupEventOnReceived, - OAuthRedirectEventHandlers, + OAuthGetResultEventHandlers, } from '@magic-sdk/types'; declare global { @@ -44,62 +44,42 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { } public loginWithRedirect(configuration: OAuthRedirectConfiguration) { - const { showUI } = configuration; - const requestPayload = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Start, [ - { - ...configuration, - apiKey: this.sdk.apiKey, - platform: 'web', - }, - ]); - - const promiEvent = createPromiEvent(async (resolve, reject) => { - try { - const oauthRedirectRequest = this.request< - OAuthRedirectStartResult | OAuthRedirectError, - OAuthRedirectEventHandlers - >(requestPayload); - - if (!showUI && oauthRedirectRequest) { - this.proxyMFAReceivedEvents(oauthRedirectRequest, promiEvent); - } - - const result = await oauthRedirectRequest; - const successResult = result as OAuthRedirectStartResult; - const errorResult = result as OAuthRedirectError; + return this.utils.createPromiEvent(async (resolve, reject) => { + const parseRedirectResult = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Start, [ + { + ...configuration, + apiKey: this.sdk.apiKey, + platform: 'web', + }, + ]); + + const result = await this.request(parseRedirectResult); + const successResult = result as OAuthRedirectStartResult; + const errorResult = result as OAuthRedirectError; + + if (errorResult.error) { + reject( + this.createError(errorResult.error, errorResult.error_description ?? 'An error occurred.', { + errorURI: errorResult.error_uri, + provider: errorResult.provider, + }), + ); + } - if (errorResult.error) { - reject( - this.createError(errorResult.error, errorResult.error_description ?? 'An error occurred.', { - errorURI: errorResult.error_uri, - provider: errorResult.provider, - }), - ); - return; - } + if (successResult?.oauthAuthoriationURI) { + const redirectURI = successResult.useMagicServerCallback + ? // @ts-ignore - this.sdk.endpoint is marked protected but we need to access it. + new URL(successResult.oauthAuthoriationURI, this.sdk.endpoint).href + : successResult.oauthAuthoriationURI; - if (successResult?.oauthAuthoriationURI) { - const redirectURI = successResult.useMagicServerCallback - ? // @ts-ignore - this.sdk.endpoint is marked protected but we need to access it. - new URL(successResult.oauthAuthoriationURI, this.sdk.endpoint).href - : successResult.oauthAuthoriationURI; - - if (successResult?.shouldReturnURI) { - resolve(redirectURI); - return; - } else { - window.location.href = redirectURI; - } + if (successResult?.shouldReturnURI) { + resolve(redirectURI); + } else { + window.location.href = redirectURI; } - resolve(null); - } catch (error) { - reject(error); } + resolve(null); }); - - this.attachMFAEmitHandlers(promiEvent, requestPayload.id as string); - - return promiEvent; } public getRedirectResult(configuration: OAuthVerificationConfiguration = {}) { @@ -110,7 +90,7 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { const urlWithoutQuery = window.location.origin + window.location.pathname; window.history.replaceState(null, '', urlWithoutQuery); - return getResult.call(this, configuration, queryString); + return this.getResult(configuration, queryString); } public loginWithPopup(configuration: OAuthPopupConfiguration) { @@ -144,8 +124,22 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { }); } - if (!showUI && oauthPopupRequest) { - this.proxyMFAReceivedEvents(oauthPopupRequest, promiEvent); + if (!showUI) { + oauthPopupRequest.on(OAuthMFAEventOnReceived.MfaSentHandle, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.MfaSentHandle, ...args); + }); + oauthPopupRequest.on(OAuthMFAEventOnReceived.InvalidMfaOtp, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.InvalidMfaOtp, ...args); + }); + oauthPopupRequest.on(OAuthMFAEventOnReceived.RecoveryCodeSentHandle, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.RecoveryCodeSentHandle, ...args); + }); + oauthPopupRequest.on(OAuthMFAEventOnReceived.InvalidRecoveryCode, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.InvalidRecoveryCode, ...args); + }); + oauthPopupRequest.on(OAuthMFAEventOnReceived.RecoveryCodeSuccess, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.RecoveryCodeSuccess, ...args); + }); } const result = await oauthPopupRequest; @@ -161,23 +155,93 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { } }); - this.attachMFAEmitHandlers(promiEvent, requestPayload.id as string); + if (!showUI && promiEvent) { + promiEvent.on(OAuthMFAEventEmit.VerifyMFACode, (mfa: string) => { + this.createIntermediaryEvent(OAuthMFAEventEmit.VerifyMFACode, requestPayload.id as string)(mfa); + }); + promiEvent.on(OAuthMFAEventEmit.LostDevice, () => { + this.createIntermediaryEvent(OAuthMFAEventEmit.LostDevice, requestPayload.id as string)(); + }); + promiEvent.on(OAuthMFAEventEmit.VerifyRecoveryCode, (recoveryCode: string) => { + this.createIntermediaryEvent(OAuthMFAEventEmit.VerifyRecoveryCode, requestPayload.id as string)(recoveryCode); + }); + promiEvent.on(OAuthMFAEventEmit.Cancel, () => { + this.createIntermediaryEvent(OAuthMFAEventEmit.Cancel, requestPayload.id as any)(); + }); + } return promiEvent; } - private proxyMFAReceivedEvents(source: { on: Function }, target: { emit: Function }) { - Object.values(OAuthMFAEventOnReceived).forEach(event => { - source.on(event, () => target.emit(event)); - }); - } + private getResult(configuration: OAuthVerificationConfiguration, queryString: string) { + const { showUI } = configuration; + const requestPayload = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Verify, [ + { + authorizationResponseParams: queryString, + magicApiKey: this.sdk.apiKey, + platform: 'web', + ...configuration, + }, + ]); + + const promiEvent = this.utils.createPromiEvent( + async (resolve, reject) => { + const getResultRequest = this.request( + requestPayload, + ); + + if (!showUI) { + getResultRequest.on(OAuthMFAEventOnReceived.MfaSentHandle, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.MfaSentHandle, ...args); + }); + getResultRequest.on(OAuthMFAEventOnReceived.InvalidMfaOtp, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.InvalidMfaOtp, ...args); + }); + getResultRequest.on(OAuthMFAEventOnReceived.RecoveryCodeSentHandle, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.RecoveryCodeSentHandle, ...args); + }); + getResultRequest.on(OAuthMFAEventOnReceived.InvalidRecoveryCode, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.InvalidRecoveryCode, ...args); + }); + getResultRequest.on(OAuthMFAEventOnReceived.RecoveryCodeSuccess, (...args) => { + promiEvent.emit(OAuthMFAEventOnReceived.RecoveryCodeSuccess, ...args); + }); + } + + // Parse the result, which may contain an OAuth-formatted error. + const resultOrError = await getResultRequest; + const maybeResult = resultOrError as OAuthRedirectResult; + const maybeError = resultOrError as OAuthRedirectError; + + if (maybeError.error) { + reject( + this.createError(maybeError.error, maybeError.error_description ?? 'An error occurred.', { + errorURI: maybeError.error_uri, + provider: maybeError.provider, + }), + ); + } + + resolve(maybeResult); + }, + ); - private attachMFAEmitHandlers(promiEvent: { on: Function }, requestId: string) { - Object.values(OAuthMFAEventEmit).forEach(event => { - promiEvent.on(event, (...args: unknown[]) => { - this.createIntermediaryEvent(event, requestId)(...args); + if (!showUI && promiEvent) { + promiEvent.on(OAuthMFAEventEmit.VerifyMFACode, (mfa: string) => { + this.createIntermediaryEvent(OAuthMFAEventEmit.VerifyMFACode, requestPayload.id as string)(mfa); }); - }); + promiEvent.on(OAuthMFAEventEmit.LostDevice, () => { + this.createIntermediaryEvent(OAuthMFAEventEmit.LostDevice, requestPayload.id as string)(); + }); + promiEvent.on(OAuthMFAEventEmit.VerifyRecoveryCode, (recoveryCode: string) => { + this.createIntermediaryEvent(OAuthMFAEventEmit.VerifyRecoveryCode, requestPayload.id as string)(recoveryCode); + }); + promiEvent.on(OAuthMFAEventEmit.Cancel, () => { + this.createIntermediaryEvent(OAuthMFAEventEmit.Cancel, requestPayload.id as any)(); + }); + } + + return promiEvent; } protected seamlessTelegramLogin() { @@ -207,33 +271,4 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { } } -function getResult(this: OAuthExtension, configuration: OAuthVerificationConfiguration, queryString: string) { - return this.utils.createPromiEvent(async (resolve, reject) => { - const parseRedirectResult = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Verify, [ - { - authorizationResponseParams: queryString, - magicApiKey: this.sdk.apiKey, - platform: 'web', - ...configuration, - }, - ]); - - // Parse the result, which may contain an OAuth-formatted error. - const resultOrError = await this.request(parseRedirectResult); - const maybeResult = resultOrError as OAuthRedirectResult; - const maybeError = resultOrError as OAuthRedirectError; - - if (maybeError.error) { - reject( - this.createError(maybeError.error, maybeError.error_description ?? 'An error occurred.', { - errorURI: maybeError.error_uri, - provider: maybeError.provider, - }), - ); - } - - resolve(maybeResult); - }); -} - export * from './types'; diff --git a/packages/@magic-ext/oauth2/src/types.ts b/packages/@magic-ext/oauth2/src/types.ts index 6c3042978..127b5476c 100644 --- a/packages/@magic-ext/oauth2/src/types.ts +++ b/packages/@magic-ext/oauth2/src/types.ts @@ -103,13 +103,13 @@ export interface OAuthRedirectConfiguration { customData?: string; providerParams?: Record; loginHint?: string; - showUI?: boolean; } export interface OAuthVerificationConfiguration { lifespan?: number; optionalQueryString?: string; skipDIDToken?: boolean; + showUI?: boolean; } export interface OAuthPopupConfiguration { diff --git a/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx b/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx index 379e1c5a1..8047b3cc3 100644 --- a/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx +++ b/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx @@ -11,6 +11,7 @@ import { OAuthPendingView } from './views/OAuthPendingView'; import AdditionalProvidersView from './views/AdditionalProvidersView'; import { getExtensionInstance } from './extension'; import { EmailLoginProvider } from './context/EmailLoginContext'; +import { OAuthLoginProvider } from './context/OAuthLoginContext'; import { WidgetConfigProvider } from './context/WidgetConfigContext'; import { EmailOTPView } from './views/EmailOTPView'; import { DeviceVerificationView } from './views/DeviceVerificationView'; @@ -109,12 +110,14 @@ function WidgetContent({ return ( - - - {renderView()} -