@boxyhq/saml-jackson 0.1.5 → 0.2.0-beta.149

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.
@@ -7,11 +7,33 @@ const crypto = require('crypto');
7
7
 
8
8
  let configStore;
9
9
 
10
+ const extractHostName = (url) => {
11
+ try {
12
+ const pUrl = new URL(url);
13
+ if(pUrl.hostname.startsWith('www.')) {
14
+ return pUrl.hostname.substring(4);
15
+ }
16
+ return pUrl.hostname;
17
+ } catch (err) {
18
+ return null;
19
+ }
20
+ };
21
+
10
22
  const config = async (body) => {
11
23
  const { rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } =
12
24
  body;
13
25
  const idpMetadata = await saml.parseMetadataAsync(rawMetadata);
14
26
 
27
+ // extract provider
28
+ let providerName = extractHostName(idpMetadata.entityID);
29
+ if (!providerName) {
30
+ providerName = extractHostName(
31
+ idpMetadata.sso.redirectUrl || idpMetadata.sso.postUrl
32
+ );
33
+ }
34
+
35
+ idpMetadata.provider = providerName ? providerName : 'Unknown';
36
+
15
37
  let clientID = dbutils.keyDigest(
16
38
  dbutils.keyFromParts(tenant, product, idpMetadata.entityID)
17
39
  );
@@ -56,12 +78,37 @@ const config = async (body) => {
56
78
  return {
57
79
  client_id: clientID,
58
80
  client_secret: clientSecret,
81
+ provider: idpMetadata.provider,
59
82
  };
60
83
  };
61
84
 
85
+ const getConfig = async (body) => {
86
+ const { clientID, tenant, product } = body;
87
+
88
+ if (clientID) {
89
+ const samlConfig = await configStore.get(clientID);
90
+ if (!samlConfig) {
91
+ return {};
92
+ }
93
+
94
+ return { provider: samlConfig.idpMetadata.provider };
95
+ } else {
96
+ const samlConfigs = await configStore.getByIndex({
97
+ name: indexNames.tenantProduct,
98
+ value: dbutils.keyFromParts(tenant, product),
99
+ });
100
+ if (!samlConfigs || !samlConfigs.length) {
101
+ return {};
102
+ }
103
+
104
+ return { provider: samlConfigs[0].idpMetadata.provider };
105
+ }
106
+ };
107
+
62
108
  module.exports = (opts) => {
63
109
  configStore = opts.configStore;
64
110
  return {
65
111
  config,
112
+ getConfig,
66
113
  };
67
114
  };
@@ -0,0 +1,12 @@
1
+ class JacksonError extends Error {
2
+ constructor(message, statusCode = 500) {
3
+ super(message);
4
+
5
+ this.name = this.constructor.name;
6
+ this.statusCode = statusCode;
7
+
8
+ Error.captureStackTrace(this, this.constructor);
9
+ }
10
+ }
11
+
12
+ module.exports = { JacksonError };
@@ -6,12 +6,13 @@ module.exports = {
6
6
  res.redirect(url);
7
7
  },
8
8
 
9
- success: (res, redirectUrl, params) => {
10
- var url = new URL(redirectUrl);
9
+ success: (redirectUrl, params) => {
10
+ const url = new URL(redirectUrl);
11
+
11
12
  for (const [key, value] of Object.entries(params)) {
12
13
  url.searchParams.set(key, value);
13
14
  }
14
15
 
15
- res.redirect(url);
16
+ return url.href;
16
17
  },
17
18
  };
@@ -6,6 +6,7 @@ const { indexNames } = require('./utils.js');
6
6
  const dbutils = require('../db/utils.js');
7
7
  const redirect = require('./oauth/redirect.js');
8
8
  const allowed = require('./oauth/allowed.js');
9
+ const { JacksonError } = require('./error.js');
9
10
 
10
11
  let configStore;
11
12
  let sessionStore;
@@ -15,16 +16,6 @@ let options;
15
16
 
16
17
  const relayStatePrefix = 'boxyhq_jackson_';
17
18
 
18
- const extractBearerToken = (req) => {
19
- const authHeader = req.get('authorization');
20
- const parts = (authHeader || '').split(' ');
21
- if (parts.length > 1) {
22
- return parts[1];
23
- }
24
-
25
- return null;
26
- };
27
-
28
19
  function getEncodedClientId(client_id) {
29
20
  try {
30
21
  const sp = new URLSearchParams(client_id);
@@ -43,7 +34,7 @@ function getEncodedClientId(client_id) {
43
34
  }
44
35
  }
45
36
 
46
- const authorize = async (req, res) => {
37
+ const authorize = async (body) => {
47
38
  const {
48
39
  response_type = 'code',
49
40
  client_id,
@@ -55,16 +46,17 @@ const authorize = async (req, res) => {
55
46
  code_challenge_method = '',
56
47
  // eslint-disable-next-line no-unused-vars
57
48
  provider = 'saml',
58
- } = req.query;
49
+ } = body;
59
50
 
60
51
  if (!redirect_uri) {
61
- return res.status(400).send('Please specify a redirect URL.');
52
+ throw new JacksonError('Please specify a redirect URL.', 400);
62
53
  }
63
54
 
64
55
  if (!state) {
65
- return res
66
- .status(400)
67
- .send('Please specify a state to safeguard against XSRF attacks.');
56
+ throw new JacksonError(
57
+ 'Please specify a state to safeguard against XSRF attacks.',
58
+ 400
59
+ );
68
60
  }
69
61
 
70
62
  let samlConfig;
@@ -84,7 +76,7 @@ const authorize = async (req, res) => {
84
76
  });
85
77
 
86
78
  if (!samlConfigs || samlConfigs.length === 0) {
87
- return res.status(403).send('SAML configuration not found.');
79
+ throw new JacksonError('SAML configuration not found.', 403);
88
80
  }
89
81
 
90
82
  // TODO: Support multiple matches
@@ -99,7 +91,7 @@ const authorize = async (req, res) => {
99
91
  });
100
92
 
101
93
  if (!samlConfigs || samlConfigs.length === 0) {
102
- return res.status(403).send('SAML configuration not found.');
94
+ throw new JacksonError('SAML configuration not found.', 403);
103
95
  }
104
96
 
105
97
  // TODO: Support multiple matches
@@ -107,15 +99,15 @@ const authorize = async (req, res) => {
107
99
  }
108
100
 
109
101
  if (!samlConfig) {
110
- return res.status(403).send('SAML configuration not found.');
102
+ throw new JacksonError('SAML configuration not found.', 403);
111
103
  }
112
104
 
113
105
  if (!allowed.redirect(redirect_uri, samlConfig.redirectUrl)) {
114
- return res.status(403).send('Redirect URL is not allowed.');
106
+ throw new JacksonError('Redirect URL is not allowed.', 403);
115
107
  }
116
108
 
117
109
  const samlReq = saml.request({
118
- entityID: samlConfig.idpMetadata.entityID,
110
+ entityID: options.samlAudience,
119
111
  callbackUrl: options.externalUrl + options.samlPath,
120
112
  signingKey: samlConfig.certs.privateKey,
121
113
  });
@@ -131,24 +123,26 @@ const authorize = async (req, res) => {
131
123
  code_challenge_method,
132
124
  });
133
125
 
134
- return redirect.success(res, samlConfig.idpMetadata.sso.redirectUrl, {
126
+ const redirectUrl = redirect.success(samlConfig.idpMetadata.sso.redirectUrl, {
135
127
  RelayState: relayStatePrefix + sessionId,
136
128
  SAMLRequest: Buffer.from(samlReq.request).toString('base64'),
137
129
  });
130
+
131
+ return { redirect_url: redirectUrl };
138
132
  };
139
133
 
140
- const samlResponse = async (req, res) => {
141
- const { SAMLResponse } = req.body; // RelayState will contain the sessionId from earlier quasi-oauth flow
134
+ const samlResponse = async (body) => {
135
+ const { SAMLResponse } = body; // RelayState will contain the sessionId from earlier quasi-oauth flow
142
136
 
143
- let RelayState = req.body.RelayState || '';
137
+ let RelayState = body.RelayState || '';
144
138
 
145
139
  if (!options.idpEnabled && !RelayState.startsWith(relayStatePrefix)) {
146
140
  // IDP is disabled so block the request
147
- return res
148
- .status(403)
149
- .send(
150
- 'IdP (Identity Provider) flow has been disabled. Please head to your Service Provider to login.'
151
- );
141
+
142
+ throw new JacksonError(
143
+ 'IdP (Identity Provider) flow has been disabled. Please head to your Service Provider to login.',
144
+ 403
145
+ );
152
146
  }
153
147
 
154
148
  if (!RelayState.startsWith(relayStatePrefix)) {
@@ -167,7 +161,7 @@ const samlResponse = async (req, res) => {
167
161
  });
168
162
 
169
163
  if (!samlConfigs || samlConfigs.length === 0) {
170
- return res.status(403).send('SAML configuration not found.');
164
+ throw new JacksonError('SAML configuration not found.', 403);
171
165
  }
172
166
 
173
167
  // TODO: Support multiple matches
@@ -178,10 +172,9 @@ const samlResponse = async (req, res) => {
178
172
  if (RelayState !== '') {
179
173
  session = await sessionStore.get(RelayState);
180
174
  if (!session) {
181
- return redirect.error(
182
- res,
183
- samlConfig.defaultRedirectUrl,
184
- 'Unable to validate state from the origin request.'
175
+ throw new JacksonError(
176
+ 'Unable to validate state from the origin request.',
177
+ 403
185
178
  );
186
179
  }
187
180
  }
@@ -217,7 +210,7 @@ const samlResponse = async (req, res) => {
217
210
  session.redirect_uri &&
218
211
  !allowed.redirect(session.redirect_uri, samlConfig.redirectUrl)
219
212
  ) {
220
- return res.status(403).send('Redirect URL is not allowed.');
213
+ throw new JacksonError('Redirect URL is not allowed.', 403);
221
214
  }
222
215
 
223
216
  let params = {
@@ -228,33 +221,34 @@ const samlResponse = async (req, res) => {
228
221
  params.state = session.state;
229
222
  }
230
223
 
231
- return redirect.success(
232
- res,
224
+ const redirectUrl = redirect.success(
233
225
  (session && session.redirect_uri) || samlConfig.defaultRedirectUrl,
234
226
  params
235
227
  );
228
+
229
+ return { redirect_url: redirectUrl };
236
230
  };
237
231
 
238
- const token = async (req, res) => {
232
+ const token = async (body) => {
239
233
  const {
240
234
  client_id,
241
235
  client_secret,
242
236
  code_verifier,
243
237
  code,
244
238
  grant_type = 'authorization_code',
245
- } = req.body;
239
+ } = body;
246
240
 
247
241
  if (grant_type !== 'authorization_code') {
248
- return res.status(400).send('Unsupported grant_type');
242
+ throw new JacksonError('Unsupported grant_type', 400);
249
243
  }
250
244
 
251
245
  if (!code) {
252
- return res.status(400).send('Please specify code');
246
+ throw new JacksonError('Please specify code', 400);
253
247
  }
254
248
 
255
249
  const codeVal = await codeStore.get(code);
256
250
  if (!codeVal || !codeVal.profile) {
257
- return res.status(403).send('Invalid code');
251
+ throw new JacksonError('Invalid code', 403);
258
252
  }
259
253
 
260
254
  if (client_id && client_secret) {
@@ -266,7 +260,7 @@ const token = async (req, res) => {
266
260
  client_id !== codeVal.clientID ||
267
261
  client_secret !== codeVal.clientSecret
268
262
  ) {
269
- return res.status(401).send('Invalid client_id or client_secret');
263
+ throw new JacksonError('Invalid client_id or client_secret', 401);
270
264
  }
271
265
  }
272
266
  } else if (code_verifier) {
@@ -277,12 +271,13 @@ const token = async (req, res) => {
277
271
  }
278
272
 
279
273
  if (codeVal.session.code_challenge !== cv) {
280
- return res.status(401).send('Invalid code_verifier');
274
+ throw new JacksonError('Invalid code_verifier', 401);
281
275
  }
282
276
  } else if (codeVal && codeVal.session) {
283
- return res
284
- .status(401)
285
- .send('Please specify client_secret or code_verifier');
277
+ throw new JacksonError(
278
+ 'Please specify client_secret or code_verifier',
279
+ 401
280
+ );
286
281
  }
287
282
 
288
283
  // store details against a token
@@ -290,24 +285,17 @@ const token = async (req, res) => {
290
285
 
291
286
  await tokenStore.put(token, codeVal.profile);
292
287
 
293
- res.json({
288
+ return {
294
289
  access_token: token,
295
290
  token_type: 'bearer',
296
291
  expires_in: options.db.ttl,
297
- });
292
+ };
298
293
  };
299
294
 
300
- const userInfo = async (req, res) => {
301
- let token = extractBearerToken(req);
302
-
303
- // check for query param
304
- if (!token) {
305
- token = req.query.access_token;
306
- }
307
-
308
- const profile = await tokenStore.get(token);
295
+ const userInfo = async (token) => {
296
+ const { claims } = await tokenStore.get(token);
309
297
 
310
- res.json(profile.claims);
298
+ return claims;
311
299
  };
312
300
 
313
301
  module.exports = (opts) => {
@@ -3,6 +3,17 @@ const indexNames = {
3
3
  tenantProduct: 'tenantProduct',
4
4
  };
5
5
 
6
+ const extractAuthToken = (req) => {
7
+ const authHeader = req.get('authorization');
8
+ const parts = (authHeader || '').split(' ');
9
+ if (parts.length > 1) {
10
+ return parts[1];
11
+ }
12
+
13
+ return null;
14
+ };
15
+
6
16
  module.exports = {
7
17
  indexNames,
18
+ extractAuthToken,
8
19
  };
package/src/db/db.test.js CHANGED
@@ -224,7 +224,7 @@ t.test('dbs', ({ end }) => {
224
224
  }
225
225
 
226
226
  await new Promise((resolve) =>
227
- setTimeout(resolve, ((dbEngine === 'mem' ? 5 : 0) + ttl + 0.5) * 1000)
227
+ setTimeout(resolve, (2*ttl + 0.5) * 1000)
228
228
  );
229
229
 
230
230
  const ret1 = await ttlStore.get(record1.id);
@@ -13,9 +13,11 @@ module.exports = new EntitySchema({
13
13
  },
14
14
  key: {
15
15
  type: 'varchar',
16
+ length: 1500,
16
17
  },
17
18
  storeKey: {
18
19
  type: 'varchar',
20
+ length: 1500,
19
21
  }
20
22
  },
21
23
  relations: {
@@ -1,20 +1,32 @@
1
1
  const EntitySchema = require('typeorm').EntitySchema;
2
2
  const JacksonStore = require('../model/JacksonStore.js');
3
3
 
4
- module.exports = new EntitySchema({
5
- name: 'JacksonStore',
6
- target: JacksonStore,
7
- columns: {
8
- key: {
9
- primary: true,
10
- type: 'varchar',
11
- },
12
- value: {
13
- type: 'varchar',
4
+ const valueType = (type) => {
5
+ switch (type) {
6
+ case 'postgres':
7
+ case 'cockroachdb':
8
+ return 'text';
9
+ case 'mysql':
10
+ case 'mariadb':
11
+ return 'mediumtext';
12
+ default:
13
+ return 'varchar';
14
+ }
15
+ };
16
+
17
+ module.exports = (type) => {
18
+ return new EntitySchema({
19
+ name: 'JacksonStore',
20
+ target: JacksonStore,
21
+ columns: {
22
+ key: {
23
+ primary: true,
24
+ type: 'varchar',
25
+ length: 1500,
26
+ },
27
+ value: {
28
+ type: valueType(type),
29
+ },
14
30
  },
15
- expiresAt: {
16
- type: 'bigint',
17
- nullable: true,
18
- }
19
- },
20
- });
31
+ });
32
+ };
@@ -0,0 +1,23 @@
1
+ const EntitySchema = require('typeorm').EntitySchema;
2
+ const JacksonTTL = require('../model/JacksonTTL.js');
3
+
4
+ module.exports = new EntitySchema({
5
+ name: 'JacksonTTL',
6
+ target: JacksonTTL,
7
+ columns: {
8
+ key: {
9
+ primary: true,
10
+ type: 'varchar',
11
+ length: 1500,
12
+ },
13
+ expiresAt: {
14
+ type: 'bigint',
15
+ },
16
+ },
17
+ indices: [
18
+ {
19
+ name: '_jackson_ttl_expires_at',
20
+ columns: ['expiresAt'],
21
+ },
22
+ ],
23
+ });
@@ -1,8 +1,7 @@
1
1
  /*export */ class JacksonStore {
2
- constructor(key, value, expiresAt) {
2
+ constructor(key, value) {
3
3
  this.key = key;
4
4
  this.value = value;
5
- this.expiresAt = expiresAt;
6
5
  }
7
6
  }
8
7
 
@@ -0,0 +1,8 @@
1
+ /*export */ class JacksonTTL {
2
+ constructor(key, expiresAt) {
3
+ this.key = key;
4
+ this.expiresAt = expiresAt;
5
+ }
6
+ }
7
+
8
+ module.exports = JacksonTTL;
package/src/db/sql/sql.js CHANGED
@@ -2,42 +2,62 @@ require('reflect-metadata');
2
2
  const typeorm = require('typeorm');
3
3
  const JacksonStore = require('./model/JacksonStore.js');
4
4
  const JacksonIndex = require('./model/JacksonIndex.js');
5
+ const JacksonTTL = require('./model/JacksonTTL.js');
5
6
 
6
7
  const dbutils = require('../utils.js');
7
8
 
8
9
  class Sql {
9
10
  constructor(options) {
10
11
  return (async () => {
11
- this.connection = await typeorm.createConnection({
12
- name: options.type,
13
- type: options.type,
14
- url: options.url,
15
- synchronize: true,
16
- logging: false,
17
- entities: [
18
- require('./entity/JacksonStore.js'),
19
- require('./entity/JacksonIndex.js'),
20
- ],
21
- });
12
+ while (true) {
13
+ try {
14
+ this.connection = await typeorm.createConnection({
15
+ name: options.type,
16
+ type: options.type,
17
+ url: options.url,
18
+ synchronize: true,
19
+ migrationsTableName: '_jackson_migrations',
20
+ logging: false,
21
+ entities: [
22
+ require('./entity/JacksonStore.js')(options.type),
23
+ require('./entity/JacksonIndex.js'),
24
+ require('./entity/JacksonTTL.js'),
25
+ ],
26
+ });
27
+
28
+ break;
29
+ } catch (err) {
30
+ console.error(`error connecting to ${options.type} db: ${err}`);
31
+ await dbutils.sleep(1000);
32
+ continue;
33
+ }
34
+ }
22
35
 
23
36
  this.storeRepository = this.connection.getRepository(JacksonStore);
24
37
  this.indexRepository = this.connection.getRepository(JacksonIndex);
38
+ this.ttlRepository = this.connection.getRepository(JacksonTTL);
25
39
 
26
40
  if (options.ttl && options.limit) {
27
41
  this.ttlCleanup = async () => {
28
42
  const now = Date.now();
29
43
 
30
44
  while (true) {
31
- const ids = await this.storeRepository.find({
32
- expiresAt: typeorm.MoreThan(now),
33
- take: options.limit,
34
- });
45
+ const ids = await this.ttlRepository
46
+ .createQueryBuilder('jackson_ttl')
47
+ .limit(options.limit)
48
+ .where('jackson_ttl.expiresAt <= :expiresAt', { expiresAt: now })
49
+ .getMany();
35
50
 
36
51
  if (ids.length <= 0) {
37
52
  break;
38
53
  }
39
54
 
55
+ const delIds = ids.map((id) => {
56
+ return id.key;
57
+ });
58
+
40
59
  await this.storeRepository.remove(ids);
60
+ await this.ttlRepository.delete(delIds);
41
61
  }
42
62
 
43
63
  this.timerId = setTimeout(this.ttlCleanup, options.ttl * 1000);
@@ -45,7 +65,9 @@ class Sql {
45
65
 
46
66
  this.timerId = setTimeout(this.ttlCleanup, options.ttl * 1000);
47
67
  } else {
48
- console.log('Warning: ttl cleanup not enabled, set both "ttl" and "limit" options to enable it!')
68
+ console.log(
69
+ 'Warning: ttl cleanup not enabled, set both "ttl" and "limit" options to enable it!'
70
+ );
49
71
  }
50
72
 
51
73
  return this;
@@ -86,13 +108,15 @@ class Sql {
86
108
 
87
109
  async put(namespace, key, val, ttl = 0, ...indexes) {
88
110
  await this.connection.transaction(async (transactionalEntityManager) => {
89
- const store = new JacksonStore(
90
- dbutils.key(namespace, key),
91
- JSON.stringify(val),
92
- ttl > 0 ? Date.now() + ttl * 1000 : null
93
- );
111
+ const dbKey = dbutils.key(namespace, key);
112
+ const store = new JacksonStore(dbKey, JSON.stringify(val));
94
113
  await transactionalEntityManager.save(store);
95
114
 
115
+ if (ttl) {
116
+ const ttlRec = new JacksonTTL(dbKey, Date.now() + ttl * 1000);
117
+ await transactionalEntityManager.save(ttlRec);
118
+ }
119
+
96
120
  // no ttl support for secondary indexes
97
121
  for (const idx of indexes || []) {
98
122
  const key = dbutils.keyForIndex(namespace, idx);
package/src/db/utils.js CHANGED
@@ -16,14 +16,15 @@ const keyFromParts = (...parts) => {
16
16
  return parts.join(':'); // TODO: pick a better strategy, keys can collide now
17
17
  };
18
18
 
19
+ const sleep = (ms) => {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ };
22
+
19
23
  module.exports = {
20
24
  key,
21
-
22
25
  keyForIndex,
23
-
24
26
  keyDigest,
25
-
26
27
  keyFromParts,
27
-
28
+ sleep,
28
29
  indexPrefix: '_index',
29
30
  };
package/src/env.js CHANGED
@@ -7,6 +7,8 @@ const samlPath = process.env.SAML_PATH || '/oauth/saml';
7
7
  const internalHostUrl = process.env.INTERNAL_HOST_URL || 'localhost';
8
8
  const internalHostPort = (process.env.INTERNAL_HOST_PORT || '6000') * 1;
9
9
 
10
+ const apiKeys = (process.env.JACKSON_API_KEYS || '').split(',');
11
+
10
12
  const samlAudience = process.env.SAML_AUDIENCE;
11
13
  const preLoadedConfig = process.env.PRE_LOADED_CONFIG;
12
14
 
@@ -27,6 +29,7 @@ module.exports = {
27
29
  preLoadedConfig,
28
30
  internalHostUrl,
29
31
  internalHostPort,
32
+ apiKeys,
30
33
  idpEnabled,
31
34
  db,
32
35
  useInternalServer: !(
package/src/index.js CHANGED
@@ -19,7 +19,7 @@ const defaultOpts = (opts) => {
19
19
  newOpts.db = newOpts.db || {};
20
20
  newOpts.db.engine = newOpts.db.engine || 'sql'; // Supported values: redis, sql, mongo, mem. Keep comment in sync with db.js
21
21
  newOpts.db.url =
22
- newOpts.db.url || 'postgres://postgres:postgres@localhost:5432/jackson';
22
+ newOpts.db.url || 'postgresql://postgres:postgres@localhost:5432/postgres';
23
23
  newOpts.db.type = newOpts.db.type || 'postgres'; // Only needed if DB_ENGINE is sql. Supported values: postgres, cockroachdb, mysql, mariadb
24
24
  newOpts.db.ttl = (newOpts.db.ttl || 300) * 1; // TTL for the code, session and token stores (in seconds)
25
25
  newOpts.db.limit = (newOpts.db.limit || 1000) * 1; // Limit ttl cleanup to this many items at a time
@@ -56,7 +56,8 @@ module.exports = async function (opts) {
56
56
  }
57
57
  }
58
58
 
59
- console.log(`Using engine: ${opts.db.engine}`);
59
+ const type = opts.db.engine === 'sql' && opts.db.type ? ' Type: ' + opts.db.type : '';
60
+ console.log(`Using engine: ${opts.db.engine}.${type}`);
60
61
 
61
62
  return {
62
63
  apiController,