Enhancements (#584)

* Throw error if `entityID` is missing

* Use `JacksonError` instead of Error

* Type enhancements - use `SAMLSSORecord`

* Better typing with `OIDCSSORecord`

* Add types for response

* Update swagger

* Sync package lock

* Assert connection record type in tests

* Mark `@deprecated` for config methods

* Mark `openid` as optional

* Gaurd against nullish

* Fix test

* Add entityID check for update op, add tests

* Cleanup `t.end()`, not required for `async` tests

* Remove oidcPath check in defaultOpts

* Return error if `oidcPath` is empty in authorize for OIDC Connection

* Add missing `async`

* Fail connection add/update if `oidcPath` is not set

* Type alignment

* Update swagger spec

* Fix type for `oidcPath`

* Cleanup

* Add missing return types and fix type for `getConfig`

* Bump up version

* Update swagger spec

* Remove uffizzi from ignore file

Co-authored-by: Kiran <kiran@boxyhq.com>
This commit is contained in:
Aswin V 2022-10-11 20:32:18 +05:30 committed by GitHub
parent ddc0c511a8
commit 2e5da524cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 593 additions and 4065 deletions

View File

@ -9,5 +9,4 @@ README.md
_dev
.vscode
swagger
uffizzi
.husky

View File

@ -9,6 +9,9 @@ import {
SAMLSSOConnectionWithEncodedMetadata,
SAMLSSOConnectionWithRawMetadata,
OIDCSSOConnection,
JacksonOption,
SAMLSSORecord,
OIDCSSORecord,
} from '../typings';
import { JacksonError } from './error';
import { IndexNames } from './utils';
@ -17,9 +20,11 @@ import samlConnection from './connection/saml';
export class ConnectionAPIController implements IConnectionAPIController {
private connectionStore: Storable;
private opts: JacksonOption;
constructor({ connectionStore }) {
constructor({ connectionStore, opts }) {
this.connectionStore = connectionStore;
this.opts = opts;
}
/**
@ -144,6 +149,8 @@ export class ConnectionAPIController implements IConnectionAPIController {
* $ref: '#/definitions/validationErrorsPost'
* 401:
* description: Unauthorized
* 500:
* description: Please set OpenID response handler path (oidcPath) on Jackson
* /api/v1/connections:
* post:
* summary: Create SSO connection
@ -178,21 +185,29 @@ export class ConnectionAPIController implements IConnectionAPIController {
*/
public async createSAMLConnection(
body: SAMLSSOConnectionWithEncodedMetadata | SAMLSSOConnectionWithRawMetadata
): Promise<any> {
): Promise<SAMLSSORecord> {
metrics.increment('createConnection');
const record = await samlConnection.create(body, this.connectionStore);
return record;
return await samlConnection.create(body, this.connectionStore);
}
// For backwards compatibility
public async config(...args: Parameters<ConnectionAPIController['createSAMLConnection']>): Promise<any> {
public async config(
...args: Parameters<ConnectionAPIController['createSAMLConnection']>
): Promise<SAMLSSORecord> {
return this.createSAMLConnection(...args);
}
public async createOIDCConnection(body: OIDCSSOConnection): Promise<any> {
public async createOIDCConnection(body: OIDCSSOConnection): Promise<OIDCSSORecord> {
metrics.increment('createConnection');
const record = await oidcConnection.create(body, this.connectionStore);
return record;
if (!this.opts.oidcPath) {
throw new JacksonError('Please set OpenID response handler path (oidcPath) on Jackson', 500);
}
return await oidcConnection.create(body, this.connectionStore);
}
/**
* @swagger
* definitions:
@ -324,6 +339,8 @@ export class ConnectionAPIController implements IConnectionAPIController {
* $ref: '#/definitions/validationErrorsPatch'
* 401:
* description: Unauthorized
* 500:
* description: Please set OpenID response handler path (oidcPath) on Jackson
*/
public async updateSAMLConnection(
body: (SAMLSSOConnectionWithEncodedMetadata | SAMLSSOConnectionWithRawMetadata) & {
@ -337,14 +354,20 @@ export class ConnectionAPIController implements IConnectionAPIController {
// For backwards compatibility
public async updateConfig(
...args: Parameters<ConnectionAPIController['updateSAMLConnection']>
): Promise<any> {
): Promise<void> {
await this.updateSAMLConnection(...args);
}
public async updateOIDCConnection(
body: OIDCSSOConnection & { clientID: string; clientSecret: string }
): Promise<void> {
if (!this.opts.oidcPath) {
throw new JacksonError('Please set OpenID response handler path (oidcPath) on Jackson', 500);
}
await oidcConnection.update(body, this.connectionStore, this.getConnections.bind(this));
}
/**
* @swagger
* parameters:
@ -363,6 +386,11 @@ export class ConnectionAPIController implements IConnectionAPIController {
* name: clientID
* type: string
* description: Client ID
* strategyParamGet:
* in: query
* name: strategy
* type: string
* description: Strategy which can help to filter connections with tenant/product query
* definitions:
* Connection:
* type: object
@ -418,6 +446,7 @@ export class ConnectionAPIController implements IConnectionAPIController {
* - $ref: '#/parameters/tenantParamGet'
* - $ref: '#/parameters/productParamGet'
* - $ref: '#/parameters/clientIDParamGet'
* - $ref: '#/parameters/strategyParamGet'
* operationId: get-connections
* tags: [Connections]
* responses:
@ -428,7 +457,7 @@ export class ConnectionAPIController implements IConnectionAPIController {
* '401':
* $ref: '#/responses/401Get'
*/
public async getConnections(body: GetConnectionsQuery): Promise<Array<any>> {
public async getConnections(body: GetConnectionsQuery): Promise<Array<SAMLSSORecord | OIDCSSORecord>> {
const clientID = 'clientID' in body ? body.clientID : undefined;
const tenant = 'tenant' in body ? body.tenant : undefined;
const product = 'product' in body ? body.product : undefined;
@ -475,6 +504,7 @@ export class ConnectionAPIController implements IConnectionAPIController {
if (!filteredConnections.length) {
return [];
}
return filteredConnections;
}
@ -528,7 +558,7 @@ export class ConnectionAPIController implements IConnectionAPIController {
* '401':
* $ref: '#/responses/401Get'
*/
public async getConfig(body: GetConfigQuery): Promise<any> {
public async getConfig(body: GetConfigQuery): Promise<SAMLSSORecord | Record<string, never>> {
const clientID = 'clientID' in body ? body.clientID : undefined;
const tenant = 'tenant' in body ? body.tenant : undefined;
const product = 'product' in body ? body.product : undefined;
@ -584,7 +614,7 @@ export class ConnectionAPIController implements IConnectionAPIController {
* name: strategy
* in: formData
* type: string
* description: Strategy
* description: Strategy which can help to filter connections with tenant/product query
* /api/v1/connections:
* delete:
* parameters:
@ -662,6 +692,7 @@ export class ConnectionAPIController implements IConnectionAPIController {
if (!connections || !connections.length) {
return;
}
// filter if strategy is passed
const filteredConnections = strategy
? connections.filter((connection) => {
@ -688,6 +719,7 @@ export class ConnectionAPIController implements IConnectionAPIController {
throw new JacksonError('Please provide `clientID` and `clientSecret` or `tenant` and `product`.', 400);
}
public async deleteConfig(body: DelConnectionsQuery): Promise<void> {
await this.deleteConnections({ ...body, strategy: 'saml' });
}

View File

@ -1,5 +1,5 @@
import crypto from 'crypto';
import { IConnectionAPIController, OIDCSSOConnection, Storable } from '../../typings';
import { IConnectionAPIController, OIDCSSOConnection, OIDCSSORecord, Storable } from '../../typings';
import * as dbutils from '../../db/utils';
import {
extractHostName,
@ -24,22 +24,15 @@ const oidc = {
oidcClientSecret = '',
} = body;
let connectionClientSecret;
let connectionClientSecret: string;
validateSSOConnection(body, 'oidc');
const redirectUrlList = extractRedirectUrls(redirectUrl);
validateRedirectUrl({ defaultRedirectUrl, redirectUrlList });
const record: Partial<OIDCSSOConnection> & {
clientID: string; // set by Jackson
clientSecret: string; // set by Jackson
oidcProvider?: {
provider?: string;
discoveryUrl?: string;
clientId?: string;
clientSecret?: string;
};
} = {
const record: Partial<OIDCSSORecord> = {
defaultRedirectUrl,
redirectUrl: redirectUrlList,
tenant,
@ -49,6 +42,7 @@ const oidc = {
clientID: '',
clientSecret: '',
};
// from OpenID Provider
record.oidcProvider = {
discoveryUrl: oidcDiscoveryUrl,
@ -79,8 +73,9 @@ const oidc = {
value: dbutils.keyFromParts(tenant, product),
});
return record;
return record as OIDCSSORecord;
},
update: async (
body: OIDCSSOConnection & { clientID: string; clientSecret: string },
connectionStore: Storable,
@ -96,25 +91,31 @@ const oidc = {
oidcClientSecret,
...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);
}
const redirectUrlList = redirectUrl ? extractRedirectUrls(redirectUrl) : null;
validateRedirectUrl({ defaultRedirectUrl, redirectUrlList });
const _savedConnection = (await connectionsGetter(clientInfo))[0];
const _savedConnection = (await connectionsGetter(clientInfo))[0] as OIDCSSORecord;
if (_savedConnection.clientSecret !== clientInfo?.clientSecret) {
throw new JacksonError('clientSecret mismatch', 400);
@ -123,6 +124,7 @@ const oidc = {
let oidcProvider;
if (_savedConnection && typeof _savedConnection.oidcProvider === 'object') {
oidcProvider = { ..._savedConnection.oidcProvider };
if (oidcClientId && typeof oidcClientId === 'string') {
const clientID = dbutils.keyDigest(
dbutils.keyFromParts(clientInfo.tenant, clientInfo.product, oidcClientId)
@ -131,9 +133,11 @@ const oidc = {
throw new JacksonError('Tenant/Product config mismatch with OIDC Provider metadata', 400);
}
}
if (oidcClientSecret && typeof oidcClientSecret === 'string') {
oidcProvider.clientSecret = oidcClientSecret;
}
if (oidcDiscoveryUrl && typeof oidcDiscoveryUrl === 'string') {
oidcProvider.discoveryUrl = oidcDiscoveryUrl;
const providerName = extractHostName(oidcDiscoveryUrl);

View File

@ -1,9 +1,9 @@
import crypto from 'crypto';
import {
IConnectionAPIController,
SAMLSSOConnection,
SAMLSSOConnectionWithEncodedMetadata,
SAMLSSOConnectionWithRawMetadata,
SAMLSSORecord,
Storable,
} from '../../typings';
import * as dbutils from '../../db/utils';
@ -35,18 +35,15 @@ const saml = {
} = body;
const forceAuthn = body.forceAuthn == 'true' || body.forceAuthn == true;
let connectionClientSecret;
let connectionClientSecret: string;
validateSSOConnection(body, 'saml');
const redirectUrlList = extractRedirectUrls(redirectUrl);
validateRedirectUrl({ defaultRedirectUrl, redirectUrlList });
const record: Partial<SAMLSSOConnection> & {
clientID: string; // set by Jackson
clientSecret: string; // set by Jackson
idpMetadata?: Record<string, any>;
certs?: Record<'publicKey' | 'privateKey', string>;
} = {
const record: Partial<SAMLSSORecord> = {
defaultRedirectUrl,
redirectUrl: redirectUrlList,
tenant,
@ -58,17 +55,21 @@ const saml = {
forceAuthn,
};
let metaData = rawMetadata;
let metaData = rawMetadata as string;
if (encodedRawMetadata) {
metaData = Buffer.from(encodedRawMetadata, 'base64').toString();
}
const idpMetadata = await saml20.parseMetadata(metaData!, {});
const idpMetadata = (await saml20.parseMetadata(metaData, {})) as SAMLSSORecord['idpMetadata'];
if (!idpMetadata.entityID) {
throw new JacksonError("Couldn't parse EntityID from SAML metadata", 400);
}
// extract provider
let providerName = extractHostName(idpMetadata.entityID);
if (!providerName) {
providerName = extractHostName(idpMetadata.sso.redirectUrl || idpMetadata.sso.postUrl);
providerName = extractHostName(idpMetadata.sso.redirectUrl || idpMetadata.sso.postUrl || '');
}
idpMetadata.provider = providerName ? providerName : 'Unknown';
@ -78,7 +79,7 @@ const saml = {
const certs = await x509.generate();
if (!certs) {
throw new Error('Error generating x509 certs');
throw new JacksonError('Error generating x509 certs');
}
record.idpMetadata = idpMetadata;
@ -108,8 +109,9 @@ const saml = {
}
);
return record;
return record as SAMLSSORecord;
},
update: async (
body: (SAMLSSOConnectionWithRawMetadata | SAMLSSOConnectionWithEncodedMetadata) & {
clientID: string;
@ -128,25 +130,31 @@ const saml = {
forceAuthn = false,
...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);
}
const redirectUrlList = redirectUrl ? extractRedirectUrls(redirectUrl) : null;
validateRedirectUrl({ defaultRedirectUrl, redirectUrlList });
const _savedConnection = (await connectionsGetter(clientInfo))[0];
const _savedConnection = (await connectionsGetter(clientInfo))[0] as SAMLSSORecord;
if (_savedConnection.clientSecret !== clientInfo?.clientSecret) {
throw new JacksonError('clientSecret mismatch', 400);
@ -156,10 +164,14 @@ const saml = {
if (encodedRawMetadata) {
metaData = Buffer.from(encodedRawMetadata, 'base64').toString();
}
let newMetadata;
if (metaData) {
newMetadata = await saml20.parseMetadata(metaData, {});
if (!newMetadata.entityID) {
throw new JacksonError("Couldn't parse EntityID from SAML metadata", 400);
}
// extract provider
let providerName = extractHostName(newMetadata.entityID);
if (!providerName) {

View File

@ -288,7 +288,7 @@ export class OAuthController implements IOAuthController {
if (
requestedOIDCFlow &&
(!this.opts.openid.jwtSigningKeys || !isJWSKeyPairLoaded(this.opts.openid.jwtSigningKeys))
(!this.opts.openid?.jwtSigningKeys || !isJWSKeyPairLoaded(this.opts.openid.jwtSigningKeys))
) {
return {
redirect_url: OAuthErrorResponse({
@ -404,6 +404,16 @@ 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) {
if (!this.opts.oidcPath) {
return {
redirect_url: OAuthErrorResponse({
error: 'server_error',
error_description: 'OpenID response handler path (oidcPath) is not set',
redirect_uri,
state,
}),
};
}
const { discoveryUrl, clientId, clientSecret } = connection.oidcProvider;
try {
const oidcIssuer = await Issuer.discover(discoveryUrl);
@ -955,7 +965,7 @@ export class OAuthController implements IOAuthController {
const requestedOIDCFlow = !!codeVal.requested?.oidc;
const requestHasNonce = !!codeVal.requested?.nonce;
if (requestedOIDCFlow) {
const { jwtSigningKeys, jwsAlg } = this.opts.openid;
const { jwtSigningKeys, jwsAlg } = this.opts.openid ?? {};
if (!jwtSigningKeys || !isJWSKeyPairLoaded(jwtSigningKeys)) {
throw new JacksonError('JWT signing keys are not loaded', 500);
}

View File

@ -25,7 +25,7 @@ export class OidcDiscoveryController implements IOidcDiscoveryController {
}
async jwks() {
const { jwtSigningKeys, jwsAlg } = this.opts.openid;
const { jwtSigningKeys, jwsAlg } = this.opts.openid ?? {};
if (!jwtSigningKeys || !isJWSKeyPairLoaded(jwtSigningKeys)) {
throw new JacksonError('JWT signing keys are not loaded', 501);
}

View File

@ -28,10 +28,6 @@ const defaultOpts = (opts: JacksonOption): JacksonOption => {
newOpts.scimPath = newOpts.scimPath || '/api/scim/v2.0';
if (!newOpts.oidcPath) {
throw new Error('oidcPath is required');
}
newOpts.samlAudience = newOpts.samlAudience || 'https://saml.boxyhq.com';
// path to folder containing static IdP connections that will be preloaded. This is useful for self-hosted deployments that only have to support a single tenant (or small number of known tenants).
newOpts.preLoadedConnection = newOpts.preLoadedConnection || '';
@ -72,7 +68,7 @@ export const controllers = async (
const tokenStore = db.store('oauth:token', opts.db.ttl);
const healthCheckStore = db.store('_health:check');
const connectionAPIController = new ConnectionAPIController({ connectionStore });
const connectionAPIController = new ConnectionAPIController({ connectionStore, opts });
const adminController = new AdminController({ connectionStore });
const healthCheckController = new HealthCheckController({ healthCheckStore });
await healthCheckController.init();

View File

@ -29,16 +29,53 @@ export interface OIDCSSOConnection extends SSOConnection {
oidcClientSecret: string;
}
export interface SAMLSSORecord extends SAMLSSOConnection {
clientID: string; // set by Jackson
clientSecret: string; // set by Jackson
idpMetadata: {
entityID: string;
loginType?: string;
provider: string | 'Unknown';
slo: {
postUrl?: string;
redirectUrl?: string;
};
sso: {
postUrl?: string;
redirectUrl?: string;
};
thumbprint?: string;
validTo?: string;
};
certs: {
privateKey: string;
publicKey: string;
};
}
export interface OIDCSSORecord extends SSOConnection {
clientID: string; // set by Jackson
clientSecret: string; // set by Jackson
oidcProvider: {
provider?: string;
discoveryUrl?: string;
clientId?: string;
clientSecret?: string;
};
}
export type ConnectionType = 'saml' | 'oidc';
type ClientIDQuery = {
clientID: string;
};
type TenantQuery = {
tenant: string;
product: string;
strategy?: ConnectionType;
};
export type GetConnectionsQuery = ClientIDQuery | TenantQuery;
export type DelConnectionsQuery = (ClientIDQuery & { clientSecret: string }) | TenantQuery;
@ -46,22 +83,34 @@ export type GetConfigQuery = ClientIDQuery | Omit<TenantQuery, 'strategy'>;
export type DelConfigQuery = (ClientIDQuery & { clientSecret: string }) | Omit<TenantQuery, 'strategy'>;
export interface IConnectionAPIController {
config(body: SAMLSSOConnection): Promise<any>;
/**
* @deprecated Use `createSAMLConnection` instead.
*/
config(body: SAMLSSOConnection): Promise<SAMLSSORecord>;
createSAMLConnection(
body: SAMLSSOConnectionWithRawMetadata | SAMLSSOConnectionWithEncodedMetadata
): Promise<any>;
createOIDCConnection(body: OIDCSSOConnection): Promise<any>;
updateConfig(body: SAMLSSOConnection & { clientID: string; clientSecret: string }): Promise<any>;
): Promise<SAMLSSORecord>;
createOIDCConnection(body: OIDCSSOConnection): Promise<OIDCSSORecord>;
/**
* @deprecated Use `updateSAMLConnection` instead.
*/
updateConfig(body: SAMLSSOConnection & { clientID: string; clientSecret: string }): Promise<void>;
updateSAMLConnection(
body: (SAMLSSOConnectionWithRawMetadata | SAMLSSOConnectionWithEncodedMetadata) & {
clientID: string;
clientSecret: string;
}
): Promise<any>;
updateOIDCConnection(body: OIDCSSOConnection & { clientID: string; clientSecret: string }): Promise<any>;
getConnections(body: GetConnectionsQuery): Promise<Array<any>>;
getConfig(body: GetConfigQuery): Promise<any>;
): Promise<void>;
updateOIDCConnection(body: OIDCSSOConnection & { clientID: string; clientSecret: string }): Promise<void>;
getConnections(body: GetConnectionsQuery): Promise<Array<SAMLSSORecord | OIDCSSORecord>>;
/**
* @deprecated Use `getConnections` instead.
*/
getConfig(body: GetConfigQuery): Promise<SAMLSSORecord | Record<string, never>>;
deleteConnections(body: DelConnectionsQuery): Promise<void>;
/**
* @deprecated Use `deleteConnections` instead.
*/
deleteConfig(body: DelConfigQuery): Promise<void>;
}
@ -123,11 +172,13 @@ export interface OAuthReqBody {
export interface OAuthReqBodyWithClientId extends OAuthReqBody {
client_id: string;
}
export interface OAuthReqBodyWithTenantProduct extends OAuthReqBody {
client_id: 'dummy';
tenant: string;
product: string;
}
export interface OAuthReqBodyWithAccessType extends OAuthReqBody {
client_id: 'dummy';
access_type: string;
@ -171,6 +222,7 @@ interface OAuthTokenReqBody {
grant_type: 'authorization_code';
redirect_uri: string;
}
export interface OAuthTokenReqWithCodeVerifier extends OAuthTokenReqBody {
code_verifier: string;
client_id?: never;
@ -252,7 +304,7 @@ export interface DatabaseOption {
export interface JacksonOption {
externalUrl: string;
samlPath: string;
oidcPath: string;
oidcPath?: string;
samlAudience?: string;
preLoadedConfig?: string;
preLoadedConnection?: string;
@ -261,7 +313,7 @@ export interface JacksonOption {
clientSecretVerifier?: string;
idpDiscoveryPath?: string;
scimPath?: string;
openid: {
openid?: {
jwsAlg?: string;
jwtSigningKeys?: {
private: string;

View File

@ -0,0 +1,9 @@
// Without Entity ID in XML
module.exports = {
defaultRedirectUrl: 'http://localhost:3366/sso/oauth/completed',
redirectUrl: '["http://localhost:3366"]',
tenant: 'boxyhqnoentityID.com',
product: 'crm',
name: 'testConfig',
description: 'Just a test configuration',
};

View File

@ -0,0 +1,33 @@
<!-- Without POST/Redirect bindings -->
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" validUntil="2026-06-22T18:39:53.000Z">
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>
MIIDdDCCAlygAwIBAgIGAXo6K+u/MA0GCSqGSIb3DQEBCwUAMHsxFDASBgNVBAoTC0dvb2dsZSBJ
bmMuMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MQ8wDQYDVQQDEwZHb29nbGUxGDAWBgNVBAsTD0dv
b2dsZSBGb3IgV29yazELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWEwHhcNMjEwNjIz
MTgzOTUzWhcNMjYwNjIyMTgzOTUzWjB7MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEWMBQGA1UEBxMN
TW91bnRhaW4gVmlldzEPMA0GA1UEAxMGR29vZ2xlMRgwFgYDVQQLEw9Hb29nbGUgRm9yIFdvcmsx
CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEA4qZcxwPiVka9GzGdQ9LVlgVkn3A7O3HtxR6RIm5AMaL4YZziEHt2HgxLdJZyXYJw
yfT1KB2IHt+XDQBkgEpQVXuuwSPI8vhI8Jr+nr8zia3MMoy9vJF8ZG7HuWeakaKEh7tJqjYu1Cl9
a81rkYdXAFUA+gl2q+stvK26xylAUwptCJSQo0NanWzCq+k5zvX0uLmh58+W5Yv11hDTtAoW+1dH
LWUTHXPfoZINPRy5NGKJ2Onq5/D5XJRimNnUa2iYi0Yv9txp1RRq4dpB9MaVttt3iKyDo4/+8fg/
bL8BLhguiOeqcP4DEIzMuExi3bZAOu2NC7k7Qf28nA81LzP9DQIDAQABMA0GCSqGSIb3DQEBCwUA
A4IBAQARBNB3+MfmKr5WXNXXE9YwUzUGmpfbqUPXh2y2dOAkj6TzoekAsOLWB0p8oyJ5d1bFlTsx
i1OY9RuFl0tc35Jbo+ae5GfUvJmbnYGi9z8sBL55HY6x3KQNmM/ehof7ttZwvB6nwuRxAiGYG497
3tSzrqMQzEskcgX1mlCW0vks/ztCaayprDXcCUxWdP9FaiSZDEXV6PHhFZgGlRNvERsgaMDJgOsq
v6hLX10Q9CtOWzqu18PI4DcfoZ7exWcC29yWvwZzDTfHGaSG1DtUFLwiQmhVUbfd7/fmLV+/iOxV
zI0b5xSYZOJ7Kena7gd5zGVrc2ygKAFKiffiI5GLmLkv
</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
<NameIDFormat>
urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
</NameIDFormat>
</IDPSSODescriptor>
</EntityDescriptor>

View File

@ -66,8 +66,6 @@ tap.test('LogoutController -> createRequest', async (t) => {
t.equal(err.message, 'SAML connection not found.');
t.equal(err.statusCode, 403);
}
t.end();
});
t.test("Should throw an error if metadata doesn't present SingleLogoutService URL", async (t) => {
@ -79,8 +77,6 @@ tap.test('LogoutController -> createRequest', async (t) => {
t.equal(err.message, `accounts.google.com doesn't support SLO or disabled by IdP.`);
t.equal(err.statusCode, 400);
}
t.end();
});
t.test('Should return logoutUrl and logoutForm for a valid logout request', async (t) => {
@ -97,11 +93,7 @@ tap.test('LogoutController -> createRequest', async (t) => {
t.ok(params.has('SAMLRequest'));
t.ok(params.has('RelayState'));
t.end();
});
t.end();
});
t.test('handleResponse', async (t) => {
@ -136,8 +128,6 @@ tap.test('LogoutController -> createRequest', async (t) => {
t.equal(err.message, 'Unable to validate state from the origin request.');
t.equal(err.statusCode, 403);
}
t.end();
});
t.test('Should throw an error is logout request not success', async (t) => {
@ -150,8 +140,6 @@ tap.test('LogoutController -> createRequest', async (t) => {
t.equal(err.message, 'SLO failed with status urn:oasis:names:tc:SAML:2.0:status:AuthnFailed.');
t.equal(err.statusCode, 400);
}
t.end();
});
t.test('Should throw an error when request ID mismatch', async (t) => {
@ -168,8 +156,6 @@ tap.test('LogoutController -> createRequest', async (t) => {
t.equal(err.message, 'SLO failed with mismatched request ID.');
t.equal(err.statusCode, 400);
}
t.end();
});
t.test('Return the redirectUrl after the post logout', async (t) => {
@ -180,12 +166,6 @@ tap.test('LogoutController -> createRequest', async (t) => {
t.ok('redirectUrl' in result);
t.match(result.redirectUrl, saml_connection.defaultRedirectUrl);
t.end();
});
t.end();
});
t.end();
});

View File

@ -2,7 +2,7 @@ import sinon from 'sinon';
import tap from 'tap';
import * as dbutils from '../../src/db/utils';
import controllers from '../../src/index';
import { IConnectionAPIController, OIDCSSOConnection } from '../../src/typings';
import { IConnectionAPIController, OIDCSSOConnection, OIDCSSORecord } from '../../src/typings';
import { oidc_connection } from './fixture';
import { databaseOptions } from '../utils';
@ -30,6 +30,23 @@ tap.test('controller/api', async (t) => {
});
t.test('Create the connection', async (t) => {
t.test('should throw when `oidcPath` is not set', async (t) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
connectionAPIController.opts.oidcPath = undefined;
const body: OIDCSSOConnection = Object.assign({}, oidc_connection);
try {
await connectionAPIController.createOIDCConnection(body);
t.fail('Expecting JacksonError.');
} catch (err: any) {
t.equal(err.message, 'Please set OpenID response handler path (oidcPath) on Jackson');
t.equal(err.statusCode, 500);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
connectionAPIController.opts.oidcPath = databaseOptions.oidcPath;
});
t.test('when required fields are missing or invalid', async (t) => {
t.test('missing discoveryUrl', async (t) => {
const body: OIDCSSOConnection = Object.assign({}, oidc_connection);
@ -179,7 +196,7 @@ tap.test('controller/api', async (t) => {
await connectionAPIController.getConnections({
clientID: CLIENT_ID_OIDC,
})
)[0];
)[0] as OIDCSSORecord;
t.equal(savedConnection.name, oidc_connection.name);
t.equal(savedConnection.oidcProvider.clientId, oidc_connection.oidcClientId);
@ -191,6 +208,34 @@ tap.test('controller/api', async (t) => {
t.test('Update the connection', async (t) => {
const body_oidc_provider: OIDCSSOConnection = Object.assign({}, oidc_connection);
t.test('should throw when `oidcPath` is not set', async (t) => {
const { clientSecret, clientID } = await connectionAPIController.createOIDCConnection(
body_oidc_provider as OIDCSSOConnection
);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
connectionAPIController.opts.oidcPath = undefined;
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await connectionAPIController.updateOIDCConnection({
clientID,
clientSecret,
defaultRedirectUrl: oidc_connection.defaultRedirectUrl,
redirectUrl: oidc_connection.redirectUrl,
tenant: oidc_connection.tenant,
product: oidc_connection.product,
});
t.fail('Expecting JacksonError.');
} catch (err: any) {
t.equal(err.message, 'Please set OpenID response handler path (oidcPath) on Jackson');
t.equal(err.statusCode, 500);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
connectionAPIController.opts.oidcPath = databaseOptions.oidcPath;
});
t.test('When clientID is missing', async (t) => {
const { clientSecret } = await connectionAPIController.createOIDCConnection(
body_oidc_provider as OIDCSSOConnection
@ -211,7 +256,6 @@ tap.test('controller/api', async (t) => {
} catch (err: any) {
t.equal(err.message, 'Please provide clientID');
t.equal(err.statusCode, 400);
t.end();
}
});
@ -235,7 +279,6 @@ tap.test('controller/api', async (t) => {
} catch (err: any) {
t.equal(err.message, 'Please provide clientSecret');
t.equal(err.statusCode, 400);
t.end();
}
});

View File

@ -43,7 +43,32 @@ tap.test('[OIDCProvider]', async (t) => {
t.match(params.get('code_challenge'), codeChallenge, 'codeChallenge present');
stubCodeVerifier.restore();
context.state = params.get('state');
t.end();
});
t.test('[authorize] Should return error if `oidcPath` is not set', async (t) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
oauthController.opts.oidcPath = undefined;
const response = (await oauthController.authorize(<OAuthReq>authz_request_oidc_provider)) as {
redirect_url: string;
};
const response_params = new URLSearchParams(new URL(response.redirect_url!).search);
t.match(response_params.get('error'), 'server_error', 'got server_error when `oidcPath` is not set');
t.match(
response_params.get('error_description'),
'OpenID response handler path (oidcPath) is not set',
'matched error_description when `oidcPath` is not set'
);
t.match(
response_params.get('state'),
authz_request_oidc_provider.state,
'state present in error response'
);
// Restore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
oauthController.opts.oidcPath = databaseOptions.oidcPath;
});
t.test('[oidcAuthzResponse] Should throw an error if `state` is missing', async (t) => {
@ -56,37 +81,29 @@ tap.test('[OIDCProvider]', async (t) => {
t.equal(message, 'State from original request is missing.', 'got expected error message');
t.equal(statusCode, 403, 'got expected status code');
}
t.end();
});
t.test('[oidcAuthzResponse] Should throw an error if `code` is missing', async (t) => {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const { redirect_url } = await oauthController.oidcAuthzResponse({ state: context.state });
const response_params = new URLSearchParams(new URL(redirect_url!).search);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const { redirect_url } = await oauthController.oidcAuthzResponse({ state: context.state });
const response_params = new URLSearchParams(new URL(redirect_url!).search);
t.match(
response_params.get('error'),
'server_error',
'got server_error when unable to retrieve code from provider'
);
t.match(
response_params.get('error_description'),
'Authorization code could not be retrieved from OIDC Provider',
'matched error_description when unable to retrieve code from provider'
);
t.match(
response_params.get('state'),
authz_request_oidc_provider.state,
'state present in error response'
);
} catch (err) {
const { message, statusCode } = err as JacksonError;
t.equal(message, 'Code is missing in AuthzResponse from IdP', 'got expected error message');
t.equal(statusCode, 403, 'got expected status code');
}
t.end();
t.match(
response_params.get('error'),
'server_error',
'got server_error when unable to retrieve code from provider'
);
t.match(
response_params.get('error_description'),
'Authorization code could not be retrieved from OIDC Provider',
'matched error_description when unable to retrieve code from provider'
);
t.match(
response_params.get('state'),
authz_request_oidc_provider.state,
'state present in error response'
);
});
t.test('[oidcAuthzResponse] Should throw an error if `state` is invalid', async (t) => {
@ -97,7 +114,6 @@ tap.test('[OIDCProvider]', async (t) => {
t.equal(message, 'Unable to validate state from the original request.', 'got expected error message');
t.equal(statusCode, 403, 'got expected status code');
}
t.end();
});
t.test('[oidcAuthzResponse] Should forward any provider errors to redirect_uri', async (t) => {
@ -118,7 +134,6 @@ tap.test('[OIDCProvider]', async (t) => {
authz_request_oidc_provider.state,
'state present in error response'
);
t.end();
});
t.test(
@ -170,9 +185,6 @@ tap.test('[OIDCProvider]', async (t) => {
t.ok(response_params.has('code'), 'redirect_url has code');
t.match(response_params.get('state'), authz_request_oidc_provider.state);
sinon.restore();
t.end();
}
);
t.end();
});

View File

@ -1,4 +1,5 @@
import * as path from 'path';
import * as fs from 'fs';
import sinon from 'sinon';
import tap from 'tap';
import * as dbutils from '../../src/db/utils';
@ -8,9 +9,11 @@ import {
IConnectionAPIController,
SAMLSSOConnection,
SAMLSSOConnectionWithEncodedMetadata,
SAMLSSORecord,
} from '../../src/typings';
import { saml_connection } from './fixture';
import { databaseOptions } from '../utils';
import boxyhqNoentityID from './data/metadata/noentityID/boxyhq-noentityID';
let connectionAPIController: IConnectionAPIController;
@ -165,6 +168,25 @@ tap.test('controller/api', async (t) => {
});
});
t.test('When metadata XML is malformed', async (t) => {
t.test('entityID missing in XML', async (t) => {
const body = Object.assign({}, boxyhqNoentityID);
const metadataPath = path.join(__dirname, '/data/metadata/noentityID');
const files = await fs.promises.readdir(metadataPath);
const rawMetadataFile = files.filter((f) => f.endsWith('.xml'))?.[0];
const rawMetadata = await fs.promises.readFile(path.join(metadataPath, rawMetadataFile), 'utf8');
body.encodedRawMetadata = Buffer.from(rawMetadata, 'utf8').toString('base64');
try {
await connectionAPIController.createSAMLConnection(body as SAMLSSOConnectionWithEncodedMetadata);
t.fail('Expecting JacksonError.');
} catch (err: any) {
t.equal(err.message, "Couldn't parse EntityID from SAML metadata");
t.equal(err.statusCode, 400);
}
});
});
t.test('when the request is good', async (t) => {
const body = Object.assign({}, saml_connection);
@ -182,7 +204,7 @@ tap.test('controller/api', async (t) => {
await connectionAPIController.getConnections({
clientID: CLIENT_ID_SAML,
})
)[0];
)[0] as SAMLSSORecord;
t.equal(savedConnection.name, 'testConfig');
t.equal(savedConnection.forceAuthn, false);
@ -207,7 +229,7 @@ tap.test('controller/api', async (t) => {
await connectionAPIController.getConnections({
clientID: CLIENT_ID_SAML,
})
)[0];
)[0] as SAMLSSORecord;
t.equal(savedConnection.forceAuthn, true);
@ -237,7 +259,6 @@ tap.test('controller/api', async (t) => {
} catch (err: any) {
t.equal(err.message, 'Please provide clientID');
t.equal(err.statusCode, 400);
t.end();
}
});
@ -261,7 +282,6 @@ tap.test('controller/api', async (t) => {
} catch (err: any) {
t.equal(err.message, 'Please provide clientSecret');
t.equal(err.statusCode, 400);
t.end();
}
});
@ -288,6 +308,35 @@ tap.test('controller/api', async (t) => {
t.equal(savedConnection.name, 'A new name');
t.equal(savedConnection.description, 'A new description');
});
t.test('When metadata XML is malformed', async (t) => {
t.test('entityID missing in XML', async (t) => {
const { clientID, clientSecret } = await connectionAPIController.createSAMLConnection(
body_saml_provider as SAMLSSOConnectionWithEncodedMetadata
);
const metadataPath = path.join(__dirname, '/data/metadata/noentityID');
const files = await fs.promises.readdir(metadataPath);
const rawMetadataFile = files.filter((f) => f.endsWith('.xml'))?.[0];
const rawMetadata = await fs.promises.readFile(path.join(metadataPath, rawMetadataFile), 'utf8');
const encodedRawMetadata = Buffer.from(rawMetadata, 'utf8').toString('base64');
try {
await connectionAPIController.updateSAMLConnection({
clientID,
clientSecret,
tenant: body_saml_provider.tenant,
product: body_saml_provider.product,
redirectUrl: saml_connection.redirectUrl,
defaultRedirectUrl: saml_connection.defaultRedirectUrl,
encodedRawMetadata,
});
t.fail('Expecting JacksonError.');
} catch (err: any) {
t.equal(err.message, "Couldn't parse EntityID from SAML metadata");
t.equal(err.statusCode, 400);
}
});
});
});
t.test('Get the connection', async (t) => {

View File

@ -92,8 +92,6 @@ tap.test('authorize()', async (t) => {
t.equal(message, 'Please specify a redirect URL.', 'got expected error message');
t.equal(statusCode, 400, 'got expected status code');
}
t.end();
});
t.test('Should return OAuth Error response if `state` is not set', async (t) => {
@ -108,8 +106,6 @@ tap.test('authorize()', async (t) => {
`${body.redirect_uri}?error=invalid_request&error_description=Please+specify+a+state+to+safeguard+against+XSRF+attacks`,
'got OAuth error'
);
t.end();
});
t.test('Should return OAuth Error response if `response_type` is not `code`', async (t) => {
@ -124,8 +120,6 @@ tap.test('authorize()', async (t) => {
`${body.redirect_uri}?error=unsupported_response_type&error_description=Only+Authorization+Code+grant+is+supported&state=${body.state}`,
'got OAuth error'
);
t.end();
});
t.test('Should return OAuth Error response if saml binding could not be retrieved', async (t) => {
@ -140,8 +134,6 @@ tap.test('authorize()', async (t) => {
`${body.redirect_uri}?error=invalid_request&error_description=SAML+binding+could+not+be+retrieved&state=${body.state}`,
'got OAuth error'
);
t.end();
});
t.test('Should return OAuth Error response if request creation fails', async (t) => {
@ -156,7 +148,6 @@ tap.test('authorize()', async (t) => {
'got OAuth error'
);
stubSamlRequest.restore();
t.end();
});
t.test('Should throw an error if `client_id` is invalid', async (t) => {
@ -171,8 +162,6 @@ tap.test('authorize()', async (t) => {
t.equal(message, 'IdP connection not found.', 'got expected error message');
t.equal(statusCode, 403, 'got expected status code');
}
t.end();
});
t.test('Should throw an error if `redirect_uri` is not allowed', async (t) => {
@ -187,8 +176,6 @@ tap.test('authorize()', async (t) => {
t.equal(message, 'Redirect URL is not allowed.', 'got expected error message');
t.equal(statusCode, 403, 'got expected status code');
}
t.end();
});
t.test('Should return the Idp SSO URL', async (t) => {
@ -203,8 +190,6 @@ tap.test('authorize()', async (t) => {
t.ok('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string');
t.end();
});
t.test('accepts single value in prompt', async (t) => {
@ -216,8 +201,6 @@ tap.test('authorize()', async (t) => {
t.ok('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string');
t.end();
});
t.test('accepts multiple values in prompt', async (t) => {
@ -229,8 +212,6 @@ tap.test('authorize()', async (t) => {
t.ok('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string');
t.end();
});
t.test('accepts access_type', async (t) => {
@ -244,8 +225,6 @@ tap.test('authorize()', async (t) => {
t.ok('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string');
t.end();
});
t.test('accepts resource', async (t) => {
@ -259,8 +238,6 @@ tap.test('authorize()', async (t) => {
t.ok('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string');
t.end();
});
t.test('accepts scope', async (t) => {
@ -274,12 +251,8 @@ tap.test('authorize()', async (t) => {
t.ok('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string');
t.end();
});
});
t.end();
});
tap.test('samlResponse()', async (t) => {
@ -312,8 +285,6 @@ tap.test('samlResponse()', async (t) => {
t.equal(statusCode, 403, 'got expected status code');
}
t.end();
});
t.test('Should return OAuth Error response if response validation fails', async (t) => {
@ -331,8 +302,6 @@ tap.test('samlResponse()', async (t) => {
t.match(params.get('error_description'), 'Internal error: Fatal');
stubValidate.restore();
t.end();
});
t.test('Should return a URL with code and state as query params', async (t) => {
@ -362,14 +331,10 @@ tap.test('samlResponse()', async (t) => {
stubRandomBytes.restore();
stubValidate.restore();
t.end();
});
t.end();
});
tap.test('token()', (t) => {
tap.test('token()', async (t) => {
t.test('Should throw an error if `grant_type` is not `authorization_code`', async (t) => {
const body = {
grant_type: 'authorization_code_1',
@ -384,8 +349,6 @@ tap.test('token()', (t) => {
t.equal(message, 'Unsupported grant_type', 'got expected error message');
t.equal(statusCode, 400, 'got expected status code');
}
t.end();
});
t.test('Should throw an error if `tenant` is invalid', async (t) => {
@ -400,8 +363,6 @@ tap.test('token()', (t) => {
t.equal(message, 'Invalid tenant or product');
t.equal(statusCode, 401, 'got expected status code');
}
t.end();
});
t.test('Should throw an error if `product` is invalid', async (t) => {
@ -416,8 +377,6 @@ tap.test('token()', (t) => {
t.equal(message, 'Invalid tenant or product');
t.equal(statusCode, 401, 'got expected status code');
}
t.end();
});
t.test('Should throw an error if `tenant` and `product` is invalid', async (t) => {
@ -432,8 +391,6 @@ tap.test('token()', (t) => {
t.equal(message, 'Invalid tenant or product');
t.equal(statusCode, 401, 'got expected status code');
}
t.end();
});
t.test('Should throw an error if `code` is missing', async (t) => {
@ -450,8 +407,6 @@ tap.test('token()', (t) => {
t.equal(message, 'Please specify code', 'got expected error message');
t.equal(statusCode, 400, 'got expected status code');
}
t.end();
});
t.test('Should throw an error if `code` or `client_secret` is invalid', async (t) => {
@ -516,8 +471,6 @@ tap.test('token()', (t) => {
t.equal(message, 'Invalid client_secret', 'got expected error message');
t.equal(statusCode, 401, 'got expected status code');
}
t.end();
});
t.test(
@ -544,8 +497,6 @@ tap.test('token()', (t) => {
t.match(response.expires_in, 300);
stubRandomBytes.restore();
t.end();
});
t.test('unencoded client_id', async (t) => {
@ -599,8 +550,6 @@ tap.test('token()', (t) => {
stubRandomBytes.restore();
stubValidate.restore();
t.end();
});
t.test('openid flow', async (t) => {
@ -643,7 +592,7 @@ tap.test('token()', (t) => {
if (tokenRes.id_token) {
const claims = jose.decodeJwt(tokenRes.id_token);
const { protectedHeader } = await jose.jwtVerify(tokenRes.id_token, keyPair.publicKey);
t.match(protectedHeader.alg, databaseOptions.openid.jwsAlg);
t.match(protectedHeader.alg, databaseOptions.openid?.jwsAlg);
t.match(claims.aud, authz_request_normal_oidc_flow.client_id);
t.match(claims.iss, databaseOptions.samlAudience);
}
@ -668,8 +617,6 @@ tap.test('token()', (t) => {
stubRandomBytes.restore();
stubValidate.restore();
stubLoadJWSPrivateKey.restore();
t.end();
});
t.test('PKCE check', async (t) => {
@ -733,13 +680,9 @@ tap.test('token()', (t) => {
stubRandomBytes.restore();
stubValidate.restore();
t.end();
});
}
);
t.end();
});
tap.test('IdP initiated flow should return token and profile', async (t) => {
@ -774,5 +717,4 @@ tap.test('IdP initiated flow should return token and profile', async (t) => {
t.equal(profile.id, 'id');
stubRandomBytes.restore();
stubValidate.restore();
t.end();
});

4104
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,7 +1,7 @@
{
"info": {
"title": "SAML Jackson API",
"version": "1.3.0",
"version": "1.3.1",
"description": "This is the API documentation for SAML Jackson service.",
"termsOfService": "https://boxyhq.com/terms.html",
"contact": {
@ -87,6 +87,9 @@
},
"401": {
"description": "Unauthorized"
},
"500": {
"description": "Please set OpenID response handler path (oidcPath) on Jackson"
}
}
},
@ -366,6 +369,9 @@
},
"401": {
"description": "Unauthorized"
},
"500": {
"description": "Please set OpenID response handler path (oidcPath) on Jackson"
}
}
},
@ -380,6 +386,9 @@
},
{
"$ref": "#/parameters/clientIDParamGet"
},
{
"$ref": "#/parameters/strategyParamGet"
}
],
"operationId": "get-connections",
@ -835,6 +844,12 @@
"type": "string",
"description": "Client ID"
},
"strategyParamGet": {
"in": "query",
"name": "strategy",
"type": "string",
"description": "Strategy which can help to filter connections with tenant/product query"
},
"clientIDDel": {
"name": "clientID",
"in": "formData",
@ -863,7 +878,7 @@
"name": "strategy",
"in": "formData",
"type": "string",
"description": "Strategy"
"description": "Strategy which can help to filter connections with tenant/product query"
}
},
"tags": []