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 the register flow (#6237)

* feat(core,schemas): implement the register flow

implement the register flow

* refactor(core,schemas): relocate the profile type defs

relocate the profile type defs

* fix(core): fix the validation guard logic

fix the validation guard logic

* fix(core): fix social and sso identity not created bug

fix social and sso identity not created bug

* fix(core): fix social identities profile key

fix social identities profile key

* fix(core): fix sso query method

fix sso query method
This commit is contained in:
simeng-li 2024-07-15 19:02:57 +08:00 committed by GitHub
parent f94fb519f4
commit 84ac935c80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 446 additions and 70 deletions

View file

@ -1,5 +1,7 @@
import { type ToZodObject } from '@logto/connector-kit'; import { type ToZodObject } from '@logto/connector-kit';
import { InteractionEvent, VerificationType } from '@logto/schemas'; import { InteractionEvent, type VerificationType } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
@ -7,8 +9,10 @@ import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import type { Interaction } from '../types.js'; import { interactionProfileGuard, type Interaction, type InteractionProfile } from '../types.js';
import { getNewUserProfileFromVerificationRecord, toUserSocialIdentityData } from './utils.js';
import { ProfileValidator } from './validators/profile-validator.js';
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js'; import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
import { import {
buildVerificationRecord, buildVerificationRecord,
@ -20,14 +24,14 @@ import {
type InteractionStorage = { type InteractionStorage = {
interactionEvent?: InteractionEvent; interactionEvent?: InteractionEvent;
userId?: string; userId?: string;
profile?: Record<string, unknown>; profile?: InteractionProfile;
verificationRecords?: VerificationRecordData[]; verificationRecords?: VerificationRecordData[];
}; };
const interactionStorageGuard = z.object({ const interactionStorageGuard = z.object({
interactionEvent: z.nativeEnum(InteractionEvent).optional(), interactionEvent: z.nativeEnum(InteractionEvent).optional(),
userId: z.string().optional(), userId: z.string().optional(),
profile: z.object({}).optional(), profile: interactionProfileGuard.optional(),
verificationRecords: verificationRecordDataGuard.array().optional(), verificationRecords: verificationRecordDataGuard.array().optional(),
}) satisfies ToZodObject<InteractionStorage>; }) satisfies ToZodObject<InteractionStorage>;
@ -39,6 +43,7 @@ const interactionStorageGuard = z.object({
*/ */
export default class ExperienceInteraction { export default class ExperienceInteraction {
public readonly signInExperienceValidator: SignInExperienceValidator; public readonly signInExperienceValidator: SignInExperienceValidator;
public readonly profileValidator: ProfileValidator;
/** The user verification record list for the current interaction. */ /** The user verification record list for the current interaction. */
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>(); private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
@ -65,6 +70,7 @@ export default class ExperienceInteraction {
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries); this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
if (!interactionDetails) { if (!interactionDetails) {
this.profileValidator = new ProfileValidator(libraries, queries);
return; return;
} }
@ -82,6 +88,9 @@ export default class ExperienceInteraction {
this.userId = userId; 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);
for (const record of verificationRecords) { for (const record of verificationRecords) {
const instance = buildVerificationRecord(libraries, queries, record); const instance = buildVerificationRecord(libraries, queries, record);
this.verificationRecords.set(instance.type, instance); this.verificationRecords.set(instance.type, instance);
@ -123,16 +132,16 @@ export default class ExperienceInteraction {
* Identify the user using the verification record. * Identify the user using the verification record.
* *
* - Check if the verification record exists. * - Check if the verification record exists.
* - Verify the verification record with `SignInExperienceValidator`. * - Verify the verification record with {@link SignInExperienceValidator}.
* - Create a new user using the verification record if the current interaction event is `Register`. * - Create a new user using the verification record if the current interaction event is `Register`.
* - Identify the user using the verification record if the current interaction event is `SignIn` or `ForgotPassword`. * - Identify the user using the verification record if the current interaction event is `SignIn` or `ForgotPassword`.
* - Set the user id to the current interaction. * - Set the user id to the current interaction.
* *
* @throws RequestError with 404 if the interaction event is not set * @throws RequestError with 404 if the interaction event is not set.
* @throws RequestError with 404 if the verification record is not found * @throws RequestError with 404 if the verification record is not found.
* @throws RequestError with 400 if the verification record is not valid for the current interaction event * @throws RequestError with 422 if the verification record is not enabled in the SIE settings.
* @throws RequestError with 401 if the user is suspended * @see {@link identifyExistingUser} for more exceptions that can be thrown in the SignIn and ForgotPassword events.
* @throws RequestError with 409 if the current session has already identified a different user * @see {@link createNewUser} for more exceptions that can be thrown in the Register event.
**/ **/
public async identifyUser(verificationId: string) { public async identifyUser(verificationId: string) {
const verificationRecord = this.getVerificationRecordById(verificationId); const verificationRecord = this.getVerificationRecordById(verificationId);
@ -196,9 +205,20 @@ export default class ExperienceInteraction {
/** Submit the current interaction result to the OIDC provider and clear the interaction data */ /** Submit the current interaction result to the OIDC provider and clear the interaction data */
public async submit() { public async submit() {
// TODO: refine the error code
assertThat(this.userId, 'session.verification_session_not_found'); assertThat(this.userId, 'session.verification_session_not_found');
// TODO: mfa validation
// TODO: missing profile fields validation
const {
queries: { users: userQueries },
} = this.tenant;
// Update user profile
await userQueries.updateUserById(this.userId, {
lastSignInAt: Date.now(),
});
const { provider } = this.tenant; const { provider } = this.tenant;
const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, { const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
@ -224,13 +244,22 @@ export default class ExperienceInteraction {
return [...this.verificationRecords.values()]; return [...this.verificationRecords.values()];
} }
/**
* Identify the existing user using the verification record.
*
* @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) { private async identifyExistingUser(verificationRecord: VerificationRecord) {
switch (verificationRecord.type) { // Check verification record can be used to identify a user using the `identifyUser` method.
case VerificationType.Password: // E.g. MFA verification record does not have the `identifyUser` method, cannot be used to identify a user.
case VerificationType.VerificationCode: assertThat(
case VerificationType.Social: 'identifyUser' in verificationRecord,
case VerificationType.EnterpriseSso: { new RequestError({ code: 'session.verification_failed', status: 400 })
// TODO: social sign-in with verified email );
const { id, isSuspended } = await verificationRecord.identifyUser(); const { id, isSuspended } = await verificationRecord.identifyUser();
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
@ -245,25 +274,41 @@ export default class ExperienceInteraction {
} }
this.userId = id; this.userId = id;
break;
}
default: {
// Unsupported verification type for identification, such as MFA verification.
throw new RequestError({ code: 'session.verification_failed', status: 400 });
}
}
} }
private async createNewUser(verificationRecord: VerificationRecord) { private async createNewUser(verificationRecord: VerificationRecord) {
// TODO: To be implemented const {
switch (verificationRecord.type) { libraries: {
case VerificationType.VerificationCode: { users: { generateUserId, insertUser },
break; },
} queries: { userSsoIdentities: userSsoIdentitiesQueries },
default: { } = this.tenant;
// Unsupported verification type for user creation, such as MFA verification.
throw new RequestError({ code: 'session.verification_failed', status: 400 }); const newProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
}
await this.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
// TODO: new user provisioning and hooks
const { socialIdentity, enterpriseSsoIdentity, ...rest } = newProfile;
const [user] = await insertUser(
{
id: await generateUserId(),
...rest,
...conditional(socialIdentity && { identities: toUserSocialIdentityData(socialIdentity) }),
},
[]
);
if (enterpriseSsoIdentity) {
await userSsoIdentitiesQueries.insert({
id: generateStandardId(),
userId: user.id,
...enterpriseSsoIdentity,
});
} }
this.userId = user.id;
} }
} }

View file

@ -1,7 +1,17 @@
import { SignInIdentifier, type InteractionIdentifier } from '@logto/schemas'; import {
SignInIdentifier,
VerificationType,
type InteractionIdentifier,
type User,
} from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import type { InteractionProfile } from '../types.js';
import { type VerificationRecord } from './verifications/index.js';
export const findUserByIdentifier = async ( export const findUserByIdentifier = async (
userQuery: Queries['users'], userQuery: Queries['users'],
{ type, value }: InteractionIdentifier { type, value }: InteractionIdentifier
@ -18,3 +28,43 @@ export const findUserByIdentifier = async (
} }
} }
}; };
/**
* @throws {RequestError} -400 if the verification record type is not supported for user creation.
* @throws {RequestError} -400 if the verification record is not verified.
*/
export const getNewUserProfileFromVerificationRecord = async (
verificationRecord: VerificationRecord
): Promise<InteractionProfile> => {
switch (verificationRecord.type) {
case VerificationType.VerificationCode: {
return verificationRecord.toUserProfile();
}
case VerificationType.EnterpriseSso:
case VerificationType.Social: {
const identityProfile = await verificationRecord.toUserProfile();
const syncedProfile = await verificationRecord.toSyncedProfile(true);
return { ...identityProfile, ...syncedProfile };
}
default: {
// Unsupported verification type for user creation, such as MFA verification.
throw new RequestError({ code: 'session.verification_failed', status: 400 });
}
}
};
/**
* Convert the interaction profile `socialIdentity` to `User['identities']` data format
*/
export const toUserSocialIdentityData = (
socialIdentity: Required<InteractionProfile>['socialIdentity']
): User['identities'] => {
const { target, userInfo } = socialIdentity;
return {
[target]: {
userId: userInfo.id,
details: userInfo,
},
};
};

View file

@ -0,0 +1,85 @@
import RequestError from '#src/errors/RequestError/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import type { InteractionProfile } from '../../types.js';
export class ProfileValidator {
constructor(
private readonly libraries: Libraries,
private readonly queries: Queries,
/** UserId is required for existing user profile update validation */
private readonly userId?: string
) {}
public async guardProfileUniquenessAcrossUsers(profile: InteractionProfile) {
const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } = this.queries.users;
const { userSsoIdentities } = this.queries;
const { username, primaryEmail, primaryPhone, socialIdentity, enterpriseSsoIdentity } = profile;
if (username) {
assertThat(
!(await hasUser(username)),
new RequestError({
code: 'user.username_already_in_use',
status: 422,
})
);
}
if (primaryEmail) {
assertThat(
!(await hasUserWithEmail(primaryEmail)),
new RequestError({
code: 'user.email_already_in_use',
status: 422,
})
);
}
if (primaryPhone) {
assertThat(
!(await hasUserWithPhone(primaryPhone)),
new RequestError({
code: 'user.phone_already_in_use',
status: 422,
})
);
}
if (socialIdentity) {
const {
target,
userInfo: { id },
} = socialIdentity;
assertThat(
!(await hasUserWithIdentity(target, id)),
new RequestError({
code: 'user.identity_already_in_use',
status: 422,
})
);
}
if (enterpriseSsoIdentity) {
const { issuer, identityId } = enterpriseSsoIdentity;
const userSsoIdentity = await userSsoIdentities.findUserSsoIdentityBySsoIdentityId(
issuer,
identityId
);
assertThat(
!userSsoIdentity,
new RequestError({
code: 'user.identity_already_in_use',
status: 422,
})
);
}
// TODO: Password validation
}
}

View file

@ -18,7 +18,7 @@ import assertThat from '#src/utils/assert-that.js';
import { findUserByIdentifier } from '../utils.js'; import { findUserByIdentifier } from '../utils.js';
import { type VerificationRecord } from './verification-record.js'; import { type IdentifierVerificationRecord } from './verification-record.js';
const eventToTemplateTypeMap: Record<InteractionEvent, TemplateType> = { const eventToTemplateTypeMap: Record<InteractionEvent, TemplateType> = {
SignIn: TemplateType.SignIn, SignIn: TemplateType.SignIn,
@ -67,7 +67,9 @@ const getPasscodeIdentifierPayload = (
* *
* To avoid the redundant naming, the `CodeVerification` is used instead of `VerificationCodeVerification`. * To avoid the redundant naming, the `CodeVerification` is used instead of `VerificationCodeVerification`.
*/ */
export class CodeVerification implements VerificationRecord<VerificationType.VerificationCode> { export class CodeVerification
implements IdentifierVerificationRecord<VerificationType.VerificationCode>
{
/** /**
* Factory method to create a new CodeVerification record using the given identifier. * Factory method to create a new CodeVerification record using the given identifier.
*/ */
@ -164,10 +166,6 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
this.verified = true; this.verified = true;
} }
/**
* Identify the user by the current `identifier`.
* Return undefined if the verification record is not verified or no user is found by the identifier.
*/
async identifyUser(): Promise<User> { async identifyUser(): Promise<User> {
assertThat( assertThat(
this.verified, this.verified,
@ -189,6 +187,17 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
return user; return user;
} }
async toUserProfile(): Promise<{ primaryEmail: string } | { primaryPhone: string }> {
assertThat(
this.verified,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
const { type, value } = this.identifier;
return type === 'email' ? { primaryEmail: value } : { primaryPhone: value };
}
toJson(): CodeVerificationRecordData { toJson(): CodeVerificationRecordData {
const { id, type, identifier, interactionEvent, verified } = this; const { id, type, identifier, interactionEvent, verified } = this;

View file

@ -21,7 +21,9 @@ import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { type VerificationRecord } from './verification-record.js'; import type { InteractionProfile } from '../../types.js';
import { type IdentifierVerificationRecord } from './verification-record.js';
/** The JSON data type for the EnterpriseSsoVerification record stored in the interaction storage */ /** The JSON data type for the EnterpriseSsoVerification record stored in the interaction storage */
export type EnterpriseSsoVerificationRecordData = { export type EnterpriseSsoVerificationRecordData = {
@ -44,7 +46,7 @@ export const enterPriseSsoVerificationRecordDataGuard = z.object({
}) satisfies ToZodObject<EnterpriseSsoVerificationRecordData>; }) satisfies ToZodObject<EnterpriseSsoVerificationRecordData>;
export class EnterpriseSsoVerification export class EnterpriseSsoVerification
implements VerificationRecord<VerificationType.EnterpriseSso> implements IdentifierVerificationRecord<VerificationType.EnterpriseSso>
{ {
static create(libraries: Libraries, queries: Queries, connectorId: string) { static create(libraries: Libraries, queries: Queries, connectorId: string) {
return new EnterpriseSsoVerification(libraries, queries, { return new EnterpriseSsoVerification(libraries, queries, {
@ -81,8 +83,10 @@ export class EnterpriseSsoVerification
return Boolean(this.enterpriseSsoUserInfo && this.issuer); return Boolean(this.enterpriseSsoUserInfo && this.issuer);
} }
async getConnectorData(connectorId: string) { async getConnectorData() {
this.connectorDataCache ||= await this.libraries.ssoConnectors.getSsoConnectorById(connectorId); this.connectorDataCache ||= await this.libraries.ssoConnectors.getSsoConnectorById(
this.connectorId
);
return this.connectorDataCache; return this.connectorDataCache;
} }
@ -104,7 +108,7 @@ export class EnterpriseSsoVerification
tenantContext: TenantContext, tenantContext: TenantContext,
payload: SocialAuthorizationUrlPayload payload: SocialAuthorizationUrlPayload
) { ) {
const connectorData = await this.getConnectorData(this.connectorId); const connectorData = await this.getConnectorData();
return getSsoAuthorizationUrl(ctx, tenantContext, connectorData, payload); return getSsoAuthorizationUrl(ctx, tenantContext, connectorData, payload);
} }
@ -117,7 +121,7 @@ export class EnterpriseSsoVerification
* See the above {@link createAuthorizationUrl} method for more details. * See the above {@link createAuthorizationUrl} method for more details.
*/ */
async verify(ctx: WithLogContext, tenantContext: TenantContext, callbackData: JsonObject) { async verify(ctx: WithLogContext, tenantContext: TenantContext, callbackData: JsonObject) {
const connectorData = await this.getConnectorData(this.connectorId); const connectorData = await this.getConnectorData();
const { issuer, userInfo } = await verifySsoIdentity( const { issuer, userInfo } = await verifySsoIdentity(
ctx, ctx,
tenantContext, tenantContext,
@ -129,10 +133,14 @@ export class EnterpriseSsoVerification
this.enterpriseSsoUserInfo = userInfo; this.enterpriseSsoUserInfo = userInfo;
} }
/**
* Identify the user by the enterprise SSO identity.
* If the user is not found, find the related user by the enterprise SSO identity and return the related user.
*/
async identifyUser(): Promise<User> { async identifyUser(): Promise<User> {
assertThat( assertThat(
this.isVerified, this.isVerified,
new RequestError({ code: 'session.verification_failed', status: 422 }) new RequestError({ code: 'session.verification_failed', status: 400 })
); );
// TODO: sync userInfo and link sso identity // TODO: sync userInfo and link sso identity
@ -152,6 +160,49 @@ export class EnterpriseSsoVerification
throw new RequestError({ code: 'user.identity_not_exist', status: 404 }); throw new RequestError({ code: 'user.identity_not_exist', status: 404 });
} }
/**
* Returns the use SSO identity as a new user profile.
*/
async toUserProfile(): Promise<Required<Pick<InteractionProfile, 'enterpriseSsoIdentity'>>> {
assertThat(
this.enterpriseSsoUserInfo && this.issuer,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
return {
enterpriseSsoIdentity: {
issuer: this.issuer,
ssoConnectorId: this.connectorId,
identityId: this.enterpriseSsoUserInfo.id,
detail: this.enterpriseSsoUserInfo,
},
};
}
/**
* 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.
*/
async toSyncedProfile(
isNewUser = false
): Promise<Pick<InteractionProfile, 'avatar' | 'name' | 'primaryEmail'>> {
assertThat(
this.enterpriseSsoUserInfo && this.issuer,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
const { name, avatar, email: primaryEmail } = this.enterpriseSsoUserInfo;
if (isNewUser) {
return { name, avatar, primaryEmail };
}
const { syncProfile } = await this.getConnectorData();
return syncProfile ? { name, avatar } : {};
}
toJson(): EnterpriseSsoVerificationRecordData { toJson(): EnterpriseSsoVerificationRecordData {
const { id, connectorId, type, enterpriseSsoUserInfo, issuer } = this; const { id, connectorId, type, enterpriseSsoUserInfo, issuer } = this;

View file

@ -15,7 +15,7 @@ import assertThat from '#src/utils/assert-that.js';
import { findUserByIdentifier } from '../utils.js'; import { findUserByIdentifier } from '../utils.js';
import { type VerificationRecord } from './verification-record.js'; import { type IdentifierVerificationRecord } from './verification-record.js';
export type PasswordVerificationRecordData = { export type PasswordVerificationRecordData = {
id: string; id: string;
@ -31,7 +31,9 @@ export const passwordVerificationRecordDataGuard = z.object({
verified: z.boolean(), verified: z.boolean(),
}) satisfies ToZodObject<PasswordVerificationRecordData>; }) satisfies ToZodObject<PasswordVerificationRecordData>;
export class PasswordVerification implements VerificationRecord<VerificationType.Password> { export class PasswordVerification
implements IdentifierVerificationRecord<VerificationType.Password>
{
/** Factory method to create a new `PasswordVerification` record using an identifier */ /** Factory method to create a new `PasswordVerification` record using an identifier */
static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) { static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) {
return new PasswordVerification(libraries, queries, { return new PasswordVerification(libraries, queries, {
@ -89,7 +91,6 @@ export class PasswordVerification implements VerificationRecord<VerificationType
return user; return user;
} }
/** Identifies the user using the username */
async identifyUser(): Promise<User> { async identifyUser(): Promise<User> {
assertThat( assertThat(
this.verified, this.verified,
@ -98,7 +99,15 @@ export class PasswordVerification implements VerificationRecord<VerificationType
const user = await findUserByIdentifier(this.queries.users, this.identifier); const user = await findUserByIdentifier(this.queries.users, this.identifier);
assertThat(user, new RequestError({ code: 'user.user_not_exist', status: 404 })); assertThat(
user,
new RequestError(
{ code: 'user.user_not_exist', status: 404 },
{
identifier: this.identifier.value,
}
)
);
return user; return user;
} }

View file

@ -18,8 +18,11 @@ import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { type LogtoConnector } from '#src/utils/connectors/types.js';
import { type VerificationRecord } from './verification-record.js'; import type { InteractionProfile } from '../../types.js';
import { type IdentifierVerificationRecord } from './verification-record.js';
/** The JSON data type for the SocialVerification record stored in the interaction storage */ /** The JSON data type for the SocialVerification record stored in the interaction storage */
export type SocialVerificationRecordData = { export type SocialVerificationRecordData = {
@ -39,7 +42,7 @@ export const socialVerificationRecordDataGuard = z.object({
socialUserInfo: socialUserInfoGuard.optional(), socialUserInfo: socialUserInfoGuard.optional(),
}) satisfies ToZodObject<SocialVerificationRecordData>; }) satisfies ToZodObject<SocialVerificationRecordData>;
export class SocialVerification implements VerificationRecord<VerificationType.Social> { export class SocialVerification implements IdentifierVerificationRecord<VerificationType.Social> {
/** /**
* Factory method to create a new SocialVerification instance * Factory method to create a new SocialVerification instance
*/ */
@ -56,6 +59,8 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
public readonly connectorId: string; public readonly connectorId: string;
public socialUserInfo?: SocialUserInfo; public socialUserInfo?: SocialUserInfo;
private connectorDataCache?: LogtoConnector;
constructor( constructor(
private readonly libraries: Libraries, private readonly libraries: Libraries,
private readonly queries: Queries, private readonly queries: Queries,
@ -130,7 +135,7 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
async identifyUser(): Promise<User> { async identifyUser(): Promise<User> {
assertThat( assertThat(
this.isVerified, this.isVerified,
new RequestError({ code: 'session.verification_failed', status: 422 }) new RequestError({ code: 'session.verification_failed', status: 400 })
); );
// TODO: sync userInfo and link social identity // TODO: sync userInfo and link social identity
@ -143,7 +148,7 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
throw new RequestError( throw new RequestError(
{ {
code: 'user.identity_not_exist', code: 'user.identity_not_exist',
status: 422, status: 404,
}, },
{ {
...(relatedUser && { relatedUser: relatedUser[0] }), ...(relatedUser && { relatedUser: relatedUser[0] }),
@ -154,6 +159,53 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
return user; return user;
} }
/**
* Returns the social identity as a new user profile.
*/
async toUserProfile(): Promise<Required<Pick<InteractionProfile, 'socialIdentity'>>> {
assertThat(
this.socialUserInfo,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
const {
metadata: { target },
} = await this.getConnectorData();
return {
socialIdentity: {
target,
userInfo: this.socialUserInfo,
},
};
}
/**
* 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.
*/
async toSyncedProfile(
isNewUser = false
): Promise<Pick<InteractionProfile, 'avatar' | 'name' | 'primaryEmail'>> {
assertThat(
this.socialUserInfo,
new RequestError({ code: 'session.verification_failed', status: 400 })
);
const { name, avatar, email: primaryEmail } = this.socialUserInfo;
if (isNewUser) {
return { name, avatar, primaryEmail };
}
const {
dbEntry: { syncProfile },
} = await this.getConnectorData();
return syncProfile ? { name, avatar } : {};
}
toJson(): SocialVerificationRecordData { toJson(): SocialVerificationRecordData {
const { id, connectorId, type, socialUserInfo } = this; const { id, connectorId, type, socialUserInfo } = this;
@ -166,7 +218,6 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
} }
private async findUserBySocialIdentity(): Promise<User | undefined> { private async findUserBySocialIdentity(): Promise<User | undefined> {
const { socials } = this.libraries;
const { const {
users: { findUserByIdentity }, users: { findUserByIdentity },
} = this.queries; } = this.queries;
@ -177,7 +228,7 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
const { const {
metadata: { target }, metadata: { target },
} = await socials.getConnector(this.connectorId); } = await this.getConnectorData();
const user = await findUserByIdentity(target, this.socialUserInfo.id); const user = await findUserByIdentity(target, this.socialUserInfo.id);
@ -198,4 +249,12 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
return socials.findSocialRelatedUser(this.socialUserInfo); return socials.findSocialRelatedUser(this.socialUserInfo);
} }
private async getConnectorData() {
const { getConnector } = this.libraries.socials;
this.connectorDataCache ||= await getConnector(this.connectorId);
return this.connectorDataCache;
}
} }

View file

@ -1,4 +1,4 @@
import type { VerificationType } from '@logto/schemas'; import { type User, type VerificationType } from '@logto/schemas';
type Data<T> = { type Data<T> = {
id: string; id: string;
@ -17,3 +17,21 @@ export abstract class VerificationRecord<
abstract toJson(): Json; abstract toJson(): Json;
} }
type IdentifierVerificationType =
| VerificationType.VerificationCode
| VerificationType.Password
| VerificationType.Social
| VerificationType.EnterpriseSso;
/**
* The abstract class for all identifier verification records.
*
* - A `identifyUser` method must be provided to identify the user associated with the verification record.
*/
export abstract class IdentifierVerificationRecord<
T extends VerificationType = IdentifierVerificationType,
Json extends Data<T> = Data<T>,
> extends VerificationRecord<T, Json> {
abstract identifyUser(): Promise<User>;
}

View file

@ -79,7 +79,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
body: z.object({ body: z.object({
interactionEvent: z.nativeEnum(InteractionEvent), interactionEvent: z.nativeEnum(InteractionEvent),
}), }),
status: [204, 403], status: [204, 400, 403],
}), }),
async (ctx, next) => { async (ctx, next) => {
const { interactionEvent } = ctx.guard.body; const { interactionEvent } = ctx.guard.body;
@ -107,7 +107,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
experienceRoutes.identification, experienceRoutes.identification,
koaGuard({ koaGuard({
body: identificationApiPayloadGuard, body: identificationApiPayloadGuard,
status: [204, 400, 401, 404, 409], status: [201, 204, 400, 401, 404, 409, 422],
}), }),
async (ctx, next) => { async (ctx, next) => {
const { verificationId } = ctx.guard.body; const { verificationId } = ctx.guard.body;

View file

@ -1,3 +1,53 @@
import { type SocialUserInfo, socialUserInfoGuard, type ToZodObject } from '@logto/connector-kit';
import { type CreateUser, Users, UserSsoIdentities, type UserSsoIdentity } from '@logto/schemas';
import type Provider from 'oidc-provider'; import type Provider from 'oidc-provider';
import { z } from 'zod';
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>; export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
export type InteractionProfile = {
socialIdentity?: {
target: string;
userInfo: SocialUserInfo;
};
enterpriseSsoIdentity?: Pick<
UserSsoIdentity,
'identityId' | 'ssoConnectorId' | 'issuer' | 'detail'
>;
} & Pick<
CreateUser,
| 'avatar'
| 'name'
| 'username'
| 'primaryEmail'
| 'primaryPhone'
| 'passwordEncrypted'
| 'passwordEncryptionMethod'
>;
export const interactionProfileGuard = Users.createGuard
.pick({
avatar: true,
name: true,
username: true,
primaryEmail: true,
primaryPhone: true,
passwordEncrypted: true,
passwordEncryptionMethod: true,
})
.extend({
socialIdentity: z
.object({
target: z.string(),
userInfo: socialUserInfoGuard,
})
.optional(),
enterpriseSsoIdentity: UserSsoIdentities.guard
.pick({
identityId: true,
ssoConnectorId: true,
issuer: true,
detail: true,
})
.optional(),
}) satisfies ToZodObject<InteractionProfile>;