From 8e632678bc853bbc4c8fff8a85e5dfabe2987d23 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 14 Oct 2024 14:47:55 +0800 Subject: [PATCH] feat(core): handle idp initiated saml assertion request (#6666) * feat(core): handle idp initiated saml assertion request handle idp initiated saml assertion request * refactor(core): extract the set cookie logic extract the set cookie logic, any refine some type definitions --- packages/core/src/constants/index.ts | 5 + .../sign-in-experience/index.test.ts | 1 + packages/core/src/libraries/sso-connector.ts | 34 +++++- packages/core/src/queries/sso-connectors.ts | 14 +++ packages/core/src/routes/authn.ts | 89 +++++++++++--- .../utils/single-sign-on-guard.test.ts | 1 + packages/core/src/sso/SamlConnector/index.ts | 42 ++++--- packages/core/src/sso/SamlConnector/utils.ts | 16 +-- .../src/__mocks__/sso-connectors-mock.ts | 52 ++++++++ .../src/api/sso-connector.ts | 8 +- .../src/tests/api/authn.test.ts | 111 ++++++++++++++++++ .../single-sign-on/sad-path.test.ts | 61 +--------- .../foundations/jsonb-types/sso-connector.ts | 4 +- 13 files changed, 323 insertions(+), 115 deletions(-) create mode 100644 packages/integration-tests/src/tests/api/authn.test.ts diff --git a/packages/core/src/constants/index.ts b/packages/core/src/constants/index.ts index f72b02237..d5e4e0700 100644 --- a/packages/core/src/constants/index.ts +++ b/packages/core/src/constants/index.ts @@ -3,3 +3,8 @@ export const protectedAppSignInCallbackUrl = 'sign-in-callback'; export const subjectTokenExpiresIn = 600; /** The prefix for subject tokens */ export const subjectTokenPrefix = 'sub_'; + +/** The default lifetime of SSO SAML assertion record (in milliseconds) */ +export const defaultIdPInitiatedSamlSsoSessionTtl = 10 * 60 * 1000; // 10 minutes + +export const idpInitiatedSamlSsoSessionCookieName = '_logto_idp_saml_sso_session_id'; diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index 1aa8c8d3c..2f13e995d 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -47,6 +47,7 @@ const ssoConnectorLibrary: jest.Mocked = { getAvailableSsoConnectors: jest.fn(), createSsoConnectorIdpInitiatedAuthConfig: jest.fn(), updateSsoConnectorIdpInitiatedAuthConfig: jest.fn(), + createIdpInitiatedSamlSsoSession: jest.fn(), }; const { MockQueries } = await import('#src/test-utils/tenant.js'); diff --git a/packages/core/src/libraries/sso-connector.ts b/packages/core/src/libraries/sso-connector.ts index 4a0367610..5494602d7 100644 --- a/packages/core/src/libraries/sso-connector.ts +++ b/packages/core/src/libraries/sso-connector.ts @@ -1,17 +1,19 @@ import { ApplicationType, + type SsoSamlAssertionContent, type CreateSsoConnectorIdpInitiatedAuthConfig, type SupportedSsoConnector, } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; import { assert } from '@silverhand/essentials'; +import { defaultIdPInitiatedSamlSsoSessionTtl } from '#src/constants/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import { ssoConnectorFactories } from '#src/sso/index.js'; import { isSupportedSsoConnector } from '#src/sso/utils.js'; import type Queries from '#src/tenants/Queries.js'; - -import assertThat from '../utils/assert-that.js'; -import { type OmitAutoSetFields } from '../utils/sql.js'; +import assertThat from '#src/utils/assert-that.js'; +import { type OmitAutoSetFields } from '#src/utils/sql.js'; export type SsoConnectorLibrary = ReturnType; @@ -107,11 +109,37 @@ export const createSsoConnectorLibrary = (queries: Queries) => { }); }; + /** + * Records the verified SAML assertion content to the database. + * @remarks + * For IdP-initiated SAML SSO flow use only. + * Save the SAML assertion content to the database + * The session ID will be used to retrieve the SAML assertion content when the user is redirected to Logto SSO authentication flow. + */ + const createIdpInitiatedSamlSsoSession = async ( + connectorId: string, + assertionContent: SsoSamlAssertionContent + ) => { + // If the assertion has a notOnOrAfter condition, we will use it as the expiration date, + // otherwise use the creation date + 10 minutes + const expiresAt = assertionContent.conditions?.notOnOrAfter + ? new Date(assertionContent.conditions.notOnOrAfter) + : new Date(Date.now() + defaultIdPInitiatedSamlSsoSessionTtl); + + return ssoConnectors.insertIdpInitiatedSamlSsoSession({ + id: generateStandardId(), + connectorId, + assertionContent, + expiresAt: expiresAt.valueOf(), + }); + }; + return { getSsoConnectors, getAvailableSsoConnectors, getSsoConnectorById, createSsoConnectorIdpInitiatedAuthConfig, updateSsoConnectorIdpInitiatedAuthConfig, + createIdpInitiatedSamlSsoSession, }; }; diff --git a/packages/core/src/queries/sso-connectors.ts b/packages/core/src/queries/sso-connectors.ts index 3d3c664c7..3edfb2808 100644 --- a/packages/core/src/queries/sso-connectors.ts +++ b/packages/core/src/queries/sso-connectors.ts @@ -5,12 +5,14 @@ import { SsoConnectorIdpInitiatedAuthConfigs, type SsoConnectorKeys, SsoConnectors, + IdpInitiatedSamlSsoSessions, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; import SchemaQueries from '#src/utils/SchemaQueries.js'; import { convertToIdentifiers } from '#src/utils/sql.js'; +import { buildDeleteByIdWithPool } from '../database/delete-by-id.js'; import { buildInsertIntoWithPool } from '../database/insert-into.js'; import { buildUpdateWhereWithPool } from '../database/update-where.js'; import { DeletionError } from '../errors/SlonikError/index.js'; @@ -48,6 +50,18 @@ export default class SsoConnectorQueries extends SchemaQueries< true ); + public readonly insertIdpInitiatedSamlSsoSession = buildInsertIntoWithPool(this.pool)( + IdpInitiatedSamlSsoSessions, + { + returning: true, + } + ); + + public readonly deleteIdpInitiatedSamlSsoSessionById = buildDeleteByIdWithPool( + this.pool, + IdpInitiatedSamlSsoSessions.table + ); + constructor(pool: CommonQueryMethods) { super(pool, SsoConnectors); } diff --git a/packages/core/src/routes/authn.ts b/packages/core/src/routes/authn.ts index d1845ad2a..3283eb1d9 100644 --- a/packages/core/src/routes/authn.ts +++ b/packages/core/src/routes/authn.ts @@ -16,6 +16,9 @@ import { assignSamlAssertionResultViaJti, } from '#src/utils/saml-assertion-handler.js'; +import { idpInitiatedSamlSsoSessionCookieName } from '../constants/index.js'; +import { EnvSet } from '../env-set/index.js'; + import { ssoPath } from './interaction/const.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; @@ -24,7 +27,7 @@ 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, id: tenantId }]: RouterInitArgs + ...[router, { envSet, provider, libraries, id: tenantId, queries }]: RouterInitArgs ) { const { users: { findUserRoles }, @@ -174,9 +177,11 @@ export default function authnRoutes( router.post( `/authn/${ssoPath}/saml/:connectorId`, koaGuard({ - body: z.object({ RelayState: z.string(), SAMLResponse: z.string() }).catchall(z.unknown()), + body: z + .object({ RelayState: z.string().optional(), SAMLResponse: z.string() }) + .catchall(z.unknown()), params: z.object({ connectorId: z.string().min(1) }), - status: [302, 404], + status: [302, 400, 404], }), async (ctx, next) => { const { @@ -188,20 +193,6 @@ export default function authnRoutes( 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, @@ -210,8 +201,70 @@ export default function authnRoutes( assertThat(connectorInstance instanceof SamlConnector, 'connector.unexpected_type'); - const userInfo = await connectorInstance.parseSamlAssertion(body); + // 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; + // IdP initiated SSO will not have the jti in the RelayState. + // Trigger the IdP initiated SSO flow if enabled for the current connector. + if (!jti && EnvSet.values.isDevFeaturesEnabled) { + const idpInitiatedAuthConfig = + await queries.ssoConnectors.getIdpInitiatedAuthConfigByConnectorId(connectorId); + + // No IdP initiated auth config found + assertThat( + idpInitiatedAuthConfig, + new RequestError({ + code: 'session.connector_validation_session_not_found', + status: 404, + }) + ); + + const assertionContent = await connectorInstance.parseSamlAssertionContent(body); + + const { id, expiresAt } = await ssoConnectorsLibrary.createIdpInitiatedSamlSsoSession( + connectorId, + assertionContent + ); + + // Set the session ID to cookie for later use. + ctx.cookies.set(idpInitiatedSamlSsoSessionCookieName, id, { + httpOnly: true, + sameSite: 'strict', + expires: new Date(expiresAt), + overwrite: true, + }); + + // TODO: redirect to SSO direct sign-in flow + + return next(); + } + + // TODO: remove this assertion after the IdP initiated SSO flow is implemented + assertThat( + jti, + new RequestError({ + code: 'session.connector_validation_session_not_found', + status: 404, + }) + ); + + // 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, + new RequestError({ + code: 'session.connector_validation_session_not_found', + status: 404, + }) + ); + + const assertionContent = await connectorInstance.parseSamlAssertionContent(body); + const userInfo = connectorInstance.getUserInfoFromSamlAssertion(assertionContent); + + // Store the extracted user info to the connector session storage for later use. await assignSamlAssertionResultViaJti(jti, provider, { ...singleSignOnSession, userInfo, diff --git a/packages/core/src/routes/interaction/utils/single-sign-on-guard.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on-guard.test.ts index d61256701..b21e980f7 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on-guard.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on-guard.test.ts @@ -15,6 +15,7 @@ const mockSsoConnectorLibrary: jest.Mocked = { getSsoConnectorById: jest.fn(), createSsoConnectorIdpInitiatedAuthConfig: jest.fn(), updateSsoConnectorIdpInitiatedAuthConfig: jest.fn(), + createIdpInitiatedSamlSsoSession: jest.fn(), }; describe('verifyEmailIdentifier tests', () => { diff --git a/packages/core/src/sso/SamlConnector/index.ts b/packages/core/src/sso/SamlConnector/index.ts index cdc2b564c..4c0a70628 100644 --- a/packages/core/src/sso/SamlConnector/index.ts +++ b/packages/core/src/sso/SamlConnector/index.ts @@ -1,7 +1,7 @@ -import { type Optional } from '@silverhand/essentials'; +import { type SsoSamlAssertionContent } from '@logto/schemas'; +import { conditional, type Optional } from '@silverhand/essentials'; import { XMLValidator } from 'fast-xml-parser'; import * as saml from 'samlify'; -import { z } from 'zod'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; @@ -45,7 +45,7 @@ import { * @property _identityProvider The SAML identity provider instance cache * * @method getSamlIdpMetadata Parse and return SAML config from the SAML connector config. Throws error if config is invalid. - * @method parseSamlAssertion Parse and store the SAML assertion from IdP. + * @method getUserInfoFromSamlAssertion Parse and store the SAML assertion from IdP. * @method getSingleSignOnUrl Get the SAML SSO URL. * @method getIdpMetadataXml Get the raw SAML metadata (in XML-format) from the raw SAML SSO connector config. * @method getIdpMetadataJson Get manually configured IdP SAML metadata from the raw SAML SSO connector config. @@ -98,13 +98,12 @@ class SamlConnector { } /** - * Parse and return the SAML assertion from IdP (with attribute mapping applied). + * Parse the SAML assertion content received from IdP. * - * @param assertion The SAML assertion from IdP. - * - * @returns The parsed SAML assertion from IdP (with attribute mapping applied). + * @param body The raw SAML assertion request body received from IdP. + * @throws {SsoConnectorError} If the SAML assertion is invalid or cannot be parsed. */ - async parseSamlAssertion(body: Record): Promise { + async parseSamlAssertionContent(body: Record) { const identityProvider = await this.getIdentityProvider(); const { x509Certificate } = await this.getSamlIdpMetadata(); @@ -114,18 +113,23 @@ class SamlConnector { entityId: this.serviceProviderMetadata.entityId, }); - const userProfileGuard = z.record(z.string().or(z.array(z.string()))); - const rawProfileParseResult = userProfileGuard.safeParse(samlAssertionContent); + return samlAssertionContent; + } - if (!rawProfileParseResult.success) { - throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { - message: 'Invalid SAML assertion', - response: samlAssertionContent, - error: rawProfileParseResult.error.flatten(), - }); - } - - const rawUserProfile = rawProfileParseResult.data; + /** + * Extract the user info from the SAML assertion received from IdP. (with attribute mapping applied). + * + * @param body The raw SAML assertion request body received from IdP. + * + * @returns {ExtendedSocialUserInfo} The parsed social user info (with attribute mapping applied). + */ + getUserInfoFromSamlAssertion( + samlAssertionContent: SsoSamlAssertionContent + ): ExtendedSocialUserInfo { + const rawUserProfile: Record = { + ...conditional(samlAssertionContent.nameID && { nameID: samlAssertionContent.nameID }), + ...samlAssertionContent.attributes, + }; const profileMap = attributeMappingPostProcessor(this.idpConfig.attributeMapping); diff --git a/packages/core/src/sso/SamlConnector/utils.ts b/packages/core/src/sso/SamlConnector/utils.ts index 38a709db4..9c708b46b 100644 --- a/packages/core/src/sso/SamlConnector/utils.ts +++ b/packages/core/src/sso/SamlConnector/utils.ts @@ -1,11 +1,12 @@ import { X509Certificate } from 'node:crypto'; import * as validator from '@authenio/samlify-node-xmllint'; +import { ssoSamlAssertionContentGuard, type SsoSamlAssertionContent } from '@logto/schemas'; import { type Optional, appendPath, tryThat } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials'; import { HTTPError, got } from 'got'; import * as saml from 'samlify'; -import { z } from 'zod'; +import { z, ZodError } from 'zod'; import { ssoPath } from '#src/routes/interaction/const.js'; @@ -172,7 +173,7 @@ export const handleSamlAssertion = async ( request: ESamlHttpRequest, identityProvider: saml.IdentityProviderInstance, metadata: { entityId: string; x509Certificate: string } -): Promise> => { +): Promise => { const { entityId: entityID, x509Certificate } = metadata; // eslint-disable-next-line new-cap @@ -191,18 +192,11 @@ export const handleSamlAssertion = async ( request ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return { - ...(Boolean(assertionResult.extract.nameID) && { - // Usually identity provider DOES NOT allow the configuration of `nameID` claim name. - nameID: assertionResult.extract.nameID, - }), - ...assertionResult.extract.attributes, - }; + return ssoSamlAssertionContentGuard.parse(assertionResult.extract); } catch (error: unknown) { throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { message: 'Invalid SAML assertion', - error, + error: error instanceof ZodError ? error.flatten() : error, }); } }; diff --git a/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts b/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts index 3473026c5..075629626 100644 --- a/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts +++ b/packages/integration-tests/src/__mocks__/sso-connectors-mock.ts @@ -72,3 +72,55 @@ export const partialConfigAndProviderNames: Array<{ }, }, ]; + +export const mockOktaSamlConnectorMetadata = ` + + + + + +MIIDqjCCApKgAwIBAgIGAZIxmUd7MA0GCSqGSIb3DQEBCwUAMIGVMQswCQYDVQQGEwJVUzETMBEG A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU MBIGA1UECwwLU1NPUHJvdmlkZXIxFjAUBgNVBAMMDXRyaWFsLTQ1NzYxMDQxHDAaBgkqhkiG9w0B CQEWDWluZm9Ab2t0YS5jb20wHhcNMjQwOTI3MDM0ODQxWhcNMzQwOTI3MDM0OTQxWjCBlTELMAkG A1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTAL BgNVBAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRYwFAYDVQQDDA10cmlhbC00NTc2MTA0 MRwwGgYJKoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAyjmF5lvCe3CU9+YBPvoioWgcgzaTsgVYTUQRgeRNhOVaRVaKoZahMYoLiHj9Vtjj9EGk 5bY9qH3+6HPbqbvpRS00izsmMWQ+XyLgNIeAVPoxhQ9FaX3Ept+SiCnL9gPtApTI1m42+n93+x8u JNLGMGnsq/T1thw9rWW30KkY7agANglStsV3d7c1Z6bCZ+CIu495AqpmYshBgucCQ31cssNz0+vh i1i/rUNvbBlc9VQovxxKInshRSnVDZdTEKqjdHpVgIEr+TRS6+rGmpfAN2H8Ou6MX0DyXKIdS1aT B5Kdwur4Bje6fTMBBRGf3/CtqERPr0nVb0xBQv8oKoLAsQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB AQCMneUgkAQBW2wQL+5zD6CI7XeChKzu0gvzBMqeH+7BhCjiiUMM0L+QS+gKYo/q9UxSmZK2qnmw rIVPW9CYJzwLLDvOU25ZcOIFGULTgnSbNIC8l9xE7UaN9uZ7ZSXDqeg/Oofg0BXy2A7zZobL/fHk HadHm+uahk8X+RxGa8I2yS/Ns32XRjlurm/wKxLdCnzzPwN4NVhzJt7VVEkjGrztMak69sZbOjH7 xQv3J3Qb27418ZgPP9sBD+r6h/d2uc/20jb+u1jNmvtmaZ97FBDTC143DzQunIrR4sMrfcwt0F8X mAhbN1/BDxPBST2NHKyMH2PE6waP3HK6FAOwMG2A + + + +urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified +urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + +`; + +export const mockOktaSamlAssertion = `http://www.okta.com/exkjbcsmt3qWQLZIR697VEVglIhA1OMUc9XH9vemBsE2keHElaL91eM9EX3d9xM=C6XRyVYM1/eOCIv5Cg9peCTo765RXvKPzO5FlQh8/wMaweXiy8H9FioRZN3ArPSR0Kbq57EN6rs8gcpvKAbJu/IPTExueToIOlUx3BxzspjboeQTaU5t5sINvf0BxQbst2VL2hb5y76QifOhOt33VOnrXt4fVLuJXbDGquTYYP3mDx1xjMJLfaNZzuGaSJb8s8O0UQh8NwjXYdPIdNOGyzy4bEX1hifZgUcWive9X9tHPIqaTpHd6c1G/DCVHOUevkSzzY6SqKD+oeur3eu900dw7CLZBmA3FqEHHAP6C8Nda6O4g8n9KSLhMcXNMrR5kODfqWu2xRjm7ImBmOtK/A==MIIDqjCCApKgAwIBAgIGAZIxmUd7MA0GCSqGSIb3DQEBCwUAMIGVMQswCQYDVQQGEwJVUzETMBEG +A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU +MBIGA1UECwwLU1NPUHJvdmlkZXIxFjAUBgNVBAMMDXRyaWFsLTQ1NzYxMDQxHDAaBgkqhkiG9w0B +CQEWDWluZm9Ab2t0YS5jb20wHhcNMjQwOTI3MDM0ODQxWhcNMzQwOTI3MDM0OTQxWjCBlTELMAkG +A1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTAL +BgNVBAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRYwFAYDVQQDDA10cmlhbC00NTc2MTA0 +MRwwGgYJKoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAyjmF5lvCe3CU9+YBPvoioWgcgzaTsgVYTUQRgeRNhOVaRVaKoZahMYoLiHj9Vtjj9EGk +5bY9qH3+6HPbqbvpRS00izsmMWQ+XyLgNIeAVPoxhQ9FaX3Ept+SiCnL9gPtApTI1m42+n93+x8u +JNLGMGnsq/T1thw9rWW30KkY7agANglStsV3d7c1Z6bCZ+CIu495AqpmYshBgucCQ31cssNz0+vh +i1i/rUNvbBlc9VQovxxKInshRSnVDZdTEKqjdHpVgIEr+TRS6+rGmpfAN2H8Ou6MX0DyXKIdS1aT +B5Kdwur4Bje6fTMBBRGf3/CtqERPr0nVb0xBQv8oKoLAsQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB +AQCMneUgkAQBW2wQL+5zD6CI7XeChKzu0gvzBMqeH+7BhCjiiUMM0L+QS+gKYo/q9UxSmZK2qnmw +rIVPW9CYJzwLLDvOU25ZcOIFGULTgnSbNIC8l9xE7UaN9uZ7ZSXDqeg/Oofg0BXy2A7zZobL/fHk +HadHm+uahk8X+RxGa8I2yS/Ns32XRjlurm/wKxLdCnzzPwN4NVhzJt7VVEkjGrztMak69sZbOjH7 +xQv3J3Qb27418ZgPP9sBD+r6h/d2uc/20jb+u1jNmvtmaZ97FBDTC143DzQunIrR4sMrfcwt0F8X +mAhbN1/BDxPBST2NHKyMH2PE6waP3HK6FAOwMG2Ahttp://www.okta.com/exkjbcsmt3qWQLZIR6975xc1+qcdoEhYuTRhiGR6RDb7oIK5XfG51YsU6QEXrIk=oDn7HoGgb50l7VXArOTEboc5Y8UX+CfXo6aAPU1e5zpLA9AFCitqPlxOq78YEecp+ur6GamP1oybTUU6z0C8eP5RopWBFBWpjeGqooftxzN2BPUYZTNA0jf3YFI4ROrtIWzrFEA4Hwq7c2Jx9o4hFXDvQLYtI51ImnolBR82XXk/PU9bspCn8i+cCWM00LJhA3V74MJ74bfvECQhuznT6I2m09SRhfIqgYTncQoO79/0IgK5tEb7lEjtR7AmssyBMJlZrBPhe3Fv9prIOzOgWTQkFPvor62fkeNBb0MEHBK2MUfpgbrZpsUlE4R7NRxEM7n3gZYFHgaoF77MFwIJhw==MIIDqjCCApKgAwIBAgIGAZIxmUd7MA0GCSqGSIb3DQEBCwUAMIGVMQswCQYDVQQGEwJVUzETMBEG +A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU +MBIGA1UECwwLU1NPUHJvdmlkZXIxFjAUBgNVBAMMDXRyaWFsLTQ1NzYxMDQxHDAaBgkqhkiG9w0B +CQEWDWluZm9Ab2t0YS5jb20wHhcNMjQwOTI3MDM0ODQxWhcNMzQwOTI3MDM0OTQxWjCBlTELMAkG +A1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTAL +BgNVBAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRYwFAYDVQQDDA10cmlhbC00NTc2MTA0 +MRwwGgYJKoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAyjmF5lvCe3CU9+YBPvoioWgcgzaTsgVYTUQRgeRNhOVaRVaKoZahMYoLiHj9Vtjj9EGk +5bY9qH3+6HPbqbvpRS00izsmMWQ+XyLgNIeAVPoxhQ9FaX3Ept+SiCnL9gPtApTI1m42+n93+x8u +JNLGMGnsq/T1thw9rWW30KkY7agANglStsV3d7c1Z6bCZ+CIu495AqpmYshBgucCQ31cssNz0+vh +i1i/rUNvbBlc9VQovxxKInshRSnVDZdTEKqjdHpVgIEr+TRS6+rGmpfAN2H8Ou6MX0DyXKIdS1aT +B5Kdwur4Bje6fTMBBRGf3/CtqERPr0nVb0xBQv8oKoLAsQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB +AQCMneUgkAQBW2wQL+5zD6CI7XeChKzu0gvzBMqeH+7BhCjiiUMM0L+QS+gKYo/q9UxSmZK2qnmw +rIVPW9CYJzwLLDvOU25ZcOIFGULTgnSbNIC8l9xE7UaN9uZ7ZSXDqeg/Oofg0BXy2A7zZobL/fHk +HadHm+uahk8X+RxGa8I2yS/Ns32XRjlurm/wKxLdCnzzPwN4NVhzJt7VVEkjGrztMak69sZbOjH7 +xQv3J3Qb27418ZgPP9sBD+r6h/d2uc/20jb+u1jNmvtmaZ97FBDTC143DzQunIrR4sMrfcwt0F8X +mAhbN1/BDxPBST2NHKyMH2PE6waP3HK6FAOwMG2Amock_user_idhttp://localhost:3001/enterprise-sso/90ipi52ch151urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransportmock_user_id`; diff --git a/packages/integration-tests/src/api/sso-connector.ts b/packages/integration-tests/src/api/sso-connector.ts index 66168c5fa..b5d0f3db2 100644 --- a/packages/integration-tests/src/api/sso-connector.ts +++ b/packages/integration-tests/src/api/sso-connector.ts @@ -68,13 +68,13 @@ export class SsoConnectorApi { return connector; } - async createMockSamlConnector(domains: string[], connectorName?: string) { + async createMockSamlConnector(domains: string[], connectorName?: string, metadata?: string) { const connector = await this.create({ providerName: SsoProviderName.SAML, connectorName: connectorName ?? `test-saml-${randomString()}`, domains, config: { - metadata: metadataXml, + metadata: metadata ?? metadataXml, }, syncProfile: true, }); @@ -89,6 +89,10 @@ export class SsoConnectorApi { return connector; } + async getSsoConnectorById(id: string) { + return getSsoConnectorById(id); + } + async delete(id: string) { await deleteSsoConnectorById(id); this.connectorInstances.delete(id); diff --git a/packages/integration-tests/src/tests/api/authn.test.ts b/packages/integration-tests/src/tests/api/authn.test.ts new file mode 100644 index 000000000..978b0b950 --- /dev/null +++ b/packages/integration-tests/src/tests/api/authn.test.ts @@ -0,0 +1,111 @@ +import { ApplicationType } from '@logto/schemas'; + +import { + mockOktaSamlConnectorMetadata, + mockOktaSamlAssertion, +} from '#src/__mocks__/sso-connectors-mock.js'; +import { createApplication, deleteApplication } from '#src/api/application.js'; +import { getSsoAuthorizationUrl, postSamlAssertion } from '#src/api/interaction-sso.js'; +import { SsoConnectorApi } from '#src/api/sso-connector.js'; +import { initClient } from '#src/helpers/client.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { devFeatureTest, randomString } from '#src/utils.js'; + +describe('SAML SSO ACS endpoint', () => { + const ssoConnectorApi = new SsoConnectorApi(); + const encodedSamlAssertion = Buffer.from(mockOktaSamlAssertion).toString('base64'); + + beforeAll(async () => { + await ssoConnectorApi.createMockSamlConnector( + ['example.com'], + undefined, + mockOktaSamlConnectorMetadata + ); + }); + + afterAll(async () => { + await ssoConnectorApi.cleanUp(); + }); + + it('should throw 404 if the session is not found', async () => { + const connectorId = ssoConnectorApi.firstConnectorId!; + + await expectRejects( + postSamlAssertion({ + connectorId, + RelayState: 'foo', + SAMLResponse: encodedSamlAssertion, + }), + { + code: 'session.not_found', + status: 400, + } + ); + }); + + it('should throw 401 if the assertion is invalid', async () => { + const connectorId = ssoConnectorApi.firstConnectorId!; + const client = await initClient(); + + const { redirectTo } = await client.send(getSsoAuthorizationUrl, { + connectorId, + state: 'foo_state', + redirectUri: 'http://foo.dev/callback', + }); + + const url = new URL(redirectTo); + const RelayState = url.searchParams.get('RelayState')!; + + const { providerConfig } = await ssoConnectorApi.getSsoConnectorById(connectorId); + + await expectRejects( + postSamlAssertion({ connectorId, RelayState, SAMLResponse: encodedSamlAssertion }), + { + code: 'connector.authorization_failed', + status: 401, + } + ); + }); + + devFeatureTest.describe('IdP initiated SSO', () => { + it('should throw 404 if no relayState is provided, and IdP initiated SSO is not enabled', async () => { + const connectorId = ssoConnectorApi.firstConnectorId!; + + await expectRejects( + postSamlAssertion({ + connectorId, + RelayState: '', + SAMLResponse: encodedSamlAssertion, + }), + { + code: 'session.connector_validation_session_not_found', + status: 404, + } + ); + }); + + it('should try to process the SAML assertion if no relayState is provided and IdP initiated SSO is enabled', async () => { + const connectorId = ssoConnectorApi.firstConnectorId!; + const application = await createApplication( + `web-app-${randomString()}`, + ApplicationType.Traditional + ); + + await ssoConnectorApi.setSsoConnectorIdpInitiatedAuthConfig({ + connectorId, + defaultApplicationId: application.id, + redirectUri: 'https://example.com', + }); + + await expectRejects( + postSamlAssertion({ connectorId, RelayState: '', SAMLResponse: encodedSamlAssertion }), + { + code: 'connector.authorization_failed', + status: 401, + } + ); + + await deleteApplication(application.id); + }); + }); +}); 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 a9fc8ff3e..1e82cb165 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,14 +1,9 @@ import { InteractionEvent, SsoProviderName } from '@logto/schemas'; -import { - partialConfigAndProviderNames, - samlAssertion, -} from '#src/__mocks__/sso-connectors-mock.js'; -import { getSsoAuthorizationUrl, postSamlAssertion } from '#src/api/interaction-sso.js'; +import { getSsoAuthorizationUrl } from '#src/api/interaction-sso.js'; import { putInteraction } from '#src/api/interaction.js'; import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js'; import { initClient } from '#src/helpers/client.js'; -import { expectRejects } from '#src/helpers/index.js'; import { randomString } from '#src/utils.js'; describe('Single Sign On Sad Path', () => { @@ -55,58 +50,4 @@ describe('Single Sign On Sad Path', () => { await deleteSsoConnectorById(id); }); }); - - 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; - }); - - afterAll(async () => { - await deleteSsoConnectorById(connectorId); - }); - - it('should throw if the session dose not exist', async () => { - await initClient(); - - await expectRejects( - postSamlAssertion({ connectorId, RelayState: 'foo', SAMLResponse: samlAssertion }), - { - code: 'session.not_found', - status: 400, - } - ); - }); - - it('should throw if the response is invalid', async () => { - const client = await initClient(); - - const { redirectTo } = await client.send(getSsoAuthorizationUrl, { - connectorId, - state, - redirectUri: 'http://foo.dev/callback', - }); - - const url = new URL(redirectTo); - const RelayState = url.searchParams.get('RelayState')!; - - await expectRejects( - postSamlAssertion({ connectorId, RelayState, SAMLResponse: samlAssertion }), - { - code: 'connector.authorization_failed', - status: 401, - } - ); - }); - }); }); diff --git a/packages/schemas/src/foundations/jsonb-types/sso-connector.ts b/packages/schemas/src/foundations/jsonb-types/sso-connector.ts index 30f41456e..4709b5012 100644 --- a/packages/schemas/src/foundations/jsonb-types/sso-connector.ts +++ b/packages/schemas/src/foundations/jsonb-types/sso-connector.ts @@ -27,8 +27,8 @@ export const ssoSamlAssertionContentGuard = z attributes: z.record(z.string().or(z.array(z.string()))).optional(), conditions: z .object({ - notBefore: z.string(), - notOnOrAfter: z.string(), + notBefore: z.string().optional(), + notOnOrAfter: z.string().optional(), }) .optional(), })