Skip to content

Commit

Permalink
feat(core,schemas): implement the sie settings guard
Browse files Browse the repository at this point in the history
implement the sie settings guard
  • Loading branch information
simeng-li committed Jul 11, 2024
1 parent 69fa072 commit c7096f8
Show file tree
Hide file tree
Showing 7 changed files with 325 additions and 123 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import assertThat from '#src/utils/assert-that.js';

import type { Interaction } from '../types.js';

import { validateSieVerificationMethod } from './utils.js';
import { SignInExperienceSettings } from './sign-in-experience-settings.js';
import {
buildVerificationRecord,
verificationRecordDataGuard,
Expand Down Expand Up @@ -46,6 +46,7 @@ export default class ExperienceInteraction {
private userId?: string;
/** The user provided profile data in the current interaction that needs to be stored to database. */
private readonly profile?: Record<string, unknown>; // TODO: Fix the type
private readonly signInExperienceSettings: SignInExperienceSettings;

/**
* Create a new `ExperienceInteraction` instance.
Expand All @@ -60,12 +61,15 @@ export default class ExperienceInteraction {
) {
const { libraries, queries } = tenant;

this.signInExperienceSettings = new SignInExperienceSettings(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 })
Expand Down Expand Up @@ -101,13 +105,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 `SignInExperienceSettings`.
* - 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
Expand All @@ -116,47 +120,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.signInExperienceSettings.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);
}

/**
Expand Down Expand Up @@ -223,7 +206,45 @@ 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) {
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 });
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
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 forbiddenMethodError = () =>
new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 });

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

export class SignInExperienceSettings {
private signInExperienceDataCache?: SignInExperience;

constructor(
private readonly libraries: Libraries,
private readonly queries: Queries
) {}

async verifyIdentificationMethod(
event: InteractionEvent,
verificationRecord: VerificationRecord
) {
await this.guardInteractionEvent(event);

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

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)
);
}),
forbiddenMethodError()
);
break;
}

case VerificationType.Social: {
// No need to verify social verification method
break;
}
case VerificationType.EnterpriseSso: {
assertThat(singleSignOnEnabled, forbiddenMethodError());
break;
}
default: {
throw forbiddenMethodError();
}
}
}

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, forbiddenMethodError());
break;
}
case VerificationType.Social: {
// No need to verify social verification method
break;
}
case VerificationType.EnterpriseSso: {
assertThat(singleSignOnEnabled, forbiddenMethodError());
break;
}
default: {
throw forbiddenMethodError();
}
}
}

/** Forgot password only supports verification code type verification record */
private guardForgotPasswordVerificationMethod(verificationRecord: VerificationRecord) {
assertThat(
verificationRecord.type === VerificationType.VerificationCode,
forbiddenMethodError()
);
}
}
Loading

0 comments on commit c7096f8

Please sign in to comment.