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 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -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}
|
||||||
|
|
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 {
|
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({
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>();
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in a new issue