diff --git a/.changeset/fresh-lies-breathe.md b/.changeset/fresh-lies-breathe.md new file mode 100644 index 000000000..6ede40d19 --- /dev/null +++ b/.changeset/fresh-lies-breathe.md @@ -0,0 +1,6 @@ +--- +"@logto/shared": patch +"@logto/core": patch +--- + +Add case sensitive username env variable diff --git a/packages/core/src/queries/user.test.ts b/packages/core/src/queries/user.test.ts index 81beaad58..ca1a5e941 100644 --- a/packages/core/src/queries/user.test.ts +++ b/packages/core/src/queries/user.test.ts @@ -1,8 +1,10 @@ import { Users } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; +import Sinon from 'sinon'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; import { mockUser } from '#src/__mocks__/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import { DeletionError } from '#src/errors/SlonikError/index.js'; import type { QueryType } from '#src/utils/test-utils.js'; import { expectSqlAssert } from '#src/utils/test-utils.js'; @@ -33,7 +35,17 @@ const { deleteUserIdentity, } = createUserQueries(pool); +const stubIsCaseSensitiveUsername = (isCaseSensitiveUsername: boolean) => + Sinon.stub(EnvSet, 'values').value({ + ...EnvSet.values, + isCaseSensitiveUsername, + }); + describe('user query', () => { + beforeEach(() => { + stubIsCaseSensitiveUsername(true); + }); + const { table, fields } = convertToIdentifiers(Users); const databaseValue = { ...mockUser, @@ -60,6 +72,24 @@ describe('user query', () => { await expect(findUserByUsername(mockUser.username!)).resolves.toEqual(databaseValue); }); + it('findUserByUsername (case insensitive)', async () => { + stubIsCaseSensitiveUsername(false); + const expectSql = sql` + select ${sql.join(Object.values(fields), sql`,`)} + from ${table} + where lower(${fields.username})=lower($1) + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([mockUser.username]); + + return createMockQueryResult([databaseValue]); + }); + + await expect(findUserByUsername(mockUser.username!)).resolves.toEqual(databaseValue); + }); + it('findUserByEmail', async () => { const expectSql = sql` select ${sql.join(Object.values(fields), sql`,`)} @@ -132,6 +162,26 @@ describe('user query', () => { await expect(hasUser(mockUser.username!)).resolves.toEqual(true); }); + it('hasUser (case insensitive)', async () => { + stubIsCaseSensitiveUsername(false); + const expectSql = sql` + SELECT EXISTS( + select ${fields.id} + from ${table} + where lower(${fields.username})=lower($1) + ) + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([mockUser.username]); + + return createMockQueryResult([{ exists: true }]); + }); + + await expect(hasUser(mockUser.username!)).resolves.toEqual(true); + }); + it('hasUserWithId', async () => { const expectSql = sql` SELECT EXISTS( diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 72c3403c4..620095b36 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -7,6 +7,7 @@ import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; +import { EnvSet } from '#src/env-set/index.js'; import { DeletionError } from '#src/errors/SlonikError/index.js'; import type { Search } from '#src/utils/search.js'; import { buildConditionsFromSearch } from '#src/utils/search.js'; @@ -63,7 +64,11 @@ export const createUserQueries = (pool: CommonQueryMethods) => { pool.maybeOne(sql` select ${sql.join(Object.values(fields), sql`,`)} from ${table} - where ${fields.username}=${username} + ${ + EnvSet.values.isCaseSensitiveUsername + ? sql`where ${fields.username}=${username}` + : sql`where lower(${fields.username})=lower(${username})` + } `); const findUserByEmail = async (email: string) => @@ -100,7 +105,11 @@ export const createUserQueries = (pool: CommonQueryMethods) => { pool.exists(sql` select ${fields.id} from ${table} - where ${fields.username}=${username} + ${ + EnvSet.values.isCaseSensitiveUsername + ? sql`where ${fields.username}=${username}` + : sql`where lower(${fields.username})=lower(${username})` + } ${conditionalSql(excludeUserId, (id) => sql`and ${fields.id}<>${id}`)} `); diff --git a/packages/shared/src/node/env/GlobalValues.ts b/packages/shared/src/node/env/GlobalValues.ts index 9e1f27bd6..34fa359e0 100644 --- a/packages/shared/src/node/env/GlobalValues.ts +++ b/packages/shared/src/node/env/GlobalValues.ts @@ -104,6 +104,9 @@ export default class GlobalValues { /** Maximum number of clients to keep in a single database pool (i.e. per `Tenant` class). */ public readonly databasePoolSize = Number(getEnv('DATABASE_POOL_SIZE', '20')); + /** Case insensitive username */ + public readonly isCaseSensitiveUsername = yes(getEnv('CASE_SENSITIVE_USERNAME', 'true')); + /** * The Redis endpoint (optional). If it's set, the central cache mechanism will be automatically enabled. *