Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test(core): implement sso related integration tests #6041

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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 @@
connectorSession: SingleSignOnConnectorSession,
data: unknown
): Promise<ExtendedSocialUserInfo> {
const { isIntegrationTest } = EnvSet.values;

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

Check warning on line 110 in packages/core/src/sso/OidcConnector/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/sso/OidcConnector/index.ts#L105-L110

Added lines #L105 - L110 were not covered by tests
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),
};
};

Check warning on line 30 in packages/core/src/sso/OidcConnector/test-utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/sso/OidcConnector/test-utils.ts#L11-L30

Added lines #L11 - L30 were not covered by tests
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 { 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 @@
const userApi = new UserApiTest();
const organizationApi = new OrganizationApiTest();
const hookName = 'customDataHookEventListener';
const ssoConnectorApi = new SsoConnectorApi();

beforeAll(async () => {
await webbHookMockServer.listen();
Expand Down Expand Up @@ -129,7 +133,7 @@
await assertOrganizationMembershipUpdated(organization.id);
});

// TODO: Add user deletion test case

Check warning on line 136 in packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts#L136

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Add user deletion test case'.

it('should trigger `Organization.Membership.Updated` event when user is provisioned by experience', async () => {
await setEmailConnector();
Expand All @@ -148,6 +152,27 @@
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
Loading