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:
parent
f94fb519f4
commit
84ac935c80
10 changed files with 446 additions and 70 deletions
|
@ -1,5 +1,7 @@
|
|||
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 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 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 {
|
||||
buildVerificationRecord,
|
||||
|
@ -20,14 +24,14 @@ import {
|
|||
type InteractionStorage = {
|
||||
interactionEvent?: InteractionEvent;
|
||||
userId?: string;
|
||||
profile?: Record<string, unknown>;
|
||||
profile?: InteractionProfile;
|
||||
verificationRecords?: VerificationRecordData[];
|
||||
};
|
||||
|
||||
const interactionStorageGuard = z.object({
|
||||
interactionEvent: z.nativeEnum(InteractionEvent).optional(),
|
||||
userId: z.string().optional(),
|
||||
profile: z.object({}).optional(),
|
||||
profile: interactionProfileGuard.optional(),
|
||||
verificationRecords: verificationRecordDataGuard.array().optional(),
|
||||
}) satisfies ToZodObject<InteractionStorage>;
|
||||
|
||||
|
@ -39,6 +43,7 @@ const interactionStorageGuard = z.object({
|
|||
*/
|
||||
export default class ExperienceInteraction {
|
||||
public readonly signInExperienceValidator: SignInExperienceValidator;
|
||||
public readonly profileValidator: ProfileValidator;
|
||||
|
||||
/** The user verification record list for the current interaction. */
|
||||
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
|
||||
|
@ -65,6 +70,7 @@ export default class ExperienceInteraction {
|
|||
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
|
||||
|
||||
if (!interactionDetails) {
|
||||
this.profileValidator = new ProfileValidator(libraries, queries);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -82,6 +88,9 @@ export default class ExperienceInteraction {
|
|||
this.userId = userId;
|
||||
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) {
|
||||
const instance = buildVerificationRecord(libraries, queries, record);
|
||||
this.verificationRecords.set(instance.type, instance);
|
||||
|
@ -123,16 +132,16 @@ export default class ExperienceInteraction {
|
|||
* Identify the user using the verification record.
|
||||
*
|
||||
* - 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`.
|
||||
* - Identify the user using the verification record if the current interaction event is `SignIn` or `ForgotPassword`.
|
||||
* - 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 verification record is not found
|
||||
* @throws RequestError with 400 if the verification record is not valid for the current interaction event
|
||||
* @throws RequestError with 401 if the user is suspended
|
||||
* @throws RequestError with 409 if the current session has already identified a different user
|
||||
* @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 422 if the verification record is not enabled in the SIE settings.
|
||||
* @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) {
|
||||
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 */
|
||||
public async submit() {
|
||||
// TODO: refine the error code
|
||||
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 redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
|
||||
|
@ -224,46 +244,71 @@ export default class ExperienceInteraction {
|
|||
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) {
|
||||
switch (verificationRecord.type) {
|
||||
case VerificationType.Password:
|
||||
case VerificationType.VerificationCode:
|
||||
case VerificationType.Social:
|
||||
case VerificationType.EnterpriseSso: {
|
||||
// TODO: social sign-in with verified email
|
||||
const { id, isSuspended } = await verificationRecord.identifyUser();
|
||||
// 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 })
|
||||
);
|
||||
|
||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||
const { id, isSuspended } = await verificationRecord.identifyUser();
|
||||
|
||||
// Throws an 409 error if the current session has already identified a different user
|
||||
if (this.userId) {
|
||||
assertThat(
|
||||
this.userId === id,
|
||||
new RequestError({ code: 'session.identity_conflict', status: 409 })
|
||||
);
|
||||
return;
|
||||
}
|
||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||
|
||||
this.userId = id;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Unsupported verification type for identification, such as MFA verification.
|
||||
throw new RequestError({ code: 'session.verification_failed', status: 400 });
|
||||
}
|
||||
// Throws an 409 error if the current session has already identified a different user
|
||||
if (this.userId) {
|
||||
assertThat(
|
||||
this.userId === id,
|
||||
new RequestError({ code: 'session.identity_conflict', status: 409 })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.userId = id;
|
||||
}
|
||||
|
||||
private async createNewUser(verificationRecord: VerificationRecord) {
|
||||
// TODO: To be implemented
|
||||
switch (verificationRecord.type) {
|
||||
case VerificationType.VerificationCode: {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Unsupported verification type for user creation, such as MFA verification.
|
||||
throw new RequestError({ code: 'session.verification_failed', status: 400 });
|
||||
}
|
||||
const {
|
||||
libraries: {
|
||||
users: { generateUserId, insertUser },
|
||||
},
|
||||
queries: { userSsoIdentities: userSsoIdentitiesQueries },
|
||||
} = this.tenant;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 { InteractionProfile } from '../types.js';
|
||||
|
||||
import { type VerificationRecord } from './verifications/index.js';
|
||||
|
||||
export const findUserByIdentifier = async (
|
||||
userQuery: Queries['users'],
|
||||
{ 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -18,7 +18,7 @@ import assertThat from '#src/utils/assert-that.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> = {
|
||||
SignIn: TemplateType.SignIn,
|
||||
|
@ -67,7 +67,9 @@ const getPasscodeIdentifierPayload = (
|
|||
*
|
||||
* 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.
|
||||
*/
|
||||
|
@ -164,10 +166,6 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
|
|||
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> {
|
||||
assertThat(
|
||||
this.verified,
|
||||
|
@ -189,6 +187,17 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
|
|||
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 {
|
||||
const { id, type, identifier, interactionEvent, verified } = this;
|
||||
|
||||
|
|
|
@ -21,7 +21,9 @@ import type Queries from '#src/tenants/Queries.js';
|
|||
import type TenantContext from '#src/tenants/TenantContext.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 */
|
||||
export type EnterpriseSsoVerificationRecordData = {
|
||||
|
@ -44,7 +46,7 @@ export const enterPriseSsoVerificationRecordDataGuard = z.object({
|
|||
}) satisfies ToZodObject<EnterpriseSsoVerificationRecordData>;
|
||||
|
||||
export class EnterpriseSsoVerification
|
||||
implements VerificationRecord<VerificationType.EnterpriseSso>
|
||||
implements IdentifierVerificationRecord<VerificationType.EnterpriseSso>
|
||||
{
|
||||
static create(libraries: Libraries, queries: Queries, connectorId: string) {
|
||||
return new EnterpriseSsoVerification(libraries, queries, {
|
||||
|
@ -81,8 +83,10 @@ export class EnterpriseSsoVerification
|
|||
return Boolean(this.enterpriseSsoUserInfo && this.issuer);
|
||||
}
|
||||
|
||||
async getConnectorData(connectorId: string) {
|
||||
this.connectorDataCache ||= await this.libraries.ssoConnectors.getSsoConnectorById(connectorId);
|
||||
async getConnectorData() {
|
||||
this.connectorDataCache ||= await this.libraries.ssoConnectors.getSsoConnectorById(
|
||||
this.connectorId
|
||||
);
|
||||
|
||||
return this.connectorDataCache;
|
||||
}
|
||||
|
@ -104,7 +108,7 @@ export class EnterpriseSsoVerification
|
|||
tenantContext: TenantContext,
|
||||
payload: SocialAuthorizationUrlPayload
|
||||
) {
|
||||
const connectorData = await this.getConnectorData(this.connectorId);
|
||||
const connectorData = await this.getConnectorData();
|
||||
return getSsoAuthorizationUrl(ctx, tenantContext, connectorData, payload);
|
||||
}
|
||||
|
||||
|
@ -117,7 +121,7 @@ export class EnterpriseSsoVerification
|
|||
* See the above {@link createAuthorizationUrl} method for more details.
|
||||
*/
|
||||
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(
|
||||
ctx,
|
||||
tenantContext,
|
||||
|
@ -129,10 +133,14 @@ export class EnterpriseSsoVerification
|
|||
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> {
|
||||
assertThat(
|
||||
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
|
||||
|
@ -152,6 +160,49 @@ export class EnterpriseSsoVerification
|
|||
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 {
|
||||
const { id, connectorId, type, enterpriseSsoUserInfo, issuer } = this;
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ import assertThat from '#src/utils/assert-that.js';
|
|||
|
||||
import { findUserByIdentifier } from '../utils.js';
|
||||
|
||||
import { type VerificationRecord } from './verification-record.js';
|
||||
import { type IdentifierVerificationRecord } from './verification-record.js';
|
||||
|
||||
export type PasswordVerificationRecordData = {
|
||||
id: string;
|
||||
|
@ -31,7 +31,9 @@ export const passwordVerificationRecordDataGuard = z.object({
|
|||
verified: z.boolean(),
|
||||
}) 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 */
|
||||
static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) {
|
||||
return new PasswordVerification(libraries, queries, {
|
||||
|
@ -89,7 +91,6 @@ export class PasswordVerification implements VerificationRecord<VerificationType
|
|||
return user;
|
||||
}
|
||||
|
||||
/** Identifies the user using the username */
|
||||
async identifyUser(): Promise<User> {
|
||||
assertThat(
|
||||
this.verified,
|
||||
|
@ -98,7 +99,15 @@ export class PasswordVerification implements VerificationRecord<VerificationType
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -18,8 +18,11 @@ import type Libraries from '#src/tenants/Libraries.js';
|
|||
import type Queries from '#src/tenants/Queries.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.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 */
|
||||
export type SocialVerificationRecordData = {
|
||||
|
@ -39,7 +42,7 @@ export const socialVerificationRecordDataGuard = z.object({
|
|||
socialUserInfo: socialUserInfoGuard.optional(),
|
||||
}) 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
|
||||
*/
|
||||
|
@ -56,6 +59,8 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
|
|||
public readonly connectorId: string;
|
||||
public socialUserInfo?: SocialUserInfo;
|
||||
|
||||
private connectorDataCache?: LogtoConnector;
|
||||
|
||||
constructor(
|
||||
private readonly libraries: Libraries,
|
||||
private readonly queries: Queries,
|
||||
|
@ -130,7 +135,7 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
|
|||
async identifyUser(): Promise<User> {
|
||||
assertThat(
|
||||
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
|
||||
|
@ -143,7 +148,7 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
|
|||
throw new RequestError(
|
||||
{
|
||||
code: 'user.identity_not_exist',
|
||||
status: 422,
|
||||
status: 404,
|
||||
},
|
||||
{
|
||||
...(relatedUser && { relatedUser: relatedUser[0] }),
|
||||
|
@ -154,6 +159,53 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
|
|||
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 {
|
||||
const { id, connectorId, type, socialUserInfo } = this;
|
||||
|
||||
|
@ -166,7 +218,6 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
|
|||
}
|
||||
|
||||
private async findUserBySocialIdentity(): Promise<User | undefined> {
|
||||
const { socials } = this.libraries;
|
||||
const {
|
||||
users: { findUserByIdentity },
|
||||
} = this.queries;
|
||||
|
@ -177,7 +228,7 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
|
|||
|
||||
const {
|
||||
metadata: { target },
|
||||
} = await socials.getConnector(this.connectorId);
|
||||
} = await this.getConnectorData();
|
||||
|
||||
const user = await findUserByIdentity(target, this.socialUserInfo.id);
|
||||
|
||||
|
@ -198,4 +249,12 @@ export class SocialVerification implements VerificationRecord<VerificationType.S
|
|||
|
||||
return socials.findSocialRelatedUser(this.socialUserInfo);
|
||||
}
|
||||
|
||||
private async getConnectorData() {
|
||||
const { getConnector } = this.libraries.socials;
|
||||
|
||||
this.connectorDataCache ||= await getConnector(this.connectorId);
|
||||
|
||||
return this.connectorDataCache;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { VerificationType } from '@logto/schemas';
|
||||
import { type User, type VerificationType } from '@logto/schemas';
|
||||
|
||||
type Data<T> = {
|
||||
id: string;
|
||||
|
@ -17,3 +17,21 @@ export abstract class VerificationRecord<
|
|||
|
||||
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>;
|
||||
}
|
||||
|
|
|
@ -79,7 +79,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
body: z.object({
|
||||
interactionEvent: z.nativeEnum(InteractionEvent),
|
||||
}),
|
||||
status: [204, 403],
|
||||
status: [204, 400, 403],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { interactionEvent } = ctx.guard.body;
|
||||
|
@ -107,7 +107,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
experienceRoutes.identification,
|
||||
koaGuard({
|
||||
body: identificationApiPayloadGuard,
|
||||
status: [204, 400, 401, 404, 409],
|
||||
status: [201, 204, 400, 401, 404, 409, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { verificationId } = ctx.guard.body;
|
||||
|
|
|
@ -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 { z } from 'zod';
|
||||
|
||||
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>;
|
||||
|
|
Loading…
Reference in a new issue