diff --git a/packages/core/src/__mocks__/sso.ts b/packages/core/src/__mocks__/sso.ts index c4fee57eea0..5f5cc303bc0 100644 --- a/packages/core/src/__mocks__/sso.ts +++ b/packages/core/src/__mocks__/sso.ts @@ -27,3 +27,17 @@ export const wellConfiguredSsoConnector = { syncProfile: true, createdAt: Date.now(), } satisfies SsoConnector; + +export const mockSamlSsoConnector = { + id: 'mock-saml-sso-connector', + tenantId: 'mock-tenant', + providerName: SsoProviderName.SAML, + connectorName: 'mock-connector-name', + config: { + metadata: 'mock-metadata', + }, + domains: ['foo.com'], + branding: {}, + syncProfile: true, + createdAt: Date.now(), +} satisfies SsoConnector; diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts index 17d62e5e01e..188c575a69e 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts @@ -1,7 +1,14 @@ +/* eslint-disable max-lines */ import { createMockUtils } from '@logto/shared/esm'; import type Provider from 'oidc-provider'; - -import { mockSsoConnector, wellConfiguredSsoConnector } from '#src/__mocks__/sso.js'; +import Sinon from 'sinon'; + +import { + mockSsoConnector, + wellConfiguredSsoConnector, + mockSamlSsoConnector, +} from '#src/__mocks__/sso.js'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import { type WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js'; @@ -13,6 +20,8 @@ import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; +import { idpInitiatedSamlSsoSessionCookieName } from '../../../constants/index.js'; +import { SamlSsoConnector } from '../../../sso/SamlSsoConnector/index.js'; import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; const { jest } = import.meta; @@ -30,20 +39,31 @@ const insertUserMock = jest.fn().mockResolvedValue([{ id: 'foo' }, { organizatio const generateUserIdMock = jest.fn().mockResolvedValue('foo'); const getAvailableSsoConnectorsMock = jest.fn(); +const findIdpInitiatedSamlSsoSessionMock = jest.fn(); +const deleteIdpInitiatedSamlSsoSessionMock = jest.fn(); + class MockOidcSsoConnector extends OidcSsoConnector { override getAuthorizationUrl = getAuthorizationUrlMock; override getIssuer = getIssuerMock; override getUserInfo = getUserInfoMock; } +class MockSamlSsoConnector extends SamlSsoConnector { + override getAuthorizationUrl = getAuthorizationUrlMock; + override getIssuer = getIssuerMock; + override getUserInfo = getUserInfoMock; +} + mockEsm('./interaction.js', () => ({ storeInteractionResult: jest.fn(), })); const { + assignSingleSignOnSessionResult: assignSingleSignOnSessionResultMock, getSingleSignOnSessionResult: getSingleSignOnSessionResultMock, assignSingleSignOnAuthenticationResult: assignSingleSignOnAuthenticationResultMock, } = await mockEsmWithActual('./single-sign-on-session.js', () => ({ + assignSingleSignOnSessionResult: jest.fn(), getSingleSignOnSessionResult: jest.fn(), assignSingleSignOnAuthenticationResult: jest.fn(), })); @@ -51,6 +71,11 @@ const { jest .spyOn(ssoConnectorFactories.OIDC, 'constructor') .mockImplementation((data: SingleSignOnConnectorData) => new MockOidcSsoConnector(data)); +jest + .spyOn(ssoConnectorFactories.SAML, 'constructor') + .mockImplementation( + (data: SingleSignOnConnectorData) => new MockSamlSsoConnector(data, 'tenantId') + ); const { getSsoAuthorizationUrl, @@ -83,6 +108,10 @@ describe('Single sign on util methods tests', () => { updateUserById: updateUserMock, findUserByEmail: findUserByEmailMock, }, + ssoConnectors: { + findIdpInitiatedSamlSsoSessionById: findIdpInitiatedSamlSsoSessionMock, + deleteIdpInitiatedSamlSsoSessionById: deleteIdpInitiatedSamlSsoSessionMock, + }, }, undefined, { @@ -327,4 +356,171 @@ describe('Single sign on util methods tests', () => { }); }); }); + + describe('getSsoAuthorizationUrl tests with idp initiated sso session', () => { + const stub = Sinon.stub(EnvSet, 'values').value({ + ...EnvSet.values, + isDevFeaturesEnabled: true, + }); + + const payload = { + state: 'state', + redirectUri: 'https://example.com', + }; + + const samlSsoSessionId = 'samlSsoSessionId'; + const samlAuthorizationUrl = 'https://saml-connector/callback'; + + const mockContextWithIdpInitiatedSsoSession = { + ...mockContext, + ...createContextWithRouteParameters({ + cookies: { + [idpInitiatedSamlSsoSessionCookieName]: samlSsoSessionId, + }, + }), + }; + + afterAll(() => { + stub.restore(); + }); + + beforeEach(() => { + getAuthorizationUrlMock.mockResolvedValueOnce(samlAuthorizationUrl); + }); + + it('should not check idp initiated sso session if the connector is not SAML', async () => { + await expect( + getSsoAuthorizationUrl( + mockContextWithIdpInitiatedSsoSession, + tenant, + wellConfiguredSsoConnector, + payload + ) + ).resolves.toBe(samlAuthorizationUrl); + + expect(findIdpInitiatedSamlSsoSessionMock).not.toHaveBeenCalled(); + }); + + it('should not check idp initiated sso session if the session cookie is not found', async () => { + await expect( + getSsoAuthorizationUrl(mockContext, tenant, mockSamlSsoConnector, payload) + ).resolves.toBe(samlAuthorizationUrl); + + expect(findIdpInitiatedSamlSsoSessionMock).not.toHaveBeenCalled(); + expect(deleteIdpInitiatedSamlSsoSessionMock).not.toHaveBeenCalled(); + }); + + it('should redirect to the connector authorization uri if the idp initiated sso session is not found', async () => { + findIdpInitiatedSamlSsoSessionMock.mockResolvedValueOnce(null); + + await expect( + getSsoAuthorizationUrl( + mockContextWithIdpInitiatedSsoSession, + tenant, + mockSamlSsoConnector, + payload + ) + ).resolves.toBe(samlAuthorizationUrl); + + expect(findIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId); + }); + + it('should redirect to the connector authorization uri if the idp initiated sso session connectorId mismatch', async () => { + findIdpInitiatedSamlSsoSessionMock.mockResolvedValueOnce({ + id: samlSsoSessionId, + connectorId: 'foo', + }); + + await expect( + getSsoAuthorizationUrl( + mockContextWithIdpInitiatedSsoSession, + tenant, + mockSamlSsoConnector, + payload + ) + ).resolves.toBe(samlAuthorizationUrl); + + expect(findIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId); + expect(deleteIdpInitiatedSamlSsoSessionMock).not.toHaveBeenCalled(); + }); + + it('should redirect to the connector authorization uri if the idp initiated sso session is expired', async () => { + findIdpInitiatedSamlSsoSessionMock.mockResolvedValueOnce({ + id: samlSsoSessionId, + connectorId: mockSamlSsoConnector.id, + expiresAt: Date.now() - 1000 * 60 * 11, + }); + + await expect( + getSsoAuthorizationUrl( + mockContextWithIdpInitiatedSsoSession, + tenant, + mockSamlSsoConnector, + payload + ) + ).resolves.toBe(samlAuthorizationUrl); + + expect(findIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId); + + // Should delete the idp initiated sso session + expect(mockContextWithIdpInitiatedSsoSession.cookies.set).toBeCalledWith( + idpInitiatedSamlSsoSessionCookieName, + '', + { + httpOnly: true, + expires: new Date(0), + } + ); + expect(deleteIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId); + }); + + it('should assign the user info and redirect to the SSO callback uri if the idp initiated sso session is valid', async () => { + findIdpInitiatedSamlSsoSessionMock.mockResolvedValueOnce({ + id: samlSsoSessionId, + connectorId: mockSamlSsoConnector.id, + expiresAt: Date.now() + 1000 * 60 * 10, + assertionContent: { + nameID: mockSsoUserInfo.id, + attributes: { + email: mockSsoUserInfo.email, + name: mockSsoUserInfo.name, + avatar: mockSsoUserInfo.avatar, + }, + }, + }); + + await expect( + getSsoAuthorizationUrl( + mockContextWithIdpInitiatedSsoSession, + tenant, + mockSamlSsoConnector, + payload + ) + ).resolves.toBe(`${payload.redirectUri}/?state=${payload.state}`); + + expect(findIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId); + + expect(assignSingleSignOnSessionResultMock).toBeCalledWith( + mockContextWithIdpInitiatedSsoSession, + mockProvider, + { + connectorId: mockSamlSsoConnector.id, + ...payload, + userInfo: mockSsoUserInfo, + } + ); + + // Should delete the idp initiated sso session + expect(mockContextWithIdpInitiatedSsoSession.cookies.set).toBeCalledWith( + idpInitiatedSamlSsoSessionCookieName, + '', + { + httpOnly: true, + expires: new Date(0), + } + ); + expect(deleteIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId); + }); + }); }); +/* eslint-enable max-lines */ 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 97a989408b1..07b480e1fcc 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -11,16 +11,16 @@ import { generateStandardId } from '@logto/shared'; import { conditional, trySafe } from '@silverhand/essentials'; import { z } from 'zod'; +import { idpInitiatedSamlSsoSessionCookieName } from '#src/constants/index.js'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import SamlConnector from '#src/sso/SamlConnector/index.js'; import { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/index.js'; import type Queries from '#src/tenants/Queries.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; -import { idpInitiatedSamlSsoSessionCookieName } from '../../../constants/index.js'; -import { EnvSet } from '../../../env-set/index.js'; -import SamlConnector from '../../../sso/SamlConnector/index.js'; import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; import { diff --git a/packages/core/src/utils/test-utils.ts b/packages/core/src/utils/test-utils.ts index e334aa4ea6a..cc7c709b967 100644 --- a/packages/core/src/utils/test-utils.ts +++ b/packages/core/src/utils/test-utils.ts @@ -99,6 +99,7 @@ export const createContextWithRouteParameters = ( set: ctx.set, path: ctx.path, URL: ctx.URL, + cookies: ctx.cookies, params: {}, headers: {}, router: new Router(),