mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add the new user provision (#6253)
add the new user provision
This commit is contained in:
parent
6c4f051cfe
commit
0a92bd2fdc
5 changed files with 509 additions and 6 deletions
|
@ -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<InsertUserResult> => [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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -11,6 +11,7 @@ import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
import { interactionProfileGuard, type Interaction, type InteractionProfile } from '../types.js';
|
import { interactionProfileGuard, type Interaction, type InteractionProfile } from '../types.js';
|
||||||
|
|
||||||
|
import { ProvisionLibrary } from './provision-library.js';
|
||||||
import { getNewUserProfileFromVerificationRecord, toUserSocialIdentityData } from './utils.js';
|
import { getNewUserProfileFromVerificationRecord, toUserSocialIdentityData } from './utils.js';
|
||||||
import { ProfileValidator } from './validators/profile-validator.js';
|
import { ProfileValidator } from './validators/profile-validator.js';
|
||||||
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
|
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
|
||||||
|
@ -44,13 +45,14 @@ const interactionStorageGuard = z.object({
|
||||||
export default class ExperienceInteraction {
|
export default class ExperienceInteraction {
|
||||||
public readonly signInExperienceValidator: SignInExperienceValidator;
|
public readonly signInExperienceValidator: SignInExperienceValidator;
|
||||||
public readonly profileValidator: ProfileValidator;
|
public readonly profileValidator: ProfileValidator;
|
||||||
|
public readonly provisionLibrary: ProvisionLibrary;
|
||||||
|
|
||||||
/** The user verification record list for the current interaction. */
|
/** The user verification record list for the current interaction. */
|
||||||
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
|
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
|
||||||
/** The userId of the user for the current interaction. Only available once the user is identified. */
|
/** The userId of the user for the current interaction. Only available once the user is identified. */
|
||||||
private userId?: string;
|
private userId?: string;
|
||||||
/** The user provided profile data in the current interaction that needs to be stored to database. */
|
/** The user provided profile data in the current interaction that needs to be stored to database. */
|
||||||
private readonly profile?: Record<string, unknown>; // TODO: Fix the type
|
private readonly profile?: InteractionProfile;
|
||||||
/** The interaction event for the current interaction. */
|
/** The interaction event for the current interaction. */
|
||||||
#interactionEvent?: InteractionEvent;
|
#interactionEvent?: InteractionEvent;
|
||||||
|
|
||||||
|
@ -63,11 +65,12 @@ export default class ExperienceInteraction {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ctx: WithLogContext,
|
private readonly ctx: WithLogContext,
|
||||||
private readonly tenant: TenantContext,
|
private readonly tenant: TenantContext,
|
||||||
public interactionDetails?: Interaction
|
interactionDetails?: Interaction
|
||||||
) {
|
) {
|
||||||
const { libraries, queries } = tenant;
|
const { libraries, queries } = tenant;
|
||||||
|
|
||||||
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
|
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
|
||||||
|
this.provisionLibrary = new ProvisionLibrary(tenant, ctx);
|
||||||
|
|
||||||
if (!interactionDetails) {
|
if (!interactionDetails) {
|
||||||
this.profileValidator = new ProfileValidator(libraries, queries);
|
this.profileValidator = new ProfileValidator(libraries, queries);
|
||||||
|
@ -294,19 +297,25 @@ export default class ExperienceInteraction {
|
||||||
|
|
||||||
await this.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
|
await this.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
|
||||||
|
|
||||||
// TODO: new user provisioning and hooks
|
|
||||||
|
|
||||||
const { socialIdentity, enterpriseSsoIdentity, ...rest } = newProfile;
|
const { socialIdentity, enterpriseSsoIdentity, ...rest } = newProfile;
|
||||||
|
|
||||||
|
const { isCreatingFirstAdminUser, initialUserRoles, customData } =
|
||||||
|
await this.provisionLibrary.getUserProvisionContext(newProfile);
|
||||||
|
|
||||||
const [user] = await insertUser(
|
const [user] = await insertUser(
|
||||||
{
|
{
|
||||||
id: await generateUserId(),
|
id: await generateUserId(),
|
||||||
...rest,
|
...rest,
|
||||||
...conditional(socialIdentity && { identities: toUserSocialIdentityData(socialIdentity) }),
|
...conditional(socialIdentity && { identities: toUserSocialIdentityData(socialIdentity) }),
|
||||||
|
...conditional(customData && { customData }),
|
||||||
},
|
},
|
||||||
[]
|
initialUserRoles
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isCreatingFirstAdminUser) {
|
||||||
|
await this.provisionLibrary.adminUserProvision(user);
|
||||||
|
}
|
||||||
|
|
||||||
if (enterpriseSsoIdentity) {
|
if (enterpriseSsoIdentity) {
|
||||||
await userSsoIdentitiesQueries.insert({
|
await userSsoIdentitiesQueries.insert({
|
||||||
id: generateStandardId(),
|
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;
|
this.userId = user.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
196
packages/core/src/routes/experience/classes/provision-library.ts
Normal file
196
packages/core/src/routes/experience/classes/provision-library.ts
Normal file
|
@ -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<string>(
|
||||||
|
isInAdminTenant && AdminTenantRole.User,
|
||||||
|
isCreatingFirstAdminUser && !isCloud && defaultManagementApiAdminName // OSS uses the legacy Management API user role
|
||||||
|
);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -180,7 +180,7 @@ describe('organization just-in-time provisioning', () => {
|
||||||
await deleteUser(userId);
|
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 emailDomain = `foo-${randomString()}.com`;
|
||||||
const email = randomString() + '@' + emailDomain;
|
const email = randomString() + '@' + emailDomain;
|
||||||
const { client } = await registerWithEmail(email);
|
const { client } = await registerWithEmail(email);
|
||||||
|
|
Loading…
Reference in a new issue