From 2ec1f56c1198ffc459b46a4548f4a81522310ba1 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Fri, 22 Nov 2024 18:22:00 +0800 Subject: [PATCH] feat(core): add PATCH/GET /saml-applications/:id APIs --- .../libraries/saml-applications.ts | 94 +++++++++ .../src/saml-applications/libraries/utils.ts | 42 ++++ .../src/saml-applications/queries/configs.ts | 2 +- .../routes/index.openapi.json | 190 ++++++++++++++++++ .../src/saml-applications/routes/index.ts | 52 ++++- packages/core/src/tenants/Libraries.ts | 4 +- .../src/api/saml-application.ts | 17 +- .../api/application/saml-application.test.ts | 42 +++- .../schemas/src/types/saml-application.ts | 15 +- 9 files changed, 448 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/saml-applications/libraries/saml-applications.ts create mode 100644 packages/core/src/saml-applications/routes/index.openapi.json diff --git a/packages/core/src/saml-applications/libraries/saml-applications.ts b/packages/core/src/saml-applications/libraries/saml-applications.ts new file mode 100644 index 000000000..62d3c40fa --- /dev/null +++ b/packages/core/src/saml-applications/libraries/saml-applications.ts @@ -0,0 +1,94 @@ +import { + ApplicationType, + type SamlApplicationResponse, + type PatchSamlApplication, +} from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { removeUndefinedKeys } from '@silverhand/essentials'; + +import type Queries from '#src/tenants/Queries.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { ensembleSamlApplication, generateKeyPairAndCertificate } from './utils.js'; + +export const createSamlApplicationsLibrary = (queries: Queries) => { + const { + applications: { findApplicationById, updateApplicationById }, + samlApplicationSecrets: { insertSamlApplicationSecret }, + samlApplicationConfigs: { + findSamlApplicationConfigByApplicationId, + updateSamlApplicationConfig, + }, + } = queries; + + const createSamlApplicationSecret = async ( + applicationId: string, + // Set certificate life span to 1 year by default. + lifeSpanInDays = 365 + ) => { + const { privateKey, certificate, notAfter } = await generateKeyPairAndCertificate( + lifeSpanInDays + ); + + return insertSamlApplicationSecret({ + id: generateStandardId(), + applicationId, + privateKey, + certificate, + expiresAt: Math.floor(notAfter.getTime() / 1000), + active: false, + }); + }; + + const findSamlApplicationById = async (id: string): Promise => { + const application = await findApplicationById(id); + assertThat(application.type === ApplicationType.SAML, 'application.saml.saml_application_only'); + + const samlConfig = await findSamlApplicationConfigByApplicationId(application.id); + + return ensembleSamlApplication({ application, samlConfig }); + }; + + const updateSamlApplicationById = async ( + id: string, + patchApplicationObject: PatchSamlApplication + ): Promise => { + const { name, description, customData, config } = patchApplicationObject; + const originalApplication = await findApplicationById(id); + assertThat( + originalApplication.type === ApplicationType.SAML, + 'application.saml.saml_application_only' + ); + + const [updatedApplication, upToDateSamlConfig] = await Promise.all([ + name || description || customData + ? updateApplicationById( + id, + removeUndefinedKeys({ + name, + description, + customData, + }) + ) + : originalApplication, + config + ? updateSamlApplicationConfig({ + set: config, + where: { applicationId: id }, + jsonbMode: 'replace', + }) + : findSamlApplicationConfigByApplicationId(id), + ]); + + return ensembleSamlApplication({ + application: updatedApplication, + samlConfig: upToDateSamlConfig, + }); + }; + + return { + createSamlApplicationSecret, + findSamlApplicationById, + updateSamlApplicationById, + }; +}; diff --git a/packages/core/src/saml-applications/libraries/utils.ts b/packages/core/src/saml-applications/libraries/utils.ts index e73621394..25d4925f7 100644 --- a/packages/core/src/saml-applications/libraries/utils.ts +++ b/packages/core/src/saml-applications/libraries/utils.ts @@ -1,8 +1,18 @@ import crypto from 'node:crypto'; +import { + type SamlApplicationResponse, + type Application, + type SamlApplicationConfig, + type SamlAcsUrl, + BindingType, +} from '@logto/schemas'; import { addDays } from 'date-fns'; import forge from 'node-forge'; +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; + export const generateKeyPairAndCertificate = async (lifeSpanInDays = 365) => { const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 }); return createCertificate(keypair, lifeSpanInDays); @@ -56,3 +66,35 @@ const createCertificate = (keypair: forge.pki.KeyPair, lifeSpanInDays: number) = notAfter, }; }; + +/** + * According to the design, a SAML app will be associated with multiple records from various tables. + * Therefore, when complete SAML app data is required, it is necessary to retrieve multiple related records and assemble them into a comprehensive SAML app dataset. This dataset includes: + * - A record from the `applications` table with a `type` of `SAML` + * - A record from the `saml_application_configs` table + */ +export const ensembleSamlApplication = ({ + application, + samlConfig, +}: { + application: Application; + samlConfig: Pick; +}): SamlApplicationResponse => { + return { + ...application, + ...samlConfig, + }; +}; + +/** + * Only HTTP-POST binding is supported for receiving SAML assertions at the moment. + */ +export const validateAcsUrl = (acsUrl: SamlAcsUrl) => { + assertThat( + acsUrl.binding === BindingType.POST, + new RequestError({ + code: 'application.saml.acs_url_binding_not_supported', + status: 422, + }) + ); +}; diff --git a/packages/core/src/saml-applications/queries/configs.ts b/packages/core/src/saml-applications/queries/configs.ts index f95486c20..ce75f7239 100644 --- a/packages/core/src/saml-applications/queries/configs.ts +++ b/packages/core/src/saml-applications/queries/configs.ts @@ -16,7 +16,7 @@ export const createSamlApplicationConfigQueries = (pool: CommonQueryMethods) => const updateSamlApplicationConfig = buildUpdateWhereWithPool(pool)(SamlApplicationConfigs, true); const findSamlApplicationConfigByApplicationId = async (applicationId: string) => - pool.maybeOne(sql` + pool.one(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} where ${fields.applicationId}=${applicationId} diff --git a/packages/core/src/saml-applications/routes/index.openapi.json b/packages/core/src/saml-applications/routes/index.openapi.json new file mode 100644 index 000000000..902fc5914 --- /dev/null +++ b/packages/core/src/saml-applications/routes/index.openapi.json @@ -0,0 +1,190 @@ +{ + "tags": [ + { + "name": "SAML applications", + "description": "SAML applications enable Single Sign-On (SSO) integration between Logto (acting as Identity Provider/IdP) and third-party Service Providers (SP) using the SAML 2.0 protocol. These endpoints allow you to manage SAML application configurations." + }, + { + "name": "Dev feature" + } + ], + "paths": { + "/api/saml-applications": { + "post": { + "summary": "Create SAML application", + "description": "Create a new SAML application with the given configuration. This will create both the application entity and its SAML-specific configurations.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the SAML application." + }, + "description": { + "type": "string", + "description": "The description of the SAML application." + }, + "customData": { + "type": "object", + "description": "Custom data for the application." + }, + "config": { + "type": "object", + "properties": { + "attributeMapping": { + "type": "object", + "description": "Mapping of SAML attributes to Logto user properties." + }, + "entityId": { + "type": "string", + "description": "Service provider's entityId." + }, + "acsUrl": { + "type": "object", + "description": "Service provider assertion consumer service URL configuration." + } + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The SAML application was created successfully." + }, + "400": { + "description": "Invalid request body or SAML configuration." + } + } + } + }, + "/api/saml-applications/{id}": { + "get": { + "summary": "Get SAML application", + "description": "Get a SAML application by ID. This will return both the application entity and its SAML-specific configurations.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the SAML application to retrieve." + } + ], + "responses": { + "200": { + "description": "The SAML application was retrieved successfully." + }, + "400": { + "description": "Invalid application ID, the application is not a SAML application." + }, + "404": { + "description": "The SAML application was not found." + } + } + }, + "patch": { + "summary": "Update SAML application", + "description": "Update a SAML application by ID. This will update both the application entity and its SAML-specific configurations.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the SAML application to update." + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string", + "description": "The name of the SAML application." + }, + "description": { + "type": "string", + "description": "The description of the SAML application." + }, + "customData": { + "type": "object", + "description": "Custom data for the application." + }, + "config": { + "type": "object", + "properties": { + "attributeMapping": { + "type": "object", + "description": "Mapping of SAML attributes to Logto user properties." + }, + "entityId": { + "type": "string", + "description": "Service provider's entityId." + }, + "acsUrl": { + "type": "object", + "description": "Service provider assertion consumer service URL configuration." + } + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The SAML application was updated successfully." + }, + "400": { + "description": "Invalid application ID or request body." + }, + "404": { + "description": "The SAML application was not found." + }, + "422": { + "description": "Invalid SAML configuration." + } + } + }, + "delete": { + "summary": "Delete SAML application", + "description": "Delete a SAML application by ID. This will remove both the application entity and its SAML-specific configurations.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the SAML application to delete." + } + ], + "responses": { + "204": { + "description": "The SAML application was deleted successfully." + }, + "400": { + "description": "Invalid application ID, the application is not a SAML application." + }, + "404": { + "description": "The SAML application was not found." + } + } + } + } + } +} diff --git a/packages/core/src/saml-applications/routes/index.ts b/packages/core/src/saml-applications/routes/index.ts index 1d0486614..c134a6bad 100644 --- a/packages/core/src/saml-applications/routes/index.ts +++ b/packages/core/src/saml-applications/routes/index.ts @@ -1,6 +1,7 @@ import { ApplicationType, samlApplicationCreateGuard, + samlApplicationPatchGuard, samlApplicationResponseGuard, } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; @@ -11,7 +12,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import { buildOidcClientMetadata } from '#src/oidc/utils.js'; import { generateInternalSecret } from '#src/routes/applications/application-secret.js'; import type { ManagementApiRouter, RouterInitArgs } from '#src/routes/types.js'; -import { ensembleSamlApplication, validateAcsUrl } from '#src/saml-applications/routes/utils.js'; +import { ensembleSamlApplication, validateAcsUrl } from '#src/saml-applications/libraries/utils.js'; import assertThat from '#src/utils/assert-that.js'; export default function samlApplicationRoutes( @@ -22,7 +23,11 @@ export default function samlApplicationRoutes( samlApplicationConfigs: { insertSamlApplicationConfig }, } = queries; const { - samlApplicationSecrets: { createSamlApplicationSecret }, + samlApplications: { + createSamlApplicationSecret, + findSamlApplicationById, + updateSamlApplicationById, + }, } = libraries; router.post( @@ -30,7 +35,7 @@ export default function samlApplicationRoutes( koaGuard({ body: samlApplicationCreateGuard, response: samlApplicationResponseGuard, - status: [201, 400], + status: [201, 400, 422], }), async (ctx, next) => { const { name, description, customData, config } = ctx.guard.body; @@ -72,6 +77,47 @@ export default function samlApplicationRoutes( } ); + router.get( + '/saml-applications/:id', + koaGuard({ + params: z.object({ + id: z.string(), + }), + response: samlApplicationResponseGuard, + status: [200, 400, 404], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + + const samlApplication = await findSamlApplicationById(id); + + ctx.status = 200; + ctx.body = samlApplication; + + return next(); + } + ); + + router.patch( + '/saml-applications/:id', + koaGuard({ + params: z.object({ id: z.string() }), + body: samlApplicationPatchGuard, + response: samlApplicationResponseGuard, + status: [200, 400, 404], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + + const updatedSamlApplication = await updateSamlApplicationById(id, ctx.guard.body); + + ctx.status = 200; + ctx.body = updatedSamlApplication; + + return next(); + } + ); + router.delete( '/saml-applications/:id', koaGuard({ diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index 24b7e5f1b..bd4575300 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -17,7 +17,7 @@ import { createSocialLibrary } from '#src/libraries/social.js'; import { createSsoConnectorLibrary } from '#src/libraries/sso-connector.js'; import { createUserLibrary } from '#src/libraries/user.js'; import { createVerificationStatusLibrary } from '#src/libraries/verification-status.js'; -import { createSamlApplicationSecretsLibrary } from '#src/saml-applications/libraries/secrets.js'; +import { createSamlApplicationsLibrary } from '#src/saml-applications/libraries/saml-applications.js'; import type Queries from './Queries.js'; @@ -38,7 +38,7 @@ export default class Libraries { passcodes = createPasscodeLibrary(this.queries, this.connectors); applications = createApplicationLibrary(this.queries); verificationStatuses = createVerificationStatusLibrary(this.queries); - samlApplicationSecrets = createSamlApplicationSecretsLibrary(this.queries); + samlApplications = createSamlApplicationsLibrary(this.queries); roleScopes = createRoleScopeLibrary(this.queries); domains = createDomainLibrary(this.queries); protectedApps = createProtectedAppLibrary(this.queries); diff --git a/packages/integration-tests/src/api/saml-application.ts b/packages/integration-tests/src/api/saml-application.ts index 88b2ff88d..c452a177d 100644 --- a/packages/integration-tests/src/api/saml-application.ts +++ b/packages/integration-tests/src/api/saml-application.ts @@ -1,4 +1,8 @@ -import { type SamlApplicationResponse, type CreateSamlApplication } from '@logto/schemas'; +import { + type SamlApplicationResponse, + type CreateSamlApplication, + type PatchSamlApplication, +} from '@logto/schemas'; import { authedAdminApi } from './api.js'; @@ -11,3 +15,14 @@ export const createSamlApplication = async (createSamlApplication: CreateSamlApp export const deleteSamlApplication = async (id: string) => authedAdminApi.delete(`saml-applications/${id}`); + +export const updateSamlApplication = async ( + id: string, + patchSamlApplication: PatchSamlApplication +) => + authedAdminApi + .patch(`saml-applications/${id}`, { json: patchSamlApplication }) + .json(); + +export const getSamlApplication = async (id: string) => + authedAdminApi.get(`saml-applications/${id}`).json(); diff --git a/packages/integration-tests/src/tests/api/application/saml-application.test.ts b/packages/integration-tests/src/tests/api/application/saml-application.test.ts index e990dd28e..a41a817cf 100644 --- a/packages/integration-tests/src/tests/api/application/saml-application.test.ts +++ b/packages/integration-tests/src/tests/api/application/saml-application.test.ts @@ -1,7 +1,12 @@ import { ApplicationType, BindingType } from '@logto/schemas'; import { createApplication, deleteApplication } from '#src/api/application.js'; -import { createSamlApplication, deleteSamlApplication } from '#src/api/saml-application.js'; +import { + createSamlApplication, + deleteSamlApplication, + updateSamlApplication, + getSamlApplication, +} from '#src/api/saml-application.js'; import { expectRejects } from '#src/helpers/index.js'; import { devFeatureTest } from '#src/utils.js'; @@ -54,7 +59,32 @@ describe('SAML application', () => { await deleteSamlApplication(createdSamlApplication.id); }); - it('can not delete non-SAML applications with `DEL /saml-applications/:id` API', async () => { + it('should be able to update SAML application and get the updated one', async () => { + const createdSamlApplication = await createSamlApplication({ + name: 'test', + description: 'test', + }); + + const newConfig = { + acsUrl: { + binding: BindingType.POST, + url: 'https://example.logto.io/sso/saml', + }, + }; + const updatedSamlApplication = await updateSamlApplication(createdSamlApplication.id, { + name: 'updated', + config: newConfig, + }); + const upToDateSamlApplication = await getSamlApplication(createdSamlApplication.id); + + expect(updatedSamlApplication).toEqual(upToDateSamlApplication); + expect(updatedSamlApplication.name).toEqual('updated'); + expect(updatedSamlApplication.acsUrl).toEqual(newConfig.acsUrl); + + await deleteSamlApplication(updatedSamlApplication.id); + }); + + it('can not delete/update/get non-SAML applications with `DEL /saml-applications/:id` API', async () => { const application = await createApplication('test-non-saml-app', ApplicationType.Traditional, { isThirdParty: true, }); @@ -63,6 +93,14 @@ describe('SAML application', () => { code: 'application.saml.saml_application_only', status: 400, }); + await expectRejects(updateSamlApplication(application.id, { name: 'updated' }), { + code: 'application.saml.saml_application_only', + status: 400, + }); + await expectRejects(getSamlApplication(application.id), { + code: 'application.saml.saml_application_only', + status: 400, + }); await deleteApplication(application.id); }); }); diff --git a/packages/schemas/src/types/saml-application.ts b/packages/schemas/src/types/saml-application.ts index 10b962ea3..98f786a7a 100644 --- a/packages/schemas/src/types/saml-application.ts +++ b/packages/schemas/src/types/saml-application.ts @@ -3,7 +3,7 @@ import { type z } from 'zod'; import { Applications } from '../db-entries/application.js'; import { SamlApplicationConfigs } from '../db-entries/saml-application-config.js'; -import { applicationCreateGuard } from './application.js'; +import { applicationCreateGuard, applicationPatchGuard } from './application.js'; const samlAppConfigGuard = SamlApplicationConfigs.guard.pick({ attributeMapping: true, @@ -24,6 +24,19 @@ export const samlApplicationCreateGuard = applicationCreateGuard export type CreateSamlApplication = z.infer; +export const samlApplicationPatchGuard = applicationPatchGuard + .pick({ + name: true, + description: true, + customData: true, + }) + .extend({ + // The reason for encapsulating attributeMapping and spMetadata into an object within the config field is that you cannot provide only one of `attributeMapping` or `spMetadata`. Due to the structure of the `saml_application_configs` table, both must be not null. + config: samlAppConfigGuard.partial().optional(), + }); + +export type PatchSamlApplication = z.infer; + export const samlApplicationResponseGuard = Applications.guard.merge( // Partial to allow the optional fields to be omitted in the response. // When starting to create a SAML application, SAML configuration is optional, which can lead to the absence of SAML configuration.