Skip to content

Commit c655458

Browse files
authored
refactor(core): make SSO connector domains case insensitive (#8011)
* refactor(core): make SSO connector domains case insencitive make SSO connector domains case insencitiv * chore: add changeset add changeset * fix(core): fix integration tests fix integration tests * fix(core): fix integration tests fix integration tests * fix(cloud): fix integration test fix integration test
1 parent cb99315 commit c655458

File tree

6 files changed

+89
-19
lines changed

6 files changed

+89
-19
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@logto/core": patch
3+
---
4+
5+
improve SSO connectors with case-insensitive domain matching
6+
7+
According to the latest standards, email domains should be treated as case-insensitive. To ensure robust and user-friendly authentication, we need to locate SSO connectors correctly regardless of the letter case in the provided email domain.
8+
9+
- Domain normalization on insert: The domains configured for SSO connectors are now normalized to lowercase before being inserted into the database. This ensures consistency and prevents issues arising from varied casing. As part of this change, identical domains with different casing will be treated as duplicates and rejected to maintain data integrity.
10+
11+
- Case-insensitive search for SSO connectors: The get SSO connectors by email endpoint has been updated to perform a case-insensitive search when matching email domains. This guarantees that the correct enabled SSO connector is identified, regardless of the casing used in the user's email address.

packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,10 @@ export class SignInExperienceValidator {
157157
const { getAvailableSsoConnectors } = this.libraries.ssoConnectors;
158158
const availableSsoConnectors = await getAvailableSsoConnectors();
159159

160-
return availableSsoConnectors.filter(({ domains }) => domains.includes(domain));
160+
return availableSsoConnectors.filter(({ domains }) => {
161+
const normalizedDomains = domains.map((item) => item.toLowerCase());
162+
return normalizedDomains.includes(domain.toLowerCase());
163+
});
161164
}
162165

163166
public async getMfaSettings() {

packages/core/src/routes/sso-connector/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export default function singleSignOnConnectorsRoutes<T extends ManagementApiRout
8686
}),
8787
async (ctx, next) => {
8888
const { body } = ctx.guard;
89-
const { providerName, connectorName, config, domains, ...rest } = body;
89+
const { providerName, connectorName, config, domains: rawDomains, ...rest } = body;
9090

9191
// Return 422 if the connector provider is not supported
9292
if (!isSupportedSsoProvider(providerName)) {
@@ -97,6 +97,9 @@ export default function singleSignOnConnectorsRoutes<T extends ManagementApiRout
9797
});
9898
}
9999

100+
// Normalize domains to lowercase for consistency
101+
const domains = rawDomains?.map((domain) => domain.toLowerCase());
102+
100103
// Validate the connector domains if it's provided
101104
if (domains) {
102105
validateConnectorDomains(domains);
@@ -268,9 +271,12 @@ export default function singleSignOnConnectorsRoutes<T extends ManagementApiRout
268271

269272
const originalConnector = await getSsoConnectorById(id);
270273
const { providerName } = originalConnector;
271-
const { config, domains, ...rest } = body;
274+
const { config, domains: rawDomains, ...rest } = body;
272275
const { providerType } = ssoConnectorFactories[providerName];
273276

277+
// Normalize domains to lowercase for consistency
278+
const domains = rawDomains?.map((domain) => domain.toLowerCase());
279+
274280
// Validate the connector domains if it's provided
275281
if (domains) {
276282
validateConnectorDomains(domains);
@@ -324,7 +330,7 @@ export default function singleSignOnConnectorsRoutes<T extends ManagementApiRout
324330
}
325331

326332
// Check if there's any valid update
327-
const hasValidUpdate = parsedConfig ?? Object.keys(rest).length > 0;
333+
const hasValidUpdate = parsedConfig ?? domains ?? Object.keys(rest).length > 0;
328334

329335
// Patch update the connector only if there's any valid update
330336
const connector = hasValidUpdate

packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/enterprise-sso.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ import { generateEmail, generatePassword } from '#src/utils.js';
2626

2727
describe('enterprise sso sign-in and sign-up', () => {
2828
const ssoConnectorApi = new SsoConnectorApi();
29-
const domain = 'foo.com';
29+
// Use a random domain that contains uppercase letters to test the case-insensitivity of email domain matching.
30+
const domain = 'Foo.com';
3031
const enterpriseSsoIdentityId = generateStandardId();
3132
const email = generateEmail(domain);
3233
const userApi = new UserApiTest();
@@ -192,16 +193,16 @@ describe('enterprise sso sign-in and sign-up', () => {
192193

193194
describe('should block email identifier from non-enterprise sso verifications if the SSO is enabled', () => {
194195
const password = generatePassword();
195-
const email = generateEmail(domain);
196-
const identifier = Object.freeze({ type: SignInIdentifier.Email, value: email });
197196

198197
beforeAll(async () => {
199198
await Promise.all([setEmailConnector(), setSmsConnector()]);
200199
await enableAllVerificationCodeSignInMethods();
201-
await userApi.create({ primaryEmail: email, password });
202200
});
203201

204202
it('should reject when trying to sign-in with email verification code', async () => {
203+
// Test with lowercase domain to ensure the domain matching is case-insensitive
204+
const email = generateEmail(domain.toLocaleLowerCase());
205+
const identifier = Object.freeze({ type: SignInIdentifier.Email, value: email });
205206
const client = await initExperienceClient();
206207

207208
const { verificationId, code } = await successfullySendVerificationCode(client, {
@@ -228,6 +229,10 @@ describe('enterprise sso sign-in and sign-up', () => {
228229

229230
it('should reject when trying to sign-in with email password', async () => {
230231
const client = await initExperienceClient();
232+
// Test with uppercase domain to ensure the domain matching is case-insensitive
233+
const email = generateEmail(domain.toUpperCase());
234+
const identifier = Object.freeze({ type: SignInIdentifier.Email, value: email });
235+
const user = await userApi.create({ primaryEmail: email, password });
231236

232237
const { verificationId } = await client.verifyPassword({
233238
identifier,
@@ -243,6 +248,8 @@ describe('enterprise sso sign-in and sign-up', () => {
243248
status: 422,
244249
}
245250
);
251+
252+
await deleteUser(user.id);
246253
});
247254
});
248255
});

packages/integration-tests/src/tests/api/experience-api/verifications/enterprise-sso-verification.test.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,11 @@ describe('enterprise sso verification', () => {
202202

203203
describe('getSsoConnectorsByEmail', () => {
204204
const ssoConnectorApi = new SsoConnectorApi();
205-
const domain = `foo${randomString()}.com`;
205+
const domain = `foo-${randomString()}.com`;
206+
const uppercaseDomain = `BAR-${randomString().toUpperCase()}.COM`;
206207

207208
beforeAll(async () => {
208-
await ssoConnectorApi.createMockOidcConnector([domain]);
209+
await ssoConnectorApi.createMockOidcConnector([domain, uppercaseDomain]);
209210

210211
await updateSignInExperience({
211212
singleSignOnEnabled: true,
@@ -216,15 +217,26 @@ describe('enterprise sso verification', () => {
216217
await ssoConnectorApi.cleanUp();
217218
});
218219

219-
it('should get sso connectors with given email properly', async () => {
220-
const client = new ExperienceClient();
221-
await client.initSession();
222-
223-
const response = await client.getAvailableSsoConnectors('bar@' + domain);
224-
225-
expect(response.connectorIds.length).toBeGreaterThan(0);
226-
expect(response.connectorIds[0]).toBe(ssoConnectorApi.firstConnectorId);
227-
});
220+
const testEmailDomains = [
221+
domain,
222+
domain.toUpperCase(),
223+
domain.toLocaleLowerCase(),
224+
uppercaseDomain,
225+
uppercaseDomain.toLowerCase(),
226+
];
227+
228+
it.each(testEmailDomains)(
229+
'should match sso connectors for email domain: %s',
230+
async (emailDomain) => {
231+
const client = new ExperienceClient();
232+
await client.initSession();
233+
234+
const response = await client.getAvailableSsoConnectors('bar@' + emailDomain);
235+
236+
expect(response.connectorIds.length).toBeGreaterThan(0);
237+
expect(response.connectorIds[0]).toBe(ssoConnectorApi.firstConnectorId);
238+
}
239+
);
228240

229241
it('should return empty array if no sso connectors found', async () => {
230242
const client = new ExperienceClient();

packages/integration-tests/src/tests/api/sso-connectors/sso-connectors.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ describe('post sso-connectors', () => {
109109
})
110110
).rejects.toThrow(HTTPError);
111111
});
112+
113+
it('should store domains in lowercase', async () => {
114+
const domains = ['Test.COM', 'Example.com', 'foo.org', 'BAR.net'];
115+
116+
const connector = await createSsoConnector({
117+
providerName: SsoProviderName.OIDC,
118+
connectorName: 'domains-test-OIDC-connector',
119+
domains,
120+
});
121+
122+
expect(connector.domains).toEqual(domains.map((domain) => domain.toLowerCase()));
123+
124+
await deleteSsoConnectorById(connector.id);
125+
});
112126
});
113127

114128
describe('get sso-connectors', () => {
@@ -310,4 +324,21 @@ describe('patch sso-connector by id', () => {
310324
await deleteSsoConnectorById(id);
311325
}
312326
);
327+
328+
it('should store domains in lowercase when patching', async () => {
329+
const { id } = await createSsoConnector({
330+
providerName: SsoProviderName.OIDC,
331+
connectorName: 'domains-test-OIDC-connector',
332+
});
333+
334+
const domains = ['Test.COM', 'Example.com', 'foo.org', 'BAR.net'];
335+
336+
const connector = await patchSsoConnectorById(id, {
337+
domains,
338+
});
339+
340+
expect(connector.domains).toEqual(domains.map((domain) => domain.toLowerCase()));
341+
342+
await deleteSsoConnectorById(connector.id);
343+
});
313344
});

0 commit comments

Comments
 (0)