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 { 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,46 +244,71 @@ 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();
|
|
||||||
|
|
||||||
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
|
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||||
if (this.userId) {
|
|
||||||
assertThat(
|
|
||||||
this.userId === id,
|
|
||||||
new RequestError({ code: 'session.identity_conflict', status: 409 })
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.userId = id;
|
// Throws an 409 error if the current session has already identified a different user
|
||||||
break;
|
if (this.userId) {
|
||||||
}
|
assertThat(
|
||||||
default: {
|
this.userId === id,
|
||||||
// Unsupported verification type for identification, such as MFA verification.
|
new RequestError({ code: 'session.identity_conflict', status: 409 })
|
||||||
throw new RequestError({ code: 'session.verification_failed', status: 400 });
|
);
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.userId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -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 { 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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
Loading…
Reference in a new issue