jackson/npm/src/controller/connection/saml.ts

360 lines
10 KiB
TypeScript

import crypto from 'crypto';
import saml20 from '@boxyhq/saml20';
import axios from 'axios';
import {
IConnectionAPIController,
SAMLSSOConnectionWithEncodedMetadata,
SAMLSSOConnectionWithRawMetadata,
SAMLSSORecord,
Storable,
UpdateSAMLConnectionParams,
} from '../../typings';
import * as dbutils from '../../db/utils';
import {
extractHostName,
extractRedirectUrls,
IndexNames,
validateSSOConnection,
validateRedirectUrl,
validateTenantAndProduct,
isLocalhost,
validateSortOrder,
} from '../utils';
import { JacksonError } from '../error';
import { OryController } from '../../ee/ory/ory';
async function fetchMetadata(resource: string) {
try {
const response = await axios(resource, {
maxContentLength: 1000000,
maxBodyLength: 1000000,
timeout: 8000,
});
return response.data;
} catch (error: any) {
throw new JacksonError("Couldn't fetch XML data", error.response?.status || 400);
}
}
function validateParsedMetadata(metadata: SAMLSSORecord['idpMetadata']) {
if (metadata.loginType !== 'idp') {
throw new JacksonError('Please provide a metadata with IDPSSODescriptor', 400);
}
if (!metadata.entityID) {
throw new JacksonError("Couldn't parse EntityID from SAML metadata", 400);
}
if (!metadata.sso.redirectUrl && !metadata.sso.postUrl) {
throw new JacksonError("Couldn't find SAML bindings for POST/REDIRECT", 400);
}
}
function validateMetadataURL(metadataUrl: string) {
if (!isLocalhost(metadataUrl) && !metadataUrl.startsWith('https')) {
throw new JacksonError('Metadata URL not valid, allowed ones are localhost/HTTPS URLs', 400);
}
}
const saml = {
create: async (
body: SAMLSSOConnectionWithRawMetadata | SAMLSSOConnectionWithEncodedMetadata,
connectionStore: Storable,
oryController: OryController
) => {
const {
encodedRawMetadata,
rawMetadata,
defaultRedirectUrl,
redirectUrl,
tenant,
product,
name,
label,
description,
metadataUrl,
identifierFormat,
} = body;
const forceAuthn = body.forceAuthn == 'true' || body.forceAuthn == true;
let connectionClientSecret: string;
validateSSOConnection(body, 'saml');
const redirectUrlList = extractRedirectUrls(redirectUrl);
validateRedirectUrl({ defaultRedirectUrl, redirectUrlList });
validateTenantAndProduct(tenant, product);
if ('sortOrder' in body) {
validateSortOrder(body.sortOrder);
}
const record: Partial<SAMLSSORecord> = {
defaultRedirectUrl,
redirectUrl: redirectUrlList,
tenant,
product,
name,
label,
description,
clientID: '',
clientSecret: '',
forceAuthn,
identifierFormat,
metadataUrl,
sortOrder: parseInt(body.sortOrder as any),
};
let metadata = rawMetadata as string;
if (encodedRawMetadata) {
metadata = Buffer.from(encodedRawMetadata, 'base64').toString();
}
metadataUrl && validateMetadataURL(metadataUrl);
metadata = metadataUrl ? await fetchMetadata(metadataUrl) : metadata;
const idpMetadata = (await saml20.parseMetadata(metadata, {})) as SAMLSSORecord['idpMetadata'];
validateParsedMetadata(idpMetadata);
// extract provider
let providerName = extractHostName(idpMetadata.entityID);
if (!providerName) {
providerName = extractHostName(idpMetadata.sso.redirectUrl || idpMetadata.sso.postUrl || '');
}
idpMetadata.provider = providerName ? providerName : 'Unknown';
record.clientID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, idpMetadata.entityID));
record.idpMetadata = idpMetadata;
const existing = (
await connectionStore.getByIndex({
name: IndexNames.EntityID,
value: idpMetadata.entityID,
})
).data;
if (existing.length > 0) {
for (let i = 0; i < existing.length; i++) {
const samlConfig = existing[i];
if (samlConfig.tenant !== tenant && samlConfig.product === product) {
throw new JacksonError('EntityID already exists for different tenant/product');
} else if (samlConfig.tenant !== tenant && samlConfig.product !== product) {
throw new JacksonError('EntityID already exists for different tenant/product');
} else {
continue;
}
}
}
const exists = await connectionStore.get(record.clientID);
const oryProjectId = exists?.ory?.projectId;
const oryOrganizationId = exists?.ory?.organizationId;
if (exists) {
connectionClientSecret = exists.clientSecret;
} else {
connectionClientSecret = crypto.randomBytes(24).toString('hex');
}
record.clientSecret = connectionClientSecret;
const oryRes = await oryController.createConnection(
{
sdkToken: undefined,
projectId: oryProjectId,
domains: body.ory?.domains,
organizationId: oryOrganizationId,
error: undefined,
},
tenant,
product
);
if (oryRes) {
record.ory = oryRes;
}
await connectionStore.put(
record.clientID,
record,
{
name: IndexNames.EntityID, // secondary index on entityID
value: idpMetadata.entityID,
},
{
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(tenant, product),
},
{
// secondary index on product
name: IndexNames.Product,
value: product,
}
);
return record as SAMLSSORecord;
},
update: async (
body: UpdateSAMLConnectionParams,
connectionStore: Storable,
connectionsGetter: IConnectionAPIController['getConnections'],
oryController: OryController
) => {
const {
encodedRawMetadata, // could be empty
rawMetadata, // could be empty
defaultRedirectUrl,
redirectUrl,
name,
label,
description,
forceAuthn,
metadataUrl,
...clientInfo
} = body;
if (!clientInfo?.clientID) {
throw new JacksonError('Please provide clientID', 400);
}
if (!clientInfo?.clientSecret) {
throw new JacksonError('Please provide clientSecret', 400);
}
if (!clientInfo?.tenant) {
throw new JacksonError('Please provide tenant', 400);
}
if (!clientInfo?.product) {
throw new JacksonError('Please provide product', 400);
}
if (description && description.length > 100) {
throw new JacksonError('Description should not exceed 100 characters', 400);
}
if ('sortOrder' in body) {
validateSortOrder(body.sortOrder);
}
const redirectUrlList = redirectUrl ? extractRedirectUrls(redirectUrl) : null;
validateRedirectUrl({ defaultRedirectUrl, redirectUrlList });
const _savedConnection = (await connectionsGetter(clientInfo))[0] as SAMLSSORecord;
if (_savedConnection.clientSecret !== clientInfo?.clientSecret) {
throw new JacksonError('clientSecret mismatch', 400);
}
let metadata = rawMetadata;
if (encodedRawMetadata) {
metadata = Buffer.from(encodedRawMetadata, 'base64').toString();
}
metadataUrl && validateMetadataURL(metadataUrl);
metadata = metadataUrl ? await fetchMetadata(metadataUrl) : metadata;
let newMetadata, newMetadataUrl;
if (metadata) {
newMetadata = await saml20.parseMetadata(metadata, {});
validateParsedMetadata(newMetadata);
// extract provider
let providerName = extractHostName(newMetadata.entityID);
if (!providerName) {
providerName = extractHostName(newMetadata.sso.redirectUrl || newMetadata.sso.postUrl);
}
newMetadata.provider = providerName ? providerName : 'Unknown';
}
if (newMetadata) {
// check if clientID matches with new metadata payload
const clientID = dbutils.keyDigest(
dbutils.keyFromParts(clientInfo.tenant, clientInfo.product, newMetadata.entityID)
);
if (clientID !== clientInfo?.clientID) {
throw new JacksonError('Tenant/Product config mismatch with IdP metadata', 400);
}
if (metadataUrl) {
newMetadataUrl = metadataUrl;
}
}
const record: SAMLSSORecord = {
..._savedConnection,
name: name || name === '' ? name : _savedConnection.name,
label: label || label === '' ? label : _savedConnection.label,
description: description || description === '' ? description : _savedConnection.description,
idpMetadata: newMetadata ? newMetadata : _savedConnection.idpMetadata,
metadataUrl: newMetadata ? newMetadataUrl : _savedConnection.metadataUrl,
defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _savedConnection.defaultRedirectUrl,
redirectUrl: redirectUrlList ? redirectUrlList : _savedConnection.redirectUrl,
forceAuthn: typeof forceAuthn === 'boolean' ? forceAuthn : _savedConnection.forceAuthn,
};
if ('sortOrder' in body) {
record.sortOrder = parseInt(body.sortOrder as any);
}
if ('deactivated' in body) {
record['deactivated'] = body.deactivated;
}
if ('identifierFormat' in body) {
record['identifierFormat'] = body.identifierFormat;
}
const oryRes = await oryController.updateConnection(
{
sdkToken: undefined,
projectId: _savedConnection.ory?.projectId,
domains: _savedConnection.ory?.domains,
organizationId: _savedConnection.ory?.organizationId,
error: undefined,
},
_savedConnection.tenant,
_savedConnection.product
);
if (oryRes) {
record.ory = oryRes;
}
await connectionStore.put(
clientInfo?.clientID,
record,
{
// secondary index on entityID
name: IndexNames.EntityID,
value: _savedConnection.idpMetadata.entityID,
},
{
// secondary index on tenant + product
name: IndexNames.TenantProduct,
value: dbutils.keyFromParts(_savedConnection.tenant, _savedConnection.product),
},
{
// secondary index on product
name: IndexNames.Product,
value: _savedConnection.product,
}
);
return record;
},
};
export default saml;