Skip to content

Commit

Permalink
Use a global certificate instead of a per tenant/product certificate (#…
Browse files Browse the repository at this point in the history
…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>
  • Loading branch information
Kiran K and deepakprabhakara authored Nov 10, 2022
1 parent 1674fd5 commit 6adb642
Show file tree
Hide file tree
Showing 17 changed files with 151 additions and 116 deletions.
5 changes: 5 additions & 0 deletions components/connection/WellKnownURLs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 0 additions & 11 deletions npm/src/controller/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
8 changes: 0 additions & 8 deletions npm/src/controller/connection/saml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
validateRedirectUrl,
} from '../utils';
import saml20 from '@boxyhq/saml20';
import x509 from '../../saml/x509';
import { JacksonError } from '../error';

const saml = {
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 3 additions & 1 deletion npm/src/controller/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
Expand Down
38 changes: 10 additions & 28 deletions npm/src/controller/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
loadJWSPrivateKey,
isJWSKeyPairLoaded,
} from './utils';
import x509 from '../saml/x509';
import { getDefaultCertificate } from '../saml/x509';

const deflateRawAsync = promisify(deflateRaw);

Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 (
Expand Down
25 changes: 13 additions & 12 deletions npm/src/controller/sp-config.ts
Original file line number Diff line number Diff line change
@@ -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}`;
Expand All @@ -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),
};
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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.
`;
2 changes: 1 addition & 1 deletion npm/src/db/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion npm/src/db/sql/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!'
);
}
Expand Down
11 changes: 8 additions & 3 deletions npm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand Down
51 changes: 47 additions & 4 deletions npm/src/saml/x509.ts
Original file line number Diff line number Diff line change
@@ -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([
Expand All @@ -30,6 +46,7 @@ const generate = () => {
dataEncipherment: false,
},
]);

// self-sign certificate
cert.sign(keys.privateKey, forge.md.sha256.create());

Expand All @@ -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());
};
Loading

0 comments on commit 6adb642

Please sign in to comment.