@boxyhq/saml-jackson 0.5.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ npm i @boxyhq/saml-jackson
14
14
 
15
15
  ## Documentation
16
16
 
17
- For full documentation, visit [boxyhq.com/docs/jackson/npm-library](https://boxyhq.com/docs/jackson/npm-library)
17
+ For full documentation, visit [boxyhq.com/docs/jackson/deploy/npm-library](https://boxyhq.com/docs/jackson/deploy/npm-library)
18
18
 
19
19
  ## License
20
20
 
@@ -4,6 +4,7 @@ export declare class APIController implements IAPIController {
4
4
  constructor({ configStore }: {
5
5
  configStore: any;
6
6
  });
7
+ private _validateRedirectUrl;
7
8
  private _validateIdPConfig;
8
9
  /**
9
10
  * @swagger
@@ -50,7 +50,7 @@ exports.APIController = void 0;
50
50
  const crypto_1 = __importDefault(require("crypto"));
51
51
  const dbutils = __importStar(require("../db/utils"));
52
52
  const metrics = __importStar(require("../opentelemetry/metrics"));
53
- const saml_1 = __importDefault(require("../saml/saml"));
53
+ const saml20_1 = __importDefault(require("@boxyhq/saml20"));
54
54
  const x509_1 = __importDefault(require("../saml/x509"));
55
55
  const error_1 = require("./error");
56
56
  const utils_1 = require("./utils");
@@ -58,6 +58,19 @@ class APIController {
58
58
  constructor({ configStore }) {
59
59
  this.configStore = configStore;
60
60
  }
61
+ _validateRedirectUrl({ redirectUrlList, defaultRedirectUrl }) {
62
+ if (redirectUrlList) {
63
+ if (redirectUrlList.length > 100) {
64
+ throw new error_1.JacksonError('Exceeded maximum number of allowed redirect urls', 400);
65
+ }
66
+ for (const url of redirectUrlList) {
67
+ (0, utils_1.validateAbsoluteUrl)(url, 'redirectUrl is invalid');
68
+ }
69
+ }
70
+ if (defaultRedirectUrl) {
71
+ (0, utils_1.validateAbsoluteUrl)(defaultRedirectUrl, 'defaultRedirectUrl is invalid');
72
+ }
73
+ }
61
74
  _validateIdPConfig(body) {
62
75
  const { encodedRawMetadata, rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product, description } = body;
63
76
  if (!rawMetadata && !encodedRawMetadata) {
@@ -168,11 +181,13 @@ class APIController {
168
181
  const { encodedRawMetadata, rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product, name, description, } = body;
169
182
  metrics.increment('createConfig');
170
183
  this._validateIdPConfig(body);
184
+ const redirectUrlList = extractRedirectUrls(redirectUrl);
185
+ this._validateRedirectUrl({ defaultRedirectUrl, redirectUrlList });
171
186
  let metaData = rawMetadata;
172
187
  if (encodedRawMetadata) {
173
188
  metaData = Buffer.from(encodedRawMetadata, 'base64').toString();
174
189
  }
175
- const idpMetadata = yield saml_1.default.parseMetadataAsync(metaData);
190
+ const idpMetadata = yield saml20_1.default.parseMetadataAsync(metaData);
176
191
  // extract provider
177
192
  let providerName = extractHostName(idpMetadata.entityID);
178
193
  if (!providerName) {
@@ -195,7 +210,7 @@ class APIController {
195
210
  const record = {
196
211
  idpMetadata,
197
212
  defaultRedirectUrl,
198
- redirectUrl: JSON.parse(redirectUrl),
213
+ redirectUrl: redirectUrlList,
199
214
  tenant,
200
215
  product,
201
216
  name,
@@ -296,6 +311,8 @@ class APIController {
296
311
  if (description && description.length > 100) {
297
312
  throw new error_1.JacksonError('Description should not exceed 100 characters', 400);
298
313
  }
314
+ const redirectUrlList = redirectUrl ? extractRedirectUrls(redirectUrl) : null;
315
+ this._validateRedirectUrl({ defaultRedirectUrl, redirectUrlList });
299
316
  const _currentConfig = yield this.getConfig(clientInfo);
300
317
  if (_currentConfig.clientSecret !== (clientInfo === null || clientInfo === void 0 ? void 0 : clientInfo.clientSecret)) {
301
318
  throw new error_1.JacksonError('clientSecret mismatch', 400);
@@ -306,7 +323,7 @@ class APIController {
306
323
  }
307
324
  let newMetadata;
308
325
  if (metaData) {
309
- newMetadata = yield saml_1.default.parseMetadataAsync(metaData);
326
+ newMetadata = yield saml20_1.default.parseMetadataAsync(metaData);
310
327
  // extract provider
311
328
  let providerName = extractHostName(newMetadata.entityID);
312
329
  if (!providerName) {
@@ -321,7 +338,7 @@ class APIController {
321
338
  throw new error_1.JacksonError('Tenant/Product config mismatch with IdP metadata', 400);
322
339
  }
323
340
  }
324
- const record = Object.assign(Object.assign({}, _currentConfig), { name: name ? name : _currentConfig.name, description: description ? description : _currentConfig.description, idpMetadata: newMetadata ? newMetadata : _currentConfig.idpMetadata, defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _currentConfig.defaultRedirectUrl, redirectUrl: redirectUrl ? JSON.parse(redirectUrl) : _currentConfig.redirectUrl });
341
+ const record = Object.assign(Object.assign({}, _currentConfig), { name: name ? name : _currentConfig.name, description: description ? description : _currentConfig.description, idpMetadata: newMetadata ? newMetadata : _currentConfig.idpMetadata, defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _currentConfig.defaultRedirectUrl, redirectUrl: redirectUrlList ? redirectUrlList : _currentConfig.redirectUrl });
325
342
  yield this.configStore.put(clientInfo === null || clientInfo === void 0 ? void 0 : clientInfo.clientID, record, {
326
343
  // secondary index on entityID
327
344
  name: utils_1.IndexNames.EntityID,
@@ -495,3 +512,18 @@ const extractHostName = (url) => {
495
512
  return null;
496
513
  }
497
514
  };
515
+ const extractRedirectUrls = (urls) => {
516
+ if (!urls) {
517
+ return [];
518
+ }
519
+ if (typeof urls === 'string') {
520
+ if (urls.startsWith('[')) {
521
+ // redirectUrl is a stringified array
522
+ return JSON.parse(urls);
523
+ }
524
+ // redirectUrl is a single URL
525
+ return [urls];
526
+ }
527
+ // redirectUrl is an array of URLs
528
+ return urls;
529
+ };
@@ -0,0 +1,11 @@
1
+ import { IHealthCheckController, Storable } from '../typings';
2
+ export declare class HealthCheckController implements IHealthCheckController {
3
+ healthCheckStore: Storable;
4
+ constructor({ healthCheckStore }: {
5
+ healthCheckStore: any;
6
+ });
7
+ init(): Promise<void>;
8
+ status(): Promise<{
9
+ status: number;
10
+ }>;
11
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.HealthCheckController = void 0;
13
+ const error_1 = require("./error");
14
+ const healthKey = 'amihealthy';
15
+ const healthValue = 'fit';
16
+ const g = global;
17
+ class HealthCheckController {
18
+ constructor({ healthCheckStore }) {
19
+ this.healthCheckStore = healthCheckStore;
20
+ }
21
+ init() {
22
+ return __awaiter(this, void 0, void 0, function* () {
23
+ this.healthCheckStore.put(healthKey, healthValue);
24
+ });
25
+ }
26
+ status() {
27
+ return __awaiter(this, void 0, void 0, function* () {
28
+ try {
29
+ if (!g.isJacksonReady) {
30
+ return {
31
+ status: 503,
32
+ };
33
+ }
34
+ const response = yield Promise.race([
35
+ this.healthCheckStore.get(healthKey),
36
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1000)),
37
+ ]);
38
+ if (response === healthValue) {
39
+ return {
40
+ status: 200,
41
+ };
42
+ }
43
+ return {
44
+ status: 503,
45
+ };
46
+ }
47
+ catch (err) {
48
+ throw new error_1.JacksonError('Service not available', 503);
49
+ }
50
+ });
51
+ }
52
+ }
53
+ exports.HealthCheckController = HealthCheckController;
@@ -0,0 +1,18 @@
1
+ import { SAMLResponsePayload, SLORequestParams } from '../typings';
2
+ export declare class LogoutController {
3
+ private configStore;
4
+ private sessionStore;
5
+ private opts;
6
+ constructor({ configStore, sessionStore, opts }: {
7
+ configStore: any;
8
+ sessionStore: any;
9
+ opts: any;
10
+ });
11
+ createRequest({ nameId, tenant, product, redirectUrl }: SLORequestParams): Promise<{
12
+ logoutUrl: string | null;
13
+ logoutForm: string | null;
14
+ }>;
15
+ handleResponse({ SAMLResponse, RelayState }: SAMLResponsePayload): Promise<{
16
+ redirectUrl: any;
17
+ }>;
18
+ }
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
26
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
27
+ return new (P || (P = Promise))(function (resolve, reject) {
28
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
29
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
30
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
31
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
32
+ });
33
+ };
34
+ var __importDefault = (this && this.__importDefault) || function (mod) {
35
+ return (mod && mod.__esModule) ? mod : { "default": mod };
36
+ };
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.LogoutController = void 0;
39
+ const crypto_1 = __importDefault(require("crypto"));
40
+ const util_1 = require("util");
41
+ const xml2js_1 = __importDefault(require("xml2js"));
42
+ const xmlbuilder_1 = __importDefault(require("xmlbuilder"));
43
+ const zlib_1 = require("zlib");
44
+ const dbutils = __importStar(require("../db/utils"));
45
+ const saml20_1 = __importDefault(require("@boxyhq/saml20"));
46
+ const error_1 = require("./error");
47
+ const redirect = __importStar(require("./oauth/redirect"));
48
+ const utils_1 = require("./utils");
49
+ const deflateRawAsync = (0, util_1.promisify)(zlib_1.deflateRaw);
50
+ const relayStatePrefix = 'boxyhq_jackson_';
51
+ const logoutXPath = "/*[local-name(.)='LogoutRequest']";
52
+ class LogoutController {
53
+ constructor({ configStore, sessionStore, opts }) {
54
+ this.opts = opts;
55
+ this.configStore = configStore;
56
+ this.sessionStore = sessionStore;
57
+ }
58
+ // Create SLO Request
59
+ createRequest({ nameId, tenant, product, redirectUrl }) {
60
+ return __awaiter(this, void 0, void 0, function* () {
61
+ let samlConfig = null;
62
+ if (tenant && product) {
63
+ const samlConfigs = yield this.configStore.getByIndex({
64
+ name: utils_1.IndexNames.TenantProduct,
65
+ value: dbutils.keyFromParts(tenant, product),
66
+ });
67
+ if (!samlConfigs || samlConfigs.length === 0) {
68
+ throw new error_1.JacksonError('SAML configuration not found.', 403);
69
+ }
70
+ samlConfig = samlConfigs[0];
71
+ }
72
+ if (!samlConfig) {
73
+ throw new error_1.JacksonError('SAML configuration not found.', 403);
74
+ }
75
+ const { idpMetadata: { slo, provider }, certs: { privateKey, publicKey }, } = samlConfig;
76
+ if ('redirectUrl' in slo === false && 'postUrl' in slo === false) {
77
+ throw new error_1.JacksonError(`${provider} doesn't support SLO or disabled by IdP.`, 400);
78
+ }
79
+ const { id, xml } = buildRequestXML(nameId, this.opts.samlAudience, slo.redirectUrl);
80
+ const sessionId = crypto_1.default.randomBytes(16).toString('hex');
81
+ let logoutUrl = null;
82
+ let logoutForm = null;
83
+ const relayState = relayStatePrefix + sessionId;
84
+ const signedXML = yield signXML(xml, privateKey, publicKey);
85
+ yield this.sessionStore.put(sessionId, {
86
+ id,
87
+ redirectUrl,
88
+ });
89
+ // HTTP-Redirect binding
90
+ if ('redirectUrl' in slo) {
91
+ logoutUrl = redirect.success(slo.redirectUrl, {
92
+ SAMLRequest: Buffer.from(yield deflateRawAsync(signedXML)).toString('base64'),
93
+ RelayState: relayState,
94
+ });
95
+ }
96
+ // HTTP-POST binding
97
+ if ('postUrl' in slo) {
98
+ logoutForm = saml20_1.default.createPostForm(slo.postUrl, [
99
+ {
100
+ name: 'RelayState',
101
+ value: relayState,
102
+ },
103
+ {
104
+ name: 'SAMLRequest',
105
+ value: Buffer.from(signedXML).toString('base64'),
106
+ },
107
+ ]);
108
+ }
109
+ return { logoutUrl, logoutForm };
110
+ });
111
+ }
112
+ // Handle SLO Response
113
+ handleResponse({ SAMLResponse, RelayState }) {
114
+ var _a;
115
+ return __awaiter(this, void 0, void 0, function* () {
116
+ const rawResponse = Buffer.from(SAMLResponse, 'base64').toString();
117
+ const sessionId = RelayState.replace(relayStatePrefix, '');
118
+ const session = yield this.sessionStore.get(sessionId);
119
+ if (!session) {
120
+ throw new error_1.JacksonError('Unable to validate state from the origin request.', 403);
121
+ }
122
+ const parsedResponse = yield parseSAMLResponse(rawResponse);
123
+ if (parsedResponse.status !== 'urn:oasis:names:tc:SAML:2.0:status:Success') {
124
+ throw new error_1.JacksonError(`SLO failed with status ${parsedResponse.status}.`, 400);
125
+ }
126
+ if (parsedResponse.inResponseTo !== session.id) {
127
+ throw new error_1.JacksonError(`SLO failed with mismatched request ID.`, 400);
128
+ }
129
+ const samlConfigs = yield this.configStore.getByIndex({
130
+ name: utils_1.IndexNames.EntityID,
131
+ value: parsedResponse.issuer,
132
+ });
133
+ if (!samlConfigs || samlConfigs.length === 0) {
134
+ throw new error_1.JacksonError('SAML configuration not found.', 403);
135
+ }
136
+ const { idpMetadata, defaultRedirectUrl } = samlConfigs[0];
137
+ if (!(yield saml20_1.default.validateSignature(rawResponse, null, idpMetadata.thumbprint))) {
138
+ throw new error_1.JacksonError('Invalid signature.', 403);
139
+ }
140
+ try {
141
+ yield this.sessionStore.delete(sessionId);
142
+ }
143
+ catch (_err) {
144
+ // Ignore
145
+ }
146
+ return {
147
+ redirectUrl: (_a = session.redirectUrl) !== null && _a !== void 0 ? _a : defaultRedirectUrl,
148
+ };
149
+ });
150
+ }
151
+ }
152
+ exports.LogoutController = LogoutController;
153
+ // Create the XML for the SLO Request
154
+ const buildRequestXML = (nameId, providerName, sloUrl) => {
155
+ const id = '_' + crypto_1.default.randomBytes(10).toString('hex');
156
+ const xml = {
157
+ 'samlp:LogoutRequest': {
158
+ '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
159
+ '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
160
+ '@ID': id,
161
+ '@Version': '2.0',
162
+ '@IssueInstant': new Date().toISOString(),
163
+ '@Destination': sloUrl,
164
+ 'saml:Issuer': {
165
+ '#text': providerName,
166
+ },
167
+ 'saml:NameID': {
168
+ '@Format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified',
169
+ '#text': nameId,
170
+ },
171
+ },
172
+ };
173
+ return {
174
+ id,
175
+ xml: xmlbuilder_1.default.create(xml).end({}),
176
+ };
177
+ };
178
+ // Parse SAMLResponse
179
+ const parseSAMLResponse = (rawResponse) => __awaiter(void 0, void 0, void 0, function* () {
180
+ return new Promise((resolve, reject) => {
181
+ xml2js_1.default.parseString(rawResponse, { tagNameProcessors: [xml2js_1.default.processors.stripPrefix] }, (err, { LogoutResponse }) => {
182
+ if (err) {
183
+ reject(err);
184
+ return;
185
+ }
186
+ resolve({
187
+ issuer: LogoutResponse.Issuer[0]._,
188
+ id: LogoutResponse.$.ID,
189
+ status: LogoutResponse.Status[0].StatusCode[0].$.Value,
190
+ destination: LogoutResponse.$.Destination,
191
+ inResponseTo: LogoutResponse.$.InResponseTo,
192
+ });
193
+ });
194
+ });
195
+ });
196
+ // Sign the XML
197
+ const signXML = (xml, signingKey, publicKey) => __awaiter(void 0, void 0, void 0, function* () {
198
+ return yield saml20_1.default.sign(xml, signingKey, publicKey, logoutXPath);
199
+ });
@@ -1 +1 @@
1
- export declare const success: (redirectUrl: string, params: Record<string, string>) => string;
1
+ export declare const success: (redirectUrl: string, params: Record<string, string | string[] | undefined>) => string;
@@ -4,7 +4,12 @@ exports.success = void 0;
4
4
  const success = (redirectUrl, params) => {
5
5
  const url = new URL(redirectUrl);
6
6
  for (const [key, value] of Object.entries(params)) {
7
- url.searchParams.set(key, value);
7
+ if (Array.isArray(value)) {
8
+ value.forEach((v) => url.searchParams.append(key, v));
9
+ }
10
+ else if (value !== undefined) {
11
+ url.searchParams.set(key, value);
12
+ }
8
13
  }
9
14
  return url.href;
10
15
  };
@@ -12,12 +12,14 @@ export declare class OAuthController implements IOAuthController {
12
12
  tokenStore: any;
13
13
  opts: any;
14
14
  });
15
+ private resolveMultipleConfigMatches;
15
16
  authorize(body: OAuthReqBody): Promise<{
16
- redirect_url: string;
17
- authorize_form: string;
17
+ redirect_url?: string;
18
+ authorize_form?: string;
18
19
  }>;
19
20
  samlResponse(body: SAMLResponsePayload): Promise<{
20
- redirect_url: string;
21
+ redirect_url?: string;
22
+ app_select_form?: string;
21
23
  }>;
22
24
  /**
23
25
  * @swagger