0
Fork 0
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:
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 { 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],

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

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