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 0f9d30712..1aa8c8d3c 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -19,6 +19,7 @@ import { mockLogtoConfigsLibrary } from '#src/test-utils/mock-libraries.js'; import { createCloudConnectionLibrary } from '../cloud-connection.js'; import { createConnectorLibrary } from '../connector.js'; +import { type SsoConnectorLibrary } from '../sso-connector.js'; const { jest } = import.meta; @@ -40,11 +41,12 @@ const signInExperiences = { }; const { findDefaultSignInExperience, updateDefaultSignInExperience } = signInExperiences; -const ssoConnectorLibrary = { +const ssoConnectorLibrary: jest.Mocked = { getSsoConnectors: jest.fn(), getSsoConnectorById: jest.fn(), getAvailableSsoConnectors: jest.fn(), createSsoConnectorIdpInitiatedAuthConfig: jest.fn(), + updateSsoConnectorIdpInitiatedAuthConfig: 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 c04c43521..4a0367610 100644 --- a/packages/core/src/libraries/sso-connector.ts +++ b/packages/core/src/libraries/sso-connector.ts @@ -81,10 +81,37 @@ export const createSsoConnectorLibrary = (queries: Queries) => { return ssoConnectors.insertIdpInitiatedAuthConfig(data); }; + const updateSsoConnectorIdpInitiatedAuthConfig = async ( + connectorId: string, + set: Pick< + Partial, + 'defaultApplicationId' | 'redirectUri' | 'authParameters' + > + ) => { + const { defaultApplicationId } = set; + + if (defaultApplicationId) { + // Throws an 404 error if the application is not found + const application = await applications.findApplicationById(defaultApplicationId); + + assertThat( + application.type === ApplicationType.Traditional && !application.isThirdParty, + new RequestError('connector.saml_idp_initiated_auth_invalid_application_type') + ); + } + + return ssoConnectors.updateIdpInitiatedAuthConfig({ + set, + where: { connectorId }, + jsonbMode: 'replace', + }); + }; + return { getSsoConnectors, getAvailableSsoConnectors, getSsoConnectorById, createSsoConnectorIdpInitiatedAuthConfig, + updateSsoConnectorIdpInitiatedAuthConfig, }; }; diff --git a/packages/core/src/queries/sso-connectors.ts b/packages/core/src/queries/sso-connectors.ts index 6184edb5b..3d3c664c7 100644 --- a/packages/core/src/queries/sso-connectors.ts +++ b/packages/core/src/queries/sso-connectors.ts @@ -13,6 +13,7 @@ import { convertToIdentifiers } from '#src/utils/sql.js'; import { buildInsertIntoWithPool } from '../database/insert-into.js'; import { buildUpdateWhereWithPool } from '../database/update-where.js'; +import { DeletionError } from '../errors/SlonikError/index.js'; const { table: ssoConnectorIdpInitiatedAuthConfigsTable, @@ -66,4 +67,15 @@ export default class SsoConnectorQueries extends SchemaQueries< WHERE ${ssoConnectorIdpInitiatedAuthConfigsFields.connectorId}=${connectorId} `); } + + async deleteIdpInitiatedAuthConfigByConnectorId(connectorId: string) { + const { rowCount } = await this.pool.query(sql` + DELETE FROM ${ssoConnectorIdpInitiatedAuthConfigsTable} + WHERE ${ssoConnectorIdpInitiatedAuthConfigsFields.connectorId}=${connectorId} + `); + + if (rowCount < 1) { + throw new DeletionError(SsoConnectorIdpInitiatedAuthConfigs.table); + } + } } 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 a5e04f753..d61256701 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 @@ -9,11 +9,12 @@ const { jest } = import.meta; const getAvailableSsoConnectorsMock = jest.fn(); -const mockSsoConnectorLibrary: SsoConnectorLibrary = { +const mockSsoConnectorLibrary: jest.Mocked = { getAvailableSsoConnectors: getAvailableSsoConnectorsMock, getSsoConnectors: jest.fn(), getSsoConnectorById: jest.fn(), createSsoConnectorIdpInitiatedAuthConfig: jest.fn(), + updateSsoConnectorIdpInitiatedAuthConfig: jest.fn(), }; describe('verifyEmailIdentifier tests', () => { diff --git a/packages/core/src/routes/sso-connector/idp-initiated-auth-config.openapi.json b/packages/core/src/routes/sso-connector/idp-initiated-auth-config.openapi.json index da05cd422..3df9a81c5 100644 --- a/packages/core/src/routes/sso-connector/idp-initiated-auth-config.openapi.json +++ b/packages/core/src/routes/sso-connector/idp-initiated-auth-config.openapi.json @@ -51,6 +51,52 @@ "description": "The request body is invalid. The SSO connector is not a SAML connector or the application is not a Traditional web application." } } + }, + "patch": { + "summary": "Update IdP initiated auth config", + "description": "Partially update the IdP initiated authentication config of the given SAML SSO connector.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "defaultApplicationId": { + "description": "The unique identifier for the application that users will sign in to using IdP initiated authentication. The application type must be `Traditional` and cannot be a third-party application." + }, + "redirectUri": { + "description": "The sign-in redirect URI for the application. This URI must be registered in the application's OIDC client metadata. If not provided, Logto will use the first registered redirect URI of the application." + }, + "authParameters": { + "description": "The additional parameters to be sent to the application's OIDC authorization endpoint, e.g. `resources` and `scopes`." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated IdP initiated auth config." + }, + "404": { + "description": "IdP initiated auth config not found for the given SSO connector or the provided application ID is not found." + }, + "400": { + "description": "The request body is invalid. The application is not a first-party Traditional web application." + } + } + }, + "delete": { + "summary": "Delete IdP initiated auth config", + "description": "Delete the IdP initiated authentication config of the given SAML SSO connector.", + "responses": { + "204": { + "description": "The IdP initiated auth config has been deleted." + }, + "404": { + "description": "IdP initiated auth config not found for the given SSO connector." + } + } } } } diff --git a/packages/core/src/routes/sso-connector/idp-initiated-auth-config.ts b/packages/core/src/routes/sso-connector/idp-initiated-auth-config.ts index 46e9beeaf..fe3f6b670 100644 --- a/packages/core/src/routes/sso-connector/idp-initiated-auth-config.ts +++ b/packages/core/src/routes/sso-connector/idp-initiated-auth-config.ts @@ -21,7 +21,11 @@ export default function ssoConnectorIdpInitiatedAuthConfigRoutes { + const { + body, + params: { id }, + } = ctx.guard; + + const config = await updateSsoConnectorIdpInitiatedAuthConfig(id, body); + + ctx.body = config; + ctx.status = 200; + + return next(); + } + ); + + router.delete( + pathPrefix, + koaGuard({ + params: z.object({ id: z.string().min(1) }), + status: [204, 404], + }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + + await queries.ssoConnectors.deleteIdpInitiatedAuthConfigByConnectorId(id); + + ctx.status = 204; + + return next(); + } + ); } diff --git a/packages/integration-tests/src/api/sso-connector.ts b/packages/integration-tests/src/api/sso-connector.ts index a367ae2f2..66168c5fa 100644 --- a/packages/integration-tests/src/api/sso-connector.ts +++ b/packages/integration-tests/src/api/sso-connector.ts @@ -115,6 +115,26 @@ export class SsoConnectorApi { .json(); } + async updateSsoConnectorIdpInitiatedAuthConfig( + connectorId: string, + data: Pick< + Partial, + 'defaultApplicationId' | 'redirectUri' | 'authParameters' + > + ) { + return authedAdminApi + .patch(`sso-connectors/${connectorId}/idp-initiated-auth-config`, { + json: data, + }) + .json(); + } + + async deleteSsoConnectorIdpInitiatedAuthConfig(connectorId: string) { + return authedAdminApi + .delete(`sso-connectors/${connectorId}/idp-initiated-auth-config`) + .json(); + } + get firstConnectorId() { return Array.from(this.connectorInstances.keys())[0]; } diff --git a/packages/integration-tests/src/tests/api/sso-connectors/sso-connector-idp-initiated-auth-config.test.ts b/packages/integration-tests/src/tests/api/sso-connectors/sso-connector-idp-initiated-auth-config.test.ts index 1215cd0a5..817259fa5 100644 --- a/packages/integration-tests/src/tests/api/sso-connectors/sso-connector-idp-initiated-auth-config.test.ts +++ b/packages/integration-tests/src/tests/api/sso-connectors/sso-connector-idp-initiated-auth-config.test.ts @@ -39,117 +39,201 @@ devFeatureTest.describe('SAML IdP initiated authentication config', () => { await ssoConnectorsApi.cleanUp(); }); - it('should throw 404 if the connector is not found', async () => { - const defaultApplicationId = applications.get('traditional')!.id; + describe('Set IdP-initiated authentication configuration', () => { + it('should throw 404 if the connector is not found', async () => { + const defaultApplicationId = applications.get('traditional')!.id; - await expectRejects( - ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ - connectorId: 'not-found', - defaultApplicationId, - redirectUri: 'https://example.com', - }), - { - code: 'entity.not_exists_with_id', - status: 404, - } - ); - }); - - it('should throw 400 if the connector is not SAML', async () => { - const defaultApplicationId = applications.get('traditional')!.id; - - await expectRejects( - ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ - connectorId: ssoConnectors.get('oidc')!.id, - defaultApplicationId, - redirectUri: 'https://example.com', - }), - { - code: 'connector.saml_only_idp_initiated_auth', - status: 400, - } - ); - }); - - it('should throw 404 if the application is not found', async () => { - await expectRejects( - ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ - connectorId: ssoConnectors.get('saml')!.id, - defaultApplicationId: 'not-found', - redirectUri: 'https://example.com', - }), - { - code: 'entity.not_exists_with_id', - status: 404, - } - ); - }); - - it.each(['spa', 'thirdParty'])( - 'should throw 400 if the application is not a first-party traditional web application', - async (applicationKey) => { - const defaultApplicationId = applications.get(applicationKey)!.id; await expectRejects( ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ - connectorId: ssoConnectors.get('saml')!.id, + connectorId: 'not-found', defaultApplicationId, redirectUri: 'https://example.com', }), { - code: 'connector.saml_idp_initiated_auth_invalid_application_type', + code: 'entity.not_exists_with_id', + status: 404, + } + ); + }); + + it('should throw 400 if the connector is not SAML', async () => { + const defaultApplicationId = applications.get('traditional')!.id; + + await expectRejects( + ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ + connectorId: ssoConnectors.get('oidc')!.id, + defaultApplicationId, + redirectUri: 'https://example.com', + }), + { + code: 'connector.saml_only_idp_initiated_auth', status: 400, } ); - } - ); - - it('should create a new IdP-initiated authentication configuration for a SAML SSO connector', async () => { - const defaultApplicationId = applications.get('traditional')!.id; - const redirectUri = 'https://example.com'; - const authParameters = { - resources: ['resource1', 'resource2'], - scopes: ['profile', 'email'], - }; - const connectorId = ssoConnectors.get('saml')!.id; - - const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ - connectorId, - defaultApplicationId, - redirectUri, - authParameters, }); - expect(config).toMatchObject({ - defaultApplicationId, - redirectUri, - authParameters, + it('should throw 404 if the application is not found', async () => { + await expectRejects( + ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ + connectorId: ssoConnectors.get('saml')!.id, + defaultApplicationId: 'not-found', + redirectUri: 'https://example.com', + }), + { + code: 'entity.not_exists_with_id', + status: 404, + } + ); }); - const fetchedConfig = await ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig(connectorId); + it.each(['spa', 'thirdParty'])( + 'should throw 400 if the application is not a first-party traditional web application', + async (applicationKey) => { + const defaultApplicationId = applications.get(applicationKey)!.id; + await expectRejects( + ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ + connectorId: ssoConnectors.get('saml')!.id, + defaultApplicationId, + redirectUri: 'https://example.com', + }), + { + code: 'connector.saml_idp_initiated_auth_invalid_application_type', + status: 400, + } + ); + } + ); - expect(fetchedConfig).toMatchObject(config); + it('should create a new IdP-initiated authentication configuration for a SAML SSO connector', async () => { + const defaultApplicationId = applications.get('traditional')!.id; + const redirectUri = 'https://example.com'; + const authParameters = { + resources: ['resource1', 'resource2'], + scopes: ['profile', 'email'], + }; + const connectorId = ssoConnectors.get('saml')!.id; + + const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ + connectorId, + defaultApplicationId, + redirectUri, + authParameters, + }); + + expect(config).toMatchObject({ + defaultApplicationId, + redirectUri, + authParameters, + }); + + const fetchedConfig = await ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig( + connectorId + ); + + expect(fetchedConfig).toMatchObject(config); + }); + + it('should cascade delete the IdP-initiated authentication configuration when the application is deleted', async () => { + const application = await createApplication( + `web-app-${randomString()}`, + ApplicationType.Traditional + ); + const connectorId = ssoConnectors.get('saml')!.id; + + const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ + connectorId, + defaultApplicationId: application.id, + redirectUri: 'https://example.com', + }); + + expect(config).not.toBeNull(); + + await deleteApplication(application.id); + + await expectRejects(ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig(connectorId), { + code: 'entity.not_found', + status: 404, + }); + }); }); - it('should cascade delete the IdP-initiated authentication configuration when the application is deleted', async () => { - const application = await createApplication( - `web-app-${randomString()}`, - ApplicationType.Traditional - ); - const connectorId = ssoConnectors.get('saml')!.id; - - const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ - connectorId, - defaultApplicationId: application.id, - redirectUri: 'https://example.com', + describe('Update IdP-initiated authentication configuration', () => { + it('should throw 404 if the application is not found', async () => { + await expectRejects( + ssoConnectorsApi.updateSsoConnectorIdpInitiatedAuthConfig(ssoConnectors.get('saml')!.id, { + defaultApplicationId: 'not-found', + redirectUri: 'https://example.com', + }), + { + code: 'entity.not_exists_with_id', + status: 404, + } + ); }); - expect(config).not.toBeNull(); + it.each(['spa', 'thirdParty'])( + 'should throw 400 if the application is not a first-party traditional web application', + async (applicationKey) => { + await expectRejects( + ssoConnectorsApi.updateSsoConnectorIdpInitiatedAuthConfig(ssoConnectors.get('saml')!.id, { + defaultApplicationId: applications.get(applicationKey)!.id, + redirectUri: 'https://example.com', + }), + { + code: 'connector.saml_idp_initiated_auth_invalid_application_type', + status: 400, + } + ); + } + ); - await deleteApplication(application.id); + it('should update the IdP-initiated authentication configuration for a SAML SSO connector', async () => { + const connectorId = ssoConnectors.get('saml')!.id; + const defaultApplicationId = applications.get('traditional')!.id; - await expectRejects(ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig(connectorId), { - code: 'entity.not_found', - status: 404, + const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({ + connectorId, + defaultApplicationId, + redirectUri: 'https://example.com', + }); + + const updatedConfig = await ssoConnectorsApi.updateSsoConnectorIdpInitiatedAuthConfig( + connectorId, + { + redirectUri: 'https://updated.com', + authParameters: { + resources: ['resource1', 'resource2'], + scopes: ['profile', 'email'], + }, + } + ); + + expect(updatedConfig).toMatchObject({ + connectorId, + defaultApplicationId, + redirectUri: 'https://updated.com', + authParameters: { + resources: ['resource1', 'resource2'], + scopes: ['profile', 'email'], + }, + }); + }); + + it('should throw 404 if the IdP-initiated authentication configuration is not found', async () => { + const connectorId = ssoConnectors.get('saml')!.id; + + await ssoConnectorsApi.deleteSsoConnectorIdpInitiatedAuthConfig(connectorId); + + await expectRejects( + ssoConnectorsApi.updateSsoConnectorIdpInitiatedAuthConfig(connectorId, { + defaultApplicationId: applications.get('traditional')!.id, + redirectUri: 'https://example.com', + }), + { + code: 'entity.not_exists', + status: 404, + } + ); }); }); });