Skip to content
Draft
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
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 `__internal_nativeOAuthHandler` to `ClerkOptions` for SDK wrappers (e.g. `@clerk/electron`) that need to handle OAuth flows outside the browser. When registered, Clerk uses the handler's `getRedirectUrl` as the FAPI redirect URL and calls `open` instead of navigating the browser, routing the callback through the native runtime. The `NativeOAuthHandler` type is exported from `@clerk/shared/types`.
44 changes: 44 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import type {
WaitlistProps,
WaitlistResource,
Web3Provider,
NativeOAuthHandler,
} from '@clerk/shared/types';
import type { ClerkUI } from '@clerk/shared/ui';
import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url';
Expand Down Expand Up @@ -254,6 +255,7 @@ export class Clerk implements ClerkInterface {
protected environment?: EnvironmentResource | null;

#queryClient: QueryClient | undefined;
#nativeOAuthHandler: NativeOAuthHandler | null = null;
#publishableKey = '';
#domain: DomainOrProxyUrl['domain'];
#proxyUrl: DomainOrProxyUrl['proxyUrl'];
Expand All @@ -273,6 +275,14 @@ export class Clerk implements ClerkInterface {
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();

get __internal_hasNativeOAuthHandler(): boolean {
return this.#nativeOAuthHandler !== null;
}

__internal_getNativeOAuthHandler(): NativeOAuthHandler | null {
return this.#nativeOAuthHandler;
}

get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined {
if (!this.#queryClient) {
void import('./query-core')
Expand Down Expand Up @@ -609,6 +619,9 @@ export class Clerk implements ClerkInterface {
});
}
this.#protect?.load(this.environment as Environment);

this.#nativeOAuthHandler = options?.__internal_nativeOAuthHandler ?? null;

debugLogger.info('load() complete', {}, 'clerk');
} catch (error) {
this.#publicEventBus.emit(clerkEvents.Status, 'error');
Expand Down Expand Up @@ -2322,6 +2335,29 @@ export class Clerk implements ClerkInterface {
});
};

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

const signIn = 'identifier' in (signInOrUp || {}) ? (signInOrUp as SignInResource) : _signIn;
const signUp = 'missingFields' in (signInOrUp || {}) ? (signInOrUp as SignUpResource) : _signUp;

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

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

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
{
Expand Down Expand Up @@ -2443,6 +2479,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
106 changes: 101 additions & 5 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { inBrowser } from '@clerk/shared/browser';
import { type ClerkError, ClerkRuntimeError, ClerkWebAuthnError } from '@clerk/shared/error';
import { ClerkAPIResponseError, type ClerkError, ClerkRuntimeError, ClerkWebAuthnError } from '@clerk/shared/error';
import {
convertJSONToPublicKeyRequestOptions,
serializePublicKeyCredentialAssertion,
Expand All @@ -15,6 +15,8 @@ import type {
AuthenticateWithPasskeyParams,
AuthenticateWithPopupParams,
AuthenticateWithRedirectParams,
NativeOAuthHandler,
HandleOAuthCallbackParams,
AuthenticateWithWeb3Params,
CaptchaWidgetType,
ClientTrustState,
Expand Down Expand Up @@ -98,6 +100,57 @@ import {
import { eventBus } from '../events';
import { BaseResource, UserData, Verification } from './internal';

function throwIfNativeCallbackHasError(callbackUrl: string): void {
const url = new URL(callbackUrl);
const code =
url.searchParams.get('clerk_error_code') ??
url.searchParams.get('error_code') ??
url.searchParams.get('code') ??
url.searchParams.get('error');
if (!code) return;
const message =
url.searchParams.get('clerk_error_message') ??
url.searchParams.get('error_message') ??
url.searchParams.get('message') ??
url.searchParams.get('error_description') ??
code;
const longMessage =
url.searchParams.get('clerk_error_long_message') ??
url.searchParams.get('long_message') ??
url.searchParams.get('longMessage') ??
undefined;
const statusStr =
url.searchParams.get('clerk_error_status') ??
url.searchParams.get('error_status') ??
url.searchParams.get('status');
const status = statusStr && Number.isInteger(Number(statusStr)) ? Number(statusStr) : 400;
throw new ClerkAPIResponseError(longMessage || message, {
status,
data: [{ code, message, long_message: longMessage }],
});
}

function throwIfSignInVerificationError(error: import('@clerk/shared/types').ClerkAPIError | null | undefined): void {
if (!error) return;
throw new ClerkAPIResponseError(error.longMessage || error.message, {
status: 400,
data: [
{
code: error.code,
message: error.message,
long_message: error.longMessage,
meta: {
param_name: error.meta?.paramName,
session_id: error.meta?.sessionId,
email_addresses: error.meta?.emailAddresses,
identifiers: error.meta?.identifiers,
zxcvbn: error.meta?.zxcvbn,
},
},
],
});
}

export class SignIn extends BaseResource implements SignInResource {
pathRoot = '/client/sign_ins';

Expand Down Expand Up @@ -344,11 +397,17 @@ export class SignIn extends BaseResource implements SignInResource {
params: AuthenticateWithRedirectParams,
navigateCallback: (url: URL | string) => void,
): Promise<void> => {
const transport = SignIn.clerk.__internal_getNativeOAuthHandler();
const { strategy, redirectUrlComplete, identifier, oidcPrompt, continueSignIn, enterpriseConnectionId } =
params || {};
const actionCompleteRedirectUrl = redirectUrlComplete;

const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl);
// For native transports, override the redirectUrl with the host runtime's deep-link URL
// so FAPI sends the callback back through the OS instead of the web SSO-callback route.
// actionCompleteRedirectUrl must also point to the deep-link: FAPI uses it (not redirectUrl)
// when sign-in completes immediately, and we need the deep-link to fire in both cases.
const redirectUrl = transport
? String(await transport.getRedirectUrl())
: SignIn.clerk.buildUrlWithAuth(params.redirectUrl);
const actionCompleteRedirectUrl = transport ? redirectUrl : redirectUrlComplete;

if (!this.id || !continueSignIn) {
await this.create({
Expand All @@ -372,12 +431,49 @@ export class SignIn extends BaseResource implements SignInResource {
const { status, externalVerificationRedirectURL } = this.firstFactorVerification;

if (status === 'unverified' && externalVerificationRedirectURL) {
navigateCallback(externalVerificationRedirectURL);
if (transport) {
await this.#handleWithTransport(transport, externalVerificationRedirectURL, params);
} else {
navigateCallback(externalVerificationRedirectURL);
}
} else {
clerkInvalidFAPIResponse(status, SignIn.fapiClient.buildEmailAddress('support'));
}
};

#handleWithTransport = async (
transport: NativeOAuthHandler,
externalVerificationRedirectURL: URL,
params: AuthenticateWithRedirectParams,
): Promise<void> => {
const { callbackUrl } = await transport.open(externalVerificationRedirectURL);
throwIfNativeCallbackHasError(callbackUrl);

const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
const callbackParams = (params.__internal_nativeCallbackParams ?? {}) as HandleOAuthCallbackParams;

if (nonce) {
await this.reload({ rotatingTokenNonce: nonce });
throwIfSignInVerificationError(this.firstFactorVerification.error);
await SignIn.clerk.__internal_handleNativeOAuthCallback(this, callbackParams);
} else {
// No nonce: provider returned without success. FAPI stores the error on the verification
// so reload until it appears, then surface it.
const TIMEOUT_MS = 3_000;
const INTERVAL_MS = 250;
const startedAt = Date.now();
await this.reload();
while (!this.firstFactorVerification.error && Date.now() - startedAt < TIMEOUT_MS) {
await new Promise(resolve => setTimeout(resolve, INTERVAL_MS));
await this.reload();
}
throwIfSignInVerificationError(this.firstFactorVerification.error);
throw new ClerkRuntimeError('Unable to complete authentication. Please try again.', {
code: 'native_redirect_incomplete',
});
}
};

public authenticateWithRedirect = async (params: AuthenticateWithRedirectParams): Promise<void> => {
return this.authenticateWithRedirectOrPopup(params, windowNavigate);
};
Expand Down
111 changes: 106 additions & 5 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { inBrowser } from '@clerk/shared/browser';
import { type ClerkError, ClerkRuntimeError, isCaptchaError, isClerkAPIResponseError } from '@clerk/shared/error';
import {
ClerkAPIResponseError,
type ClerkError,
ClerkRuntimeError,
isCaptchaError,
isClerkAPIResponseError,
} from '@clerk/shared/error';
import { createValidatePassword } from '@clerk/shared/internal/clerk-js/passwords/password';
import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
import { Poller } from '@clerk/shared/poller';
Expand All @@ -10,6 +16,8 @@ import type {
AttemptWeb3WalletVerificationParams,
AuthenticateWithPopupParams,
AuthenticateWithRedirectParams,
NativeOAuthHandler,
HandleOAuthCallbackParams,
AuthenticateWithWeb3Params,
CaptchaWidgetType,
CreateEmailLinkFlowReturn,
Expand Down Expand Up @@ -67,6 +75,57 @@ import {
import { eventBus } from '../events';
import { BaseResource, SignUpVerifications } from './internal';

function throwIfNativeCallbackHasError(callbackUrl: string): void {
const url = new URL(callbackUrl);
const code =
url.searchParams.get('clerk_error_code') ??
url.searchParams.get('error_code') ??
url.searchParams.get('code') ??
url.searchParams.get('error');
if (!code) return;
const message =
url.searchParams.get('clerk_error_message') ??
url.searchParams.get('error_message') ??
url.searchParams.get('message') ??
url.searchParams.get('error_description') ??
code;
const longMessage =
url.searchParams.get('clerk_error_long_message') ??
url.searchParams.get('long_message') ??
url.searchParams.get('longMessage') ??
undefined;
const statusStr =
url.searchParams.get('clerk_error_status') ??
url.searchParams.get('error_status') ??
url.searchParams.get('status');
const status = statusStr && Number.isInteger(Number(statusStr)) ? Number(statusStr) : 400;
throw new ClerkAPIResponseError(longMessage || message, {
status,
data: [{ code, message, long_message: longMessage }],
});
}

function throwIfSignUpVerificationError(error: import('@clerk/shared/types').ClerkAPIError | null | undefined): void {
if (!error) return;
throw new ClerkAPIResponseError(error.longMessage || error.message, {
status: 400,
data: [
{
code: error.code,
message: error.message,
long_message: error.longMessage,
meta: {
param_name: error.meta?.paramName,
session_id: error.meta?.sessionId,
email_addresses: error.meta?.emailAddresses,
identifiers: error.meta?.identifiers,
zxcvbn: error.meta?.zxcvbn,
},
},
],
});
}

declare global {
interface Window {
ethereum: any;
Expand Down Expand Up @@ -394,6 +453,7 @@ export class SignUp extends BaseResource implements SignUpResource {
},
navigateCallback: (url: URL | string) => void,
): Promise<void> => {
const transport = SignUp.clerk.__internal_getNativeOAuthHandler();
const {
redirectUrl,
redirectUrlComplete,
Expand All @@ -406,13 +466,19 @@ export class SignUp extends BaseResource implements SignUpResource {
enterpriseConnectionId,
} = params;

const redirectUrlWithAuthToken = SignUp.clerk.buildUrlWithAuth(redirectUrl);
// For native transports, override the redirectUrl with the host runtime's deep-link URL.
// actionCompleteRedirectUrl must also point to the deep-link: FAPI uses it (not redirectUrl)
// when sign-up completes immediately, and we need the deep-link to fire in both cases.
const effectiveRedirectUrl = transport
? String(await transport.getRedirectUrl())
: SignUp.clerk.buildUrlWithAuth(redirectUrl);
const effectiveActionCompleteRedirectUrl = transport ? effectiveRedirectUrl : redirectUrlComplete;

const authenticateFn = () => {
const authParams = {
strategy,
redirectUrl: redirectUrlWithAuthToken,
actionCompleteRedirectUrl: redirectUrlComplete,
redirectUrl: effectiveRedirectUrl,
actionCompleteRedirectUrl: effectiveActionCompleteRedirectUrl,
unsafeMetadata,
emailAddress,
legalAccepted,
Expand All @@ -438,12 +504,47 @@ export class SignUp extends BaseResource implements SignUpResource {
const { status, externalVerificationRedirectURL } = externalAccount;

if (status === 'unverified' && !!externalVerificationRedirectURL) {
navigateCallback(externalVerificationRedirectURL);
if (transport) {
await this.#handleWithTransport(transport, externalVerificationRedirectURL, params);
} else {
navigateCallback(externalVerificationRedirectURL);
}
} else {
clerkInvalidFAPIResponse(status, SignUp.fapiClient.buildEmailAddress('support'));
}
};

#handleWithTransport = async (
transport: NativeOAuthHandler,
externalVerificationRedirectURL: URL,
params: AuthenticateWithRedirectParams,
): Promise<void> => {
const { callbackUrl } = await transport.open(externalVerificationRedirectURL);
throwIfNativeCallbackHasError(callbackUrl);

const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
const callbackParams = (params.__internal_nativeCallbackParams ?? {}) as HandleOAuthCallbackParams;

if (nonce) {
await this.reload({ rotatingTokenNonce: nonce });
throwIfSignUpVerificationError(this.verifications.externalAccount.error);
await SignUp.clerk.__internal_handleNativeOAuthCallback(this, callbackParams);
} else {
const TIMEOUT_MS = 3_000;
const INTERVAL_MS = 250;
const startedAt = Date.now();
await this.reload();
while (!this.verifications.externalAccount.error && Date.now() - startedAt < TIMEOUT_MS) {
await new Promise(resolve => setTimeout(resolve, INTERVAL_MS));
await this.reload();
}
throwIfSignUpVerificationError(this.verifications.externalAccount.error);
throw new ClerkRuntimeError('Unable to complete authentication. Please try again.', {
code: 'native_redirect_incomplete',
});
}
};

public authenticateWithRedirect = async (
params: AuthenticateWithRedirectParams & {
unsafeMetadata?: SignUpUnsafeMetadata;
Expand Down
Loading
Loading