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:
parent
d254dae5ff
commit
e1538b12e7
15 changed files with 227 additions and 11 deletions
|
@ -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",
|
||||
|
|
|
@ -16,6 +16,7 @@ export enum UserApps {
|
|||
Api = 'api',
|
||||
Oidc = 'oidc',
|
||||
DemoApp = 'demo-app',
|
||||
Saml = 'saml',
|
||||
}
|
||||
|
||||
/** Apps (also paths) ONLY for the admin tenant. */
|
||||
|
|
|
@ -34,6 +34,7 @@ const logtoConfigs: LogtoConfigLibrary = {
|
|||
resource: 'resource',
|
||||
}),
|
||||
getOidcConfigs: jest.fn(),
|
||||
getSamlSigningKeyPair: jest.fn(),
|
||||
};
|
||||
|
||||
describe('getAccessToken()', () => {
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -19,6 +19,7 @@ const logtoConfigs: LogtoConfigLibrary = {
|
|||
resource: 'resource',
|
||||
}),
|
||||
getOidcConfigs: jest.fn(),
|
||||
getSamlSigningKeyPair: jest.fn(),
|
||||
};
|
||||
|
||||
describe('koaTenantGuard middleware', () => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
43
packages/core/src/saml/inits.ts
Normal file
43
packages/core/src/saml/inits.ts
Normal 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;
|
||||
}
|
|
@ -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)));
|
||||
|
||||
|
|
14
packages/core/src/utils/saml.test.ts
Normal file
14
packages/core/src/utils/saml.test.ts
Normal 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-----');
|
||||
});
|
||||
});
|
42
packages/core/src/utils/saml.ts
Normal file
42
packages/core/src/utils/saml.ts
Normal 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),
|
||||
};
|
||||
};
|
|
@ -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),
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue