diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index efe293a0c..7b3b62f87 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -8,6 +8,7 @@ import pRetry from 'p-retry'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; +import OrganizationQueries from '#src/queries/organization/index.js'; import { createUsersRolesQueries } from '#src/queries/users-roles.js'; import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; @@ -130,6 +131,22 @@ export const createUserLibrary = (queries: Queries) => { ); } + // Just-in-time organization provisioning + const userEmailDomain = data.primaryEmail?.split('@')[1]; + // TODO: Remove this check when launching + if (EnvSet.values.isDevFeaturesEnabled && userEmailDomain) { + const organizationQueries = new OrganizationQueries(connection); + const organizationIds = await organizationQueries.emailDomains.getOrganizationIdsByDomain( + userEmailDomain + ); + + if (organizationIds.length > 0) { + await organizationQueries.relations.users.insert( + ...organizationIds.map<[string, string]>((organizationId) => [organizationId, user.id]) + ); + } + } + return user; }); }; diff --git a/packages/core/src/queries/organization/email-domains.ts b/packages/core/src/queries/organization/email-domains.ts index b583cdb8d..f772662b3 100644 --- a/packages/core/src/queries/organization/email-domains.ts +++ b/packages/core/src/queries/organization/email-domains.ts @@ -49,6 +49,15 @@ export class EmailDomainQueries { return [Number(count), rows]; } + async getOrganizationIdsByDomain(emailDomain: string): Promise { + const rows = await this.pool.any>(sql` + select ${fields.organizationId} + from ${table} + where ${fields.emailDomain} = ${emailDomain} + `); + return rows.map((row) => row.organizationId); + } + async insert(organizationId: string, emailDomain: string): Promise { return this.#insert({ organizationId, diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 55ebfe98c..028f0673c 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -137,6 +137,7 @@ export default function organizationRoutes( userRoleRelationRoutes(router, organizations); + // TODO: Remove this check when launching if (EnvSet.values.isDevFeaturesEnabled) { emailDomainRoutes(router, organizations); } diff --git a/packages/integration-tests/src/api/admin-user.ts b/packages/integration-tests/src/api/admin-user.ts index f9dd9d371..c8ca34b2e 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 { Identity, MfaFactor, MfaVerification, + OrganizationWithRoles, Role, User, UserSsoIdentity, @@ -123,3 +124,6 @@ export const createUserMfaVerification = async (userId: string, type: MfaFactor) | { type: MfaFactor.TOTP; secret: string; secretQrCode: string } | { type: MfaFactor.BackupCode; codes: string[] } >(); + +export const getUserOrganizations = async (userId: string) => + authedAdminApi.get(`users/${userId}/organizations`).json(); diff --git a/packages/integration-tests/src/helpers/interactions.ts b/packages/integration-tests/src/helpers/interactions.ts index 19e180846..b5a5d244b 100644 --- a/packages/integration-tests/src/helpers/interactions.ts +++ b/packages/integration-tests/src/helpers/interactions.ts @@ -36,6 +36,47 @@ export const registerNewUser = async (username: string, password: string) => { return userId; }; +/** + * Register a new user with email and verification code. Email connector must be enabled. + * + * @param email The email address of the user to register. + * @returns The client and the user ID. + */ +export const registerWithEmail = async (email: string) => { + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.Register, + }); + + await client.successSend(sendVerificationCode, { + email, + }); + + const verificationCodeRecord = await readConnectorMessage('Email'); + + expect(verificationCodeRecord).toMatchObject({ + address: email, + type: InteractionEvent.Register, + }); + + const { code } = verificationCodeRecord; + + await client.successSend(patchInteractionIdentifiers, { + email, + verificationCode: code, + }); + + await client.successSend(putInteractionProfile, { + email, + }); + + const { redirectTo } = await client.submitInteraction(); + const id = await processSession(client, redirectTo); + + return { client, id }; +}; + export const signInWithPassword = async ( payload: UsernamePasswordPayload | EmailPasswordPayload | PhonePasswordPayload ) => { diff --git a/packages/integration-tests/src/tests/api/admin-user.organization-jit.test.ts b/packages/integration-tests/src/tests/api/admin-user.organization-jit.test.ts new file mode 100644 index 000000000..dbbcf84ea --- /dev/null +++ b/packages/integration-tests/src/tests/api/admin-user.organization-jit.test.ts @@ -0,0 +1,34 @@ +import { getUserOrganizations } from '#src/api/index.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { UserApiTest } from '#src/helpers/user.js'; +import { randomString } from '#src/utils.js'; + +describe('organization just-in-time provisioning', () => { + const organizationApi = new OrganizationApiTest(); + const userApi = new UserApiTest(); + + afterEach(async () => { + await Promise.all([organizationApi.cleanUp(), userApi.cleanUp()]); + }); + + it('should automatically provision a user to the organization with the matched email domain', async () => { + const organizations = await Promise.all([ + organizationApi.create({ name: 'foo' }), + organizationApi.create({ name: 'bar' }), + ]); + const emailDomain = 'foo.com'; + await Promise.all( + organizations.map(async (organization) => + organizationApi.addEmailDomain(organization.id, emailDomain) + ) + ); + + const email = randomString() + '@' + emailDomain; + const { id } = await userApi.create({ primaryEmail: email }); + + const userOrganizations = await getUserOrganizations(id); + expect(userOrganizations).toEqual( + expect.arrayContaining(organizations.map((item) => expect.objectContaining({ id: item.id }))) + ); + }); +}); diff --git a/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts b/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts new file mode 100644 index 000000000..b8b1d3ca2 --- /dev/null +++ b/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts @@ -0,0 +1,66 @@ +/** + * @fileoverview + * Tests for the organization just-in-time (JIT) provisioning when a user registers with a matched + * email domain. + */ + +import { ConnectorType, SignInIdentifier } from '@logto/schemas'; + +import { deleteUser, getUserOrganizations } from '#src/api/index.js'; +import { logoutClient } from '#src/helpers/client.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { registerWithEmail } from '#src/helpers/interactions.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { + enableAllVerificationCodeSignInMethods, + resetPasswordPolicy, +} from '#src/helpers/sign-in-experience.js'; +import { randomString } from '#src/utils.js'; + +describe('organization just-in-time provisioning', () => { + const organizationApi = new OrganizationApiTest(); + + afterEach(async () => { + await organizationApi.cleanUp(); + }); + + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await Promise.all([setEmailConnector(), setSmsConnector()]); + await resetPasswordPolicy(); + // Run it sequentially to avoid race condition + await enableAllVerificationCodeSignInMethods({ + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }); + }); + + it('should automatically provision a user to the organization with the matched email domain', async () => { + const organizations = await Promise.all([ + organizationApi.create({ name: 'foo' }), + organizationApi.create({ name: 'bar' }), + ]); + const emailDomain = 'foo.com'; + await Promise.all( + organizations.map(async (organization) => + organizationApi.addEmailDomain(organization.id, emailDomain) + ) + ); + + const email = randomString() + '@' + emailDomain; + const { client, id } = await registerWithEmail(email); + + const userOrganizations = await getUserOrganizations(id); + expect(userOrganizations).toEqual( + expect.arrayContaining(organizations.map((item) => expect.objectContaining({ id: item.id }))) + ); + + await logoutClient(client); + await deleteUser(id); + }); +}); diff --git a/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts index 761086c56..fb266baba 100644 --- a/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts @@ -18,6 +18,7 @@ import { setSmsConnector, } from '#src/helpers/connector.js'; import { readConnectorMessage, expectRejects } from '#src/helpers/index.js'; +import { registerWithEmail } from '#src/helpers/interactions.js'; import { enableAllVerificationCodeSignInMethods, enableAllPasswordSignInMethods, @@ -75,37 +76,7 @@ describe('register with passwordless identifier', () => { }); const { primaryEmail } = generateNewUserProfile({ primaryEmail: true }); - const client = await initClient(); - - await client.successSend(putInteraction, { - event: InteractionEvent.Register, - }); - - await client.successSend(sendVerificationCode, { - email: primaryEmail, - }); - - const verificationCodeRecord = await readConnectorMessage('Email'); - - expect(verificationCodeRecord).toMatchObject({ - address: primaryEmail, - type: InteractionEvent.Register, - }); - - const { code } = verificationCodeRecord; - - await client.successSend(patchInteractionIdentifiers, { - email: primaryEmail, - verificationCode: code, - }); - - await client.successSend(putInteractionProfile, { - email: primaryEmail, - }); - - const { redirectTo } = await client.submitInteraction(); - - const id = await processSession(client, redirectTo); + const { client, id } = await registerWithEmail(primaryEmail); await logoutClient(client); await deleteUser(id); });