Skip to content

Commit 296fb0b

Browse files
authored
feat(clerk-js,types): Add support for modal SSO sign-ins (#7026)
1 parent 7ef5682 commit 296fb0b

File tree

4 files changed

+141
-11
lines changed

4 files changed

+141
-11
lines changed

.changeset/ripe-banks-pay.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/types': minor
4+
---
5+
6+
[Experimental] Add support for modal SSO sign-ins to new APIs

packages/clerk-js/src/core/resources/SignIn.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ import {
7777
getOKXWalletIdentifier,
7878
windowNavigate,
7979
} from '../../utils';
80-
import { _authenticateWithPopup } from '../../utils/authenticateWithPopup';
80+
import {
81+
_authenticateWithPopup,
82+
_futureAuthenticateWithPopup,
83+
wrapWithPopupRoutes,
84+
} from '../../utils/authenticateWithPopup';
8185
import {
8286
convertJSONToPublicKeyRequestOptions,
8387
serializePublicKeyCredentialAssertion,
@@ -893,29 +897,52 @@ class SignInFuture implements SignInFutureResource {
893897
}
894898

895899
async sso(params: SignInFutureSSOParams): Promise<{ error: unknown }> {
896-
const { flow = 'auto', strategy, redirectUrl, redirectCallbackUrl } = params;
900+
const { strategy, redirectUrl, redirectCallbackUrl, popup, oidcPrompt, enterpriseConnectionId } = params;
897901
return runAsyncResourceTask(this.resource, async () => {
898-
if (flow !== 'auto') {
899-
throw new Error('modal flow is not supported yet');
900-
}
901-
902902
let actionCompleteRedirectUrl = redirectUrl;
903903
try {
904904
new URL(redirectUrl);
905905
} catch {
906906
actionCompleteRedirectUrl = window.location.origin + redirectUrl;
907907
}
908908

909+
const routes = { redirectUrl: SignIn.clerk.buildUrlWithAuth(redirectCallbackUrl), actionCompleteRedirectUrl };
910+
if (popup) {
911+
const wrappedRoutes = wrapWithPopupRoutes(SignIn.clerk, {
912+
redirectCallbackUrl: routes.redirectUrl,
913+
redirectUrl: actionCompleteRedirectUrl,
914+
});
915+
routes.redirectUrl = wrappedRoutes.redirectCallbackUrl;
916+
routes.actionCompleteRedirectUrl = wrappedRoutes.redirectUrl;
917+
}
918+
909919
await this._create({
910920
strategy,
911-
redirectUrl: SignIn.clerk.buildUrlWithAuth(redirectCallbackUrl),
912-
actionCompleteRedirectUrl,
921+
...routes,
913922
});
914923

924+
if (strategy === 'enterprise_sso') {
925+
await this.resource.__internal_basePost({
926+
body: {
927+
...routes,
928+
oidcPrompt,
929+
enterpriseConnectionId,
930+
strategy: 'enterprise_sso',
931+
},
932+
action: 'prepare_first_factor',
933+
});
934+
}
935+
915936
const { status, externalVerificationRedirectURL } = this.resource.firstFactorVerification;
916937

917938
if (status === 'unverified' && externalVerificationRedirectURL) {
918-
windowNavigate(externalVerificationRedirectURL);
939+
if (popup) {
940+
await _futureAuthenticateWithPopup(SignIn.clerk, { popup, externalVerificationRedirectURL });
941+
// Pick up the modified SignIn resource
942+
await this.resource.reload();
943+
} else {
944+
windowNavigate(externalVerificationRedirectURL);
945+
}
919946
}
920947
});
921948
}

packages/clerk-js/src/utils/authenticateWithPopup.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,74 @@ export async function _authenticateWithPopup(
7979
navigateCallback,
8080
);
8181
}
82+
83+
/**
84+
* Creates new redirect and callback URLs that point to the `/popup-callback` route on Account Portal. These URLs will
85+
* be used by FAPI to redirect after the OAuth flow completes, and will result in a message being sent to the parent
86+
* window.
87+
*/
88+
export function wrapWithPopupRoutes(
89+
client: Clerk,
90+
{
91+
redirectCallbackUrl,
92+
redirectUrl,
93+
}: {
94+
/**
95+
* The route to navigate to if a session was not created.
96+
*/
97+
redirectCallbackUrl: string;
98+
/**
99+
* The route to navigate to if a session was created.
100+
*/
101+
redirectUrl: string;
102+
},
103+
): { redirectCallbackUrl: string; redirectUrl: string } {
104+
const accountPortalHost = buildAccountsBaseUrl(client.frontendApi);
105+
106+
// We set the force_redirect_url query parameter to ensure that the user is redirected to the correct page even
107+
// in situations like a modal transfer flow.
108+
const r = new URL(redirectCallbackUrl);
109+
r.searchParams.set('sign_in_force_redirect_url', redirectUrl);
110+
r.searchParams.set('sign_up_force_redirect_url', redirectUrl);
111+
// All URLs are decorated with the dev browser token in development mode since we're moving between AP and the app.
112+
const redirectUrlWithForceRedirectUrl = client.buildUrlWithAuth(r.toString());
113+
114+
const popupRedirectUrlComplete = client.buildUrlWithAuth(`${accountPortalHost}/popup-callback`);
115+
const popupRedirectUrl = client.buildUrlWithAuth(
116+
`${accountPortalHost}/popup-callback?return_url=${encodeURIComponent(redirectUrlWithForceRedirectUrl)}`,
117+
);
118+
119+
return { redirectCallbackUrl: popupRedirectUrl, redirectUrl: popupRedirectUrlComplete };
120+
}
121+
122+
export function _futureAuthenticateWithPopup(
123+
client: Clerk,
124+
params: { popup: { location: { href: string } }; externalVerificationRedirectURL: URL },
125+
): Promise<void> {
126+
return new Promise((resolve, reject) => {
127+
if (!client.client || !params.popup) {
128+
reject();
129+
return;
130+
}
131+
132+
const messageHandler = async (event: MessageEvent) => {
133+
if (event.origin !== buildAccountsBaseUrl(client.frontendApi)) {
134+
return;
135+
}
136+
137+
// The OAuth flow was successful, and we received a message with either a session or a return URL.
138+
if (event.data.session || event.data.return_url) {
139+
window.removeEventListener('message', messageHandler);
140+
resolve();
141+
} else {
142+
reject();
143+
}
144+
};
145+
146+
// Listen for messages from the popup window.
147+
window.addEventListener('message', messageHandler);
148+
149+
// Navigate the popup window to the external verification redirect URL, which kicks off the OAuth flow.
150+
params.popup.location.href = params.externalVerificationRedirectURL.toString();
151+
});
152+
}

packages/shared/src/types/signInFuture.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,10 @@ export interface SignInFuturePhoneCodeVerifyParams {
167167
}
168168

169169
export interface SignInFutureSSOParams {
170-
flow?: 'auto' | 'modal';
171-
strategy: OAuthStrategy | 'saml' | 'enterprise_sso';
170+
/**
171+
* The strategy to use for authentication.
172+
*/
173+
strategy: OAuthStrategy | 'enterprise_sso';
172174
/**
173175
* The URL to redirect to after the user has completed the SSO flow.
174176
*/
@@ -177,6 +179,30 @@ export interface SignInFutureSSOParams {
177179
* TODO @revamp-hooks: This should be handled by FAPI instead.
178180
*/
179181
redirectCallbackUrl: string;
182+
/**
183+
* If provided, a `Window` to use for the OAuth flow. Useful in instances where you cannot navigate to an
184+
* OAuth provider.
185+
*
186+
* @example
187+
* ```ts
188+
* const popup = window.open('about:blank', '', 'width=600,height=800');
189+
* if (!popup) {
190+
* throw new Error('Failed to open popup');
191+
* }
192+
* await signIn.sso({ popup, strategy: 'oauth_google', redirectUrl: '/dashboard' });
193+
* ```
194+
*/
195+
popup?: { location: { href: string } };
196+
/**
197+
* Optional for `oauth_<provider>` or `enterprise_sso` strategies. The value to pass to the
198+
* [OIDC prompt parameter](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=prompt,reauthentication%20and%20consent.)
199+
* in the generated OAuth redirect URL.
200+
*/
201+
oidcPrompt?: string;
202+
/**
203+
* @experimental
204+
*/
205+
enterpriseConnectionId?: string;
180206
}
181207

182208
export interface SignInFutureMFAPhoneCodeVerifyParams {

0 commit comments

Comments
 (0)