Skip to content

Commit

Permalink
refactor: use node-forge to generate private key and certificate
Browse files Browse the repository at this point in the history
  • Loading branch information
darcyYe committed Nov 20, 2024
1 parent b1d147c commit 2c8ef08
Show file tree
Hide file tree
Showing 11 changed files with 180 additions and 158 deletions.
16 changes: 4 additions & 12 deletions packages/cli/src/commands/database/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { promisify } from 'node:util';
import { type OidcConfigKey, SupportedSigningKeyAlgorithm } from '@logto/schemas';
import { generateStandardId, generateStandardSecret } from '@logto/shared';

export const generatePrivateKey = async (
export const generateOidcPrivateKey = async (
type: SupportedSigningKeyAlgorithm = SupportedSigningKeyAlgorithm.EC
) => {
): Promise<OidcConfigKey> => {
if (type === SupportedSigningKeyAlgorithm.RSA) {
const { privateKey } = await promisify(generateKeyPair)('rsa', {
modulusLength: 4096,
Expand All @@ -20,7 +20,7 @@ export const generatePrivateKey = async (
},
});

return privateKey;
return buildOidcKeyFromRawString(privateKey);
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
Expand All @@ -38,20 +38,12 @@ export const generatePrivateKey = async (
},
});

return privateKey;
return buildOidcKeyFromRawString(privateKey);
}

throw new Error(`Unsupported private key ${String(type)}`);
};

export const generateOidcPrivateKey = async (
type: SupportedSigningKeyAlgorithm = SupportedSigningKeyAlgorithm.EC
): Promise<OidcConfigKey> => {
const privateKey = await generatePrivateKey(type);

return buildOidcKeyFromRawString(privateKey);
};

export const generateOidcCookieKey = () => buildOidcKeyFromRawString(generateStandardSecret());

export const buildOidcKeyFromRawString = (raw: string) => ({
Expand Down
4 changes: 3 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
},
"scripts": {
"precommit": "lint-staged",
"copy:apidocs": "rsync -a -m --include '*/' --include '*.openapi.json' --exclude '*' src/routes/ build/routes/",
"copy:apidocs": "rsync -a -m --include '*/' --include '*.openapi.json' --exclude '*' src/routes/ build/routes/ && rsync -a -m --include '*/' --include '*.openapi.json' --exclude '*' src/saml-applications/ build/saml-applications/",
"check": "tsc --noEmit",
"build": "tsup",
"build:test": "rm -rf build/ && tsc -p tsconfig.test.json --sourcemap && pnpm run copy:apidocs",
Expand Down Expand Up @@ -80,6 +80,7 @@
"ky": "^1.2.3",
"lru-cache": "^11.0.0",
"nanoid": "^5.0.1",
"node-forge": "^1.3.1",
"oidc-provider": "^8.4.6",
"openapi-types": "^12.1.3",
"otplib": "^12.0.1",
Expand Down Expand Up @@ -114,6 +115,7 @@
"@types/koa-send": "^4.1.3",
"@types/koa__cors": "^5.0.0",
"@types/node": "^20.9.5",
"@types/node-forge": "^1.3.1",
"@types/oidc-provider": "^8.4.4",
"@types/pluralize": "^0.0.33",
"@types/qrcode": "^1.5.2",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/routes/swagger/utils/documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const managementApiIdentifiableEntityNames = Object.freeze([
'organization-role',
'organization-scope',
'organization-invitation',
'saml-application',
]);

/** Additional tags that cannot be inferred from the path. */
Expand Down Expand Up @@ -213,7 +214,7 @@ export const buildUserApiBaseDocument = (
});

export const getSupplementDocuments = async (
directory = 'routes',
directory = 'build',
option?: FindSupplementFilesOptions
) => {
// Find supplemental documents
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/routes/swagger/utils/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const findSupplementFiles = async (
}

if (stats.isDirectory()) {
result.push(...(await findSupplementFiles(path.join(directory, file))));
result.push(...(await findSupplementFiles(path.join(directory, file), option)));
} else if (file.endsWith('.openapi.json')) {
result.push(path.join(directory, file));
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/routes/well-known/well-known.openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function openapiRoutes<T extends AnonymousRouter, R extends Unkno
const { pathMap, tags } = groupRoutesByPath(managementApiRoutes);

// Find supplemental documents
const supplementDocuments = await getSupplementDocuments('routes', {
const supplementDocuments = await getSupplementDocuments('build', {

Check warning on line 33 in packages/core/src/routes/well-known/well-known.openapi.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/well-known/well-known.openapi.ts#L33

Added line #L33 was not covered by tests
excludeDirectories: ['experience', 'interaction', 'profile', 'verification'],
});
const baseDocument = buildManagementApiBaseDocument(pathMap, tags, ctx.request.origin);
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/saml-applications/libraries/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { generateStandardId } from '@logto/shared';

import type Queries from '#src/tenants/Queries.js';

import { generateSamlCredentials } from './utils.js';
import { generateKeyPairAndCertificate } from './utils.js';

export const createSamlApplicationSecretsLibrary = (queries: Queries) => {
const {
Expand All @@ -12,17 +12,21 @@ export const createSamlApplicationSecretsLibrary = (queries: Queries) => {

const createNewSamlApplicationSecretForApplication = async (
applicationId: string,
lifeSpan: number,
type: SupportedSigningKeyAlgorithm = SupportedSigningKeyAlgorithm.EC
// Set certificate life span to 1 year by default.
lifeSpanInDays = 365,
type: SupportedSigningKeyAlgorithm = SupportedSigningKeyAlgorithm.RSA
) => {
const { privateKey, certificate } = await generateSamlCredentials(type);
const { privateKey, certificate, notAfter } = await generateKeyPairAndCertificate(
lifeSpanInDays,
type
);

return insertSamlApplicationSecret({
id: generateStandardId(),
applicationId,
privateKey,
certificate,
expiresAt: Date.now() + lifeSpan,
expiresAt: Math.floor(notAfter.getTime() / 1000),
active: false,
});

Check warning on line 31 in packages/core/src/saml-applications/libraries/secrets.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/secrets.ts#L14-L31

Added lines #L14 - L31 were not covered by tests
};
Expand Down
113 changes: 59 additions & 54 deletions packages/core/src/saml-applications/libraries/utils.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,73 @@
import { spawn } from 'node:child_process';
import { writeFile, readFile, unlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
// eslint-disable-next-line unicorn/import-style
import { join } from 'node:path';
import crypto from 'node:crypto';

import { generatePrivateKey } from '@logto/cli/lib/commands/database/utils.js';
import { SupportedSigningKeyAlgorithm } from '@logto/schemas';
import { addDays } from 'date-fns';
import forge from 'node-forge';

const generateCertificate = async (privateKeyPem: string) => {
const temporaryDirectory = tmpdir();
const privateKeyPath = join(temporaryDirectory, 'private.key');
const certPath = join(temporaryDirectory, 'cert.pem');

await writeFile(privateKeyPath, privateKeyPem);

const opensslResult = await new Promise<{ status: number | undefined; stderr: Uint8Array }>(
(resolve, reject) => {
const process = spawn('openssl', [
'req',
'-new',
'-x509',
'-key',
privateKeyPath,
'-out',
certPath,
'-days',
'365',
'-subj',
'/CN=SAML Service Provider/O=Logto',
]);

const stderr = new Uint8Array();
process.stderr.on('data', (data: Uint8Array) => {
stderr.set(new Uint8Array(data), stderr.length);
});
export const generateKeyPairAndCertificate = async (
lifeSpanInDays = 365,
type: SupportedSigningKeyAlgorithm = SupportedSigningKeyAlgorithm.RSA
) => {
if (type === SupportedSigningKeyAlgorithm.RSA) {
const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 });
return createCertificate(keypair, lifeSpanInDays);
}

process.on('close', (status) => {
// `conditional()` is not used here to avoid mistakenly treating status = 0 as undefined.
resolve({ stderr, status: typeof status === 'number' ? status : undefined });
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (type === SupportedSigningKeyAlgorithm.EC) {
const ecdsa = forge.pki.ed25519;
const keypair = ecdsa.generateKeyPair();
return createCertificate(keypair, lifeSpanInDays);
}

process.on('error', reject);
}
);
throw new Error(`Unsupported key type ${String(type)}`);
};

Check warning on line 24 in packages/core/src/saml-applications/libraries/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/utils.ts#L8-L24

Added lines #L8 - L24 were not covered by tests

if (opensslResult.status !== 0) {
throw new Error(`Failed to generate certificate: ${opensslResult.stderr.toString()}`);
}
const createCertificate = (keypair: forge.pki.KeyPair, lifeSpanInDays: number) => {
const cert = forge.pki.createCertificate();
const notBefore = new Date();
const notAfter = addDays(notBefore, lifeSpanInDays);

const certificate = await readFile(certPath, 'utf8');
// Can not initialize the certificate with the keypair directly, so we need to set the public key manually.
/* eslint-disable @silverhand/fp/no-mutation */
cert.publicKey = keypair.publicKey;
// Use cryptographically secure pseudorandom number generator (CSPRNG) to generate a random serial number (usually more than 8 bytes).
// `serialNumber` should be IDENTICAL across different certificates, better not to be incremental.
cert.serialNumber = crypto.randomBytes(16).toString('hex');
cert.validity.notBefore = notBefore;
cert.validity.notAfter = notAfter;
/* eslint-enable @silverhand/fp/no-mutation */

await Promise.all([unlink(privateKeyPath), unlink(certPath)]);
// TODO: read from tenant config or let user customize before downloading
const subjectAttributes: forge.pki.CertificateField[] = [
{
name: 'commonName',
value: 'example.com',
},
];

return certificate;
};
const issuerAttributes: forge.pki.CertificateField[] = [
{
name: 'commonName',
value: 'logto.io',
},
{
name: 'organizationName',
value: 'Logto',
},
{
name: 'countryName',
value: 'US',
},
];

export const generateSamlCredentials = async (
algorithm: SupportedSigningKeyAlgorithm = SupportedSigningKeyAlgorithm.EC
) => {
const privateKey = await generatePrivateKey(algorithm);
const certificate = await generateCertificate(privateKey);
cert.setSubject(subjectAttributes);
cert.setIssuer(issuerAttributes);
cert.sign(keypair.privateKey);

return {
privateKey,
certificate,
privateKey: forge.pki.privateKeyToPem(keypair.privateKey),
certificate: forge.pki.certificateToPem(cert),
notAfter,
};
};

Check warning on line 73 in packages/core/src/saml-applications/libraries/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/libraries/utils.ts#L27-L73

Added lines #L27 - L73 were not covered by tests
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,17 @@
},
"customData": {
"type": "object",
"required": false,
"description": "Custom data for the application."
},
"config": {
"type": "object",
"required": false,
"properties": {
"attributeMapping": {
"type": "object",
"description": "Mapping of SAML attributes to Logto user properties."
},
"spMetadata": {
"type": "object",
"required": false,
"description": "Service Provider metadata configuration."
}
}
Expand Down
6 changes: 1 addition & 5 deletions packages/core/src/saml-applications/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,7 @@ export default function samlApplicationRoutes<T extends ManagementApiRouter>(
spMetadata,
}))
);
const samlSecret = await createNewSamlApplicationSecretForApplication(
application.id,
// Default to 3 years.
3 * 365 * 24 * 60 * 60 * 1000
);
const samlSecret = await createNewSamlApplicationSecretForApplication(application.id);

ctx.status = 201;
ctx.body = ensembleSamlApplication({ application, samlConfig, samlSecret });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,23 @@ import { createSamlApplication, deleteSamlApplication } from '#src/api/saml-appl

describe('SAML application', () => {
it('should create and delete a SAML application successfully', async () => {
const response = await createSamlApplication({
const createdSamlApplication = await createSamlApplication({
name: 'test',
description: 'test',
});

await deleteSamlApplication(response.id);
// Check secrets array exists and not empty
expect(Array.isArray(createdSamlApplication.secrets)).toBe(true);
expect(createdSamlApplication.secrets.length).toBeGreaterThan(0);

// Check first secret has non-empty privateKey and certificate
// Since we checked the array is not empty in previous check, we can safely access the first element.
const firstSecret = createdSamlApplication.secrets[0]!;
expect(typeof firstSecret.privateKey).toBe('string');
expect(firstSecret.privateKey).not.toBe('');
expect(typeof firstSecret.certificate).toBe('string');
expect(firstSecret.certificate).not.toBe('');

await deleteSamlApplication(createdSamlApplication.id);
});
});
Loading

0 comments on commit 2c8ef08

Please sign in to comment.