@boxyhq/saml-jackson 0.1.6 → 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.
package/README.md CHANGED
@@ -17,7 +17,7 @@ There are two ways to use this repo.
17
17
 
18
18
  ## Install as an npm library
19
19
 
20
- Jackson is available as an [npm package](https://www.npmjs.com/package/@boxyhq/saml-jackson) that can be integrated into Express.js routes. The library should be usable with other node.js web application frameworks but is currently untested. Please file an issue or submit a PR if you encounter any issues.
20
+ Jackson is available as an [npm package](https://www.npmjs.com/package/@boxyhq/saml-jackson) that can be integrated into any web application framework (like Express.js for example). Please file an issue or submit a PR if you encounter any issues with your choice of framework.
21
21
 
22
22
  ```
23
23
  npm i @boxyhq/saml-jackson
@@ -75,33 +75,60 @@ router.post('/api/v1/saml/config', async (req, res) => {
75
75
  // OAuth 2.0 flow
76
76
  router.get('/oauth/authorize', async (req, res) => {
77
77
  try {
78
- await oauthController.authorize(req, res);
78
+ const { redirect_url } = await oauthController.authorize(req.query);
79
+
80
+ res.redirect(redirect_url);
79
81
  } catch (err) {
80
- res.status(500).send(err.message);
82
+ const { message, statusCode = 500 } = err;
83
+
84
+ res.status(statusCode).send(message);
81
85
  }
82
86
  });
83
87
 
84
88
  router.post('/oauth/saml', async (req, res) => {
85
89
  try {
86
- await oauthController.samlResponse(req, res);
90
+ const { redirect_url } = await oauthController.samlResponse(req.body);
91
+
92
+ res.redirect(redirect_url);
87
93
  } catch (err) {
88
- res.status(500).send(err.message);
94
+ const { message, statusCode = 500 } = err;
95
+
96
+ res.status(statusCode).send(message);
89
97
  }
90
98
  });
91
99
 
92
100
  router.post('/oauth/token', cors(), async (req, res) => {
93
101
  try {
94
- await oauthController.token(req, res);
102
+ const result = await oauthController.token(req.body);
103
+
104
+ res.json(result);
95
105
  } catch (err) {
96
- res.status(500).send(err.message);
106
+ const { message, statusCode = 500 } = err;
107
+
108
+ res.status(statusCode).send(message);
97
109
  }
98
110
  });
99
111
 
100
112
  router.get('/oauth/userinfo', async (req, res) => {
101
113
  try {
102
- await oauthController.userInfo(req, res);
114
+ let token = extractAuthToken(req);
115
+
116
+ // check for query param
117
+ if (!token) {
118
+ token = req.query.access_token;
119
+ }
120
+
121
+ if (!token) {
122
+ res.status(401).json({ message: 'Unauthorized' });
123
+ }
124
+
125
+ const profile = await oauthController.userInfo(token);
126
+
127
+ res.json(profile);
103
128
  } catch (err) {
104
- res.status(500).send(err.message);
129
+ const { message, statusCode = 500 } = err;
130
+
131
+ res.status(statusCode).json({ message });
105
132
  }
106
133
  });
107
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxyhq/saml-jackson",
3
- "version": "0.1.6",
3
+ "version": "0.2.0-beta.149",
4
4
  "license": "Apache 2.0",
5
5
  "description": "SAML 2.0 service",
6
6
  "main": "src/index.js",
@@ -57,6 +57,7 @@
57
57
  "lint-staged": "12.1.2",
58
58
  "nodemon": "2.0.15",
59
59
  "prettier": "2.5.1",
60
+ "sinon": "12.0.1",
60
61
  "tap": "15.1.5"
61
62
  },
62
63
  "lint-staged": {
@@ -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
  };
@@ -2,10 +2,11 @@ const crypto = require('crypto');
2
2
 
3
3
  const saml = require('../saml/saml.js');
4
4
  const codeVerifier = require('./oauth/code-verifier.js');
5
- const { indexNames, extractAuthToken } = require('./utils.js');
5
+ 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;
@@ -33,7 +34,7 @@ function getEncodedClientId(client_id) {
33
34
  }
34
35
  }
35
36
 
36
- const authorize = async (req, res) => {
37
+ const authorize = async (body) => {
37
38
  const {
38
39
  response_type = 'code',
39
40
  client_id,
@@ -45,16 +46,17 @@ const authorize = async (req, res) => {
45
46
  code_challenge_method = '',
46
47
  // eslint-disable-next-line no-unused-vars
47
48
  provider = 'saml',
48
- } = req.query;
49
+ } = body;
49
50
 
50
51
  if (!redirect_uri) {
51
- return res.status(400).send('Please specify a redirect URL.');
52
+ throw new JacksonError('Please specify a redirect URL.', 400);
52
53
  }
53
54
 
54
55
  if (!state) {
55
- return res
56
- .status(400)
57
- .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
+ );
58
60
  }
59
61
 
60
62
  let samlConfig;
@@ -74,7 +76,7 @@ const authorize = async (req, res) => {
74
76
  });
75
77
 
76
78
  if (!samlConfigs || samlConfigs.length === 0) {
77
- return res.status(403).send('SAML configuration not found.');
79
+ throw new JacksonError('SAML configuration not found.', 403);
78
80
  }
79
81
 
80
82
  // TODO: Support multiple matches
@@ -89,7 +91,7 @@ const authorize = async (req, res) => {
89
91
  });
90
92
 
91
93
  if (!samlConfigs || samlConfigs.length === 0) {
92
- return res.status(403).send('SAML configuration not found.');
94
+ throw new JacksonError('SAML configuration not found.', 403);
93
95
  }
94
96
 
95
97
  // TODO: Support multiple matches
@@ -97,11 +99,11 @@ const authorize = async (req, res) => {
97
99
  }
98
100
 
99
101
  if (!samlConfig) {
100
- return res.status(403).send('SAML configuration not found.');
102
+ throw new JacksonError('SAML configuration not found.', 403);
101
103
  }
102
104
 
103
105
  if (!allowed.redirect(redirect_uri, samlConfig.redirectUrl)) {
104
- return res.status(403).send('Redirect URL is not allowed.');
106
+ throw new JacksonError('Redirect URL is not allowed.', 403);
105
107
  }
106
108
 
107
109
  const samlReq = saml.request({
@@ -121,24 +123,26 @@ const authorize = async (req, res) => {
121
123
  code_challenge_method,
122
124
  });
123
125
 
124
- return redirect.success(res, samlConfig.idpMetadata.sso.redirectUrl, {
126
+ const redirectUrl = redirect.success(samlConfig.idpMetadata.sso.redirectUrl, {
125
127
  RelayState: relayStatePrefix + sessionId,
126
128
  SAMLRequest: Buffer.from(samlReq.request).toString('base64'),
127
129
  });
130
+
131
+ return { redirect_url: redirectUrl };
128
132
  };
129
133
 
130
- const samlResponse = async (req, res) => {
131
- 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
132
136
 
133
- let RelayState = req.body.RelayState || '';
137
+ let RelayState = body.RelayState || '';
134
138
 
135
139
  if (!options.idpEnabled && !RelayState.startsWith(relayStatePrefix)) {
136
140
  // IDP is disabled so block the request
137
- return res
138
- .status(403)
139
- .send(
140
- 'IdP (Identity Provider) flow has been disabled. Please head to your Service Provider to login.'
141
- );
141
+
142
+ throw new JacksonError(
143
+ 'IdP (Identity Provider) flow has been disabled. Please head to your Service Provider to login.',
144
+ 403
145
+ );
142
146
  }
143
147
 
144
148
  if (!RelayState.startsWith(relayStatePrefix)) {
@@ -157,7 +161,7 @@ const samlResponse = async (req, res) => {
157
161
  });
158
162
 
159
163
  if (!samlConfigs || samlConfigs.length === 0) {
160
- return res.status(403).send('SAML configuration not found.');
164
+ throw new JacksonError('SAML configuration not found.', 403);
161
165
  }
162
166
 
163
167
  // TODO: Support multiple matches
@@ -168,10 +172,9 @@ const samlResponse = async (req, res) => {
168
172
  if (RelayState !== '') {
169
173
  session = await sessionStore.get(RelayState);
170
174
  if (!session) {
171
- return redirect.error(
172
- res,
173
- samlConfig.defaultRedirectUrl,
174
- 'Unable to validate state from the origin request.'
175
+ throw new JacksonError(
176
+ 'Unable to validate state from the origin request.',
177
+ 403
175
178
  );
176
179
  }
177
180
  }
@@ -207,7 +210,7 @@ const samlResponse = async (req, res) => {
207
210
  session.redirect_uri &&
208
211
  !allowed.redirect(session.redirect_uri, samlConfig.redirectUrl)
209
212
  ) {
210
- return res.status(403).send('Redirect URL is not allowed.');
213
+ throw new JacksonError('Redirect URL is not allowed.', 403);
211
214
  }
212
215
 
213
216
  let params = {
@@ -218,33 +221,34 @@ const samlResponse = async (req, res) => {
218
221
  params.state = session.state;
219
222
  }
220
223
 
221
- return redirect.success(
222
- res,
224
+ const redirectUrl = redirect.success(
223
225
  (session && session.redirect_uri) || samlConfig.defaultRedirectUrl,
224
226
  params
225
227
  );
228
+
229
+ return { redirect_url: redirectUrl };
226
230
  };
227
231
 
228
- const token = async (req, res) => {
232
+ const token = async (body) => {
229
233
  const {
230
234
  client_id,
231
235
  client_secret,
232
236
  code_verifier,
233
237
  code,
234
238
  grant_type = 'authorization_code',
235
- } = req.body;
239
+ } = body;
236
240
 
237
241
  if (grant_type !== 'authorization_code') {
238
- return res.status(400).send('Unsupported grant_type');
242
+ throw new JacksonError('Unsupported grant_type', 400);
239
243
  }
240
244
 
241
245
  if (!code) {
242
- return res.status(400).send('Please specify code');
246
+ throw new JacksonError('Please specify code', 400);
243
247
  }
244
248
 
245
249
  const codeVal = await codeStore.get(code);
246
250
  if (!codeVal || !codeVal.profile) {
247
- return res.status(403).send('Invalid code');
251
+ throw new JacksonError('Invalid code', 403);
248
252
  }
249
253
 
250
254
  if (client_id && client_secret) {
@@ -256,7 +260,7 @@ const token = async (req, res) => {
256
260
  client_id !== codeVal.clientID ||
257
261
  client_secret !== codeVal.clientSecret
258
262
  ) {
259
- return res.status(401).send('Invalid client_id or client_secret');
263
+ throw new JacksonError('Invalid client_id or client_secret', 401);
260
264
  }
261
265
  }
262
266
  } else if (code_verifier) {
@@ -267,12 +271,13 @@ const token = async (req, res) => {
267
271
  }
268
272
 
269
273
  if (codeVal.session.code_challenge !== cv) {
270
- return res.status(401).send('Invalid code_verifier');
274
+ throw new JacksonError('Invalid code_verifier', 401);
271
275
  }
272
276
  } else if (codeVal && codeVal.session) {
273
- return res
274
- .status(401)
275
- .send('Please specify client_secret or code_verifier');
277
+ throw new JacksonError(
278
+ 'Please specify client_secret or code_verifier',
279
+ 401
280
+ );
276
281
  }
277
282
 
278
283
  // store details against a token
@@ -280,24 +285,17 @@ const token = async (req, res) => {
280
285
 
281
286
  await tokenStore.put(token, codeVal.profile);
282
287
 
283
- res.json({
288
+ return {
284
289
  access_token: token,
285
290
  token_type: 'bearer',
286
291
  expires_in: options.db.ttl,
287
- });
292
+ };
288
293
  };
289
294
 
290
- const userInfo = async (req, res) => {
291
- let token = extractAuthToken(req);
292
-
293
- // check for query param
294
- if (!token) {
295
- token = req.query.access_token;
296
- }
297
-
298
- const profile = await tokenStore.get(token);
295
+ const userInfo = async (token) => {
296
+ const { claims } = await tokenStore.get(token);
299
297
 
300
- res.json(profile.claims);
298
+ return claims;
301
299
  };
302
300
 
303
301
  module.exports = (opts) => {
package/src/jackson.js CHANGED
@@ -17,33 +17,60 @@ app.use(express.urlencoded({ extended: true }));
17
17
 
18
18
  app.get(oauthPath + '/authorize', async (req, res) => {
19
19
  try {
20
- await oauthController.authorize(req, res);
20
+ const { redirect_url } = await oauthController.authorize(req.query);
21
+
22
+ res.redirect(redirect_url);
21
23
  } catch (err) {
22
- res.status(500).send(err.message);
24
+ const { message, statusCode = 500 } = err;
25
+
26
+ res.status(statusCode).send(message);
23
27
  }
24
28
  });
25
29
 
26
30
  app.post(env.samlPath, async (req, res) => {
27
31
  try {
28
- await oauthController.samlResponse(req, res);
32
+ const { redirect_url } = await oauthController.samlResponse(req.body);
33
+
34
+ res.redirect(redirect_url);
29
35
  } catch (err) {
30
- res.status(500).send(err.message);
36
+ const { message, statusCode = 500 } = err;
37
+
38
+ res.status(statusCode).send(message);
31
39
  }
32
40
  });
33
41
 
34
42
  app.post(oauthPath + '/token', cors(), async (req, res) => {
35
43
  try {
36
- await oauthController.token(req, res);
44
+ const result = await oauthController.token(req.body);
45
+
46
+ res.json(result);
37
47
  } catch (err) {
38
- res.status(500).send(err.message);
48
+ const { message, statusCode = 500 } = err;
49
+
50
+ res.status(statusCode).send(message);
39
51
  }
40
52
  });
41
53
 
42
54
  app.get(oauthPath + '/userinfo', async (req, res) => {
43
55
  try {
44
- await oauthController.userInfo(req, res);
56
+ let token = extractAuthToken(req);
57
+
58
+ // check for query param
59
+ if (!token) {
60
+ token = req.query.access_token;
61
+ }
62
+
63
+ if (!token) {
64
+ res.status(401).json({ message: 'Unauthorized' });
65
+ }
66
+
67
+ const profile = await oauthController.userInfo(token);
68
+
69
+ res.json(profile);
45
70
  } catch (err) {
46
- res.status(500).send(err.message);
71
+ const { message, statusCode = 500 } = err;
72
+
73
+ res.status(statusCode).json({ message });
47
74
  }
48
75
  });
49
76
 
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ defaultRedirectUrl: 'http://localhost:3000/sso/oauth/completed',
3
+ redirectUrl: '["http://localhost:3000"]',
4
+ tenant: 'boxyhq.com',
5
+ product: 'crm',
6
+ };
@@ -0,0 +1,30 @@
1
+ <?xml version="1.0" encoding="UTF-8" standalone="no"?>
2
+ <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://accounts.google.com/o/saml2" validUntil="2026-06-22T18:39:53.000Z">
3
+ <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
4
+ <md:KeyDescriptor use="signing">
5
+ <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
6
+ <ds:X509Data>
7
+ <ds:X509Certificate>MIIDdDCCAlygAwIBAgIGAXo6K+u/MA0GCSqGSIb3DQEBCwUAMHsxFDASBgNVBAoTC0dvb2dsZSBJ
8
+ bmMuMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MQ8wDQYDVQQDEwZHb29nbGUxGDAWBgNVBAsTD0dv
9
+ b2dsZSBGb3IgV29yazELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWEwHhcNMjEwNjIz
10
+ MTgzOTUzWhcNMjYwNjIyMTgzOTUzWjB7MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEWMBQGA1UEBxMN
11
+ TW91bnRhaW4gVmlldzEPMA0GA1UEAxMGR29vZ2xlMRgwFgYDVQQLEw9Hb29nbGUgRm9yIFdvcmsx
12
+ CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
13
+ MIIBCgKCAQEA4qZcxwPiVka9GzGdQ9LVlgVkn3A7O3HtxR6RIm5AMaL4YZziEHt2HgxLdJZyXYJw
14
+ yfT1KB2IHt+XDQBkgEpQVXuuwSPI8vhI8Jr+nr8zia3MMoy9vJF8ZG7HuWeakaKEh7tJqjYu1Cl9
15
+ a81rkYdXAFUA+gl2q+stvK26xylAUwptCJSQo0NanWzCq+k5zvX0uLmh58+W5Yv11hDTtAoW+1dH
16
+ LWUTHXPfoZINPRy5NGKJ2Onq5/D5XJRimNnUa2iYi0Yv9txp1RRq4dpB9MaVttt3iKyDo4/+8fg/
17
+ bL8BLhguiOeqcP4DEIzMuExi3bZAOu2NC7k7Qf28nA81LzP9DQIDAQABMA0GCSqGSIb3DQEBCwUA
18
+ A4IBAQARBNB3+MfmKr5WXNXXE9YwUzUGmpfbqUPXh2y2dOAkj6TzoekAsOLWB0p8oyJ5d1bFlTsx
19
+ i1OY9RuFl0tc35Jbo+ae5GfUvJmbnYGi9z8sBL55HY6x3KQNmM/ehof7ttZwvB6nwuRxAiGYG497
20
+ 3tSzrqMQzEskcgX1mlCW0vks/ztCaayprDXcCUxWdP9FaiSZDEXV6PHhFZgGlRNvERsgaMDJgOsq
21
+ v6hLX10Q9CtOWzqu18PI4DcfoZ7exWcC29yWvwZzDTfHGaSG1DtUFLwiQmhVUbfd7/fmLV+/iOxV
22
+ zI0b5xSYZOJ7Kena7gd5zGVrc2ygKAFKiffiI5GLmLkv</ds:X509Certificate>
23
+ </ds:X509Data>
24
+ </ds:KeyInfo>
25
+ </md:KeyDescriptor>
26
+ <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
27
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://accounts.google.com/o/saml2"/>
28
+ <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://accounts.google.com/o/saml2"/>
29
+ </md:IDPSSODescriptor>
30
+ </md:EntityDescriptor>
@@ -0,0 +1 @@
1
+ PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzYW1sMnA6UmVzcG9uc2UgeG1sbnM6c2FtbDJwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIERlc3RpbmF0aW9uPSJodHRwczovLzdlYTItMTAzLTE1My0xMDQtMTYxLm5ncm9rLmlvL3Nzby9vYXV0aC9zYW1sIiBJRD0iXzRkZmM5MjYwZDFjZTVlMDRhZTQ4ZTg4ZWJkNGNlOTY3IiBJblJlc3BvbnNlVG89Il9kYWNkMTRhZGVmMmNiMDc1NGM5NiIgSXNzdWVJbnN0YW50PSIyMDIxLTEyLTA2VDE1OjIzOjA3LjM2MFoiIFZlcnNpb249IjIuMCI+DQogICA8c2FtbDI6SXNzdWVyIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5odHRwczovL2FjY291bnRzLmdvb2dsZS5jb20vby9zYW1sMjwvc2FtbDI6SXNzdWVyPg0KICAgPGRzOlNpZ25hdHVyZSB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+DQogICAgICA8ZHM6U2lnbmVkSW5mbz4NCiAgICAgICAgIDxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIiAvPg0KICAgICAgICAgPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZHNpZy1tb3JlI3JzYS1zaGEyNTYiIC8+DQogICAgICAgICA8ZHM6UmVmZXJlbmNlIFVSST0iI180ZGZjOTI2MGQxY2U1ZTA0YWU0OGU4OGViZDRjZTk2NyI+DQogICAgICAgICAgICA8ZHM6VHJhbnNmb3Jtcz4NCiAgICAgICAgICAgICAgIDxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIgLz4NCiAgICAgICAgICAgICAgIDxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiIC8+DQogICAgICAgICAgICA8L2RzOlRyYW5zZm9ybXM+DQogICAgICAgICAgICA8ZHM6RGlnZXN0TWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjc2hhMjU2IiAvPg0KICAgICAgICAgICAgPGRzOkRpZ2VzdFZhbHVlPjI3Vy9EZTVKTlZIWkk1VTVKZGIvUi9mUXIya0pmd1VPbk0vVlRmQ1ZwQU09PC9kczpEaWdlc3RWYWx1ZT4NCiAgICAgICAgIDwvZHM6UmVmZXJlbmNlPg0KICAgICAgPC9kczpTaWduZWRJbmZvPg0KICAgICAgPGRzOlNpZ25hdHVyZVZhbHVlPm4vWCsvb0ZzK3JNU2gxMlg4dnowWlNkNlFpcU50eTY3RnFVbVNwdGllRmNoM0NScGQzZ2dpSHNwTTlMcm9MWjZVZGlsbThtdFZqTkgNCi9ob21yWDNvVEQ4UStxdzNiSllOTEptNEQvRWNmZmRDNmpSb0RJZzRYeFYxYlBVaWhQTnI4dlBFZEF2eEdwNTNiZ2MyREJsWkJpT3gNCnh4RlBSbEtnajJDWjh5SWk1R05FMUVTYms2SEtjY3g4R2dxWmtSYkVRbWtnbVMxZG1xcGl5bUpQM3orMHlaaXQyZ3dwQW5WVjNCMDUNCnZOcy8rSTFzRlZLaHNWcTc4QzZNWlZzV1pUSi80RFhadWhVSnpLNElTSU11b1RqUWFacEZMeEpBKzlhZzIvcm9OMjkwcitpcTZ5MVQNCjNHSlF1TlBPU0JVS1NpWlZVNmdwTldRRDBxckFxSWFQUFpOZnp3PT08L2RzOlNpZ25hdHVyZVZhbHVlPg0KICAgICAgPGRzOktleUluZm8+DQogICAgICAgICA8ZHM6WDUwOURhdGE+DQogICAgICAgICAgICA8ZHM6WDUwOVN1YmplY3ROYW1lPlNUPUNhbGlmb3JuaWEsQz1VUyxPVT1Hb29nbGUgRm9yIFdvcmssQ049R29vZ2xlLEw9TW91bnRhaW4gVmlldyxPPUdvb2dsZSBJbmMuPC9kczpYNTA5U3ViamVjdE5hbWU+DQogICAgICAgICAgICA8ZHM6WDUwOUNlcnRpZmljYXRlPk1JSURkRENDQWx5Z0F3SUJBZ0lHQVhvNksrdS9NQTBHQ1NxR1NJYjNEUUVCQ3dVQU1Ic3hGREFTQmdOVkJBb1RDMGR2YjJkc1pTQkoNCmJtTXVNUll3RkFZRFZRUUhFdzFOYjNWdWRHRnBiaUJXYVdWM01ROHdEUVlEVlFRREV3WkhiMjluYkdVeEdEQVdCZ05WQkFzVEQwZHYNCmIyZHNaU0JHYjNJZ1YyOXlhekVMTUFrR0ExVUVCaE1DVlZNeEV6QVJCZ05WQkFnVENrTmhiR2xtYjNKdWFXRXdIaGNOTWpFd05qSXoNCk1UZ3pPVFV6V2hjTk1qWXdOakl5TVRnek9UVXpXakI3TVJRd0VnWURWUVFLRXd0SGIyOW5iR1VnU1c1akxqRVdNQlFHQTFVRUJ4TU4NClRXOTFiblJoYVc0Z1ZtbGxkekVQTUEwR0ExVUVBeE1HUjI5dloyeGxNUmd3RmdZRFZRUUxFdzlIYjI5bmJHVWdSbTl5SUZkdmNtc3gNCkN6QUpCZ05WQkFZVEFsVlRNUk13RVFZRFZRUUlFd3BEWVd4cFptOXlibWxoTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEENCk1JSUJDZ0tDQVFFQTRxWmN4d1BpVmthOUd6R2RROUxWbGdWa24zQTdPM0h0eFI2UkltNUFNYUw0WVp6aUVIdDJIZ3hMZEpaeVhZSncNCnlmVDFLQjJJSHQrWERRQmtnRXBRVlh1dXdTUEk4dmhJOEpyK25yOHppYTNNTW95OXZKRjhaRzdIdVdlYWthS0VoN3RKcWpZdTFDbDkNCmE4MXJrWWRYQUZVQStnbDJxK3N0dksyNnh5bEFVd3B0Q0pTUW8wTmFuV3pDcStrNXp2WDB1TG1oNTgrVzVZdjExaERUdEFvVysxZEgNCkxXVVRIWFBmb1pJTlBSeTVOR0tKMk9ucTUvRDVYSlJpbU5uVWEyaVlpMFl2OXR4cDFSUnE0ZHBCOU1hVnR0dDNpS3lEbzQvKzhmZy8NCmJMOEJMaGd1aU9lcWNQNERFSXpNdUV4aTNiWkFPdTJOQzdrN1FmMjhuQTgxTHpQOURRSURBUUFCTUEwR0NTcUdTSWIzRFFFQkN3VUENCkE0SUJBUUFSQk5CMytNZm1LcjVXWE5YWEU5WXdVelVHbXBmYnFVUFhoMnkyZE9Ba2o2VHpvZWtBc09MV0IwcDhveUo1ZDFiRmxUc3gNCmkxT1k5UnVGbDB0YzM1SmJvK2FlNUdmVXZKbWJuWUdpOXo4c0JMNTVIWTZ4M0tRTm1NL2Vob2Y3dHRad3ZCNm53dVJ4QWlHWUc0OTcNCjN0U3pycU1RekVza2NnWDFtbENXMHZrcy96dENhYXlwckRYY0NVeFdkUDlGYWlTWkRFWFY2UEhoRlpnR2xSTnZFUnNnYU1ESmdPc3ENCnY2aExYMTBROUN0T1d6cXUxOFBJNERjZm9aN2V4V2NDMjl5V3Z3WnpEVGZIR2FTRzFEdFVGTHdpUW1oVlViZmQ3L2ZtTFYrL2lPeFYNCnpJMGI1eFNZWk9KN0tlbmE3Z2Q1ekdWcmMyeWdLQUZLaWZmaUk1R0xtTGt2PC9kczpYNTA5Q2VydGlmaWNhdGU+DQogICAgICAgICA8L2RzOlg1MDlEYXRhPg0KICAgICAgPC9kczpLZXlJbmZvPg0KICAgPC9kczpTaWduYXR1cmU+DQogICA8c2FtbDJwOlN0YXR1cz4NCiAgICAgIDxzYW1sMnA6U3RhdHVzQ29kZSBWYWx1ZT0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnN0YXR1czpTdWNjZXNzIiAvPg0KICAgPC9zYW1sMnA6U3RhdHVzPg0KICAgPHNhbWwyOkFzc2VydGlvbiB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiIgSUQ9Il8xMWZkN2U5MDdjZmNiMTEwODM3NjI5OGM1Nzc0ZjgyNyIgSXNzdWVJbnN0YW50PSIyMDIxLTEyLTA2VDE1OjIzOjA3LjM2MFoiIFZlcnNpb249IjIuMCI+DQogICAgICA8c2FtbDI6SXNzdWVyPmh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbS9vL3NhbWwyPC9zYW1sMjpJc3N1ZXI+DQogICAgICA8c2FtbDI6U3ViamVjdD4NCiAgICAgICAgIDxzYW1sMjpOYW1lSUQgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoxLjE6bmFtZWlkLWZvcm1hdDplbWFpbEFkZHJlc3MiPmtpcmFuQGRlbW8uY29tPC9zYW1sMjpOYW1lSUQ+DQogICAgICAgICA8c2FtbDI6U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpjbTpiZWFyZXIiPg0KICAgICAgICAgICAgPHNhbWwyOlN1YmplY3RDb25maXJtYXRpb25EYXRhIEluUmVzcG9uc2VUbz0iX2RhY2QxNGFkZWYyY2IwNzU0Yzk2IiBOb3RPbk9yQWZ0ZXI9IjIwMjEtMTItMDZUMTU6Mjg6MDcuMzYwWiIgUmVjaXBpZW50PSJodHRwczovLzdlYTItMTAzLTE1My0xMDQtMTYxLm5ncm9rLmlvL3Nzby9vYXV0aC9zYW1sIiAvPg0KICAgICAgICAgPC9zYW1sMjpTdWJqZWN0Q29uZmlybWF0aW9uPg0KICAgICAgPC9zYW1sMjpTdWJqZWN0Pg0KICAgICAgPHNhbWwyOkNvbmRpdGlvbnMgTm90QmVmb3JlPSIyMDIxLTEyLTA2VDE1OjE4OjA3LjM2MFoiIE5vdE9uT3JBZnRlcj0iMjAyMS0xMi0wNlQxNToyODowNy4zNjBaIj4NCiAgICAgICAgIDxzYW1sMjpBdWRpZW5jZVJlc3RyaWN0aW9uPg0KICAgICAgICAgICAgPHNhbWwyOkF1ZGllbmNlPmh0dHBzOi8vc2FtbC5ib3h5aHEuY29tPC9zYW1sMjpBdWRpZW5jZT4NCiAgICAgICAgIDwvc2FtbDI6QXVkaWVuY2VSZXN0cmljdGlvbj4NCiAgICAgIDwvc2FtbDI6Q29uZGl0aW9ucz4NCiAgICAgIDxzYW1sMjpBdHRyaWJ1dGVTdGF0ZW1lbnQ+DQogICAgICAgICA8c2FtbDI6QXR0cmlidXRlIE5hbWU9InVzZXIuZW1haWwiPg0KICAgICAgICAgICAgPHNhbWwyOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOmFueVR5cGUiPmtpcmFuQGRlbW8uY29tPC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT4NCiAgICAgICAgIDwvc2FtbDI6QXR0cmlidXRlPg0KICAgICAgICAgPHNhbWwyOkF0dHJpYnV0ZSBOYW1lPSJ1c2VyLmZpcnN0TmFtZSI+DQogICAgICAgICAgICA8c2FtbDI6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6YW55VHlwZSI+S2lyYW48L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPg0KICAgICAgICAgPC9zYW1sMjpBdHRyaWJ1dGU+DQogICAgICAgICA8c2FtbDI6QXR0cmlidXRlIE5hbWU9InVzZXIuaWQiIC8+DQogICAgICAgICA8c2FtbDI6QXR0cmlidXRlIE5hbWU9InVzZXIubGFzdE5hbWUiPg0KICAgICAgICAgICAgPHNhbWwyOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOmFueVR5cGUiPks8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPg0KICAgICAgICAgPC9zYW1sMjpBdHRyaWJ1dGU+DQogICAgICAgICA8c2FtbDI6QXR0cmlidXRlIE5hbWU9Im1lbWJlci1vZiIgLz4NCiAgICAgIDwvc2FtbDI6QXR0cmlidXRlU3RhdGVtZW50Pg0KICAgICAgPHNhbWwyOkF1dGhuU3RhdGVtZW50IEF1dGhuSW5zdGFudD0iMjAyMS0xMi0wNlQxNToxNzowNS4wMDBaIiBTZXNzaW9uSW5kZXg9Il8xMWZkN2U5MDdjZmNiMTEwODM3NjI5OGM1Nzc0ZjgyNyI+DQogICAgICAgICA8c2FtbDI6QXV0aG5Db250ZXh0Pg0KICAgICAgICAgICAgPHNhbWwyOkF1dGhuQ29udGV4dENsYXNzUmVmPnVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOnVuc3BlY2lmaWVkPC9zYW1sMjpBdXRobkNvbnRleHRDbGFzc1JlZj4NCiAgICAgICAgIDwvc2FtbDI6QXV0aG5Db250ZXh0Pg0KICAgICAgPC9zYW1sMjpBdXRoblN0YXRlbWVudD4NCiAgIDwvc2FtbDI6QXNzZXJ0aW9uPg0KPC9zYW1sMnA6UmVzcG9uc2U+
@@ -0,0 +1,342 @@
1
+ const tap = require('tap');
2
+ const { promises: fs } = require('fs');
3
+ const path = require('path');
4
+ const sinon = require('sinon');
5
+ const crypto = require('crypto');
6
+
7
+ const readConfig = require('../read-config');
8
+ const saml = require('../saml/saml');
9
+
10
+ let apiController;
11
+ let oauthController;
12
+
13
+ const code = '1234567890';
14
+ const token = '24c1550190dd6a5a9bd6fe2a8ff69d593121c7b9';
15
+
16
+ const metadataPath = path.join(__dirname, '/data/metadata');
17
+
18
+ const options = {
19
+ externalUrl: 'https://my-cool-app.com',
20
+ samlAudience: 'https://saml.boxyhq.com',
21
+ samlPath: '/sso/oauth/saml',
22
+ db: {
23
+ engine: 'mem',
24
+ },
25
+ };
26
+
27
+ const samlConfig = {
28
+ tenant: 'boxyhq.com',
29
+ product: 'crm',
30
+ redirectUrl: '["http://localhost:3000/*"]',
31
+ defaultRedirectUrl: 'http://localhost:3000/login/saml',
32
+ rawMetadata: null,
33
+ };
34
+
35
+ const addMetadata = async (metadataPath) => {
36
+ const configs = await readConfig(metadataPath);
37
+
38
+ for (const config of configs) {
39
+ await apiController.config(config);
40
+ }
41
+ };
42
+
43
+ tap.before(async () => {
44
+ const controller = await require('../index.js')(options);
45
+
46
+ apiController = controller.apiController;
47
+ oauthController = controller.oauthController;
48
+
49
+ await addMetadata(metadataPath);
50
+ });
51
+
52
+ tap.teardown(async () => {
53
+ process.exit(0);
54
+ });
55
+
56
+ tap.test('authorize()', async (t) => {
57
+ t.test('Should throw an error if `redirect_uri` null', async (t) => {
58
+ const body = {
59
+ redirect_uri: null,
60
+ state: 'state',
61
+ };
62
+
63
+ try {
64
+ await oauthController.authorize(body);
65
+ t.fail('Expecting JacksonError.');
66
+ } catch (err) {
67
+ t.equal(
68
+ err.message,
69
+ 'Please specify a redirect URL.',
70
+ 'got expected error message'
71
+ );
72
+ t.equal(err.statusCode, 400, 'got expected status code');
73
+ }
74
+
75
+ t.end();
76
+ });
77
+
78
+ t.test('Should throw an error if `state` null', async (t) => {
79
+ const body = {
80
+ redirect_uri: 'https://example.com/',
81
+ state: null,
82
+ };
83
+
84
+ try {
85
+ await oauthController.authorize(body);
86
+
87
+ t.fail('Expecting JacksonError.');
88
+ } catch (err) {
89
+ t.equal(
90
+ err.message,
91
+ 'Please specify a state to safeguard against XSRF attacks.',
92
+ 'got expected error message'
93
+ );
94
+ t.equal(err.statusCode, 400, 'got expected status code');
95
+ }
96
+
97
+ t.end();
98
+ });
99
+
100
+ t.test('Should throw an error if `client_id` is invalid', async (t) => {
101
+ const body = {
102
+ redirect_uri: 'https://example.com/',
103
+ state: 'state-123',
104
+ client_id: '27fa9a11875ec3a0',
105
+ };
106
+
107
+ try {
108
+ await oauthController.authorize(body);
109
+
110
+ t.fail('Expecting JacksonError.');
111
+ } catch (err) {
112
+ t.equal(
113
+ err.message,
114
+ 'SAML configuration not found.',
115
+ 'got expected error message'
116
+ );
117
+ t.equal(err.statusCode, 403, 'got expected status code');
118
+ }
119
+
120
+ t.end();
121
+ });
122
+
123
+ t.test(
124
+ 'Should throw an error if `redirect_uri` is not allowed',
125
+ async (t) => {
126
+ const body = {
127
+ redirect_uri: 'https://example.com/',
128
+ state: 'state-123',
129
+ client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
130
+ };
131
+
132
+ try {
133
+ await oauthController.authorize(body);
134
+
135
+ t.fail('Expecting JacksonError.');
136
+ } catch (err) {
137
+ t.equal(
138
+ err.message,
139
+ 'Redirect URL is not allowed.',
140
+ 'got expected error message'
141
+ );
142
+ t.equal(err.statusCode, 403, 'got expected status code');
143
+ }
144
+
145
+ t.end();
146
+ }
147
+ );
148
+
149
+ t.test('Should return the Idp SSO URL', async (t) => {
150
+ const body = {
151
+ redirect_uri: samlConfig.defaultRedirectUrl,
152
+ state: 'state-123',
153
+ client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
154
+ };
155
+
156
+ const response = await oauthController.authorize(body);
157
+ const params = new URLSearchParams(new URL(response.redirect_url).search);
158
+
159
+ t.ok('redirect_url' in response, 'got the Idp authorize URL');
160
+ t.ok(params.has('RelayState'), 'RelayState present in the query string');
161
+ t.ok(params.has('SAMLRequest'), 'SAMLRequest present in the query string');
162
+
163
+ t.end();
164
+ });
165
+
166
+ t.end();
167
+ });
168
+
169
+ tap.test('samlResponse()', async (t) => {
170
+ const authBody = {
171
+ redirect_uri: samlConfig.defaultRedirectUrl,
172
+ state: 'state-123',
173
+ client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
174
+ };
175
+
176
+ const { redirect_url } = await oauthController.authorize(authBody);
177
+
178
+ const relayState = new URLSearchParams(new URL(redirect_url).search).get(
179
+ 'RelayState'
180
+ );
181
+
182
+ const rawResponse = await fs.readFile(
183
+ path.join(__dirname, '/data/saml_response'),
184
+ 'utf8'
185
+ );
186
+
187
+ t.test('Should throw an error if `RelayState` is missing', async (t) => {
188
+ const responseBody = {
189
+ SAMLResponse: rawResponse,
190
+ };
191
+
192
+ try {
193
+ await oauthController.samlResponse(responseBody);
194
+
195
+ t.fail('Expecting JacksonError.');
196
+ } catch (err) {
197
+ t.equal(
198
+ err.message,
199
+ 'IdP (Identity Provider) flow has been disabled. Please head to your Service Provider to login.',
200
+ 'got expected error message'
201
+ );
202
+
203
+ t.equal(err.statusCode, 403, 'got expected status code');
204
+ }
205
+
206
+ t.end();
207
+ });
208
+
209
+ t.test(
210
+ 'Should return a URL with code and state as query params',
211
+ async (t) => {
212
+ const responseBody = {
213
+ SAMLResponse: rawResponse,
214
+ RelayState: relayState,
215
+ };
216
+
217
+ const stubValidateAsync = sinon.stub(saml, 'validateAsync').returns({
218
+ id: 1,
219
+ email: 'john@example.com',
220
+ firstName: 'John',
221
+ lastName: 'Doe',
222
+ });
223
+
224
+ const stubRandomBytes = sinon.stub(crypto, 'randomBytes').returns(code);
225
+
226
+ const response = await oauthController.samlResponse(responseBody);
227
+
228
+ const params = new URLSearchParams(new URL(response.redirect_url).search);
229
+
230
+ t.ok(stubValidateAsync.calledOnce, 'randomBytes called once');
231
+ t.ok(stubRandomBytes.calledOnce, 'validateAsync called once');
232
+ t.ok('redirect_url' in response, 'response contains redirect_url');
233
+ t.ok(params.has('code'), 'query string includes code');
234
+ t.ok(params.has('state'), 'query string includes state');
235
+ t.match(params.get('state'), authBody.state, 'state value is valid');
236
+
237
+ stubRandomBytes.restore();
238
+ stubValidateAsync.restore();
239
+
240
+ t.end();
241
+ }
242
+ );
243
+
244
+ t.end();
245
+ });
246
+
247
+ tap.test('token()', (t) => {
248
+ t.test(
249
+ 'Should throw an error if `grant_type` is not `authorization_code`',
250
+ async (t) => {
251
+ const body = {
252
+ grant_type: 'authorization_code_1',
253
+ };
254
+
255
+ try {
256
+ await oauthController.token(body);
257
+
258
+ t.fail('Expecting JacksonError.');
259
+ } catch (err) {
260
+ t.equal(
261
+ err.message,
262
+ 'Unsupported grant_type',
263
+ 'got expected error message'
264
+ );
265
+ t.equal(err.statusCode, 400, 'got expected status code');
266
+ }
267
+
268
+ t.end();
269
+ }
270
+ );
271
+
272
+ t.test('Should throw an error if `code` is missing', async (t) => {
273
+ const body = {
274
+ grant_type: 'authorization_code',
275
+ };
276
+
277
+ try {
278
+ await oauthController.token(body);
279
+
280
+ t.fail('Expecting JacksonError.');
281
+ } catch (err) {
282
+ t.equal(err.message, 'Please specify code', 'got expected error message');
283
+ t.equal(err.statusCode, 400, 'got expected status code');
284
+ }
285
+
286
+ t.end();
287
+ });
288
+
289
+ t.test('Should throw an error if `code` is invalid', async (t) => {
290
+ const body = {
291
+ grant_type: 'authorization_code',
292
+ client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
293
+ client_secret: 'some-secret',
294
+ redirect_uri: null,
295
+ code: 'invalid-code',
296
+ };
297
+
298
+ try {
299
+ await oauthController.token(body);
300
+
301
+ t.fail('Expecting JacksonError.');
302
+ } catch (err) {
303
+ t.equal(err.message, 'Invalid code', 'got expected error message');
304
+ t.equal(err.statusCode, 403, 'got expected status code');
305
+ }
306
+
307
+ t.end();
308
+ });
309
+
310
+ t.test('Should return the `access_token` for a valid request', async (t) => {
311
+ const body = {
312
+ grant_type: 'authorization_code',
313
+ client_id: `tenant=${samlConfig.tenant}&product=${samlConfig.product}`,
314
+ client_secret: 'some-secret',
315
+ redirect_uri: null,
316
+ code: code,
317
+ };
318
+
319
+ const stubRandomBytes = sinon.stub(crypto, 'randomBytes').returns(token);
320
+
321
+ const response = await oauthController.token(body);
322
+
323
+ t.ok(stubRandomBytes.calledOnce, 'randomBytes called once');
324
+ t.ok('access_token' in response, 'includes access_token');
325
+ t.ok('token_type' in response, 'includes token_type');
326
+ t.ok('expires_in' in response, 'includes expires_in');
327
+ t.match(response.access_token, token);
328
+ t.match(response.token_type, 'bearer');
329
+ t.match(response.expires_in, 300);
330
+
331
+ stubRandomBytes.reset();
332
+
333
+ t.end();
334
+ });
335
+
336
+ // TODO
337
+ t.test('Handle invalid client_id', async (t) => {
338
+ t.end();
339
+ });
340
+
341
+ t.end();
342
+ });