mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -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 { koaManagementApiHooks } from '#src/middleware/koa-management-api-hooks.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 type TenantContext from '#src/tenants/TenantContext.js';
|
||||
|
||||
|
@ -120,6 +121,13 @@ const createRouters = (tenant: TenantContext) => {
|
|||
wellKnownRoutes(anonymousRouter, tenant);
|
||||
statusRoutes(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, {
|
||||
experienceRouters: [experienceRouter, interactionRouter],
|
||||
|
|
|
@ -3,20 +3,30 @@ import {
|
|||
type SamlApplicationResponse,
|
||||
type PatchSamlApplication,
|
||||
type SamlApplicationSecret,
|
||||
BindingType,
|
||||
} from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
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 type Queries from '#src/tenants/Queries.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) => {
|
||||
const {
|
||||
applications: { findApplicationById, updateApplicationById },
|
||||
samlApplicationSecrets: { insertSamlApplicationSecret },
|
||||
samlApplicationSecrets: {
|
||||
insertSamlApplicationSecret,
|
||||
findActiveSamlApplicationSecretByApplicationId,
|
||||
},
|
||||
samlApplicationConfigs: {
|
||||
findSamlApplicationConfigByApplicationId,
|
||||
updateSamlApplicationConfig,
|
||||
|
@ -25,8 +35,8 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
|
|||
|
||||
const createSamlApplicationSecret = async (
|
||||
applicationId: string,
|
||||
// Set certificate life span to 1 year by default.
|
||||
lifeSpanInDays = 365
|
||||
// Set certificate life span to 3 years by default.
|
||||
lifeSpanInDays = 365 * 3
|
||||
): Promise<SamlApplicationSecret> => {
|
||||
const { privateKey, certificate, notAfter } = await generateKeyPairAndCertificate(
|
||||
lifeSpanInDays
|
||||
|
@ -37,7 +47,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
|
|||
applicationId,
|
||||
privateKey,
|
||||
certificate,
|
||||
expiresAt: Math.floor(notAfter.getTime() / 1000),
|
||||
expiresAt: notAfter.getTime(),
|
||||
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 {
|
||||
createSamlApplicationSecret,
|
||||
findSamlApplicationById,
|
||||
updateSamlApplicationById,
|
||||
getSamlIdPMetadataByApplicationId,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { addDays } from 'date-fns';
|
||||
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', () => {
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
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 SamlAcsUrl,
|
||||
BindingType,
|
||||
type CertificateFingerprints,
|
||||
} from '@logto/schemas';
|
||||
import { appendPath } from '@silverhand/essentials';
|
||||
import { addDays } from 'date-fns';
|
||||
import forge from 'node-forge';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.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) => {
|
||||
const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 });
|
||||
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.
|
||||
* 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) => {
|
||||
const { binding } = acsUrl;
|
||||
assertThat(
|
||||
binding === BindingType.POST,
|
||||
binding === BindingType.Post,
|
||||
new RequestError({
|
||||
code: 'application.saml.acs_url_binding_not_supported',
|
||||
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) =>
|
||||
/**
|
||||
* @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:
|
||||
* 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.
|
||||
|
|
|
@ -3,6 +3,7 @@ import type { CommonQueryMethods } from '@silverhand/slonik';
|
|||
import { sql } from '@silverhand/slonik';
|
||||
|
||||
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 { convertToIdentifiers } from '#src/utils/sql.js';
|
||||
|
||||
|
@ -20,6 +21,22 @@ export const createSamlApplicationSecretsQueries = (pool: CommonQueryMethods) =>
|
|||
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) =>
|
||||
pool.one<SamlApplicationSecret>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
|
@ -68,6 +85,7 @@ export const createSamlApplicationSecretsQueries = (pool: CommonQueryMethods) =>
|
|||
return {
|
||||
insertSamlApplicationSecret,
|
||||
findSamlApplicationSecretsByApplicationId,
|
||||
findActiveSamlApplicationSecretByApplicationId,
|
||||
findSamlApplicationSecretByApplicationIdAndId,
|
||||
deleteSamlApplicationSecretById,
|
||||
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 {
|
||||
ApplicationType,
|
||||
certificateFingerprintsGuard,
|
||||
samlApplicationCreateGuard,
|
||||
samlApplicationPatchGuard,
|
||||
samlApplicationResponseGuard,
|
||||
|
@ -16,8 +17,13 @@ import { buildOidcClientMetadata } from '#src/oidc/utils.js';
|
|||
import { generateInternalSecret } from '#src/routes/applications/application-secret.js';
|
||||
import type { ManagementApiRouter, RouterInitArgs } from '#src/routes/types.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>(
|
||||
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||
|
@ -30,6 +36,7 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
|||
findSamlApplicationSecretsByApplicationId,
|
||||
findSamlApplicationSecretByApplicationIdAndId,
|
||||
updateSamlApplicationSecretStatusByApplicationIdAndSecretId,
|
||||
findActiveSamlApplicationSecretByApplicationId,
|
||||
},
|
||||
} = queries;
|
||||
const {
|
||||
|
@ -37,6 +44,7 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
|||
createSamlApplicationSecret,
|
||||
findSamlApplicationById,
|
||||
updateSamlApplicationById,
|
||||
getSamlIdPMetadataByApplicationId,
|
||||
},
|
||||
} = libraries;
|
||||
|
||||
|
@ -252,4 +260,70 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
|
|||
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,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { authedAdminApi } from './api.js';
|
||||
import api, { authedAdminApi } from './api.js';
|
||||
|
||||
export const createSamlApplication = async (createSamlApplication: CreateSamlApplication) =>
|
||||
authedAdminApi
|
||||
|
@ -43,3 +43,16 @@ export const updateSamlApplicationSecret = async (id: string, secretId: string,
|
|||
authedAdminApi
|
||||
.patch(`saml-applications/${id}/secrets/${secretId}`, { json: { active } })
|
||||
.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,
|
||||
updateSamlApplicationSecret,
|
||||
getSamlApplicationSecrets,
|
||||
getSamlApplicationMetadata,
|
||||
getSamlApplicationCertificate,
|
||||
} from '#src/api/saml-application.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { devFeatureTest } from '#src/utils.js';
|
||||
|
@ -33,7 +35,7 @@ describe('SAML application', () => {
|
|||
description: 'test',
|
||||
config: {
|
||||
acsUrl: {
|
||||
binding: BindingType.REDIRECT,
|
||||
binding: BindingType.Redirect,
|
||||
url: 'https://example.com',
|
||||
},
|
||||
},
|
||||
|
@ -49,7 +51,7 @@ describe('SAML application', () => {
|
|||
const config = {
|
||||
entityId: 'https://example.logto.io',
|
||||
acsUrl: {
|
||||
binding: BindingType.POST,
|
||||
binding: BindingType.Post,
|
||||
url: 'https://example.logto.io/sso/saml',
|
||||
},
|
||||
};
|
||||
|
@ -71,7 +73,7 @@ describe('SAML application', () => {
|
|||
|
||||
const newConfig = {
|
||||
acsUrl: {
|
||||
binding: BindingType.POST,
|
||||
binding: BindingType.Post,
|
||||
url: 'https://example.logto.io/sso/saml',
|
||||
},
|
||||
};
|
||||
|
@ -107,8 +109,10 @@ describe('SAML application', () => {
|
|||
});
|
||||
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({
|
||||
name: 'test',
|
||||
description: 'test',
|
||||
|
@ -116,23 +120,32 @@ describe('SAML application', () => {
|
|||
|
||||
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();
|
||||
|
||||
const samlApplicationSecrets = await getSamlApplicationSecrets(id);
|
||||
expect(samlApplicationSecrets.length).toBe(2);
|
||||
expect(
|
||||
samlApplicationSecrets.find((secret) => secret.id === createdSecret.id)
|
||||
).not.toBeUndefined();
|
||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
||||
expect(samlApplicationSecrets.every((secret) => secret.privateKey === undefined)).toBe(true);
|
||||
|
||||
await deleteSamlApplicationSecret(id, createdSecret.id);
|
||||
|
||||
await deleteSamlApplication(id);
|
||||
});
|
||||
|
||||
it('should be able to update SAML application secret status and can not delete active secret', async () => {
|
||||
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({
|
||||
name: 'test',
|
||||
description: 'test',
|
||||
|
@ -140,30 +153,64 @@ describe('SAML application', () => {
|
|||
|
||||
const createdSecret = await createSamlApplicationSecret(id, 30);
|
||||
|
||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
||||
expect(createdSecret.privateKey).toBeUndefined();
|
||||
|
||||
const samlApplicationSecrets = await getSamlApplicationSecrets(id);
|
||||
expect(samlApplicationSecrets.length).toBe(2);
|
||||
expect(
|
||||
samlApplicationSecrets.find((secret) => secret.id === createdSecret.id)
|
||||
).not.toBeUndefined();
|
||||
|
||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
||||
expect(samlApplicationSecrets.every((secret) => secret.privateKey === undefined)).toBe(true);
|
||||
const secrets = await getSamlApplicationSecrets(id);
|
||||
expect(secrets.length).toBe(2);
|
||||
expect(secrets.every(({ createdAt, expiresAt }) => createdAt < expiresAt)).toBe(true);
|
||||
|
||||
const updatedSecret = await updateSamlApplicationSecret(id, createdSecret.id, true);
|
||||
expect(updatedSecret.active).toBe(true);
|
||||
// @ts-expect-error - Make sure the `privateKey` is not exposed in the response.
|
||||
expect(updatedSecret.privateKey).toBeUndefined();
|
||||
|
||||
await expectRejects(deleteSamlApplicationSecret(id, createdSecret.id), {
|
||||
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',
|
||||
status: 400,
|
||||
});
|
||||
|
||||
await updateSamlApplicationSecret(id, createdSecret.id, false);
|
||||
await deleteSamlApplicationSecret(id, createdSecret.id);
|
||||
await updateSamlApplicationSecret(id, secret.id, false);
|
||||
|
||||
await deleteSamlApplicationSecret(id, secret.id);
|
||||
|
||||
await deleteSamlApplication(id);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -26,6 +26,9 @@ const application = {
|
|||
acs_url_binding_not_supported:
|
||||
'Only HTTP-POST binding is supported for receiving SAML assertions.',
|
||||
can_not_delete_active_secret: 'Can not delete the active secret.',
|
||||
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>;
|
||||
|
||||
export enum BindingType {
|
||||
POST = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||
REDIRECT = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
||||
Post = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||
Redirect = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
|
||||
}
|
||||
|
||||
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 { 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>;
|
||||
|
||||
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