diff --git a/.changeset/bright-slack-workspaces.md b/.changeset/bright-slack-workspaces.md new file mode 100644 index 00000000000..550a972de4c --- /dev/null +++ b/.changeset/bright-slack-workspaces.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add the optional `slackTeamIdHint` workspace hint to Slack sign-in and sign-up attempts, including redirect, popup, and unified SSO flows. diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 24c325edbf1..83caee52941 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -423,8 +423,15 @@ export class SignIn extends BaseResource implements SignInResource { params: AuthenticateWithRedirectParams, navigateCallback: (url: URL | string) => void, ): Promise => { - const { strategy, redirectUrlComplete, identifier, oidcPrompt, continueSignIn, enterpriseConnectionId } = - params || {}; + const { + strategy, + redirectUrlComplete, + identifier, + oidcPrompt, + slackTeamIdHint, + continueSignIn, + enterpriseConnectionId, + } = params || {}; const actionCompleteRedirectUrl = redirectUrlComplete; const redirectUrl = SignIn.clerk.buildUrlWithAuth(params.redirectUrl); @@ -450,13 +457,15 @@ export class SignIn extends BaseResource implements SignInResource { // provider first" and the challenge runs on the way back, where the callback routes to it. let challengedCreateHandOff: URL | null = null; - if (!this.id || !continueSignIn) { + const shouldCreateSignIn = !this.id || !continueSignIn; + if (shouldCreateSignIn) { await this.create({ strategy, identifier, redirectUrl, actionCompleteRedirectUrl, oidcPrompt, + slackTeamIdHint, }); if (isChallengePending()) { @@ -467,14 +476,16 @@ export class SignIn extends BaseResource implements SignInResource { } } - if (strategy === 'enterprise_sso') { - await this.prepareFirstFactor({ + if (strategy === 'enterprise_sso' || (!shouldCreateSignIn && slackTeamIdHint !== undefined)) { + const prepareParams = { strategy, redirectUrl, actionCompleteRedirectUrl, oidcPrompt, + slackTeamIdHint, enterpriseConnectionId, - }); + }; + await this._basePost({ body: prepareParams, action: 'prepare_first_factor', coalesce: true }); // A challenged prepare builds no verification, so any redirect left on the sign-in is from an // earlier attempt and may be for another connection. Only this call's create hand-off is safe @@ -1366,8 +1377,16 @@ class SignInFuture implements SignInFutureResource { } async sso(params: SignInFutureSSOParams): Promise<{ error: ClerkError | null }> { - const { strategy, redirectUrl, redirectCallbackUrl, popup, oidcPrompt, enterpriseConnectionId, identifier } = - params; + const { + strategy, + redirectUrl, + redirectCallbackUrl, + popup, + oidcPrompt, + slackTeamIdHint, + enterpriseConnectionId, + identifier, + } = params; return runAsyncResourceTask(this.#resource, async () => { let actionCompleteRedirectUrl = redirectUrl; try { @@ -1388,13 +1407,12 @@ class SignInFuture implements SignInFutureResource { // Reuse the existing sign-in by default so any state already attached to it carries // into the SSO attempt (e.g. a ticket from `signIn.create({ strategy: 'ticket' })`, - // or a discovered identifier). OAuth is the exception: its redirect URL only comes - // back from `_create` and cannot be refreshed in place, so any redirect already - // lingering on the resource is stale. Reusing it would replay a previous attempt's - // redirect, including a retry of the same provider after an abandoned redirect - // (SDK-75), so whenever an OAuth call finds a pending redirect we start fresh. - // `enterprise_sso` is always safe to reuse because the `prepare_first_factor` call - // below refreshes its redirect against the existing sign-in. + // or a discovered identifier). For OAuth, a pending redirect may belong to an + // abandoned attempt, including a retry of the same provider (SDK-75), so start + // fresh rather than replay it. `enterprise_sso` can reuse the sign-in because + // `prepare_first_factor` below refreshes its redirect. When reusing a sign-in + // without a pending redirect, an explicit `slackTeamIdHint` also triggers preparation + // so the hint is applied to the existing attempt. const hasPendingRedirect = !!this.#resource.firstFactorVerification.externalVerificationRedirectURL; const wouldReplayStaleRedirect = strategy !== 'enterprise_sso' && hasPendingRedirect; const shouldCreateSignIn = !this.#resource.id || wouldReplayStaleRedirect; @@ -1415,6 +1433,7 @@ class SignInFuture implements SignInFutureResource { ...routes, identifier, oidcPrompt, + slackTeamIdHint, }, { resolveProtectCheck: false }, ); @@ -1427,15 +1446,16 @@ class SignInFuture implements SignInFutureResource { } } - if (strategy === 'enterprise_sso') { + if (strategy === 'enterprise_sso' || (!shouldCreateSignIn && slackTeamIdHint !== undefined)) { const prepare = (options?: { resolveProtectCheck?: boolean }) => this.#resource.__internal_basePost( { body: { ...routes, oidcPrompt, + slackTeamIdHint, enterpriseConnectionId, - strategy: 'enterprise_sso', + strategy, }, action: 'prepare_first_factor', coalesce: true, diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index 1babb4b914c..b335d7cfe14 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -452,6 +452,7 @@ export class SignUp extends BaseResource implements SignUpResource { emailAddress, legalAccepted, oidcPrompt, + slackTeamIdHint, enterpriseConnectionId, } = params; @@ -466,6 +467,7 @@ export class SignUp extends BaseResource implements SignUpResource { emailAddress, legalAccepted, oidcPrompt, + slackTeamIdHint, enterpriseConnectionId, }; return continueSignUp && this.id ? this.update(authParams) : this.create(authParams); @@ -1099,6 +1101,7 @@ class SignUpFuture implements SignUpFutureResource { unsafeMetadata, legalAccepted, oidcPrompt, + slackTeamIdHint, enterpriseConnectionId, emailAddress, popup, @@ -1134,6 +1137,7 @@ class SignUpFuture implements SignUpFutureResource { unsafeMetadata, legalAccepted, oidcPrompt, + slackTeamIdHint, enterpriseConnectionId, emailAddress, captchaToken, diff --git a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts index 2019cf9e5f3..53a50f0baa0 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts @@ -3,12 +3,16 @@ import { camelToSnake } from '@clerk/shared/underscore'; import { createDeferredPromise } from '@clerk/shared/utils'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Clerk } from '../../clerk'; import { eventBus } from '../../events'; +import { createFapiClient } from '../../fapiClient'; import { ProtectCheckGate } from '../../protectCheckGate'; import { signInErrorSignal, signInResourceSignal } from '../../signals'; -import { BaseResource } from '../internal'; +import { BaseResource, Client, Environment } from '../internal'; import { SignIn } from '../SignIn'; +const resourceFetch = BaseResource._fetch; + // Mock the authenticateWithPopup module vi.mock('../../../utils/authenticateWithPopup', async () => { const actual = await vi.importActual('../../../utils/authenticateWithPopup'); @@ -4333,3 +4337,458 @@ describe('SignIn protect_check gate', () => { }); }); }); + +describe('SignIn Slack workspace hint requests', () => { + const fetchMock = vi.fn(); + const response = { + ...new SignIn().__internal_toSnapshot(), + id: 'signin_slack', + status: 'needs_first_factor' as const, + first_factor_verification: { + ...new SignIn().__internal_toSnapshot().first_factor_verification, + status: 'unverified' as const, + external_verification_redirect_url: 'https://slack.com/openid/connect/authorize', + }, + }; + const jsonResponse = (payload: unknown, status = 200) => ({ + ok: status === 200, + status, + headers: new Headers(), + json: () => Promise.resolve(payload), + }); + const workspaceResponse = (team: string) => ({ + ...response, + first_factor_verification: { + ...response.first_factor_verification, + status: 'unverified' as const, + external_verification_redirect_url: `https://slack.com/openid/connect/authorize?team=${team}`, + }, + }); + const challengedResponse = { + ...workspaceResponse('TOLD'), + status: 'needs_protect_check', + protect_check: { status: 'pending', token: 'challenge', sdk_url: 'https://protect.example/sdk.js' }, + }; + let previousFetch: typeof BaseResource._fetch; + let previousBaseClerk: typeof BaseResource.clerk; + let previousClerk: typeof SignIn.clerk; + const fields = (index = 0) => new URLSearchParams(fetchMock.mock.calls[index][1].body); + + beforeEach(() => { + previousFetch = BaseResource._fetch; + previousBaseClerk = BaseResource.clerk; + previousClerk = SignIn.clerk; + BaseResource._fetch = resourceFetch; + fetchMock.mockReset().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ client: null, response }), + }); + vi.stubGlobal('fetch', fetchMock); + const fapiClient = createFapiClient({ + frontendApi: 'clerk.example.com', + instanceType: 'production', + getSessionId: () => undefined, + }); + const clerk = new Clerk('pk_live_Y2xlcmsuZXhhbXBsZS5jb20k'); + clerk.getFapiClient = () => fapiClient; + clerk.__internal_setCountry = vi.fn(); + clerk.buildUrlWithAuth = url => url; + clerk.__internal_windowNavigate = vi.fn(); + clerk.client = Client.getOrCreateInstance(); + const environment = new Environment(); + vi.spyOn(clerk, '__internal_environment', 'get').mockReturnValue(environment); + BaseResource.clerk = SignIn.clerk = clerk; + vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined); + }); + + afterEach(() => { + BaseResource._fetch = previousFetch; + BaseResource.clerk = previousBaseClerk; + SignIn.clerk = previousClerk; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each(['T123ABC', '', undefined])('serializes a low-level create hint of %s', async slackTeamIdHint => { + await new SignIn().create({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/callback', + slackTeamIdHint, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fields().get('slack_team_id_hint')).toBe(slackTeamIdHint ?? null); + expect(fields().has('slackTeamIdHint')).toBe(false); + }); + + it('serializes a unified low-level create hint', async () => { + const { error } = await new SignIn().__internal_future.create({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/callback', + slackTeamIdHint: 'T123ABC', + }); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fields().get('slack_team_id_hint')).toBe('T123ABC'); + }); + + it.each(['redirect', 'popup'] as const)('forwards a hint through legacy %s authentication', async mode => { + const signIn = new SignIn(); + const params = { + strategy: 'oauth_slack' as const, + redirectUrl: 'https://example.com/callback', + redirectUrlComplete: 'https://example.com/dashboard', + slackTeamIdHint: 'T123ABC', + }; + if (mode === 'popup') { + await signIn.authenticateWithPopup({ ...params, popup: { location: { href: '' } } as Window }); + } else { + await signIn.authenticateWithRedirect(params); + } + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fields().get('slack_team_id_hint')).toBe('T123ABC'); + }); + + it.each(['redirect', 'popup'] as const)('refreshes the workspace on legacy %s continuation', async mode => { + const signIn = new SignIn(); + const popup = { location: { href: '' } } as Window; + const params = { + strategy: 'oauth_slack' as const, + redirectUrl: 'https://example.com/callback', + redirectUrlComplete: 'https://example.com/dashboard', + }; + const authenticate = (slackTeamIdHint: string, continueSignIn = false) => + mode === 'popup' + ? signIn.authenticateWithPopup({ ...params, slackTeamIdHint, continueSignIn, popup }) + : signIn.authenticateWithRedirect({ ...params, slackTeamIdHint, continueSignIn }); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ response: workspaceResponse('T123ABC') })) + .mockResolvedValueOnce(jsonResponse({ response: workspaceResponse('T456DEF') })); + await authenticate('T123ABC'); + await authenticate('T456DEF', true); + + expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual([ + '/v1/client/sign_ins', + '/v1/client/sign_ins/signin_slack/prepare_first_factor', + ]); + expect([fields(0).get('slack_team_id_hint'), fields(1).get('slack_team_id_hint')]).toEqual(['T123ABC', 'T456DEF']); + if (mode === 'popup') { + expect(popup.location.href).toBe('https://slack.com/openid/connect/authorize?team=T456DEF'); + } else { + expect(SignIn.clerk.__internal_windowNavigate).toHaveBeenLastCalledWith( + new URL('https://slack.com/openid/connect/authorize?team=T456DEF'), + ); + } + }); + + it.each([ + { strategy: 'oauth_slack' as const, slackTeamIdHint: '' }, + { strategy: 'oauth_google' as const, slackTeamIdHint: 'T123ABC' }, + ])('forwards a rejected legacy continuation hint for $strategy with "$slackTeamIdHint"', async params => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ code: 'form_param_value_invalid' }] }, 422)); + const signIn = new SignIn(workspaceResponse('TOLD')); + + await expect( + signIn.authenticateWithRedirect({ + ...params, + continueSignIn: true, + redirectUrl: 'https://example.com/callback', + redirectUrlComplete: 'https://example.com/dashboard', + }), + ).rejects.toMatchObject({ errors: [{ code: 'form_param_value_invalid' }] }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].pathname).toBe('/v1/client/sign_ins/signin_slack/prepare_first_factor'); + expect(fields().get('strategy')).toBe(params.strategy); + expect(fields().get('slack_team_id_hint')).toBe(params.slackTeamIdHint); + expect(SignIn.clerk.__internal_windowNavigate).not.toHaveBeenCalled(); + }); + + it('reuses the legacy redirect without a request when continuation omits the hint', async () => { + const signIn = new SignIn(workspaceResponse('T123ABC')); + await signIn.authenticateWithRedirect({ + strategy: 'oauth_slack', + continueSignIn: true, + redirectUrl: 'https://example.com/callback', + redirectUrlComplete: 'https://example.com/dashboard', + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(SignIn.clerk.__internal_windowNavigate).toHaveBeenCalledWith( + new URL('https://slack.com/openid/connect/authorize?team=T123ABC'), + ); + }); + + it('continues the same legacy attempt after Protect without following its stale redirect', async () => { + const signIn = new SignIn(workspaceResponse('TOLD')); + fetchMock + .mockResolvedValueOnce(jsonResponse({ response: challengedResponse })) + .mockResolvedValueOnce(jsonResponse({ response: { id: 'signin_slack', status: 'needs_first_factor' } })) + .mockResolvedValueOnce(jsonResponse({ response: workspaceResponse('T456DEF') })); + const params = { + strategy: 'oauth_slack' as const, + continueSignIn: true, + redirectUrl: 'https://example.com/callback', + redirectUrlComplete: 'https://example.com/dashboard', + slackTeamIdHint: 'T456DEF', + }; + + await expect(signIn.authenticateWithRedirect(params)).rejects.toMatchObject({ code: 'protect_check_required' }); + expect(SignIn.clerk.__internal_windowNavigate).not.toHaveBeenCalled(); + await signIn.submitProtectCheck({ proofToken: 'proof' }); + await signIn.authenticateWithRedirect(params); + + expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual([ + '/v1/client/sign_ins/signin_slack/prepare_first_factor', + '/v1/client/sign_ins/signin_slack/protect_check', + '/v1/client/sign_ins/signin_slack/prepare_first_factor', + ]); + expect(fields(0).get('slack_team_id_hint')).toBe('T456DEF'); + expect(fields(2).get('slack_team_id_hint')).toBe('T456DEF'); + expect(SignIn.clerk.__internal_windowNavigate).toHaveBeenCalledExactlyOnceWith( + new URL('https://slack.com/openid/connect/authorize?team=T456DEF'), + ); + }); + + it.each(['redirect', 'popup'] as const)('prepares a workspace on an existing unified %s attempt', async mode => { + const signIn = new SignIn({ + ...new SignIn().__internal_toSnapshot(), + id: 'signin_slack', + status: 'needs_first_factor', + }); + const popup = mode === 'popup' ? ({ location: { href: '' } } as Window) : undefined; + vi.mocked(_futureAuthenticateWithPopup).mockImplementation((_clerk, params) => { + params.popup.location.href = params.externalVerificationRedirectURL.toString(); + return Promise.resolve(); + }); + fetchMock.mockResolvedValueOnce(jsonResponse({ response: workspaceResponse('T456DEF') })); + const { error } = await signIn.__internal_future.sso({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint: 'T456DEF', + popup, + }); + + expect(error).toBeNull(); + expect(fetchMock.mock.calls[0][0].pathname).toBe('/v1/client/sign_ins/signin_slack/prepare_first_factor'); + expect(fields().get('slack_team_id_hint')).toBe('T456DEF'); + if (popup) { + expect(popup.location.href).toBe('https://slack.com/openid/connect/authorize?team=T456DEF'); + } else { + expect(SignIn.clerk.__internal_windowNavigate).toHaveBeenCalledWith( + new URL('https://slack.com/openid/connect/authorize?team=T456DEF'), + ); + } + }); + + it.each([ + { strategy: 'oauth_slack' as const, slackTeamIdHint: '' }, + { strategy: 'oauth_google' as const, slackTeamIdHint: 'T123ABC' }, + ])('forwards a rejected existing unified hint for $strategy with "$slackTeamIdHint"', async params => { + fetchMock.mockResolvedValueOnce(jsonResponse({ errors: [{ code: 'form_param_value_invalid' }] }, 422)); + const signIn = new SignIn({ + ...new SignIn().__internal_toSnapshot(), + id: 'signin_slack', + status: 'needs_first_factor', + }); + const { error } = await signIn.__internal_future.sso({ + ...params, + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + }); + + expect(error).toMatchObject({ errors: [{ code: 'form_param_value_invalid' }] }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].pathname).toBe('/v1/client/sign_ins/signin_slack/prepare_first_factor'); + expect(fields().get('strategy')).toBe(params.strategy); + expect(fields().get('slack_team_id_hint')).toBe(params.slackTeamIdHint); + expect(SignIn.clerk.__internal_windowNavigate).not.toHaveBeenCalled(); + }); + + it('keeps existing unified reuse without a request when the hint is omitted', async () => { + const signIn = new SignIn({ + ...new SignIn().__internal_toSnapshot(), + id: 'signin_slack', + status: 'needs_first_factor', + }); + const { error } = await signIn.__internal_future.sso({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + }); + + expect(error).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(SignIn.clerk.__internal_windowNavigate).not.toHaveBeenCalled(); + }); + + it('retries unified workspace preparation on the same attempt after Protect', async () => { + const signIn = new SignIn({ + ...new SignIn().__internal_toSnapshot(), + id: 'signin_slack', + status: 'needs_first_factor', + }); + fetchMock + .mockResolvedValueOnce(jsonResponse({ response: challengedResponse })) + .mockResolvedValueOnce(jsonResponse({ response: { id: 'signin_slack', status: 'needs_first_factor' } })) + .mockResolvedValueOnce(jsonResponse({ response: workspaceResponse('T456DEF') })); + vi.mocked(ProtectCheckGate.prototype.resolve).mockImplementation(async () => { + expect(SignIn.clerk.__internal_windowNavigate).not.toHaveBeenCalled(); + if (signIn.protectCheck) { + await signIn.submitProtectCheck({ proofToken: 'proof' }); + } + }); + const { error } = await signIn.__internal_future.sso({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint: 'T456DEF', + oidcPrompt: 'consent', + }); + + expect(error).toBeNull(); + expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual([ + '/v1/client/sign_ins/signin_slack/prepare_first_factor', + '/v1/client/sign_ins/signin_slack/protect_check', + '/v1/client/sign_ins/signin_slack/prepare_first_factor', + ]); + expect(fields(0).get('slack_team_id_hint')).toBe('T456DEF'); + expect(fields(2)).toEqual(fields(0)); + expect(SignIn.clerk.__internal_windowNavigate).toHaveBeenCalledExactlyOnceWith( + new URL('https://slack.com/openid/connect/authorize?team=T456DEF'), + ); + }); + + it('does not follow a stale unified redirect when Protect resolution fails', async () => { + const signIn = new SignIn({ + ...new SignIn().__internal_toSnapshot(), + id: 'signin_slack', + status: 'needs_first_factor', + }); + fetchMock.mockResolvedValueOnce(jsonResponse({ response: challengedResponse })); + vi.mocked(ProtectCheckGate.prototype.resolve).mockRejectedValueOnce(new Error('challenge failed')); + const { error } = await signIn.__internal_future.sso({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint: 'T456DEF', + }); + + expect(error).toMatchObject({ message: 'challenge failed' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(SignIn.clerk.__internal_windowNavigate).not.toHaveBeenCalled(); + }); + + it('forwards incompatible legacy enterprise hints to first-factor preparation', async () => { + const signIn = new SignIn(); + signIn.id = 'signin_existing'; + await signIn.authenticateWithRedirect({ + strategy: 'enterprise_sso', + continueSignIn: true, + redirectUrl: 'https://example.com/callback', + redirectUrlComplete: 'https://example.com/dashboard', + slackTeamIdHint: 'T123ABC', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0].pathname).toBe('/v1/client/sign_ins/signin_existing/prepare_first_factor'); + expect(fields().get('strategy')).toBe('enterprise_sso'); + expect(fields().get('slack_team_id_hint')).toBe('T123ABC'); + }); + + it('preserves unsupported legacy social first-factor preparation', () => { + const signIn = new SignIn(); + signIn.id = 'signin_existing'; + expect(() => + signIn.prepareFirstFactor({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/callback', + actionCompleteRedirectUrl: 'https://example.com/dashboard', + }), + ).toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('creates fresh unified attempts for changed workspaces and then omits an absent hint', async () => { + const signIn = new SignIn(); + for (const slackTeamIdHint of ['T123ABC', 'T456DEF', undefined]) { + const { error } = await signIn.__internal_future.sso({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint, + }); + expect(error).toBeNull(); + } + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual(Array(3).fill('/v1/client/sign_ins')); + expect([0, 1, 2].map(index => fields(index).get('slack_team_id_hint'))).toEqual(['T123ABC', 'T456DEF', null]); + }); + + it('forwards a unified popup hint to the create request', async () => { + const popup = { location: { href: '' } } as Window; + vi.mocked(_futureAuthenticateWithPopup).mockResolvedValue(undefined); + const { error } = await new SignIn().__internal_future.sso({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint: 'T123ABC', + popup, + }); + expect(error).toBeNull(); + expect(fields().get('slack_team_id_hint')).toBe('T123ABC'); + expect(_futureAuthenticateWithPopup).toHaveBeenCalledWith(SignIn.clerk, expect.objectContaining({ popup })); + }); + + it.each(['oauth_google', 'enterprise_sso'] as const)('forwards incompatible unified %s hints', async strategy => { + const { error } = await new SignIn().__internal_future.sso({ + strategy, + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint: 'T123ABC', + }); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(strategy === 'enterprise_sso' ? 2 : 1); + for (let index = 0; index < fetchMock.mock.calls.length; index++) { + expect(fields(index).get('strategy')).toBe(strategy); + expect(fields(index).get('slack_team_id_hint')).toBe('T123ABC'); + } + }); + + it('preserves the hint when unified enterprise preparation retries after a challenge', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers(), + json: () => + Promise.resolve({ + response: { + id: 'signin_existing', + status: 'needs_protect_check', + protect_check: { status: 'pending', token: 'challenge', sdk_url: 'https://protect.example/sdk.js' }, + }, + }), + }); + const signIn = new SignIn(); + signIn.id = 'signin_existing'; + vi.mocked(ProtectCheckGate.prototype.resolve).mockImplementation(() => { + signIn.protectCheck = null; + return Promise.resolve(); + }); + const { error } = await signIn.__internal_future.sso({ + strategy: 'enterprise_sso', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint: 'T123ABC', + }); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fields(0).get('slack_team_id_hint')).toBe('T123ABC'); + expect(fields(1).get('slack_team_id_hint')).toBe('T123ABC'); + expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual( + Array(2).fill('/v1/client/sign_ins/signin_existing/prepare_first_factor'), + ); + }); +}); diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index b5fdc758c6b..67668057776 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -1,12 +1,16 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Clerk } from '../../clerk'; import { eventBus } from '../../events'; +import { createFapiClient } from '../../fapiClient'; import { ProtectCheckGate } from '../../protectCheckGate'; import { signUpErrorSignal, signUpResourceSignal } from '../../signals'; -import { BaseResource } from '../internal'; +import { BaseResource, Client, Environment } from '../internal'; import { SignUp } from '../SignUp'; +const resourceFetch = BaseResource._fetch; + // Mock the authenticateWithPopup module vi.mock('../../../utils/authenticateWithPopup', async () => { const actual = await vi.importActual('../../../utils/authenticateWithPopup'); @@ -2478,3 +2482,186 @@ describe('SignUp protect_check gate', () => { }); }); }); + +describe('SignUp Slack workspace hint requests', () => { + const fetchMock = vi.fn(); + const response = { + id: 'signup_slack', + status: 'missing_requirements', + verifications: { + external_account: { + status: 'unverified', + external_verification_redirect_url: 'https://slack.com/openid/connect/authorize', + }, + }, + }; + let previousFetch: typeof BaseResource._fetch; + let previousBaseClerk: typeof BaseResource.clerk; + let previousClerk: typeof SignUp.clerk; + const fields = (index = 0) => new URLSearchParams(fetchMock.mock.calls[index][1].body); + + beforeEach(() => { + previousFetch = BaseResource._fetch; + previousBaseClerk = BaseResource.clerk; + previousClerk = SignUp.clerk; + BaseResource._fetch = resourceFetch; + fetchMock.mockReset().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: () => Promise.resolve({ client: null, response }), + }); + vi.stubGlobal('fetch', fetchMock); + const fapiClient = createFapiClient({ + frontendApi: 'clerk.example.com', + instanceType: 'production', + getSessionId: () => undefined, + }); + const clerk = new Clerk('pk_live_Y2xlcmsuZXhhbXBsZS5jb20k'); + clerk.getFapiClient = () => fapiClient; + clerk.__internal_setCountry = vi.fn(); + clerk.buildUrlWithAuth = url => url; + clerk.__internal_windowNavigate = vi.fn(); + clerk.client = Client.getOrCreateInstance(); + const environment = new Environment(); + vi.spyOn(clerk, '__internal_environment', 'get').mockReturnValue(environment); + vi.spyOn(environment, 'reload').mockResolvedValue(environment); + BaseResource.clerk = SignUp.clerk = clerk; + vi.spyOn(ProtectCheckGate.prototype, 'resolve').mockResolvedValue(undefined); + }); + + afterEach(() => { + BaseResource._fetch = previousFetch; + BaseResource.clerk = previousBaseClerk; + SignUp.clerk = previousClerk; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each(['create', 'update', 'prepareVerification'] as const)( + 'serializes per-attempt hints through low-level %s', + async method => { + const signUp = new SignUp(); + if (method !== 'create') { + signUp.id = 'signup_slack'; + } + for (const slackTeamIdHint of ['T123ABC', 'T456DEF', '', undefined]) { + await signUp[method]({ strategy: 'oauth_slack', redirectUrl: 'https://example.com/callback', slackTeamIdHint }); + } + expect(fetchMock).toHaveBeenCalledTimes(4); + expect([0, 1, 2, 3].map(index => fields(index).get('slack_team_id_hint'))).toEqual([ + 'T123ABC', + 'T456DEF', + '', + null, + ]); + expect(fetchMock.mock.calls.every((_, index) => !fields(index).has('slackTeamIdHint'))).toBe(true); + const expectedPath = + method === 'create' + ? '/v1/client/sign_ups' + : method === 'update' + ? '/v1/client/sign_ups/signup_slack' + : '/v1/client/sign_ups/signup_slack/prepare_verification'; + expect(fetchMock.mock.calls.every(([url]) => url.pathname === expectedPath)).toBe(true); + }, + ); + + it.each([ + { popup: false, continueSignUp: false }, + { popup: true, continueSignUp: false }, + { popup: false, continueSignUp: true }, + { popup: true, continueSignUp: true }, + ])('forwards a legacy authentication hint with %j', async ({ popup, continueSignUp }) => { + const signUp = new SignUp(); + if (continueSignUp) { + signUp.id = 'signup_existing'; + } + const params = { + strategy: 'oauth_slack' as const, + redirectUrl: 'https://example.com/callback', + redirectUrlComplete: 'https://example.com/dashboard', + slackTeamIdHint: 'T123ABC', + continueSignUp, + }; + if (popup) { + await signUp.authenticateWithPopup({ ...params, popup: { location: { href: '' } } as Window }); + } else { + await signUp.authenticateWithRedirect(params); + } + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fields().get('slack_team_id_hint')).toBe('T123ABC'); + expect(fetchMock.mock.calls[0][0].pathname).toBe( + continueSignUp ? '/v1/client/sign_ups/signup_existing' : '/v1/client/sign_ups', + ); + expect(fetchMock.mock.calls[0][0].searchParams.get('_method')).toBe(continueSignUp ? 'PATCH' : null); + }); + + it('forwards unified create and update hints without retaining the previous workspace', async () => { + const signUp = new SignUp(); + for (const slackTeamIdHint of ['T123ABC', 'T456DEF', undefined]) { + const { error } = await signUp.__internal_future.sso({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint, + }); + expect(error).toBeNull(); + } + expect(fetchMock).toHaveBeenCalledTimes(3); + expect([0, 1, 2].map(index => fields(index).get('slack_team_id_hint'))).toEqual(['T123ABC', 'T456DEF', null]); + expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual([ + '/v1/client/sign_ups', + '/v1/client/sign_ups/signup_slack', + '/v1/client/sign_ups/signup_slack', + ]); + expect(fetchMock.mock.calls.map(([url]) => url.searchParams.get('_method'))).toEqual([null, 'PATCH', 'PATCH']); + }); + + it('forwards a unified popup hint to the create request', async () => { + const popup = { location: { href: '' } } as Window; + vi.mocked(_futureAuthenticateWithPopup).mockResolvedValue(undefined); + const { error } = await new SignUp().__internal_future.sso({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint: 'T123ABC', + popup, + }); + expect(error).toBeNull(); + expect(fields().get('slack_team_id_hint')).toBe('T123ABC'); + expect(_futureAuthenticateWithPopup).toHaveBeenCalledWith(SignUp.clerk, expect.objectContaining({ popup })); + }); + + it.each(['oauth_google', 'enterprise_sso'])('forwards incompatible unified %s hints', async strategy => { + const { error } = await new SignUp().__internal_future.sso({ + strategy, + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint: 'T123ABC', + }); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fields().get('strategy')).toBe(strategy); + expect(fields().get('slack_team_id_hint')).toBe('T123ABC'); + }); + + it('preserves a unified hint when an environment change requires a retry', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers(), + json: () => Promise.resolve({ errors: [{ code: 'captcha_invalid', message: 'Invalid captcha' }] }), + }); + const { error } = await new SignUp().__internal_future.sso({ + strategy: 'oauth_slack', + redirectUrl: 'https://example.com/dashboard', + redirectCallbackUrl: 'https://example.com/callback', + slackTeamIdHint: 'T123ABC', + }); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fields(0).get('slack_team_id_hint')).toBe('T123ABC'); + expect(fields(1).get('slack_team_id_hint')).toBe('T123ABC'); + expect(SignUp.clerk.__internal_environment?.reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/types/redirects.ts b/packages/shared/src/types/redirects.ts index 2fb281c9b90..4b52a8e95a9 100644 --- a/packages/shared/src/types/redirects.ts +++ b/packages/shared/src/types/redirects.ts @@ -76,6 +76,10 @@ export type AuthenticateWithRedirectParams = { * Optional for `oauth_` or `enterprise_sso` strategies. The value to pass to the [OIDC prompt parameter](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=prompt,reauthentication%20and%20consent.) in the generated OAuth redirect URL. */ oidcPrompt?: string; + /** + * Optional per-attempt Slack workspace hint. Only supported with the `oauth_slack` strategy. + */ + slackTeamIdHint?: string; /** * Internal OAuth callback context, consumed only when an OAuth transport completes the diff --git a/packages/shared/src/types/signInCommon.ts b/packages/shared/src/types/signInCommon.ts index 46c8f8133ab..8f0d39e194c 100644 --- a/packages/shared/src/types/signInCommon.ts +++ b/packages/shared/src/types/signInCommon.ts @@ -145,6 +145,10 @@ export type SignInCreateParams = ( identifier?: string; oidcPrompt?: string; oidcLoginHint?: string; + /** + * Optional per-attempt Slack workspace hint. Only supported with the `oauth_slack` strategy. + */ + slackTeamIdHint?: string; } | { strategy: TicketStrategy; diff --git a/packages/shared/src/types/signInFuture.ts b/packages/shared/src/types/signInFuture.ts index 65c2d6354d5..079e414e242 100644 --- a/packages/shared/src/types/signInFuture.ts +++ b/packages/shared/src/types/signInFuture.ts @@ -35,6 +35,10 @@ export interface SignInFutureCreateParams { * The value to pass to the [OIDC `login_hint` parameter](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=login_hint,in%20\(if%20necessary\).) in the generated OAuth redirect URL. */ oidcLoginHint?: string; + /** + * Optional per-attempt Slack workspace hint. Only supported with the `oauth_slack` strategy. + */ + slackTeamIdHint?: string; /** * The URL that the user will be redirected to, after successful authorization from the OAuth provider and Clerk sign-in. */ @@ -243,6 +247,10 @@ export interface SignInFutureSSOParams { * The value to pass to the [OIDC prompt parameter](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=prompt,reauthentication%20and%20consent.) in the generated OAuth redirect URL. */ oidcPrompt?: string; + /** + * Optional per-attempt Slack workspace hint. Only supported with the `oauth_slack` strategy. + */ + slackTeamIdHint?: string; /** * The identifier of the enterprise connection to target when using the `enterprise_sso` strategy. * @experimental diff --git a/packages/shared/src/types/signUpCommon.ts b/packages/shared/src/types/signUpCommon.ts index da860fe5533..ebdd07a98a1 100644 --- a/packages/shared/src/types/signUpCommon.ts +++ b/packages/shared/src/types/signUpCommon.ts @@ -79,6 +79,10 @@ export type PrepareVerificationParams = actionCompleteRedirectUrl?: string; oidcPrompt?: string; oidcLoginHint?: string; + /** + * Optional per-attempt Slack workspace hint. Only supported with the `oauth_slack` strategy. + */ + slackTeamIdHint?: string; } | { strategy: EnterpriseSSOStrategy; @@ -134,6 +138,10 @@ export type SignUpCreateParams = Partial< legalAccepted: boolean; oidcPrompt: string; oidcLoginHint: string; + /** + * Optional per-attempt Slack workspace hint. Only supported with the `oauth_slack` strategy. + */ + slackTeamIdHint?: string; channel: PhoneCodeChannel; locale?: string; /** diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index 773a94043c2..479044078c4 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -65,6 +65,10 @@ export interface SignUpFutureCreateParams extends SignUpFutureAdditionalParams { | GoogleOneTapStrategy | AppleIdTokenStrategy | PhoneCodeStrategy; + /** + * Optional per-attempt Slack workspace hint. Only supported with the `oauth_slack` strategy. + */ + slackTeamIdHint?: string; /** * The user's email address. Only supported if [Email address](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options#email) is enabled in the instance settings. Keep in mind that the email address requires an extra verification process. */ @@ -248,6 +252,10 @@ export interface SignUpFutureSSOParams extends SignUpFutureAdditionalParams { * The value to pass to the [OIDC prompt parameter](https://openid.net/specs/openid-connect-core-1_0.html#:~:text=prompt,reauthentication%20and%20consent.) in the generated OAuth redirect URL. */ oidcPrompt?: string; + /** + * Optional per-attempt Slack workspace hint. Only supported with the `oauth_slack` strategy. + */ + slackTeamIdHint?: string; /** * The identifier of the enterprise connection to target when using the `enterprise_sso` strategy. * @experimental