Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): add POST /saml-applications and DEL /saml-applications/:id APIs #6822

Open
wants to merge 2 commits into
base: yemq-log-10113-add-saml-application-proxies-table
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"scripts": {
"precommit": "lint-staged",
"copy:apidocs": "rsync -a -m --include '*/' --include '*.openapi.json' --exclude '*' src/routes/ build/routes/",
"copy:apidocs": "rsync -a -m --include '*/' --include '*.openapi.json' --exclude '*' src/routes/ build/routes/ && rsync -a -m --include '*/' --include '*.openapi.json' --exclude '*' src/saml-applications/ build/saml-applications/",
"check": "tsc --noEmit",
"build": "tsup",
"build:test": "rm -rf build/ && tsc -p tsconfig.test.json --sourcemap && pnpm run copy:apidocs",
Expand Down Expand Up @@ -80,6 +80,7 @@
"ky": "^1.2.3",
"lru-cache": "^11.0.0",
"nanoid": "^5.0.1",
"node-forge": "^1.3.1",
"oidc-provider": "^8.4.6",
"openapi-types": "^12.1.3",
"otplib": "^12.0.1",
Expand Down Expand Up @@ -114,6 +115,7 @@
"@types/koa-send": "^4.1.3",
"@types/koa__cors": "^5.0.0",
"@types/node": "^20.9.5",
"@types/node-forge": "^1.3.1",
"@types/oidc-provider": "^8.4.4",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.2",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/routes/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import koaAuditLog from '#src/middleware/koa-audit-log.js';
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
import { koaManagementApiHooks } from '#src/middleware/koa-management-api-hooks.js';
import koaTenantGuard from '#src/middleware/koa-tenant-guard.js';
import samlApplicationRoutes from '#src/saml-applications/routes/index.js';
import type TenantContext from '#src/tenants/TenantContext.js';

import koaAuth from '../middleware/koa-auth/index.js';
Expand Down Expand Up @@ -99,6 +100,9 @@ const createRouters = (tenant: TenantContext) => {
systemRoutes(managementRouter, tenant);
subjectTokenRoutes(managementRouter, tenant);
accountCentersRoutes(managementRouter, tenant);
if (EnvSet.values.isDevFeaturesEnabled) {
samlApplicationRoutes(managementRouter, tenant);
}

const anonymousRouter: AnonymousRouter = new Router();

Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/routes/swagger/utils/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,17 @@ const managementApiIdentifiableEntityNames = Object.freeze([
'organization-role',
'organization-scope',
'organization-invitation',
'saml-application',
]);

/** Additional tags that cannot be inferred from the path. */
const additionalTags = Object.freeze(
condArray<string>('Organization applications', 'Custom UI assets', 'Organization users')
condArray<string>(
'Organization applications',
'Custom UI assets',
'Organization users',
EnvSet.values.isDevFeaturesEnabled && 'SAML applications'
)
);

export const buildManagementApiBaseDocument = (
Expand Down Expand Up @@ -207,7 +213,7 @@ export const buildUserApiBaseDocument = (
});

export const getSupplementDocuments = async (
directory = 'routes',
directory = 'build',
option?: FindSupplementFilesOptions
) => {
// Find supplemental documents
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/routes/swagger/utils/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from 'node:assert';
import fs from 'node:fs/promises';
import path from 'node:path';

import { isKeyInObject, isObject, type Optional } from '@silverhand/essentials';
import { conditional, isKeyInObject, isObject, type Optional } from '@silverhand/essentials';
import type Router from 'koa-router';
import { OpenAPIV3 } from 'openapi-types';
import { z } from 'zod';
Expand Down Expand Up @@ -35,6 +35,7 @@ const tagMap = new Map([
['sso-connectors', 'SSO connectors'],
['sso-connector-providers', 'SSO connector providers'],
['.well-known', 'Well-known'],
['saml-applications', 'SAML applications'],
]);

/**
Expand Down Expand Up @@ -80,7 +81,18 @@ export const findSupplementFiles = async (
}

if (stats.isDirectory()) {
result.push(...(await findSupplementFiles(path.join(directory, file))));
result.push(
...(await findSupplementFiles(
path.join(directory, file),
conditional(
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
(option?.includeDirectories?.includes(file) ||
!option?.excludeDirectories?.includes(file)) && {
excludeDirectories: option?.excludeDirectories,
}
)
))
);
} else if (file.endsWith('.openapi.json')) {
result.push(path.join(directory, file));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
const { pathMap, tags } = groupRoutesByPath(managementApiRoutes);

// Find supplemental documents
const supplementDocuments = await getSupplementDocuments('routes', {
const supplementDocuments = await getSupplementDocuments('build', {

Check warning on line 33 in packages/core/src/routes/well-known/well-known.openapi.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/well-known/well-known.openapi.ts#L33

Added line #L33 was not covered by tests
excludeDirectories: ['experience', 'interaction', 'account', 'verification'],
});
const baseDocument = buildManagementApiBaseDocument(pathMap, tags, ctx.request.origin);
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/saml-applications/libraries/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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 createNewSamlApplicationSecretForApplication = 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,
});

Check warning on line 28 in packages/core/src/saml-applications/libraries/secrets.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/secrets.ts#L13-L28

Added lines #L13 - L28 were not covered by tests
};

return {
createNewSamlApplicationSecretForApplication,
};
};
58 changes: 58 additions & 0 deletions packages/core/src/saml-applications/libraries/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import crypto from 'node:crypto';

import { addDays } from 'date-fns';
import forge from 'node-forge';

export const generateKeyPairAndCertificate = async (lifeSpanInDays = 365) => {
const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 });
return createCertificate(keypair, lifeSpanInDays);
};

Check warning on line 9 in packages/core/src/saml-applications/libraries/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/utils.ts#L7-L9

Added lines #L7 - L9 were not covered by tests

const createCertificate = (keypair: forge.pki.KeyPair, lifeSpanInDays: number) => {
const cert = forge.pki.createCertificate();
const notBefore = new Date();
const notAfter = addDays(notBefore, lifeSpanInDays);

// Can not initialize the certificate with the keypair directly, so we need to set the public key manually.
/* eslint-disable @silverhand/fp/no-mutation */
cert.publicKey = keypair.publicKey;
// Use cryptographically secure pseudorandom number generator (CSPRNG) to generate a random serial number (usually more than 8 bytes).
// `serialNumber` should be IDENTICAL across different certificates, better not to be incremental.
cert.serialNumber = crypto.randomBytes(16).toString('hex');
cert.validity.notBefore = notBefore;
cert.validity.notAfter = notAfter;
/* eslint-enable @silverhand/fp/no-mutation */

// TODO: read from tenant config or let user customize before downloading
const subjectAttributes: forge.pki.CertificateField[] = [
{
name: 'commonName',
value: 'example.com',
},
];

const issuerAttributes: forge.pki.CertificateField[] = [
{
name: 'commonName',
value: 'logto.io',
},
{
name: 'organizationName',
value: 'Logto',
},
{
name: 'countryName',
value: 'US',
},
];

cert.setSubject(subjectAttributes);
cert.setIssuer(issuerAttributes);
cert.sign(keypair.privateKey);

return {
privateKey: forge.pki.privateKeyToPem(keypair.privateKey),
certificate: forge.pki.certificateToPem(cert),
notAfter,
};
};

Check warning on line 58 in packages/core/src/saml-applications/libraries/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/utils.ts#L12-L58

Added lines #L12 - L58 were not covered by tests
30 changes: 30 additions & 0 deletions packages/core/src/saml-applications/queries/configs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type SamlApplicationConfig, SamlApplicationConfigs } from '@logto/schemas';
import type { CommonQueryMethods } from '@silverhand/slonik';
import { sql } from '@silverhand/slonik';

import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
import { convertToIdentifiers } from '#src/utils/sql.js';

const { table, fields } = convertToIdentifiers(SamlApplicationConfigs);

export const createSamlApplicationConfigQueries = (pool: CommonQueryMethods) => {
const insertSamlApplicationConfig = buildInsertIntoWithPool(pool)(SamlApplicationConfigs, {
returning: true,
});

const updateSamlApplicationConfig = buildUpdateWhereWithPool(pool)(SamlApplicationConfigs, true);

const findSamlApplicationConfigByApplicationId = async (applicationId: string) =>
pool.maybeOne<SamlApplicationConfig>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.applicationId}=${applicationId}

Check warning on line 22 in packages/core/src/saml-applications/queries/configs.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/queries/configs.ts#L19-L22

Added lines #L19 - L22 were not covered by tests
`);

return {
insertSamlApplicationConfig,
updateSamlApplicationConfig,
findSamlApplicationConfigByApplicationId,
};
};
39 changes: 39 additions & 0 deletions packages/core/src/saml-applications/queries/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { SamlApplicationSecrets, type SamlApplicationSecret } from '@logto/schemas';
import type { CommonQueryMethods } from '@silverhand/slonik';
import { sql } from '@silverhand/slonik';

import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { DeletionError } from '#src/errors/SlonikError/index.js';
import { convertToIdentifiers } from '#src/utils/sql.js';

const { table, fields } = convertToIdentifiers(SamlApplicationSecrets);

export const createSamlApplicationSecretsQueries = (pool: CommonQueryMethods) => {
const insertSamlApplicationSecret = buildInsertIntoWithPool(pool)(SamlApplicationSecrets, {
returning: true,
});

const findSamlApplicationSecretsByApplicationId = async (applicationId: string) =>
pool.any<SamlApplicationSecret>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.applicationId}=${applicationId}

Check warning on line 20 in packages/core/src/saml-applications/queries/secrets.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/queries/secrets.ts#L17-L20

Added lines #L17 - L20 were not covered by tests
`);

const deleteSamlApplicationSecretById = async (id: string) => {
const { rowCount } = await pool.query(sql`
delete from ${table}
where ${fields.id} = ${id}
`);

if (rowCount < 1) {
throw new DeletionError(SamlApplicationSecrets.table);
}

Check warning on line 31 in packages/core/src/saml-applications/queries/secrets.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/queries/secrets.ts#L24-L31

Added lines #L24 - L31 were not covered by tests
};

return {
insertSamlApplicationSecret,
findSamlApplicationSecretsByApplicationId,
deleteSamlApplicationSecretById,
};
};
95 changes: 95 additions & 0 deletions packages/core/src/saml-applications/routes/index.openapi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{
"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}": {
"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."
}
}
}
}
}
}
Loading
Loading