Skip to content

Commit

Permalink
feat: automatic social account linking (#5881)
Browse files Browse the repository at this point in the history
* feat: automatic social account linking

* chore: add integration tests

* chore: add changeset
  • Loading branch information
gao-sun authored Jun 8, 2024
1 parent 5e13495 commit 1363205
Show file tree
Hide file tree
Showing 13 changed files with 204 additions and 16 deletions.
11 changes: 11 additions & 0 deletions .changeset/mean-dogs-pump.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,7 +13,8 @@ import SocialConnectorEditBox from './SocialConnectorEditBox';

function SocialSignInForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { control } = useFormContext<SignInExperienceForm>();
const { control, watch, register } = useFormContext<SignInExperienceForm>();
const socialConnectorCount = watch('socialSignInConnectorTargets').length || 0;

return (
<Card>
Expand All @@ -30,6 +32,16 @@ function SocialSignInForm() {
}}
/>
</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>
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/__mocks__/sign-in-experience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,5 @@ export const mockSignInExperience: SignInExperience = {
factors: [],
},
singleSignOnEnabled: true,
socialSignIn: {},
};
3 changes: 2 additions & 1 deletion packages/core/src/queries/sign-in-experience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`;
Expand Down
2 changes: 2 additions & 0 deletions packages/experience/src/__mocks__/logto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export const mockSignInExperience: SignInExperience = {
factors: [],
},
singleSignOnEnabled: true,
socialSignIn: {},
};

export const mockSignInExperienceSettings: SignInExperienceResponse = {
Expand Down Expand Up @@ -142,6 +143,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
},
isDevelopmentTenant: false,
singleSignOnEnabled: true,
socialSignIn: {},
};

const usernameSettings = {
Expand Down
2 changes: 2 additions & 0 deletions packages/experience/src/hooks/use-sie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand All @@ -41,18 +40,32 @@ 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;
}

// Register with social
await registerWithSocial(connectorId);
},
[connectorId, navigate, registerWithSocial]
[
bindSocialRelatedUser,
connectorId,
navigate,
registerWithSocial,
socialSignInSettings.automaticAccountLinking,
]
);

const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 14 additions & 0 deletions packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -52,6 +54,18 @@ export const signInGuard = z.object({

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 type ConnectorTargets = z.infer<typeof connectorTargetsGuard>;
Expand Down
1 change: 1 addition & 0 deletions packages/schemas/tables/sign_in_experiences.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 1363205

Please sign in to comment.