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

feat(core,schemas): implement social/sso link and sync logic (#6257)

* feat(core,schemas): implement social/sso link and sync logic

implement social/sso link and sync logic

* test(core): add intergration tests

add integration tests
This commit is contained in:
simeng-li 2024-07-19 17:41:55 +08:00 committed by GitHub
parent 3d614725ac
commit c93ffb4760
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 310 additions and 40 deletions

View file

@ -39,6 +39,8 @@ const getUserInfo: GetUserInfo = async (data, getSession) => {
userId: z.optional(z.string()),
email: z.string().optional(),
phone: z.string().optional(),
name: z.string().optional(),
avatar: z.string().optional(),
});
const result = dataGuard.safeParse(data);

View file

@ -12,7 +12,11 @@ import assertThat from '#src/utils/assert-that.js';
import { interactionProfileGuard, type Interaction, type InteractionProfile } from '../types.js';
import { ProvisionLibrary } from './provision-library.js';
import { getNewUserProfileFromVerificationRecord, toUserSocialIdentityData } from './utils.js';
import {
getNewUserProfileFromVerificationRecord,
identifyUserByVerificationRecord,
toUserSocialIdentityData,
} from './utils.js';
import { ProfileValidator } from './validators/profile-validator.js';
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
import {
@ -52,7 +56,7 @@ export default class ExperienceInteraction {
/** The userId of the user for the current interaction. Only available once the user is identified. */
private userId?: string;
/** The user provided profile data in the current interaction that needs to be stored to database. */
private readonly profile?: InteractionProfile;
#profile?: InteractionProfile;
/** The interaction event for the current interaction. */
#interactionEvent?: InteractionEvent;
@ -89,7 +93,7 @@ export default class ExperienceInteraction {
this.#interactionEvent = interactionEvent;
this.userId = userId;
this.profile = profile;
this.#profile = profile;
// Profile validator requires the userId for existing user profile update validation
this.profileValidator = new ProfileValidator(libraries, queries, userId);
@ -146,7 +150,7 @@ export default class ExperienceInteraction {
* @see {@link identifyExistingUser} for more exceptions that can be thrown in the SignIn and ForgotPassword events.
* @see {@link createNewUser} for more exceptions that can be thrown in the Register event.
**/
public async identifyUser(verificationId: string) {
public async identifyUser(verificationId: string, linkSocialIdentity?: boolean) {
const verificationRecord = this.getVerificationRecordById(verificationId);
assertThat(
@ -169,7 +173,7 @@ export default class ExperienceInteraction {
return;
}
await this.identifyExistingUser(verificationRecord);
await this.identifyExistingUser(verificationRecord, linkSocialIdentity);
}
/**
@ -210,18 +214,43 @@ export default class ExperienceInteraction {
public async submit() {
assertThat(this.userId, 'session.verification_session_not_found');
const {
queries: { users: userQueries, userSsoIdentities: userSsoIdentitiesQueries },
} = this.tenant;
const user = await userQueries.findUserById(this.userId);
// TODO: mfa validation
// TODO: profile updates validation
// TODO: missing profile fields validation
const {
queries: { users: userQueries },
} = this.tenant;
const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile ?? {};
// Update user profile
await userQueries.updateUserById(this.userId, {
...rest,
...conditional(
socialIdentity && {
identities: {
...user.identities,
...toUserSocialIdentityData(socialIdentity),
},
}
),
lastSignInAt: Date.now(),
});
if (enterpriseSsoIdentity) {
await userSsoIdentitiesQueries.insert({
id: generateStandardId(),
userId: user.id,
...enterpriseSsoIdentity,
});
await this.provisionLibrary.newUserJtiOrganizationProvision(user.id, {
enterpriseSsoIdentity,
});
}
const { provider } = this.tenant;
const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
@ -233,12 +262,12 @@ export default class ExperienceInteraction {
/** Convert the current interaction to JSON, so that it can be stored as the OIDC provider interaction result */
public toJson(): InteractionStorage {
const { interactionEvent, userId, profile } = this;
const { interactionEvent, userId } = this;
return {
interactionEvent,
userId,
profile,
profile: this.#profile,
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
};
}
@ -250,21 +279,24 @@ export default class ExperienceInteraction {
/**
* Identify the existing user using the verification record.
*
* @param linkSocialIdentity Applies only to the SocialIdentity verification record sign-in events only.
* If true, the social identity will be linked to related user.
*
* @throws RequestError with 400 if the verification record is not verified or not valid for identifying a user
* @throws RequestError with 404 if the user is not found
* @throws RequestError with 401 if the user is suspended
* @throws RequestError with 409 if the current session has already identified a different user
*/
private async identifyExistingUser(verificationRecord: VerificationRecord) {
// Check verification record can be used to identify a user using the `identifyUser` method.
// E.g. MFA verification record does not have the `identifyUser` method, cannot be used to identify a user.
assertThat(
'identifyUser' in verificationRecord,
new RequestError({ code: 'session.verification_failed', status: 400 })
private async identifyExistingUser(
verificationRecord: VerificationRecord,
linkSocialIdentity?: boolean
) {
const { user, syncedProfile } = await identifyUserByVerificationRecord(
verificationRecord,
linkSocialIdentity
);
const { id, isSuspended } = await verificationRecord.identifyUser();
const { id, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
// Throws an 409 error if the current session has already identified a different user
@ -276,6 +308,11 @@ export default class ExperienceInteraction {
return;
}
// Sync social/enterprise SSO identity profile data
if (syncedProfile) {
this.setProfile(syncedProfile);
}
this.userId = id;
}
@ -330,4 +367,14 @@ export default class ExperienceInteraction {
this.userId = user.id;
}
/**
* Private method to set the profile data without validation.
*/
private setProfile(profile: InteractionProfile) {
this.#profile = {
...this.#profile,
...profile,
};
}
}

View file

@ -5,9 +5,11 @@ import {
type InteractionIdentifier,
type User,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import type { InteractionProfile } from '../types.js';
@ -55,6 +57,71 @@ export const getNewUserProfileFromVerificationRecord = async (
}
};
/**
* @throws {RequestError} -400 if the verification record type is not supported for user identification.
* @throws {RequestError} -400 if the verification record is not verified.
* @throws {RequestError} -404 if the user is not found.
*/
export const identifyUserByVerificationRecord = async (
verificationRecord: VerificationRecord,
linkSocialIdentity?: boolean
): Promise<{
user: User;
/**
* Returns the social/enterprise SSO synced profiled if the verification record is a social/enterprise SSO verification.
* - For new linked identity, the synced profile will includes the new social or enterprise SSO identity.
* - For existing social or enterprise SSO identity, the synced profile will return the synced user profile based on connector settings.
*/
syncedProfile?: Pick<
InteractionProfile,
'enterpriseSsoIdentity' | 'socialIdentity' | 'avatar' | 'name'
>;
}> => {
// Check verification record can be used to identify a user using the `identifyUser` method.
// E.g. MFA verification record does not have the `identifyUser` method, cannot be used to identify a user.
assertThat(
'identifyUser' in verificationRecord,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
switch (verificationRecord.type) {
case VerificationType.Password:
case VerificationType.VerificationCode: {
return { user: await verificationRecord.identifyUser() };
}
case VerificationType.Social: {
const user = linkSocialIdentity
? await verificationRecord.identifyRelatedUser()
: await verificationRecord.identifyUser();
const syncedProfile = {
...(await verificationRecord.toSyncedProfile()),
...conditional(linkSocialIdentity && (await verificationRecord.toUserProfile())),
};
return { user, syncedProfile };
}
case VerificationType.EnterpriseSso: {
try {
const user = await verificationRecord.identifyUser();
const syncedProfile = await verificationRecord.toSyncedProfile();
return { user, syncedProfile };
} catch (error: unknown) {
// Auto fallback to identify the related user if the user does not exist for enterprise SSO.
if (error instanceof RequestError && error.code === 'user.identity_not_exist') {
const user = await verificationRecord.identifyRelatedUser();
const syncedProfile = {
...(await verificationRecord.toUserProfile()),
...(await verificationRecord.toSyncedProfile()),
};
return { user, syncedProfile };
}
throw error;
}
}
}
};
/**
* Convert the interaction profile `socialIdentity` to `User['identities']` data format
*/

View file

@ -8,6 +8,7 @@ import {
type UserSsoIdentity,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
@ -151,6 +152,15 @@ export class EnterpriseSsoVerification
return userSsoIdentityResult.user;
}
throw new RequestError({ code: 'user.identity_not_exist', status: 404 });
}
async identifyRelatedUser(): Promise<User> {
assertThat(
this.isVerified,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
const relatedUser = await this.findRelatedUserSsoIdentity();
if (relatedUser) {
@ -182,7 +192,7 @@ export class EnterpriseSsoVerification
/**
* Returns the synced profile from the enterprise SSO identity.
*
* @param isNewUser - Whether the returned profile is for a new user. If true, the profile should contain the user's email.
* @param isNewUser - Whether the returned profile is for a new user. Only return the primary email if it is a new user.
*/
async toSyncedProfile(
isNewUser = false
@ -195,12 +205,21 @@ export class EnterpriseSsoVerification
const { name, avatar, email: primaryEmail } = this.enterpriseSsoUserInfo;
if (isNewUser) {
return { name, avatar, primaryEmail };
return {
...conditional(primaryEmail && { primaryEmail }),
...conditional(name && { name }),
...conditional(avatar && { avatar }),
};
}
const { syncProfile } = await this.getConnectorData();
return syncProfile ? { name, avatar } : {};
return syncProfile
? {
...conditional(name && { name }),
...conditional(avatar && { avatar }),
}
: {};
}
toJson(): EnterpriseSsoVerificationRecordData {

View file

@ -6,6 +6,7 @@ import {
type User,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
@ -159,6 +160,19 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
return user;
}
async identifyRelatedUser(): Promise<User> {
assertThat(
this.isVerified,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
const relatedUser = await this.findRelatedUserBySocialIdentity();
assertThat(relatedUser, new RequestError({ code: 'user.identity_not_exist', status: 404 }));
return relatedUser[1];
}
/**
* Returns the social identity as a new user profile.
*/
@ -183,27 +197,37 @@ export class SocialVerification implements IdentifierVerificationRecord<Verifica
/**
* Returns the synced profile from the social identity.
*
* @param isNewUser - Whether the profile is for a new user. If set to true, the primary email will be included in the profile.
* @param isNewUser - Whether the profile is for a new user. Only return the primary email/phone if it is a new user.
*/
async toSyncedProfile(
isNewUser = false
): Promise<Pick<InteractionProfile, 'avatar' | 'name' | 'primaryEmail'>> {
): Promise<Pick<InteractionProfile, 'avatar' | 'name' | 'primaryEmail' | 'primaryPhone'>> {
assertThat(
this.socialUserInfo,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
const { name, avatar, email: primaryEmail } = this.socialUserInfo;
const { name, avatar, email: primaryEmail, phone: primaryPhone } = this.socialUserInfo;
if (isNewUser) {
return { name, avatar, primaryEmail };
return {
...conditional(primaryEmail && { primaryEmail }),
...conditional(primaryPhone && { primaryPhone }),
...conditional(name && { name }),
...conditional(avatar && { avatar }),
};
}
const {
dbEntry: { syncProfile },
} = await this.getConnectorData();
return syncProfile ? { name, avatar } : {};
return syncProfile
? {
...conditional(name && { name }),
...conditional(avatar && { avatar }),
}
: {};
}
toJson(): SocialVerificationRecordData {

View file

@ -33,5 +33,6 @@ export abstract class IdentifierVerificationRecord<
T extends VerificationType = IdentifierVerificationType,
Json extends Data<T> = Data<T>,
> extends VerificationRecord<T, Json> {
/** Identify the user associated with the verification record. */
abstract identifyUser(): Promise<User>;
}

View file

@ -111,10 +111,10 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
status: [201, 204, 400, 401, 404, 409, 422],
}),
async (ctx, next) => {
const { verificationId } = ctx.guard.body;
const { verificationId, linkSocialIdentity } = ctx.guard.body;
const { experienceInteraction } = ctx;
await experienceInteraction.identifyUser(verificationId);
await experienceInteraction.identifyUser(verificationId, linkSocialIdentity);
await experienceInteraction.save();

View file

@ -54,6 +54,7 @@ export class SsoConnectorApi {
clientSecret: 'bar',
issuer: `${logtoUrl}/oidc`,
},
syncProfile: true,
});
return connector;

View file

@ -40,6 +40,7 @@ export const setSocialConnector = async () =>
postConnector({
connectorId: mockSocialConnectorId,
config: mockSocialConnectorConfig,
syncProfile: true,
});
export const resetPasswordlessConnectors = async () => {

View file

@ -141,7 +141,10 @@ export const registerNewUserWithVerificationCode = async (
export const signInWithSocial = async (
connectorId: string,
socialUserInfo: SocialUserInfo,
registerNewUser = false
options?: {
registerNewUser?: boolean;
linkSocial?: boolean;
}
) => {
const state = 'state';
const redirectUri = 'http://localhost:3000';
@ -154,6 +157,8 @@ export const signInWithSocial = async (
state,
});
const { id, ...rest } = socialUserInfo;
await successFullyVerifySocialAuthorization(client, connectorId, {
verificationId,
connectorData: {
@ -161,11 +166,11 @@ export const signInWithSocial = async (
redirectUri,
code: 'fake_code',
userId: socialUserInfo.id,
email: socialUserInfo.email,
...rest,
},
});
if (registerNewUser) {
if (options?.registerNewUser) {
await expectRejects(client.identifyUser({ verificationId }), {
code: 'user.identity_not_exist',
status: 404,
@ -173,6 +178,13 @@ export const signInWithSocial = async (
await client.updateInteractionEvent({ interactionEvent: InteractionEvent.Register });
await client.identifyUser({ verificationId });
} else if (options?.linkSocial) {
await expectRejects(client.identifyUser({ verificationId }), {
code: 'user.identity_not_exist',
status: 404,
});
await client.identifyUser({ verificationId, linkSocialIdentity: true });
} else {
await client.identifyUser({
verificationId,

View file

@ -1,7 +1,7 @@
import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier } from '@logto/schemas';
import { deleteUser, getUserOrganizations } from '#src/api/admin-user.js';
import { createUser, deleteUser, getUserOrganizations } from '#src/api/admin-user.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { SsoConnectorApi } from '#src/api/sso-connector.js';
import {
@ -148,7 +148,7 @@ devFeatureTest.describe('organization just-in-time provisioning', () => {
await deleteUser(userId);
});
it('should provision a user with the matched sso connector', async () => {
it('should provision a user with the matched sso connector registration', async () => {
const organization = await organizationApi.create({ name: 'sso_foo' });
const domain = 'sso_example.com';
const connector = await ssoConnectorApi.createMockOidcConnector([domain]);
@ -172,4 +172,30 @@ devFeatureTest.describe('organization just-in-time provisioning', () => {
await deleteUser(userId);
});
it('should provision a user with the matched linked SSO connector identity', async () => {
const organization = await organizationApi.create({ name: 'sso_foo' });
const domain = 'sso_example.com';
const email = generateEmail(domain);
const connector = await ssoConnectorApi.createMockOidcConnector([domain]);
await organizationApi.jit.ssoConnectors.add(organization.id, [connector.id]);
const user = await createUser({ primaryEmail: email });
const userId = await signInWithEnterpriseSso(connector.id, {
sub: randomString(),
email,
email_verified: true,
});
expect(userId).toBe(user.id);
const userOrganizations = await getUserOrganizations(userId);
expect(userOrganizations).toEqual(
expect.arrayContaining([expect.objectContaining({ id: organization.id })])
);
await deleteUser(userId);
});
});

View file

@ -4,12 +4,13 @@ import { deleteUser, getUser } from '#src/api/admin-user.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { SsoConnectorApi } from '#src/api/sso-connector.js';
import { signInWithEnterpriseSso } from '#src/helpers/experience/index.js';
import { generateNewUser } from '#src/helpers/user.js';
import { devFeatureTest, generateEmail } from '#src/utils.js';
devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
const ssoConnectorApi = new SsoConnectorApi();
const domain = 'foo.com';
const socialUserId = generateStandardId();
const enterpriseSsoIdentityId = generateStandardId();
const email = generateEmail(domain);
beforeAll(async () => {
@ -21,11 +22,11 @@ devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
await ssoConnectorApi.cleanUp();
});
it('should successfully sign-up with enterprise sso ans sync email', async () => {
it('should successfully sign-up with enterprise sso and sync email', async () => {
const userId = await signInWithEnterpriseSso(
ssoConnectorApi.firstConnectorId!,
{
sub: socialUserId,
sub: enterpriseSsoIdentityId,
email,
email_verified: true,
},
@ -38,11 +39,40 @@ devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
it('should successfully sign-in with enterprise sso', async () => {
const userId = await signInWithEnterpriseSso(ssoConnectorApi.firstConnectorId!, {
sub: socialUserId,
sub: enterpriseSsoIdentityId,
email,
email_verified: true,
name: 'John Doe',
});
const { name } = await getUser(userId);
expect(name).toBe('John Doe');
await deleteUser(userId);
});
it('should successfully sign-in and link new enterprise sso identity', async () => {
const { userProfile, user } = await generateNewUser({
primaryEmail: true,
});
const { primaryEmail } = userProfile;
const userId = await signInWithEnterpriseSso(ssoConnectorApi.firstConnectorId!, {
sub: enterpriseSsoIdentityId,
email: primaryEmail,
email_verified: true,
name: 'John Doe',
});
expect(userId).toBe(user.id);
const { name, ssoIdentities } = await getUser(userId, true);
expect(name).toBe('John Doe');
expect(ssoIdentities?.some((identity) => identity.identityId === enterpriseSsoIdentityId)).toBe(
true
);
await deleteUser(userId);
});
});

View file

@ -1,10 +1,14 @@
import { ConnectorType } from '@logto/connector-kit';
import { generateStandardId } from '@logto/shared';
import { mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js';
import {
mockSocialConnectorId,
mockSocialConnectorTarget,
} from '#src/__mocks__/connectors-mock.js';
import { deleteUser, getUser } from '#src/api/admin-user.js';
import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
import { signInWithSocial } from '#src/helpers/experience/index.js';
import { generateNewUser } from '#src/helpers/user.js';
import { devFeatureTest, generateEmail } from '#src/utils.js';
devFeatureTest.describe('social sign-in and sign-up', () => {
@ -30,19 +34,49 @@ devFeatureTest.describe('social sign-in and sign-up', () => {
id: socialUserId,
email,
},
true
{
registerNewUser: true,
}
);
const { primaryEmail } = await getUser(userId);
expect(primaryEmail).toBe(email);
});
it('should successfully sign-in with social', async () => {
it('should successfully sign-in with social and sync name', async () => {
const userId = await signInWithSocial(connectorIdMap.get(mockSocialConnectorId)!, {
id: socialUserId,
email,
name: 'John Doe',
});
const { name } = await getUser(userId);
expect(name).toBe('John Doe');
await deleteUser(userId);
});
it('should successfully sign-in with linked email and sync name', async () => {
const { userProfile, user } = await generateNewUser({
primaryEmail: true,
});
const userId = await signInWithSocial(
connectorIdMap.get(mockSocialConnectorId)!,
{
id: socialUserId,
email: userProfile.primaryEmail,
name: 'Foo Bar',
},
{ linkSocial: true }
);
expect(userId).toBe(user.id);
const { identities, name } = await getUser(userId);
expect(identities[mockSocialConnectorTarget]).toBeTruthy();
expect(name).toBe('Foo Bar');
await deleteUser(userId);
});
});

View file

@ -140,9 +140,15 @@ export const newPasswordIdentityVerificationPayloadGuard = z.object({
export type IdentificationApiPayload = {
/** The ID of the verification record that is used to identify the user. */
verificationId: string;
/**
* Link social identity to a related user account with the same email or phone.
* Only applicable for social verification records and a related user account is found.
*/
linkSocialIdentity?: boolean;
};
export const identificationApiPayloadGuard = z.object({
verificationId: z.string(),
linkSocialIdentity: z.boolean().optional(),
}) satisfies ToZodObject<IdentificationApiPayload>;
/** Payload type for `POST /api/experience`. */