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

Merge pull request #6827 from logto-io/yemq-patch-get-saml-app-by-id-apis

feat(core): add `PATCH/GET /saml-applications/:id` APIs
This commit is contained in:
Darcy Ye 2024-12-01 20:09:45 -08:00 committed by GitHub
commit c211dd1d38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 287 additions and 90 deletions

View file

@ -0,0 +1,98 @@
import {
ApplicationType,
type SamlApplicationResponse,
type PatchSamlApplication,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { removeUndefinedKeys } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
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,
new RequestError({
code: 'application.saml.saml_application_only',
status: 422,
})
);
const samlConfig = await findSamlApplicationConfigByApplicationId(application.id);
return ensembleSamlApplication({ application, samlConfig });
};
const updateSamlApplicationById = async (
id: string,
patchApplicationObject: PatchSamlApplication
): Promise<SamlApplicationResponse> => {
const { config, ...applicationData } = patchApplicationObject;
const originalApplication = await findApplicationById(id);
assertThat(
originalApplication.type === ApplicationType.SAML,
new RequestError({
code: 'application.saml.saml_application_only',
status: 422,
})
);
const [updatedApplication, upToDateSamlConfig] = await Promise.all([
Object.keys(applicationData).length > 0
? updateApplicationById(id, removeUndefinedKeys(applicationData))
: 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,34 +0,0 @@
import { generateStandardId } from '@logto/shared';
import type Queries from '#src/tenants/Queries.js';
import { generateKeyPairAndCertificate } from './utils.js';
export const createSamlApplicationSecretsLibrary = (queries: Queries) => {
const {
samlApplicationSecrets: { insertSamlApplicationSecret },
} = 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,
});
};
return {
createSamlApplicationSecret,
};
};

View file

@ -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,36 @@ 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) => {
const { binding } = acsUrl;
assertThat(
binding === BindingType.POST,
new RequestError({
code: 'application.saml.acs_url_binding_not_supported',
status: 422,
})
);
};

View file

@ -16,7 +16,19 @@ export const createSamlApplicationConfigQueries = (pool: CommonQueryMethods) =>
const updateSamlApplicationConfig = buildUpdateWhereWithPool(pool)(SamlApplicationConfigs, true);
const findSamlApplicationConfigByApplicationId = async (applicationId: string) =>
pool.maybeOne<SamlApplicationConfig>(sql`
/**
* @remarks
* 使 `.one()` instead of `.maybeOne()` SAML app SAML config API SAML app config
* 1. SAML config alternative SAML app SAML config PATCH 使 insert into ... on conflict query
* 2. SAML app config GET null SAML config
* SAML app SAML config DB update SAML config delete
*
* Here we use the `.one()` method instead of the `.maybeOne()` method because when creating a SAML app, we directly create a corresponding SAML config record. This means that in subsequent API operations on the SAML app's config, we don't need additional checks:
* 1. Whether to insert a SAML config (an alternative approach is not to insert a SAML config record when creating the SAML app, and use an `insert into ... on conflict` query during PATCH to achieve the same result).
* 2. When the corresponding config for the SAML app does not exist, the GET method needs to handle the null SAML config additionally.
* According to our design, if a SAML config record is created at the same time as the SAML app, in all subsequent scenarios, we only deal with update operations on this DB record. In our business scenario, there is no manual deletion of SAML config records.
*/
pool.one<SamlApplicationConfig>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.applicationId}=${applicationId}

View file

@ -1,19 +1,22 @@
import {
ApplicationType,
samlApplicationCreateGuard,
samlApplicationPatchGuard,
samlApplicationResponseGuard,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { removeUndefinedKeys } from '@silverhand/essentials';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
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 assertThat from '#src/utils/assert-that.js';
import { ensembleSamlApplication, validateAcsUrl } from '../libraries/utils.js';
export default function samlApplicationRoutes<T extends ManagementApiRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T>
) {
@ -22,7 +25,11 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
samlApplicationConfigs: { insertSamlApplicationConfig },
} = queries;
const {
samlApplicationSecrets: { createSamlApplicationSecret },
samlApplications: {
createSamlApplicationSecret,
findSamlApplicationById,
updateSamlApplicationById,
},
} = libraries;
router.post(
@ -30,7 +37,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,17 +79,64 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
}
);
router.get(
'/saml-applications/:id',
koaGuard({
params: z.object({
id: z.string(),
}),
response: samlApplicationResponseGuard,
status: [200, 404, 422],
}),
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, 404, 422],
}),
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({
params: z.object({ id: z.string() }),
status: [204, 400, 404],
status: [204, 422, 404],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
const { type } = await findApplicationById(id);
assertThat(type === ApplicationType.SAML, 'application.saml.saml_application_only');
assertThat(
type === ApplicationType.SAML,
new RequestError({
code: 'application.saml.saml_application_only',
status: 422,
})
);
await deleteApplicationById(id);

View file

@ -1,42 +0,0 @@
import {
type SamlApplicationResponse,
type Application,
type SamlApplicationConfig,
type SamlAcsUrl,
BindingType,
} from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
import assertThat from '#src/utils/assert-that.js';
/**
* 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

@ -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);

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';
@ -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>();

View file

@ -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,14 +59,47 @@ 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,
});
await expectRejects(deleteSamlApplication(application.id), {
code: 'application.saml.saml_application_only',
status: 400,
status: 422,
});
await expectRejects(updateSamlApplication(application.id, { name: 'updated' }), {
code: 'application.saml.saml_application_only',
status: 422,
});
await expectRejects(getSamlApplication(application.id), {
code: 'application.saml.saml_application_only',
status: 422,
});
await deleteApplication(application.id);
});

View file

@ -19,5 +19,5 @@ export type SamlAcsUrl = {
export const samlAcsUrlGuard = z.object({
binding: z.nativeEnum(BindingType),
url: z.string(),
url: z.string().url(),
}) satisfies ToZodObject<SamlAcsUrl>;

View file

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