From 136320584f0ee015cc4e4ff3e68278fee0f6cbb8 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sat, 8 Jun 2024 20:23:57 +0800 Subject: [PATCH] feat: automatic social account linking (#5881) * feat: automatic social account linking * chore: add integration tests * chore: add changeset --- .changeset/mean-dogs-pump.md | 11 ++ .../SocialSignInForm/index.tsx | 14 ++- .../core/src/__mocks__/sign-in-experience.ts | 1 + .../src/queries/sign-in-experience.test.ts | 3 +- packages/experience/src/__mocks__/logto.tsx | 2 + packages/experience/src/hooks/use-sie.ts | 2 + .../use-social-sign-in-listener.ts | 31 +++-- .../automatic-account-linking.test.ts | 114 ++++++++++++++++++ .../tests/experience/direct-sign-in.test.ts | 6 +- .../sign-in-exp/sign-up-and-sign-in.ts | 3 + .../next-1717567857-social-sign-in-linking.ts | 18 +++ .../jsonb-types/sign-in-experience.ts | 14 +++ .../schemas/tables/sign_in_experiences.sql | 1 + 13 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 .changeset/mean-dogs-pump.md create mode 100644 packages/integration-tests/src/tests/experience/automatic-account-linking.test.ts create mode 100644 packages/schemas/alterations/next-1717567857-social-sign-in-linking.ts diff --git a/.changeset/mean-dogs-pump.md b/.changeset/mean-dogs-pump.md new file mode 100644 index 000000000..25176e53f --- /dev/null +++ b/.changeset/mean-dogs-pump.md @@ -0,0 +1,11 @@ +--- +"@logto/experience": patch +"@logto/console": patch +"@logto/phrases": patch +--- + +allow skipping manual account linking during sign-in + +You can find this configuration in Console -> Sign-in experience -> Sign-up and sign-in -> Social sign-in -> Automatic account linking. + +When switched on, if a user signs in with a social identity that is new to the system, and there is exactly one existing account with the same identifier (e.g., email), Logto will automatically link the account with the social identity instead of prompting the user for account linking. diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SocialSignInForm/index.tsx b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SocialSignInForm/index.tsx index b038571b2..b1691c2b2 100644 --- a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SocialSignInForm/index.tsx +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SocialSignInForm/index.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import Card from '@/ds-components/Card'; import FormField from '@/ds-components/FormField'; +import Switch from '@/ds-components/Switch'; import type { SignInExperienceForm } from '../../../types'; import FormFieldDescription from '../../components/FormFieldDescription'; @@ -12,7 +13,8 @@ import SocialConnectorEditBox from './SocialConnectorEditBox'; function SocialSignInForm() { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { control } = useFormContext(); + const { control, watch, register } = useFormContext(); + const socialConnectorCount = watch('socialSignInConnectorTargets').length || 0; return ( @@ -30,6 +32,16 @@ function SocialSignInForm() { }} /> + {socialConnectorCount > 0 && ( + + + + )} ); } diff --git a/packages/core/src/__mocks__/sign-in-experience.ts b/packages/core/src/__mocks__/sign-in-experience.ts index b168a0be8..6e92ec63a 100644 --- a/packages/core/src/__mocks__/sign-in-experience.ts +++ b/packages/core/src/__mocks__/sign-in-experience.ts @@ -97,4 +97,5 @@ export const mockSignInExperience: SignInExperience = { factors: [], }, singleSignOnEnabled: true, + socialSignIn: {}, }; diff --git a/packages/core/src/queries/sign-in-experience.test.ts b/packages/core/src/queries/sign-in-experience.test.ts index 6b974df87..c18b5a205 100644 --- a/packages/core/src/queries/sign-in-experience.test.ts +++ b/packages/core/src/queries/sign-in-experience.test.ts @@ -34,12 +34,13 @@ describe('sign-in-experience query', () => { customContent: JSON.stringify(mockSignInExperience.customContent), passwordPolicy: JSON.stringify(mockSignInExperience.passwordPolicy), mfa: JSON.stringify(mockSignInExperience.mfa), + socialSignIn: JSON.stringify(mockSignInExperience.socialSignIn), }; it('findDefaultSignInExperience', async () => { /* eslint-disable sql/no-unsafe-query */ const expectSql = ` - select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "sign_in", "sign_up", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "password_policy", "mfa", "single_sign_on_enabled" + select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "sign_in", "sign_up", "social_sign_in", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "password_policy", "mfa", "single_sign_on_enabled" from "sign_in_experiences" where "id"=$1 `; diff --git a/packages/experience/src/__mocks__/logto.tsx b/packages/experience/src/__mocks__/logto.tsx index 62d125421..ec3b66d09 100644 --- a/packages/experience/src/__mocks__/logto.tsx +++ b/packages/experience/src/__mocks__/logto.tsx @@ -110,6 +110,7 @@ export const mockSignInExperience: SignInExperience = { factors: [], }, singleSignOnEnabled: true, + socialSignIn: {}, }; export const mockSignInExperienceSettings: SignInExperienceResponse = { @@ -142,6 +143,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = { }, isDevelopmentTenant: false, singleSignOnEnabled: true, + socialSignIn: {}, }; const usernameSettings = { diff --git a/packages/experience/src/hooks/use-sie.ts b/packages/experience/src/hooks/use-sie.ts index e432e7517..c16ac95dc 100644 --- a/packages/experience/src/hooks/use-sie.ts +++ b/packages/experience/src/hooks/use-sie.ts @@ -9,6 +9,7 @@ import { type VerificationCodeIdentifier } from '@/types'; export const useSieMethods = () => { const { experienceSettings } = useContext(PageContext); + const socialSignInSettings = experienceSettings?.socialSignIn ?? {}; const { identifiers, password, verify } = experienceSettings?.signUp ?? {}; return { @@ -19,6 +20,7 @@ export const useSieMethods = () => { // Filter out empty settings ({ password, verificationCode }) => password || verificationCode ) ?? [], + socialSignInSettings, socialConnectors: experienceSettings?.socialConnectors ?? [], ssoConnectors: experienceSettings?.ssoConnectors ?? [], signInMode: experienceSettings?.signInMode, diff --git a/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts index 902a23f3a..b62847392 100644 --- a/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts +++ b/packages/experience/src/pages/SocialSignInWebCallback/use-social-sign-in-listener.ts @@ -6,6 +6,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { validate } from 'superstruct'; import { signInWithSocial } from '@/apis/interaction'; +import useBindSocialRelatedUser from '@/containers/SocialLinkAccount/use-social-link-related-user'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; @@ -21,18 +22,16 @@ import { stateValidation } from '@/utils/social-connectors'; const useSocialSignInListener = (connectorId: string) => { const [loading, setLoading] = useState(true); const { setToast } = useToast(); - const { signInMode } = useSieMethods(); + const { signInMode, socialSignInSettings } = useSieMethods(); const { t } = useTranslation(); const { termsValidation } = useTerms(); const [isConsumed, setIsConsumed] = useState(false); const [searchParameters, setSearchParameters] = useSearchParams(); const navigate = useNavigate(); - const handleError = useErrorHandler(); - + const bindSocialRelatedUser = useBindSocialRelatedUser(); const registerWithSocial = useSocialRegister(connectorId, true); - const asyncSignInWithSocial = useApi(signInWithSocial); const accountNotExistErrorHandler = useCallback( @@ -41,10 +40,18 @@ const useSocialSignInListener = (connectorId: string) => { const { relatedUser } = data ?? {}; if (relatedUser) { - navigate(`/social/link/${connectorId}`, { - replace: true, - state: { relatedUser }, - }); + if (socialSignInSettings.automaticAccountLinking) { + const { type, value } = relatedUser; + await bindSocialRelatedUser({ + connectorId, + ...(type === 'email' ? { email: value } : { phone: value }), + }); + } else { + navigate(`/social/link/${connectorId}`, { + replace: true, + state: { relatedUser }, + }); + } return; } @@ -52,7 +59,13 @@ const useSocialSignInListener = (connectorId: string) => { // Register with social await registerWithSocial(connectorId); }, - [connectorId, navigate, registerWithSocial] + [ + bindSocialRelatedUser, + connectorId, + navigate, + registerWithSocial, + socialSignInSettings.automaticAccountLinking, + ] ); const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); diff --git a/packages/integration-tests/src/tests/experience/automatic-account-linking.test.ts b/packages/integration-tests/src/tests/experience/automatic-account-linking.test.ts new file mode 100644 index 000000000..a3adb2de5 --- /dev/null +++ b/packages/integration-tests/src/tests/experience/automatic-account-linking.test.ts @@ -0,0 +1,114 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { SignInIdentifier } from '@logto/schemas'; + +import { createUser, deleteUser } from '#src/api/admin-user.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { demoAppUrl } from '#src/constants.js'; +import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js'; +import ExpectExperience from '#src/ui-helpers/expect-experience.js'; +import { generateEmail, randomString } from '#src/utils.js'; + +describe('automatic account linking', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email, ConnectorType.Sms]); + await setSocialConnector(); + await updateSignInExperience({ + termsOfUseUrl: null, + privacyPolicyUrl: null, + signUp: { identifiers: [], password: true, verify: false }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + singleSignOnEnabled: true, + socialSignInConnectorTargets: ['mock-social'], + }); + }); + + it('should automatically link account', async () => { + await updateSignInExperience({ + termsOfUseUrl: null, + privacyPolicyUrl: null, + socialSignIn: { automaticAccountLinking: true }, + }); + const socialUserId = 'foo_' + randomString(); + const user = await createUser({ primaryEmail: generateEmail() }); + const experience = new ExpectExperience(await browser.newPage()); + + await experience.navigateTo(demoAppUrl.href); + await experience.toProcessSocialSignIn({ + socialUserId, + socialEmail: user.primaryEmail!, + }); + + experience.toMatchUrl(demoAppUrl); + await experience.toMatchElement('div', { text: `User ID: ${user.id}` }); + await experience.toClick('div[role=button]', /sign out/i); + await experience.page.close(); + + await deleteUser(user.id); + }); + + it('should automatically link account with terms of use and privacy policy', async () => { + await updateSignInExperience({ + termsOfUseUrl: 'https://example.com/terms', + privacyPolicyUrl: 'https://example.com/privacy', + socialSignIn: { automaticAccountLinking: true }, + }); + const socialUserId = 'foo_' + randomString(); + const user = await createUser({ primaryEmail: generateEmail() }); + const experience = new ExpectExperience(await browser.newPage()); + + await experience.navigateTo(demoAppUrl.href); + await experience.toProcessSocialSignIn({ + socialUserId, + socialEmail: user.primaryEmail!, + }); + + // Should have popped up the terms of use and privacy policy dialog + await experience.toMatchElement('div', { text: /terms of use/i }); + await experience.toClick('button', /agree/i); + + experience.toMatchUrl(demoAppUrl); + await experience.toMatchElement('div', { text: `User ID: ${user.id}` }); + await experience.toClick('div[role=button]', /sign out/i); + await experience.page.close(); + + await deleteUser(user.id); + }); + + it('should not automatically link account', async () => { + await updateSignInExperience({ + termsOfUseUrl: null, + privacyPolicyUrl: null, + socialSignIn: { automaticAccountLinking: false }, + }); + const socialUserId = 'foo_' + randomString(); + const user = await createUser({ primaryEmail: generateEmail() }); + const experience = new ExpectExperience(await browser.newPage()); + + await experience.navigateTo(demoAppUrl.href); + await experience.toProcessSocialSignIn({ + socialUserId, + socialEmail: user.primaryEmail!, + }); + + await experience.toClick('button', /create account without linking/i); + experience.toMatchUrl(demoAppUrl); + try { + await experience.toMatchElement('div', { text: `User ID: ${user.id}`, timeout: 100 }); + throw new Error('User ID should not be displayed'); + } catch {} + + await experience.toClick('div[role=button]', /sign out/i); + await experience.page.close(); + + await deleteUser(user.id); + }); +}); diff --git a/packages/integration-tests/src/tests/experience/direct-sign-in.test.ts b/packages/integration-tests/src/tests/experience/direct-sign-in.test.ts index 4bd3633c2..c1f816803 100644 --- a/packages/integration-tests/src/tests/experience/direct-sign-in.test.ts +++ b/packages/integration-tests/src/tests/experience/direct-sign-in.test.ts @@ -1,5 +1,3 @@ -import crypto from 'node:crypto'; - import { ConnectorType } from '@logto/connector-kit'; import { SignInIdentifier, SsoProviderName } from '@logto/schemas'; import { appendPath } from '@silverhand/essentials'; @@ -10,9 +8,7 @@ import { createSsoConnector } from '#src/api/sso-connector.js'; import { demoAppUrl, logtoUrl } from '#src/constants.js'; import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js'; import ExpectExperience from '#src/ui-helpers/expect-experience.js'; -import { dcls, dmodal } from '#src/utils.js'; - -const randomString = () => crypto.randomBytes(8).toString('hex'); +import { dcls, dmodal, randomString } from '#src/utils.js'; /** * NOTE: This test suite assumes test cases will run sequentially (which is Jest default). diff --git a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/sign-up-and-sign-in.ts b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/sign-up-and-sign-in.ts index 680e2e619..edd04b992 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/sign-up-and-sign-in.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/sign-up-and-sign-in.ts @@ -40,6 +40,9 @@ const sign_up_and_sign_in = { set_up_more: 'Set up', go_to: 'other social connectors now.', }, + automatic_account_linking: 'Automatic account linking', + automatic_account_linking_label: + 'When switched on, if a user signs in with a social identity that is new to the system, and there is exactly one existing account with the same identifier (e.g., email), Logto will automatically link the account with the social identity instead of prompting the user for account linking.', }, tip: { set_a_password: 'A unique set of a password to your username is a must.', diff --git a/packages/schemas/alterations/next-1717567857-social-sign-in-linking.ts b/packages/schemas/alterations/next-1717567857-social-sign-in-linking.ts new file mode 100644 index 000000000..63a523757 --- /dev/null +++ b/packages/schemas/alterations/next-1717567857-social-sign-in-linking.ts @@ -0,0 +1,18 @@ +import { sql } from '@silverhand/slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + alter table sign_in_experiences add column social_sign_in jsonb not null default '{}'::jsonb; + `); + }, + down: async (pool) => { + await pool.query(sql` + alter table sign_in_experiences drop column social_sign_in; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts b/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts index fd77dc2c7..b8542d986 100644 --- a/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts +++ b/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts @@ -2,6 +2,8 @@ import { hexColorRegEx } from '@logto/core-kit'; import { languageTagGuard } from '@logto/language-kit'; import { z } from 'zod'; +import { type ToZodObject } from '../../utils/zod.js'; + export const colorGuard = z.object({ primaryColor: z.string().regex(hexColorRegEx), isDarkModeEnabled: z.boolean(), @@ -52,6 +54,18 @@ export const signInGuard = z.object({ export type SignIn = z.infer; +export type SocialSignIn = { + /** + * If account linking should be performed when a user signs in with a social identity that is new + * to the system and exactly one existing account is found with the same identifier (e.g., email). + */ + automaticAccountLinking?: boolean; +}; + +export const socialSignInGuard = z.object({ + automaticAccountLinking: z.boolean().optional(), +}) satisfies ToZodObject; + export const connectorTargetsGuard = z.string().array(); export type ConnectorTargets = z.infer; diff --git a/packages/schemas/tables/sign_in_experiences.sql b/packages/schemas/tables/sign_in_experiences.sql index 385e08f11..21594b0c6 100644 --- a/packages/schemas/tables/sign_in_experiences.sql +++ b/packages/schemas/tables/sign_in_experiences.sql @@ -11,6 +11,7 @@ create table sign_in_experiences ( privacy_policy_url varchar(2048), sign_in jsonb /* @use SignIn */ not null, sign_up jsonb /* @use SignUp */ not null, + social_sign_in jsonb /* @use SocialSignIn */ not null default '{}'::jsonb, social_sign_in_connector_targets jsonb /* @use ConnectorTargets */ not null default '[]'::jsonb, sign_in_mode sign_in_mode not null default 'SignInAndRegister', custom_css text,