Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
198 changes: 153 additions & 45 deletions packages/@magic-ext/oauth2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand All @@ -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',
Expand All @@ -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<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 (!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<OAuthRedirectResult, OAuthGetResultEventHandlers>(
async (resolve, reject) => {
if (hasStateMismatch) {
reject(
this.createError<object>(
'STATE_MISMATCH',
'OAuth state parameter mismatch β€” request may have been tampered with',
{},
),
);
}

const getResultRequest = this.request<OAuthRedirectResult | OAuthRedirectError, OAuthGetResultEventHandlers>(
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<OAuthErrorData>(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;
}
Expand Down Expand Up @@ -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<OAuthRedirectResult>(async (resolve, reject) => {
private retrievePKCEMetadata(queryString: string): {
clientMetadata?: Record<string, string>;
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.
Expand All @@ -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<object>('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<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);
});
return { clientMetadata, hasStateMismatch };
}
}

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 @@ -119,6 +119,7 @@ export interface OAuthVerificationConfiguration {
lifespan?: number;
optionalQueryString?: string;
skipDIDToken?: boolean;
showMfaModal?: boolean;
}

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

export enum OAuthErrorCode {
Expand Down
16 changes: 10 additions & 6 deletions packages/@magic-ext/wallet-kit/src/MagicWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -79,6 +80,7 @@ function WidgetContent({
<OAuthPendingView
key={`oauth-${state.selectedProvider}`}
provider={state.selectedProvider as OAuthProvider}
state={state}
dispatch={dispatch}
/>
);
Expand Down Expand Up @@ -109,12 +111,14 @@ function WidgetContent({

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