diff --git a/.changeset/serious-tools-double.md b/.changeset/serious-tools-double.md new file mode 100644 index 00000000000..9abed2f0717 --- /dev/null +++ b/.changeset/serious-tools-double.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Add support for the `oauthFlow` prop on `` and ``, allowing developers to opt-in to using a popup for OAuth authorization instead of redirects. + + With the new `oauthFlow` prop, developers can opt-in to using a popup window instead of redirects for their OAuth flows by setting `oauthFlow` to `"popup"`. While we still recommend the default `"redirect"` for most scenarios, the `"popup"` option is useful in environments where the redirect flow does not currently work, such as when your application is embedded into an `iframe`. We also opt applications into the `"popup"` flow when we detect that your application is running on a domain that's typically embedded into an `iframe`, such as `loveable.app`. diff --git a/integration/templates/next-app-router/src/app/buttons/page.tsx b/integration/templates/next-app-router/src/app/buttons/page.tsx index fa87107d7df..b565e35e2ad 100644 --- a/integration/templates/next-app-router/src/app/buttons/page.tsx +++ b/integration/templates/next-app-router/src/app/buttons/page.tsx @@ -11,6 +11,15 @@ export default function Home() { Sign in button (force) + + Sign in button (force, popup) + + { + test('SignIn with oauthFlow=popup opens popup', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToRelative('/buttons'); + await u.page.waitForClerkJsLoaded(); + await u.po.expect.toBeSignedOut(); + + await u.page.getByText('Sign in button (force, popup)').click(); + + await u.po.signIn.waitForModal(); + + const popupPromise = context.waitForEvent('page'); + await u.page.getByRole('button', { name: 'E2E OAuth Provider' }).click(); + const popup = await popupPromise; + const popupUtils = createTestUtils({ app, page: popup, context }); + await popupUtils.page.getByText('Sign in to oauth-provider').waitFor(); + + await popupUtils.po.signIn.setIdentifier(fakeUser.email); + await popupUtils.po.signIn.continue(); + await popupUtils.po.signIn.enterTestOtpCode(); + + await u.page.waitForAppUrl('/protected'); + + await u.po.expect.toBeSignedIn(); + }); + }); }); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 629d66d37e4..659c6c18e7e 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,8 +1,8 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "580kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "78.6kB" }, - { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, + { "path": "./dist/clerk.js", "maxSize": "580.5kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "79.25kB" }, + { "path": "./dist/clerk.headless.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "94KB" }, { "path": "./dist/vendors*.js", "maxSize": "30KB" }, { "path": "./dist/coinbase*.js", "maxSize": "35.5KB" }, @@ -11,8 +11,8 @@ { "path": "./dist/organizationprofile*.js", "maxSize": "12KB" }, { "path": "./dist/organizationswitcher*.js", "maxSize": "5KB" }, { "path": "./dist/organizationlist*.js", "maxSize": "5.5KB" }, - { "path": "./dist/signin*.js", "maxSize": "12.4KB" }, - { "path": "./dist/signup*.js", "maxSize": "6.6KB" }, + { "path": "./dist/signin*.js", "maxSize": "12.5KB" }, + { "path": "./dist/signup*.js", "maxSize": "6.75KB" }, { "path": "./dist/userbutton*.js", "maxSize": "5KB" }, { "path": "./dist/userprofile*.js", "maxSize": "15KB" }, { "path": "./dist/userverification*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index f0e373d4bed..50c34e563d3 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -853,6 +853,17 @@ describe('Clerk singleton', () => { }); describe('.handleRedirectCallback()', () => { + // handleRedirectCallback calls signIn/signUp.reload, which relies on the global fetch instance. We don't actually + // need a return value though, so we just mock a resolved promise. + const originalFetch = global.fetch; + beforeAll(() => { + global.fetch = jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({}) }); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + beforeEach(() => { mockClientFetch.mockReset(); mockEnvironmentFetch.mockReset(); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 56897c72d3b..65cd4c2e923 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1489,6 +1489,25 @@ export class Clerk implements ClerkInterface { return; } + // If `handleRedirectCallback` is called on a window without an opener property (such as when the OAuth flow popup + // directs the opening page to navigate to the /sso-callback route), we need to reload the signIn and signUp resources + // to ensure that we have the latest state. This operation can fail when we try reloading a resource that doesn't + // exist (such as when reloading a signIn resource during a signUp attempt), but this can be safely ignored. + if (!window.opener) { + try { + await signIn.reload(); + } catch (err) { + console.log('This can be safely ignored:'); + console.error(err); + } + try { + await signUp.reload(); + } catch (err) { + console.log('This can be safely ignored:'); + console.error(err); + } + } + const { displayConfig } = this.environment; const { firstFactorVerification } = signIn; const { externalAccount } = signUp.verifications; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 8a1f2c85049..10a01dfbd83 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -9,6 +9,7 @@ import type { AttemptFirstFactorParams, AttemptSecondFactorParams, AuthenticateWithPasskeyParams, + AuthenticateWithPopupParams, AuthenticateWithRedirectParams, AuthenticateWithWeb3Params, CreateEmailLinkFlowReturn, @@ -48,6 +49,7 @@ import { getOKXWalletIdentifier, windowNavigate, } from '../../utils'; +import { _authenticateWithPopup } from '../../utils/authenticateWithPopup'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -224,7 +226,10 @@ export class SignIn extends BaseResource implements SignInResource { }); }; - public authenticateWithRedirect = async (params: AuthenticateWithRedirectParams): Promise => { + private authenticateWithRedirectOrPopup = async ( + params: AuthenticateWithRedirectParams, + navigateCallback: (url: URL | string) => void, + ): Promise => { const { strategy, redirectUrl, redirectUrlComplete, identifier } = params || {}; const { firstFactorVerification } = @@ -244,12 +249,26 @@ export class SignIn extends BaseResource implements SignInResource { const { status, externalVerificationRedirectURL } = firstFactorVerification; if (status === 'unverified' && externalVerificationRedirectURL) { - windowNavigate(externalVerificationRedirectURL); + navigateCallback(externalVerificationRedirectURL); } else { clerkInvalidFAPIResponse(status, SignIn.fapiClient.buildEmailAddress('support')); } }; + public authenticateWithRedirect = async (params: AuthenticateWithRedirectParams): Promise => { + return this.authenticateWithRedirectOrPopup(params, windowNavigate); + }; + + public authenticateWithPopup = async (params: AuthenticateWithPopupParams): Promise => { + const { popup } = params || {}; + if (!popup) { + clerkMissingOptionError('popup'); + } + return _authenticateWithPopup(SignIn.clerk, this.authenticateWithRedirectOrPopup, params, url => { + popup.location.href = url.toString(); + }); + }; + public authenticateWithWeb3 = async (params: AuthenticateWithWeb3Params): Promise => { if (__BUILD_DISABLE_RHC__) { clerkUnsupportedEnvironmentWarning('Web3'); diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 340dec1409b..8c499e85bc6 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -5,6 +5,7 @@ import type { AttemptPhoneNumberVerificationParams, AttemptVerificationParams, AttemptWeb3WalletVerificationParams, + AuthenticateWithPopupParams, AuthenticateWithRedirectParams, AuthenticateWithWeb3Params, CreateEmailLinkFlowReturn, @@ -34,6 +35,7 @@ import { getOKXWalletIdentifier, windowNavigate, } from '../../utils'; +import { _authenticateWithPopup } from '../../utils/authenticateWithPopup'; import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge'; import { createValidatePassword } from '../../utils/passwords/password'; import { normalizeUnsafeMetadata } from '../../utils/resourceParams'; @@ -290,19 +292,24 @@ export class SignUp extends BaseResource implements SignUpResource { }); }; - public authenticateWithRedirect = async ({ - redirectUrl, - redirectUrlComplete, - strategy, - continueSignUp = false, - unsafeMetadata, - emailAddress, - legalAccepted, - }: AuthenticateWithRedirectParams & { - unsafeMetadata?: SignUpUnsafeMetadata; - }): Promise => { + private authenticateWithRedirectOrPopup = async ( + params: AuthenticateWithRedirectParams & { + unsafeMetadata?: SignUpUnsafeMetadata; + }, + navigateCallback: (url: URL | string) => void, + ): Promise => { + const { + redirectUrl, + redirectUrlComplete, + strategy, + continueSignUp = false, + unsafeMetadata, + emailAddress, + legalAccepted, + } = params; + const authenticateFn = () => { - const params = { + const authParams = { strategy, redirectUrl: SignUp.clerk.buildUrlWithAuth(redirectUrl), actionCompleteRedirectUrl: redirectUrlComplete, @@ -310,7 +317,7 @@ export class SignUp extends BaseResource implements SignUpResource { emailAddress, legalAccepted, }; - return continueSignUp && this.id ? this.update(params) : this.create(params); + return continueSignUp && this.id ? this.update(authParams) : this.create(authParams); }; const { verifications } = await authenticateFn().catch(async e => { @@ -329,12 +336,35 @@ export class SignUp extends BaseResource implements SignUpResource { const { status, externalVerificationRedirectURL } = externalAccount; if (status === 'unverified' && !!externalVerificationRedirectURL) { - windowNavigate(externalVerificationRedirectURL); + navigateCallback(externalVerificationRedirectURL); } else { clerkInvalidFAPIResponse(status, SignUp.fapiClient.buildEmailAddress('support')); } }; + public authenticateWithRedirect = async ( + params: AuthenticateWithRedirectParams & { + unsafeMetadata?: SignUpUnsafeMetadata; + }, + ): Promise => { + return this.authenticateWithRedirectOrPopup(params, windowNavigate); + }; + + public authenticateWithPopup = async ( + params: AuthenticateWithPopupParams & { + unsafeMetadata?: SignUpUnsafeMetadata; + }, + ): Promise => { + const { popup } = params || {}; + if (!popup) { + clerkMissingOptionError('popup'); + } + + return _authenticateWithPopup(SignUp.clerk, this.authenticateWithRedirectOrPopup, params, url => { + popup.location.href = url instanceof URL ? url.toString() : url; + }); + }; + update = (params: SignUpUpdateParams): Promise => { return this._basePatch({ body: normalizeUnsafeMetadata(params), diff --git a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap index e8fbec1216d..e7578beaa23 100644 --- a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Client.test.ts.snap @@ -220,7 +220,9 @@ Client { "authenticateWithMetamask": [Function], "authenticateWithOKXWallet": [Function], "authenticateWithPasskey": [Function], + "authenticateWithPopup": [Function], "authenticateWithRedirect": [Function], + "authenticateWithRedirectOrPopup": [Function], "authenticateWithWeb3": [Function], "create": [Function], "createEmailLinkFlow": [Function], @@ -300,7 +302,9 @@ Client { "authenticateWithCoinbaseWallet": [Function], "authenticateWithMetamask": [Function], "authenticateWithOKXWallet": [Function], + "authenticateWithPopup": [Function], "authenticateWithRedirect": [Function], + "authenticateWithRedirectOrPopup": [Function], "authenticateWithWeb3": [Function], "create": [Function], "createEmailLinkFlow": [Function], diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx index dcab4d31679..5a3754a9db1 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInSocialButtons.tsx @@ -8,7 +8,7 @@ import { useCardState } from '../../elements/contexts'; import type { SocialButtonsProps } from '../../elements/SocialButtons'; import { SocialButtons } from '../../elements/SocialButtons'; import { useRouter } from '../../router'; -import { handleError, web3CallbackErrorHandler } from '../../utils'; +import { handleError, originPrefersPopup, web3CallbackErrorHandler } from '../../utils'; export const SignInSocialButtons = React.memo((props: SocialButtonsProps) => { const clerk = useClerk(); @@ -19,11 +19,30 @@ export const SignInSocialButtons = React.memo((props: SocialButtonsProps) => { const signIn = useCoreSignIn(); const redirectUrl = buildSSOCallbackURL(ctx, displayConfig.signInUrl); const redirectUrlComplete = ctx.afterSignInUrl || '/'; + const shouldUsePopup = ctx.oauthFlow === 'popup' || (ctx.oauthFlow === 'auto' && originPrefersPopup()); return ( { + if (shouldUsePopup) { + // We create the popup window here with the `about:blank` URL since some browsers will block popups that are + // opened within async functions. The `signInWithPopup` method handles setting the URL of the popup. + const popup = window.open('about:blank', '', 'width=600,height=800'); + // Unfortunately, there's no good way to detect when the popup is closed, so we simply poll and check if it's closed. + const interval = setInterval(() => { + if (!popup || popup.closed) { + clearInterval(interval); + card.setIdle(); + } + }, 500); + + return signIn + .authenticateWithPopup({ strategy, redirectUrl, redirectUrlComplete, popup }) + .catch(err => handleError(err, [], card.setError)); + } + return signIn .authenticateWithRedirect({ strategy, redirectUrl, redirectUrlComplete }) .catch(err => handleError(err, [], card.setError)); diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpSocialButtons.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpSocialButtons.tsx index 211c6dbaed4..2eaa170f5eb 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpSocialButtons.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpSocialButtons.tsx @@ -7,7 +7,7 @@ import { useCardState } from '../../elements'; import type { SocialButtonsProps } from '../../elements/SocialButtons'; import { SocialButtons } from '../../elements/SocialButtons'; import { useRouter } from '../../router'; -import { handleError, web3CallbackErrorHandler } from '../../utils'; +import { handleError, originPrefersPopup, web3CallbackErrorHandler } from '../../utils'; export type SignUpSocialButtonsProps = SocialButtonsProps & { continueSignUp?: boolean; legalAccepted?: boolean }; @@ -19,12 +19,39 @@ export const SignUpSocialButtons = React.memo((props: SignUpSocialButtonsProps) const signUp = useCoreSignUp(); const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignUpUrl || '/'; + const shouldUsePopup = ctx.oauthFlow === 'popup' || (ctx.oauthFlow === 'auto' && originPrefersPopup()); const { continueSignUp = false, ...rest } = props; return ( { + if (shouldUsePopup) { + // We create the popup window here with the `about:blank` URL since some browsers will block popups that are + // opened within async functions. The `signUpWithPopup` method handles setting the URL of the popup. + const popup = window.open('about:blank', '', 'width=600,height=800'); + // Unfortunately, there's no good way to detect when the popup is closed, so we simply poll and check if it's closed. + const interval = setInterval(() => { + if (!popup || popup.closed) { + clearInterval(interval); + card.setIdle(); + } + }, 500); + + return signUp + .authenticateWithPopup({ + strategy, + redirectUrl, + redirectUrlComplete, + popup, + continueSignUp, + unsafeMetadata: ctx.unsafeMetadata, + legalAccepted: props.legalAccepted, + }) + .catch(err => handleError(err, [], card.setError)); + } + return signUp .authenticateWithRedirect({ continueSignUp, diff --git a/packages/clerk-js/src/ui/contexts/components/SignIn.ts b/packages/clerk-js/src/ui/contexts/components/SignIn.ts index d709a0d14b4..3d5e77f50d7 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignIn.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignIn.ts @@ -129,6 +129,7 @@ export const useSignInContext = (): SignInContextType => { return { ...(ctx as SignInCtx), transferable: ctx.transferable ?? true, + oauthFlow: ctx.oauthFlow || 'auto', componentName, signUpUrl, signInUrl, diff --git a/packages/clerk-js/src/ui/contexts/components/SignUp.ts b/packages/clerk-js/src/ui/contexts/components/SignUp.ts index 59ce5f4ab09..7d1d58f6f88 100644 --- a/packages/clerk-js/src/ui/contexts/components/SignUp.ts +++ b/packages/clerk-js/src/ui/contexts/components/SignUp.ts @@ -123,6 +123,7 @@ export const useSignUpContext = (): SignUpContextType => { return { ...ctx, + oauthFlow: ctx.oauthFlow || 'auto', componentName, signInUrl, signUpUrl, diff --git a/packages/clerk-js/src/ui/elements/SocialButtons.tsx b/packages/clerk-js/src/ui/elements/SocialButtons.tsx index 8e55cdc2a89..5d33d4d77f7 100644 --- a/packages/clerk-js/src/ui/elements/SocialButtons.tsx +++ b/packages/clerk-js/src/ui/elements/SocialButtons.tsx @@ -35,6 +35,7 @@ export type SocialButtonsProps = React.PropsWithChildren<{ type SocialButtonsRootProps = SocialButtonsProps & { oauthCallback: (strategy: OAuthStrategy) => Promise; web3Callback: (strategy: Web3Strategy) => Promise; + idleAfterDelay?: boolean; }; const isWeb3Strategy = (val: string): val is Web3Strategy => { @@ -42,7 +43,13 @@ const isWeb3Strategy = (val: string): val is Web3Strategy => { }; export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { - const { oauthCallback, web3Callback, enableOAuthProviders = true, enableWeb3Providers = true } = props; + const { + oauthCallback, + web3Callback, + enableOAuthProviders = true, + enableWeb3Providers = true, + idleAfterDelay = true, + } = props; const { web3Strategies, authenticatableOauthStrategies, strategyToDisplayData } = useEnabledThirdPartyProviders(); const card = useCardState(); const { socialButtonsVariant } = useAppearance().parsedLayout; @@ -78,8 +85,10 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { await sleep(1000); card.setIdle(); } - await sleep(5000); - card.setIdle(); + if (idleAfterDelay) { + await sleep(5000); + card.setIdle(); + } }; const ButtonElement = preferBlockButtons ? SocialButtonBlock : SocialButtonIcon; diff --git a/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx b/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx index 92523042b4f..58a12ce2f18 100644 --- a/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx @@ -33,6 +33,7 @@ type FlowMetadata = { | 'passwordPwnedMethods' | 'havingTrouble' | 'ssoCallback' + | 'popupCallback' | 'popover' | 'complete' | 'accountSwitcher'; diff --git a/packages/clerk-js/src/ui/utils/index.ts b/packages/clerk-js/src/ui/utils/index.ts index c946647108e..c846f8399c1 100644 --- a/packages/clerk-js/src/ui/utils/index.ts +++ b/packages/clerk-js/src/ui/utils/index.ts @@ -25,4 +25,5 @@ export * from './colorOptionToHslaScale'; export * from './createCustomMenuItems'; export * from './usernameUtils'; export * from './web3CallbackErrorHandler'; +export * from './originPrefersPopup'; export * from './normalizeColorString'; diff --git a/packages/clerk-js/src/ui/utils/originPrefersPopup.ts b/packages/clerk-js/src/ui/utils/originPrefersPopup.ts new file mode 100644 index 00000000000..6fca8a25b3c --- /dev/null +++ b/packages/clerk-js/src/ui/utils/originPrefersPopup.ts @@ -0,0 +1,10 @@ +const POPUP_PREFERRED_ORIGINS = ['.lovable.app', '.lovableproject.com', '.webcontainer-api.io']; + +/** + * Returns `true` if the current origin is one that is typically embedded via an iframe, which would benefit from the + * popup flow. + * @returns {boolean} Whether the current origin prefers the popup flow. + */ +export function originPrefersPopup(): boolean { + return POPUP_PREFERRED_ORIGINS.some(origin => window.location.origin.endsWith(origin)); +} diff --git a/packages/clerk-js/src/utils/authenticateWithPopup.ts b/packages/clerk-js/src/utils/authenticateWithPopup.ts new file mode 100644 index 00000000000..0dfd0419eed --- /dev/null +++ b/packages/clerk-js/src/utils/authenticateWithPopup.ts @@ -0,0 +1,77 @@ +import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; +import type { AuthenticateWithPopupParams, AuthenticateWithRedirectParams } from '@clerk/types'; + +import type { Clerk } from '../core/clerk'; + +export async function _authenticateWithPopup( + client: Clerk, + authenticateMethod: ( + params: AuthenticateWithRedirectParams, + navigateCallback: (url: URL | string) => void, + ) => Promise, + params: AuthenticateWithPopupParams & { + unsafeMetadata?: SignUpUnsafeMetadata; + }, + navigateCallback: (url: URL | string) => void, +): Promise { + if (!client.client || !params.popup) { + return; + } + + const accountPortalHost = buildAccountsBaseUrl(client.frontendApi); + + const { redirectUrl } = params; + + // We set the force_redirect_url query parameter to ensure that the user is redirected to the correct page even + // in situations like a modal transfer flow. + const r = new URL(redirectUrl); + r.searchParams.set('sign_in_force_redirect_url', params.redirectUrlComplete); + r.searchParams.set('sign_up_force_redirect_url', params.redirectUrlComplete); + // All URLs are decorated with the dev browser token in development mode since we're moving between AP and the app. + const redirectUrlWithForceRedirectUrl = client.buildUrlWithAuth(r.toString()); + + const popupRedirectUrlComplete = client.buildUrlWithAuth(`${accountPortalHost}/popup-callback`); + const popupRedirectUrl = client.buildUrlWithAuth( + `${accountPortalHost}/popup-callback?return_url=${encodeURIComponent(redirectUrlWithForceRedirectUrl)}`, + ); + + const messageHandler = async (event: MessageEvent) => { + if (event.origin !== accountPortalHost) return; + + let shouldRemoveListener = false; + + if (event.data.session) { + const existingSession = client.client?.sessions.find(x => x.id === event.data.session) || null; + if (!existingSession) { + try { + await client.client?.reload(); + } catch (e) { + console.error(e); + } + } + await client.setActive({ + session: event.data.session, + redirectUrl: params.redirectUrlComplete, + }); + shouldRemoveListener = true; + } else if (event.data.return_url) { + client.navigate(event.data.return_url); + shouldRemoveListener = true; + } + + if (shouldRemoveListener) { + window.removeEventListener('message', messageHandler); + } + }; + + window.addEventListener('message', messageHandler); + + await authenticateMethod( + { + ...params, + redirectUrlComplete: popupRedirectUrlComplete, + redirectUrl: popupRedirectUrl, + }, + navigateCallback, + ); +} diff --git a/packages/react/src/components/SignInButton.tsx b/packages/react/src/components/SignInButton.tsx index d985bc2bcca..a584baa5521 100644 --- a/packages/react/src/components/SignInButton.tsx +++ b/packages/react/src/components/SignInButton.tsx @@ -15,6 +15,7 @@ export const SignInButton = withClerk( mode, initialValues, withSignUp, + oauthFlow, ...rest } = props; children = normalizeWithDefaultValue(children, 'Sign in'); @@ -28,6 +29,7 @@ export const SignInButton = withClerk( signUpForceRedirectUrl, initialValues, withSignUp, + oauthFlow, }; if (mode === 'modal') { diff --git a/packages/react/src/components/SignUpButton.tsx b/packages/react/src/components/SignUpButton.tsx index b4b6ac27363..ccf5ce4f3c9 100644 --- a/packages/react/src/components/SignUpButton.tsx +++ b/packages/react/src/components/SignUpButton.tsx @@ -15,6 +15,7 @@ export const SignUpButton = withClerk( mode, unsafeMetadata, initialValues, + oauthFlow, ...rest } = props; @@ -29,6 +30,7 @@ export const SignUpButton = withClerk( signInForceRedirectUrl, unsafeMetadata, initialValues, + oauthFlow, }; if (mode === 'modal') { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 5f7c6e24ce1..ff19882dd30 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1031,6 +1031,10 @@ export type SignInProps = RoutingOptions & { * Enable sign-in-or-up flow for `` component instance. */ withSignUp?: boolean; + /** + * Control whether OAuth flows use redirects or popups. + */ + oauthFlow?: 'auto' | 'redirect' | 'popup'; } & TransferableOption & SignUpForceRedirectUrl & SignUpFallbackRedirectUrl & @@ -1164,6 +1168,10 @@ export type SignUpProps = RoutingOptions & { * Used to fill the "Join waitlist" link in the SignUp component. */ waitlistUrl?: string; + /** + * Control whether OAuth flows use redirects or popups. + */ + oauthFlow?: 'auto' | 'redirect' | 'popup'; } & SignInFallbackRedirectUrl & SignInForceRedirectUrl & LegacyRedirectProps & @@ -1541,6 +1549,7 @@ export type SignInButtonProps = ButtonProps & | 'signUpFallbackRedirectUrl' | 'initialValues' | 'withSignUp' + | 'oauthFlow' >; export type SignUpButtonProps = { @@ -1553,6 +1562,7 @@ export type SignUpButtonProps = { | 'signInForceRedirectUrl' | 'signInFallbackRedirectUrl' | 'initialValues' + | 'oauthFlow' >; export type CreateOrganizationInvitationParams = { diff --git a/packages/types/src/redirects.ts b/packages/types/src/redirects.ts index b67d6742a9d..efeb4e9b432 100644 --- a/packages/types/src/redirects.ts +++ b/packages/types/src/redirects.ts @@ -83,6 +83,8 @@ export type AuthenticateWithRedirectParams = { legalAccepted?: boolean; }; +export type AuthenticateWithPopupParams = AuthenticateWithRedirectParams & { popup: Window | null }; + export type RedirectUrlProp = { /** * Full URL or path to navigate after a successful action. diff --git a/packages/types/src/signIn.ts b/packages/types/src/signIn.ts index ac6d662278c..7db407f303e 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -47,7 +47,7 @@ import type { VerificationJSON, } from './json'; import type { ValidatePasswordCallbacks } from './passwords'; -import type { AuthenticateWithRedirectParams } from './redirects'; +import type { AuthenticateWithPopupParams, AuthenticateWithRedirectParams } from './redirects'; import type { ClerkResource } from './resource'; import type { SignInJSONSnapshot } from './snapshots'; import type { @@ -104,6 +104,8 @@ export interface SignInResource extends ClerkResource { authenticateWithRedirect: (params: AuthenticateWithRedirectParams) => Promise; + authenticateWithPopup: (params: AuthenticateWithPopupParams) => Promise; + authenticateWithWeb3: (params: AuthenticateWithWeb3Params) => Promise; authenticateWithMetamask: () => Promise; diff --git a/packages/types/src/signUp.ts b/packages/types/src/signUp.ts index de7d3158718..77ce720c6b8 100644 --- a/packages/types/src/signUp.ts +++ b/packages/types/src/signUp.ts @@ -9,7 +9,7 @@ import type { } from './identifiers'; import type { ValidatePasswordCallbacks } from './passwords'; import type { AttemptPhoneNumberVerificationParams, PreparePhoneNumberVerificationParams } from './phoneNumber'; -import type { AuthenticateWithRedirectParams } from './redirects'; +import type { AuthenticateWithPopupParams, AuthenticateWithRedirectParams } from './redirects'; import type { ClerkResource } from './resource'; import type { SignUpJSONSnapshot, SignUpVerificationJSONSnapshot, SignUpVerificationsJSONSnapshot } from './snapshots'; import type { @@ -99,6 +99,10 @@ export interface SignUpResource extends ClerkResource { params: AuthenticateWithRedirectParams & { unsafeMetadata?: SignUpUnsafeMetadata }, ) => Promise; + authenticateWithPopup: ( + params: AuthenticateWithPopupParams & { unsafeMetadata?: SignUpUnsafeMetadata }, + ) => Promise; + authenticateWithWeb3: ( params: AuthenticateWithWeb3Params & { unsafeMetadata?: SignUpUnsafeMetadata;