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:
parent
3d85ab101e
commit
2ec1f56c11
9 changed files with 448 additions and 10 deletions
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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<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,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -16,7 +16,7 @@ export const createSamlApplicationConfigQueries = (pool: CommonQueryMethods) =>
|
|||
const updateSamlApplicationConfig = buildUpdateWhereWithPool(pool)(SamlApplicationConfigs, true);
|
||||
|
||||
const findSamlApplicationConfigByApplicationId = async (applicationId: string) =>
|
||||
pool.maybeOne<SamlApplicationConfig>(sql`
|
||||
pool.one<SamlApplicationConfig>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.applicationId}=${applicationId}
|
||||
|
|
190
packages/core/src/saml-applications/routes/index.openapi.json
Normal file
190
packages/core/src/saml-applications/routes/index.openapi.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<T extends ManagementApiRouter>(
|
||||
|
@ -22,7 +23,11 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
|||
samlApplicationConfigs: { insertSamlApplicationConfig },
|
||||
} = queries;
|
||||
const {
|
||||
samlApplicationSecrets: { createSamlApplicationSecret },
|
||||
samlApplications: {
|
||||
createSamlApplicationSecret,
|
||||
findSamlApplicationById,
|
||||
updateSamlApplicationById,
|
||||
},
|
||||
} = libraries;
|
||||
|
||||
router.post(
|
||||
|
@ -30,7 +35,7 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
|||
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<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(
|
||||
'/saml-applications/:id',
|
||||
koaGuard({
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<SamlApplicationResponse>();
|
||||
|
||||
export const getSamlApplication = async (id: string) =>
|
||||
authedAdminApi.get(`saml-applications/${id}`).json<SamlApplicationResponse>();
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<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(
|
||||
// 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.
|
||||
|
|
Loading…
Reference in a new issue