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

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

* test(core): implement sso related integration tests

implement sso related integration tests

* chore(core): remove unnecessary comments

remove unnecessary comments
This commit is contained in:
simeng-li 2024-06-18 10:16:27 +08:00 committed by GitHub
parent 0ef712e4ea
commit d210f4f2e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 312 additions and 32 deletions

View file

@ -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;

View file

@ -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';
/**
@ -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;

View file

@ -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),
};
};

View file

@ -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 }>();
};

View file

@ -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;
@ -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];
}
}

View file

@ -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;
};

View file

@ -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';
@ -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();
@ -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();
});
});
});

View file

@ -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,
@ -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();
@ -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,
@ -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();
});
});

View file

@ -1,46 +1,36 @@
import { InteractionEvent, SsoProviderName, type SsoConnectorMetadata } from '@logto/schemas';
import { InteractionEvent } from '@logto/schemas';
import { deleteUser } from '#src/api/admin-user.js';
import { getSsoAuthorizationUrl, getSsoConnectorsByEmail } from '#src/api/interaction-sso.js';
import { putInteraction } from '#src/api/interaction.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js';
import { SsoConnectorApi } from '#src/api/sso-connector.js';
import { logtoUrl } from '#src/constants.js';
import { initClient } from '#src/helpers/client.js';
import { randomString } from '#src/utils.js';
import { registerNewUserWithSso, signInWithSso } from '#src/helpers/single-sign-on.js';
import { generateEmail, generateUserId, randomString } from '#src/utils.js';
describe('Single Sign On Happy Path', () => {
const connectorIdMap = new Map<string, SsoConnectorMetadata>();
const state = 'foo_state';
const redirectUri = 'http://foo.dev/callback';
const ssoConnectorApi = new SsoConnectorApi();
const domain = `foo${randomString()}.com`;
beforeAll(async () => {
const { id, connectorName } = await createSsoConnector({
providerName: SsoProviderName.OIDC,
connectorName: `test-oidc-${randomString()}`,
domains: [domain],
config: {
clientId: 'foo',
clientSecret: 'bar',
issuer: `${logtoUrl}/oidc`,
},
});
await ssoConnectorApi.createMockOidcConnector([domain]);
// Make sure single sign on is enabled
await updateSignInExperience({
singleSignOnEnabled: true,
});
connectorIdMap.set(id, { id, connectorName, logo: '' });
});
afterAll(async () => {
const connectorIds = Array.from(connectorIdMap.keys());
await Promise.all(connectorIds.map(async (id) => deleteSsoConnectorById(id)));
await ssoConnectorApi.cleanUp();
});
it('should get sso authorization url properly', async () => {
const state = 'foo_state';
const redirectUri = 'http://foo.dev/callback';
const client = await initClient();
await client.successSend(putInteraction, {
@ -48,7 +38,7 @@ describe('Single Sign On Happy Path', () => {
});
const response = await client.send(getSsoAuthorizationUrl, {
connectorId: Array.from(connectorIdMap.keys())[0]!,
connectorId: ssoConnectorApi.firstConnectorId!,
state,
redirectUri,
});
@ -68,7 +58,7 @@ describe('Single Sign On Happy Path', () => {
expect(response.length).toBeGreaterThan(0);
for (const connectorId of response) {
expect(connectorIdMap.has(connectorId)).toBe(true);
expect(ssoConnectorApi.connectorInstances.has(connectorId)).toBe(true);
}
});
@ -95,4 +85,29 @@ describe('Single Sign On Happy Path', () => {
expect(response.length).toBe(0);
});
describe('single sign-on interaction', () => {
const ssoUserId = generateUserId();
const ssoEmail = generateEmail();
it('should register new user with sso identity', async () => {
await registerNewUserWithSso(ssoConnectorApi.firstConnectorId!, {
authData: {
sub: ssoUserId,
email: ssoEmail,
},
});
});
it('should sign-in with sso identity', async () => {
const userId = await signInWithSso(ssoConnectorApi.firstConnectorId!, {
authData: {
sub: ssoUserId,
email: ssoEmail,
},
});
await deleteUser(userId);
});
});
});