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:
parent
3d614725ac
commit
c93ffb4760
14 changed files with 310 additions and 40 deletions
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -54,6 +54,7 @@ export class SsoConnectorApi {
|
|||
clientSecret: 'bar',
|
||||
issuer: `${logtoUrl}/oidc`,
|
||||
},
|
||||
syncProfile: true,
|
||||
});
|
||||
|
||||
return connector;
|
||||
|
|
|
@ -40,6 +40,7 @@ export const setSocialConnector = async () =>
|
|||
postConnector({
|
||||
connectorId: mockSocialConnectorId,
|
||||
config: mockSocialConnectorConfig,
|
||||
syncProfile: true,
|
||||
});
|
||||
|
||||
export const resetPasswordlessConnectors = async () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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`. */
|
||||
|
|
Loading…
Reference in a new issue