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:
parent
5e13495554
commit
136320584f
13 changed files with 204 additions and 16 deletions
11
.changeset/mean-dogs-pump.md
Normal file
11
.changeset/mean-dogs-pump.md
Normal 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.
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -97,4 +97,5 @@ export const mockSignInExperience: SignInExperience = {
|
||||||
factors: [],
|
factors: [],
|
||||||
},
|
},
|
||||||
singleSignOnEnabled: true,
|
singleSignOnEnabled: true,
|
||||||
|
socialSignIn: {},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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).
|
||||||
|
|
|
@ -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.',
|
||||||
|
|
|
@ -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;
|
|
@ -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>;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue