0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat: automatic social account linking (#5881)

* feat: automatic social account linking

* chore: add integration tests

* chore: add changeset
This commit is contained in:
Gao Sun 2024-06-08 20:23:57 +08:00 committed by GitHub
parent 5e13495554
commit 136320584f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 204 additions and 16 deletions

View file

@ -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.

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import Card from '@/ds-components/Card'; import Card from '@/ds-components/Card';
import FormField from '@/ds-components/FormField'; import FormField from '@/ds-components/FormField';
import Switch from '@/ds-components/Switch';
import type { SignInExperienceForm } from '../../../types'; import type { SignInExperienceForm } from '../../../types';
import FormFieldDescription from '../../components/FormFieldDescription'; import FormFieldDescription from '../../components/FormFieldDescription';
@ -12,7 +13,8 @@ import SocialConnectorEditBox from './SocialConnectorEditBox';
function SocialSignInForm() { function SocialSignInForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { control } = useFormContext<SignInExperienceForm>(); const { control, watch, register } = useFormContext<SignInExperienceForm>();
const socialConnectorCount = watch('socialSignInConnectorTargets').length || 0;
return ( return (
<Card> <Card>
@ -30,6 +32,16 @@ function SocialSignInForm() {
}} }}
/> />
</FormField> </FormField>
{socialConnectorCount > 0 && (
<FormField title="sign_in_exp.sign_up_and_sign_in.social_sign_in.automatic_account_linking">
<Switch
{...register('socialSignIn.automaticAccountLinking')}
label={t(
'sign_in_exp.sign_up_and_sign_in.social_sign_in.automatic_account_linking_label'
)}
/>
</FormField>
)}
</Card> </Card>
); );
} }

View file

@ -97,4 +97,5 @@ export const mockSignInExperience: SignInExperience = {
factors: [], factors: [],
}, },
singleSignOnEnabled: true, singleSignOnEnabled: true,
socialSignIn: {},
}; };

View file

@ -34,12 +34,13 @@ describe('sign-in-experience query', () => {
customContent: JSON.stringify(mockSignInExperience.customContent), customContent: JSON.stringify(mockSignInExperience.customContent),
passwordPolicy: JSON.stringify(mockSignInExperience.passwordPolicy), passwordPolicy: JSON.stringify(mockSignInExperience.passwordPolicy),
mfa: JSON.stringify(mockSignInExperience.mfa), mfa: JSON.stringify(mockSignInExperience.mfa),
socialSignIn: JSON.stringify(mockSignInExperience.socialSignIn),
}; };
it('findDefaultSignInExperience', async () => { it('findDefaultSignInExperience', async () => {
/* eslint-disable sql/no-unsafe-query */ /* eslint-disable sql/no-unsafe-query */
const expectSql = ` 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" from "sign_in_experiences"
where "id"=$1 where "id"=$1
`; `;

View file

@ -110,6 +110,7 @@ export const mockSignInExperience: SignInExperience = {
factors: [], factors: [],
}, },
singleSignOnEnabled: true, singleSignOnEnabled: true,
socialSignIn: {},
}; };
export const mockSignInExperienceSettings: SignInExperienceResponse = { export const mockSignInExperienceSettings: SignInExperienceResponse = {
@ -142,6 +143,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
}, },
isDevelopmentTenant: false, isDevelopmentTenant: false,
singleSignOnEnabled: true, singleSignOnEnabled: true,
socialSignIn: {},
}; };
const usernameSettings = { const usernameSettings = {

View file

@ -9,6 +9,7 @@ import { type VerificationCodeIdentifier } from '@/types';
export const useSieMethods = () => { export const useSieMethods = () => {
const { experienceSettings } = useContext(PageContext); const { experienceSettings } = useContext(PageContext);
const socialSignInSettings = experienceSettings?.socialSignIn ?? {};
const { identifiers, password, verify } = experienceSettings?.signUp ?? {}; const { identifiers, password, verify } = experienceSettings?.signUp ?? {};
return { return {
@ -19,6 +20,7 @@ export const useSieMethods = () => {
// Filter out empty settings // Filter out empty settings
({ password, verificationCode }) => password || verificationCode ({ password, verificationCode }) => password || verificationCode
) ?? [], ) ?? [],
socialSignInSettings,
socialConnectors: experienceSettings?.socialConnectors ?? [], socialConnectors: experienceSettings?.socialConnectors ?? [],
ssoConnectors: experienceSettings?.ssoConnectors ?? [], ssoConnectors: experienceSettings?.ssoConnectors ?? [],
signInMode: experienceSettings?.signInMode, signInMode: experienceSettings?.signInMode,

View file

@ -6,6 +6,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import { signInWithSocial } from '@/apis/interaction'; import { signInWithSocial } from '@/apis/interaction';
import useBindSocialRelatedUser from '@/containers/SocialLinkAccount/use-social-link-related-user';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler'; import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler 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 useSocialSignInListener = (connectorId: string) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { setToast } = useToast(); const { setToast } = useToast();
const { signInMode } = useSieMethods(); const { signInMode, socialSignInSettings } = useSieMethods();
const { t } = useTranslation(); const { t } = useTranslation();
const { termsValidation } = useTerms(); const { termsValidation } = useTerms();
const [isConsumed, setIsConsumed] = useState(false); const [isConsumed, setIsConsumed] = useState(false);
const [searchParameters, setSearchParameters] = useSearchParams(); const [searchParameters, setSearchParameters] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const bindSocialRelatedUser = useBindSocialRelatedUser();
const registerWithSocial = useSocialRegister(connectorId, true); const registerWithSocial = useSocialRegister(connectorId, true);
const asyncSignInWithSocial = useApi(signInWithSocial); const asyncSignInWithSocial = useApi(signInWithSocial);
const accountNotExistErrorHandler = useCallback( const accountNotExistErrorHandler = useCallback(
@ -41,10 +40,18 @@ const useSocialSignInListener = (connectorId: string) => {
const { relatedUser } = data ?? {}; const { relatedUser } = data ?? {};
if (relatedUser) { if (relatedUser) {
navigate(`/social/link/${connectorId}`, { if (socialSignInSettings.automaticAccountLinking) {
replace: true, const { type, value } = relatedUser;
state: { relatedUser }, await bindSocialRelatedUser({
}); connectorId,
...(type === 'email' ? { email: value } : { phone: value }),
});
} else {
navigate(`/social/link/${connectorId}`, {
replace: true,
state: { relatedUser },
});
}
return; return;
} }
@ -52,7 +59,13 @@ const useSocialSignInListener = (connectorId: string) => {
// Register with social // Register with social
await registerWithSocial(connectorId); await registerWithSocial(connectorId);
}, },
[connectorId, navigate, registerWithSocial] [
bindSocialRelatedUser,
connectorId,
navigate,
registerWithSocial,
socialSignInSettings.automaticAccountLinking,
]
); );
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true }); const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });

View file

@ -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);
});
});

View file

@ -1,5 +1,3 @@
import crypto from 'node:crypto';
import { ConnectorType } from '@logto/connector-kit'; import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier, SsoProviderName } from '@logto/schemas'; import { SignInIdentifier, SsoProviderName } from '@logto/schemas';
import { appendPath } from '@silverhand/essentials'; 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 { demoAppUrl, logtoUrl } from '#src/constants.js';
import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js'; import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
import ExpectExperience from '#src/ui-helpers/expect-experience.js'; import ExpectExperience from '#src/ui-helpers/expect-experience.js';
import { dcls, dmodal } from '#src/utils.js'; import { dcls, dmodal, randomString } from '#src/utils.js';
const randomString = () => crypto.randomBytes(8).toString('hex');
/** /**
* NOTE: This test suite assumes test cases will run sequentially (which is Jest default). * NOTE: This test suite assumes test cases will run sequentially (which is Jest default).

View file

@ -40,6 +40,9 @@ const sign_up_and_sign_in = {
set_up_more: 'Set up', set_up_more: 'Set up',
go_to: 'other social connectors now.', 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: { tip: {
set_a_password: 'A unique set of a password to your username is a must.', set_a_password: 'A unique set of a password to your username is a must.',

View file

@ -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;

View file

@ -2,6 +2,8 @@ import { hexColorRegEx } from '@logto/core-kit';
import { languageTagGuard } from '@logto/language-kit'; import { languageTagGuard } from '@logto/language-kit';
import { z } from 'zod'; import { z } from 'zod';
import { type ToZodObject } from '../../utils/zod.js';
export const colorGuard = z.object({ export const colorGuard = z.object({
primaryColor: z.string().regex(hexColorRegEx), primaryColor: z.string().regex(hexColorRegEx),
isDarkModeEnabled: z.boolean(), isDarkModeEnabled: z.boolean(),
@ -52,6 +54,18 @@ export const signInGuard = z.object({
export type SignIn = z.infer<typeof signInGuard>; export type SignIn = z.infer<typeof signInGuard>;
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<SocialSignIn>;
export const connectorTargetsGuard = z.string().array(); export const connectorTargetsGuard = z.string().array();
export type ConnectorTargets = z.infer<typeof connectorTargetsGuard>; export type ConnectorTargets = z.infer<typeof connectorTargetsGuard>;

View file

@ -11,6 +11,7 @@ create table sign_in_experiences (
privacy_policy_url varchar(2048), privacy_policy_url varchar(2048),
sign_in jsonb /* @use SignIn */ not null, sign_in jsonb /* @use SignIn */ not null,
sign_up jsonb /* @use SignUp */ 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, social_sign_in_connector_targets jsonb /* @use ConnectorTargets */ not null default '[]'::jsonb,
sign_in_mode sign_in_mode not null default 'SignInAndRegister', sign_in_mode sign_in_mode not null default 'SignInAndRegister',
custom_css text, custom_css text,