0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core,schemas): implement the 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:
simeng-li 2024-07-15 09:53:50 +08:00 committed by GitHub
parent f6901f591c
commit 5bae495cc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 882 additions and 155 deletions

View file

@ -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 });
}
}
}
}

View file

@ -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;
}
}
};

View file

@ -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 */

View file

@ -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 })
);
}
}

View file

@ -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);
}
}

View file

@ -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();
}

View file

@ -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

View file

@ -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) {

View file

@ -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();

View file

@ -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,

View file

@ -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,
});

View file

@ -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({

View file

@ -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',

View file

@ -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,
});
});

View file

@ -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({

View file

@ -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,

View file

@ -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,
});
});

View file

@ -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',
},
];

View file

@ -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: {

View file

@ -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>;