0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(core): organization jit sso

This commit is contained in:
Gao Sun 2024-06-20 12:49:11 +08:00
parent c1ffadeff6
commit 2cf30d2f03
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
21 changed files with 394 additions and 250 deletions

View file

@ -33,7 +33,8 @@ mockEsm('#src/utils/password.js', () => ({
}));
const { MockQueries } = await import('#src/test-utils/tenant.js');
const { encryptUserPassword, createUserLibrary } = await import('./user.js');
const { createUserLibrary } = await import('./user.js');
const { encryptUserPassword } = await import('./user.utils.js');
const hasUserWithId = jest.fn();
const updateUserById = jest.fn();

View file

@ -1,7 +1,7 @@
import type { BindMfa, CreateUser, MfaVerification, Scope, User } from '@logto/schemas';
import { MfaFactor, RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
import type { BindMfa, CreateUser, Scope, User } from '@logto/schemas';
import { RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
import { generateStandardId, generateStandardShortId } from '@logto/shared';
import { deduplicateByKey, type Nullable } from '@silverhand/essentials';
import { condArray, deduplicateByKey, type Nullable } from '@silverhand/essentials';
import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm';
import pRetry from 'p-retry';
@ -9,75 +9,14 @@ 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 { type JitOrganization } from '#src/queries/organization/email-domains.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';
import { encryptPassword } from '#src/utils/password.js';
import type { OmitAutoSetFields } from '#src/utils/sql.js';
export const encryptUserPassword = async (
password: string
): Promise<{
passwordEncrypted: string;
passwordEncryptionMethod: UsersPasswordEncryptionMethod;
}> => {
const passwordEncryptionMethod = UsersPasswordEncryptionMethod.Argon2i;
const passwordEncrypted = await encryptPassword(password, passwordEncryptionMethod);
import { convertBindMfaToMfaVerification, encryptUserPassword } from './user.utils.js';
return { passwordEncrypted, passwordEncryptionMethod };
};
/**
* Convert bindMfa to mfaVerification, add common fields like "id" and "createdAt"
* and transpile formats like "codes" to "code" for backup code
*/
const converBindMfaToMfaVerification = (bindMfa: BindMfa): MfaVerification => {
const { type } = bindMfa;
const base = {
id: generateStandardId(),
createdAt: new Date().toISOString(),
};
if (type === MfaFactor.BackupCode) {
const { codes } = bindMfa;
return {
...base,
type,
codes: codes.map((code) => ({ code })),
};
}
if (type === MfaFactor.TOTP) {
const { secret } = bindMfa;
return {
...base,
type,
key: secret,
};
}
const { credentialId, counter, publicKey, transports, agent } = bindMfa;
return {
...base,
type,
credentialId,
counter,
publicKey,
transports,
agent,
};
};
export type InsertUserResult = [
User,
{
/** The organizations and organization roles that the user has been provisioned into. */
organizations: readonly JitOrganization[];
},
];
export type InsertUserResult = [User];
export type UserLibrary = ReturnType<typeof createUserLibrary>;
@ -143,43 +82,7 @@ export const createUserLibrary = (queries: Queries) => {
);
}
// TODO: If the user's email is not verified, we should not provision the user into any organization.
const provisionOrganizations = async (): Promise<readonly JitOrganization[]> => {
// Just-in-time organization provisioning
const userEmailDomain = data.primaryEmail?.split('@')[1];
if (userEmailDomain) {
const organizationQueries = new OrganizationQueries(connection);
const organizations = await organizationQueries.jit.emailDomains.getJitOrganizations(
userEmailDomain
);
if (organizations.length > 0) {
await organizationQueries.relations.users.insert(
...organizations.map(({ organizationId }) => ({
organizationId,
userId: user.id,
}))
);
const data = organizations.flatMap(({ organizationId, organizationRoleIds }) =>
organizationRoleIds.map((organizationRoleId) => ({
organizationId,
organizationRoleId,
userId: user.id,
}))
);
if (data.length > 0) {
await organizationQueries.relations.rolesUsers.insert(...data);
}
return organizations;
}
}
return [];
};
return [user, { organizations: await provisionOrganizations() }];
return [user];
});
};
@ -261,7 +164,7 @@ export const createUserLibrary = (queries: Queries) => {
// TODO @sijie use jsonb array append
const { mfaVerifications } = await findUserById(userId);
await updateUserById(userId, {
mfaVerifications: [...mfaVerifications, converBindMfaToMfaVerification(payload)],
mfaVerifications: [...mfaVerifications, convertBindMfaToMfaVerification(payload)],
});
};
@ -338,6 +241,66 @@ export const createUserLibrary = (queries: Queries) => {
const findUserSsoIdentities = async (userId: string) =>
userSsoIdentities.findUserSsoIdentitiesByUserId(userId);
type ProvisionOrganizationsParams =
| {
/** The user ID to provision organizations for. */
userId: string;
/** The user's email to determine JIT organizations. */
email: string;
/** The SSO connector ID to determine JIT organizations. */
ssoConnectorId?: undefined;
}
| {
/** The user ID to provision organizations for. */
userId: string;
/** The user's email to determine JIT organizations. */
email?: undefined;
/** The SSO connector ID to determine JIT organizations. */
ssoConnectorId: string;
};
// TODO: If the user's email is not verified, we should not provision the user into any organization.
/**
* Provision the user with JIT organizations and roles based on the user's email domain and the
* enterprise SSO connector.
*/
const provisionOrganizations = async ({
userId,
email,
ssoConnectorId,
}: ProvisionOrganizationsParams): Promise<readonly JitOrganization[]> => {
const userEmailDomain = email?.split('@')[1];
const jitOrganizations = condArray(
userEmailDomain &&
(await organizations.jit.emailDomains.getJitOrganizations(userEmailDomain)),
ssoConnectorId && (await organizations.jit.ssoConnectors.getJitOrganizations(ssoConnectorId))
);
if (jitOrganizations.length === 0) {
return [];
}
await organizations.relations.users.insert(
...jitOrganizations.map(({ organizationId }) => ({
organizationId,
userId,
}))
);
const data = jitOrganizations.flatMap(({ organizationId, organizationRoleIds }) =>
organizationRoleIds.map((organizationRoleId) => ({
organizationId,
organizationRoleId,
userId,
}))
);
if (data.length > 0) {
await organizations.relations.rolesUsers.insert(...data);
}
return jitOrganizations;
};
return {
generateUserId,
insertUser,
@ -349,5 +312,6 @@ export const createUserLibrary = (queries: Queries) => {
verifyUserPassword,
signOutUser,
findUserSsoIdentities,
provisionOrganizations,
};
};

View file

@ -0,0 +1,60 @@
import type { BindMfa, MfaVerification } from '@logto/schemas';
import { MfaFactor, UsersPasswordEncryptionMethod } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { encryptPassword } from '#src/utils/password.js';
export const encryptUserPassword = async (
password: string
): Promise<{
passwordEncrypted: string;
passwordEncryptionMethod: UsersPasswordEncryptionMethod;
}> => {
const passwordEncryptionMethod = UsersPasswordEncryptionMethod.Argon2i;
const passwordEncrypted = await encryptPassword(password, passwordEncryptionMethod);
return { passwordEncrypted, passwordEncryptionMethod };
};
/**
* Convert bindMfa to mfaVerification, add common fields like "id" and "createdAt"
* and transpile formats like "codes" to "code" for backup code
*/
export const convertBindMfaToMfaVerification = (bindMfa: BindMfa): MfaVerification => {
const { type } = bindMfa;
const base = {
id: generateStandardId(),
createdAt: new Date().toISOString(),
};
if (type === MfaFactor.BackupCode) {
const { codes } = bindMfa;
return {
...base,
type,
codes: codes.map((code) => ({ code })),
};
}
if (type === MfaFactor.TOTP) {
const { secret } = bindMfa;
return {
...base,
type,
key: secret,
};
}
const { credentialId, counter, publicKey, transports, agent } = bindMfa;
return {
...base,
type,
credentialId,
counter,
publicKey,
transports,
agent,
};
};

View file

@ -54,7 +54,11 @@ export class EmailDomainQueries {
* Given an email domain, return the organizations and organization roles that need to be
* provisioned.
*/
async getJitOrganizations(emailDomain: string): Promise<readonly JitOrganization[]> {
async getJitOrganizations(emailDomain?: string): Promise<readonly JitOrganization[]> {
if (!emailDomain) {
return [];
}
const { fields } = convertToIdentifiers(OrganizationJitEmailDomains, true);
const organizationJitRoles = convertToIdentifiers(OrganizationJitRoles, true);
return this.pool.any<JitOrganization>(sql`

View file

@ -24,8 +24,6 @@ import {
OrganizationJitRoles,
OrganizationApplicationRelations,
Applications,
OrganizationJitSsoConnectors,
SsoConnectors,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
@ -36,6 +34,7 @@ import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
import { EmailDomainQueries } from './email-domains.js';
import { RoleUserRelationQueries } from './role-user-relations.js';
import { SsoConnectorQueries } from './sso-connectors.js';
import { UserRelationQueries } from './user-relations.js';
/**
@ -311,12 +310,7 @@ export default class OrganizationQueries extends SchemaQueries<
Organizations,
OrganizationRoles
),
ssoConnectors: new TwoRelationsQueries(
this.pool,
OrganizationJitSsoConnectors.table,
Organizations,
SsoConnectors
),
ssoConnectors: new SsoConnectorQueries(this.pool),
};
constructor(pool: CommonQueryMethods) {

View file

@ -0,0 +1,45 @@
import {
OrganizationJitRoles,
OrganizationJitSsoConnectors,
Organizations,
SsoConnectors,
} from '@logto/schemas';
import { type CommonQueryMethods, sql } from '@silverhand/slonik';
import { TwoRelationsQueries } from '#src/utils/RelationQueries.js';
import { convertToIdentifiers } from '#src/utils/sql.js';
import { type JitOrganization } from './email-domains.js';
const { table, fields } = convertToIdentifiers(OrganizationJitSsoConnectors);
export class SsoConnectorQueries extends TwoRelationsQueries<
typeof Organizations,
typeof SsoConnectors
> {
constructor(pool: CommonQueryMethods) {
super(pool, OrganizationJitSsoConnectors.table, Organizations, SsoConnectors);
}
async getJitOrganizations(ssoConnectorId?: string): Promise<readonly JitOrganization[]> {
if (!ssoConnectorId) {
return [];
}
const { fields } = convertToIdentifiers(OrganizationJitSsoConnectors, true);
const organizationJitRoles = convertToIdentifiers(OrganizationJitRoles, true);
return this.pool.any<JitOrganization>(sql`
select
${fields.organizationId},
array_remove(
array_agg(${organizationJitRoles.fields.organizationRoleId}),
null
) as "organizationRoleIds"
from ${table}
left join ${organizationJitRoles.table}
on ${fields.organizationId} = ${organizationJitRoles.fields.organizationId}
where ${fields.ssoConnectorId} = ${ssoConnectorId}
group by ${fields.organizationId}
`);
}
}

View file

@ -4,7 +4,7 @@ import { conditional, pick } from '@silverhand/essentials';
import { literal, object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { encryptUserPassword } from '#src/libraries/user.js';
import { encryptUserPassword } from '#src/libraries/user.utils.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';

View file

@ -70,7 +70,7 @@ const mockHasUserWithPhone = jest.fn(async () => false);
const { hasUser, findUserById, updateUserById, deleteUserIdentity, deleteUserById } =
mockedQueries.users;
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.utils.js', () => ({
encryptUserPassword: jest.fn(() => ({
passwordEncrypted: 'password',
passwordEncryptionMethod: 'Argon2i',
@ -87,7 +87,6 @@ const usersLibraries = {
...mockUser,
...removeUndefinedKeys(user), // No undefined values will be returned from database
},
{ organizations: [] },
]
),
verifyUserPassword,

View file

@ -12,7 +12,7 @@ import { boolean, literal, nativeEnum, object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { buildManagementApiContext } from '#src/libraries/hook/utils.js';
import { encryptUserPassword } from '#src/libraries/user.js';
import { encryptUserPassword } from '#src/libraries/user.utils.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';
@ -200,7 +200,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
const id = await generateUserId();
const [user, { organizations }] = await insertUser(
const [user] = await insertUser(
{
id,
primaryEmail,
@ -221,13 +221,6 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
[]
);
for (const { organizationId } of organizations) {
ctx.appendDataHookContext('Organization.Membership.Updated', {
...buildManagementApiContext(ctx),
organizationId,
});
}
ctx.body = pick(user, ...userInfoSelectFields);
return next();
}

View file

@ -55,7 +55,6 @@ const usersLibraries = {
...mockUser,
...removeUndefinedKeys(user), // No undefined values will be returned from database
},
{ organizations: [] },
]
),
} satisfies Partial<Libraries['users']>;

View file

@ -58,7 +58,6 @@ const usersLibraries = {
...mockUser,
...removeUndefinedKeys(user), // No undefined values will be returned from database
},
{ organizations: [] },
]
),
} satisfies Partial<Libraries['users']>;

View file

@ -54,7 +54,6 @@ const usersLibraries = {
...mockUser,
...removeUndefinedKeys(user), // No undefined values will be returned from database
},
{ organizations: [] },
]
),
} satisfies Partial<Libraries['users']>;

View file

@ -6,7 +6,7 @@ import { type IRouterContext } from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
import { type ConnectorLibrary } from '#src/libraries/connector.js';
import { encryptUserPassword } from '#src/libraries/user.js';
import { encryptUserPassword } from '#src/libraries/user.utils.js';
import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';

View file

@ -24,7 +24,7 @@ const { assignInteractionResults } = mockEsm('#src/libraries/session.js', () =>
assignInteractionResults: jest.fn(),
}));
mockEsm('#src/libraries/user.js', () => ({
mockEsm('#src/libraries/user.utils.js', () => ({
encryptUserPassword: jest.fn().mockResolvedValue({
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',

View file

@ -1,4 +1,3 @@
/* eslint-disable max-lines */
import {
InteractionEvent,
adminConsoleApplicationId,
@ -33,7 +32,7 @@ const { assignInteractionResults } = mockEsm('#src/libraries/session.js', () =>
assignInteractionResults: jest.fn(),
}));
const { encryptUserPassword } = mockEsm('#src/libraries/user.js', () => ({
const { encryptUserPassword } = mockEsm('#src/libraries/user.utils.js', () => ({
encryptUserPassword: jest.fn().mockResolvedValue({
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
@ -63,9 +62,7 @@ const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = u
const userLibraries = {
generateUserId: jest.fn().mockResolvedValue('uid'),
insertUser: jest.fn(
async (user: CreateUser): Promise<InsertUserResult> => [user as User, { organizations: [] }]
),
insertUser: jest.fn(async (user: CreateUser): Promise<InsertUserResult> => [user as User]),
};
const { generateUserId, insertUser } = userLibraries;
@ -465,4 +462,3 @@ describe('submit action', () => {
});
});
});
/* eslint-enable max-lines */

View file

@ -21,7 +21,7 @@ import { conditional, conditionalArray, trySafe } from '@silverhand/essentials';
import { EnvSet } from '#src/env-set/index.js';
import { assignInteractionResults } from '#src/libraries/session.js';
import { encryptUserPassword } from '#src/libraries/user.js';
import { encryptUserPassword } from '#src/libraries/user.utils.js';
import type { LogEntry, WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
@ -135,7 +135,7 @@ async function handleSubmitRegister(
(invitation) => invitation.status === OrganizationInvitationStatus.Pending
);
const [user, { organizations: provisionedOrganizations }] = await insertUser(
const [user] = await insertUser(
{
id,
...userProfile,
@ -190,10 +190,18 @@ async function handleSubmitRegister(
ctx.assignInteractionHookResult({ userId: id });
ctx.appendDataHookContext('User.Created', { user });
for (const { organizationId } of provisionedOrganizations) {
ctx.appendDataHookContext('Organization.Membership.Updated', {
organizationId,
// JIT provisioning for email domain
if (user.primaryEmail) {
const provisionedOrganizations = await libraries.users.provisionOrganizations({
userId: id,
email: user.primaryEmail,
});
for (const { organizationId } of provisionedOrganizations) {
ctx.appendDataHookContext('Organization.Membership.Updated', {
organizationId,
});
}
}
log?.append({ userId: id });

View file

@ -35,7 +35,7 @@ type AuthorizationUrlPayload = z.infer<typeof authorizationUrlPayloadGuard>;
// Get the authorization url for the SSO provider
export const getSsoAuthorizationUrl = async (
ctx: WithLogContext & WithInteractionDetailsContext,
ctx: WithInteractionDetailsContext<WithLogContext>,
{ provider, id: tenantId }: TenantContext,
connectorData: SupportedSsoConnector,
payload: AuthorizationUrlPayload
@ -84,7 +84,7 @@ type SsoAuthenticationResult = {
// Get the user authentication result from the SSO provider
export const getSsoAuthentication = async (
ctx: WithLogContext,
ctx: WithInteractionHooksContext<WithLogContext>,
{ provider, id: tenantId }: TenantContext,
connectorData: SupportedSsoConnector,
data: Record<string, unknown>
@ -133,7 +133,7 @@ export const getSsoAuthentication = async (
// Handle the SSO authentication result and return the user id
export const handleSsoAuthentication = async (
ctx: WithLogContext,
ctx: WithInteractionHooksContext<WithLogContext>,
tenant: TenantContext,
connectorData: SupportedSsoConnector,
ssoAuthentication: SsoAuthenticationResult
@ -161,7 +161,7 @@ export const handleSsoAuthentication = async (
// SignIn and link with existing user account with a same email
if (user) {
return signInAndLinkWithSsoAuthentication(ctx, queries, {
return signInAndLinkWithSsoAuthentication(ctx, tenant, {
connectorData,
user,
ssoAuthentication,
@ -178,7 +178,7 @@ export const handleSsoAuthentication = async (
};
const signInWithSsoAuthentication = async (
ctx: WithLogContext,
ctx: WithInteractionHooksContext<WithLogContext>,
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries,
{
connectorData: { id: connectorId, syncProfile },
@ -232,8 +232,11 @@ const signInWithSsoAuthentication = async (
};
const signInAndLinkWithSsoAuthentication = async (
ctx: WithLogContext,
{ userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries }: Queries,
ctx: WithInteractionHooksContext<WithLogContext>,
{
queries: { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries },
libraries: { users: usersLibrary },
}: TenantContext,
{
connectorData: { id: connectorId, syncProfile },
user: { id: userId },
@ -273,6 +276,18 @@ const signInAndLinkWithSsoAuthentication = async (
lastSignInAt: Date.now(),
});
// JIT provision for existing users signing in with SSO for the first time
const provisionedOrganizations = await usersLibrary.provisionOrganizations({
userId,
ssoConnectorId: connectorId,
});
for (const { organizationId } of provisionedOrganizations) {
ctx.appendDataHookContext('Organization.Membership.Updated', {
organizationId,
});
}
log.append({
userId,
interaction: {
@ -309,7 +324,7 @@ export const registerWithSsoAuthentication = async (
};
// Insert new user
const [user, { organizations }] = await usersLibrary.insertUser(
const [user] = await usersLibrary.insertUser(
{
id: await usersLibrary.generateUserId(),
...syncingProfile,
@ -317,11 +332,6 @@ export const registerWithSsoAuthentication = async (
},
[]
);
for (const { organizationId } of organizations) {
ctx.appendDataHookContext('Organization.Membership.Updated', {
organizationId,
});
}
const { id: userId } = user;
@ -335,6 +345,18 @@ export const registerWithSsoAuthentication = async (
detail: userInfo,
});
// JIT provision for new users signing up with SSO
const provisionedOrganizations = await usersLibrary.provisionOrganizations({
userId: user.id,
ssoConnectorId: connectorId,
});
for (const { organizationId } of provisionedOrganizations) {
ctx.appendDataHookContext('Organization.Membership.Updated', {
organizationId,
});
}
log.append({
userId,
interaction: {

View file

@ -77,6 +77,28 @@ export const registerWithEmail = async (email: string) => {
return { client, id };
};
export const signInWithEmail = async (email: string) => {
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
});
await client.successSend(sendVerificationCode, {
email,
});
const { code } = await readConnectorMessage('Email');
await client.successSend(patchInteractionIdentifiers, {
email,
verificationCode: code,
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
return { client, id };
};
export const signInWithPassword = async (
payload: UsernamePasswordPayload | EmailPasswordPayload | PhonePasswordPayload
) => {

View file

@ -11,7 +11,7 @@ describe('organization just-in-time provisioning', () => {
await Promise.all([organizationApi.cleanUp(), userApi.cleanUp()]);
});
it('should automatically provision a user to the organizations with roles', async () => {
it('should not automatically provision a user to the organizations when email domain matches', async () => {
const organizations = await Promise.all([
organizationApi.create({ name: 'foo' }),
organizationApi.create({ name: 'bar' }),
@ -36,60 +36,6 @@ describe('organization just-in-time provisioning', () => {
const { id } = await userApi.create({ primaryEmail: email });
const userOrganizations = await getUserOrganizations(id);
expect(userOrganizations).toEqual(
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 to the organizations without roles', async () => {
const organizations = await Promise.all([
organizationApi.create({ name: 'foo' }),
organizationApi.create({ name: 'bar' }),
organizationApi.create({ name: 'baz' }),
]);
const emailDomain = 'foo.com';
await Promise.all(
organizations.map(async (organization) =>
organizationApi.jit.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([
expect.objectContaining({
id: organizations[0].id,
organizationRoles: [],
}),
expect.objectContaining({
id: organizations[1].id,
organizationRoles: [],
}),
expect.objectContaining({
id: organizations[2].id,
organizationRoles: [],
}),
])
);
expect(userOrganizations).toEqual([]);
});
});

View file

@ -129,15 +129,6 @@ describe('manual data hook tests', () => {
});
describe('organization membership update by just-in-time organization provisioning', () => {
it('should trigger `Organization.Membership.Updated` event when user is provisioned by Management API', async () => {
const organization = await organizationApi.create({ name: 'foo' });
const domain = 'example.com';
await organizationApi.jit.addEmailDomain(organization.id, domain);
await userApi.create({ primaryEmail: `${randomString()}@${domain}` });
await assertOrganizationMembershipUpdated(organization.id);
});
// TODO: Add user deletion test case
it('should trigger `Organization.Membership.Updated` event when user is provisioned by experience', async () => {
@ -160,9 +151,9 @@ describe('manual data hook tests', () => {
it('should trigger `Organization.Membership.Updated` event when user is provisioned by SSO', async () => {
const organization = await organizationApi.create({ name: 'bar' });
const domain = 'sso_example.com';
await organizationApi.jit.addEmailDomain(organization.id, domain);
const connector = await ssoConnectorApi.createMockOidcConnector([domain]);
await organizationApi.jit.ssoConnectors.add(organization.id, [connector.id]);
await updateSignInExperience({
singleSignOnEnabled: true,
});

View file

@ -14,13 +14,13 @@ import {
setEmailConnector,
setSmsConnector,
} from '#src/helpers/connector.js';
import { registerWithEmail } from '#src/helpers/interactions.js';
import { registerWithEmail, signInWithEmail } from '#src/helpers/interactions.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
import {
enableAllVerificationCodeSignInMethods,
resetPasswordPolicy,
} from '#src/helpers/sign-in-experience.js';
import { registerNewUserWithSso } from '#src/helpers/single-sign-on.js';
import { registerNewUserWithSso, signInWithSso } from '#src/helpers/single-sign-on.js';
import { generateEmail, randomString } from '#src/utils.js';
describe('organization just-in-time provisioning', () => {
@ -45,7 +45,7 @@ describe('organization just-in-time provisioning', () => {
});
});
it('should automatically provision a user to the organization with roles', async () => {
const prepare = async (emailDomain = `foo-${randomString()}.com`, ssoConnectorId?: string) => {
const organizations = await Promise.all([
organizationApi.create({ name: 'foo' }),
organizationApi.create({ name: 'bar' }),
@ -55,47 +55,83 @@ describe('organization just-in-time provisioning', () => {
organizationApi.roleApi.create({ name: randomString() }),
organizationApi.roleApi.create({ name: randomString() }),
]);
const emailDomain = 'foo.com';
await Promise.all(
organizations.map(async (organization) =>
organizationApi.jit.addEmailDomain(organization.id, emailDomain)
)
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: [],
}),
]),
};
};
beforeEach(async () => {
await updateSignInExperience({
singleSignOnEnabled: false,
});
});
afterEach(async () => {
await Promise.all([organizationApi.cleanUp(), ssoConnectorApi.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 with roles', async () => {
const { emailDomain, expectOrganizations } = await prepare();
const email = randomString() + '@' + emailDomain;
const { client, id } = await registerWithEmail(email);
const userOrganizations = await getUserOrganizations(id);
expect(userOrganizations).toEqual(
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: [],
}),
])
);
expect(userOrganizations).toEqual(expectOrganizations());
await logoutClient(client);
await deleteUser(id);
});
it('should automatically provision a user with the matched email to the organization from a SSO identity', async () => {
it('should not automatically 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);
@ -113,6 +149,28 @@ describe('organization just-in-time provisioning', () => {
},
});
const userOrganizations = await getUserOrganizations(userId);
expect(userOrganizations).toEqual([]);
await deleteUser(userId);
});
it('should automatically 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]);
await updateSignInExperience({
singleSignOnEnabled: true,
});
const userId = await registerNewUserWithSso(connector.id, {
authData: {
sub: randomString(),
email: generateEmail(domain),
email_verified: true,
},
});
const userOrganizations = await getUserOrganizations(userId);
expect(userOrganizations).toEqual(
@ -121,4 +179,48 @@ 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 () => {
const emailDomain = `foo-${randomString()}.com`;
const email = randomString() + '@' + emailDomain;
const { client } = await registerWithEmail(email);
await client.signOut();
await prepare(emailDomain);
const { id } = await signInWithEmail(email);
const userOrganizations = await getUserOrganizations(id);
expect(userOrganizations).toEqual([]);
await deleteUser(id);
});
it('should automatically provision a user with new SSO identity', async () => {
const domain = `sso-${randomString()}.com`;
const email = randomString() + '@' + domain;
// No organization should be provisioned at this point
const { client, id: userId } = await registerWithEmail(email);
expect(await getUserOrganizations(userId)).toEqual([]);
await client.signOut();
// Configure the SSO connector
const connector = await ssoConnectorApi.createMockOidcConnector([domain]);
const { expectOrganizations } = await prepare(undefined, connector.id);
await updateSignInExperience({
singleSignOnEnabled: true,
});
await signInWithSso(connector.id, {
authData: {
sub: randomString(),
email,
email_verified: true,
},
});
const userOrganizations = await getUserOrganizations(userId);
expect(userOrganizations).toEqual(expectOrganizations());
await deleteUser(userId);
});
});