0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core,schemas): add saml cert

add saml key paires to logto config and create cert download endpoint
This commit is contained in:
simeng-li 2023-09-22 17:09:05 +08:00
parent d254dae5ff
commit e1538b12e7
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
15 changed files with 227 additions and 11 deletions

View file

@ -35,12 +35,12 @@
"@logto/console": "workspace:*",
"@logto/core-kit": "workspace:^2.1.1",
"@logto/demo-app": "workspace:*",
"@logto/experience": "workspace:*",
"@logto/language-kit": "workspace:^1.0.0",
"@logto/phrases": "workspace:^1.5.0",
"@logto/phrases-experience": "workspace:^1.3.1",
"@logto/schemas": "workspace:^1.9.1",
"@logto/shared": "workspace:^2.0.1",
"@logto/experience": "workspace:*",
"@silverhand/essentials": "^2.8.4",
"@withtyped/client": "^0.7.22",
"chalk": "^5.0.0",
@ -68,6 +68,7 @@
"koa-send": "^5.0.1",
"lru-cache": "^10.0.0",
"nanoid": "^4.0.0",
"node-forge": "^1.3.1",
"oidc-provider": "^8.2.2",
"p-retry": "^6.0.0",
"pg-protocol": "^1.6.0",
@ -96,6 +97,7 @@
"@types/koa-send": "^4.1.3",
"@types/koa__cors": "^4.0.0",
"@types/node": "^18.11.18",
"@types/node-forge": "^1.3.5",
"@types/oidc-provider": "^8.0.0",
"@types/semver": "^7.3.12",
"@types/sinon": "^10.0.13",

View file

@ -16,6 +16,7 @@ export enum UserApps {
Api = 'api',
Oidc = 'oidc',
DemoApp = 'demo-app',
Saml = 'saml',
}
/** Apps (also paths) ONLY for the admin tenant. */

View file

@ -34,6 +34,7 @@ const logtoConfigs: LogtoConfigLibrary = {
resource: 'resource',
}),
getOidcConfigs: jest.fn(),
getSamlSigningKeyPair: jest.fn(),
};
describe('getAccessToken()', () => {

View file

@ -1,20 +1,28 @@
import type { LogtoOidcConfigType } from '@logto/schemas';
import type { LogtoOidcConfigType, LogtoSamlSigningKeyPair } from '@logto/schemas';
import {
cloudApiIndicator,
cloudConnectionDataGuard,
logtoOidcConfigGuard,
logtoSamlSigningKeyPairGuard,
LogtoOidcConfigKey,
} from '@logto/schemas';
import chalk from 'chalk';
import { z, ZodError } from 'zod';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import { consoleLog } from '#src/utils/console.js';
import { generateSamlKeyPair } from '#src/utils/saml.js';
export type LogtoConfigLibrary = ReturnType<typeof createLogtoConfigLibrary>;
export const createLogtoConfigLibrary = ({
logtoConfigs: { getRowsByKeys, getCloudConnectionData: queryCloudConnectionData },
logtoConfigs: {
getRowsByKeys,
getCloudConnectionData: queryCloudConnectionData,
getSamlSigningKeyPair: querySamlSigningKeyPair,
insertSamlSigningKeyPair,
},
}: Pick<Queries, 'logtoConfigs'>) => {
const getOidcConfigs = async (): Promise<LogtoOidcConfigType> => {
try {
@ -59,5 +67,23 @@ export const createLogtoConfigLibrary = ({
};
};
return { getOidcConfigs, getCloudConnectionData };
/* We will generate a pair of RSA keys for SAML for each tenant up on SAML IdP creation request. */
const getSamlSigningKeyPair = async (): Promise<LogtoSamlSigningKeyPair> => {
const signingKeyPair = await querySamlSigningKeyPair();
if (signingKeyPair) {
return signingKeyPair;
}
// Generate one if not exists
const keyPair = generateSamlKeyPair();
const { value } = await insertSamlSigningKeyPair(keyPair);
const result = logtoSamlSigningKeyPairGuard.safeParse(value);
assertThat(result.success, new Error('Failed to generate SAML signing key pair'));
return result.data;
};
return { getOidcConfigs, getCloudConnectionData, getSamlSigningKeyPair };
};

View file

@ -19,6 +19,7 @@ const logtoConfigs: LogtoConfigLibrary = {
resource: 'resource',
}),
getOidcConfigs: jest.fn(),
getSamlSigningKeyPair: jest.fn(),
};
describe('koaTenantGuard middleware', () => {

View file

@ -47,7 +47,6 @@ export default function initOidc(
const {
resources: { findResourceByIndicator, findDefaultResource },
users: { findUserById },
dailyActiveUsers: { insertActiveUser },
} = queries;
const { findUserScopesForResourceIndicator } = libraries.users;
const { findApplicationScopesForResourceIndicator } = libraries.applications;

View file

@ -1,9 +1,16 @@
import type { AdminConsoleData, LogtoConfig, LogtoConfigKey } from '@logto/schemas';
import { LogtoTenantConfigKey, LogtoConfigs } from '@logto/schemas';
import type {
AdminConsoleData,
LogtoConfig,
LogtoConfigKey,
LogtoSamlSigningKeyPair,
} from '@logto/schemas';
import { LogtoTenantConfigKey, LogtoConfigs, LogtoSamlConfigKey } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
const { table, fields } = convertToIdentifiers(LogtoConfigs);
export const createLogtoConfigQueries = (pool: CommonQueryMethods) => {
@ -27,11 +34,33 @@ export const createLogtoConfigQueries = (pool: CommonQueryMethods) => {
where ${fields.key} = ${LogtoTenantConfigKey.CloudConnection}
`);
const getSamlSigningKeyPair = async () => {
const result = await pool.maybeOne<{ value: LogtoSamlSigningKeyPair }>(sql`
select ${fields.value} from ${table}
where ${fields.key} = ${LogtoSamlConfigKey.SigningKeyPair}
`);
return result ? result.value : undefined;
};
const insertSamlSigningKeyPair = async (keyPair: LogtoSamlSigningKeyPair) =>
buildInsertIntoWithPool(pool)(LogtoConfigs, { returning: true })({
key: LogtoSamlConfigKey.SigningKeyPair,
value: keyPair,
});
const getRowsByKeys = async (keys: LogtoConfigKey[]) =>
pool.query<LogtoConfig>(sql`
select ${sql.join([fields.key, fields.value], sql`,`)} from ${table}
where ${fields.key} in (${sql.join(keys, sql`,`)})
`);
return { getAdminConsoleConfig, updateAdminConsoleConfig, getCloudConnectionData, getRowsByKeys };
return {
getAdminConsoleConfig,
updateAdminConsoleConfig,
getCloudConnectionData,
getRowsByKeys,
getSamlSigningKeyPair,
insertSamlSigningKeyPair,
};
};

View file

@ -0,0 +1,43 @@
import Koa from 'koa';
import Router from 'koa-router';
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
import { type AnonymousRouter } from '#src/routes/types.js';
import type TenantContext from '#src/tenants/TenantContext.js';
const createSamlRouter = ({ id, logtoConfigs }: TenantContext) => {
const router: AnonymousRouter = new Router();
router.get('/cert', async (ctx, next) => {
const { publicCert } = await logtoConfigs.getSamlSigningKeyPair();
// Convert the certificate string to a Buffer
const certBuffer = Buffer.from(publicCert, 'utf8');
// Set the response headers
ctx.set('Content-Type', 'application/x-x509-ca-cert');
ctx.response.set(
'Content-Disposition',
`attachment; filename="logto-saml-certificate-${id}.crt"`
);
// Send the certificate
ctx.body = certBuffer;
return next();
});
return router;
};
export default function initSaml(tenant: TenantContext): Koa {
const samlApp = new Koa();
samlApp.use(koaBodyEtag());
const samlRouter = createSamlRouter(tenant);
samlApp.use(samlRouter.routes()).use(samlRouter.allowedMethods());
return samlApp;
}

View file

@ -25,6 +25,7 @@ import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
import initOidc from '#src/oidc/init.js';
import initApis from '#src/routes/init.js';
import initMeApis from '#src/routes-me/init.js';
import initSaml from '#src/saml/inits.js';
import Libraries from './Libraries.js';
import Queries from './Queries.js';
@ -94,6 +95,9 @@ export default class Tenant implements TenantContext {
envSet,
};
// Mount SAML
app.use(mount('/saml', initSaml(tenantContext)));
// Mount APIs
app.use(mount('/api', initApis(tenantContext)));

View file

@ -0,0 +1,14 @@
import { generateSamlKeyPair } from './saml.js';
describe('SAML utils', () => {
it('should generate SAML key pair', async () => {
const { privateKey, publicCert } = generateSamlKeyPair();
expect(privateKey).toBeDefined();
expect(publicCert).toBeDefined();
// Should contains header
expect(privateKey).toContain('-----BEGIN RSA PRIVATE KEY-----');
expect(publicCert).toContain('-----BEGIN CERTIFICATE-----');
});
});

View file

@ -0,0 +1,42 @@
import { generateStandardId } from '@logto/shared';
import forge from 'node-forge';
export const generateSamlKeyPair = () => {
const { pki } = forge;
const { privateKey, publicKey } = pki.rsa.generateKeyPair(1024);
const cert = pki.createCertificate();
/* eslint-disable @silverhand/fp/no-mutation */
cert.publicKey = publicKey;
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10);
cert.serialNumber = generateStandardId();
/* eslint-enable @silverhand/fp/no-mutation */
// Certificate attributes
const subject = [
{
name: 'commonName',
value: 'logto.io',
},
{
name: 'organizationName',
value: 'Silverhand',
},
{
name: 'emailAddress',
value: 'contact@silverhand.io',
},
];
cert.setSubject(subject);
cert.setIssuer(subject);
cert.sign(privateKey);
return {
privateKey: pki.privateKeyToPem(privateKey),
publicCert: pki.certificateToPem(cert),
};
};

View file

@ -28,3 +28,7 @@ export const authedAdminTenantApi = adminTenantApi.extend({
export const cloudApi = got.extend({
prefixUrl: new URL('/api', logtoCloudUrl),
});
export const samlApi = got.extend({
prefixUrl: new URL('/saml', logtoUrl),
});

View file

@ -0,0 +1,11 @@
import { samlApi } from '#src/api/api.js';
describe('download-cert', () => {
it('should download the cert successfully', async () => {
const response = await samlApi.get('cert');
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('application/x-x509-ca-cert');
expect(response.body).toContain('BEGIN CERTIFICATE');
});
});

View file

@ -55,17 +55,44 @@ export const logtoTenantConfigGuard: Readonly<{
[LogtoTenantConfigKey.SessionNotFoundRedirectUrl]: z.object({ url: z.string() }),
});
/* --- Logto SAML configs --- */
export enum LogtoSamlConfigKey {
// Only support signing key for now, consider support encryption key later
SigningKeyPair = 'saml.signingKeyPair',
}
export const logtoSamlSigningKeyPairGuard = z.object({
privateKey: z.string(),
publicCert: z.string(),
});
export type LogtoSamlSigningKeyPair = z.infer<typeof logtoSamlSigningKeyPairGuard>;
// Saml config is optional, generate one only when SAML is enabled
export type LogtoSamlConfigType = {
[LogtoSamlConfigKey.SigningKeyPair]: LogtoSamlSigningKeyPair;
};
export const logtoSamlConfigGuard: Readonly<{
[key in LogtoSamlConfigKey]: ZodType<LogtoSamlConfigType[key]>;
}> = Object.freeze({
[LogtoSamlConfigKey.SigningKeyPair]: logtoSamlSigningKeyPairGuard,
});
/* --- Summary --- */
export type LogtoConfigKey = LogtoOidcConfigKey | LogtoTenantConfigKey;
export type LogtoConfigType = LogtoOidcConfigType | LogtoTenantConfigType;
export type LogtoConfigGuard = typeof logtoOidcConfigGuard & typeof logtoTenantConfigGuard;
export type LogtoConfigKey = LogtoOidcConfigKey | LogtoTenantConfigKey | LogtoSamlConfigKey;
export type LogtoConfigGuard = typeof logtoOidcConfigGuard &
typeof logtoTenantConfigGuard &
typeof logtoSamlConfigGuard;
export const logtoConfigKeys: readonly LogtoConfigKey[] = Object.freeze([
...Object.values(LogtoOidcConfigKey),
...Object.values(LogtoTenantConfigKey),
...Object.values(LogtoSamlConfigKey),
]);
export const logtoConfigGuards: LogtoConfigGuard = Object.freeze({
...logtoOidcConfigGuard,
...logtoTenantConfigGuard,
...logtoSamlConfigGuard,
});

View file

@ -3244,6 +3244,9 @@ importers:
nanoid:
specifier: ^4.0.0
version: 4.0.0
node-forge:
specifier: ^1.3.1
version: 1.3.1
oidc-provider:
specifier: ^8.2.2
version: 8.2.2
@ -3323,6 +3326,9 @@ importers:
'@types/node':
specifier: ^18.11.18
version: 18.11.18
'@types/node-forge':
specifier: ^1.3.5
version: 1.3.5
'@types/oidc-provider':
specifier: ^8.0.0
version: 8.0.0
@ -9634,6 +9640,12 @@ packages:
form-data: 3.0.1
dev: false
/@types/node-forge@1.3.5:
resolution: {integrity: sha512-GASdDr5u+BDTUcmWaS9Ljs1QTcyI+iPuCh/FdpDqLuLJP7Stx+j3DI0g4/cBn0zFjOnajMKATkq6PqBD0K5uzQ==}
dependencies:
'@types/node': 18.11.18
dev: true
/@types/node@12.20.55:
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
dev: true