diff --git a/packages/core/src/routes/experience/classes/experience-interaction.test.ts b/packages/core/src/routes/experience/classes/experience-interaction.test.ts new file mode 100644 index 000000000..561df47d9 --- /dev/null +++ b/packages/core/src/routes/experience/classes/experience-interaction.test.ts @@ -0,0 +1,119 @@ +import { + adminConsoleApplicationId, + adminTenantId, + type CreateUser, + InteractionEvent, + SignInIdentifier, + SignInMode, + type User, + VerificationType, +} from '@logto/schemas'; +import { createMockUtils, pickDefault } from '@logto/shared/esm'; + +import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; +import { type InsertUserResult } from '#src/libraries/user.js'; +import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import { CodeVerification } from './verifications/code-verification.js'; + +const { jest } = import.meta; +const { mockEsm } = createMockUtils(jest); + +mockEsm('#src/utils/tenant.js', () => ({ + getTenantId: () => [adminTenantId], +})); + +const mockEmail = 'foo@bar.com'; +const userQueries = { + hasActiveUsers: jest.fn().mockResolvedValue(false), + hasUserWithEmail: jest.fn().mockResolvedValue(false), + hasUserWithPhone: jest.fn().mockResolvedValue(false), + hasUserWithIdentity: jest.fn().mockResolvedValue(false), +}; +const userLibraries = { + generateUserId: jest.fn().mockResolvedValue('uid'), + insertUser: jest.fn(async (user: CreateUser): Promise => [user as User]), + provisionOrganizations: jest.fn(), +}; +const ssoConnectors = { + getAvailableSsoConnectors: jest.fn().mockResolvedValue([]), +}; +const signInExperiences = { + findDefaultSignInExperience: jest.fn().mockResolvedValue({ + ...mockSignInExperience, + signUp: { + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }, + }), + updateDefaultSignInExperience: jest.fn(), +}; + +const mockProviderInteractionDetails = jest + .fn() + .mockResolvedValue({ params: { client_id: adminConsoleApplicationId } }); + +const ExperienceInteraction = await pickDefault(import('./experience-interaction.js')); + +describe('ExperienceInteraction class', () => { + const tenant = new MockTenant( + createMockProvider(mockProviderInteractionDetails), + { + users: userQueries, + signInExperiences, + }, + undefined, + { users: userLibraries, ssoConnectors } + ); + const ctx = { + ...createContextWithRouteParameters(), + ...createMockLogContext(), + }; + const { libraries, queries } = tenant; + + const emailVerificationRecord = new CodeVerification(libraries, queries, { + id: 'mock_email_verification_id', + type: VerificationType.VerificationCode, + identifier: { + type: SignInIdentifier.Email, + value: mockEmail, + }, + interactionEvent: InteractionEvent.Register, + verified: true, + }); + + beforeAll(() => { + jest.clearAllMocks(); + }); + + describe('new user registration', () => { + it('First admin user provisioning', async () => { + const experienceInteraction = new ExperienceInteraction(ctx, tenant); + + await experienceInteraction.setInteractionEvent(InteractionEvent.Register); + experienceInteraction.setVerificationRecord(emailVerificationRecord); + await experienceInteraction.identifyUser(emailVerificationRecord.id); + + expect(userLibraries.insertUser).toHaveBeenCalledWith( + { + id: 'uid', + primaryEmail: mockEmail, + }, + ['user', 'default:admin'] + ); + + expect(signInExperiences.updateDefaultSignInExperience).toHaveBeenCalledWith({ + signInMode: SignInMode.SignIn, + }); + + expect(userLibraries.provisionOrganizations).toHaveBeenCalledWith({ + userId: 'uid', + email: mockEmail, + }); + }); + }); +}); diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index a5632b996..210b1d79c 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -11,6 +11,7 @@ import assertThat from '#src/utils/assert-that.js'; import { interactionProfileGuard, type Interaction, type InteractionProfile } from '../types.js'; +import { ProvisionLibrary } from './provision-library.js'; import { getNewUserProfileFromVerificationRecord, toUserSocialIdentityData } from './utils.js'; import { ProfileValidator } from './validators/profile-validator.js'; import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js'; @@ -44,13 +45,14 @@ const interactionStorageGuard = z.object({ export default class ExperienceInteraction { public readonly signInExperienceValidator: SignInExperienceValidator; public readonly profileValidator: ProfileValidator; + public readonly provisionLibrary: ProvisionLibrary; /** The user verification record list for the current interaction. */ private readonly verificationRecords = new Map(); /** The userId of the user for the current interaction. Only available once the user is identified. */ private userId?: string; /** The user provided profile data in the current interaction that needs to be stored to database. */ - private readonly profile?: Record; // TODO: Fix the type + private readonly profile?: InteractionProfile; /** The interaction event for the current interaction. */ #interactionEvent?: InteractionEvent; @@ -63,11 +65,12 @@ export default class ExperienceInteraction { constructor( private readonly ctx: WithLogContext, private readonly tenant: TenantContext, - public interactionDetails?: Interaction + interactionDetails?: Interaction ) { const { libraries, queries } = tenant; this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries); + this.provisionLibrary = new ProvisionLibrary(tenant, ctx); if (!interactionDetails) { this.profileValidator = new ProfileValidator(libraries, queries); @@ -294,19 +297,25 @@ export default class ExperienceInteraction { await this.profileValidator.guardProfileUniquenessAcrossUsers(newProfile); - // TODO: new user provisioning and hooks - const { socialIdentity, enterpriseSsoIdentity, ...rest } = newProfile; + const { isCreatingFirstAdminUser, initialUserRoles, customData } = + await this.provisionLibrary.getUserProvisionContext(newProfile); + const [user] = await insertUser( { id: await generateUserId(), ...rest, ...conditional(socialIdentity && { identities: toUserSocialIdentityData(socialIdentity) }), + ...conditional(customData && { customData }), }, - [] + initialUserRoles ); + if (isCreatingFirstAdminUser) { + await this.provisionLibrary.adminUserProvision(user); + } + if (enterpriseSsoIdentity) { await userSsoIdentitiesQueries.insert({ id: generateStandardId(), @@ -315,6 +324,10 @@ export default class ExperienceInteraction { }); } + await this.provisionLibrary.newUserJtiOrganizationProvision(user.id, newProfile); + + // TODO: new user hooks + this.userId = user.id; } } diff --git a/packages/core/src/routes/experience/classes/provision-library.ts b/packages/core/src/routes/experience/classes/provision-library.ts new file mode 100644 index 000000000..3a978bc56 --- /dev/null +++ b/packages/core/src/routes/experience/classes/provision-library.ts @@ -0,0 +1,196 @@ +import { + adminConsoleApplicationId, + adminTenantId, + AdminTenantRole, + defaultManagementApiAdminName, + defaultTenantId, + getTenantOrganizationId, + getTenantRole, + OrganizationInvitationStatus, + SignInMode, + TenantRole, + userOnboardingDataKey, + type User, + type UserOnboardingData, +} from '@logto/schemas'; +import { conditionalArray } from '@silverhand/essentials'; + +import { EnvSet } from '#src/env-set/index.js'; +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import type TenantContext from '#src/tenants/TenantContext.js'; +import { getTenantId } from '#src/utils/tenant.js'; + +import { type InteractionProfile } from '../types.js'; + +type OrganizationProvisionPayload = + | { + userId: string; + email: string; + } + | { + userId: string; + ssoConnectorId: string; + }; + +export class ProvisionLibrary { + constructor( + private readonly tenantContext: TenantContext, + private readonly ctx: WithLogContext + ) {} + + /** + * This method is used to get the provision context for a new user registration. + * It will return the provision context based on the current tenant and the request context. + */ + async getUserProvisionContext(profile: InteractionProfile): Promise<{ + /** Admin user provisioning flag */ + isCreatingFirstAdminUser: boolean; + /** Initial user roles for admin tenant users */ + initialUserRoles: string[]; + /** Skip onboarding flow if the new user has pending Cloud invitations */ + customData?: { [userOnboardingDataKey]: UserOnboardingData }; + }> { + const { + provider, + queries: { + users: { hasActiveUsers }, + organizations: organizationsQueries, + }, + } = this.tenantContext; + + const { req, res, URL } = this.ctx; + + const [interactionDetails, [currentTenantId]] = await Promise.all([ + provider.interactionDetails(req, res), + getTenantId(URL), + ]); + + const { + params: { client_id }, + } = interactionDetails; + + const isAdminTenant = currentTenantId === adminTenantId; + const isAdminConsoleApp = String(client_id) === adminConsoleApplicationId; + + const { isCloud, isIntegrationTest } = EnvSet.values; + + /** + * Only allow creating the first admin user when + * + * - it's in OSS or integration tests + * - it's in the admin tenant + * - the client_id is the admin console application + * - there are no active users in the tenant + */ + const isCreatingFirstAdminUser = + (!isCloud || isIntegrationTest) && + isAdminTenant && + isAdminConsoleApp && + !(await hasActiveUsers()); + + const invitations = + isCloud && profile.primaryEmail + ? await organizationsQueries.invitations.findEntities({ + invitee: profile.primaryEmail, + }) + : []; + + const hasPendingInvitations = invitations.some( + (invitation) => invitation.status === OrganizationInvitationStatus.Pending + ); + + const initialUserRoles = this.getInitialUserRoles( + isAdminTenant, + isCreatingFirstAdminUser, + isCloud + ); + + // Skip onboarding flow if the new user has pending Cloud invitations + const customData = hasPendingInvitations + ? { + [userOnboardingDataKey]: { + isOnboardingDone: true, + } satisfies UserOnboardingData, + } + : undefined; + + return { + isCreatingFirstAdminUser, + initialUserRoles, + customData, + }; + } + + /** + * First admin user provision + * + * - For OSS, update the default sign-in experience to "sign-in only" once the first admin has been created. + * - Add the user to the default organization and assign the admin role. + */ + async adminUserProvision({ id }: User) { + const { isCloud } = EnvSet.values; + const { + queries: { signInExperiences, organizations }, + } = this.tenantContext; + + // In OSS, we need to limit sign-in experience to "sign-in only" once + // the first admin has been create since we don't want other unexpected registrations + await signInExperiences.updateDefaultSignInExperience({ + signInMode: isCloud ? SignInMode.SignInAndRegister : SignInMode.SignIn, + }); + + const organizationId = getTenantOrganizationId(defaultTenantId); + await organizations.relations.users.insert({ organizationId, userId: id }); + await organizations.relations.usersRoles.insert({ + organizationId, + userId: id, + organizationRoleId: getTenantRole(TenantRole.Admin).id, + }); + } + + /** + * Provision the organization for a new user + * + * - If the user has an enterprise SSO identity, provision the organization based on the SSO connector + * - Otherwise, provision the organization based on the primary email + */ + async newUserJtiOrganizationProvision( + userId: string, + { primaryEmail, enterpriseSsoIdentity }: InteractionProfile + ) { + if (enterpriseSsoIdentity) { + return this.jitOrganizationProvision({ + userId, + ssoConnectorId: enterpriseSsoIdentity.ssoConnectorId, + }); + } + if (primaryEmail) { + return this.jitOrganizationProvision({ + userId, + email: primaryEmail, + }); + } + } + + private async jitOrganizationProvision(payload: OrganizationProvisionPayload) { + const { + libraries: { users: usersLibraries }, + } = this.tenantContext; + + const provisionedOrganizations = await usersLibraries.provisionOrganizations(payload); + + // TODO: trigger hooks event + + return provisionedOrganizations; + } + + private readonly getInitialUserRoles = ( + isInAdminTenant: boolean, + isCreatingFirstAdminUser: boolean, + isCloud: boolean + ) => + conditionalArray( + isInAdminTenant && AdminTenantRole.User, + isCreatingFirstAdminUser && !isCloud && defaultManagementApiAdminName // OSS uses the legacy Management API user role + ); +} diff --git a/packages/integration-tests/src/tests/api/experience-api/register-interaction/organization-jti.test.ts b/packages/integration-tests/src/tests/api/experience-api/register-interaction/organization-jti.test.ts new file mode 100644 index 000000000..5cc220841 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/register-interaction/organization-jti.test.ts @@ -0,0 +1,175 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { SignInIdentifier } from '@logto/schemas'; + +import { deleteUser, getUserOrganizations } from '#src/api/admin-user.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { SsoConnectorApi } from '#src/api/sso-connector.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { + registerNewUserWithVerificationCode, + signInWithEnterpriseSso, + signInWithVerificationCode, +} from '#src/helpers/experience/index.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { devFeatureTest, generateEmail, randomString } from '#src/utils.js'; + +devFeatureTest.describe('organization just-in-time provisioning', () => { + const organizationApi = new OrganizationApiTest(); + const ssoConnectorApi = new SsoConnectorApi(); + + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email]); + await Promise.all([setEmailConnector(), setSmsConnector()]); + + await enableAllVerificationCodeSignInMethods({ + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }); + + await updateSignInExperience({ + singleSignOnEnabled: true, + }); + }); + + afterEach(async () => { + await Promise.all([organizationApi.cleanUp(), ssoConnectorApi.cleanUp()]); + }); + + const prepare = async (emailDomain = `foo-${randomString()}.com`, ssoConnectorId?: string) => { + const organizations = await Promise.all([ + organizationApi.create({ name: 'foo' }), + organizationApi.create({ name: 'bar' }), + organizationApi.create({ name: 'baz' }), + ]); + const roles = await Promise.all([ + organizationApi.roleApi.create({ name: randomString() }), + organizationApi.roleApi.create({ name: randomString() }), + ]); + await Promise.all( + organizations.map(async (organization) => { + if (emailDomain) { + await organizationApi.jit.addEmailDomain(organization.id, emailDomain); + } + + if (ssoConnectorId) { + await organizationApi.jit.ssoConnectors.add(organization.id, [ssoConnectorId]); + } + }) + ); + await Promise.all([ + organizationApi.jit.roles.add(organizations[0].id, [roles[0].id, roles[1].id]), + organizationApi.jit.roles.add(organizations[1].id, [roles[0].id]), + ]); + return { + organizations, + roles, + emailDomain, + expectOrganizations: () => + expect.arrayContaining([ + expect.objectContaining({ + id: organizations[0].id, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + organizationRoles: expect.arrayContaining([ + expect.objectContaining({ id: roles[0].id }), + expect.objectContaining({ id: roles[1].id }), + ]), + }), + expect.objectContaining({ + id: organizations[1].id, + organizationRoles: [expect.objectContaining({ id: roles[0].id })], + }), + expect.objectContaining({ + id: organizations[2].id, + organizationRoles: [], + }), + ]), + }; + }; + + it('should automatically provision a user with matched email', async () => { + const { emailDomain, expectOrganizations } = await prepare(); + const email = randomString() + '@' + emailDomain; + + const userId = await registerNewUserWithVerificationCode({ + type: SignInIdentifier.Email, + value: email, + }); + + const userOrganizations = await getUserOrganizations(userId); + expect(userOrganizations).toEqual(expectOrganizations()); + + await deleteUser(userId); + }); + + it('should not provision a user with the matched email from a SSO identity', async () => { + const organization = await organizationApi.create({ name: 'sso_foo' }); + const domain = 'sso_example.com'; + await organizationApi.jit.addEmailDomain(organization.id, domain); + const connector = await ssoConnectorApi.createMockOidcConnector([domain]); + + const userId = await signInWithEnterpriseSso( + connector.id, + { + sub: randomString(), + email: generateEmail(domain), + email_verified: true, + }, + true + ); + + const userOrganizations = await getUserOrganizations(userId); + expect(userOrganizations).toEqual([]); + await deleteUser(userId); + }); + + it('should not provision an existing user with the matched email when sign-in', async () => { + const emailDomain = `foo-${randomString()}.com`; + const email = randomString() + '@' + emailDomain; + const userId = await registerNewUserWithVerificationCode({ + type: SignInIdentifier.Email, + value: email, + }); + + await prepare(emailDomain); + + await signInWithVerificationCode({ + type: SignInIdentifier.Email, + value: email, + }); + + const userOrganizations = await getUserOrganizations(userId); + expect(userOrganizations).toEqual([]); + await deleteUser(userId); + }); + + it('should provision a user with the matched sso connector', async () => { + const organization = await organizationApi.create({ name: 'sso_foo' }); + const domain = 'sso_example.com'; + const connector = await ssoConnectorApi.createMockOidcConnector([domain]); + await organizationApi.jit.ssoConnectors.add(organization.id, [connector.id]); + + const userId = await signInWithEnterpriseSso( + connector.id, + { + sub: randomString(), + email: generateEmail(domain), + email_verified: true, + }, + true + ); + + const userOrganizations = await getUserOrganizations(userId); + + expect(userOrganizations).toEqual( + expect.arrayContaining([expect.objectContaining({ id: organization.id })]) + ); + + await deleteUser(userId); + }); +}); 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 index d315d0803..e78fd7ef3 100644 --- a/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts @@ -180,7 +180,7 @@ describe('organization just-in-time provisioning', () => { await deleteUser(userId); }); - it('should not automatically provision an existing user when the user is an existing user', async () => { + it('should not automatically provision an existing user', async () => { const emailDomain = `foo-${randomString()}.com`; const email = randomString() + '@' + emailDomain; const { client } = await registerWithEmail(email);