From edb2bdccecbcf77a26480c94d8c9324c2f15d503 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 17 Nov 2023 14:14:45 +0800 Subject: [PATCH] feat(core): add the saml assertion handle flow (#4872) * feat(core): add saml assertion handle flow add saml assertion handle flow * fix(core): address some comments address some comments * chore(core): comments update comments update * chore(core): add deprecated tag add dreprecated tag * refactor(core): use url object instead of plain string use url object instead of plain string --- packages/core/src/routes/authn.ts | 84 +++++++++++- .../interaction/utils/single-sign-on-guard.ts | 5 - .../interaction/utils/single-sign-on.test.ts | 37 +++++- .../interaction/utils/single-sign-on.ts | 6 +- packages/core/src/sso/OidcConnector/index.ts | 18 +-- packages/core/src/sso/SamlConnector/index.ts | 20 +-- .../core/src/sso/SamlSsoConnector/index.ts | 18 ++- .../core/src/utils/saml-assertion-handler.ts | 58 +++++++++ .../src/__mocks__/sso-connectors-mock.ts | 44 +++++++ .../src/api/interaction-sso.ts | 13 ++ .../single-sign-on/sad-path.test.ts | 123 +++++++++++++----- 11 files changed, 359 insertions(+), 67 deletions(-) diff --git a/packages/core/src/routes/authn.ts b/packages/core/src/routes/authn.ts index 6699e31a7..8edd18fd4 100644 --- a/packages/core/src/routes/authn.ts +++ b/packages/core/src/routes/authn.ts @@ -6,12 +6,17 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import SamlConnector from '#src/sso/SamlConnector/index.js'; +import { ssoConnectorFactories } from '#src/sso/index.js'; import assertThat from '#src/utils/assert-that.js'; import { getConnectorSessionResultFromJti, assignConnectorSessionResultViaJti, + getSingleSignOnSessionResultByJti, + assignSamlAssertionResultViaJti, } from '#src/utils/saml-assertion-handler.js'; +import { ssoPath } from './interaction/const.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; /** @@ -19,11 +24,12 @@ import type { AnonymousRouter, RouterInitArgs } from './types.js'; * This router will have a route `/authn` to authenticate tokens with a general manner. */ export default function authnRoutes( - ...[router, { envSet, provider, libraries }]: RouterInitArgs + ...[router, { envSet, provider, libraries, id: tenantId }]: RouterInitArgs ) { const { users: { findUserRoles }, socials: { getConnector }, + ssoConnectors: ssoConnectorsLibrary, } = libraries; const hasuraResponseGuard = z.object({ @@ -90,7 +96,11 @@ export default function authnRoutes( } ); - // Create an specialized API to handle SAML assertion + /** + * Standard SAML social connector's assertion consumer service endpoint + * @deprecated + * Will be replaced by the SSO SAML assertion consumer service endpoint bellow. + */ router.post( '/authn/saml/:connectorId', /** @@ -144,4 +154,74 @@ export default function authnRoutes( return next(); } ); + + /** + * SAML SSO connector's assertion consumer service endpoint + * + * @param connectorId The connector id. + * @property body The SAML assertion response body. + * @property body.RelayState We use this to find the connector session. + * RelayState is a SAML standard parameter that will be transmitted between the identity provider and the service provider. + * @returns Redirect to the redirect uri find in the connector session storage. + * + * @remark + * This API is used to handle SSO SAML assertion from the identity provider. + * Validate and parse the SAML assertion, then store the assertion data to the connector session storage. + * Redirect to the redirect uri find in the connector session storage. + */ + router.post( + `/authn/${ssoPath}/saml/:connectorId`, + koaGuard({ + body: z.object({ RelayState: z.string() }).catchall(z.unknown()), + params: z.object({ connectorId: z.string().min(1) }), + status: [302, 404], + }), + async (ctx, next) => { + const { + params: { connectorId }, + body, + } = ctx.guard; + + // Will throw 404 if connector not found, or not supported + const connectorData = await ssoConnectorsLibrary.getSsoConnectorById(connectorId); + const { providerName } = connectorData; + + // Get relay state from the request body. We need to use it to find the connector session. + // All the rest of the request body will be validated and parsed by the connector. + const { RelayState: jti } = body; + + // Retrieve the single sign on session data using the jti + const singleSignOnSession = await getSingleSignOnSessionResultByJti(jti, provider); + + const { redirectUri, state, connectorId: sessionConnectorId } = singleSignOnSession; + + assertThat( + connectorId === sessionConnectorId, + 'session.connector_validation_session_not_found' + ); + + // Will throw ConnectorError if the config is invalid + const connectorInstance = new ssoConnectorFactories[providerName].constructor( + connectorData, + tenantId + ); + + assertThat(connectorInstance instanceof SamlConnector, 'connector.unexpected_type'); + + const userInfo = await connectorInstance.parseSamlAssertion(body); + + await assignSamlAssertionResultViaJti(jti, provider, { + ...singleSignOnSession, + userInfo, + }); + + // Client side will verify the state to prevent CSRF attack. + const url = new URL(redirectUri); + url.searchParams.append('state', state); + + ctx.redirect(url.toString()); + + return next(); + } + ); } diff --git a/packages/core/src/routes/interaction/utils/single-sign-on-guard.ts b/packages/core/src/routes/interaction/utils/single-sign-on-guard.ts index 335e2380f..1abdf1132 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on-guard.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on-guard.ts @@ -52,11 +52,6 @@ export const verifySsoOnlyEmailIdentifier = async ( /** * Get the single sign on session data from the oidc provider session storage. * - * @param ctx - * @param provider - * @param connectorId - * @returns The single sign on session data - * * @remark Forked from ./social-verification.ts. * Use SingleSignOnSession guard instead of ConnectorSession guard. */ 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 77b4a45f6..4d3983ee3 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 @@ -3,6 +3,7 @@ import { createMockUtils } from '@logto/shared/esm'; import type Provider from 'oidc-provider'; import { mockSsoConnector, wellConfiguredSsoConnector } from '#src/__mocks__/sso.js'; +import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import { OidcSsoConnector } from '#src/sso/OidcSsoConnector/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js'; @@ -27,6 +28,7 @@ const findUserByEmailMock = jest.fn(); const insertUserMock = jest.fn(); const storeInteractionResultMock = jest.fn(); const getAvailableSsoConnectorsMock = jest.fn(); +const getSingleSignOnSessionResultMock = jest.fn(); class MockOidcSsoConnector extends OidcSsoConnector { override getAuthorizationUrl = getAuthorizationUrlMock; @@ -38,6 +40,10 @@ mockEsm('./interaction.js', () => ({ storeInteractionResult: storeInteractionResultMock, })); +mockEsm('./single-sign-on-guard.js', () => ({ + getSingleSignOnSessionResult: getSingleSignOnSessionResultMock, +})); + jest .spyOn(ssoConnectorFactories.OIDC, 'constructor') .mockImplementation((data: SsoConnector) => new MockOidcSsoConnector(data)); @@ -109,6 +115,20 @@ describe('Single sign on util methods tests', () => { }); describe('getSsoAuthentication tests', () => { + it('should throw an error if the connector config is invalid', async () => { + getSingleSignOnSessionResultMock.mockRejectedValueOnce( + new RequestError('session.connector_validation_session_not_found') + ); + + await expect(getSsoAuthentication(mockContext, tenant, mockSsoConnector, {})).rejects.toThrow( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + expect.objectContaining({ + status: 400, + code: `session.connector_validation_session_not_found`, + }) + ); + }); + it('should throw an error if the connector config is invalid', async () => { await expect(getSsoAuthentication(mockContext, tenant, mockSsoConnector, {})).rejects.toThrow( // eslint-disable-next-line @typescript-eslint/no-unsafe-argument @@ -117,6 +137,19 @@ describe('Single sign on util methods tests', () => { }); it('should return the authentication result', async () => { + const session = { + connectorId: 'connectorId', + jti: 'jti', + redirectUri: 'https://example.com', + state: 'state', + }; + + const payload = { + code: 'code', + }; + + getSingleSignOnSessionResultMock.mockResolvedValueOnce(session); + getUserInfoMock.mockResolvedValueOnce({ id: 'id', email: 'email' }); getIssuerMock.mockReturnValueOnce('https://example.com'); @@ -124,11 +157,11 @@ describe('Single sign on util methods tests', () => { mockContext, tenant, wellConfiguredSsoConnector, - {} + payload ); expect(getIssuerMock).toBeCalled(); - expect(getUserInfoMock).toBeCalled(); + expect(getUserInfoMock).toBeCalledWith(session, payload); expect(result).toStrictEqual({ issuer: 'https://example.com', 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 695b43695..9ec65e9f3 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -90,6 +90,8 @@ export const getSsoAuthentication = async ( const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Submit`); log.append({ connectorId, data }); + const singleSignOnSession = await getSingleSignOnSessionResult(ctx, provider); + try { // Will throw ConnectorError if the config is invalid const connectorInstance = new ssoConnectorFactories[providerName].constructor( @@ -98,9 +100,7 @@ export const getSsoAuthentication = async ( ); const issuer = await connectorInstance.getIssuer(); - const userInfo = await connectorInstance.getUserInfo(data, async () => - getSingleSignOnSessionResult(ctx, provider) - ); + const userInfo = await connectorInstance.getUserInfo(singleSignOnSession, data); const result = { issuer, diff --git a/packages/core/src/sso/OidcConnector/index.ts b/packages/core/src/sso/OidcConnector/index.ts index 92b01d446..496f2e6d5 100644 --- a/packages/core/src/sso/OidcConnector/index.ts +++ b/packages/core/src/sso/OidcConnector/index.ts @@ -4,7 +4,10 @@ import { assert, conditional } from '@silverhand/essentials'; import snakecaseKeys from 'snakecase-keys'; import { type BaseOidcConfig, type BasicOidcConnectorConfig } from '../types/oidc.js'; -import { type CreateSingleSignOnSession, type GetSingleSignOnSession } from '../types/session.js'; +import { + type SingleSignOnConnectorSession, + type CreateSingleSignOnSession, +} from '../types/session.js'; import { fetchOidcConfig, fetchToken, getIdTokenClaims } from './utils.js'; @@ -91,21 +94,14 @@ class OidcConnector { * Handle the sign-in callback from the OIDC provider and return the user info * * @param data unknown oidc authorization response - * @param getSession Get the connector session data from the oidc provider session storage. @see @logto/connector-kit + * @param connectorSession The connector session data from the oidc provider session storage * @returns The user info from the OIDC provider * @remark Forked from @logto/oidc-connector * */ - getUserInfo = async (data: unknown, getSession: GetSingleSignOnSession) => { - assert( - getSession, - new ConnectorError(ConnectorErrorCodes.NotImplemented, { - message: 'Connector session storage not found', - }) - ); - + getUserInfo = async (connectorSession: SingleSignOnConnectorSession, data: unknown) => { const oidcConfig = await this.getOidcConfig(); - const { nonce, redirectUri } = await getSession(); + const { nonce, redirectUri } = connectorSession; // Fetch token from the OIDC provider using authorization code const { idToken } = await fetchToken(oidcConfig, data, redirectUri); diff --git a/packages/core/src/sso/SamlConnector/index.ts b/packages/core/src/sso/SamlConnector/index.ts index 093c671ed..8dfe9f9b6 100644 --- a/packages/core/src/sso/SamlConnector/index.ts +++ b/packages/core/src/sso/SamlConnector/index.ts @@ -4,8 +4,14 @@ import * as saml from 'samlify'; import { z } from 'zod'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; +import { ssoPath } from '#src/routes/interaction/const.js'; -import { type SamlConfig, type SamlConnectorConfig, samlMetadataGuard } from '../types/saml.js'; +import { + type SamlConfig, + type SamlConnectorConfig, + samlMetadataGuard, + type ExtendedSocialUserInfo, +} from '../types/saml.js'; import { parseXmlMetadata, @@ -23,7 +29,7 @@ import { * All the SAML single sign-on connector should extend this class. * * @property config The SAML connector config - * @property acsUrl The SAML connector's assertion consumer service URL + * @property acsUrl The SAML connector's assertion consumer service URL {@link file://src/routes/authn.ts} * @property _rawSamlMetadata The cached raw SAML metadata (in XML-format) from the raw SAML SSO connector config * @property _parsedSamlMetadata The cached parsed SAML metadata from the raw SAML SSO connector config * @property _samlAssertionContent The cached parsed SAML assertion from IdP (with attribute mapping applied) @@ -45,8 +51,7 @@ class SamlConnector { ) { this.acsUrl = appendPath( getTenantEndpoint(tenantId, EnvSet.values), - // TODO: update this endpoint - `api/authn/saml/sso/${ssoConnectorId}` + `api/authn/${ssoPath}/saml/${ssoConnectorId}` ).toString(); } @@ -93,7 +98,7 @@ class SamlConnector { * * @returns The parsed SAML assertion from IdP (with attribute mapping applied). */ - async parseSamlAssertion(assertion: Record) { + async parseSamlAssertion(assertion: Record): Promise { const parsedConfig = await this.getSamlConfig(); const profileMap = attributeMappingPostProcessor(parsedConfig.attributeMapping); const idpMetadataXml = await this.getIdpXmlMetadata(); @@ -149,16 +154,15 @@ class SamlConnector { // eslint-disable-next-line new-cap const identityProvider = saml.IdentityProvider({ - wantAuthnRequestsSigned: true, // Sign auth request by default metadata: idpMetadataXml, }); + // eslint-disable-next-line new-cap const serviceProvider = saml.ServiceProvider({ - entityID, + entityID, // FIXME: @darcyYe relayState, nameIDFormat: nameIdFormat, signingCert: x509Certificate, - authnRequestsSigned: true, // Sign auth request by default requestSignatureAlgorithm: signingAlgorithm, assertionConsumerService: [ { diff --git a/packages/core/src/sso/SamlSsoConnector/index.ts b/packages/core/src/sso/SamlSsoConnector/index.ts index b93873818..cb00e01d2 100644 --- a/packages/core/src/sso/SamlSsoConnector/index.ts +++ b/packages/core/src/sso/SamlSsoConnector/index.ts @@ -2,12 +2,16 @@ import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import { type SsoConnector } from '@logto/schemas'; import { SsoProviderName } from '#src/sso/types/index.js'; +import assertThat from '#src/utils/assert-that.js'; import SamlConnector from '../SamlConnector/index.js'; import { type SingleSignOnFactory } from '../index.js'; import { type SingleSignOn } from '../types/index.js'; import { samlConnectorConfigGuard } from '../types/saml.js'; -import { type CreateSingleSignOnSession } from '../types/session.js'; +import { + type SingleSignOnConnectorSession, + type CreateSingleSignOnSession, +} from '../types/session.js'; /** * SAML SSO connector @@ -75,12 +79,16 @@ export class SamlSsoConnector extends SamlConnector implements SingleSignOn { /** * Get social user info. * - * @param assertion The SAML assertion from IdP. - * + * @param connectorSession The connector session data from interaction session storage. * @returns The social user info extracted from SAML assertion. + * + * @remarks For SAML connector, userInfo will be extracted from the SAML assertion by ACS callback endpoint. + * This method only asserts the userInfo is not null and directly return it. */ - async getUserInfo(assertion: Record) { - return this.parseSamlAssertion(assertion); + async getUserInfo({ userInfo }: SingleSignOnConnectorSession) { + assertThat(userInfo, 'session.connector_validation_session_not_found'); + + return userInfo; } } diff --git a/packages/core/src/utils/saml-assertion-handler.ts b/packages/core/src/utils/saml-assertion-handler.ts index d25641268..507228625 100644 --- a/packages/core/src/utils/saml-assertion-handler.ts +++ b/packages/core/src/utils/saml-assertion-handler.ts @@ -7,6 +7,11 @@ import { getInteractionFromProviderByJti, assignResultToInteraction, } from '#src/routes/interaction/utils/interaction.js'; +import { + type SingleSignOnConnectorSession, + singleSignOnConnectorSessionGuard, +} from '#src/sso/types/index.js'; +import { type ExtendedSocialUserInfo } from '#src/sso/types/saml.js'; import assertThat from './assert-that.js'; @@ -39,6 +44,11 @@ export const assignConnectorSessionResultViaJti = async ( }); }; +/** + * Used by the standard SAML social connectors ACS endpoint. + * @deprecated + * Will be cleaned once the old SAML social connectors are removed. + */ export const getConnectorSessionResultFromJti = async ( jti: string, provider: Provider @@ -60,3 +70,51 @@ export const getConnectorSessionResultFromJti = async ( return connectorSessionResult.data.connectorSession; }; + +/** + * Get the single sign on session data from the oidc provider session storage by the jti. + * + * @param jti The jti of the interaction session. + * + * @remark This method is used by the SSO SAML assertion consumer service endpoint. + * Since we do not have the interaction ctx under SAML ACS endpoint, we need to get the session data by the jti. + * Forked from the above {@link getConnectorSessionResultFromJti} method, with more detailed SingleSignOnConnectorSession type guard. + */ +export const getSingleSignOnSessionResultByJti = async ( + jti: string, + provider: Provider +): Promise => { + const { result } = await getInteractionFromProviderByJti(jti, provider); + + const singleSignOnSessionResult = z + .object({ + connectorSession: singleSignOnConnectorSessionGuard, + }) + .safeParse(result); + + assertThat(singleSignOnSessionResult.success, 'session.connector_validation_session_not_found'); + + return singleSignOnSessionResult.data.connectorSession; +}; + +/** + * Assign the SAML assertion result to the interaction single sign-on session storage by the jti. + * + * @param jti The jti of the interaction session. + */ +export const assignSamlAssertionResultViaJti = async ( + jti: string, + provider: Provider, + sessionResultWithAssertion: Omit & { + userInfo: ExtendedSocialUserInfo; + } +) => { + const interaction = await getInteractionFromProviderByJti(jti, provider); + + const { result } = interaction; + + await assignResultToInteraction(interaction, { + ...result, + connectorSession: sessionResultWithAssertion, + }); +}; diff --git a/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts b/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts index 4bdf91036..30f043080 100644 --- a/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts +++ b/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts @@ -6,6 +6,50 @@ const logtoIssuer = `${logtoUrl}/oidc`; const metadataXml = `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 mutable display name of the user.SubjectAn immutable, globally unique, non-reusable identifier of the user that is unique to the application for which a token is issued.Given NameFirst name of the user.SurnameLast name of the user.Display NameDisplay name of the user.Nick NameNick name of the user.Authentication InstantThe time (UTC) when the user is authenticated to Windows Azure Active Directory.Authentication MethodThe method that Windows Azure Active Directory uses to authenticate users.ObjectIdentifierPrimary identifier for the user in the directory. Immutable, globally unique, non-reusable.TenantIdIdentifier for the user's tenant.IdentityProviderIdentity provider for the user.EmailEmail address of the user.GroupsGroups of the user.External Access TokenAccess token issued by external identity provider.External Access Token ExpirationUTC expiration time of access token issued by external identity provider.External OpenID 2.0 IdentifierOpenID 2.0 identifier issued by external identity provider.GroupsOverageClaimIssued when number of user's group claims exceeds return limit.Role ClaimRoles that the user or Service Principal is attached toRoleTemplate Id ClaimRole template id of the Built-in Directory Roles that the user is a member ofhttps://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedhttps://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedMIIC8DCCAdigAwIBAgIQcu5YCNUIL6JNotFNdirF2DANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yMzEwMjUwODAyMDNaFw0yNjEwMjUwODAyMDNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2EC5TZmW2ePPI0Od2Z3qFykouY/R8SBVJDD9xUcAIocMSqMLsxqd9ydkjaNC+QLbBUnpCvUd7+7ZyVcABbr5ixIMU+yxKIoZQdchECyasrR4HHXHXMeijQ8ziyF3Ys1yRB+iVQd2wZI+26pXlq9/bmT/keqMqdbAFD78QAYVF0LniL+sQav9Y0tsgrqXaE0GzqpTUsUfEcc1kynIQQG4ltFAkMTqaDhgw44S1GErjYC91dPEZMj4Ywwf1FIfnNJaRZoG77F3SlWUg345z/kAHBzNKjFMq3deobCHDZCZBJ6a+ABzgqdunUo4xBFG/YHNjjGkZEImALwp+P45mF5OLQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAK7s967KnFm0d7R1HpTHhr6D+L/X2Ejmgawo2HlkFLsHXPgGkeogrXl0Fw6NImJ+Zo/ChE2Vb8ZeYoEz5mdAYc0hK4k4UWJkv3yZ0GPKOzEcIWZ8Q8WAKqqWnzaO8NmZKpdc/sk8PluKH/BJ7IjEHZUgzhmuRGuJGJhVn2EPLXFIxBubyRlyMhBEZvX4syeeiCwGzvZY9CoTUPqftlrvc1xs78GFN+8cT2+B0vjcbifMkZ1Hq0iPQLN/LotM1qGbSVu/OFhuA+8mnp3Acw3XNZPOy9dZdNiVBF8ZoUz0rAC64dKYROPEDJhBTF30UzDcq6lfLA9KAgzEzupAxB8D4Nhttps://sts.windows.net/ac016212-4f8d-46c6-892c-57c90a255a02/https://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedhttps://login.microsoftonline.com/ac016212-4f8d-46c6-892c-57c90a255a02/wsfedMIIC8DCCAdigAwIBAgIQcu5YCNUIL6JNotFNdirF2DANBgkqhkiG9w0BAQsFADA0MTIwMAYDVQQDEylNaWNyb3NvZnQgQXp1cmUgRmVkZXJhdGVkIFNTTyBDZXJ0aWZpY2F0ZTAeFw0yMzEwMjUwODAyMDNaFw0yNjEwMjUwODAyMDNaMDQxMjAwBgNVBAMTKU1pY3Jvc29mdCBBenVyZSBGZWRlcmF0ZWQgU1NPIENlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2EC5TZmW2ePPI0Od2Z3qFykouY/R8SBVJDD9xUcAIocMSqMLsxqd9ydkjaNC+QLbBUnpCvUd7+7ZyVcABbr5ixIMU+yxKIoZQdchECyasrR4HHXHXMeijQ8ziyF3Ys1yRB+iVQd2wZI+26pXlq9/bmT/keqMqdbAFD78QAYVF0LniL+sQav9Y0tsgrqXaE0GzqpTUsUfEcc1kynIQQG4ltFAkMTqaDhgw44S1GErjYC91dPEZMj4Ywwf1FIfnNJaRZoG77F3SlWUg345z/kAHBzNKjFMq3deobCHDZCZBJ6a+ABzgqdunUo4xBFG/YHNjjGkZEImALwp+P45mF5OLQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAK7s967KnFm0d7R1HpTHhr6D+L/X2Ejmgawo2HlkFLsHXPgGkeogrXl0Fw6NImJ+Zo/ChE2Vb8ZeYoEz5mdAYc0hK4k4UWJkv3yZ0GPKOzEcIWZ8Q8WAKqqWnzaO8NmZKpdc/sk8PluKH/BJ7IjEHZUgzhmuRGuJGJhVn2EPLXFIxBubyRlyMhBEZvX4syeeiCwGzvZY9CoTUPqftlrvc1xs78GFN+8cT2+B0vjcbifMkZ1Hq0iPQLN/LotM1qGbSVu/OFhuA+8mnp3Acw3XNZPOy9dZdNiVBF8ZoUz0rAC64dKYROPEDJhBTF30UzDcq6lfLA9KAgzEzupAxB8D4N`; +export const samlAssertion = ` + + http://idp.example.com/metadata.php + + + + + http://idp.example.com/metadata.php + + + Nm1AlMpkTW9qo9jKUtSJBZZgQvU=DuSetp1ypZgPF2/+DJxeBSqtQiz669Fw0GcrqVjuXvSSgP997MLyXj3yZ6Szrc60WC5iCORn827rxQS4uxKtYdm6KunCv1GxckaIk49yo2uOblaXuAM69I2EffLL3vFLyyGv8zTFnTjNXU0M334pkN6DEj7LIdVA2Jdj/L/8ohU= +MIICajCCAdOgAwIBAgIBADANBgkqhkiG9w0BAQ0FADBSMQswCQYDVQQGEwJ1czETMBEGA1UECAwKQ2FsaWZvcm5pYTEVMBMGA1UECgwMT25lbG9naW4gSW5jMRcwFQYDVQQDDA5zcC5leGFtcGxlLmNvbTAeFw0xNDA3MTcxNDEyNTZaFw0xNTA3MTcxNDEyNTZaMFIxCzAJBgNVBAYTAnVzMRMwEQYDVQQIDApDYWxpZm9ybmlhMRUwEwYDVQQKDAxPbmVsb2dpbiBJbmMxFzAVBgNVBAMMDnNwLmV4YW1wbGUuY29tMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDZx+ON4IUoIWxgukTb1tOiX3bMYzYQiwWPUNMp+Fq82xoNogso2bykZG0yiJm5o8zv/sd6pGouayMgkx/2FSOdc36T0jGbCHuRSbtia0PEzNIRtmViMrt3AeoWBidRXmZsxCNLwgIV6dn2WpuE5Az0bHgpZnQxTKFek0BMKU/d8wIDAQABo1AwTjAdBgNVHQ4EFgQUGHxYqZYyX7cTxKVODVgZwSTdCnwwHwYDVR0jBBgwFoAUGHxYqZYyX7cTxKVODVgZwSTdCnwwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQ0FAAOBgQByFOl+hMFICbd3DJfnp2Rgd/dqttsZG/tyhILWvErbio/DEe98mXpowhTkC04ENprOyXi7ZbUqiicF89uAGyt1oqgTUCD1VsLahqIcmrzgumNyTwLGWo17WDAa1/usDhetWAMhgzF/Cnf5ek0nK00m0YZGyc4LzgD0CROMASTWNg== + + _ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7 + + + + + + + http://sp.example.com/demo1/metadata.php + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:Password + + + + + test + + + test@example.com + + + users + examplerole1 + + + + +`; + export const providerNames = ['OIDC', 'SAML']; export const partialConfigAndProviderNames: Array<{ providerName: string; diff --git a/packages/integration-tests/src/api/interaction-sso.ts b/packages/integration-tests/src/api/interaction-sso.ts index d60a2fc86..474b9e685 100644 --- a/packages/integration-tests/src/api/interaction-sso.ts +++ b/packages/integration-tests/src/api/interaction-sso.ts @@ -35,3 +35,16 @@ export const getSsoConnectorsByEmail = async ( }) .json(); }; + +export const postSamlAssertion = async (data: { + connectorId: string; + RelayState: string; + SAMLResponse: string; +}) => { + const { connectorId, ...payload } = data; + return api + .post(`authn/${ssoPath}/saml/${connectorId}`, { + json: payload, + }) + .json(); +}; diff --git a/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts b/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts index d4843aaf0..4f5a6db50 100644 --- a/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts @@ -1,55 +1,116 @@ import { InteractionEvent } from '@logto/schemas'; -import { getSsoAuthorizationUrl } from '#src/api/interaction-sso.js'; +import { + partialConfigAndProviderNames, + samlAssertion, +} from '#src/__mocks__/sso-connectors-mock.js'; +import { getSsoAuthorizationUrl, postSamlAssertion } from '#src/api/interaction-sso.js'; import { putInteraction } from '#src/api/interaction.js'; import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js'; import { SsoProviderName } from '#src/constants.js'; import { initClient } from '#src/helpers/client.js'; +import { expectRejects } from '#src/helpers/index.js'; describe('Single Sign On Sad Path', () => { const state = 'foo_state'; const redirectUri = 'http://foo.dev/callback'; - it('should throw if connector not found', async () => { - const client = await initClient(); + describe('getAuthorizationUrl', () => { + it('should throw if connector not found', async () => { + const client = await initClient(); - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + await expect( + client.send(getSsoAuthorizationUrl, { + connectorId: 'foo', + state, + redirectUri, + }) + ).rejects.toThrow(); }); - await expect( - client.send(getSsoAuthorizationUrl, { - connectorId: 'foo', - state, - redirectUri, - }) - ).rejects.toThrow(); + it('should throw if connector config is invalid', async () => { + const { id } = await createSsoConnector({ + providerName: SsoProviderName.OIDC, + connectorName: 'test-oidc', + config: { + clientId: 'foo', + clientSecret: 'bar', + }, + }); + + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + await expect( + client.send(getSsoAuthorizationUrl, { + connectorId: id, + state, + redirectUri, + }) + ).rejects.toThrow(); + + await deleteSsoConnectorById(id); + }); }); - it('should throw if connector config is invalid', async () => { - const { id } = await createSsoConnector({ - providerName: SsoProviderName.OIDC, - connectorName: 'test-oidc', - config: { - clientId: 'foo', - clientSecret: 'bar', - }, + describe('postSamlAssertion', () => { + // eslint-disable-next-line @silverhand/fp/no-let + let connectorId: string; + + beforeAll(async () => { + const mockSamlConnector = partialConfigAndProviderNames[1]!; + + const { id } = await createSsoConnector({ + connectorName: 'test-saml', + ...mockSamlConnector, + }); + + // eslint-disable-next-line @silverhand/fp/no-mutation + connectorId = id; }); - const client = await initClient(); - - await client.successSend(putInteraction, { - event: InteractionEvent.SignIn, + afterAll(async () => { + await deleteSsoConnectorById(connectorId); }); - await expect( - client.send(getSsoAuthorizationUrl, { - connectorId: id, + it('should throw if the session dose not exist', async () => { + await initClient(); + + await expectRejects( + postSamlAssertion({ connectorId, RelayState: 'foo', SAMLResponse: samlAssertion }), + { + code: 'session.not_found', + statusCode: 400, + } + ); + }); + + it('should throw if the response is invalid', async () => { + const client = await initClient(); + + const { redirectTo } = await client.send(getSsoAuthorizationUrl, { + connectorId, state, - redirectUri, - }) - ).rejects.toThrow(); + redirectUri: 'http://foo.dev/callback', + }); - await deleteSsoConnectorById(id); + const url = new URL(redirectTo); + const RelayState = url.searchParams.get('RelayState')!; + + await expectRejects( + postSamlAssertion({ connectorId, RelayState, SAMLResponse: samlAssertion }), + { + code: 'connector.general', + statusCode: 400, + } + ); + }); }); });