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
6 changes: 6 additions & 0 deletions .changeset/enterprise-sso-hand-off-challenge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': patch
'@clerk/ui': patch
---

Fix enterprise SSO sign-ins erroring instead of showing a verification challenge raised while handing off to the identity provider.
12 changes: 12 additions & 0 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,13 +388,21 @@ export class SignIn extends BaseResource implements SignInResource {

const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl);

// Defer external navigation while a challenge is pending: the caller resolves it and calls
// back in with `continueSignIn`.
const isChallengePending = () => !!this.protectCheck || this.status === 'needs_protect_check';

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

if (isChallengePending()) {
return;
}
Comment on lines +403 to +405

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Since this is part of a public API, this is technically a breaking change right? Before, it always failed or navigated, now it can get stuck.

We might be fine with that since it only affects a private beta, but wanted to be clear about it.

That this is a hidden behavior you need to know about and compensate for at the callsite also kind of irks me. Doesn't feel like a great long term API?

}

if (strategy === 'enterprise_sso') {
Expand All @@ -405,6 +413,10 @@ export class SignIn extends BaseResource implements SignInResource {
oidcPrompt,
enterpriseConnectionId,
});

if (isChallengePending()) {
return;
}
}

const { status, externalVerificationRedirectURL } = this.firstFactorVerification;
Expand Down
98 changes: 98 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 @@ -311,6 +311,104 @@ describe('SignIn', () => {
});
});

describe('authenticateWithRedirect with a pending challenge', () => {
const originalFetch = BaseResource._fetch;

afterEach(() => {
BaseResource._fetch = originalFetch;
vi.clearAllMocks();
SignIn.clerk = {} as any;
});

const gatedResponse = {
client: null,
response: {
id: 'signin_123',
status: 'needs_protect_check',
first_factor_verification: null,
protect_check: {
status: 'pending',
token: 'challenge-token-abc',
sdk_url: 'https://sdk.example.com/challenge.js',
},
},
};

const setupClerk = () => {
const windowNavigate = vi.fn();
SignIn.clerk = {
buildUrlWithAuth: vi.fn(u => u),
__internal_windowNavigate: windowNavigate,
__internal_environment: { displayConfig: { captchaOauthBypass: [] } },
} as any;
return windowNavigate;
};

it('stops after create instead of preparing a hand-off it cannot follow', async () => {
const windowNavigate = setupClerk();
const mockFetch = vi.fn().mockResolvedValue(gatedResponse);
BaseResource._fetch = mockFetch;

const signIn = new SignIn();
await expect(
signIn.authenticateWithRedirect({
strategy: 'enterprise_sso',
redirectUrl: '/sso-callback',
redirectUrlComplete: '/',
}),
).resolves.toBeUndefined();

// Only the create call — the prepare is not attempted while the challenge is pending.
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(windowNavigate).not.toHaveBeenCalled();
expect(signIn.protectCheck?.status).toBe('pending');
});

it('stops when preparing the enterprise SSO hand-off returns a challenge', async () => {
const windowNavigate = setupClerk();
const mockFetch = vi.fn().mockResolvedValue(gatedResponse);
BaseResource._fetch = mockFetch;

const signIn = new SignIn({ id: 'signin_123' } as any);
await expect(
signIn.authenticateWithRedirect({
strategy: 'enterprise_sso',
redirectUrl: '/sso-callback',
redirectUrlComplete: '/',
continueSignIn: true,
}),
).resolves.toBeUndefined();

expect(windowNavigate).not.toHaveBeenCalled();
expect(signIn.protectCheck?.status).toBe('pending');
});

it('follows the hand-off once no challenge is pending', async () => {
const windowNavigate = setupClerk();
BaseResource._fetch = vi.fn().mockResolvedValue({
client: null,
response: {
id: 'signin_123',
status: 'needs_first_factor',
first_factor_verification: {
status: 'unverified',
external_verification_redirect_url: 'https://idp.example/auth',
},
},
});

const signIn = new SignIn({ id: 'signin_123' } as any);
await signIn.authenticateWithRedirect({
strategy: 'enterprise_sso',
redirectUrl: '/sso-callback',
redirectUrlComplete: '/',
continueSignIn: true,
});

expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.example/auth'));
});
});

describe('signIn.create', () => {
afterEach(() => {
vi.clearAllMocks();
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,13 +454,17 @@ function SignInStartInternal(): JSX.Element {
const redirectUrl = ctx.ssoCallbackUrl;
const redirectUrlComplete = ctx.afterSignInUrl || '/';

return signIn.authenticateWithRedirect({
await signIn.authenticateWithRedirect({
strategy: 'enterprise_sso',
redirectUrl,
redirectUrlComplete,
oidcPrompt: ctx.oidcPrompt,
continueSignIn: true,
});

// Preparing the hand-off can itself raise a challenge, in which case no redirect was issued
// and the sign-in is sitting on the gate instead.
navigateOnSignInProtectGate(signIn, navigate, 'protect-check');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does SignInFactorOneEnterpriseConnections also need this treatment?

const handleEnterpriseSSO = (enterpriseConnectionId: string) => {
const redirectUrl = ctx.ssoCallbackUrl;
const redirectUrlComplete = ctx.afterSignInUrl || '/';
return signIn.authenticateWithRedirect({
strategy: 'enterprise_sso',
redirectUrl,
redirectUrlComplete,
oidcPrompt: ctx.oidcPrompt,
continueSignIn: true,
enterpriseConnectionId,
});

};

const attemptToRecoverFromSignInError = async (e: any) => {
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/components/SignIn/__tests__/SignInStart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,28 @@ describe('SignInStart', () => {
continueSignIn: true,
});
});

it('routes to the challenge when preparing the hand-off raises one', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
});
fixtures.signIn.create.mockReturnValueOnce(
Promise.resolve({
status: 'needs_first_factor',
supportedFirstFactors: [{ strategy: 'enterprise_sso' }],
} as unknown as SignInResource),
);
// No redirect is issued: the sign-in comes back sitting on the challenge instead.
fixtures.signIn.authenticateWithRedirect.mockImplementationOnce(() => {
(fixtures.signIn as any).protectCheck = { status: 'pending', token: 'challenge-token-abc' };
return Promise.resolve();
});
const { userEvent } = render(<SignInStart />, { wrapper });
await userEvent.type(screen.getByLabelText(/email address/i), 'hello@clerk.com');
await userEvent.click(screen.getByText('Continue'));
expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalled();
expect(fixtures.router.navigate).toHaveBeenCalledWith('protect-check');
});
});

describe('Identifier switching', () => {
Expand Down
Loading