diff --git a/packages/@magic-ext/oauth2/src/index.ts b/packages/@magic-ext/oauth2/src/index.ts index 519877049..da60a5f02 100644 --- a/packages/@magic-ext/oauth2/src/index.ts +++ b/packages/@magic-ext/oauth2/src/index.ts @@ -9,8 +9,15 @@ import { OAuthPopupConfiguration, OAuthVerificationConfiguration, } from './types'; +import { + OAuthMFAEventEmit, + OAuthMFAEventOnReceived, + OAuthPopupEventEmit, + OAuthPopupEventHandlers, + OAuthPopupEventOnReceived, + OAuthGetResultEventHandlers, +} from '@magic-sdk/types'; import { createCryptoChallenge } from './crypto'; -import { OAuthPopupEventEmit, OAuthPopupEventHandlers, OAuthPopupEventOnReceived } from '@magic-sdk/types'; const PKCE_STORAGE_KEY = 'magic_oauth_pkce_verifier'; @@ -70,10 +77,7 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { if (successResult?.pkceMetadata) { // New path: store codeVerifier + all OAuth metadata at the SDK (parent page) level. // sessionStorage persists across same-tab redirects but never enters the iframe. - sessionStorage.setItem( - PKCE_STORAGE_KEY, - JSON.stringify({ codeVerifier, ...successResult.pkceMetadata }), - ); + sessionStorage.setItem(PKCE_STORAGE_KEY, JSON.stringify({ codeVerifier, ...successResult.pkceMetadata })); } if (successResult?.oauthAuthoriationURI) { @@ -100,13 +104,15 @@ 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) { + const { showMfaModal } = configuration; const requestPayload = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Popup, [ { ...configuration, + showUI: showMfaModal, returnTo: window.location.href, apiKey: this.sdk.apiKey, platform: 'web', @@ -119,33 +125,158 @@ 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 (!showMfaModal) { + oauthPopupRequest.on(OAuthMFAEventOnReceived.MfaSentHandle, () => { + promiEvent.emit(OAuthMFAEventOnReceived.MfaSentHandle); + }); + oauthPopupRequest.on(OAuthMFAEventOnReceived.InvalidMfaOtp, () => { + promiEvent.emit(OAuthMFAEventOnReceived.InvalidMfaOtp); + }); + oauthPopupRequest.on(OAuthMFAEventOnReceived.RecoveryCodeSentHandle, () => { + promiEvent.emit(OAuthMFAEventOnReceived.RecoveryCodeSentHandle); + }); + oauthPopupRequest.on(OAuthMFAEventOnReceived.InvalidRecoveryCode, () => { + promiEvent.emit(OAuthMFAEventOnReceived.InvalidRecoveryCode); + }); + oauthPopupRequest.on(OAuthMFAEventOnReceived.RecoveryCodeSuccess, () => { + promiEvent.emit(OAuthMFAEventOnReceived.RecoveryCodeSuccess); + }); + } + const result = await oauthPopupRequest; window.removeEventListener('message', redirectEvent); - if ((result as OAuthRedirectError).error) { - reject(result); + const maybeResult = result as OAuthRedirectResult; + const maybeError = result as OAuthRedirectError; + + if (maybeError.error) { + reject( + this.createError(maybeError.error, maybeError.error_description ?? 'An error occurred.', { + errorURI: maybeError.error_uri, + provider: maybeError.provider, + }), + ); } else { - resolve(result as OAuthRedirectResult); + resolve(maybeResult); } } catch (error) { reject(error); } }); - promiEvent.on(OAuthPopupEventEmit.Cancel, () => { - this.createIntermediaryEvent(OAuthPopupEventEmit.Cancel, requestPayload.id as string)(); - }); + if (!showMfaModal && 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 string)(); + }); + } + + return promiEvent; + } + + private getResult(configuration: OAuthVerificationConfiguration, queryString: string) { + const { showMfaModal } = configuration; + const { hasStateMismatch, clientMetadata } = this.retrievePKCEMetadata(queryString); + + const requestPayload = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Verify, [ + { + authorizationResponseParams: queryString, + magicApiKey: this.sdk.apiKey, + platform: 'web', + showUI: showMfaModal, + ...configuration, + ...(clientMetadata ? { clientMetadata } : {}), + }, + ]); + + const promiEvent = this.utils.createPromiEvent( + async (resolve, reject) => { + if (hasStateMismatch) { + reject( + this.createError( + 'STATE_MISMATCH', + 'OAuth state parameter mismatch — request may have been tampered with', + {}, + ), + ); + } + + const getResultRequest = this.request( + requestPayload, + ); + + if (!showMfaModal) { + getResultRequest.on(OAuthMFAEventOnReceived.MfaSentHandle, () => { + promiEvent.emit(OAuthMFAEventOnReceived.MfaSentHandle); + }); + getResultRequest.on(OAuthMFAEventOnReceived.InvalidMfaOtp, () => { + promiEvent.emit(OAuthMFAEventOnReceived.InvalidMfaOtp); + }); + getResultRequest.on(OAuthMFAEventOnReceived.RecoveryCodeSentHandle, () => { + promiEvent.emit(OAuthMFAEventOnReceived.RecoveryCodeSentHandle); + }); + getResultRequest.on(OAuthMFAEventOnReceived.InvalidRecoveryCode, () => { + promiEvent.emit(OAuthMFAEventOnReceived.InvalidRecoveryCode); + }); + getResultRequest.on(OAuthMFAEventOnReceived.RecoveryCodeSuccess, () => { + promiEvent.emit(OAuthMFAEventOnReceived.RecoveryCodeSuccess); + }); + } + + // 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); + }, + ); + + if (!showMfaModal && 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 string)(); + }); + } return promiEvent; } @@ -175,13 +306,16 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { console.log('Error while loading telegram-web-app script', seamlessLoginError); } } -} -function getResult(this: OAuthExtension, configuration: OAuthVerificationConfiguration, queryString: string) { - return this.utils.createPromiEvent(async (resolve, reject) => { + private retrievePKCEMetadata(queryString: string): { + clientMetadata?: Record; + hasStateMismatch: boolean; + } { + let hasStateMismatch = false; // Retrieve and immediately clear the full PKCE metadata stored at SDK level. const stored = sessionStorage.getItem(PKCE_STORAGE_KEY); sessionStorage.removeItem(PKCE_STORAGE_KEY); + // clientMetadata contains { codeVerifier, state, redirectUri, appID, provider }. // Forwarding it lets the embedded-wallet verify handler skip its iframe storage entirely. // When absent (old embedded-wallet path), the handler falls back to its stored metadata. @@ -193,38 +327,12 @@ function getResult(this: OAuthExtension, configuration: OAuthVerificationConfigu if (clientMetadata) { const returnedState = new URLSearchParams(queryString).get('state'); if (!returnedState || returnedState !== clientMetadata.state) { - return reject( - this.createError('STATE_MISMATCH', 'OAuth state parameter mismatch — request may have been tampered with', {}), - ); + hasStateMismatch = true; } } - const parseRedirectResult = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Verify, [ - { - authorizationResponseParams: queryString, - magicApiKey: this.sdk.apiKey, - platform: 'web', - ...configuration, - ...(clientMetadata ? { clientMetadata } : {}), - }, - ]); - - // 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); - }); + return { clientMetadata, hasStateMismatch }; + } } export * from './types'; diff --git a/packages/@magic-ext/oauth2/src/types.ts b/packages/@magic-ext/oauth2/src/types.ts index cecea04b0..fdda46850 100644 --- a/packages/@magic-ext/oauth2/src/types.ts +++ b/packages/@magic-ext/oauth2/src/types.ts @@ -119,6 +119,7 @@ export interface OAuthVerificationConfiguration { lifespan?: number; optionalQueryString?: string; skipDIDToken?: boolean; + showMfaModal?: boolean; } export interface OAuthPopupConfiguration { @@ -127,6 +128,7 @@ export interface OAuthPopupConfiguration { loginHint?: string; providerParams?: Record; shouldReturnURI?: boolean; + showMfaModal?: boolean; } export enum OAuthErrorCode { diff --git a/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx b/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx index 8bee9f435..42adc2e70 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'; @@ -79,6 +80,7 @@ function WidgetContent({ ); @@ -109,12 +111,14 @@ function WidgetContent({ return ( - - - {renderView()} -