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:
parent
0ef712e4ea
commit
d210f4f2e4
9 changed files with 312 additions and 32 deletions
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
30
packages/core/src/sso/OidcConnector/test-utils.ts
Normal file
30
packages/core/src/sso/OidcConnector/test-utils.ts
Normal 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),
|
||||
};
|
||||
};
|
|
@ -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 }>();
|
||||
};
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
104
packages/integration-tests/src/helpers/single-sign-on.ts
Normal file
104
packages/integration-tests/src/helpers/single-sign-on.ts
Normal 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;
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue