mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #6832 from logto-io/yemq-saml-applications-secrets-apis
feat: add SAML app secret related APIs
This commit is contained in:
commit
542250339c
7 changed files with 236 additions and 1 deletions
|
@ -2,6 +2,7 @@ import {
|
|||
ApplicationType,
|
||||
type SamlApplicationResponse,
|
||||
type PatchSamlApplication,
|
||||
type SamlApplicationSecret,
|
||||
} from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { removeUndefinedKeys } from '@silverhand/essentials';
|
||||
|
@ -26,7 +27,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
|
|||
applicationId: string,
|
||||
// Set certificate life span to 1 year by default.
|
||||
lifeSpanInDays = 365
|
||||
) => {
|
||||
): Promise<SamlApplicationSecret> => {
|
||||
const { privateKey, certificate, notAfter } = await generateKeyPairAndCertificate(
|
||||
lifeSpanInDays
|
||||
);
|
||||
|
|
|
@ -20,6 +20,13 @@ export const createSamlApplicationSecretsQueries = (pool: CommonQueryMethods) =>
|
|||
where ${fields.applicationId}=${applicationId}
|
||||
`);
|
||||
|
||||
const findSamlApplicationSecretByApplicationIdAndId = async (applicationId: string, id: string) =>
|
||||
pool.one<SamlApplicationSecret>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.applicationId} = ${applicationId} and ${fields.id} = ${id}
|
||||
`);
|
||||
|
||||
const deleteSamlApplicationSecretById = async (id: string) => {
|
||||
const { rowCount } = await pool.query(sql`
|
||||
delete from ${table}
|
||||
|
@ -31,9 +38,38 @@ export const createSamlApplicationSecretsQueries = (pool: CommonQueryMethods) =>
|
|||
}
|
||||
};
|
||||
|
||||
const updateSamlApplicationSecretStatusByApplicationIdAndSecretId = async (
|
||||
applicationId: string,
|
||||
secretId: string,
|
||||
active: boolean
|
||||
) => {
|
||||
return pool.transaction(async (transaction) => {
|
||||
if (active) {
|
||||
// If activating this secret, first deactivate all other secrets of the same application
|
||||
await transaction.query(sql`
|
||||
update ${table}
|
||||
set ${fields.active} = false
|
||||
where ${fields.applicationId} = ${applicationId}
|
||||
`);
|
||||
}
|
||||
|
||||
// Update the status of the specified secret
|
||||
const updatedSecret = await transaction.one<SamlApplicationSecret>(sql`
|
||||
update ${table}
|
||||
set ${fields.active} = ${active}
|
||||
where ${fields.id} = ${secretId} and ${fields.applicationId} = ${applicationId}
|
||||
returning ${sql.join(Object.values(fields), sql`, `)}
|
||||
`);
|
||||
|
||||
return updatedSecret;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
insertSamlApplicationSecret,
|
||||
findSamlApplicationSecretsByApplicationId,
|
||||
findSamlApplicationSecretByApplicationIdAndId,
|
||||
deleteSamlApplicationSecretById,
|
||||
updateSamlApplicationSecretStatusByApplicationIdAndSecretId,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -3,6 +3,8 @@ import {
|
|||
samlApplicationCreateGuard,
|
||||
samlApplicationPatchGuard,
|
||||
samlApplicationResponseGuard,
|
||||
samlApplicationSecretResponseGuard,
|
||||
SamlApplicationSecrets,
|
||||
} from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { removeUndefinedKeys } from '@silverhand/essentials';
|
||||
|
@ -23,6 +25,12 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
|||
const {
|
||||
applications: { insertApplication, findApplicationById, deleteApplicationById },
|
||||
samlApplicationConfigs: { insertSamlApplicationConfig },
|
||||
samlApplicationSecrets: {
|
||||
deleteSamlApplicationSecretById,
|
||||
findSamlApplicationSecretsByApplicationId,
|
||||
findSamlApplicationSecretByApplicationIdAndId,
|
||||
updateSamlApplicationSecretStatusByApplicationIdAndSecretId,
|
||||
},
|
||||
} = queries;
|
||||
const {
|
||||
samlApplications: {
|
||||
|
@ -145,4 +153,103 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/saml-applications/:id/secrets',
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string() }),
|
||||
body: z.object({ lifeSpanInDays: z.number().optional() }),
|
||||
response: samlApplicationSecretResponseGuard,
|
||||
status: [201, 400, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
body: { lifeSpanInDays },
|
||||
params: { id },
|
||||
} = ctx.guard;
|
||||
|
||||
ctx.status = 201;
|
||||
ctx.body = await createSamlApplicationSecret(id, lifeSpanInDays);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/saml-applications/:id/secrets',
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string() }),
|
||||
response: samlApplicationSecretResponseGuard.array(),
|
||||
status: [200, 400, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
|
||||
ctx.status = 200;
|
||||
ctx.body = await findSamlApplicationSecretsByApplicationId(id);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/saml-applications/:id/secrets/:secretId',
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string(), secretId: z.string() }),
|
||||
status: [204, 400, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id, secretId } = ctx.guard.params;
|
||||
|
||||
// Although we can directly find the SAML app secret by `secretId` here, to prevent deleting a secret that does not belong to the current application, we will first verify through the application ID and secret ID.
|
||||
const samlApplicationSecret = await findSamlApplicationSecretByApplicationIdAndId(
|
||||
id,
|
||||
secretId
|
||||
);
|
||||
|
||||
assertThat(!samlApplicationSecret.active, 'application.saml.can_not_delete_active_secret');
|
||||
|
||||
await deleteSamlApplicationSecretById(secretId);
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/saml-applications/:id/secrets/:secretId',
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string(), secretId: z.string() }),
|
||||
body: SamlApplicationSecrets.createGuard.pick({
|
||||
active: true,
|
||||
}),
|
||||
response: samlApplicationSecretResponseGuard,
|
||||
status: [200, 400, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id, secretId } = ctx.guard.params;
|
||||
const { active } = ctx.guard.body;
|
||||
|
||||
const originalSamlApplicationSecret = await findSamlApplicationSecretByApplicationIdAndId(
|
||||
id,
|
||||
secretId
|
||||
);
|
||||
|
||||
if (originalSamlApplicationSecret.active === active) {
|
||||
ctx.status = 200;
|
||||
ctx.body = originalSamlApplicationSecret;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
const updatedSamlApplicationSecret =
|
||||
await updateSamlApplicationSecretStatusByApplicationIdAndSecretId(id, secretId, active);
|
||||
|
||||
ctx.status = 200;
|
||||
ctx.body = updatedSamlApplicationSecret;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
type SamlApplicationResponse,
|
||||
type CreateSamlApplication,
|
||||
type PatchSamlApplication,
|
||||
type SamlApplicationSecretResponse,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
|
@ -26,3 +27,19 @@ export const updateSamlApplication = async (
|
|||
|
||||
export const getSamlApplication = async (id: string) =>
|
||||
authedAdminApi.get(`saml-applications/${id}`).json<SamlApplicationResponse>();
|
||||
|
||||
export const createSamlApplicationSecret = async (id: string, lifeSpanInDays: number) =>
|
||||
authedAdminApi
|
||||
.post(`saml-applications/${id}/secrets`, { json: { lifeSpanInDays } })
|
||||
.json<SamlApplicationSecretResponse>();
|
||||
|
||||
export const getSamlApplicationSecrets = async (id: string) =>
|
||||
authedAdminApi.get(`saml-applications/${id}/secrets`).json<SamlApplicationSecretResponse[]>();
|
||||
|
||||
export const deleteSamlApplicationSecret = async (id: string, secretId: string) =>
|
||||
authedAdminApi.delete(`saml-applications/${id}/secrets/${secretId}`);
|
||||
|
||||
export const updateSamlApplicationSecret = async (id: string, secretId: string, active: boolean) =>
|
||||
authedAdminApi
|
||||
.patch(`saml-applications/${id}/secrets/${secretId}`, { json: { active } })
|
||||
.json<SamlApplicationSecretResponse>();
|
||||
|
|
|
@ -6,6 +6,10 @@ import {
|
|||
deleteSamlApplication,
|
||||
updateSamlApplication,
|
||||
getSamlApplication,
|
||||
deleteSamlApplicationSecret,
|
||||
createSamlApplicationSecret,
|
||||
updateSamlApplicationSecret,
|
||||
getSamlApplicationSecrets,
|
||||
} from '#src/api/saml-application.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { devFeatureTest } from '#src/utils.js';
|
||||
|
@ -103,4 +107,63 @@ describe('SAML application', () => {
|
|||
});
|
||||
await deleteApplication(application.id);
|
||||
});
|
||||
|
||||
it('should be able to create and delete SAML application secrets', async () => {
|
||||
const { id } = await createSamlApplication({
|
||||
name: 'test',
|
||||
description: 'test',
|
||||
});
|
||||
|
||||
const createdSecret = await createSamlApplicationSecret(id, 30);
|
||||
|
||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
||||
expect(createdSecret.privateKey).toBeUndefined();
|
||||
|
||||
const samlApplicationSecrets = await getSamlApplicationSecrets(id);
|
||||
expect(samlApplicationSecrets.length).toBe(2);
|
||||
expect(
|
||||
samlApplicationSecrets.find((secret) => secret.id === createdSecret.id)
|
||||
).not.toBeUndefined();
|
||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
||||
expect(samlApplicationSecrets.every((secret) => secret.privateKey === undefined)).toBe(true);
|
||||
|
||||
await deleteSamlApplicationSecret(id, createdSecret.id);
|
||||
|
||||
await deleteSamlApplication(id);
|
||||
});
|
||||
|
||||
it('should be able to update SAML application secret status and can not delete active secret', async () => {
|
||||
const { id } = await createSamlApplication({
|
||||
name: 'test',
|
||||
description: 'test',
|
||||
});
|
||||
|
||||
const createdSecret = await createSamlApplicationSecret(id, 30);
|
||||
|
||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
||||
expect(createdSecret.privateKey).toBeUndefined();
|
||||
|
||||
const samlApplicationSecrets = await getSamlApplicationSecrets(id);
|
||||
expect(samlApplicationSecrets.length).toBe(2);
|
||||
expect(
|
||||
samlApplicationSecrets.find((secret) => secret.id === createdSecret.id)
|
||||
).not.toBeUndefined();
|
||||
|
||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
||||
expect(samlApplicationSecrets.every((secret) => secret.privateKey === undefined)).toBe(true);
|
||||
|
||||
const updatedSecret = await updateSamlApplicationSecret(id, createdSecret.id, true);
|
||||
expect(updatedSecret.active).toBe(true);
|
||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
||||
expect(updatedSecret.privateKey).toBeUndefined();
|
||||
|
||||
await expectRejects(deleteSamlApplicationSecret(id, createdSecret.id), {
|
||||
code: 'application.saml.can_not_delete_active_secret',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
await updateSamlApplicationSecret(id, createdSecret.id, false);
|
||||
await deleteSamlApplicationSecret(id, createdSecret.id);
|
||||
await deleteSamlApplication(id);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -25,6 +25,7 @@ const application = {
|
|||
saml_application_only: 'The API is only available for SAML applications.',
|
||||
acs_url_binding_not_supported:
|
||||
'Only HTTP-POST binding is supported for receiving SAML assertions.',
|
||||
can_not_delete_active_secret: 'Can not delete the active secret.',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { type z } from 'zod';
|
|||
|
||||
import { Applications } from '../db-entries/application.js';
|
||||
import { SamlApplicationConfigs } from '../db-entries/saml-application-config.js';
|
||||
import { SamlApplicationSecrets } from '../db-entries/saml-application-secret.js';
|
||||
|
||||
import { applicationCreateGuard, applicationPatchGuard } from './application.js';
|
||||
|
||||
|
@ -37,6 +38,15 @@ export const samlApplicationPatchGuard = applicationPatchGuard
|
|||
|
||||
export type PatchSamlApplication = z.infer<typeof samlApplicationPatchGuard>;
|
||||
|
||||
// Make sure the `privateKey` is not exposed in the response.
|
||||
export const samlApplicationSecretResponseGuard = SamlApplicationSecrets.guard.omit({
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
privateKey: true,
|
||||
});
|
||||
|
||||
export type SamlApplicationSecretResponse = z.infer<typeof samlApplicationSecretResponseGuard>;
|
||||
|
||||
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