0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add PATCH/GET /saml-applications/:id APIs

This commit is contained in:
Darcy Ye 2024-11-22 18:22:00 +08:00
parent 3d85ab101e
commit 2ec1f56c11
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
9 changed files with 448 additions and 10 deletions

View file

@ -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<SamlApplicationResponse> => {
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<SamlApplicationResponse> => {
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,
};
};

View file

@ -1,8 +1,18 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import {
type SamlApplicationResponse,
type Application,
type SamlApplicationConfig,
type SamlAcsUrl,
BindingType,
} from '@logto/schemas';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
import forge from 'node-forge'; 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) => { export const generateKeyPairAndCertificate = async (lifeSpanInDays = 365) => {
const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 }); const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 });
return createCertificate(keypair, lifeSpanInDays); return createCertificate(keypair, lifeSpanInDays);
@ -56,3 +66,35 @@ const createCertificate = (keypair: forge.pki.KeyPair, lifeSpanInDays: number) =
notAfter, 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<SamlApplicationConfig, 'attributeMapping' | 'entityId' | 'acsUrl'>;
}): 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,
})
);
};

View file

@ -16,7 +16,7 @@ export const createSamlApplicationConfigQueries = (pool: CommonQueryMethods) =>
const updateSamlApplicationConfig = buildUpdateWhereWithPool(pool)(SamlApplicationConfigs, true); const updateSamlApplicationConfig = buildUpdateWhereWithPool(pool)(SamlApplicationConfigs, true);
const findSamlApplicationConfigByApplicationId = async (applicationId: string) => const findSamlApplicationConfigByApplicationId = async (applicationId: string) =>
pool.maybeOne<SamlApplicationConfig>(sql` pool.one<SamlApplicationConfig>(sql`
select ${sql.join(Object.values(fields), sql`, `)} select ${sql.join(Object.values(fields), sql`, `)}
from ${table} from ${table}
where ${fields.applicationId}=${applicationId} where ${fields.applicationId}=${applicationId}

View file

@ -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."
}
}
}
}
}
}

View file

@ -1,6 +1,7 @@
import { import {
ApplicationType, ApplicationType,
samlApplicationCreateGuard, samlApplicationCreateGuard,
samlApplicationPatchGuard,
samlApplicationResponseGuard, samlApplicationResponseGuard,
} from '@logto/schemas'; } from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; 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 { buildOidcClientMetadata } from '#src/oidc/utils.js';
import { generateInternalSecret } from '#src/routes/applications/application-secret.js'; import { generateInternalSecret } from '#src/routes/applications/application-secret.js';
import type { ManagementApiRouter, RouterInitArgs } from '#src/routes/types.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'; import assertThat from '#src/utils/assert-that.js';
export default function samlApplicationRoutes<T extends ManagementApiRouter>( export default function samlApplicationRoutes<T extends ManagementApiRouter>(
@ -22,7 +23,11 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
samlApplicationConfigs: { insertSamlApplicationConfig }, samlApplicationConfigs: { insertSamlApplicationConfig },
} = queries; } = queries;
const { const {
samlApplicationSecrets: { createSamlApplicationSecret }, samlApplications: {
createSamlApplicationSecret,
findSamlApplicationById,
updateSamlApplicationById,
},
} = libraries; } = libraries;
router.post( router.post(
@ -30,7 +35,7 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
koaGuard({ koaGuard({
body: samlApplicationCreateGuard, body: samlApplicationCreateGuard,
response: samlApplicationResponseGuard, response: samlApplicationResponseGuard,
status: [201, 400], status: [201, 400, 422],
}), }),
async (ctx, next) => { async (ctx, next) => {
const { name, description, customData, config } = ctx.guard.body; const { name, description, customData, config } = ctx.guard.body;
@ -72,6 +77,47 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
} }
); );
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( router.delete(
'/saml-applications/:id', '/saml-applications/:id',
koaGuard({ koaGuard({

View file

@ -17,7 +17,7 @@ import { createSocialLibrary } from '#src/libraries/social.js';
import { createSsoConnectorLibrary } from '#src/libraries/sso-connector.js'; import { createSsoConnectorLibrary } from '#src/libraries/sso-connector.js';
import { createUserLibrary } from '#src/libraries/user.js'; import { createUserLibrary } from '#src/libraries/user.js';
import { createVerificationStatusLibrary } from '#src/libraries/verification-status.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'; import type Queries from './Queries.js';
@ -38,7 +38,7 @@ export default class Libraries {
passcodes = createPasscodeLibrary(this.queries, this.connectors); passcodes = createPasscodeLibrary(this.queries, this.connectors);
applications = createApplicationLibrary(this.queries); applications = createApplicationLibrary(this.queries);
verificationStatuses = createVerificationStatusLibrary(this.queries); verificationStatuses = createVerificationStatusLibrary(this.queries);
samlApplicationSecrets = createSamlApplicationSecretsLibrary(this.queries); samlApplications = createSamlApplicationsLibrary(this.queries);
roleScopes = createRoleScopeLibrary(this.queries); roleScopes = createRoleScopeLibrary(this.queries);
domains = createDomainLibrary(this.queries); domains = createDomainLibrary(this.queries);
protectedApps = createProtectedAppLibrary(this.queries); protectedApps = createProtectedAppLibrary(this.queries);

View file

@ -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'; import { authedAdminApi } from './api.js';
@ -11,3 +15,14 @@ export const createSamlApplication = async (createSamlApplication: CreateSamlApp
export const deleteSamlApplication = async (id: string) => export const deleteSamlApplication = async (id: string) =>
authedAdminApi.delete(`saml-applications/${id}`); authedAdminApi.delete(`saml-applications/${id}`);
export const updateSamlApplication = async (
id: string,
patchSamlApplication: PatchSamlApplication
) =>
authedAdminApi
.patch(`saml-applications/${id}`, { json: patchSamlApplication })
.json<SamlApplicationResponse>();
export const getSamlApplication = async (id: string) =>
authedAdminApi.get(`saml-applications/${id}`).json<SamlApplicationResponse>();

View file

@ -1,7 +1,12 @@
import { ApplicationType, BindingType } from '@logto/schemas'; import { ApplicationType, BindingType } from '@logto/schemas';
import { createApplication, deleteApplication } from '#src/api/application.js'; 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 { expectRejects } from '#src/helpers/index.js';
import { devFeatureTest } from '#src/utils.js'; import { devFeatureTest } from '#src/utils.js';
@ -54,7 +59,32 @@ describe('SAML application', () => {
await deleteSamlApplication(createdSamlApplication.id); 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, { const application = await createApplication('test-non-saml-app', ApplicationType.Traditional, {
isThirdParty: true, isThirdParty: true,
}); });
@ -63,6 +93,14 @@ describe('SAML application', () => {
code: 'application.saml.saml_application_only', code: 'application.saml.saml_application_only',
status: 400, 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); await deleteApplication(application.id);
}); });
}); });

View file

@ -3,7 +3,7 @@ import { type z } from 'zod';
import { Applications } from '../db-entries/application.js'; import { Applications } from '../db-entries/application.js';
import { SamlApplicationConfigs } from '../db-entries/saml-application-config.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({ const samlAppConfigGuard = SamlApplicationConfigs.guard.pick({
attributeMapping: true, attributeMapping: true,
@ -24,6 +24,19 @@ export const samlApplicationCreateGuard = applicationCreateGuard
export type CreateSamlApplication = z.infer<typeof samlApplicationCreateGuard>; export type CreateSamlApplication = z.infer<typeof samlApplicationCreateGuard>;
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<typeof samlApplicationPatchGuard>;
export const samlApplicationResponseGuard = Applications.guard.merge( export const samlApplicationResponseGuard = Applications.guard.merge(
// Partial to allow the optional fields to be omitted in the response. // 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. // When starting to create a SAML application, SAML configuration is optional, which can lead to the absence of SAML configuration.