diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.ts b/packages/core/src/routes/interaction/utils/single-sign-on.ts index e67514a3b55..77ae536f243 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -138,8 +138,7 @@ export const handleSsoAuthentication = async ( connectorData: SupportedSsoConnector, ssoAuthentication: SsoAuthenticationResult ): Promise => { - const { createLog } = ctx; - const { provider, queries } = tenant; + const { queries } = tenant; const { userSsoIdentities: userSsoIdentitiesQueries, users: usersQueries } = queries; const { issuer, userInfo } = ssoAuthentication; diff --git a/packages/core/src/sso/OidcConnector/index.ts b/packages/core/src/sso/OidcConnector/index.ts index 480318ed2fa..b86ebbd3f53 100644 --- a/packages/core/src/sso/OidcConnector/index.ts +++ b/packages/core/src/sso/OidcConnector/index.ts @@ -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 { + const { isIntegrationTest } = EnvSet.values; + + if (isIntegrationTest) { + return mockGetUserInfo(connectorSession, data); + } + const oidcConfig = await this.getOidcConfig(); const { nonce, redirectUri } = connectorSession; diff --git a/packages/core/src/sso/OidcConnector/test-utils.ts b/packages/core/src/sso/OidcConnector/test-utils.ts new file mode 100644 index 00000000000..39cfb3accf4 --- /dev/null +++ b/packages/core/src/sso/OidcConnector/test-utils.ts @@ -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), + }; +}; diff --git a/packages/integration-tests/src/api/interaction-sso.ts b/packages/integration-tests/src/api/interaction-sso.ts index 9d2011c5d72..5bd78f019e7 100644 --- a/packages/integration-tests/src/api/interaction-sso.ts +++ b/packages/integration-tests/src/api/interaction-sso.ts @@ -47,3 +47,27 @@ export const postSamlAssertion = async (data: { }) .json(); }; + +export const postSsoAuthentication = async ( + cookie: string, + payload: { + connectorId: string; + data: Record; + } +) => { + 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 }>(); +}; diff --git a/packages/integration-tests/src/api/sso-connector.ts b/packages/integration-tests/src/api/sso-connector.ts index 17c99bf1260..8baf77c567d 100644 --- a/packages/integration-tests/src/api/sso-connector.ts +++ b/packages/integration-tests/src/api/sso-connector.ts @@ -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(); + +export class SsoConnectorApi { + readonly connectorInstances = new Map(); + + 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): Promise { + 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]; + } +} diff --git a/packages/integration-tests/src/helpers/single-sign-on.ts b/packages/integration-tests/src/helpers/single-sign-on.ts new file mode 100644 index 00000000000..2313a33532c --- /dev/null +++ b/packages/integration-tests/src/helpers/single-sign-on.ts @@ -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; +}; diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts index 6c0a5fa348f..e3ab6f59345 100644 --- a/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts @@ -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(); + }); }); }); diff --git a/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts b/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts index af9eb5f2442..810287a625b 100644 --- a/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts @@ -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(); + }); }); diff --git a/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts index 966044b3b53..6469d5f6562 100644 --- a/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts @@ -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(); - - 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); + }); + }); });