0
Fork 0
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:
Darcy Ye 2024-12-02 23:58:52 -08:00 committed by GitHub
parent 542250339c
commit 14b4254d1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 427 additions and 49 deletions

View file

@ -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],

View file

@ -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,
}; };
}; };

View file

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

View file

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

View file

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

View file

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

View 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();
}
);
}

View file

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

View 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}`;
};

View file

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

View file

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

View file

@ -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',
}, },
}; };

View file

@ -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 = {

View file

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