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 _dev
.vscode .vscode
swagger swagger
uffizzi
.husky .husky

View File

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

View File

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

View File

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

View File

@ -288,7 +288,7 @@ export class OAuthController implements IOAuthController {
if ( if (
requestedOIDCFlow && requestedOIDCFlow &&
(!this.opts.openid.jwtSigningKeys || !isJWSKeyPairLoaded(this.opts.openid.jwtSigningKeys)) (!this.opts.openid?.jwtSigningKeys || !isJWSKeyPairLoaded(this.opts.openid.jwtSigningKeys))
) { ) {
return { return {
redirect_url: OAuthErrorResponse({ 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 // OIDC Connection: Issuer discovery, openid-client init and extraction of authorization endpoint happens here
let oidcCodeVerifier: string | undefined; let oidcCodeVerifier: string | undefined;
if (connectionIsOIDC) { 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; const { discoveryUrl, clientId, clientSecret } = connection.oidcProvider;
try { try {
const oidcIssuer = await Issuer.discover(discoveryUrl); const oidcIssuer = await Issuer.discover(discoveryUrl);
@ -955,7 +965,7 @@ export class OAuthController implements IOAuthController {
const requestedOIDCFlow = !!codeVal.requested?.oidc; const requestedOIDCFlow = !!codeVal.requested?.oidc;
const requestHasNonce = !!codeVal.requested?.nonce; const requestHasNonce = !!codeVal.requested?.nonce;
if (requestedOIDCFlow) { if (requestedOIDCFlow) {
const { jwtSigningKeys, jwsAlg } = this.opts.openid; const { jwtSigningKeys, jwsAlg } = this.opts.openid ?? {};
if (!jwtSigningKeys || !isJWSKeyPairLoaded(jwtSigningKeys)) { if (!jwtSigningKeys || !isJWSKeyPairLoaded(jwtSigningKeys)) {
throw new JacksonError('JWT signing keys are not loaded', 500); throw new JacksonError('JWT signing keys are not loaded', 500);
} }

View File

@ -25,7 +25,7 @@ export class OidcDiscoveryController implements IOidcDiscoveryController {
} }
async jwks() { async jwks() {
const { jwtSigningKeys, jwsAlg } = this.opts.openid; const { jwtSigningKeys, jwsAlg } = this.opts.openid ?? {};
if (!jwtSigningKeys || !isJWSKeyPairLoaded(jwtSigningKeys)) { if (!jwtSigningKeys || !isJWSKeyPairLoaded(jwtSigningKeys)) {
throw new JacksonError('JWT signing keys are not loaded', 501); 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'; 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'; 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). // 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 || ''; newOpts.preLoadedConnection = newOpts.preLoadedConnection || '';
@ -72,7 +68,7 @@ export const controllers = async (
const tokenStore = db.store('oauth:token', opts.db.ttl); const tokenStore = db.store('oauth:token', opts.db.ttl);
const healthCheckStore = db.store('_health:check'); const healthCheckStore = db.store('_health:check');
const connectionAPIController = new ConnectionAPIController({ connectionStore }); const connectionAPIController = new ConnectionAPIController({ connectionStore, opts });
const adminController = new AdminController({ connectionStore }); const adminController = new AdminController({ connectionStore });
const healthCheckController = new HealthCheckController({ healthCheckStore }); const healthCheckController = new HealthCheckController({ healthCheckStore });
await healthCheckController.init(); await healthCheckController.init();

View File

@ -29,16 +29,53 @@ export interface OIDCSSOConnection extends SSOConnection {
oidcClientSecret: string; 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'; export type ConnectionType = 'saml' | 'oidc';
type ClientIDQuery = { type ClientIDQuery = {
clientID: string; clientID: string;
}; };
type TenantQuery = { type TenantQuery = {
tenant: string; tenant: string;
product: string; product: string;
strategy?: ConnectionType; strategy?: ConnectionType;
}; };
export type GetConnectionsQuery = ClientIDQuery | TenantQuery; export type GetConnectionsQuery = ClientIDQuery | TenantQuery;
export type DelConnectionsQuery = (ClientIDQuery & { clientSecret: string }) | 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 type DelConfigQuery = (ClientIDQuery & { clientSecret: string }) | Omit<TenantQuery, 'strategy'>;
export interface IConnectionAPIController { export interface IConnectionAPIController {
config(body: SAMLSSOConnection): Promise<any>; /**
* @deprecated Use `createSAMLConnection` instead.
*/
config(body: SAMLSSOConnection): Promise<SAMLSSORecord>;
createSAMLConnection( createSAMLConnection(
body: SAMLSSOConnectionWithRawMetadata | SAMLSSOConnectionWithEncodedMetadata body: SAMLSSOConnectionWithRawMetadata | SAMLSSOConnectionWithEncodedMetadata
): Promise<any>; ): Promise<SAMLSSORecord>;
createOIDCConnection(body: OIDCSSOConnection): Promise<any>; createOIDCConnection(body: OIDCSSOConnection): Promise<OIDCSSORecord>;
updateConfig(body: SAMLSSOConnection & { clientID: string; clientSecret: string }): Promise<any>; /**
* @deprecated Use `updateSAMLConnection` instead.
*/
updateConfig(body: SAMLSSOConnection & { clientID: string; clientSecret: string }): Promise<void>;
updateSAMLConnection( updateSAMLConnection(
body: (SAMLSSOConnectionWithRawMetadata | SAMLSSOConnectionWithEncodedMetadata) & { body: (SAMLSSOConnectionWithRawMetadata | SAMLSSOConnectionWithEncodedMetadata) & {
clientID: string; clientID: string;
clientSecret: string; clientSecret: string;
} }
): Promise<any>; ): Promise<void>;
updateOIDCConnection(body: OIDCSSOConnection & { clientID: string; clientSecret: string }): Promise<any>; updateOIDCConnection(body: OIDCSSOConnection & { clientID: string; clientSecret: string }): Promise<void>;
getConnections(body: GetConnectionsQuery): Promise<Array<any>>; getConnections(body: GetConnectionsQuery): Promise<Array<SAMLSSORecord | OIDCSSORecord>>;
getConfig(body: GetConfigQuery): Promise<any>; /**
* @deprecated Use `getConnections` instead.
*/
getConfig(body: GetConfigQuery): Promise<SAMLSSORecord | Record<string, never>>;
deleteConnections(body: DelConnectionsQuery): Promise<void>; deleteConnections(body: DelConnectionsQuery): Promise<void>;
/**
* @deprecated Use `deleteConnections` instead.
*/
deleteConfig(body: DelConfigQuery): Promise<void>; deleteConfig(body: DelConfigQuery): Promise<void>;
} }
@ -123,11 +172,13 @@ export interface OAuthReqBody {
export interface OAuthReqBodyWithClientId extends OAuthReqBody { export interface OAuthReqBodyWithClientId extends OAuthReqBody {
client_id: string; client_id: string;
} }
export interface OAuthReqBodyWithTenantProduct extends OAuthReqBody { export interface OAuthReqBodyWithTenantProduct extends OAuthReqBody {
client_id: 'dummy'; client_id: 'dummy';
tenant: string; tenant: string;
product: string; product: string;
} }
export interface OAuthReqBodyWithAccessType extends OAuthReqBody { export interface OAuthReqBodyWithAccessType extends OAuthReqBody {
client_id: 'dummy'; client_id: 'dummy';
access_type: string; access_type: string;
@ -171,6 +222,7 @@ interface OAuthTokenReqBody {
grant_type: 'authorization_code'; grant_type: 'authorization_code';
redirect_uri: string; redirect_uri: string;
} }
export interface OAuthTokenReqWithCodeVerifier extends OAuthTokenReqBody { export interface OAuthTokenReqWithCodeVerifier extends OAuthTokenReqBody {
code_verifier: string; code_verifier: string;
client_id?: never; client_id?: never;
@ -252,7 +304,7 @@ export interface DatabaseOption {
export interface JacksonOption { export interface JacksonOption {
externalUrl: string; externalUrl: string;
samlPath: string; samlPath: string;
oidcPath: string; oidcPath?: string;
samlAudience?: string; samlAudience?: string;
preLoadedConfig?: string; preLoadedConfig?: string;
preLoadedConnection?: string; preLoadedConnection?: string;
@ -261,7 +313,7 @@ export interface JacksonOption {
clientSecretVerifier?: string; clientSecretVerifier?: string;
idpDiscoveryPath?: string; idpDiscoveryPath?: string;
scimPath?: string; scimPath?: string;
openid: { openid?: {
jwsAlg?: string; jwsAlg?: string;
jwtSigningKeys?: { jwtSigningKeys?: {
private: string; 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.message, 'SAML connection not found.');
t.equal(err.statusCode, 403); t.equal(err.statusCode, 403);
} }
t.end();
}); });
t.test("Should throw an error if metadata doesn't present SingleLogoutService URL", async (t) => { 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.message, `accounts.google.com doesn't support SLO or disabled by IdP.`);
t.equal(err.statusCode, 400); t.equal(err.statusCode, 400);
} }
t.end();
}); });
t.test('Should return logoutUrl and logoutForm for a valid logout request', async (t) => { 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('SAMLRequest'));
t.ok(params.has('RelayState')); t.ok(params.has('RelayState'));
t.end();
}); });
t.end();
}); });
t.test('handleResponse', async (t) => { 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.message, 'Unable to validate state from the origin request.');
t.equal(err.statusCode, 403); t.equal(err.statusCode, 403);
} }
t.end();
}); });
t.test('Should throw an error is logout request not success', async (t) => { 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.message, 'SLO failed with status urn:oasis:names:tc:SAML:2.0:status:AuthnFailed.');
t.equal(err.statusCode, 400); t.equal(err.statusCode, 400);
} }
t.end();
}); });
t.test('Should throw an error when request ID mismatch', async (t) => { 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.message, 'SLO failed with mismatched request ID.');
t.equal(err.statusCode, 400); t.equal(err.statusCode, 400);
} }
t.end();
}); });
t.test('Return the redirectUrl after the post logout', async (t) => { 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.ok('redirectUrl' in result);
t.match(result.redirectUrl, saml_connection.defaultRedirectUrl); 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 tap from 'tap';
import * as dbutils from '../../src/db/utils'; import * as dbutils from '../../src/db/utils';
import controllers from '../../src/index'; import controllers from '../../src/index';
import { IConnectionAPIController, OIDCSSOConnection } from '../../src/typings'; import { IConnectionAPIController, OIDCSSOConnection, OIDCSSORecord } from '../../src/typings';
import { oidc_connection } from './fixture'; import { oidc_connection } from './fixture';
import { databaseOptions } from '../utils'; import { databaseOptions } from '../utils';
@ -30,6 +30,23 @@ tap.test('controller/api', async (t) => {
}); });
t.test('Create the connection', 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('when required fields are missing or invalid', async (t) => {
t.test('missing discoveryUrl', async (t) => { t.test('missing discoveryUrl', async (t) => {
const body: OIDCSSOConnection = Object.assign({}, oidc_connection); const body: OIDCSSOConnection = Object.assign({}, oidc_connection);
@ -179,7 +196,7 @@ tap.test('controller/api', async (t) => {
await connectionAPIController.getConnections({ await connectionAPIController.getConnections({
clientID: CLIENT_ID_OIDC, clientID: CLIENT_ID_OIDC,
}) })
)[0]; )[0] as OIDCSSORecord;
t.equal(savedConnection.name, oidc_connection.name); t.equal(savedConnection.name, oidc_connection.name);
t.equal(savedConnection.oidcProvider.clientId, oidc_connection.oidcClientId); 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) => { t.test('Update the connection', async (t) => {
const body_oidc_provider: OIDCSSOConnection = Object.assign({}, oidc_connection); 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) => { t.test('When clientID is missing', async (t) => {
const { clientSecret } = await connectionAPIController.createOIDCConnection( const { clientSecret } = await connectionAPIController.createOIDCConnection(
body_oidc_provider as OIDCSSOConnection body_oidc_provider as OIDCSSOConnection
@ -211,7 +256,6 @@ tap.test('controller/api', async (t) => {
} catch (err: any) { } catch (err: any) {
t.equal(err.message, 'Please provide clientID'); t.equal(err.message, 'Please provide clientID');
t.equal(err.statusCode, 400); t.equal(err.statusCode, 400);
t.end();
} }
}); });
@ -235,7 +279,6 @@ tap.test('controller/api', async (t) => {
} catch (err: any) { } catch (err: any) {
t.equal(err.message, 'Please provide clientSecret'); t.equal(err.message, 'Please provide clientSecret');
t.equal(err.statusCode, 400); 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'); t.match(params.get('code_challenge'), codeChallenge, 'codeChallenge present');
stubCodeVerifier.restore(); stubCodeVerifier.restore();
context.state = params.get('state'); 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) => { 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(message, 'State from original request is missing.', 'got expected error message');
t.equal(statusCode, 403, 'got expected status code'); t.equal(statusCode, 403, 'got expected status code');
} }
t.end();
}); });
t.test('[oidcAuthzResponse] Should throw an error if `code` is missing', async (t) => { t.test('[oidcAuthzResponse] Should throw an error if `code` is missing', async (t) => {
try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore
//@ts-ignore const { redirect_url } = await oauthController.oidcAuthzResponse({ state: context.state });
const { redirect_url } = await oauthController.oidcAuthzResponse({ state: context.state }); const response_params = new URLSearchParams(new URL(redirect_url!).search);
const response_params = new URLSearchParams(new URL(redirect_url!).search);
t.match( t.match(
response_params.get('error'), response_params.get('error'),
'server_error', 'server_error',
'got server_error when unable to retrieve code from provider' 'got server_error when unable to retrieve code from provider'
); );
t.match( t.match(
response_params.get('error_description'), response_params.get('error_description'),
'Authorization code could not be retrieved from OIDC Provider', 'Authorization code could not be retrieved from OIDC Provider',
'matched error_description when unable to retrieve code from provider' 'matched error_description when unable to retrieve code from provider'
); );
t.match( t.match(
response_params.get('state'), response_params.get('state'),
authz_request_oidc_provider.state, authz_request_oidc_provider.state,
'state present in error response' '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.test('[oidcAuthzResponse] Should throw an error if `state` is invalid', async (t) => { 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(message, 'Unable to validate state from the original request.', 'got expected error message');
t.equal(statusCode, 403, 'got expected status code'); t.equal(statusCode, 403, 'got expected status code');
} }
t.end();
}); });
t.test('[oidcAuthzResponse] Should forward any provider errors to redirect_uri', async (t) => { 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, authz_request_oidc_provider.state,
'state present in error response' 'state present in error response'
); );
t.end();
}); });
t.test( t.test(
@ -170,9 +185,6 @@ tap.test('[OIDCProvider]', async (t) => {
t.ok(response_params.has('code'), 'redirect_url has code'); t.ok(response_params.has('code'), 'redirect_url has code');
t.match(response_params.get('state'), authz_request_oidc_provider.state); t.match(response_params.get('state'), authz_request_oidc_provider.state);
sinon.restore(); sinon.restore();
t.end();
} }
); );
t.end();
}); });

View File

@ -1,4 +1,5 @@
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs';
import sinon from 'sinon'; import sinon from 'sinon';
import tap from 'tap'; import tap from 'tap';
import * as dbutils from '../../src/db/utils'; import * as dbutils from '../../src/db/utils';
@ -8,9 +9,11 @@ import {
IConnectionAPIController, IConnectionAPIController,
SAMLSSOConnection, SAMLSSOConnection,
SAMLSSOConnectionWithEncodedMetadata, SAMLSSOConnectionWithEncodedMetadata,
SAMLSSORecord,
} from '../../src/typings'; } from '../../src/typings';
import { saml_connection } from './fixture'; import { saml_connection } from './fixture';
import { databaseOptions } from '../utils'; import { databaseOptions } from '../utils';
import boxyhqNoentityID from './data/metadata/noentityID/boxyhq-noentityID';
let connectionAPIController: IConnectionAPIController; 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) => { t.test('when the request is good', async (t) => {
const body = Object.assign({}, saml_connection); const body = Object.assign({}, saml_connection);
@ -182,7 +204,7 @@ tap.test('controller/api', async (t) => {
await connectionAPIController.getConnections({ await connectionAPIController.getConnections({
clientID: CLIENT_ID_SAML, clientID: CLIENT_ID_SAML,
}) })
)[0]; )[0] as SAMLSSORecord;
t.equal(savedConnection.name, 'testConfig'); t.equal(savedConnection.name, 'testConfig');
t.equal(savedConnection.forceAuthn, false); t.equal(savedConnection.forceAuthn, false);
@ -207,7 +229,7 @@ tap.test('controller/api', async (t) => {
await connectionAPIController.getConnections({ await connectionAPIController.getConnections({
clientID: CLIENT_ID_SAML, clientID: CLIENT_ID_SAML,
}) })
)[0]; )[0] as SAMLSSORecord;
t.equal(savedConnection.forceAuthn, true); t.equal(savedConnection.forceAuthn, true);
@ -237,7 +259,6 @@ tap.test('controller/api', async (t) => {
} catch (err: any) { } catch (err: any) {
t.equal(err.message, 'Please provide clientID'); t.equal(err.message, 'Please provide clientID');
t.equal(err.statusCode, 400); t.equal(err.statusCode, 400);
t.end();
} }
}); });
@ -261,7 +282,6 @@ tap.test('controller/api', async (t) => {
} catch (err: any) { } catch (err: any) {
t.equal(err.message, 'Please provide clientSecret'); t.equal(err.message, 'Please provide clientSecret');
t.equal(err.statusCode, 400); 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.name, 'A new name');
t.equal(savedConnection.description, 'A new description'); 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) => { 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(message, 'Please specify a redirect URL.', 'got expected error message');
t.equal(statusCode, 400, 'got expected status code'); t.equal(statusCode, 400, 'got expected status code');
} }
t.end();
}); });
t.test('Should return OAuth Error response if `state` is not set', async (t) => { 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`, `${body.redirect_uri}?error=invalid_request&error_description=Please+specify+a+state+to+safeguard+against+XSRF+attacks`,
'got OAuth error' 'got OAuth error'
); );
t.end();
}); });
t.test('Should return OAuth Error response if `response_type` is not `code`', async (t) => { 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}`, `${body.redirect_uri}?error=unsupported_response_type&error_description=Only+Authorization+Code+grant+is+supported&state=${body.state}`,
'got OAuth error' 'got OAuth error'
); );
t.end();
}); });
t.test('Should return OAuth Error response if saml binding could not be retrieved', async (t) => { 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}`, `${body.redirect_uri}?error=invalid_request&error_description=SAML+binding+could+not+be+retrieved&state=${body.state}`,
'got OAuth error' 'got OAuth error'
); );
t.end();
}); });
t.test('Should return OAuth Error response if request creation fails', async (t) => { 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' 'got OAuth error'
); );
stubSamlRequest.restore(); stubSamlRequest.restore();
t.end();
}); });
t.test('Should throw an error if `client_id` is invalid', async (t) => { 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(message, 'IdP connection not found.', 'got expected error message');
t.equal(statusCode, 403, 'got expected status code'); t.equal(statusCode, 403, 'got expected status code');
} }
t.end();
}); });
t.test('Should throw an error if `redirect_uri` is not allowed', async (t) => { 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(message, 'Redirect URL is not allowed.', 'got expected error message');
t.equal(statusCode, 403, 'got expected status code'); t.equal(statusCode, 403, 'got expected status code');
} }
t.end();
}); });
t.test('Should return the Idp SSO URL', async (t) => { 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('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string'); t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest 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) => { 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('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string'); t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest 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) => { 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('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string'); t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest 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) => { 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('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string'); t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string'); t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string');
t.end();
}); });
t.test('accepts resource', async (t) => { 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('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string'); t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string'); t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string');
t.end();
}); });
t.test('accepts scope', async (t) => { 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('redirect_url' in response, 'got the Idp authorize URL');
t.ok(params.has('RelayState'), 'RelayState present in the query string'); t.ok(params.has('RelayState'), 'RelayState present in the query string');
t.ok(params.has('SAMLRequest'), 'SAMLRequest 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) => { tap.test('samlResponse()', async (t) => {
@ -312,8 +285,6 @@ tap.test('samlResponse()', async (t) => {
t.equal(statusCode, 403, 'got expected status code'); t.equal(statusCode, 403, 'got expected status code');
} }
t.end();
}); });
t.test('Should return OAuth Error response if response validation fails', async (t) => { 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'); t.match(params.get('error_description'), 'Internal error: Fatal');
stubValidate.restore(); stubValidate.restore();
t.end();
}); });
t.test('Should return a URL with code and state as query params', async (t) => { 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(); stubRandomBytes.restore();
stubValidate.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) => { t.test('Should throw an error if `grant_type` is not `authorization_code`', async (t) => {
const body = { const body = {
grant_type: 'authorization_code_1', 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(message, 'Unsupported grant_type', 'got expected error message');
t.equal(statusCode, 400, 'got expected status code'); t.equal(statusCode, 400, 'got expected status code');
} }
t.end();
}); });
t.test('Should throw an error if `tenant` is invalid', async (t) => { 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(message, 'Invalid tenant or product');
t.equal(statusCode, 401, 'got expected status code'); t.equal(statusCode, 401, 'got expected status code');
} }
t.end();
}); });
t.test('Should throw an error if `product` is invalid', async (t) => { 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(message, 'Invalid tenant or product');
t.equal(statusCode, 401, 'got expected status code'); t.equal(statusCode, 401, 'got expected status code');
} }
t.end();
}); });
t.test('Should throw an error if `tenant` and `product` is invalid', async (t) => { 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(message, 'Invalid tenant or product');
t.equal(statusCode, 401, 'got expected status code'); t.equal(statusCode, 401, 'got expected status code');
} }
t.end();
}); });
t.test('Should throw an error if `code` is missing', async (t) => { 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(message, 'Please specify code', 'got expected error message');
t.equal(statusCode, 400, 'got expected status code'); 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) => { 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(message, 'Invalid client_secret', 'got expected error message');
t.equal(statusCode, 401, 'got expected status code'); t.equal(statusCode, 401, 'got expected status code');
} }
t.end();
}); });
t.test( t.test(
@ -544,8 +497,6 @@ tap.test('token()', (t) => {
t.match(response.expires_in, 300); t.match(response.expires_in, 300);
stubRandomBytes.restore(); stubRandomBytes.restore();
t.end();
}); });
t.test('unencoded client_id', async (t) => { t.test('unencoded client_id', async (t) => {
@ -599,8 +550,6 @@ tap.test('token()', (t) => {
stubRandomBytes.restore(); stubRandomBytes.restore();
stubValidate.restore(); stubValidate.restore();
t.end();
}); });
t.test('openid flow', async (t) => { t.test('openid flow', async (t) => {
@ -643,7 +592,7 @@ tap.test('token()', (t) => {
if (tokenRes.id_token) { if (tokenRes.id_token) {
const claims = jose.decodeJwt(tokenRes.id_token); const claims = jose.decodeJwt(tokenRes.id_token);
const { protectedHeader } = await jose.jwtVerify(tokenRes.id_token, keyPair.publicKey); 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.aud, authz_request_normal_oidc_flow.client_id);
t.match(claims.iss, databaseOptions.samlAudience); t.match(claims.iss, databaseOptions.samlAudience);
} }
@ -668,8 +617,6 @@ tap.test('token()', (t) => {
stubRandomBytes.restore(); stubRandomBytes.restore();
stubValidate.restore(); stubValidate.restore();
stubLoadJWSPrivateKey.restore(); stubLoadJWSPrivateKey.restore();
t.end();
}); });
t.test('PKCE check', async (t) => { t.test('PKCE check', async (t) => {
@ -733,13 +680,9 @@ tap.test('token()', (t) => {
stubRandomBytes.restore(); stubRandomBytes.restore();
stubValidate.restore(); stubValidate.restore();
t.end();
}); });
} }
); );
t.end();
}); });
tap.test('IdP initiated flow should return token and profile', async (t) => { 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'); t.equal(profile.id, 'id');
stubRandomBytes.restore(); stubRandomBytes.restore();
stubValidate.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", "name": "jackson",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "SAML 2.0 service", "description": "SAML 2.0 service",
"keywords": [ "keywords": [

View File

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