Use a global certificate instead of a per tenant/product certificate (#667)

* Replace Admin UI with Admin Portal

* Create a default certificate

* Use the default certs instead of per connection certificate

* Revert the changes

* refactored to encapsulate all logic inside x509.ts

* added certs to sp-metadata

* Cache the certificate before return

* Fix the type

* added expiry check to cached certificate

* added url to download public cert

* added instructions to encrypt assertion

* bumped up version

Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
This commit is contained in:
Kiran K 2022-11-11 03:08:06 +05:30 committed by GitHub
parent 1674fd5afa
commit 6adb642266
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 151 additions and 116 deletions

View File

@ -14,6 +14,11 @@ const links = [
'The configuration setup guide that your customers will need to refer to when setting up SAML application with their Identity Provider.',
href: '/.well-known/saml-configuration',
},
{
title: 'SAML Public Certificate',
description: 'The SAML Public Certificate if you want to enable encryption with your Identity Provider.',
href: '/.well-known/saml.cer',
},
{
title: 'OpenID Configuration',
description:

View File

@ -29,6 +29,10 @@ module.exports = {
},
rewrites: async () => {
return [
{
source: '/.well-known/saml.cer',
destination: '/api/well-known/saml.cer',
},
{
source: '/.well-known/openid-configuration',
destination: '/api/well-known/openid-configuration',

View File

@ -52,10 +52,6 @@ export class ConnectionAPIController implements IConnectionAPIController {
* "description": "SP for hoppscotch.io",
* "clientID": "Xq8AJt3yYAxmXizsCWmUBDRiVP1iTC8Y/otnvFIMitk",
* "clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943",
* "certs": {
* "publicKey": "-----BEGIN CERTIFICATE-----.......-----END CERTIFICATE-----",
* "privateKey": "-----BEGIN PRIVATE KEY-----......-----END PRIVATE KEY-----"
* }
* }
* validationErrorsPost:
* description: Please provide rawMetadata or encodedRawMetadata | Please provide a defaultRedirectUrl | Please provide redirectUrl | redirectUrl is invalid | Exceeded maximum number of allowed redirect urls | defaultRedirectUrl is invalid | Please provide tenant | Please provide product | Please provide a friendly name | Description should not exceed 100 characters | Strategy&#58; xxxx not supported | Please provide the clientId from OpenID Provider | Please provide the clientSecret from OpenID Provider | Please provide the discoveryUrl for the OpenID Provider
@ -422,9 +418,6 @@ export class ConnectionAPIController implements IConnectionAPIController {
* idpMetadata:
* type: object
* description: SAML IdP metadata
* certs:
* type: object
* description: Certs generated for SAML connection
* oidcProvider:
* type: object
* description: OIDC IdP metadata
@ -548,10 +541,6 @@ export class ConnectionAPIController implements IConnectionAPIController {
* "description": "SP for hoppscotch.io",
* "clientID": "Xq8AJt3yYAxmXizsCWmUBDRiVP1iTC8Y/otnvFIMitk",
* "clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943",
* "certs": {
* "publicKey": "-----BEGIN CERTIFICATE-----.......-----END CERTIFICATE-----",
* "privateKey": "-----BEGIN PRIVATE KEY-----......-----END PRIVATE KEY-----"
* }
* }
* '400':
* $ref: '#/responses/400Get'

View File

@ -15,7 +15,6 @@ import {
validateRedirectUrl,
} from '../utils';
import saml20 from '@boxyhq/saml20';
import x509 from '../../saml/x509';
import { JacksonError } from '../error';
const saml = {
@ -76,14 +75,7 @@ const saml = {
record.clientID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, idpMetadata.entityID));
const certs = await x509.generate();
if (!certs) {
throw new JacksonError('Error generating x509 certs');
}
record.idpMetadata = idpMetadata;
record.certs = certs;
const exists = await connectionStore.get(record.clientID);

View File

@ -10,6 +10,7 @@ import { JacksonOption, SAMLConnection, SAMLResponsePayload, SLORequestParams, S
import { JacksonError } from './error';
import * as redirect from './oauth/redirect';
import { IndexNames } from './utils';
import { getDefaultCertificate } from '../saml/x509';
const deflateRawAsync = promisify(deflateRaw);
@ -50,9 +51,10 @@ export class LogoutController {
const {
idpMetadata: { slo, provider },
certs: { privateKey, publicKey },
} = samlConnection;
const { privateKey, publicKey } = await getDefaultCertificate();
if ('redirectUrl' in slo === false && 'postUrl' in slo === false) {
throw new JacksonError(`${provider} doesn't support SLO or disabled by IdP.`, 400);
}

View File

@ -32,7 +32,7 @@ import {
loadJWSPrivateKey,
isJWSKeyPairLoaded,
} from './utils';
import x509 from '../saml/x509';
import { getDefaultCertificate } from '../saml/x509';
const deflateRawAsync = promisify(deflateRaw);
@ -354,41 +354,20 @@ export class OAuthController implements IOAuthController {
};
}
const cert = await getDefaultCertificate();
try {
const { validTo } = new crypto.X509Certificate(connection.certs.publicKey);
const isValidExpiry = validTo != 'Bad time value' && new Date(validTo) > new Date();
if (!isValidExpiry) {
const certs = await x509.generate();
connection.certs = certs;
if (certs) {
await this.connectionStore.put(
connection.clientID,
connection,
{
// secondary index on entityID
name: IndexNames.EntityID,
value: connection.idpMetadata.entityID,
},
{
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(connection.tenant, connection.product),
}
);
} else {
throw new Error('Error generating x509 certs');
}
}
// We will get undefined or Space delimited, case sensitive list of ASCII string values in prompt
// If login is one of the value in prompt we want to enable forceAuthn
// Else use the saml connection forceAuthn value
const promptOptions = prompt ? prompt.split(' ').filter((p) => p === 'login') : [];
samlReq = saml.request({
ssoUrl,
entityID: this.opts.samlAudience!,
callbackUrl: this.opts.externalUrl + this.opts.samlPath,
signingKey: connection.certs.privateKey,
publicKey: connection.certs.publicKey,
signingKey: cert.privateKey,
publicKey: cert.publicKey,
forceAuthn: promptOptions.length > 0 ? true : !!connection.forceAuthn,
});
} catch (err: unknown) {
@ -402,6 +381,7 @@ export class OAuthController implements IOAuthController {
};
}
}
// OIDC Connection: Issuer discovery, openid-client init and extraction of authorization endpoint happens here
let oidcCodeVerifier: string | undefined;
if (connectionIsOIDC) {
@ -616,10 +596,12 @@ export class OAuthController implements IOAuthController {
throw new JacksonError('SAML connection not found.', 403);
}
const { privateKey } = await getDefaultCertificate();
const validateOpts: Record<string, string> = {
thumbprint: samlConnection.idpMetadata.thumbprint,
audience: this.opts.samlAudience!,
privateKey: samlConnection.certs.privateKey,
privateKey,
};
if (

View File

@ -1,9 +1,11 @@
import type { JacksonOption } from '../typings';
import { marked } from 'marked';
import saml20 from '@boxyhq/saml20';
// Service Provider SAML Configuration
export class SPSAMLConfig {
constructor(private opts: JacksonOption) {}
constructor(private opts: JacksonOption, private getDefaultCertificate: any) {}
private get acsUrl(): string {
return `${this.opts.externalUrl}${this.opts.samlPath}`;
@ -25,25 +27,25 @@ export class SPSAMLConfig {
return 'RSA-SHA256';
}
private get assertionEncryption(): string {
return 'Unencrypted';
}
public get(): {
public async get(): Promise<{
acsUrl: string;
entityId: string;
response: string;
assertionSignature: string;
signatureAlgorithm: string;
assertionEncryption: string;
} {
publicKey: string;
publicKeyString: string;
}> {
const cert = await this.getDefaultCertificate();
return {
acsUrl: this.acsUrl,
entityId: this.entityId,
response: this.responseSigned,
assertionSignature: this.assertionSignature,
signatureAlgorithm: this.signatureAlgorithm,
assertionEncryption: this.assertionEncryption,
publicKey: cert.publicKey,
publicKeyString: saml20.stripCertHeaderAndFooter(cert.publicKey),
};
}
@ -53,8 +55,7 @@ export class SPSAMLConfig {
.replace('{{entityId}}', this.entityId)
.replace('{{responseSigned}}', this.responseSigned)
.replace('{{assertionSignature}}', this.assertionSignature)
.replace('{{signatureAlgorithm}}', this.signatureAlgorithm)
.replace('{{assertionEncryption}}', this.assertionEncryption);
.replace('{{signatureAlgorithm}}', this.signatureAlgorithm);
}
public toHTML(): string {
@ -83,5 +84,5 @@ Your Identity Provider (IdP) will ask for the following information while config
{{signatureAlgorithm}}
**Assertion Encryption** <br />
{{assertionEncryption}}
If you want to encrypt the assertion, you can download our [public certificate](/.well-known/saml.cer). Otherwise select the 'Unencrypted' option.
`;

View File

@ -20,7 +20,7 @@ class Redis implements DatabaseDriver {
}
this.client = redis.createClient(opts);
this.client.on('error', (err: any) => console.log('Redis Client Error', err));
this.client.on('error', (err: any) => console.info('Redis Client Error', err));
await this.client.connect();

View File

@ -102,7 +102,7 @@ class Sql implements DatabaseDriver {
this.timerId = setTimeout(this.ttlCleanup, this.options.ttl! * 1000);
} else {
console.log(
console.warn(
'Warning: ttl cleanup not enabled, set both "ttl" and "cleanupLimit" options to enable it!'
);
}

View File

@ -12,6 +12,7 @@ import { LogoutController } from './controller/logout';
import initDirectorySync from './directory-sync';
import { OidcDiscoveryController } from './controller/oidc-discovery';
import { SPSAMLConfig } from './controller/sp-config';
import * as x509 from './saml/x509';
const defaultOpts = (opts: JacksonOption): JacksonOption => {
const newOpts = {
@ -67,12 +68,16 @@ export const controllers = async (
const codeStore = db.store('oauth:code', opts.db.ttl);
const tokenStore = db.store('oauth:token', opts.db.ttl);
const healthCheckStore = db.store('_health:check');
const certificateStore = db.store('x509:certificates');
const connectionAPIController = new ConnectionAPIController({ connectionStore, opts });
const adminController = new AdminController({ connectionStore });
const healthCheckController = new HealthCheckController({ healthCheckStore });
await healthCheckController.init();
// Create default certificate if it doesn't exist.
await x509.init(certificateStore);
const oauthController = new OAuthController({
connectionStore,
sessionStore,
@ -91,7 +96,7 @@ export const controllers = async (
const oidcDiscoveryController = new OidcDiscoveryController({ opts });
const spConfig = new SPSAMLConfig(opts);
const spConfig = new SPSAMLConfig(opts, x509.getDefaultCertificate);
// write pre-loaded connections if present
const preLoadedConnection = opts.preLoadedConnection || opts.preLoadedConfig;
@ -105,13 +110,13 @@ export const controllers = async (
await connectionAPIController.createSAMLConnection(connection);
}
console.log(`loaded connection for tenant "${connection.tenant}" and product "${connection.product}"`);
console.info(`loaded connection for tenant "${connection.tenant}" and product "${connection.product}"`);
}
}
const type = opts.db.engine === 'sql' && opts.db.type ? ' Type: ' + opts.db.type : '';
console.log(`Using engine: ${opts.db.engine}.${type}`);
console.info(`Using engine: ${opts.db.engine}.${type}`);
return {
spConfig,

View File

@ -1,19 +1,35 @@
import * as forge from 'node-forge';
import crypto from 'crypto';
import type { Storable } from '../typings';
const pki = forge.pki;
const generate = () => {
let certificateStore: Storable;
let cachedCertificate: { publicKey: string; privateKey: string };
export const init = async (store: Storable) => {
certificateStore = store;
return await getDefaultCertificate();
};
export const generateCertificate = () => {
const today = new Date();
const keys = pki.rsa.generateKeyPair(2048);
const cert = pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = '01';
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date(today.setFullYear(today.getFullYear() + 10));
cert.validity.notAfter = new Date(today.setFullYear(today.getFullYear() + 30));
const attrs = [
{
name: 'commonName',
value: 'BoxyHQ Jackson',
},
];
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.setExtensions([
@ -30,6 +46,7 @@ const generate = () => {
dataEncipherment: false,
},
]);
// self-sign certificate
cert.sign(keys.privateKey, forge.md.sha256.create());
@ -39,6 +56,32 @@ const generate = () => {
};
};
export default {
generate,
export const getDefaultCertificate = async (): Promise<{ publicKey: string; privateKey: string }> => {
if (cachedCertificate && !(await isCertificateExpired(cachedCertificate.publicKey))) {
return cachedCertificate;
}
if (!certificateStore) {
throw new Error('Certificate store not initialized');
}
cachedCertificate = await certificateStore.get('default');
// If certificate is expired let it drop through so it creates a new cert
if (cachedCertificate && !(await isCertificateExpired(cachedCertificate.publicKey))) {
return cachedCertificate;
}
// If default certificate is not found or has expired, create one and store it.
cachedCertificate = generateCertificate();
await certificateStore.put('default', cachedCertificate);
return cachedCertificate;
};
const isCertificateExpired = async (publicKey: string) => {
const { validTo } = new crypto.X509Certificate(publicKey);
return !(validTo != 'Bad time value' && new Date(validTo) > new Date());
};

View File

@ -47,10 +47,6 @@ export interface SAMLSSORecord extends SAMLSSOConnection {
thumbprint?: string;
validTo?: string;
};
certs: {
privateKey: string;
publicKey: string;
};
}
export interface OIDCSSORecord extends SSOConnection {
@ -349,10 +345,6 @@ interface Metadata {
export interface SAMLConnection {
idpMetadata: Metadata;
certs: {
privateKey: string;
publicKey: string;
};
defaultRedirectUrl: string;
}
@ -384,14 +376,15 @@ export type OIDCErrorCodes =
| 'registration_not_supported';
export interface ISPSAMLConfig {
get(): {
get(): Promise<{
acsUrl: string;
entityId: string;
response: string;
assertionSignature: string;
signatureAlgorithm: string;
assertionEncryption: string;
};
publicKey: string;
publicKeyString: string;
}>;
toMarkdown(): string;
toHTML(): string;
}

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "jackson",
"version": "1.3.5",
"version": "1.3.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "jackson",
"version": "1.3.5",
"version": "1.3.6",
"license": "Apache 2.0",
"dependencies": {
"@boxyhq/saml-jackson": "file:./npm",

View File

@ -1,6 +1,6 @@
{
"name": "jackson",
"version": "1.3.5",
"version": "1.3.6",
"private": true,
"description": "SAML 2.0 service",
"keywords": [

View File

@ -0,0 +1,13 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import jackson from '@lib/jackson';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
throw { message: 'Method not allowed', statusCode: 405 };
}
const { spConfig } = await jackson();
const config = await spConfig.get();
res.status(200).setHeader('Content-Type', 'application/x-x509-ca-cert').send(config.publicKey);
}

View File

@ -5,14 +5,12 @@ import xmlbuilder from 'xmlbuilder';
const createSSOMetadataXML = async ({
entityId,
acsUrl,
}: //certificate,
{
publicKeyString,
}: {
entityId: string;
acsUrl: string;
//certificate: string;
publicKeyString: string;
}): Promise<string> => {
// certificate = saml.stripCertHeaderAndFooter(certificate);
const today = new Date();
const nodes = {
@ -23,17 +21,33 @@ const createSSOMetadataXML = async ({
'md:SPSSODescriptor': {
//'@WantAuthnRequestsSigned': true,
'@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol',
// 'md:KeyDescriptor': {
// '@use': 'signing',
// 'ds:KeyInfo': {
// '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
// 'ds:X509Data': {
// 'ds:X509Certificate': {
// '#text': certificate,
// },
// },
// },
// },
'md:KeyDescriptor': [
{
'@use': 'signing',
'ds:KeyInfo': {
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'ds:X509Data': {
'ds:X509Certificate': {
'#text': publicKeyString,
},
},
},
},
{
'@use': 'encryption',
'ds:KeyInfo': {
'@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#',
'ds:X509Data': {
'ds:X509Certificate': {
'#text': publicKeyString,
},
},
},
'md:EncryptionMethod': {
'@Algorithm': 'http://www.w3.org/2001/04/xmlenc#aes256-cbc',
},
},
],
'md:NameIDFormat': {
'#text': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
},
@ -55,8 +69,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
const { spConfig } = await jackson();
const config = spConfig.get();
const config = await spConfig.get();
const xml = await createSSOMetadataXML({ entityId: config.entityId, acsUrl: config.acsUrl });
const xml = await createSSOMetadataXML({
entityId: config.entityId,
acsUrl: config.acsUrl,
publicKeyString: config.publicKeyString,
});
res.status(200).setHeader('Content-Type', 'text/xml').send(xml);
}

View File

@ -1,7 +1,7 @@
{
"info": {
"title": "SAML Jackson API",
"version": "1.3.2",
"version": "1.3.6",
"description": "This is the API documentation for SAML Jackson service.",
"termsOfService": "https://boxyhq.com/terms.html",
"contact": {
@ -191,11 +191,7 @@
"name": "Hoppscotch-SP",
"description": "SP for hoppscotch.io",
"clientID": "Xq8AJt3yYAxmXizsCWmUBDRiVP1iTC8Y/otnvFIMitk",
"clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943",
"certs": {
"publicKey": "-----BEGIN CERTIFICATE-----.......-----END CERTIFICATE-----",
"privateKey": "-----BEGIN PRIVATE KEY-----......-----END PRIVATE KEY-----"
}
"clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943"
}
}
},
@ -602,11 +598,7 @@
"name": "Hoppscotch-SP",
"description": "SP for hoppscotch.io",
"clientID": "Xq8AJt3yYAxmXizsCWmUBDRiVP1iTC8Y/otnvFIMitk",
"clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943",
"certs": {
"publicKey": "-----BEGIN CERTIFICATE-----.......-----END CERTIFICATE-----",
"privateKey": "-----BEGIN PRIVATE KEY-----......-----END PRIVATE KEY-----"
}
"clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943"
},
"properties": {
"clientID": {
@ -645,10 +637,6 @@
"type": "object",
"description": "SAML IdP metadata"
},
"certs": {
"type": "object",
"description": "Certs generated for SAML connection"
},
"oidcProvider": {
"type": "object",
"description": "OIDC IdP metadata"