diff --git a/packages/core/src/queries/user.test.ts b/packages/core/src/queries/user.test.ts index 6eb556874..c2d925c9a 100644 --- a/packages/core/src/queries/user.test.ts +++ b/packages/core/src/queries/user.test.ts @@ -67,7 +67,7 @@ describe('user query', () => { const expectSql = sql` select ${sql.join(Object.values(fields), sql`,`)} from ${table} - where ${fields.primaryEmail}=$1 + where lower(${fields.primaryEmail})=lower($1) `; mockQuery.mockImplementationOnce(async (sql, values) => { @@ -179,7 +179,7 @@ describe('user query', () => { SELECT EXISTS( select ${fields.primaryEmail} from ${table} - where ${fields.primaryEmail}=$1 + where lower(${fields.primaryEmail})=lower($1) ) `; diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 6a4e70881..6a8d43cc8 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -21,7 +21,7 @@ export const findUserByEmail = async (email: string) => envSet.pool.one(sql` select ${sql.join(Object.values(fields), sql`,`)} from ${table} - where ${fields.primaryEmail}=${email} + where lower(${fields.primaryEmail})=lower(${email}) `); export const findUserByPhone = async (phone: string) => @@ -65,7 +65,7 @@ export const hasUserWithEmail = async (email: string) => envSet.pool.exists(sql` select ${fields.primaryEmail} from ${table} - where ${fields.primaryEmail}=${email} + where lower(${fields.primaryEmail})=lower(${email}) `); export const hasUserWithPhone = async (phone: string) => diff --git a/packages/core/src/routes/admin-user.test.ts b/packages/core/src/routes/admin-user.test.ts index 756dfdecd..66ea3daa3 100644 --- a/packages/core/src/routes/admin-user.test.ts +++ b/packages/core/src/routes/admin-user.test.ts @@ -117,8 +117,11 @@ describe('adminUserRoutes', () => { const username = 'MJAtLogto'; const password = 'PASSWORD'; const name = 'Michael'; + const primaryEmail = 'foo@logto.io'; - const response = await userRequest.post('/users').send({ username, password, name }); + const response = await userRequest + .post('/users') + .send({ primaryEmail, username, password, name }); expect(response.status).toEqual(200); expect(response.body).toEqual({ ...mockUserResponse, @@ -133,21 +136,6 @@ describe('adminUserRoutes', () => { const password = 'PASSWORD'; const name = 'Michael'; - // Missing input - await expect(userRequest.post('/users').send({})).resolves.toHaveProperty('status', 400); - await expect(userRequest.post('/users').send({ username, password })).resolves.toHaveProperty( - 'status', - 400 - ); - await expect(userRequest.post('/users').send({ username, name })).resolves.toHaveProperty( - 'status', - 400 - ); - await expect(userRequest.post('/users').send({ password, name })).resolves.toHaveProperty( - 'status', - 400 - ); - // Invalid input format await expect( userRequest.post('/users').send({ username, password: 'abc', name }) diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index 36ebf2ce4..796baef37 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -18,6 +18,7 @@ import { findUserById, hasUser, updateUserById, + hasUserWithEmail, } from '@/queries/user'; import assertThat from '@/utils/assert-that'; @@ -121,20 +122,29 @@ export default function adminUserRoutes(router: T) { '/users', koaGuard({ body: object({ - username: string().regex(usernameRegEx), + primaryEmail: string().regex(emailRegEx).optional(), + username: string().regex(usernameRegEx).optional(), password: string().regex(passwordRegEx), - name: string(), + name: string().optional(), }), }), async (ctx, next) => { - const { username, password, name } = ctx.guard.body; + const { primaryEmail, username, password, name } = ctx.guard.body; + assertThat( - !(await hasUser(username)), + !username || !(await hasUser(username)), new RequestError({ code: 'user.username_exists_register', status: 422, }) ); + assertThat( + !primaryEmail || !(await hasUserWithEmail(primaryEmail)), + new RequestError({ + code: 'user.email_exists_register', + status: 422, + }) + ); const id = await generateUserId(); @@ -142,6 +152,7 @@ export default function adminUserRoutes(router: T) { const user = await insertUser({ id, + primaryEmail, username, passwordEncrypted, passwordEncryptionMethod, diff --git a/packages/integration-tests/src/api/admin-user.ts b/packages/integration-tests/src/api/admin-user.ts index 529fd17fc..4c7cf72f9 100644 --- a/packages/integration-tests/src/api/admin-user.ts +++ b/packages/integration-tests/src/api/admin-user.ts @@ -3,6 +3,7 @@ import type { User } from '@logto/schemas'; import { authedAdminApi } from './api'; type CreateUserPayload = { + primaryEmail?: string; username: string; password: string; name: string; diff --git a/packages/integration-tests/src/api/session.ts b/packages/integration-tests/src/api/session.ts index 7b3598eea..b650c645a 100644 --- a/packages/integration-tests/src/api/session.ts +++ b/packages/integration-tests/src/api/session.ts @@ -24,17 +24,27 @@ export const registerUserWithUsernameAndPassword = async ( }) .json(); -export const signInWithUsernameAndPassword = async ( - username: string, - password: string, - interactionCookie: string -) => +export type SignInWithPassword = { + username?: string; + email?: string; + password: string; + interactionCookie: string; +}; + +export const signInWithPassword = async ({ + email, + username, + password, + interactionCookie, +}: SignInWithPassword) => api - .post('session/sign-in/password/username', { + // This route in core needs to be refactored + .post('session/sign-in/password/' + (username ? 'username' : 'email'), { headers: { cookie: interactionCookie, }, json: { + email, username, password, }, diff --git a/packages/integration-tests/src/helpers.ts b/packages/integration-tests/src/helpers.ts index b6ee77c7c..4af50b09c 100644 --- a/packages/integration-tests/src/helpers.ts +++ b/packages/integration-tests/src/helpers.ts @@ -8,7 +8,7 @@ import { HTTPError } from 'got'; import { createUser, registerUserWithUsernameAndPassword, - signInWithUsernameAndPassword, + signInWithPassword, updateConnectorConfig, enableConnector, bindWithSocial, @@ -21,14 +21,12 @@ import { generateUsername, generatePassword } from '@/utils'; import { mockSocialConnectorId } from './__mocks__/connectors-mock'; -export const createUserByAdmin = (_username?: string, _password?: string) => { - const username = _username ?? generateUsername(); - const password = _password ?? generatePassword(); - +export const createUserByAdmin = (username?: string, password?: string, primaryEmail?: string) => { return createUser({ - username, - password, - name: username, + username: username ?? generateUsername(), + password: password ?? generatePassword(), + name: username ?? 'John', + primaryEmail, }).json(); }; @@ -49,17 +47,24 @@ export const registerNewUser = async (username: string, password: string) => { assert(client.isAuthenticated, new Error('Sign in failed')); }; -export const signIn = async (username: string, password: string) => { +export type SignInHelper = { + username?: string; + email?: string; + password: string; +}; + +export const signIn = async ({ username, email, password }: SignInHelper) => { const client = new MockClient(); await client.initSession(); assert(client.interactionCookie, new Error('Session not found')); - const { redirectTo } = await signInWithUsernameAndPassword( + const { redirectTo } = await signInWithPassword({ username, + email, password, - client.interactionCookie - ); + interactionCookie: client.interactionCookie, + }); await client.processSession(redirectTo); @@ -135,11 +140,11 @@ export const bindSocialToNewCreatedUser = async () => { new Error('Auth with social failed') ); - const { redirectTo } = await signInWithUsernameAndPassword( + const { redirectTo } = await signInWithPassword({ username, password, - client.interactionCookie - ); + interactionCookie: client.interactionCookie, + }); await bindWithSocial(mockSocialConnectorId, client.interactionCookie); diff --git a/packages/integration-tests/src/utils.ts b/packages/integration-tests/src/utils.ts index 7203ce9b2..2b975f4a8 100644 --- a/packages/integration-tests/src/utils.ts +++ b/packages/integration-tests/src/utils.ts @@ -11,7 +11,7 @@ export const generatePassword = () => `pwd_${crypto.randomUUID()}`; export const generateResourceName = () => `res_${crypto.randomUUID()}`; export const generateResourceIndicator = () => `https://${crypto.randomUUID()}.logto.io`; -export const generateEmail = () => `${crypto.randomUUID()}@logto.io`; +export const generateEmail = () => `${crypto.randomUUID().toLowerCase()}@logto.io`; export const generatePhone = () => { const array = new Uint32Array(1); diff --git a/packages/integration-tests/tests/api/dashboard.test.ts b/packages/integration-tests/tests/api/dashboard.test.ts index cb93b330b..e0c324308 100644 --- a/packages/integration-tests/tests/api/dashboard.test.ts +++ b/packages/integration-tests/tests/api/dashboard.test.ts @@ -44,7 +44,7 @@ describe('admin console dashboard', () => { const username = generateUsername(); await createUserByAdmin(username, password); - await signIn(username, password); + await signIn({ username, password }); const newActiveUserStatistics = await getActiveUsersData(); diff --git a/packages/integration-tests/tests/api/get-access-token.test.ts b/packages/integration-tests/tests/api/get-access-token.test.ts index da220cdf4..e635e4993 100644 --- a/packages/integration-tests/tests/api/get-access-token.test.ts +++ b/packages/integration-tests/tests/api/get-access-token.test.ts @@ -5,7 +5,7 @@ import { managementResource } from '@logto/schemas/lib/seeds'; import { assert } from '@silverhand/essentials'; import fetch from 'node-fetch'; -import { signInWithUsernameAndPassword } from '@/api'; +import { signInWithPassword } from '@/api'; import MockClient, { defaultConfig } from '@/client'; import { logtoUrl } from '@/constants'; import { createUserByAdmin } from '@/helpers'; @@ -24,11 +24,11 @@ describe('get access token', () => { await client.initSession(); assert(client.interactionCookie, new Error('Session not found')); - const { redirectTo } = await signInWithUsernameAndPassword( + const { redirectTo } = await signInWithPassword({ username, password, - client.interactionCookie - ); + interactionCookie: client.interactionCookie, + }); await client.processSession(redirectTo); @@ -47,11 +47,11 @@ describe('get access token', () => { await client.initSession(); assert(client.interactionCookie, new Error('Session not found')); - const { redirectTo } = await signInWithUsernameAndPassword( + const { redirectTo } = await signInWithPassword({ username, password, - client.interactionCookie - ); + interactionCookie: client.interactionCookie, + }); await client.processSession(redirectTo); assert(client.isAuthenticated, new Error('Sign in get get access token failed')); diff --git a/packages/integration-tests/tests/api/session.test.ts b/packages/integration-tests/tests/api/session.test.ts index 5ff8c6e73..4e0c0cfee 100644 --- a/packages/integration-tests/tests/api/session.test.ts +++ b/packages/integration-tests/tests/api/session.test.ts @@ -18,7 +18,8 @@ import { sendSignInUserWithSmsPasscode, verifySignInUserWithSmsPasscode, disableConnector, - signInWithUsernameAndPassword, + signInWithPassword, + createUser, } from '@/api'; import MockClient from '@/client'; import { @@ -36,12 +37,57 @@ describe('username and password flow', () => { const username = generateUsername(); const password = generatePassword(); - it('register with username & password', async () => { - await expect(registerNewUser(username, password)).resolves.not.toThrow(); + beforeAll(async () => { + await setSignUpIdentifier(SignUpIdentifier.Username, true); + await setSignInMethod([ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: false, + }, + ]); }); - it('sign-in with username & password', async () => { - await expect(signIn(username, password)).resolves.not.toThrow(); + it('register and sign in with username & password', async () => { + await expect(registerNewUser(username, password)).resolves.not.toThrow(); + await expect(signIn({ username, password })).resolves.not.toThrow(); + }); +}); + +describe('email and password flow', () => { + const email = generateEmail(); + const [localPart, domain] = email.split('@'); + const password = generatePassword(); + + assert(localPart && domain, new Error('Email address local part or domain is empty')); + + beforeAll(async () => { + await setUpConnector(mockEmailConnectorId, mockEmailConnectorConfig); + await setSignUpIdentifier(SignUpIdentifier.Email, true); + await setSignInMethod([ + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: false, + isPasswordPrimary: false, + }, + ]); + }); + + it('can sign in with email & password', async () => { + await createUser({ password, primaryEmail: email, username: generateUsername(), name: 'John' }); + await expect( + Promise.all([ + signIn({ email, password }), + signIn({ email: localPart.toUpperCase() + '@' + domain, password }), + signIn({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + email: localPart[0]! + localPart.toUpperCase().slice(1) + '@' + domain, + password, + }), + ]) + ).resolves.not.toThrow(); }); }); @@ -236,11 +282,11 @@ describe('sign-in and sign-out', () => { assert(client.interactionCookie, new Error('Session not found')); - const { redirectTo } = await signInWithUsernameAndPassword( + const { redirectTo } = await signInWithPassword({ username, password, - client.interactionCookie - ); + interactionCookie: client.interactionCookie, + }); await client.processSession(redirectTo); @@ -266,11 +312,11 @@ describe('sign-in to demo app and revisit Admin Console', () => { assert(client.interactionCookie, new Error('Session not found')); - const { redirectTo } = await signInWithUsernameAndPassword( + const { redirectTo } = await signInWithPassword({ username, password, - client.interactionCookie - ); + interactionCookie: client.interactionCookie, + }); await client.processSession(redirectTo); diff --git a/packages/integration-tests/tests/api/social-session.test.ts b/packages/integration-tests/tests/api/social-session.test.ts index 70583b437..381805968 100644 --- a/packages/integration-tests/tests/api/social-session.test.ts +++ b/packages/integration-tests/tests/api/social-session.test.ts @@ -12,7 +12,7 @@ import { getAuthWithSocial, registerWithSocial, bindWithSocial, - signInWithUsernameAndPassword, + signInWithPassword, getUser, } from '@/api'; import MockClient from '@/client'; @@ -126,11 +126,11 @@ describe('social bind account', () => { // User with social does not exist expect(response instanceof HTTPError && response.response.statusCode === 422).toBe(true); - const { redirectTo } = await signInWithUsernameAndPassword( + const { redirectTo } = await signInWithPassword({ username, password, - client.interactionCookie - ); + interactionCookie: client.interactionCookie, + }); await expect( bindWithSocial(mockSocialConnectorId, client.interactionCookie)