Skip to content
Open
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/oauth-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/shared': minor
'@clerk/clerk-js': minor
'@clerk/ui': minor
---

Add an internal OAuth transport (`__internal_oauthTransport`) so native desktop SDK wrappers can run Clerk's prebuilt OAuth/SSO flows through a system browser. Existing redirect and popup behavior is unchanged when no transport is registered.
133 changes: 133 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,41 @@ describe('Clerk singleton', () => {
});
});

describe('__internal_oauthTransport', () => {
it('defaults to null with no getter access errors', () => {
const sut = new Clerk(productionPublishableKey);

expect(sut.__internal_hasOAuthTransport).toBe(false);
expect(sut.__internal_oauthTransport).toBeNull();
});

it('exposes the transport registered via options after load', async () => {
const transport = {
getRedirectUrl: () => 'myapp://sso-callback',
open: async (_url: URL) => ({ callbackUrl: 'myapp://sso-callback' }),
};
const sut = new Clerk(productionPublishableKey);

await sut.load({ __internal_oauthTransport: transport });

expect(sut.__internal_hasOAuthTransport).toBe(true);
expect(sut.__internal_oauthTransport).toBe(transport);
});
});

describe('__internal_handleResourceCallback', () => {
it('handleGoogleOneTapCallback delegates to __internal_handleResourceCallback', async () => {
const clerk = new Clerk(productionPublishableKey);
await clerk.load();
const spy = vi.spyOn(clerk, '__internal_handleResourceCallback').mockResolvedValue(undefined);
const signInLike = { identifier: 'x' } as any;

await clerk.handleGoogleOneTapCallback(signInLike, { signInUrl: '/sign-in' });

expect(spy).toHaveBeenCalledWith(signInLike, { signInUrl: '/sign-in' }, undefined);
});
});

describe('.setActive', () => {
describe('with `active` session status', () => {
const mockSession = {
Expand Down Expand Up @@ -1205,6 +1240,104 @@ describe('Clerk singleton', () => {
});
});

it('uses navigateOnSetActive for completed sign in callbacks', async () => {
const sessionId = 'sess_123';
const mockSession = { id: sessionId, currentTask: null };
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
authConfig: {},
userSettings: mockUserSettings,
displayConfig: mockDisplayConfig,
isSingleSession: () => false,
isProduction: () => false,
isDevelopmentOrStaging: () => true,
onWindowLocationHost: () => false,
}),
);
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn({
status: 'complete',
created_session_id: sessionId,
} as any as SignInJSON),
signUp: new SignUp(null),
}),
);

const navigateOnSetActive = vi.fn(async ({ session, redirectUrl, decorateUrl }) => {
expect(session).toBe(mockSession);
expect(redirectUrl).toBe('http://test.host/after-sign-in');
expect(decorateUrl('/decorated')).toBe('/decorated');
});
const mockSetActive = vi.fn(async ({ navigate }) => navigate({ session: mockSession }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
sut.setActive = mockSetActive as any;

await sut.handleRedirectCallback({
signInForceRedirectUrl: '/after-sign-in',
navigateOnSetActive,
});

expect(mockSetActive).toHaveBeenCalledWith({
session: sessionId,
navigate: expect.any(Function),
});
expect(navigateOnSetActive).toHaveBeenCalledTimes(1);
expect(mockNavigate).not.toHaveBeenCalled();
});

it('uses navigateOnSetActive for completed sign up callbacks', async () => {
const sessionId = 'sess_123';
const mockSession = { id: sessionId, currentTask: null };
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
authConfig: {},
userSettings: mockUserSettings,
displayConfig: mockDisplayConfig,
isSingleSession: () => false,
isProduction: () => false,
isDevelopmentOrStaging: () => true,
onWindowLocationHost: () => false,
}),
);
mockClientFetch.mockReturnValue(
Promise.resolve({
signedInSessions: [],
signIn: new SignIn(null),
signUp: new SignUp({
status: 'complete',
created_session_id: sessionId,
} as any as SignUpJSON),
}),
);

const navigateOnSetActive = vi.fn(async ({ session, redirectUrl, decorateUrl }) => {
expect(session).toBe(mockSession);
expect(redirectUrl).toBe('http://test.host/after-sign-up');
expect(decorateUrl('/decorated')).toBe('/decorated');
});
const mockSetActive = vi.fn(async ({ navigate }) => navigate({ session: mockSession }));

const sut = new Clerk(productionPublishableKey);
await sut.load(mockedLoadOptions);
sut.setActive = mockSetActive as any;

await sut.handleRedirectCallback({
signUpForceRedirectUrl: '/after-sign-up',
navigateOnSetActive,
});

expect(mockSetActive).toHaveBeenCalledWith({
session: sessionId,
navigate: expect.any(Function),
});
expect(navigateOnSetActive).toHaveBeenCalledTimes(1);
expect(mockNavigate).not.toHaveBeenCalled();
});

it('does not initiate the transfer flow when transferable: false is passed', async () => {
mockEnvironmentFetch.mockReturnValue(
Promise.resolve({
Expand Down
90 changes: 85 additions & 5 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ import type {
LoadedClerk,
NavigateOptions,
OAuthApplicationNamespace,
OAuthTransport,
OrganizationListProps,
OrganizationProfileProps,
OrganizationResource,
Expand Down Expand Up @@ -269,6 +270,7 @@ export class Clerk implements ClerkInterface {
#listeners: Array<(emission: Resources) => void> = [];
#navigationListeners: Array<() => void> = [];
#options: ClerkOptions = {};
#oauthTransport: OAuthTransport | null = null;
#pageLifecycle: ReturnType<typeof createPageLifecycle> | null = null;
#touchThrottledUntil = 0;
#publicEventBus = createClerkEventBus();
Expand All @@ -295,6 +297,14 @@ export class Clerk implements ClerkInterface {
: undefined;
}

get __internal_hasOAuthTransport(): boolean {
return this.#oauthTransport !== null;
}

get __internal_oauthTransport(): OAuthTransport | null {
return this.#oauthTransport;
}

public __internal_getCachedResources:
| (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>)
| undefined;
Expand Down Expand Up @@ -529,6 +539,7 @@ export class Clerk implements ClerkInterface {
}

this.#options = this.#initOptions(options);
this.#oauthTransport = this.#options.__internal_oauthTransport ?? null;

// Initialize ClerkUI if it was provided
if (this.#options.ui?.ClerkUI) {
Expand Down Expand Up @@ -2285,7 +2296,7 @@ export class Clerk implements ClerkInterface {
return null;
};

public handleGoogleOneTapCallback = async (
public __internal_handleResourceCallback = async (
signInOrUp: SignInResource | SignUpResource,
params: HandleOAuthCallbackParams,
customNavigate?: (to: string) => Promise<unknown>,
Expand All @@ -2310,6 +2321,14 @@ export class Clerk implements ClerkInterface {
});
};

public handleGoogleOneTapCallback = async (
signInOrUp: SignInResource | SignUpResource,
params: HandleOAuthCallbackParams,
customNavigate?: (to: string) => Promise<unknown>,
): Promise<unknown> => {
return this.__internal_handleResourceCallback(signInOrUp, params, customNavigate);
};

private _handleRedirectCallback = async (
params: HandleOAuthCallbackParams,
{
Expand Down Expand Up @@ -2446,7 +2465,20 @@ export class Clerk implements ClerkInterface {
return this.setActive({
session: si.sessionId,
navigate: async ({ session }) => {
await setActiveNavigate({ session, baseUrl: signInUrl, redirectUrl: redirectUrls.getAfterSignInUrl() });
if (params.navigateOnSetActive) {
await params.navigateOnSetActive({
session,
redirectUrl: redirectUrls.getAfterSignInUrl(),
decorateUrl: url => this.buildUrlWithAuth(url),
});
return;
}

await setActiveNavigate({
session,
baseUrl: signInUrl,
redirectUrl: redirectUrls.getAfterSignInUrl(),
});
},
});
}
Expand All @@ -2461,7 +2493,20 @@ export class Clerk implements ClerkInterface {
return this.setActive({
session: res.createdSessionId,
navigate: async ({ session }) => {
await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignInUrl() });
if (params.navigateOnSetActive) {
await params.navigateOnSetActive({
session,
redirectUrl: redirectUrls.getAfterSignInUrl(),
decorateUrl: url => this.buildUrlWithAuth(url),
});
return;
}

await setActiveNavigate({
session,
baseUrl: signUpUrl,
redirectUrl: redirectUrls.getAfterSignInUrl(),
});
},
});
case 'needs_first_factor':
Expand Down Expand Up @@ -2512,7 +2557,20 @@ export class Clerk implements ClerkInterface {
return this.setActive({
session: res.createdSessionId,
navigate: async ({ session }) => {
await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignUpUrl() });
if (params.navigateOnSetActive) {
await params.navigateOnSetActive({
session,
redirectUrl: redirectUrls.getAfterSignUpUrl(),
decorateUrl: url => this.buildUrlWithAuth(url),
});
return;
}

await setActiveNavigate({
session,
baseUrl: signUpUrl,
redirectUrl: redirectUrls.getAfterSignUpUrl(),
});
},
});
case 'missing_requirements':
Expand All @@ -2526,7 +2584,20 @@ export class Clerk implements ClerkInterface {
return this.setActive({
session: su.sessionId,
navigate: async ({ session }) => {
await setActiveNavigate({ session, baseUrl: signUpUrl, redirectUrl: redirectUrls.getAfterSignUpUrl() });
if (params.navigateOnSetActive) {
await params.navigateOnSetActive({
session,
redirectUrl: redirectUrls.getAfterSignUpUrl(),
decorateUrl: url => this.buildUrlWithAuth(url),
});
return;
}

await setActiveNavigate({
session,
baseUrl: signUpUrl,
redirectUrl: redirectUrls.getAfterSignUpUrl(),
});
},
});
}
Expand All @@ -2552,6 +2623,15 @@ export class Clerk implements ClerkInterface {
return this.setActive({
session: sessionId,
navigate: async ({ session }) => {
if (params.navigateOnSetActive) {
await params.navigateOnSetActive({
session,
redirectUrl: redirectUrls.getAfterSignInUrl(),
decorateUrl: url => this.buildUrlWithAuth(url),
});
return;
}

await setActiveNavigate({
session,
baseUrl: suUserAlreadySignedIn ? signUpUrl : signInUrl,
Expand Down
13 changes: 13 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
_futureAuthenticateWithPopup,
wrapWithPopupRoutes,
} from '../../utils/authenticateWithPopup';
import { _authenticateWithTransport } from '../../utils/authenticateWithTransport';
import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge';
import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask';
import { loadZxcvbn } from '../../utils/zxcvbn';
Expand Down Expand Up @@ -379,6 +380,18 @@ export class SignIn extends BaseResource implements SignInResource {
};

public authenticateWithRedirect = async (params: AuthenticateWithRedirectParams): Promise<void> => {
const transport = SignIn.clerk.__internal_oauthTransport;
if (transport) {
return _authenticateWithTransport({
clerk: SignIn.clerk,
transport,
resource: this,
authenticateMethod: this.authenticateWithRedirectOrPopup,
params,
callbackParams: params.__internal_callbackParams ?? {},
});
}
Comment on lines +383 to +393

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

If no transport is registered, the existing redirect behavior is preserved. If a transport is registered, we reuse the same private authenticateWithRedirectOrPopup initiation path through _authenticateWithTransport


return this.authenticateWithRedirectOrPopup(params, windowNavigate);
};

Expand Down
13 changes: 13 additions & 0 deletions packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
_futureAuthenticateWithPopup,
wrapWithPopupRoutes,
} from '../../utils/authenticateWithPopup';
import { _authenticateWithTransport } from '../../utils/authenticateWithTransport';
import { CaptchaChallenge } from '../../utils/captcha/CaptchaChallenge';
import { normalizeUnsafeMetadata } from '../../utils/resourceParams';
import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask';
Expand Down Expand Up @@ -449,6 +450,18 @@ export class SignUp extends BaseResource implements SignUpResource {
unsafeMetadata?: SignUpUnsafeMetadata;
},
): Promise<void> => {
const transport = SignUp.clerk.__internal_oauthTransport;
if (transport) {
return _authenticateWithTransport({
clerk: SignUp.clerk,
transport,
resource: this,
authenticateMethod: this.authenticateWithRedirectOrPopup,
params,
callbackParams: params.__internal_callbackParams ?? {},
});
}

return this.authenticateWithRedirectOrPopup(params, windowNavigate);
};

Expand Down
Loading
Loading