Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
1a9ebc7
wip
dstaley Feb 26, 2025
b672410
Merge branch 'main' into ds.feat/auth-with-popup
dstaley Mar 3, 2025
6b35b89
feat(clerk-js): Use Account Portal for popup callback
dstaley Mar 3, 2025
819e7ab
chore: Add changeset
dstaley Mar 3, 2025
93c0dbb
fix(clerk-js): Only allow accountPortalDomain messages
dstaley Mar 3, 2025
1c3f2cd
feat(clerk-js): Add oauthFlow prop
dstaley Mar 4, 2025
bc25b7f
feat(clerk-js): Implement popup authentication flow for sign up
dstaley Mar 4, 2025
b6ae69a
feat(clerk-js): add webcontainer-api.io to popup origns
dstaley Mar 4, 2025
aecbfa9
fix(clerk-js): Support modal sign in/sign up with return_url parameter
dstaley Mar 6, 2025
0d7249e
Merge branch 'main' into ds.feat/auth-with-popup
dstaley Mar 6, 2025
5ca29b7
feat: Add oauthFlow prop to SignInButton
dstaley Mar 7, 2025
a8a07ca
test(e2e): Add e2e test for oauth popup flow
dstaley Mar 7, 2025
f80fb86
add new lovable preview domain to pop-up list
mwickett Mar 12, 2025
f5aaaae
fix(clerk-js): Use DB JWT on both URLs
dstaley Mar 17, 2025
f8017d1
fix(clerk-js): Remove popup handling now that AP handles it
dstaley Mar 18, 2025
10de915
Merge branch 'main' into ds.feat/auth-with-popup
dstaley Mar 18, 2025
d105c04
fix(clerk-js): Update max size limits
dstaley Mar 18, 2025
1dfd8ec
fix(clerk-js): Update max size limits
dstaley Mar 18, 2025
adfd1ad
fix(clerk-js): Update IsomorphicClerk type
dstaley Mar 19, 2025
73035da
fix(clerk-js): Mock global fetch
dstaley Mar 19, 2025
262707b
Merge branch 'main' into ds.feat/auth-with-popup
dstaley Mar 19, 2025
f41aa5d
cleanup(clerk-js): rm console.log
dstaley Mar 19, 2025
7f6ed87
fix(clerk-js): originPrefersPopup util function
dstaley Mar 19, 2025
a3a3dc8
fix(types,react): Add oauthFlow prop to SignUpButton
dstaley Mar 19, 2025
c87acd1
feat(clerk-js): Improve loading state for popup auth
dstaley Mar 19, 2025
a60b18d
chore(repo): Update changeset
dstaley Mar 19, 2025
a53c7b9
chore(clerk-js): Add comments
dstaley Mar 19, 2025
b9e5009
chore(clerk-js): Sort imports
dstaley Mar 19, 2025
41b69f4
Merge branch 'main' into ds.feat/auth-with-popup
dstaley Mar 20, 2025
1d988be
fix(clerk-js): Always call toString()
dstaley Mar 21, 2025
e9c24bf
fix(clerk-js): Restore idle state if popup is blocked
dstaley Mar 21, 2025
684fd8f
Merge branch 'main' into ds.feat/auth-with-popup
dstaley Mar 21, 2025
b2cfec2
fix(clerk-js): Use shared buildAccountsBaseUrl utility
dstaley Mar 21, 2025
3b269a9
fix(clerk-js): Use logical OR instead of nullish coalescing
dstaley Mar 21, 2025
2f80ccb
feat(clerk-js): Move authenticateWithPopup methods to signIn/signUp
dstaley Mar 21, 2025
b750eac
Merge branch 'main' into ds.feat/auth-with-popup
dstaley Mar 21, 2025
d2fddec
cleanup(react): Remove isomorphic withPopup methods
dstaley Mar 21, 2025
642664d
fix(clerk-js): Increase bundlewatch thresholds
dstaley Mar 21, 2025
5852ecb
cleanup(react): Remove unused type
dstaley Mar 21, 2025
9788a67
Merge branch 'main' into ds.feat/auth-with-popup
dstaley Mar 25, 2025
367577b
Merge branch 'main' into ds.feat/auth-with-popup
dstaley Mar 25, 2025
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
8 changes: 8 additions & 0 deletions .changeset/serious-tools-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

Add support for the `oauthFlow` prop on `<SignIn />` and `<SignUp />`, 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`.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ export default function Home() {
Sign in button (force)
</SignInButton>

<SignInButton
mode='modal'
oauthFlow='popup'
forceRedirectUrl='/protected'
signUpForceRedirectUrl='/protected'
>
Sign in button (force, popup)
</SignInButton>

<SignInButton
mode='modal'
fallbackRedirectUrl='/protected'
Expand Down
28 changes: 28 additions & 0 deletions integration/tests/oauth-flows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,32 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('oauth flo

await u.page.waitForAppUrl('/protected');
});

test.describe('authenticateWithPopup', () => {
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();
});
});
});
10 changes: 5 additions & 5 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -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" },
Expand All @@ -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" },
Expand Down
11 changes: 11 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 19 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +1500 to +1501
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a significant reason to leave the logs ?

}
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;
Expand Down
23 changes: 21 additions & 2 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
AttemptFirstFactorParams,
AttemptSecondFactorParams,
AuthenticateWithPasskeyParams,
AuthenticateWithPopupParams,
AuthenticateWithRedirectParams,
AuthenticateWithWeb3Params,
CreateEmailLinkFlowReturn,
Expand Down Expand Up @@ -48,6 +49,7 @@ import {
getOKXWalletIdentifier,
windowNavigate,
} from '../../utils';
import { _authenticateWithPopup } from '../../utils/authenticateWithPopup';
import {
convertJSONToPublicKeyRequestOptions,
serializePublicKeyCredentialAssertion,
Expand Down Expand Up @@ -224,7 +226,10 @@ export class SignIn extends BaseResource implements SignInResource {
});
};

public authenticateWithRedirect = async (params: AuthenticateWithRedirectParams): Promise<void> => {
private authenticateWithRedirectOrPopup = async (
params: AuthenticateWithRedirectParams,
navigateCallback: (url: URL | string) => void,
): Promise<void> => {
const { strategy, redirectUrl, redirectUrlComplete, identifier } = params || {};

const { firstFactorVerification } =
Expand All @@ -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<void> => {
return this.authenticateWithRedirectOrPopup(params, windowNavigate);
};

public authenticateWithPopup = async (params: AuthenticateWithPopupParams): Promise<void> => {
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<SignInResource> => {
if (__BUILD_DISABLE_RHC__) {
clerkUnsupportedEnvironmentWarning('Web3');
Expand Down
58 changes: 44 additions & 14 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AttemptPhoneNumberVerificationParams,
AttemptVerificationParams,
AttemptWeb3WalletVerificationParams,
AuthenticateWithPopupParams,
AuthenticateWithRedirectParams,
AuthenticateWithWeb3Params,
CreateEmailLinkFlowReturn,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -290,27 +292,32 @@ export class SignUp extends BaseResource implements SignUpResource {
});
};

public authenticateWithRedirect = async ({
redirectUrl,
redirectUrlComplete,
strategy,
continueSignUp = false,
unsafeMetadata,
emailAddress,
legalAccepted,
}: AuthenticateWithRedirectParams & {
unsafeMetadata?: SignUpUnsafeMetadata;
}): Promise<void> => {
private authenticateWithRedirectOrPopup = async (
params: AuthenticateWithRedirectParams & {
unsafeMetadata?: SignUpUnsafeMetadata;
},
navigateCallback: (url: URL | string) => void,
): Promise<void> => {
const {
redirectUrl,
redirectUrlComplete,
strategy,
continueSignUp = false,
unsafeMetadata,
emailAddress,
legalAccepted,
} = params;

const authenticateFn = () => {
const params = {
const authParams = {
strategy,
redirectUrl: SignUp.clerk.buildUrlWithAuth(redirectUrl),
actionCompleteRedirectUrl: redirectUrlComplete,
unsafeMetadata,
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 => {
Expand All @@ -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<void> => {
return this.authenticateWithRedirectOrPopup(params, windowNavigate);
};

public authenticateWithPopup = async (
params: AuthenticateWithPopupParams & {
unsafeMetadata?: SignUpUnsafeMetadata;
},
): Promise<void> => {
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<SignUpResource> => {
return this._basePatch({
body: normalizeUnsafeMetadata(params),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ Client {
"authenticateWithMetamask": [Function],
"authenticateWithOKXWallet": [Function],
"authenticateWithPasskey": [Function],
"authenticateWithPopup": [Function],
"authenticateWithRedirect": [Function],
"authenticateWithRedirectOrPopup": [Function],
"authenticateWithWeb3": [Function],
"create": [Function],
"createEmailLinkFlow": [Function],
Expand Down Expand Up @@ -300,7 +302,9 @@ Client {
"authenticateWithCoinbaseWallet": [Function],
"authenticateWithMetamask": [Function],
"authenticateWithOKXWallet": [Function],
"authenticateWithPopup": [Function],
"authenticateWithRedirect": [Function],
"authenticateWithRedirectOrPopup": [Function],
"authenticateWithWeb3": [Function],
"create": [Function],
"createEmailLinkFlow": [Function],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 (
<SocialButtons
{...props}
idleAfterDelay={!shouldUsePopup}
oauthCallback={strategy => {
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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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 (
<SocialButtons
{...rest}
idleAfterDelay={!shouldUsePopup}
oauthCallback={(strategy: OAuthStrategy) => {
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,
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/contexts/components/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const useSignInContext = (): SignInContextType => {
return {
...(ctx as SignInCtx),
transferable: ctx.transferable ?? true,
oauthFlow: ctx.oauthFlow || 'auto',
componentName,
signUpUrl,
signInUrl,
Expand Down
Loading