diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 06bf5998c..1c2e32a3e 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -83,7 +83,7 @@ export default function interactionRoutes( if (identifier && event !== InteractionEvent.ForgotPassword) { verifyIdentifierSettings(identifier, signInExperience); - await verifySsoOnlyEmailIdentifier(libraries.ssoConnectors, identifier); + await verifySsoOnlyEmailIdentifier(libraries.ssoConnectors, identifier, signInExperience); } if (profile && event !== InteractionEvent.ForgotPassword) { @@ -183,7 +183,11 @@ export default function interactionRoutes( if (interactionStorage.event !== InteractionEvent.ForgotPassword) { verifyIdentifierSettings(identifierPayload, signInExperience); - await verifySsoOnlyEmailIdentifier(libraries.ssoConnectors, identifierPayload); + await verifySsoOnlyEmailIdentifier( + libraries.ssoConnectors, + identifierPayload, + signInExperience + ); } const verifiedIdentifier = await verifyIdentifierPayload( diff --git a/packages/core/src/routes/interaction/utils/single-sign-on-guard.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on-guard.test.ts index 9abd4eb2f..4b4739fef 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on-guard.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on-guard.test.ts @@ -1,3 +1,4 @@ +import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; import { wellConfiguredSsoConnector } from '#src/__mocks__/sso.js'; import RequestError from '#src/errors/RequestError/index.js'; import { type SsoConnectorLibrary } from '#src/libraries/sso-connector.js'; @@ -15,14 +16,41 @@ const mockSsoConnectorLibrary: SsoConnectorLibrary = { }; describe('verifyEmailIdentifier tests', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return if the identifier is not an email', async () => { await expect( - verifySsoOnlyEmailIdentifier(mockSsoConnectorLibrary, { - username: 'foo', - password: 'bar', - }) + verifySsoOnlyEmailIdentifier( + mockSsoConnectorLibrary, + { + username: 'foo', + password: 'bar', + }, + mockSignInExperience + ) ).resolves.not.toThrow(); }); + + it('should return if the ssoConnector is not enabled', async () => { + await expect( + verifySsoOnlyEmailIdentifier( + mockSsoConnectorLibrary, + { + email: 'foo@bar.com', + password: 'bar', + }, + { + ...mockSignInExperience, + singleSignOnEnabled: false, + } + ) + ).resolves.not.toThrow(); + + expect(getAvailableSsoConnectorsMock).not.toBeCalled(); + }); + it('should return if no sso connectors found with the given email', async () => { getAvailableSsoConnectorsMock.mockResolvedValueOnce([ { @@ -32,10 +60,14 @@ describe('verifyEmailIdentifier tests', () => { ]); await expect( - verifySsoOnlyEmailIdentifier(mockSsoConnectorLibrary, { - email: 'foo@bar.com', - password: 'bar', - }) + verifySsoOnlyEmailIdentifier( + mockSsoConnectorLibrary, + { + email: 'foo@bar.com', + password: 'bar', + }, + mockSignInExperience + ) ).resolves.not.toThrow(); }); @@ -48,10 +80,14 @@ describe('verifyEmailIdentifier tests', () => { getAvailableSsoConnectorsMock.mockResolvedValueOnce([connector]); await expect( - verifySsoOnlyEmailIdentifier(mockSsoConnectorLibrary, { - email: 'foo@example.com', - verificationCode: 'bar', - }) + verifySsoOnlyEmailIdentifier( + mockSsoConnectorLibrary, + { + email: 'foo@example.com', + verificationCode: 'bar', + }, + mockSignInExperience + ) ).rejects.toMatchError( new RequestError( { diff --git a/packages/core/src/routes/interaction/utils/single-sign-on-guard.ts b/packages/core/src/routes/interaction/utils/single-sign-on-guard.ts index 42a2e484d..734f488cb 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on-guard.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on-guard.ts @@ -1,5 +1,5 @@ import { type SocialUserInfo } from '@logto/connector-kit'; -import { type IdentifierPayload } from '@logto/schemas'; +import { type IdentifierPayload, type SignInExperience } from '@logto/schemas'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; @@ -9,7 +9,8 @@ import assertThat from '#src/utils/assert-that.js'; // Guard the SSO only email identifier export const verifySsoOnlyEmailIdentifier = async ( { getAvailableSsoConnectors }: SsoConnectorLibrary, - identifier: IdentifierPayload | SocialUserInfo + identifier: IdentifierPayload | SocialUserInfo, + signInExperience: SignInExperience ) => { // TODO: @simeng-li remove the dev features check when the SSO feature is released if (!EnvSet.values.isDevFeaturesEnabled) { @@ -20,6 +21,11 @@ export const verifySsoOnlyEmailIdentifier = async ( return; } + // SSO is not enabled + if (!signInExperience.singleSignOnEnabled) { + return; + } + const { email } = identifier; const availableSsoConnectors = await getAvailableSsoConnectors(); const domain = email.split('@')[1]; diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts index d0192ae9d..d79e82d6d 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts @@ -1,6 +1,10 @@ +import crypto from 'node:crypto'; + +import { PasswordPolicyChecker } from '@logto/core-kit'; import { InteractionEvent } from '@logto/schemas'; import { createMockUtils, pickDefault } from '@logto/shared/esm'; +import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; import RequestError from '#src/errors/RequestError/index.js'; import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { MockTenant } from '#src/test-utils/tenant.js'; @@ -44,7 +48,16 @@ const logContext = createMockLogContext(); const tenant = new MockTenant(); describe('identifier verification', () => { - const baseCtx = { ...createContextWithRouteParameters(), ...logContext }; + const baseCtx = { + ...createContextWithRouteParameters(), + ...logContext, + signInExperience: mockSignInExperience, + passwordPolicyChecker: new PasswordPolicyChecker( + mockSignInExperience.passwordPolicy, + crypto.subtle + ), + }; + const interactionStorage = { event: InteractionEvent.SignIn }; afterEach(() => { @@ -194,7 +207,11 @@ describe('identifier verification', () => { ).rejects.toMatchError(new RequestError('session.sso_enabled')); expect(verifySocialIdentity).toBeCalledWith(identifier, baseCtx, tenant); - expect(verifySsoOnlyEmailIdentifier).toBeCalledWith(tenant.libraries.ssoConnectors, useInfo); + expect(verifySsoOnlyEmailIdentifier).toBeCalledWith( + tenant.libraries.ssoConnectors, + useInfo, + mockSignInExperience + ); expect(findUserByIdentifier).not.toBeCalled(); }); diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts index df1699ae4..f607b2011 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts @@ -18,6 +18,7 @@ import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; import { i18next } from '#src/utils/i18n.js'; +import { type WithInteractionSieContext } from '../middleware/koa-interaction-sie.js'; import type { PasswordIdentifierPayload, SocialIdentifier, @@ -86,7 +87,7 @@ const verifyVerificationCodeIdentifier = async ( const verifySocialIdentifier = async ( identifier: SocialConnectorPayload, - ctx: WithLogContext, + ctx: WithInteractionSieContext, tenant: TenantContext ): Promise => { const userInfo = await verifySocialIdentity(identifier, ctx, tenant); @@ -94,7 +95,10 @@ const verifySocialIdentifier = async ( const { libraries: { ssoConnectors }, } = tenant; - await verifySsoOnlyEmailIdentifier(ssoConnectors, userInfo); + + const { signInExperience } = ctx; + + await verifySsoOnlyEmailIdentifier(ssoConnectors, userInfo, signInExperience); return { key: 'social', connectorId: identifier.connectorId, userInfo }; }; @@ -158,7 +162,7 @@ const verifySocialVerifiedIdentifier = async ( * or phone, then the payload is valid. */ async function identifierPayloadVerification( - ctx: WithLogContext, + ctx: WithInteractionSieContext, tenant: TenantContext, identifierPayload: IdentifierPayload, interactionStorage: AnonymousInteractionResult diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts index eb02bb44d..73d126a2b 100644 --- a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts @@ -13,7 +13,7 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; import type { IdentifierVerifiedInteractionResult } from '../types/index.js'; const { jest } = import.meta; -const { mockEsm, mockEsmWithActual } = createMockUtils(jest); +const { mockEsm } = createMockUtils(jest); const findUserById = jest.fn(); const hasUserWithEmail = jest.fn(); diff --git a/packages/integration-tests/src/tests/api/interaction/register-with-identifier/sad-path.test.ts b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/sad-path.test.ts index feb2e12ce..af8e4073a 100644 --- a/packages/integration-tests/src/tests/api/interaction/register-with-identifier/sad-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/sad-path.test.ts @@ -6,8 +6,6 @@ import { sendVerificationCode, } from '#src/api/interaction.js'; import { updateSignInExperience } from '#src/api/sign-in-experience.js'; -import { createSsoConnector } from '#src/api/sso-connector.js'; -import { newOidcSsoConnectorPayload } from '#src/constants.js'; import { initClient } from '#src/helpers/client.js'; import { clearConnectorsByTypes, @@ -16,17 +14,9 @@ import { setSmsConnector, } from '#src/helpers/connector.js'; import { expectRejects, readVerificationCode } from '#src/helpers/index.js'; -import { - enableAllPasswordSignInMethods, - enableAllVerificationCodeSignInMethods, -} from '#src/helpers/sign-in-experience.js'; +import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; import { generateNewUserProfile } from '#src/helpers/user.js'; -import { - generateEmail, - generatePassword, - generateSsoConnectorName, - generateUsername, -} from '#src/utils.js'; +import { generatePassword, generateUsername } from '#src/utils.js'; describe('Register with identifiers sad path', () => { beforeAll(async () => { @@ -58,40 +48,6 @@ describe('Register with identifiers sad path', () => { await updateSignInExperience({ signInMode: SignInMode.SignInAndRegister }); }); - it('Should fail to register with email if email domain is enabled for SSO only', async () => { - await enableAllVerificationCodeSignInMethods(); - const email = generateEmail('sso-register-sad-path.io'); - - await createSsoConnector({ - ...newOidcSsoConnectorPayload, - connectorName: generateSsoConnectorName(), - domains: ['sso-register-sad-path.io'], - }); - - const client = await initClient(); - - await client.successSend(putInteraction, { - event: InteractionEvent.Register, - }); - - await client.successSend(sendVerificationCode, { - email, - }); - - const { code: verificationCode } = await readVerificationCode(); - - await expectRejects( - client.send(patchInteractionIdentifiers, { - email, - verificationCode, - }), - { - code: 'session.sso_enabled', - statusCode: 422, - } - ); - }); - describe('Should fail to register with identifiers if sign-up settings are not enabled', () => { beforeAll(async () => { // This function call will disable all sign-up settings by default diff --git a/packages/integration-tests/src/tests/api/interaction/register-with-identifier/single-sign-on.test.ts b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/single-sign-on.test.ts new file mode 100644 index 000000000..e60a0c6c3 --- /dev/null +++ b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/single-sign-on.test.ts @@ -0,0 +1,140 @@ +import { ConnectorType, InteractionEvent, SignInIdentifier } from '@logto/schemas'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { + putInteraction, + sendVerificationCode, + patchInteractionIdentifiers, + putInteractionProfile, +} from '#src/api/interaction.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js'; +import { newOidcSsoConnectorPayload } from '#src/constants.js'; +import { initClient, processSession, logoutClient } from '#src/helpers/client.js'; +import { + clearConnectorsByTypes, + clearSsoConnectors, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { expectRejects, readVerificationCode } from '#src/helpers/index.js'; +import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { generateEmail, generateSsoConnectorName } from '#src/utils.js'; + +const happyPath = async (email: string) => { + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.Register, + }); + + await client.successSend(sendVerificationCode, { + email, + }); + + const verificationCodeRecord = await readVerificationCode(); + + expect(verificationCodeRecord).toMatchObject({ + address: email, + type: InteractionEvent.Register, + }); + + const { code } = verificationCodeRecord; + + await client.successSend(patchInteractionIdentifiers, { + email, + verificationCode: code, + }); + + await client.successSend(putInteractionProfile, { + email, + }); + + const { redirectTo } = await client.submitInteraction(); + + const id = await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(id); +}; + +describe('test register with email with SSO feature', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await setEmailConnector(); + await setSmsConnector(); + await enableAllVerificationCodeSignInMethods({ + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }); + }); + + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email]); + await clearSsoConnectors(); + }); + + it('should register with email with SSO disabled', async () => { + await updateSignInExperience({ singleSignOnEnabled: false }); + const email = generateEmail('sso-register-happy-path.io'); + + const { id } = await createSsoConnector({ + ...newOidcSsoConnectorPayload, + connectorName: generateSsoConnectorName(), + domains: ['sso-register-happy-path.io'], + }); + + await happyPath(email); + + await deleteSsoConnectorById(id); + }); + + it('should register with email with SSO enabled but no connector found', async () => { + await updateSignInExperience({ singleSignOnEnabled: true }); + const email = generateEmail('sso-register-happy-path.io'); + + const { id } = await createSsoConnector({ + ...newOidcSsoConnectorPayload, + connectorName: generateSsoConnectorName(), + domains: ['happy.io'], + }); + + await happyPath(email); + + await deleteSsoConnectorById(id); + }); + + it('Should fail to register with email if email domain is enabled for SSO only', async () => { + await updateSignInExperience({ singleSignOnEnabled: true }); + const email = generateEmail('sso-register-sad-path.io'); + + await createSsoConnector({ + ...newOidcSsoConnectorPayload, + connectorName: generateSsoConnectorName(), + domains: ['sso-register-sad-path.io'], + }); + + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.Register, + }); + + await client.successSend(sendVerificationCode, { + email, + }); + + const { code: verificationCode } = await readVerificationCode(); + + await expectRejects( + client.send(patchInteractionIdentifiers, { + email, + verificationCode, + }), + { + code: 'session.sso_enabled', + statusCode: 422, + } + ); + }); +}); diff --git a/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier/sad-path.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier/sad-path.test.ts index d5187444e..cb69a6f8b 100644 --- a/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier/sad-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier/sad-path.test.ts @@ -1,15 +1,13 @@ import { ConnectorType } from '@logto/connector-kit'; import { InteractionEvent, SignInMode } from '@logto/schemas'; -import { createUser, deleteUser, suspendUser } from '#src/api/admin-user.js'; +import { suspendUser } from '#src/api/admin-user.js'; import { patchInteractionIdentifiers, putInteraction, sendVerificationCode, } from '#src/api/interaction.js'; import { updateSignInExperience } from '#src/api/sign-in-experience.js'; -import { createSsoConnector } from '#src/api/sso-connector.js'; -import { newOidcSsoConnectorPayload } from '#src/constants.js'; import { initClient } from '#src/helpers/client.js'; import { clearConnectorsByTypes, @@ -20,12 +18,7 @@ import { import { expectRejects, readVerificationCode } from '#src/helpers/index.js'; import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js'; import { generateNewUser } from '#src/helpers/user.js'; -import { - generateEmail, - generatePassword, - generatePhone, - generateSsoConnectorName, -} from '#src/utils.js'; +import { generateEmail, generatePhone } from '#src/utils.js'; describe('Sign-in flow sad path using verification-code identifiers', () => { beforeAll(async () => { @@ -218,42 +211,6 @@ describe('Sign-in flow sad path using verification-code identifiers', () => { }); }); - it('Should fail to sign in with email and passcode if the email domain is enabled for SSO only', async () => { - const password = generatePassword(); - const email = generateEmail('sso-sad-path.io'); - const user = await createUser({ primaryEmail: email, password }); - - await createSsoConnector({ - ...newOidcSsoConnectorPayload, - connectorName: generateSsoConnectorName(), - domains: ['sso-sad-path.io'], - }); - - const client = await initClient(); - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - }); - - await client.successSend(sendVerificationCode, { - email, - }); - - const { code: verificationCode } = await readVerificationCode(); - - await expectRejects( - client.send(patchInteractionIdentifiers, { - email, - verificationCode, - }), - { - code: 'session.sso_enabled', - statusCode: 422, - } - ); - - await deleteUser(user.id); - }); - it('Should fail to sign in with phone and passcode if related user is not exist', async () => { const notExistUserPhone = generatePhone(); diff --git a/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier/single-sign-on.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier/single-sign-on.test.ts new file mode 100644 index 000000000..1e2b3a669 --- /dev/null +++ b/packages/integration-tests/src/tests/api/interaction/sign-in-with-passcode-identifier/single-sign-on.test.ts @@ -0,0 +1,140 @@ +import { ConnectorType, InteractionEvent, SignInIdentifier } from '@logto/schemas'; + +import { createUser, deleteUser } from '#src/api/admin-user.js'; +import { + putInteraction, + sendVerificationCode, + patchInteractionIdentifiers, +} from '#src/api/interaction.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js'; +import { newOidcSsoConnectorPayload } from '#src/constants.js'; +import { initClient, processSession, logoutClient } from '#src/helpers/client.js'; +import { + clearConnectorsByTypes, + clearSsoConnectors, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { expectRejects, readVerificationCode } from '#src/helpers/index.js'; +import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { generateEmail, generateSsoConnectorName } from '#src/utils.js'; + +const happyPath = async (email: string) => { + const user = await createUser({ primaryEmail: email }); + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + await client.successSend(sendVerificationCode, { + email, + }); + + const verificationCodeRecord = await readVerificationCode(); + + expect(verificationCodeRecord).toMatchObject({ + address: email, + type: InteractionEvent.SignIn, + }); + + const { code } = verificationCodeRecord; + + await client.successSend(patchInteractionIdentifiers, { + email, + verificationCode: code, + }); + + const { redirectTo } = await client.submitInteraction(); + + await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(user.id); +}; + +describe('test sign-in with email passcode identifier with SSO feature', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await setEmailConnector(); + await setSmsConnector(); + await enableAllVerificationCodeSignInMethods({ + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }); + }); + + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email]); + await clearSsoConnectors(); + }); + + it('Should fail to sign in with email and passcode if the email domain is enabled for SSO only', async () => { + const email = generateEmail('sso-sad-path.io'); + const user = await createUser({ primaryEmail: email }); + await updateSignInExperience({ singleSignOnEnabled: true }); + + const { id } = await createSsoConnector({ + ...newOidcSsoConnectorPayload, + connectorName: generateSsoConnectorName(), + domains: ['sso-sad-path.io'], + }); + + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + await client.successSend(sendVerificationCode, { + email, + }); + + const { code: verificationCode } = await readVerificationCode(); + + await expectRejects( + client.send(patchInteractionIdentifiers, { + email, + verificationCode, + }), + { + code: 'session.sso_enabled', + statusCode: 422, + } + ); + + await deleteUser(user.id); + await deleteSsoConnectorById(id); + }); + + it('Should sign-in with email with SSO disabled', async () => { + await updateSignInExperience({ singleSignOnEnabled: false }); + + const { id } = await createSsoConnector({ + ...newOidcSsoConnectorPayload, + connectorName: generateSsoConnectorName(), + domains: ['sso-sign-in-happy-path.io'], + }); + + const email = generateEmail('sso-sign-in-happy-path.io'); + + await happyPath(email); + await deleteSsoConnectorById(id); + }); + + it('Should sign-in with email with SSO enabled but no connector found', async () => { + await updateSignInExperience({ singleSignOnEnabled: true }); + + const { id } = await createSsoConnector({ + ...newOidcSsoConnectorPayload, + connectorName: generateSsoConnectorName(), + domains: ['sso-sign-in-happy-path.io'], + }); + + const email = generateEmail('happy-path.io'); + + await happyPath(email); + await deleteSsoConnectorById(id); + }); +}); diff --git a/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/happy-path.test.ts index 6f5e2d1ff..f406925c7 100644 --- a/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/happy-path.test.ts @@ -7,8 +7,6 @@ import { putInteractionProfile, deleteUser, } from '#src/api/index.js'; -import { createSsoConnector } from '#src/api/sso-connector.js'; -import { newOidcSsoConnectorPayload } from '#src/constants.js'; import { initClient, processSession, logoutClient } from '#src/helpers/client.js'; import { clearConnectorsByTypes, @@ -21,7 +19,6 @@ import { enableAllVerificationCodeSignInMethods, } from '#src/helpers/sign-in-experience.js'; import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js'; -import { generateSsoConnectorName } from '#src/utils.js'; describe('Sign-in flow using password identifiers', () => { beforeAll(async () => { @@ -75,32 +72,6 @@ describe('Sign-in flow using password identifiers', () => { await deleteUser(user.id); }); - it('should allow sign-in with email and password with unmatched SSO connector domains', async () => { - const { userProfile, user } = await generateNewUser({ primaryEmail: true, password: true }); - const client = await initClient(); - - // Create a new OIDC SSO connector with email domain 'example.com', it should not block the sign-in flow of email logto.io - await createSsoConnector({ - ...newOidcSsoConnectorPayload, - connectorName: generateSsoConnectorName(), - }); - - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { - email: userProfile.primaryEmail, - password: userProfile.password, - }, - }); - - const { redirectTo } = await client.submitInteraction(); - - await processSession(client, redirectTo); - await logoutClient(client); - - await deleteUser(user.id); - }); - it('sign-in with phone and password', async () => { const { userProfile, user } = await generateNewUser({ primaryPhone: true, password: true }); const client = await initClient(); diff --git a/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/sad-path.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/sad-path.test.ts index ff586de4a..d11d8a1c5 100644 --- a/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/sad-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/sad-path.test.ts @@ -1,21 +1,14 @@ import { InteractionEvent, SignInMode } from '@logto/schemas'; -import { createUser, deleteUser, suspendUser } from '#src/api/admin-user.js'; +import { suspendUser } from '#src/api/admin-user.js'; import { putInteraction } from '#src/api/interaction.js'; import { updateSignInExperience } from '#src/api/sign-in-experience.js'; -import { createSsoConnector } from '#src/api/sso-connector.js'; -import { newOidcSsoConnectorPayload } from '#src/constants.js'; import { initClient } from '#src/helpers/client.js'; import { clearSsoConnectors } from '#src/helpers/connector.js'; import { expectRejects } from '#src/helpers/index.js'; import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; import { generateNewUser } from '#src/helpers/user.js'; -import { - generateEmail, - generateName, - generatePassword, - generateSsoConnectorName, -} from '#src/utils.js'; +import { generateName, generatePassword } from '#src/utils.js'; describe('Sign-in flow sad path using password identifiers', () => { beforeAll(async () => { @@ -189,32 +182,6 @@ describe('Sign-in flow sad path using password identifiers', () => { ); }); - it('Should fail to sign-in with email and password if the email domain is enabled for SSO only', async () => { - const password = generatePassword(); - const email = generateEmail('sso-sad-path.io'); - const user = await createUser({ primaryEmail: email, password }); - - await createSsoConnector({ - ...newOidcSsoConnectorPayload, - connectorName: generateSsoConnectorName(), - domains: ['sso-sad-path.io'], - }); - - const client = await initClient(); - - await expectRejects( - client.send(putInteraction, { - event: InteractionEvent.SignIn, - identifier: { email, password }, - }), - { - code: 'session.sso_enabled', - statusCode: 422, - } - ); - await deleteUser(user.id); - }); - it('Should fail to sign-in with username and password if the user is suspended', async () => { const { user, diff --git a/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/single-sign-on.test.ts b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/single-sign-on.test.ts new file mode 100644 index 000000000..19205d177 --- /dev/null +++ b/packages/integration-tests/src/tests/api/interaction/sign-in-with-password-identifier/single-sign-on.test.ts @@ -0,0 +1,107 @@ +import { ConnectorType, InteractionEvent } from '@logto/schemas'; + +import { createUser, deleteUser } from '#src/api/admin-user.js'; +import { putInteraction } from '#src/api/interaction.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js'; +import { newOidcSsoConnectorPayload } from '#src/constants.js'; +import { initClient, processSession, logoutClient } from '#src/helpers/client.js'; +import { + clearConnectorsByTypes, + clearSsoConnectors, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { generateEmail, generatePassword, generateSsoConnectorName } from '#src/utils.js'; + +const happyPath = async (email: string) => { + const password = generatePassword(); + const user = await createUser({ primaryEmail: email, password }); + + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { + email, + password, + }, + }); + + const { redirectTo } = await client.submitInteraction(); + + await processSession(client, redirectTo); + await logoutClient(client); + + await deleteUser(user.id); +}; + +describe('test sign-in with email passcode identifier with SSO feature', () => { + beforeAll(async () => { + await enableAllPasswordSignInMethods(); + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await setEmailConnector(); + await setSmsConnector(); + }); + + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await clearSsoConnectors(); + }); + + it('should allow sign-in with email and password with SSO disabled', async () => { + await updateSignInExperience({ singleSignOnEnabled: false }); + const email = generateEmail('sso-password-happy-path.io'); + + const { id } = await createSsoConnector({ + ...newOidcSsoConnectorPayload, + connectorName: generateSsoConnectorName(), + domains: ['sso-password-happy-path.io'], + }); + + await happyPath(email); + await deleteSsoConnectorById(id); + }); + + it('should allow sign-in with email and password with unmatched SSO connector domains', async () => { + await updateSignInExperience({ singleSignOnEnabled: true }); + const email = generateEmail('happy-path.io'); + + const { id } = await createSsoConnector({ + ...newOidcSsoConnectorPayload, + connectorName: generateSsoConnectorName(), + domains: ['sso-password-happy-path.io'], + }); + + await happyPath(email); + await deleteSsoConnectorById(id); + }); + + it('Should fail to sign-in with email and password if the email domain is enabled for SSO only', async () => { + const password = generatePassword(); + const email = generateEmail('sso-sad-path.io'); + const user = await createUser({ primaryEmail: email, password }); + + await createSsoConnector({ + ...newOidcSsoConnectorPayload, + connectorName: generateSsoConnectorName(), + domains: ['sso-sad-path.io'], + }); + + const client = await initClient(); + + await expectRejects( + client.send(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { email, password }, + }), + { + code: 'session.sso_enabled', + statusCode: 422, + } + ); + await deleteUser(user.id); + }); +});