mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat: add SAML app anonymous metadata and certificate APIs (#6833)
This commit is contained in:
parent
542250339c
commit
14b4254d1e
14 changed files with 427 additions and 49 deletions
|
@ -7,6 +7,7 @@ import koaAuditLog from '#src/middleware/koa-audit-log.js';
|
||||||
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
|
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
|
||||||
import { koaManagementApiHooks } from '#src/middleware/koa-management-api-hooks.js';
|
import { koaManagementApiHooks } from '#src/middleware/koa-management-api-hooks.js';
|
||||||
import koaTenantGuard from '#src/middleware/koa-tenant-guard.js';
|
import koaTenantGuard from '#src/middleware/koa-tenant-guard.js';
|
||||||
|
import samlApplicationAnonymousRoutes from '#src/saml-applications/routes/anonymous.js';
|
||||||
import samlApplicationRoutes from '#src/saml-applications/routes/index.js';
|
import samlApplicationRoutes from '#src/saml-applications/routes/index.js';
|
||||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
|
||||||
|
@ -120,6 +121,13 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
wellKnownRoutes(anonymousRouter, tenant);
|
wellKnownRoutes(anonymousRouter, tenant);
|
||||||
statusRoutes(anonymousRouter, tenant);
|
statusRoutes(anonymousRouter, tenant);
|
||||||
authnRoutes(anonymousRouter, tenant);
|
authnRoutes(anonymousRouter, tenant);
|
||||||
|
// TODO: @darcy per our design, we will move related routes to Cloud repo and the routes will be loaded from remote.
|
||||||
|
if (
|
||||||
|
(EnvSet.values.isDevFeaturesEnabled && EnvSet.values.isCloud) ||
|
||||||
|
EnvSet.values.isIntegrationTest
|
||||||
|
) {
|
||||||
|
samlApplicationAnonymousRoutes(anonymousRouter, tenant);
|
||||||
|
}
|
||||||
|
|
||||||
wellKnownOpenApiRoutes(anonymousRouter, {
|
wellKnownOpenApiRoutes(anonymousRouter, {
|
||||||
experienceRouters: [experienceRouter, interactionRouter],
|
experienceRouters: [experienceRouter, interactionRouter],
|
||||||
|
|
|
@ -3,20 +3,30 @@ import {
|
||||||
type SamlApplicationResponse,
|
type SamlApplicationResponse,
|
||||||
type PatchSamlApplication,
|
type PatchSamlApplication,
|
||||||
type SamlApplicationSecret,
|
type SamlApplicationSecret,
|
||||||
|
BindingType,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { generateStandardId } from '@logto/shared';
|
import { generateStandardId } from '@logto/shared';
|
||||||
import { removeUndefinedKeys } from '@silverhand/essentials';
|
import { removeUndefinedKeys } from '@silverhand/essentials';
|
||||||
|
import * as saml from 'samlify';
|
||||||
|
|
||||||
|
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
import { ensembleSamlApplication, generateKeyPairAndCertificate } from './utils.js';
|
import {
|
||||||
|
ensembleSamlApplication,
|
||||||
|
generateKeyPairAndCertificate,
|
||||||
|
buildSingleSignOnUrl,
|
||||||
|
} from './utils.js';
|
||||||
|
|
||||||
export const createSamlApplicationsLibrary = (queries: Queries) => {
|
export const createSamlApplicationsLibrary = (queries: Queries) => {
|
||||||
const {
|
const {
|
||||||
applications: { findApplicationById, updateApplicationById },
|
applications: { findApplicationById, updateApplicationById },
|
||||||
samlApplicationSecrets: { insertSamlApplicationSecret },
|
samlApplicationSecrets: {
|
||||||
|
insertSamlApplicationSecret,
|
||||||
|
findActiveSamlApplicationSecretByApplicationId,
|
||||||
|
},
|
||||||
samlApplicationConfigs: {
|
samlApplicationConfigs: {
|
||||||
findSamlApplicationConfigByApplicationId,
|
findSamlApplicationConfigByApplicationId,
|
||||||
updateSamlApplicationConfig,
|
updateSamlApplicationConfig,
|
||||||
|
@ -25,8 +35,8 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
|
||||||
|
|
||||||
const createSamlApplicationSecret = async (
|
const createSamlApplicationSecret = async (
|
||||||
applicationId: string,
|
applicationId: string,
|
||||||
// Set certificate life span to 1 year by default.
|
// Set certificate life span to 3 years by default.
|
||||||
lifeSpanInDays = 365
|
lifeSpanInDays = 365 * 3
|
||||||
): Promise<SamlApplicationSecret> => {
|
): Promise<SamlApplicationSecret> => {
|
||||||
const { privateKey, certificate, notAfter } = await generateKeyPairAndCertificate(
|
const { privateKey, certificate, notAfter } = await generateKeyPairAndCertificate(
|
||||||
lifeSpanInDays
|
lifeSpanInDays
|
||||||
|
@ -37,7 +47,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
|
||||||
applicationId,
|
applicationId,
|
||||||
privateKey,
|
privateKey,
|
||||||
certificate,
|
certificate,
|
||||||
expiresAt: Math.floor(notAfter.getTime() / 1000),
|
expiresAt: notAfter.getTime(),
|
||||||
active: false,
|
active: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -91,9 +101,37 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSamlIdPMetadataByApplicationId = async (id: string): Promise<{ metadata: string }> => {
|
||||||
|
const [{ tenantId, entityId }, { certificate }] = await Promise.all([
|
||||||
|
findSamlApplicationConfigByApplicationId(id),
|
||||||
|
findActiveSamlApplicationSecretByApplicationId(id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertThat(entityId, 'application.saml.entity_id_required');
|
||||||
|
|
||||||
|
const tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values);
|
||||||
|
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
const idp = saml.IdentityProvider({
|
||||||
|
entityID: entityId,
|
||||||
|
signingCert: certificate,
|
||||||
|
singleSignOnService: [
|
||||||
|
{
|
||||||
|
Location: buildSingleSignOnUrl(tenantEndpoint, id),
|
||||||
|
Binding: BindingType.Redirect,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata: idp.getMetadata(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createSamlApplicationSecret,
|
createSamlApplicationSecret,
|
||||||
findSamlApplicationById,
|
findSamlApplicationById,
|
||||||
updateSamlApplicationById,
|
updateSamlApplicationById,
|
||||||
|
getSamlIdPMetadataByApplicationId,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import forge from 'node-forge';
|
import forge from 'node-forge';
|
||||||
|
|
||||||
import { generateKeyPairAndCertificate } from './utils.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
|
||||||
|
import { generateKeyPairAndCertificate, calculateCertificateFingerprints } from './utils.js';
|
||||||
|
|
||||||
describe('generateKeyPairAndCertificate', () => {
|
describe('generateKeyPairAndCertificate', () => {
|
||||||
it('should generate valid key pair and certificate', async () => {
|
it('should generate valid key pair and certificate', async () => {
|
||||||
|
@ -57,3 +59,69 @@ describe('generateKeyPairAndCertificate', () => {
|
||||||
expect(forge.pki.privateKeyToPem(privateKey).length).toBeGreaterThan(3000); // A 4096-bit RSA private key in PEM format is typically longer than 3000 characters
|
expect(forge.pki.privateKeyToPem(privateKey).length).toBeGreaterThan(3000); // A 4096-bit RSA private key in PEM format is typically longer than 3000 characters
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('calculateCertificateFingerprints', () => {
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-let
|
||||||
|
let validCertificate: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Generate a valid certificate for testing
|
||||||
|
const { certificate } = await generateKeyPairAndCertificate();
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
validCertificate = certificate;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct fingerprints for valid certificate', () => {
|
||||||
|
const fingerprints = calculateCertificateFingerprints(validCertificate);
|
||||||
|
|
||||||
|
// Verify fingerprint format
|
||||||
|
expect(fingerprints.sha256.formatted).toMatch(/^([\dA-F]{2}:){31}[\dA-F]{2}$/);
|
||||||
|
expect(fingerprints.sha256.unformatted).toMatch(/^[\dA-F]{64}$/);
|
||||||
|
|
||||||
|
// Verify formatted and unformatted consistency
|
||||||
|
expect(fingerprints.sha256.unformatted).toBe(fingerprints.sha256.formatted.replaceAll(':', ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid PEM format', () => {
|
||||||
|
const invalidCertificates = [
|
||||||
|
'not a certificate',
|
||||||
|
'-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n',
|
||||||
|
// Missing begin/end markers
|
||||||
|
'MIIFWjCCA0KgAwIBAgIUMDAwMDAwMDAwMDAwMDAwMDAwMDAwDQYJKoZIhvcNAQEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cert of invalidCertificates) {
|
||||||
|
expect(() => calculateCertificateFingerprints(cert)).toThrow(
|
||||||
|
new RequestError('application.saml.invalid_certificate_pem_format')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid base64 content', () => {
|
||||||
|
const invalidBase64Certificate =
|
||||||
|
'-----BEGIN CERTIFICATE-----\n' +
|
||||||
|
'This is not base64!@#$%^&*()\n' +
|
||||||
|
'-----END CERTIFICATE-----\n';
|
||||||
|
|
||||||
|
expect(() => calculateCertificateFingerprints(invalidBase64Certificate)).toThrow(
|
||||||
|
new RequestError('application.saml.invalid_certificate_pem_format')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle certificates with different line endings', () => {
|
||||||
|
// Replace \n with \r\n in valid certificate
|
||||||
|
const crlfCertificate = validCertificate.replaceAll('\n', '\r\n');
|
||||||
|
|
||||||
|
const originalFingerprints = calculateCertificateFingerprints(validCertificate);
|
||||||
|
const crlfFingerprints = calculateCertificateFingerprints(crlfCertificate);
|
||||||
|
|
||||||
|
expect(crlfFingerprints).toEqual(originalFingerprints);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate consistent fingerprints for the same certificate', () => {
|
||||||
|
const firstResult = calculateCertificateFingerprints(validCertificate);
|
||||||
|
const secondResult = calculateCertificateFingerprints(validCertificate);
|
||||||
|
|
||||||
|
expect(firstResult).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -6,13 +6,25 @@ import {
|
||||||
type SamlApplicationConfig,
|
type SamlApplicationConfig,
|
||||||
type SamlAcsUrl,
|
type SamlAcsUrl,
|
||||||
BindingType,
|
BindingType,
|
||||||
|
type CertificateFingerprints,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
import { appendPath } from '@silverhand/essentials';
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import forge from 'node-forge';
|
import forge from 'node-forge';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
|
// Add PEM certificate validation
|
||||||
|
const pemCertificateGuard = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^-{5}BEGIN CERTIFICATE-{5}[\n\r]+[\S\s]*?[\n\r]+-{5}END CERTIFICATE-{5}$/);
|
||||||
|
|
||||||
|
// Add base64 validation schema
|
||||||
|
const base64Guard = z.string().regex(/^[\d+/A-Za-z]*={0,2}$/);
|
||||||
|
|
||||||
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);
|
||||||
|
@ -67,6 +79,44 @@ const createCertificate = (keypair: forge.pki.KeyPair, lifeSpanInDays: number) =
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const calculateCertificateFingerprints = (
|
||||||
|
pemCertificate: string
|
||||||
|
): CertificateFingerprints => {
|
||||||
|
try {
|
||||||
|
// Validate PEM certificate format
|
||||||
|
pemCertificateGuard.parse(pemCertificate);
|
||||||
|
|
||||||
|
// Remove PEM headers, newlines and spaces
|
||||||
|
const cleanedPem = pemCertificate
|
||||||
|
.replace('-----BEGIN CERTIFICATE-----', '')
|
||||||
|
.replace('-----END CERTIFICATE-----', '')
|
||||||
|
.replaceAll(/\s/g, '');
|
||||||
|
|
||||||
|
// Validate base64 format using zod
|
||||||
|
base64Guard.parse(cleanedPem);
|
||||||
|
|
||||||
|
// Convert base64 to binary
|
||||||
|
const certDer = Buffer.from(cleanedPem, 'base64');
|
||||||
|
|
||||||
|
// Calculate SHA-256 fingerprint
|
||||||
|
const sha256Unformatted = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(certDer)
|
||||||
|
.digest('hex')
|
||||||
|
.toUpperCase();
|
||||||
|
const sha256Formatted = sha256Unformatted.match(/.{2}/g)?.join(':') ?? '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
sha256: {
|
||||||
|
formatted: sha256Formatted,
|
||||||
|
unformatted: sha256Unformatted,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new RequestError('application.saml.invalid_certificate_pem_format');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* According to the design, a SAML app will be associated with multiple records from various tables.
|
* 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:
|
* 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:
|
||||||
|
@ -92,10 +142,13 @@ export const ensembleSamlApplication = ({
|
||||||
export const validateAcsUrl = (acsUrl: SamlAcsUrl) => {
|
export const validateAcsUrl = (acsUrl: SamlAcsUrl) => {
|
||||||
const { binding } = acsUrl;
|
const { binding } = acsUrl;
|
||||||
assertThat(
|
assertThat(
|
||||||
binding === BindingType.POST,
|
binding === BindingType.Post,
|
||||||
new RequestError({
|
new RequestError({
|
||||||
code: 'application.saml.acs_url_binding_not_supported',
|
code: 'application.saml.acs_url_binding_not_supported',
|
||||||
status: 422,
|
status: 422,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const buildSingleSignOnUrl = (baseUrl: URL, samlApplicationId: string) =>
|
||||||
|
appendPath(baseUrl, `api/saml/${samlApplicationId}/authn`).toString();
|
||||||
|
|
|
@ -18,11 +18,6 @@ export const createSamlApplicationConfigQueries = (pool: CommonQueryMethods) =>
|
||||||
const findSamlApplicationConfigByApplicationId = async (applicationId: string) =>
|
const findSamlApplicationConfigByApplicationId = async (applicationId: string) =>
|
||||||
/**
|
/**
|
||||||
* @remarks
|
* @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:
|
* 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).
|
* 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.
|
* 2. When the corresponding config for the SAML app does not exist, the GET method needs to handle the null SAML config additionally.
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { CommonQueryMethods } from '@silverhand/slonik';
|
||||||
import { sql } from '@silverhand/slonik';
|
import { sql } from '@silverhand/slonik';
|
||||||
|
|
||||||
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
import { DeletionError } from '#src/errors/SlonikError/index.js';
|
||||||
import { convertToIdentifiers } from '#src/utils/sql.js';
|
import { convertToIdentifiers } from '#src/utils/sql.js';
|
||||||
|
|
||||||
|
@ -20,6 +21,22 @@ export const createSamlApplicationSecretsQueries = (pool: CommonQueryMethods) =>
|
||||||
where ${fields.applicationId}=${applicationId}
|
where ${fields.applicationId}=${applicationId}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const findActiveSamlApplicationSecretByApplicationId = async (
|
||||||
|
applicationId: string
|
||||||
|
): Promise<SamlApplicationSecret> => {
|
||||||
|
const activeSecret = await pool.maybeOne<SamlApplicationSecret>(sql`
|
||||||
|
select ${sql.join(Object.values(fields), sql`, `)}
|
||||||
|
from ${table}
|
||||||
|
where ${fields.applicationId}=${applicationId} and ${fields.active}=true
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!activeSecret) {
|
||||||
|
throw new RequestError({ code: 'application.saml.no_active_secret', status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeSecret;
|
||||||
|
};
|
||||||
|
|
||||||
const findSamlApplicationSecretByApplicationIdAndId = async (applicationId: string, id: string) =>
|
const findSamlApplicationSecretByApplicationIdAndId = async (applicationId: string, id: string) =>
|
||||||
pool.one<SamlApplicationSecret>(sql`
|
pool.one<SamlApplicationSecret>(sql`
|
||||||
select ${sql.join(Object.values(fields), sql`, `)}
|
select ${sql.join(Object.values(fields), sql`, `)}
|
||||||
|
@ -68,6 +85,7 @@ export const createSamlApplicationSecretsQueries = (pool: CommonQueryMethods) =>
|
||||||
return {
|
return {
|
||||||
insertSamlApplicationSecret,
|
insertSamlApplicationSecret,
|
||||||
findSamlApplicationSecretsByApplicationId,
|
findSamlApplicationSecretsByApplicationId,
|
||||||
|
findActiveSamlApplicationSecretByApplicationId,
|
||||||
findSamlApplicationSecretByApplicationIdAndId,
|
findSamlApplicationSecretByApplicationIdAndId,
|
||||||
deleteSamlApplicationSecretById,
|
deleteSamlApplicationSecretById,
|
||||||
updateSamlApplicationSecretStatusByApplicationIdAndSecretId,
|
updateSamlApplicationSecretStatusByApplicationIdAndSecretId,
|
||||||
|
|
32
packages/core/src/saml-applications/routes/anonymous.ts
Normal file
32
packages/core/src/saml-applications/routes/anonymous.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
import type { AnonymousRouter, RouterInitArgs } from '#src/routes/types.js';
|
||||||
|
|
||||||
|
export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter>(
|
||||||
|
...[router, { libraries }]: RouterInitArgs<T>
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
samlApplications: { getSamlIdPMetadataByApplicationId },
|
||||||
|
} = libraries;
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/saml-applications/:id/metadata',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string() }),
|
||||||
|
status: [200, 400, 404],
|
||||||
|
response: z.string(),
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id } = ctx.guard.params;
|
||||||
|
|
||||||
|
const { metadata } = await getSamlIdPMetadataByApplicationId(id);
|
||||||
|
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = metadata;
|
||||||
|
ctx.type = 'text/xml;charset=utf-8';
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
|
certificateFingerprintsGuard,
|
||||||
samlApplicationCreateGuard,
|
samlApplicationCreateGuard,
|
||||||
samlApplicationPatchGuard,
|
samlApplicationPatchGuard,
|
||||||
samlApplicationResponseGuard,
|
samlApplicationResponseGuard,
|
||||||
|
@ -16,8 +17,13 @@ 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 assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
import { createContentDisposition } from '#src/utils/content-disposition.js';
|
||||||
|
|
||||||
import { ensembleSamlApplication, validateAcsUrl } from '../libraries/utils.js';
|
import {
|
||||||
|
calculateCertificateFingerprints,
|
||||||
|
ensembleSamlApplication,
|
||||||
|
validateAcsUrl,
|
||||||
|
} from '../libraries/utils.js';
|
||||||
|
|
||||||
export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
||||||
...[router, { queries, libraries }]: RouterInitArgs<T>
|
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||||
|
@ -30,6 +36,7 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
||||||
findSamlApplicationSecretsByApplicationId,
|
findSamlApplicationSecretsByApplicationId,
|
||||||
findSamlApplicationSecretByApplicationIdAndId,
|
findSamlApplicationSecretByApplicationIdAndId,
|
||||||
updateSamlApplicationSecretStatusByApplicationIdAndSecretId,
|
updateSamlApplicationSecretStatusByApplicationIdAndSecretId,
|
||||||
|
findActiveSamlApplicationSecretByApplicationId,
|
||||||
},
|
},
|
||||||
} = queries;
|
} = queries;
|
||||||
const {
|
const {
|
||||||
|
@ -37,6 +44,7 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
||||||
createSamlApplicationSecret,
|
createSamlApplicationSecret,
|
||||||
findSamlApplicationById,
|
findSamlApplicationById,
|
||||||
updateSamlApplicationById,
|
updateSamlApplicationById,
|
||||||
|
getSamlIdPMetadataByApplicationId,
|
||||||
},
|
},
|
||||||
} = libraries;
|
} = libraries;
|
||||||
|
|
||||||
|
@ -252,4 +260,70 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/saml-applications/:id/certificate',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string() }),
|
||||||
|
status: [200, 400, 404],
|
||||||
|
response: z.object({
|
||||||
|
certificate: z.string(),
|
||||||
|
fingerprints: certificateFingerprintsGuard,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id } = ctx.guard.params;
|
||||||
|
|
||||||
|
const { certificate } = await findActiveSamlApplicationSecretByApplicationId(id);
|
||||||
|
|
||||||
|
const fingerprints = calculateCertificateFingerprints(certificate);
|
||||||
|
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = { certificate, fingerprints };
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/saml-applications/:id/certificate.pem',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string() }),
|
||||||
|
status: [200, 400, 404],
|
||||||
|
response: z.string(),
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id } = ctx.guard.params;
|
||||||
|
|
||||||
|
const { certificate } = await findActiveSamlApplicationSecretByApplicationId(id);
|
||||||
|
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = certificate;
|
||||||
|
ctx.type = 'application/x-pem-file';
|
||||||
|
ctx.set('Content-Disposition', createContentDisposition(`certificate.pem`));
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/saml-applications/:id/metadata.xml',
|
||||||
|
koaGuard({
|
||||||
|
params: z.object({ id: z.string() }),
|
||||||
|
status: [200, 400, 404],
|
||||||
|
response: z.string(),
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id } = ctx.guard.params;
|
||||||
|
|
||||||
|
const { metadata } = await getSamlIdPMetadataByApplicationId(id);
|
||||||
|
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.body = metadata;
|
||||||
|
ctx.type = 'text/xml;charset=utf-8';
|
||||||
|
ctx.set('Content-Disposition', createContentDisposition(`metadata.xml`));
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
10
packages/core/src/utils/content-disposition.ts
Normal file
10
packages/core/src/utils/content-disposition.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* Generate Content-Disposition header value for file download
|
||||||
|
* @param filename The name of the file to be downloaded
|
||||||
|
* @returns Content-Disposition header value
|
||||||
|
*/
|
||||||
|
export const createContentDisposition = (filename: string) => {
|
||||||
|
// RFC 6266 requires the filename to be quoted and UTF-8 encoded
|
||||||
|
const encodedFilename = encodeURIComponent(filename);
|
||||||
|
return `attachment; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`;
|
||||||
|
};
|
|
@ -5,7 +5,7 @@ import {
|
||||||
type SamlApplicationSecretResponse,
|
type SamlApplicationSecretResponse,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
|
|
||||||
import { authedAdminApi } from './api.js';
|
import api, { authedAdminApi } from './api.js';
|
||||||
|
|
||||||
export const createSamlApplication = async (createSamlApplication: CreateSamlApplication) =>
|
export const createSamlApplication = async (createSamlApplication: CreateSamlApplication) =>
|
||||||
authedAdminApi
|
authedAdminApi
|
||||||
|
@ -43,3 +43,16 @@ export const updateSamlApplicationSecret = async (id: string, secretId: string,
|
||||||
authedAdminApi
|
authedAdminApi
|
||||||
.patch(`saml-applications/${id}/secrets/${secretId}`, { json: { active } })
|
.patch(`saml-applications/${id}/secrets/${secretId}`, { json: { active } })
|
||||||
.json<SamlApplicationSecretResponse>();
|
.json<SamlApplicationSecretResponse>();
|
||||||
|
|
||||||
|
export const getSamlApplicationCertificate = async (id: string) =>
|
||||||
|
authedAdminApi.get(`saml-applications/${id}/certificate`).json<{ certificate: string }>();
|
||||||
|
|
||||||
|
// Anonymous endpoints
|
||||||
|
export const getSamlApplicationMetadata = async (id: string) =>
|
||||||
|
api
|
||||||
|
.get(`saml-applications/${id}/metadata`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/xml',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.text();
|
||||||
|
|
|
@ -10,6 +10,8 @@ import {
|
||||||
createSamlApplicationSecret,
|
createSamlApplicationSecret,
|
||||||
updateSamlApplicationSecret,
|
updateSamlApplicationSecret,
|
||||||
getSamlApplicationSecrets,
|
getSamlApplicationSecrets,
|
||||||
|
getSamlApplicationMetadata,
|
||||||
|
getSamlApplicationCertificate,
|
||||||
} from '#src/api/saml-application.js';
|
} 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';
|
||||||
|
@ -33,7 +35,7 @@ describe('SAML application', () => {
|
||||||
description: 'test',
|
description: 'test',
|
||||||
config: {
|
config: {
|
||||||
acsUrl: {
|
acsUrl: {
|
||||||
binding: BindingType.REDIRECT,
|
binding: BindingType.Redirect,
|
||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -49,7 +51,7 @@ describe('SAML application', () => {
|
||||||
const config = {
|
const config = {
|
||||||
entityId: 'https://example.logto.io',
|
entityId: 'https://example.logto.io',
|
||||||
acsUrl: {
|
acsUrl: {
|
||||||
binding: BindingType.POST,
|
binding: BindingType.Post,
|
||||||
url: 'https://example.logto.io/sso/saml',
|
url: 'https://example.logto.io/sso/saml',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -71,7 +73,7 @@ describe('SAML application', () => {
|
||||||
|
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
acsUrl: {
|
acsUrl: {
|
||||||
binding: BindingType.POST,
|
binding: BindingType.Post,
|
||||||
url: 'https://example.logto.io/sso/saml',
|
url: 'https://example.logto.io/sso/saml',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -107,8 +109,10 @@ describe('SAML application', () => {
|
||||||
});
|
});
|
||||||
await deleteApplication(application.id);
|
await deleteApplication(application.id);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should be able to create and delete SAML application secrets', async () => {
|
describe('SAML application secrets/certificate/metadata', () => {
|
||||||
|
it('should create a secret and verify privateKey is not exposed', async () => {
|
||||||
const { id } = await createSamlApplication({
|
const { id } = await createSamlApplication({
|
||||||
name: 'test',
|
name: 'test',
|
||||||
description: 'test',
|
description: 'test',
|
||||||
|
@ -116,23 +120,32 @@ describe('SAML application', () => {
|
||||||
|
|
||||||
const createdSecret = await createSamlApplicationSecret(id, 30);
|
const createdSecret = await createSamlApplicationSecret(id, 30);
|
||||||
|
|
||||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
// @ts-expect-error - Make sure the `privateKey` is not exposed
|
||||||
expect(createdSecret.privateKey).toBeUndefined();
|
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);
|
await deleteSamlApplication(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to update SAML application secret status and can not delete active secret', async () => {
|
it('should return 404 when getting certificate/metadata without active secret', async () => {
|
||||||
|
const { id } = await createSamlApplication({
|
||||||
|
name: 'test',
|
||||||
|
description: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(getSamlApplicationCertificate(id), {
|
||||||
|
status: 404,
|
||||||
|
code: 'application.saml.no_active_secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(getSamlApplicationMetadata(id), {
|
||||||
|
status: 404,
|
||||||
|
code: 'application.saml.no_active_secret',
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteSamlApplication(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should manage secret activation status correctly', async () => {
|
||||||
const { id } = await createSamlApplication({
|
const { id } = await createSamlApplication({
|
||||||
name: 'test',
|
name: 'test',
|
||||||
description: 'test',
|
description: 'test',
|
||||||
|
@ -140,30 +153,64 @@ describe('SAML application', () => {
|
||||||
|
|
||||||
const createdSecret = await createSamlApplicationSecret(id, 30);
|
const createdSecret = await createSamlApplicationSecret(id, 30);
|
||||||
|
|
||||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
const secrets = await getSamlApplicationSecrets(id);
|
||||||
expect(createdSecret.privateKey).toBeUndefined();
|
expect(secrets.length).toBe(2);
|
||||||
|
expect(secrets.every(({ createdAt, expiresAt }) => createdAt < expiresAt)).toBe(true);
|
||||||
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);
|
const updatedSecret = await updateSamlApplicationSecret(id, createdSecret.id, true);
|
||||||
expect(updatedSecret.active).toBe(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), {
|
const { certificate } = await getSamlApplicationCertificate(id);
|
||||||
|
expect(typeof certificate).toBe('string');
|
||||||
|
|
||||||
|
await deleteSamlApplication(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle metadata requirements correctly', async () => {
|
||||||
|
const { id } = await createSamlApplication({
|
||||||
|
name: 'test',
|
||||||
|
description: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const secret = await createSamlApplicationSecret(id, 30);
|
||||||
|
await updateSamlApplicationSecret(id, secret.id, true);
|
||||||
|
|
||||||
|
await expect(getSamlApplicationCertificate(id)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
await expectRejects(getSamlApplicationMetadata(id), {
|
||||||
|
status: 400,
|
||||||
|
code: 'application.saml.entity_id_required',
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateSamlApplication(id, {
|
||||||
|
config: {
|
||||||
|
entityId: 'https://example.logto.io',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(getSamlApplicationMetadata(id)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
await deleteSamlApplication(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent deletion of active secrets', async () => {
|
||||||
|
const { id } = await createSamlApplication({
|
||||||
|
name: 'test',
|
||||||
|
description: 'test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const secret = await createSamlApplicationSecret(id, 30);
|
||||||
|
await updateSamlApplicationSecret(id, secret.id, true);
|
||||||
|
|
||||||
|
await expectRejects(deleteSamlApplicationSecret(id, secret.id), {
|
||||||
code: 'application.saml.can_not_delete_active_secret',
|
code: 'application.saml.can_not_delete_active_secret',
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateSamlApplicationSecret(id, createdSecret.id, false);
|
await updateSamlApplicationSecret(id, secret.id, false);
|
||||||
await deleteSamlApplicationSecret(id, createdSecret.id);
|
|
||||||
|
await deleteSamlApplicationSecret(id, secret.id);
|
||||||
|
|
||||||
await deleteSamlApplication(id);
|
await deleteSamlApplication(id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -26,6 +26,9 @@ const application = {
|
||||||
acs_url_binding_not_supported:
|
acs_url_binding_not_supported:
|
||||||
'Only HTTP-POST binding is supported for receiving SAML assertions.',
|
'Only HTTP-POST binding is supported for receiving SAML assertions.',
|
||||||
can_not_delete_active_secret: 'Can not delete the active secret.',
|
can_not_delete_active_secret: 'Can not delete the active secret.',
|
||||||
|
no_active_secret: 'No active secret found.',
|
||||||
|
entity_id_required: 'Entity ID is required to generate metadata.',
|
||||||
|
invalid_certificate_pem_format: 'Invalid PEM certificate format',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,8 @@ export const samlAttributeMappingGuard = z.record(
|
||||||
) satisfies z.ZodType<SamlAttributeMapping>;
|
) satisfies z.ZodType<SamlAttributeMapping>;
|
||||||
|
|
||||||
export enum BindingType {
|
export enum BindingType {
|
||||||
POST = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
Post = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||||
REDIRECT = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
Redirect = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SamlAcsUrl = {
|
export type SamlAcsUrl = {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { type z } from 'zod';
|
import { type ToZodObject } from '@logto/connector-kit';
|
||||||
|
import { 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';
|
||||||
|
@ -54,3 +55,21 @@ export const samlApplicationResponseGuard = Applications.guard.merge(
|
||||||
);
|
);
|
||||||
|
|
||||||
export type SamlApplicationResponse = z.infer<typeof samlApplicationResponseGuard>;
|
export type SamlApplicationResponse = z.infer<typeof samlApplicationResponseGuard>;
|
||||||
|
|
||||||
|
type FingerprintFormat = {
|
||||||
|
formatted: string;
|
||||||
|
unformatted: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fingerprintFormatGuard = z.object({
|
||||||
|
formatted: z.string(),
|
||||||
|
unformatted: z.string(),
|
||||||
|
}) satisfies ToZodObject<FingerprintFormat>;
|
||||||
|
|
||||||
|
export type CertificateFingerprints = {
|
||||||
|
sha256: FingerprintFormat;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const certificateFingerprintsGuard = z.object({
|
||||||
|
sha256: fingerprintFormatGuard,
|
||||||
|
}) satisfies ToZodObject<CertificateFingerprints>;
|
||||||
|
|
Loading…
Reference in a new issue