mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core,schemas): implement the sie settings guard (#6215)
* feat(core,schemas): implement the sie settings guard implement the sie settings guard * fix(test): fix integration test fix integration test * test(core): add sie guard ut add sie guard ut * chore(core): add some comment add some comment * refactor(core): rename the sign-in-experience-settings class rename the sign-in-experience-settings class
This commit is contained in:
parent
f6901f591c
commit
5bae495cc9
20 changed files with 882 additions and 155 deletions
|
@ -9,7 +9,7 @@ import assertThat from '#src/utils/assert-that.js';
|
|||
|
||||
import type { Interaction } from '../types.js';
|
||||
|
||||
import { validateSieVerificationMethod } from './utils.js';
|
||||
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
|
||||
import {
|
||||
buildVerificationRecord,
|
||||
verificationRecordDataGuard,
|
||||
|
@ -38,6 +38,8 @@ const interactionStorageGuard = z.object({
|
|||
* @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004.
|
||||
*/
|
||||
export default class ExperienceInteraction {
|
||||
public readonly signInExperienceValidator: SignInExperienceValidator;
|
||||
|
||||
/** The user verification record list for the current interaction. */
|
||||
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
|
||||
/** The userId of the user for the current interaction. Only available once the user is identified. */
|
||||
|
@ -60,12 +62,15 @@ export default class ExperienceInteraction {
|
|||
) {
|
||||
const { libraries, queries } = tenant;
|
||||
|
||||
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
|
||||
|
||||
if (!interactionDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = interactionStorageGuard.safeParse(interactionDetails.result ?? {});
|
||||
|
||||
// `interactionDetails.result` is not a valid experience interaction storage
|
||||
assertThat(
|
||||
result.success,
|
||||
new RequestError({ code: 'session.interaction_not_found', status: 404 })
|
||||
|
@ -91,9 +96,26 @@ export default class ExperienceInteraction {
|
|||
return this.#interactionEvent;
|
||||
}
|
||||
|
||||
/** Set the interaction event for the current interaction */
|
||||
public setInteractionEvent(interactionEvent: InteractionEvent) {
|
||||
// TODO: conflict event check (e.g. reset password session can't be used for sign in)
|
||||
/**
|
||||
* Set the interaction event for the current interaction
|
||||
*
|
||||
* @throws RequestError with 403 if the interaction event is not allowed by the `SignInExperienceValidator`
|
||||
* @throws RequestError with 400 if the interaction event is `ForgotPassword` and the current interaction event is not `ForgotPassword`
|
||||
* @throws RequestError with 400 if the interaction event is not `ForgotPassword` and the current interaction event is `ForgotPassword`
|
||||
*/
|
||||
public async setInteractionEvent(interactionEvent: InteractionEvent) {
|
||||
await this.signInExperienceValidator.guardInteractionEvent(interactionEvent);
|
||||
|
||||
// `ForgotPassword` interaction event can not interchanged with other events
|
||||
if (this.interactionEvent) {
|
||||
assertThat(
|
||||
interactionEvent === InteractionEvent.ForgotPassword
|
||||
? this.interactionEvent === InteractionEvent.ForgotPassword
|
||||
: this.interactionEvent !== InteractionEvent.ForgotPassword,
|
||||
new RequestError({ code: 'session.not_supported_for_forgot_password', status: 400 })
|
||||
);
|
||||
}
|
||||
|
||||
this.#interactionEvent = interactionEvent;
|
||||
}
|
||||
|
||||
|
@ -101,13 +123,13 @@ export default class ExperienceInteraction {
|
|||
* Identify the user using the verification record.
|
||||
*
|
||||
* - Check if the verification record exists.
|
||||
* - Check if the verification record is valid for the current interaction event.
|
||||
* - Verify the verification record with `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 verification record is not found
|
||||
* @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
|
||||
|
@ -116,47 +138,26 @@ export default class ExperienceInteraction {
|
|||
const verificationRecord = this.getVerificationRecordById(verificationId);
|
||||
|
||||
assertThat(
|
||||
verificationRecord && this.interactionEvent,
|
||||
this.interactionEvent,
|
||||
new RequestError({ code: 'session.interaction_not_found', status: 404 })
|
||||
);
|
||||
|
||||
assertThat(
|
||||
verificationRecord,
|
||||
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
|
||||
);
|
||||
|
||||
// Existing user identification flow
|
||||
validateSieVerificationMethod(this.interactionEvent, verificationRecord);
|
||||
await this.signInExperienceValidator.verifyIdentificationMethod(
|
||||
this.interactionEvent,
|
||||
verificationRecord
|
||||
);
|
||||
|
||||
// User creation flow
|
||||
if (this.interactionEvent === InteractionEvent.Register) {
|
||||
this.createNewUser(verificationRecord);
|
||||
await this.createNewUser(verificationRecord);
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||
|
||||
// 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;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Unsupported verification type for identification, such as MFA verification.
|
||||
throw new RequestError({ code: 'session.verification_failed', status: 400 });
|
||||
}
|
||||
}
|
||||
await this.identifyExistingUser(verificationRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -223,7 +224,46 @@ export default class ExperienceInteraction {
|
|||
return [...this.verificationRecords.values()];
|
||||
}
|
||||
|
||||
private createNewUser(verificationRecord: VerificationRecord) {
|
||||
// TODO: create new user for the Register event
|
||||
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();
|
||||
|
||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||
|
||||
// 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;
|
||||
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) {
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,62 +1,20 @@
|
|||
import {
|
||||
InteractionEvent,
|
||||
InteractionIdentifierType,
|
||||
VerificationType,
|
||||
type InteractionIdentifier,
|
||||
} from '@logto/schemas';
|
||||
import { SignInIdentifier, type InteractionIdentifier } from '@logto/schemas';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { type VerificationRecord } from './verifications/index.js';
|
||||
|
||||
export const findUserByIdentifier = async (
|
||||
userQuery: Queries['users'],
|
||||
{ type, value }: InteractionIdentifier
|
||||
) => {
|
||||
switch (type) {
|
||||
case InteractionIdentifierType.Username: {
|
||||
case SignInIdentifier.Username: {
|
||||
return userQuery.findUserByUsername(value);
|
||||
}
|
||||
case InteractionIdentifierType.Email: {
|
||||
case SignInIdentifier.Email: {
|
||||
return userQuery.findUserByEmail(value);
|
||||
}
|
||||
case InteractionIdentifierType.Phone: {
|
||||
case SignInIdentifier.Phone: {
|
||||
return userQuery.findUserByPhone(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the verification record is valid for the current interaction event.
|
||||
*
|
||||
* This function will compare the verification record for the current interaction event with Logto's SIE settings
|
||||
*
|
||||
* @throws RequestError with 400 if the verification record is not valid for the current interaction event
|
||||
*/
|
||||
export const validateSieVerificationMethod = (
|
||||
interactionEvent: InteractionEvent,
|
||||
verificationRecord: VerificationRecord
|
||||
) => {
|
||||
switch (interactionEvent) {
|
||||
case InteractionEvent.SignIn: {
|
||||
// TODO: sign-in methods validation
|
||||
break;
|
||||
}
|
||||
case InteractionEvent.Register: {
|
||||
// TODO: sign-up methods validation
|
||||
break;
|
||||
}
|
||||
case InteractionEvent.ForgotPassword: {
|
||||
// Forgot password only supports verification code type verification record
|
||||
// The verification record's interaction event must be ForgotPassword
|
||||
assertThat(
|
||||
verificationRecord.type === VerificationType.VerificationCode &&
|
||||
verificationRecord.interactionEvent === InteractionEvent.ForgotPassword,
|
||||
new RequestError({ code: 'session.verification_session_not_found', status: 400 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,500 @@
|
|||
/* eslint-disable max-lines */
|
||||
import {
|
||||
InteractionEvent,
|
||||
type SignInExperience,
|
||||
SignInIdentifier,
|
||||
SignInMode,
|
||||
VerificationType,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
|
||||
import { CodeVerification } from '../verifications/code-verification.js';
|
||||
import { EnterpriseSsoVerification } from '../verifications/enterprise-sso-verification.js';
|
||||
import { type VerificationRecord } from '../verifications/index.js';
|
||||
import { PasswordVerification } from '../verifications/password-verification.js';
|
||||
import { SocialVerification } from '../verifications/social-verification.js';
|
||||
|
||||
import { SignInExperienceValidator } from './sign-in-experience-validator.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const emailDomain = 'logto.io';
|
||||
|
||||
const signInExperiences = {
|
||||
findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
|
||||
};
|
||||
const ssoConnectors = {
|
||||
getAvailableSsoConnectors: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const mockTenant = new MockTenant(undefined, { signInExperiences }, undefined, { ssoConnectors });
|
||||
|
||||
const passwordVerificationRecords = Object.fromEntries(
|
||||
Object.values(SignInIdentifier).map((identifier) => [
|
||||
identifier,
|
||||
PasswordVerification.create(mockTenant.libraries, mockTenant.queries, {
|
||||
type: identifier,
|
||||
value: identifier === SignInIdentifier.Email ? `foo@${emailDomain}` : 'value',
|
||||
}),
|
||||
])
|
||||
) as Record<SignInIdentifier, PasswordVerification>;
|
||||
|
||||
const verificationCodeVerificationRecords = Object.freeze({
|
||||
[SignInIdentifier.Email]: CodeVerification.create(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries,
|
||||
{
|
||||
type: SignInIdentifier.Email,
|
||||
value: `foo@${emailDomain}`,
|
||||
},
|
||||
InteractionEvent.SignIn
|
||||
),
|
||||
[SignInIdentifier.Phone]: CodeVerification.create(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries,
|
||||
{
|
||||
type: SignInIdentifier.Phone,
|
||||
value: 'value',
|
||||
},
|
||||
InteractionEvent.SignIn
|
||||
),
|
||||
});
|
||||
|
||||
const enterpriseSsoVerificationRecords = EnterpriseSsoVerification.create(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries,
|
||||
'mock_connector_id'
|
||||
);
|
||||
|
||||
const socialVerificationRecord = new SocialVerification(mockTenant.libraries, mockTenant.queries, {
|
||||
id: 'social_verification_id',
|
||||
type: VerificationType.Social,
|
||||
connectorId: 'mock_connector_id',
|
||||
socialUserInfo: {
|
||||
id: 'user_id',
|
||||
email: `foo@${emailDomain}`,
|
||||
},
|
||||
});
|
||||
|
||||
describe('SignInExperienceValidator', () => {
|
||||
describe('guardInteractionEvent', () => {
|
||||
it('SignInMode.Register', async () => {
|
||||
const signInExperience = {
|
||||
signInMode: SignInMode.Register,
|
||||
};
|
||||
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience);
|
||||
|
||||
const signInExperienceSettings = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.SignIn)
|
||||
).rejects.toMatchError(new RequestError({ code: 'auth.forbidden', status: 403 }));
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.Register)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.ForgotPassword)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('SignInMode.SignIn', async () => {
|
||||
const signInExperience = {
|
||||
signInMode: SignInMode.SignIn,
|
||||
};
|
||||
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience);
|
||||
|
||||
const signInExperienceSettings = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.Register)
|
||||
).rejects.toMatchError(new RequestError({ code: 'auth.forbidden', status: 403 }));
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.SignIn)
|
||||
).resolves.not.toThrow();
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.ForgotPassword)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('SignInMode.SignInAndRegister', async () => {
|
||||
const signInExperience = {
|
||||
signInMode: SignInMode.SignInAndRegister,
|
||||
};
|
||||
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience);
|
||||
|
||||
const signInExperienceSettings = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.Register)
|
||||
).resolves.not.toThrow();
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.SignIn)
|
||||
).resolves.not.toThrow();
|
||||
await expect(
|
||||
signInExperienceSettings.guardInteractionEvent(InteractionEvent.ForgotPassword)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyIdentificationMethod (SignIn)', () => {
|
||||
const signInVerificationTestCases: Record<
|
||||
string,
|
||||
{
|
||||
signInExperience: SignInExperience;
|
||||
cases: Array<{ verificationRecord: VerificationRecord; accepted: boolean }>;
|
||||
}
|
||||
> = Object.freeze({
|
||||
'password enabled for all identifiers': {
|
||||
signInExperience: mockSignInExperience,
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: passwordVerificationRecords[SignInIdentifier.Username],
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
verificationRecord: passwordVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
verificationRecord: passwordVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'password disabled for email and phone': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signIn: {
|
||||
methods: mockSignInExperience.signIn.methods.map((method) =>
|
||||
method.identifier === SignInIdentifier.Username
|
||||
? method
|
||||
: { ...method, password: false }
|
||||
),
|
||||
},
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: passwordVerificationRecords[SignInIdentifier.Username],
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
verificationRecord: passwordVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
verificationRecord: passwordVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'verification code enabled for email and phone': {
|
||||
signInExperience: mockSignInExperience,
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'verification code disabled for email and phone': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signIn: {
|
||||
methods: mockSignInExperience.signIn.methods.map((method) =>
|
||||
method.identifier === SignInIdentifier.Username
|
||||
? method
|
||||
: { ...method, verificationCode: false }
|
||||
),
|
||||
},
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'no sign-in methods is enabled': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signIn: {
|
||||
methods: [],
|
||||
},
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: passwordVerificationRecords[SignInIdentifier.Username],
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'single sign-on enabled': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
singleSignOnEnabled: true,
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: enterpriseSsoVerificationRecords,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'single sign-on disabled': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
singleSignOnEnabled: false,
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: enterpriseSsoVerificationRecords,
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe.each(Object.keys(signInVerificationTestCases))(`%s`, (testCase) => {
|
||||
const { signInExperience, cases } = signInVerificationTestCases[testCase]!;
|
||||
|
||||
it.each(cases)('guard verification record %p', async ({ verificationRecord, accepted }) => {
|
||||
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience);
|
||||
|
||||
const signInExperienceSettings = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
await (accepted
|
||||
? expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
verificationRecord
|
||||
)
|
||||
).resolves.not.toThrow()
|
||||
: expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
verificationRecord
|
||||
)
|
||||
).rejects.toMatchError(
|
||||
new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 })
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyIdentificationMethod (Register)', () => {
|
||||
const registerVerificationTestCases: Record<
|
||||
string,
|
||||
{
|
||||
signInExperience: SignInExperience;
|
||||
cases: Array<{ verificationRecord: VerificationRecord; accepted: boolean }>;
|
||||
}
|
||||
> = Object.freeze({
|
||||
'only username is enabled for sign-up': {
|
||||
signInExperience: mockSignInExperience,
|
||||
cases: [
|
||||
// TODO: username password registration
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'email is enabled for sign-up': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: true,
|
||||
verify: true,
|
||||
},
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'email and phone are enabled for sign-up': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
|
||||
password: true,
|
||||
verify: true,
|
||||
},
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'enterprise sso enabled': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
singleSignOnEnabled: true,
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: enterpriseSsoVerificationRecords,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'enterprise sso disabled': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
singleSignOnEnabled: false,
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: enterpriseSsoVerificationRecords,
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe.each(Object.keys(registerVerificationTestCases))(`%s`, (testCase) => {
|
||||
const { signInExperience, cases } = registerVerificationTestCases[testCase]!;
|
||||
|
||||
it.each(cases)('guard verification record %p', async ({ verificationRecord, accepted }) => {
|
||||
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience);
|
||||
|
||||
const signInExperienceSettings = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
await (accepted
|
||||
? expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
InteractionEvent.Register,
|
||||
verificationRecord
|
||||
)
|
||||
).resolves.not.toThrow()
|
||||
: expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
InteractionEvent.Register,
|
||||
verificationRecord
|
||||
)
|
||||
).rejects.toMatchError(
|
||||
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardSsoOnlyEmailIdentifier: identifier with SSO enabled domain should throw', () => {
|
||||
const mockSsoConnector = {
|
||||
domains: [emailDomain],
|
||||
};
|
||||
|
||||
const expectError = new RequestError(
|
||||
{
|
||||
code: 'session.sso_enabled',
|
||||
status: 422,
|
||||
},
|
||||
{
|
||||
ssoConnectors: [mockSsoConnector],
|
||||
}
|
||||
);
|
||||
|
||||
it('email password verification record', async () => {
|
||||
ssoConnectors.getAvailableSsoConnectors.mockResolvedValueOnce([mockSsoConnector]);
|
||||
|
||||
const signInExperienceSettings = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
passwordVerificationRecords[SignInIdentifier.Email]
|
||||
)
|
||||
).rejects.toMatchError(expectError);
|
||||
});
|
||||
|
||||
it('email verification code verification record', async () => {
|
||||
ssoConnectors.getAvailableSsoConnectors.mockResolvedValueOnce([mockSsoConnector]);
|
||||
|
||||
const signInExperienceSettings = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
verificationCodeVerificationRecords[SignInIdentifier.Email]
|
||||
)
|
||||
).rejects.toMatchError(expectError);
|
||||
});
|
||||
|
||||
it('social verification record', async () => {
|
||||
ssoConnectors.getAvailableSsoConnectors.mockResolvedValueOnce([mockSsoConnector]);
|
||||
|
||||
const signInExperienceSettings = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
socialVerificationRecord
|
||||
)
|
||||
).rejects.toMatchError(expectError);
|
||||
});
|
||||
});
|
||||
});
|
||||
/* eslint-enable max-lines */
|
|
@ -0,0 +1,230 @@
|
|||
import {
|
||||
InteractionEvent,
|
||||
type SignInExperience,
|
||||
SignInMode,
|
||||
VerificationType,
|
||||
} from '@logto/schemas';
|
||||
|
||||
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 VerificationRecord } from '../verifications/index.js';
|
||||
|
||||
const getEmailIdentifierFromVerificationRecord = (verificationRecord: VerificationRecord) => {
|
||||
switch (verificationRecord.type) {
|
||||
case VerificationType.Password:
|
||||
case VerificationType.VerificationCode: {
|
||||
const {
|
||||
identifier: { type, value },
|
||||
} = verificationRecord;
|
||||
|
||||
return type === 'email' ? value : undefined;
|
||||
}
|
||||
case VerificationType.Social: {
|
||||
const { socialUserInfo } = verificationRecord;
|
||||
return socialUserInfo?.email;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* SignInExperienceValidator class provides all the sign-in experience settings validation logic.
|
||||
*
|
||||
* - Guard the interaction event based on the sign-in experience settings
|
||||
* - Guard the identification method based on the sign-in experience settings
|
||||
* - Guard the email identifier with SSO enabled domains
|
||||
*/
|
||||
export class SignInExperienceValidator {
|
||||
private signInExperienceDataCache?: SignInExperience;
|
||||
|
||||
constructor(
|
||||
private readonly libraries: Libraries,
|
||||
private readonly queries: Queries
|
||||
) {}
|
||||
|
||||
public async guardInteractionEvent(event: InteractionEvent) {
|
||||
const { signInMode } = await this.getSignInExperienceData();
|
||||
|
||||
switch (event) {
|
||||
case InteractionEvent.SignIn: {
|
||||
assertThat(
|
||||
signInMode !== SignInMode.Register,
|
||||
new RequestError({ code: 'auth.forbidden', status: 403 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
case InteractionEvent.Register: {
|
||||
assertThat(
|
||||
signInMode !== SignInMode.SignIn,
|
||||
new RequestError({ code: 'auth.forbidden', status: 403 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
case InteractionEvent.ForgotPassword: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async verifyIdentificationMethod(
|
||||
event: InteractionEvent,
|
||||
verificationRecord: VerificationRecord
|
||||
) {
|
||||
switch (event) {
|
||||
case InteractionEvent.SignIn: {
|
||||
await this.guardSignInVerificationMethod(verificationRecord);
|
||||
break;
|
||||
}
|
||||
case InteractionEvent.Register: {
|
||||
await this.guardRegisterVerificationMethod(verificationRecord);
|
||||
break;
|
||||
}
|
||||
case InteractionEvent.ForgotPassword: {
|
||||
this.guardForgotPasswordVerificationMethod(verificationRecord);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.guardSsoOnlyEmailIdentifier(verificationRecord);
|
||||
}
|
||||
|
||||
private async getSignInExperienceData() {
|
||||
this.signInExperienceDataCache ||=
|
||||
await this.queries.signInExperiences.findDefaultSignInExperience();
|
||||
|
||||
return this.signInExperienceDataCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard the verification records contains email identifier with SSO enabled
|
||||
*
|
||||
* @remarks
|
||||
* Email identifier with SSO enabled domain will be blocked.
|
||||
* Can only verify/identify via SSO verification record.
|
||||
*
|
||||
* - VerificationCode with email identifier
|
||||
* - Social userinfo with email
|
||||
**/
|
||||
private async guardSsoOnlyEmailIdentifier(verificationRecord: VerificationRecord) {
|
||||
const emailIdentifier = getEmailIdentifierFromVerificationRecord(verificationRecord);
|
||||
|
||||
if (!emailIdentifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = emailIdentifier.split('@')[1];
|
||||
const { singleSignOnEnabled } = await this.getSignInExperienceData();
|
||||
|
||||
if (!singleSignOnEnabled || !domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { getAvailableSsoConnectors } = this.libraries.ssoConnectors;
|
||||
const availableSsoConnectors = await getAvailableSsoConnectors();
|
||||
|
||||
const domainEnabledConnectors = availableSsoConnectors.filter(({ domains }) =>
|
||||
domains.includes(domain)
|
||||
);
|
||||
|
||||
assertThat(
|
||||
domainEnabledConnectors.length === 0,
|
||||
new RequestError(
|
||||
{
|
||||
code: 'session.sso_enabled',
|
||||
status: 422,
|
||||
},
|
||||
{
|
||||
ssoConnectors: domainEnabledConnectors,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async guardSignInVerificationMethod(verificationRecord: VerificationRecord) {
|
||||
const {
|
||||
signIn: { methods: signInMethods },
|
||||
singleSignOnEnabled,
|
||||
} = await this.getSignInExperienceData();
|
||||
|
||||
switch (verificationRecord.type) {
|
||||
case VerificationType.Password:
|
||||
case VerificationType.VerificationCode: {
|
||||
const {
|
||||
identifier: { type },
|
||||
} = verificationRecord;
|
||||
|
||||
assertThat(
|
||||
signInMethods.some(({ identifier: method, password, verificationCode }) => {
|
||||
return (
|
||||
method === type &&
|
||||
(verificationRecord.type === VerificationType.Password ? password : verificationCode)
|
||||
);
|
||||
}),
|
||||
new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case VerificationType.Social: {
|
||||
// No need to verify social verification method
|
||||
break;
|
||||
}
|
||||
case VerificationType.EnterpriseSso: {
|
||||
assertThat(
|
||||
singleSignOnEnabled,
|
||||
new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async guardRegisterVerificationMethod(verificationRecord: VerificationRecord) {
|
||||
const { signUp, singleSignOnEnabled } = await this.getSignInExperienceData();
|
||||
|
||||
switch (verificationRecord.type) {
|
||||
// TODO: username password registration
|
||||
case VerificationType.VerificationCode: {
|
||||
const {
|
||||
identifier: { type },
|
||||
} = verificationRecord;
|
||||
|
||||
assertThat(
|
||||
signUp.identifiers.includes(type) && signUp.verify,
|
||||
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
case VerificationType.Social: {
|
||||
// No need to verify social verification method
|
||||
break;
|
||||
}
|
||||
case VerificationType.EnterpriseSso: {
|
||||
assertThat(
|
||||
singleSignOnEnabled,
|
||||
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgot password only supports verification code type verification record */
|
||||
private guardForgotPasswordVerificationMethod(verificationRecord: VerificationRecord) {
|
||||
assertThat(
|
||||
verificationRecord.type === VerificationType.VerificationCode,
|
||||
new RequestError({ code: 'session.not_supported_for_forgot_password', status: 422 })
|
||||
);
|
||||
}
|
||||
}
|
|
@ -70,9 +70,8 @@ const getPasscodeIdentifierPayload = (
|
|||
export class CodeVerification implements VerificationRecord<VerificationType.VerificationCode> {
|
||||
/**
|
||||
* Factory method to create a new CodeVerification record using the given identifier.
|
||||
* The sendVerificationCode method will be automatically triggered.
|
||||
*/
|
||||
static async create(
|
||||
static create(
|
||||
libraries: Libraries,
|
||||
queries: Queries,
|
||||
identifier: VerificationCodeIdentifier,
|
||||
|
@ -86,8 +85,6 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
|
|||
verified: false,
|
||||
});
|
||||
|
||||
await record.sendVerificationCode();
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
|
@ -123,6 +120,25 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
|
|||
return this.verified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the verification code to the current `identifier`
|
||||
*
|
||||
* @remark Instead of session jti,
|
||||
* the verification id is used as `interaction_jti` to uniquely identify the passcode record in DB
|
||||
* for the current interaction.
|
||||
*/
|
||||
async sendVerificationCode() {
|
||||
const { createPasscode, sendPasscode } = this.libraries.passcodes;
|
||||
|
||||
const verificationCode = await createPasscode(
|
||||
this.id,
|
||||
getTemplateTypeByEvent(this.interactionEvent),
|
||||
getPasscodeIdentifierPayload(this.identifier)
|
||||
);
|
||||
|
||||
await sendPasscode(verificationCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the `identifier` with the given code
|
||||
*
|
||||
|
@ -184,23 +200,4 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
|
|||
verified,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the verification code to the current `identifier`
|
||||
*
|
||||
* @remark Instead of session jti,
|
||||
* the verification id is used as `interaction_jti` to uniquely identify the passcode record in DB
|
||||
* for the current interaction.
|
||||
*/
|
||||
private async sendVerificationCode() {
|
||||
const { createPasscode, sendPasscode } = this.libraries.passcodes;
|
||||
|
||||
const verificationCode = await createPasscode(
|
||||
this.id,
|
||||
getTemplateTypeByEvent(this.interactionEvent),
|
||||
getPasscodeIdentifierPayload(this.identifier)
|
||||
);
|
||||
|
||||
await sendPasscode(verificationCode);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
body: z.object({
|
||||
interactionEvent: z.nativeEnum(InteractionEvent),
|
||||
}),
|
||||
status: [204],
|
||||
status: [204, 403],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { interactionEvent } = ctx.guard.body;
|
||||
|
@ -61,7 +61,8 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
createLog(`Interaction.${interactionEvent}.Update`);
|
||||
|
||||
const experienceInteraction = new ExperienceInteraction(ctx, tenant);
|
||||
experienceInteraction.setInteractionEvent(interactionEvent);
|
||||
|
||||
await experienceInteraction.setInteractionEvent(interactionEvent);
|
||||
|
||||
await experienceInteraction.save();
|
||||
|
||||
|
@ -78,7 +79,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
body: z.object({
|
||||
interactionEvent: z.nativeEnum(InteractionEvent),
|
||||
}),
|
||||
status: [204],
|
||||
status: [204, 403],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { interactionEvent } = ctx.guard.body;
|
||||
|
@ -88,7 +89,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
`Interaction.${experienceInteraction.interactionEvent ?? interactionEvent}.Update`
|
||||
);
|
||||
|
||||
experienceInteraction.setInteractionEvent(interactionEvent);
|
||||
await experienceInteraction.setInteractionEvent(interactionEvent);
|
||||
|
||||
eventLog.append({
|
||||
interactionEvent,
|
||||
|
@ -106,16 +107,18 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
experienceRoutes.identification,
|
||||
koaGuard({
|
||||
body: identificationApiPayloadGuard,
|
||||
status: [204, 400, 401, 404],
|
||||
status: [204, 400, 401, 404, 409],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { verificationId } = ctx.guard.body;
|
||||
const { experienceInteraction } = ctx;
|
||||
|
||||
await ctx.experienceInteraction.identifyUser(verificationId);
|
||||
await experienceInteraction.identifyUser(verificationId);
|
||||
|
||||
await ctx.experienceInteraction.save();
|
||||
await experienceInteraction.save();
|
||||
|
||||
ctx.status = 204;
|
||||
// Return 201 if a new user is created
|
||||
ctx.status = experienceInteraction.interactionEvent === InteractionEvent.Register ? 201 : 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ export default function backupCodeVerificationRoutes<T extends WithLogContext>(
|
|||
const { experienceInteraction } = ctx;
|
||||
const { code } = ctx.guard.body;
|
||||
|
||||
assertThat(experienceInteraction.identifiedUserId, 'session.not_identified');
|
||||
assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found');
|
||||
|
||||
// TODO: Check if the MFA is enabled
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ export default function totpVerificationRoutes<T extends WithLogContext>(
|
|||
async (ctx, next) => {
|
||||
const { experienceInteraction } = ctx;
|
||||
|
||||
assertThat(experienceInteraction.identifiedUserId, 'session.not_identified');
|
||||
assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found');
|
||||
|
||||
// TODO: Check if the MFA is enabled
|
||||
// TODO: Check if the interaction is fully verified
|
||||
|
@ -71,7 +71,7 @@ export default function totpVerificationRoutes<T extends WithLogContext>(
|
|||
const { experienceInteraction } = ctx;
|
||||
const { verificationId, code } = ctx.guard.body;
|
||||
|
||||
assertThat(experienceInteraction.identifiedUserId, 'session.not_identified');
|
||||
assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found');
|
||||
|
||||
// Verify new generated secret
|
||||
if (verificationId) {
|
||||
|
|
|
@ -36,13 +36,15 @@ export default function verificationCodeRoutes<T extends WithLogContext>(
|
|||
async (ctx, next) => {
|
||||
const { identifier, interactionEvent } = ctx.guard.body;
|
||||
|
||||
const codeVerification = await CodeVerification.create(
|
||||
const codeVerification = CodeVerification.create(
|
||||
libraries,
|
||||
queries,
|
||||
identifier,
|
||||
interactionEvent
|
||||
);
|
||||
|
||||
await codeVerification.sendVerificationCode();
|
||||
|
||||
ctx.experienceInteraction.setVerificationRecord(codeVerification);
|
||||
|
||||
await ctx.experienceInteraction.save();
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import {
|
||||
InteractionEvent,
|
||||
InteractionIdentifierType,
|
||||
SignInIdentifier,
|
||||
type InteractionIdentifier,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
|
@ -86,7 +86,7 @@ export const identifyUserWithUsernamePassword = async (
|
|||
|
||||
const { verificationId } = await client.verifyPassword({
|
||||
identifier: {
|
||||
type: InteractionIdentifierType.Username,
|
||||
type: SignInIdentifier.Username,
|
||||
value: username,
|
||||
},
|
||||
password,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { InteractionEvent, InteractionIdentifierType } from '@logto/schemas';
|
||||
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { initExperienceClient } from '#src/helpers/client.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
|
@ -19,7 +19,7 @@ devFeatureTest.describe('PUT /experience API', () => {
|
|||
const client = await initExperienceClient();
|
||||
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
|
||||
const { verificationId } = await client.verifyPassword({
|
||||
identifier: { type: InteractionIdentifierType.Username, value: username },
|
||||
identifier: { type: SignInIdentifier.Username, value: username },
|
||||
password,
|
||||
});
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { InteractionIdentifierType } from '@logto/schemas';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { signInWithPassword } from '#src/helpers/experience/index.js';
|
||||
|
@ -17,7 +17,7 @@ devFeatureTest.describe('sign-in with password verification happy path', () => {
|
|||
await enableAllPasswordSignInMethods();
|
||||
});
|
||||
|
||||
it.each(Object.values(InteractionIdentifierType))(
|
||||
it.each(Object.values(SignInIdentifier))(
|
||||
'should sign-in with password using %p',
|
||||
async (identifier) => {
|
||||
const { userProfile, user } = await generateNewUser({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { InteractionIdentifierType } from '@logto/schemas';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js';
|
||||
|
@ -7,10 +7,8 @@ import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-exp
|
|||
import { generateNewUser } from '#src/helpers/user.js';
|
||||
import { devFeatureTest } from '#src/utils.js';
|
||||
|
||||
const verificationIdentifierType: readonly [
|
||||
InteractionIdentifierType.Email,
|
||||
InteractionIdentifierType.Phone,
|
||||
] = Object.freeze([InteractionIdentifierType.Email, InteractionIdentifierType.Phone]);
|
||||
const verificationIdentifierType: readonly [SignInIdentifier.Email, SignInIdentifier.Phone] =
|
||||
Object.freeze([SignInIdentifier.Email, SignInIdentifier.Phone]);
|
||||
|
||||
const identifiersTypeToUserProfile = Object.freeze({
|
||||
email: 'primaryEmail',
|
||||
|
|
|
@ -25,7 +25,7 @@ devFeatureTest.describe('backup code verification APIs', () => {
|
|||
const client = await initExperienceClient();
|
||||
|
||||
await expectRejects(client.verifyBackupCode({ code: '1234' }), {
|
||||
code: 'session.not_identified',
|
||||
code: 'session.identifier_not_found',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { InteractionIdentifierType } from '@logto/schemas';
|
||||
import { SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { initExperienceClient } from '#src/helpers/client.js';
|
||||
|
@ -12,7 +12,7 @@ const identifiersTypeToUserProfile = Object.freeze({
|
|||
});
|
||||
|
||||
devFeatureTest.describe('password verifications', () => {
|
||||
it.each(Object.values(InteractionIdentifierType))(
|
||||
it.each(Object.values(SignInIdentifier))(
|
||||
'should verify with password successfully using %p',
|
||||
async (identifier) => {
|
||||
const { userProfile, user } = await generateNewUser({
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import { InteractionEvent, InteractionIdentifierType } from '@logto/schemas';
|
||||
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { mockEmailConnectorId, mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js';
|
||||
import { initExperienceClient } from '#src/helpers/client.js';
|
||||
|
@ -134,7 +134,7 @@ devFeatureTest.describe('social verification', () => {
|
|||
|
||||
const { verificationId } = await client.sendVerificationCode({
|
||||
identifier: {
|
||||
type: InteractionIdentifierType.Email,
|
||||
type: SignInIdentifier.Email,
|
||||
value: 'foo',
|
||||
},
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
|
|
|
@ -31,7 +31,7 @@ devFeatureTest.describe('TOTP verification APIs', () => {
|
|||
const client = await initExperienceClient();
|
||||
|
||||
await expectRejects(client.createTotpSecret(), {
|
||||
code: 'session.not_identified',
|
||||
code: 'session.identifier_not_found',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
@ -50,7 +50,7 @@ devFeatureTest.describe('TOTP verification APIs', () => {
|
|||
const client = await initExperienceClient();
|
||||
|
||||
await expectRejects(client.verifyTotp({ code: '1234' }), {
|
||||
code: 'session.not_identified',
|
||||
code: 'session.identifier_not_found',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
@ -107,7 +107,7 @@ devFeatureTest.describe('TOTP verification APIs', () => {
|
|||
const client = await initExperienceClient();
|
||||
|
||||
await expectRejects(client.verifyTotp({ code: '1234' }), {
|
||||
code: 'session.not_identified',
|
||||
code: 'session.identifier_not_found',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import {
|
||||
InteractionEvent,
|
||||
InteractionIdentifierType,
|
||||
SignInIdentifier,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
|
||||
|
@ -25,11 +25,11 @@ devFeatureTest.describe('Verification code verification APIs', () => {
|
|||
|
||||
const identifiers: VerificationCodeIdentifier[] = [
|
||||
{
|
||||
type: InteractionIdentifierType.Email,
|
||||
type: SignInIdentifier.Email,
|
||||
value: 'foo@logto.io',
|
||||
},
|
||||
{
|
||||
type: InteractionIdentifierType.Phone,
|
||||
type: SignInIdentifier.Phone,
|
||||
value: '+1234567890',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -23,7 +23,6 @@ const session = {
|
|||
interaction_not_found:
|
||||
'Interaction session not found. Please go back and start the session again.',
|
||||
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
|
||||
not_identified: 'User not identified. Please sign in first.',
|
||||
identity_conflict:
|
||||
'Identity mismatch detected. Please initiate a new session to proceed with a different identity.',
|
||||
mfa: {
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MfaFactor, jsonObjectGuard, webAuthnTransportGuard } from '../foundations/index.js';
|
||||
import {
|
||||
MfaFactor,
|
||||
SignInIdentifier,
|
||||
jsonObjectGuard,
|
||||
webAuthnTransportGuard,
|
||||
} from '../foundations/index.js';
|
||||
import { type ToZodObject } from '../utils/zod.js';
|
||||
|
||||
import type {
|
||||
|
@ -24,31 +29,26 @@ export enum InteractionEvent {
|
|||
}
|
||||
|
||||
// ====== Experience API payload guards and type definitions start ======
|
||||
export enum InteractionIdentifierType {
|
||||
Username = 'username',
|
||||
Email = 'email',
|
||||
Phone = 'phone',
|
||||
}
|
||||
|
||||
/** Identifiers that can be used to uniquely identify a user. */
|
||||
export type InteractionIdentifier = {
|
||||
type: InteractionIdentifierType;
|
||||
type: SignInIdentifier;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const interactionIdentifierGuard = z.object({
|
||||
type: z.nativeEnum(InteractionIdentifierType),
|
||||
type: z.nativeEnum(SignInIdentifier),
|
||||
value: z.string(),
|
||||
}) satisfies ToZodObject<InteractionIdentifier>;
|
||||
|
||||
/** Currently only email and phone are supported for verification code validation. */
|
||||
export type VerificationCodeIdentifier = {
|
||||
type: InteractionIdentifierType.Email | InteractionIdentifierType.Phone;
|
||||
type: SignInIdentifier.Email | SignInIdentifier.Phone;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const verificationCodeIdentifierGuard = z.object({
|
||||
type: z.enum([InteractionIdentifierType.Email, InteractionIdentifierType.Phone]),
|
||||
type: z.enum([SignInIdentifier.Email, SignInIdentifier.Phone]),
|
||||
value: z.string(),
|
||||
}) satisfies ToZodObject<VerificationCodeIdentifier>;
|
||||
|
||||
|
|
Loading…
Reference in a new issue