Skip to content

Commit

Permalink
test(core): implement sso related integration tests (#6041)
Browse files Browse the repository at this point in the history
* test(core): implement sso related integration tests

implement sso related integration tests

* chore(core): remove unnecessary comments

remove unnecessary comments
  • Loading branch information
simeng-li committed Jun 18, 2024
1 parent 0ef712e commit d210f4f
Show file tree
Hide file tree
Showing 9 changed files with 312 additions and 32 deletions.
3 changes: 1 addition & 2 deletions packages/core/src/routes/interaction/utils/single-sign-on.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,7 @@ export const handleSsoAuthentication = async (
connectorData: SupportedSsoConnector,
ssoAuthentication: SsoAuthenticationResult
): Promise<string> => {
const { createLog } = ctx;
const { provider, queries } = tenant;
const { queries } = tenant;
const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = queries;
const { issuer, userInfo } = ssoAuthentication;

Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/sso/OidcConnector/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ import { conditional } from '@silverhand/essentials';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';

import { EnvSet } from '#src/env-set/index.js';
import assertThat from '#src/utils/assert-that.js';

import { SsoConnectorError, SsoConnectorErrorCodes } from '../types/error.js';
import {
scopePostProcessor,
type BaseOidcConfig,
type BasicOidcConnectorConfig,
scopePostProcessor,
} from '../types/oidc.js';
import { type ExtendedSocialUserInfo } from '../types/saml.js';
import {
type SingleSignOnConnectorSession,
type CreateSingleSignOnSession,
type SingleSignOnConnectorSession,
} from '../types/session.js';

import { mockGetUserInfo } from './test-utils.js';
import { fetchOidcConfig, fetchToken, getIdTokenClaims, getUserInfo } from './utils.js';

/**
Expand Down Expand Up @@ -100,6 +102,12 @@ class OidcConnector {
connectorSession: SingleSignOnConnectorSession,
data: unknown
): Promise<ExtendedSocialUserInfo> {
const { isIntegrationTest } = EnvSet.values;

if (isIntegrationTest) {
return mockGetUserInfo(connectorSession, data);
}

const oidcConfig = await this.getOidcConfig();
const { nonce, redirectUri } = connectorSession;

Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/sso/OidcConnector/test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { conditional } from '@silverhand/essentials';
import camelcaseKeys from 'camelcase-keys';

import assertThat from '#src/utils/assert-that.js';

import { SsoConnectorError, SsoConnectorErrorCodes } from '../types/error.js';
import { idTokenProfileStandardClaimsGuard } from '../types/oidc.js';
import { type SingleSignOnConnectorSession } from '../types/session.js';

export const mockGetUserInfo = (connectorSession: SingleSignOnConnectorSession, data: unknown) => {
const result = idTokenProfileStandardClaimsGuard.safeParse(data);

assertThat(
result.success,
new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, {
message: 'Invalid user info',
})
);

const { sub, name, picture, email, email_verified, phone, phone_verified, ...rest } = result.data;

return {
id: sub,
...conditional(name && { name }),
...conditional(picture && { avatar: picture }),
...conditional(email && email_verified && { email }),
...conditional(phone && phone_verified && { phone }),
...camelcaseKeys(rest),
};
};
24 changes: 24 additions & 0 deletions packages/integration-tests/src/api/interaction-sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,27 @@ export const postSamlAssertion = async (data: {
})
.json();
};

export const postSsoAuthentication = async (
cookie: string,
payload: {
connectorId: string;
data: Record<string, unknown>;
}
) => {
const { connectorId, data } = payload;
return api
.post(`interaction/${ssoPath}/${connectorId}/authentication`, {
headers: { cookie },
json: data,
})
.json<{ redirectTo: string }>();
};

export const postSsoRegistration = async (cookie: string, connectorId: string) => {
return api
.post(`interaction/${ssoPath}/${connectorId}/registration`, {
headers: { cookie },
})
.json<{ redirectTo: string }>();
};
44 changes: 44 additions & 0 deletions packages/integration-tests/src/api/sso-connector.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {
SsoProviderName,
type CreateSsoConnector,
type SsoConnector,
type SsoConnectorProvidersResponse,
} from '@logto/schemas';

import { authedAdminApi } from '#src/api/api.js';
import { logtoUrl } from '#src/constants.js';
import { randomString } from '#src/utils.js';

export type SsoConnectorWithProviderConfig = SsoConnector & {
providerLogo: string;
Expand Down Expand Up @@ -37,3 +40,44 @@ export const patchSsoConnectorById = async (id: string, data: Partial<SsoConnect
json: data,
})
.json<SsoConnectorWithProviderConfig>();

export class SsoConnectorApi {
readonly connectorInstances = new Map<string, SsoConnector>();

async createMockOidcConnector(domains: string[], connectorName?: string) {
const connector = await this.create({
providerName: SsoProviderName.OIDC,
connectorName: connectorName ?? `test-oidc-${randomString()}`,
domains,
config: {
clientId: 'foo',
clientSecret: 'bar',
issuer: `${logtoUrl}/oidc`,
},
});

return connector;
}

async create(data: Partial<CreateSsoConnector>): Promise<SsoConnector> {
const connector = await createSsoConnector(data);

this.connectorInstances.set(connector.id, connector);
return connector;
}

async delete(id: string) {
await deleteSsoConnectorById(id);
this.connectorInstances.delete(id);
}

async cleanUp() {
await Promise.all(
Array.from(this.connectorInstances.keys()).map(async (id) => this.delete(id))
);
}

get firstConnectorId() {
return Array.from(this.connectorInstances.keys())[0];
}
}
104 changes: 104 additions & 0 deletions packages/integration-tests/src/helpers/single-sign-on.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { InteractionEvent } from '@logto/schemas';

import {
getSsoAuthorizationUrl,
postSsoAuthentication,
postSsoRegistration,
} from '#src/api/interaction-sso.js';
import { putInteractionEvent } from '#src/api/interaction.js';

import { putInteraction } from './admin-tenant.js';
import { initClient, logoutClient, processSession } from './client.js';
import { expectRejects } from './index.js';

export type MockOidcSsoConnectorIdTokenProfileStandardClaims = {
sub: string;
name?: string;
picture?: string;
email?: string;
email_verified?: boolean;
phone?: string;
phone_verified?: boolean;
};

export const registerNewUserWithSso = async (
connectorId: string,
params: {
authData: MockOidcSsoConnectorIdTokenProfileStandardClaims;
}
) => {
const state = 'foo_state';
const redirectUri = 'http://foo.dev/callback';

const { authData } = params;
const client = await initClient();

await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
});

const response = await client.send(getSsoAuthorizationUrl, {
connectorId,
state,
redirectUri,
});

expect(response.redirectTo).not.toBeUndefined();
expect(response.redirectTo.indexOf(state)).not.toBe(-1);

await expectRejects(
client.send(postSsoAuthentication, {
connectorId,
data: authData,
}),
{
code: 'user.identity_not_exist',
status: 422,
}
);

await client.successSend(putInteractionEvent, { event: InteractionEvent.Register });

const { redirectTo } = await client.send(postSsoRegistration, connectorId);

const userId = await processSession(client, redirectTo);
await logoutClient(client);

return userId;
};

export const signInWithSso = async (
connectorId: string,
params: {
authData: MockOidcSsoConnectorIdTokenProfileStandardClaims;
}
) => {
const state = 'foo_state';
const redirectUri = 'http://foo.dev/callback';

const { authData } = params;
const client = await initClient();

await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
});

const response = await client.send(getSsoAuthorizationUrl, {
connectorId,
state,
redirectUri,
});

expect(response.redirectTo).not.toBeUndefined();
expect(response.redirectTo.indexOf(state)).not.toBe(-1);

const { redirectTo } = await client.send(postSsoAuthentication, {
connectorId,
data: authData,
});

const userId = await processSession(client, redirectTo);
await logoutClient(client);

return userId;
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import { deleteUser } from '#src/api/admin-user.js';
import { createResource, deleteResource } from '#src/api/resource.js';
import { createRole } from '#src/api/role.js';
import { createScope } from '#src/api/scope.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { SsoConnectorApi } from '#src/api/sso-connector.js';
import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js';
import { WebHookApiTest } from '#src/helpers/hook.js';
import { registerWithEmail } from '#src/helpers/interactions.js';
import { OrganizationApiTest } from '#src/helpers/organization.js';
import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js';
import { registerNewUserWithSso } from '#src/helpers/single-sign-on.js';
import { UserApiTest } from '#src/helpers/user.js';
import { generateName, generateRoleName, randomString } from '#src/utils.js';
import { generateEmail, generateName, generateRoleName, randomString } from '#src/utils.js';

import WebhookMockServer from './WebhookMockServer.js';
import { assertHookLogResult } from './utils.js';
Expand All @@ -22,6 +25,7 @@ describe('manual data hook tests', () => {
const userApi = new UserApiTest();
const organizationApi = new OrganizationApiTest();
const hookName = 'customDataHookEventListener';
const ssoConnectorApi = new SsoConnectorApi();

beforeAll(async () => {
await webbHookMockServer.listen();
Expand Down Expand Up @@ -148,6 +152,27 @@ describe('manual data hook tests', () => {
await assertOrganizationMembershipUpdated(organization.id);
});

// TODO: Add SSO test case
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 updateSignInExperience({
singleSignOnEnabled: true,
});

await registerNewUserWithSso(connector.id, {
authData: {
sub: randomString(),
email: generateEmail(domain),
email_verified: true,
},
});

await assertOrganizationMembershipUpdated(organization.id);

await ssoConnectorApi.cleanUp();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

import { ConnectorType, SignInIdentifier } from '@logto/schemas';

import { deleteUser, getUserOrganizations } from '#src/api/index.js';
import { deleteUser, getUserOrganizations, updateSignInExperience } from '#src/api/index.js';
import { SsoConnectorApi } from '#src/api/sso-connector.js';
import { logoutClient } from '#src/helpers/client.js';
import {
clearConnectorsByTypes,
Expand All @@ -19,10 +20,12 @@ import {
enableAllVerificationCodeSignInMethods,
resetPasswordPolicy,
} from '#src/helpers/sign-in-experience.js';
import { randomString } from '#src/utils.js';
import { registerNewUserWithSso } from '#src/helpers/single-sign-on.js';
import { generateEmail, randomString } from '#src/utils.js';

describe('organization just-in-time provisioning', () => {
const organizationApi = new OrganizationApiTest();
const ssoConnectorApi = new SsoConnectorApi();

afterEach(async () => {
await organizationApi.cleanUp();
Expand All @@ -31,8 +34,10 @@ describe('organization just-in-time provisioning', () => {
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,
Expand Down Expand Up @@ -64,5 +69,31 @@ describe('organization just-in-time provisioning', () => {
await deleteUser(id);
});

// TODO: Add SSO test case
it('should automatically provision a user to the organization 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]);
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(
expect.arrayContaining([expect.objectContaining({ id: organization.id })])
);

await deleteUser(userId);
await ssoConnectorApi.cleanUp();
});
});
Loading

0 comments on commit d210f4f

Please sign in to comment.