Skip to content
Draft
7 changes: 7 additions & 0 deletions .changeset/native-external-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@clerk/shared": minor
"@clerk/ui": minor
"@clerk/clerk-js": minor
---

Add experimental native redirect support for Electron SSO flows. Clerk's prebuilt sign-in, sign-up, and connected-account UI can use an internal Electron preload bridge to open external verification URLs and resume after the native callback.
31 changes: 31 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2443,6 +2443,14 @@ export class Clerk implements ClerkInterface {
baseUrl: string;
redirectUrl: string;
}) => {
if (params.navigateOnSetActive) {
return params.navigateOnSetActive({
session,
redirectUrl,
decorateUrl: url => this.buildUrlWithAuth(url),
});
}

if (!session.currentTask) {
await this.navigate(redirectUrl);
return;
Expand Down Expand Up @@ -2611,6 +2619,29 @@ export class Clerk implements ClerkInterface {
});
};

public __experimental_handleNativeRedirectCallback = async (
signInOrUp: SignInResource | SignUpResource,
params: HandleOAuthCallbackParams = {},
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
if (!this.loaded || !this.environment || !this.client) {
return;
}

const { signIn: currentSignIn, signUp: currentSignUp } = this.client;
const signIn = 'identifier' in (signInOrUp || {}) ? (signInOrUp as SignInResource) : currentSignIn;
const signUp = 'missingFields' in (signInOrUp || {}) ? (signInOrUp as SignUpResource) : currentSignUp;

const navigate = (to: string) =>
customNavigate && typeof customNavigate === 'function' ? customNavigate(to) : this.navigate(to);

return this._handleRedirectCallback(params, {
signUp,
signIn,
navigate,
});
};

// TODO: Deprecate this one, and mark it as internal. Is there actual benefit for external developers to use this ? Should they ever reach for it ?
public handleUnauthenticated = async (opts = { broadcast: true }): Promise<unknown> => {
if (!this.client || !this.session) {
Expand Down
35 changes: 35 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Poller } from '@clerk/shared/poller';
import type {
AttemptFirstFactorParams,
AttemptSecondFactorParams,
AuthenticateWithNativeRedirectParams,
AuthenticateWithPasskeyParams,
AuthenticateWithPopupParams,
AuthenticateWithRedirectParams,
Expand Down Expand Up @@ -392,6 +393,40 @@ export class SignIn extends BaseResource implements SignInResource {
});
};

public __experimental_authenticateWithNativeRedirect = async (
params: AuthenticateWithNativeRedirectParams,
): Promise<SignInResource> => {
const { strategy, redirectUrl, identifier, oidcPrompt, continueSignIn, enterpriseConnectionId } = params || {};

if (!this.id || !continueSignIn) {
await this.create({
strategy,
identifier,
redirectUrl,
actionCompleteRedirectUrl: redirectUrl,
oidcPrompt,
});
}

if (strategy === 'enterprise_sso') {
await this.prepareFirstFactor({
strategy,
redirectUrl,
actionCompleteRedirectUrl: redirectUrl,
oidcPrompt,
enterpriseConnectionId,
});
}

const { status, externalVerificationRedirectURL } = this.firstFactorVerification;

if (status !== 'unverified' || !externalVerificationRedirectURL) {
clerkInvalidFAPIResponse(status, SignIn.fapiClient.buildEmailAddress('support'));
}

return this;
};

public authenticateWithWeb3 = async (params: AuthenticateWithWeb3Params): Promise<SignInResource> => {
const { identifier, generateSignature, strategy = 'web3_metamask_signature', walletName } = params || {};
const provider = strategy.replace('web3_', '').replace('_signature', '') as Web3Provider;
Expand Down
49 changes: 49 additions & 0 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
AttemptPhoneNumberVerificationParams,
AttemptVerificationParams,
AttemptWeb3WalletVerificationParams,
AuthenticateWithNativeRedirectParams,
AuthenticateWithPopupParams,
AuthenticateWithRedirectParams,
AuthenticateWithWeb3Params,
Expand Down Expand Up @@ -467,6 +468,54 @@ export class SignUp extends BaseResource implements SignUpResource {
});
};

public __experimental_authenticateWithNativeRedirect = async (
params: AuthenticateWithNativeRedirectParams & {
unsafeMetadata?: SignUpUnsafeMetadata;
},
): Promise<SignUpResource> => {
const {
strategy,
redirectUrl,
continueSignUp = false,
unsafeMetadata,
emailAddress,
legalAccepted,
oidcPrompt,
enterpriseConnectionId,
} = params;

const authenticate = () => {
const authParams = {
strategy,
redirectUrl,
actionCompleteRedirectUrl: redirectUrl,
unsafeMetadata,
emailAddress,
legalAccepted,
oidcPrompt,
enterpriseConnectionId,
};
return continueSignUp && this.id ? this.update(authParams) : this.create(authParams);
};

await authenticate().catch(async e => {
if (isClerkAPIResponseError(e) && isCaptchaError(e)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await SignUp.clerk.__internal_environment!.reload();
return authenticate();
}
throw e;
});

const { status, externalVerificationRedirectURL } = this.verifications.externalAccount;

if (status !== 'unverified' || !externalVerificationRedirectURL) {
clerkInvalidFAPIResponse(status, SignUp.fapiClient.buildEmailAddress('support'));
}

return this;
};

update = (params: SignUpUpdateParams): Promise<SignUpResource> => {
return this._basePatch({
body: normalizeUnsafeMetadata(params),
Expand Down
63 changes: 63 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2057,6 +2057,69 @@ describe('SignIn', () => {
vi.unstubAllGlobals();
});

it('prepares OAuth for native redirect', async () => {
const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth');

const signIn = new SignIn();

vi.spyOn(signIn, 'create').mockImplementation(async () => {
signIn.firstFactorVerification = {
status: 'unverified',
externalVerificationRedirectURL,
} as any;
return signIn;
});

const res = await signIn.__experimental_authenticateWithNativeRedirect({
strategy: 'oauth_google',
identifier: 'user@example.com',
redirectUrl: 'myapp://sso-callback',
redirectUrlComplete: '/after-sign-in',
});

expect(signIn.create).toHaveBeenCalledWith({
strategy: 'oauth_google',
identifier: 'user@example.com',
redirectUrl: 'myapp://sso-callback',
actionCompleteRedirectUrl: 'myapp://sso-callback',
oidcPrompt: undefined,
});
expect(res).toBe(signIn);
expect(signIn.firstFactorVerification.externalVerificationRedirectURL).toBe(externalVerificationRedirectURL);
});

it('continues enterprise SSO for native redirect', async () => {
const externalVerificationRedirectURL = new URL('https://sso.example.com/auth');

const signIn = new SignIn({ id: 'signin_123', status: 'needs_first_factor' } as any);
vi.spyOn(signIn, 'create');
vi.spyOn(signIn, 'prepareFirstFactor').mockImplementation(async () => {
signIn.firstFactorVerification = {
status: 'unverified',
externalVerificationRedirectURL,
} as any;
return signIn;
});

await signIn.__experimental_authenticateWithNativeRedirect({
strategy: 'enterprise_sso',
continueSignIn: true,
enterpriseConnectionId: 'ec_123',
redirectUrl: 'myapp://sso-callback',
redirectUrlComplete: '/',
});

expect(signIn.create).not.toHaveBeenCalled();
expect(signIn.prepareFirstFactor).toHaveBeenCalledWith({
strategy: 'enterprise_sso',
redirectUrl: 'myapp://sso-callback',
actionCompleteRedirectUrl: 'myapp://sso-callback',
oidcPrompt: undefined,
enterpriseConnectionId: 'ec_123',
});
expect(signIn.firstFactorVerification.externalVerificationRedirectURL).toBe(externalVerificationRedirectURL);
});

it('creates signIn with enterprise_sso strategy and prepares first factor', async () => {
vi.stubGlobal('window', { location: { origin: 'https://example.com' } });

Expand Down
73 changes: 73 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,79 @@ describe('SignUp', () => {
SignUp.clerk = {} as any;
});

it('prepares OAuth for native redirect', async () => {
const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth');

const signUp = new SignUp();

vi.spyOn(signUp, 'create').mockImplementation(async () => {
signUp.verifications.externalAccount = {
status: 'unverified',
externalVerificationRedirectURL,
} as any;
return signUp;
});

const res = await signUp.__experimental_authenticateWithNativeRedirect({
strategy: 'oauth_google',
redirectUrl: 'myapp://sso-callback',
redirectUrlComplete: '/',
unsafeMetadata: { plan: 'pro' },
legalAccepted: true,
});

expect(signUp.create).toHaveBeenCalledWith({
strategy: 'oauth_google',
redirectUrl: 'myapp://sso-callback',
actionCompleteRedirectUrl: 'myapp://sso-callback',
unsafeMetadata: { plan: 'pro' },
emailAddress: undefined,
legalAccepted: true,
oidcPrompt: undefined,
enterpriseConnectionId: undefined,
});
expect(res).toBe(signUp);
expect(signUp.verifications.externalAccount.externalVerificationRedirectURL).toBe(
externalVerificationRedirectURL,
);
});

it('continues sign up for native redirect', async () => {
const externalVerificationRedirectURL = new URL('https://accounts.example.com/oauth');

const signUp = new SignUp({ id: 'signup_123', status: 'missing_requirements' } as any);
vi.spyOn(signUp, 'create');
vi.spyOn(signUp, 'update').mockImplementation(async () => {
signUp.verifications.externalAccount = {
status: 'unverified',
externalVerificationRedirectURL,
} as any;
return signUp;
});

await signUp.__experimental_authenticateWithNativeRedirect({
strategy: 'oauth_google',
redirectUrl: 'myapp://sso-callback',
redirectUrlComplete: '/',
continueSignUp: true,
});

expect(signUp.create).not.toHaveBeenCalled();
expect(signUp.update).toHaveBeenCalledWith({
strategy: 'oauth_google',
redirectUrl: 'myapp://sso-callback',
actionCompleteRedirectUrl: 'myapp://sso-callback',
unsafeMetadata: undefined,
emailAddress: undefined,
legalAccepted: undefined,
oidcPrompt: undefined,
enterpriseConnectionId: undefined,
});
expect(signUp.verifications.externalAccount.externalVerificationRedirectURL).toBe(
externalVerificationRedirectURL,
);
});

it('handles relative redirectUrl by converting to absolute', async () => {
vi.stubGlobal('window', { location: { origin: 'https://example.com' } });

Expand Down
21 changes: 21 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,17 @@ export interface Clerk {
customNavigate?: (to: string) => Promise<unknown>,
) => Promise<unknown>;

/**
* Completes a custom OAuth or SAML redirect flow using an already refreshed SignIn or SignUp resource.
*
* @internal
*/
__experimental_handleNativeRedirectCallback: (
signInOrUp: SignInResource | SignUpResource,
params: HandleOAuthCallbackParams | HandleSamlCallbackParams,
customNavigate?: (to: string) => Promise<unknown>,
) => Promise<unknown>;

/**
* Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email.
* @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution.
Expand Down Expand Up @@ -1221,6 +1232,16 @@ export type HandleOAuthCallbackParams = TransferableOption &
* The underlying resource to optionally reload before processing an OAuth callback.
*/
reloadResource?: 'signIn' | 'signUp';
/**
* Internal navigation hook used by Clerk UI to preserve custom post-activation routing behavior.
*
* @internal
*/
navigateOnSetActive?: (opts: {
session: SessionResource;
redirectUrl: string;
decorateUrl: DecorateUrl;
}) => Promise<unknown>;
/**
* Metadata that can be read and set from the frontend. Once the sign-up is complete, the value of this field will be automatically copied to the newly created user's unsafe metadata. One common use case for this attribute is to use it to implement custom fields that can be collected during sign-up and will automatically be attached to the created `User` object.
*/
Expand Down
16 changes: 16 additions & 0 deletions packages/shared/src/types/electron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
type Awaitable<T> = T | Promise<T>;

/**
* Internal bridge exposed by @clerk/electron from the Electron preload script.
*
* @internal
*/
export type ClerkElectronBridge = {
getRedirectUrl: () => Awaitable<string>;
openExternal: (url: string | URL) => Awaitable<void>;
/**
* Resolves with the native callback URL, or rejects with a known Clerk error
* when the native browser flow does not complete.
*/
waitForRedirectCallback: () => Awaitable<string>;
};
1 change: 1 addition & 0 deletions packages/shared/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type * from './customPages';
export type * from './deletedObject';
export type * from './devtools';
export type * from './displayConfig';
export type * from './electron';
export type * from './elementIds';
export type * from './emailAddress';
export type * from './enterpriseAccount';
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/types/redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export type AuthenticateWithRedirectParams = {

export type AuthenticateWithPopupParams = AuthenticateWithRedirectParams & { popup: Window | null };

/**
* @experimental This API is subject to change.
*/
export type AuthenticateWithNativeRedirectParams = AuthenticateWithRedirectParams;

/** @generateWithEmptyComment */
export type RedirectUrlProp = {
/**
Expand Down
Loading
Loading