mirror of https://github.com/boxyhq/jackson.git
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:
parent
ddc0c511a8
commit
2e5da524cf
|
@ -9,5 +9,4 @@ README.md
|
|||
_dev
|
||||
.vscode
|
||||
swagger
|
||||
uffizzi
|
||||
.husky
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "jackson",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"description": "SAML 2.0 service",
|
||||
"keywords": [
|
||||
|
|
|
@ -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": []
|
||||
|
|
Loading…
Reference in New Issue