Skip to content

Commit 49e482f

Browse files
authored
feat(core,schemas,console): add email blocklist policy to sie (#7355)
* feat(core,schemas,console): add email blocklist policy to sie add email blocklist policy to sie * refactor(core): deduplicate custom block list deduplicate custom block list * chore(test): update ut update ut * fix(test): remove outdated tests remove outdateted tests
1 parent f66af87 commit 49e482f

File tree

13 files changed

+178
-20
lines changed

13 files changed

+178
-20
lines changed

packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export const sieFormDataParser = {
122122
mfa,
123123
captchaPolicy,
124124
sentinelPolicy,
125+
emailBlocklistPolicy,
125126
// End: Remove the omitted fields from the data
126127
...rest
127128
} = data;
@@ -161,10 +162,11 @@ export const sieFormDataParser = {
161162
* Affected fields:
162163
* - `signUp.secondaryIdentifiers`: This field is optional in the data schema,
163164
* but through the form, we always fill it with an empty array.
164-
* - `mfa`: This field is omitted in the sign-in experience form.
165-
* - `passwordPolicy`: This field is omitted in the sign-in experience form.
166-
* - `captchaPolicy`: This field is omitted in the sign-in experience form.
167-
* - `sentinelPolicy`: This field is omitted in the sign-in experience form.
165+
* - `mfa`
166+
* - `passwordPolicy`
167+
* - `captchaPolicy`
168+
* - `sentinelPolicy`
169+
* - `emailBlocklistPolicy`
168170
*/
169171
export const signInExperienceToUpdatedDataParser = (
170172
data: SignInExperience
@@ -176,6 +178,7 @@ export const signInExperienceToUpdatedDataParser = (
176178
passwordPolicy,
177179
captchaPolicy,
178180
sentinelPolicy,
181+
emailBlocklistPolicy,
179182
// End: Remove the omitted fields from the data
180183
...rest
181184
} = data;

packages/console/src/pages/SignInExperience/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import {
66
} from '@logto/schemas';
77

88
/**
9-
* Omit the `mfa`, `captchaPolicy`, 'passwordPolicy', and `sentinelPolicy` fields from the sign-in experience.
9+
* Omit the `mfa`, `captchaPolicy`, 'passwordPolicy', `sentinelPolicy` and `emailBlocklistPolicy` fields from the sign-in experience.
1010
* Since those fields are not managed by the sign-in experience page.
1111
*/
1212
type OmittedSignInExperienceKeys = keyof Pick<
1313
SignInExperience,
14-
'mfa' | 'captchaPolicy' | 'sentinelPolicy' | 'passwordPolicy'
14+
'mfa' | 'captchaPolicy' | 'sentinelPolicy' | 'passwordPolicy' | 'emailBlocklistPolicy'
1515
>;
1616

1717
export enum SignInExperienceTab {
@@ -48,7 +48,7 @@ export type SignUpForm = Omit<SignUp, 'identifiers' | 'secondaryIdentifiers'> &
4848

4949
export type SignInExperienceForm = Omit<
5050
SignInExperience,
51-
'signUp' | 'customCss' | 'passwordPolicy' | OmittedSignInExperienceKeys
51+
'signUp' | 'customCss' | OmittedSignInExperienceKeys
5252
> & {
5353
customCss?: string; // Code editor components can not properly handle null value, manually transform null to undefined instead.
5454
signUp: SignUpForm;

packages/core/src/__mocks__/sign-in-experience.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,5 @@ export const mockSignInExperience: SignInExperience = {
105105
unknownSessionRedirectUrl: null,
106106
captchaPolicy: {},
107107
sentinelPolicy: {},
108+
emailBlocklistPolicy: {},
108109
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { deduplicate } from '@silverhand/essentials';
2+
3+
import RequestError from '../../errors/RequestError/index.js';
4+
5+
import { parseEmailBlocklistPolicy } from './email-blocklist-policy.js';
6+
7+
const invalidCustomBlockList = ['bar', 'bar@foo', '@foo', '@foo.', 'bar@foo.'];
8+
const validCustomBlockList = ['[email protected]', '@foo.com', '[email protected]', '[email protected]'];
9+
10+
describe('validateEmailBlocklistPolicy', () => {
11+
it.each(invalidCustomBlockList)(
12+
'should throw error for invalid custom block list item: %s',
13+
(item) => {
14+
const emailBlocklistPolicy = { customBlocklist: [item] };
15+
expect(() => {
16+
parseEmailBlocklistPolicy(emailBlocklistPolicy);
17+
}).toMatchError(
18+
new RequestError({
19+
code: 'sign_in_experiences.invalid_custom_email_blocklist_format',
20+
items: Array.from([item]),
21+
status: 400,
22+
})
23+
);
24+
}
25+
);
26+
27+
it('should pass the validation with valid format and deduplicate items', () => {
28+
const parsed = parseEmailBlocklistPolicy({ customBlocklist: validCustomBlockList });
29+
expect(parsed).toEqual({ customBlocklist: deduplicate(validCustomBlockList) });
30+
});
31+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { type EmailBlocklistPolicy } from '@logto/schemas';
2+
import { conditional, deduplicate } from '@silverhand/essentials';
3+
4+
import { EnvSet } from '../../env-set/index.js';
5+
import RequestError from '../../errors/RequestError/index.js';
6+
import assertThat from '../../utils/assert-that.js';
7+
8+
const emailOrEmailDomainRegex = /^\S+@\S+\.\S+|^@\S+\.\S+$/;
9+
10+
const validateCustomBlockListFormat = (list: string[]) => {
11+
const invalidItems = new Set();
12+
13+
for (const item of list) {
14+
if (!emailOrEmailDomainRegex.test(item)) {
15+
invalidItems.add(item);
16+
}
17+
}
18+
19+
return invalidItems;
20+
};
21+
22+
const parseCustomBlocklist = (customBlocklist: string[]) => {
23+
const deduplicated = deduplicate(customBlocklist);
24+
const invalidItems = validateCustomBlockListFormat(deduplicated);
25+
26+
if (invalidItems.size > 0) {
27+
throw new RequestError({
28+
code: 'sign_in_experiences.invalid_custom_email_blocklist_format',
29+
items: Array.from(invalidItems),
30+
status: 400,
31+
});
32+
}
33+
34+
return deduplicated;
35+
};
36+
37+
/**
38+
* This function will deduplicate the custom blocklist (if not undefined) and validate the format of each item.
39+
* If any item is invalid, it throws a RequestError with the details of the invalid items.
40+
*/
41+
export const parseEmailBlocklistPolicy = (
42+
emailBlocklistPolicy: EmailBlocklistPolicy
43+
): EmailBlocklistPolicy => {
44+
// TODO: @simeng remove this validation when the feature is ready
45+
assertThat(
46+
EnvSet.values.isDevFeaturesEnabled,
47+
new RequestError('request.invalid_input', {
48+
details: 'Email block list policy is not supported in this environment',
49+
})
50+
);
51+
52+
const { customBlocklist, ...rest } = emailBlocklistPolicy;
53+
54+
return {
55+
...rest,
56+
...conditional(customBlocklist && { customBlocklist: parseCustomBlocklist(customBlocklist) }),
57+
};
58+
};

packages/core/src/libraries/sign-in-experience/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { type CloudConnectionLibrary } from '../cloud-connection.js';
2626

2727
export * from './sign-up.js';
2828
export * from './sign-in.js';
29+
export * from './email-blocklist-policy.js';
2930

3031
export const developmentTenantPlanId = 'dev';
3132

packages/core/src/queries/sign-in-experience.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@ describe('sign-in-experience query', () => {
3838
socialSignIn: JSON.stringify(mockSignInExperience.socialSignIn),
3939
captchaPolicy: JSON.stringify(mockSignInExperience.captchaPolicy),
4040
sentinelPolicy: JSON.stringify(mockSignInExperience.sentinelPolicy),
41+
emailBlocklistPolicy: JSON.stringify(mockSignInExperience.emailBlocklistPolicy),
4142
};
4243

4344
it('findDefaultSignInExperience', async () => {
4445
/* eslint-disable sql/no-unsafe-query */
4546
const expectSql = `
46-
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "agree_to_terms_policy", "sign_in", "sign_up", "social_sign_in", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "custom_ui_assets", "password_policy", "mfa", "single_sign_on_enabled", "support_email", "support_website_url", "unknown_session_redirect_url", "captcha_policy", "sentinel_policy"
47+
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "agree_to_terms_policy", "sign_in", "sign_up", "social_sign_in", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "custom_ui_assets", "password_policy", "mfa", "single_sign_on_enabled", "support_email", "support_website_url", "unknown_session_redirect_url", "captcha_policy", "sentinel_policy", "email_blocklist_policy"
4748
from "sign_in_experiences"
4849
where "id"=$1
4950
`;

packages/core/src/routes/sign-in-experience/index.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { DemoConnector } from '@logto/connector-kit';
22
import { PasswordPolicyChecker } from '@logto/core-kit';
33
import { ConnectorType, SignInExperiences } from '@logto/schemas';
4-
import { tryThat } from '@silverhand/essentials';
4+
import { conditional, tryThat } from '@silverhand/essentials';
55
import { literal, object, string, z } from 'zod';
66

7-
import { validateSignUp, validateSignIn } from '#src/libraries/sign-in-experience/index.js';
7+
import {
8+
validateSignUp,
9+
validateSignIn,
10+
parseEmailBlocklistPolicy,
11+
} from '#src/libraries/sign-in-experience/index.js';
812
import { validateMfa } from '#src/libraries/sign-in-experience/mfa.js';
913
import koaGuard from '#src/middleware/koa-guard.js';
1014

@@ -75,7 +79,7 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
7579
async (ctx, next) => {
7680
const {
7781
query: { removeUnusedDemoSocialConnector },
78-
body: { socialSignInConnectorTargets, ...rest },
82+
body: { socialSignInConnectorTargets, emailBlocklistPolicy, ...rest },
7983
} = ctx.guard;
8084
const { languageInfo, signUp, signIn, mfa, sentinelPolicy, captchaPolicy } = rest;
8185

@@ -119,8 +123,8 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
119123
await quota.guardTenantUsageByKey('securityFeaturesEnabled');
120124
}
121125

122-
// Remove unused demo social connectors, those that are not selected in onboarding SIE config.
123126
if (removeUnusedDemoSocialConnector && filteredSocialSignInConnectorTargets) {
127+
// Remove unused demo social connectors, those that are not selected in onboarding SIE config.
124128
await Promise.all(
125129
connectors
126130
.filter((connector) => {
@@ -134,14 +138,21 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
134138
);
135139
}
136140

137-
ctx.body = await updateDefaultSignInExperience(
138-
filteredSocialSignInConnectorTargets
139-
? {
140-
...rest,
141-
socialSignInConnectorTargets: filteredSocialSignInConnectorTargets,
142-
}
143-
: rest
144-
);
141+
const payload = {
142+
...rest,
143+
...conditional(
144+
filteredSocialSignInConnectorTargets && {
145+
socialSignInConnectorTargets: filteredSocialSignInConnectorTargets,
146+
}
147+
),
148+
...conditional(
149+
emailBlocklistPolicy && {
150+
emailBlocklistPolicy: parseEmailBlocklistPolicy(emailBlocklistPolicy),
151+
}
152+
),
153+
};
154+
155+
ctx.body = await updateDefaultSignInExperience(payload);
145156

146157
void quota.reportSubscriptionUpdatesUsage('mfaEnabled');
147158

packages/experience/src/__mocks__/logto.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export const mockSignInExperience: SignInExperience = {
119119
unknownSessionRedirectUrl: null,
120120
captchaPolicy: {},
121121
sentinelPolicy: {},
122+
emailBlocklistPolicy: {},
122123
};
123124

124125
export const mockSignInExperienceSettings: SignInExperienceResponse = {
@@ -159,6 +160,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
159160
unknownSessionRedirectUrl: null,
160161
captchaPolicy: {},
161162
sentinelPolicy: {},
163+
emailBlocklistPolicy: {},
162164
};
163165

164166
const usernameSettings = {

packages/phrases/src/locales/en/errors/sign-in-experiences.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const sign_in_experiences = {
1919
duplicated_mfa_factors: 'Duplicated MFA factors.',
2020
duplicated_sign_up_identifiers: 'Duplicate sign-up identifiers detected.',
2121
missing_sign_up_identifiers: 'Primary sign-up identifier cannot be empty.',
22+
invalid_custom_email_blocklist_format:
23+
'Invalid custom email blocklist items: {{items, list(type:conjunction)}}. Each item must be a valid email address or email domain, e.g., [email protected] or @example.com.',
2224
};
2325

2426
export default Object.freeze(sign_in_experiences);

0 commit comments

Comments
 (0)