Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
645de0b
feat: add mfa events to oauth flows
sherzod-bakhodirov Feb 16, 2026
81e26e4
feat: implement mfa events in wallet kit
sherzod-bakhodirov Feb 18, 2026
b74e465
Merge branch 'master' into sherzodbakhodirov/emb-211-implement-headle…
sherzod-bakhodirov Feb 18, 2026
666591f
chore: update yarn.lock
sherzod-bakhodirov Feb 18, 2026
739cd2d
fix: properly display OAuth errors in pending view and clean up handles
sherzod-bakhodirov Feb 19, 2026
7682de9
Update packages/@magic-ext/wallet-kit/src/context/OAuthLoginContext.tsx
sherzod-bakhodirov Feb 19, 2026
dc3258f
Update packages/@magic-ext/oauth2/src/index.ts
sherzod-bakhodirov Feb 19, 2026
033da7b
Update packages/@magic-ext/oauth2/src/index.ts
sherzod-bakhodirov Feb 19, 2026
0304431
Update packages/@magic-ext/wallet-kit/src/context/OAuthLoginContext.tsx
sherzod-bakhodirov Feb 19, 2026
0712608
refactor: rename email login types to generic OTP for SMS support
sherzod-bakhodirov Feb 19, 2026
7f8bc69
feat: add MFA and recovery code events for SMS login
sherzod-bakhodirov Feb 19, 2026
4f42136
feat: implement SMS login support in wallet kit
sherzod-bakhodirov Feb 19, 2026
a7130f5
refactor: update components and views to use generic OTP naming
sherzod-bakhodirov Feb 19, 2026
667201c
feat: add SMS support to useMfa hook and exports
sherzod-bakhodirov Feb 19, 2026
0b89ae0
chore: install libphonenumber-js
sherzod-bakhodirov Feb 19, 2026
57ab1b5
refactor: minor improvements
sherzod-bakhodirov Feb 20, 2026
d6d2902
chore: add tests for whitelable sms flow
sherzod-bakhodirov Feb 20, 2026
d68df5d
feat: implement webauthn support
sherzod-bakhodirov Feb 23, 2026
4084ee3
chore: minor improvements in wallet kit sms flow
sherzod-bakhodirov Feb 23, 2026
591c418
Merge branch 'master' into sherzodbakhodirov/emb-253-implement-sms-we…
sherzod-bakhodirov Feb 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 128 additions & 38 deletions packages/@magic-ext/oauth2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import {
OAuthPopupConfiguration,
OAuthVerificationConfiguration,
} from './types';
import { OAuthPopupEventEmit, OAuthPopupEventHandlers, OAuthPopupEventOnReceived } from '@magic-sdk/types';
import {
OAuthMFAEventEmit,
OAuthMFAEventOnReceived,
OAuthPopupEventEmit,
OAuthPopupEventHandlers,
OAuthPopupEventOnReceived,
OAuthGetResultEventHandlers,
} from '@magic-sdk/types';

declare global {
interface Window {
Expand Down Expand Up @@ -83,10 +90,11 @@ 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 { showUI } = configuration;
const requestPayload = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Popup, [
{
...configuration,
Expand All @@ -102,33 +110,144 @@ 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.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<OAuthErrorData>(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 (!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 string)();
});
}

return promiEvent;
}

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<OAuthRedirectResult, OAuthGetResultEventHandlers>(
async (resolve, reject) => {
const getResultRequest = this.request<OAuthRedirectResult | OAuthRedirectError, OAuthGetResultEventHandlers>(
requestPayload,
);

if (!showUI) {
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<OAuthErrorData>(maybeError.error, maybeError.error_description ?? 'An error occurred.', {
errorURI: maybeError.error_uri,
provider: maybeError.provider,
}),
);
}

resolve(maybeResult);
},
);

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 string)();
});
}

return promiEvent;
}
Expand Down Expand Up @@ -160,33 +279,4 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> {
}
}

function getResult(this: OAuthExtension, configuration: OAuthVerificationConfiguration, queryString: string) {
return this.utils.createPromiEvent<OAuthRedirectResult>(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<OAuthRedirectResult | OAuthRedirectError>(parseRedirectResult);
const maybeResult = resultOrError as OAuthRedirectResult;
const maybeError = resultOrError as OAuthRedirectError;

if (maybeError.error) {
reject(
this.createError<OAuthErrorData>(maybeError.error, maybeError.error_description ?? 'An error occurred.', {
errorURI: maybeError.error_uri,
provider: maybeError.provider,
}),
);
}

resolve(maybeResult);
});
}

export * from './types';
2 changes: 2 additions & 0 deletions packages/@magic-ext/oauth2/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export interface OAuthVerificationConfiguration {
lifespan?: number;
optionalQueryString?: string;
skipDIDToken?: boolean;
showUI?: boolean;
}

export interface OAuthPopupConfiguration {
Expand All @@ -117,6 +118,7 @@ export interface OAuthPopupConfiguration {
loginHint?: string;
providerParams?: Record<string, string | number | boolean>;
shouldReturnURI?: boolean;
showUI?: boolean;
}

export enum OAuthErrorCode {
Expand Down
1 change: 1 addition & 0 deletions packages/@magic-ext/wallet-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@reown/appkit-adapter-wagmi": "^1.8.0",
"@wagmi/core": "^2.0.0",
"@walletconnect/ethereum-provider": "^2.23.0",
"libphonenumber-js": "^1.12.37",
"wagmi": "^2.0.0"
},
"peerDependencies": {
Expand Down
42 changes: 29 additions & 13 deletions packages/@magic-ext/wallet-kit/src/MagicWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,19 @@ 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 { SmsLoginProvider } from './context/SmsLoginContext';
import { WebAuthnLoginProvider } from './context/WebAuthnLoginContext';
import { WidgetConfigProvider } from './context/WidgetConfigContext';
import { EmailOTPView } from './views/EmailOTPView';
import { OtpView } from './views/OtpView';
import { DeviceVerificationView } from './views/DeviceVerificationView';
import { LoginSuccessView } from './views/LoginSuccessView';
import { MFAView } from './views/MfaView';
import { RecoveryCodeView } from './views/RecoveryCode';
import { LostRecoveryCode } from './views/LostRecoveryCode';
import { WalletConnectView } from './views/WalletConnectView';
import { SmsLoginView } from './views/SmsLoginView';
import { WebAuthnLoginView } from './views/WebAuthnLoginView';
import { FarcasterPendingView } from './views/FarcasterPendingView';
import { FarcasterSuccessView } from './views/FarcasterSuccessView';
import { FarcasterFailedView } from './views/FarcasterFailedView';
Expand Down Expand Up @@ -56,10 +61,14 @@ function WidgetContent({
const renderView = () => {
switch (state.view) {
case 'login':
return <LoginView dispatch={dispatch} />;
return <LoginView dispatch={dispatch} state={state} />;
case 'sms_login':
return <SmsLoginView state={state} />;
case 'webauthn_login':
return <WebAuthnLoginView state={state} />;
case 'wallet_pending':
if (!state.selectedProvider) {
return <LoginView dispatch={dispatch} />;
return <LoginView dispatch={dispatch} state={state} />;
}
return (
<WalletPendingView
Expand All @@ -73,19 +82,20 @@ function WidgetContent({
return <WalletConnectView key="walletconnect" dispatch={dispatch} />;
case 'oauth_pending':
if (!state.selectedProvider) {
return <LoginView dispatch={dispatch} />;
return <LoginView dispatch={dispatch} state={state} />;
}
return (
<OAuthPendingView
key={`oauth-${state.selectedProvider}`}
provider={state.selectedProvider as OAuthProvider}
state={state}
dispatch={dispatch}
/>
);
case 'additional_providers':
return <AdditionalProvidersView dispatch={dispatch} />;
case 'email_otp_pending':
return <EmailOTPView state={state} dispatch={dispatch} />;
case 'otp_pending':
return <OtpView state={state} dispatch={dispatch} />;
case 'device_verification':
return <DeviceVerificationView state={state} dispatch={dispatch} />;
case 'mfa_pending':
Expand All @@ -103,18 +113,24 @@ function WidgetContent({
case 'farcaster_failed':
return <FarcasterFailedView state={state} dispatch={dispatch} />;
default:
return <LoginView dispatch={dispatch} />;
return <LoginView dispatch={dispatch} state={state} />;
}
};

return (
<EmailLoginProvider dispatch={dispatch}>
<Modal isWidget fullscreen={isModal && isMobile}>
<VStack width="full" minWidth="380px">
{renderView()}
<Footer showLogo={showFooterLogo} />
</VStack>
</Modal>
<SmsLoginProvider dispatch={dispatch}>
<WebAuthnLoginProvider dispatch={dispatch}>
<OAuthLoginProvider dispatch={dispatch}>
<Modal isWidget fullscreen={isModal && isMobile}>
<VStack width="full" minWidth="380px">
{renderView()}
<Footer showLogo={showFooterLogo} />
</VStack>
</Modal>
</OAuthLoginProvider>
</WebAuthnLoginProvider>
</SmsLoginProvider>
</EmailLoginProvider>
);
}
Expand Down
Loading