@boxyhq/saml-jackson 0.1.6 → 0.2.1-beta.152

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
 
@@ -209,6 +236,8 @@ https://localhost:5000/oauth/authorize
209
236
 
210
237
  - response_type=code: This is the only supported type for now but maybe extended in the future
211
238
  - client_id: Use the client_id returned by the SAML config API or use `tenant=<tenantID>&product=<productID>` to use the tenant and product IDs instead. **Note:** Please don't forget to URL encode the query parameters including `client_id`.
239
+ - tenant: Optionally you can provide a dummy `client_id` and specify the `tenant` and `product` custom attributes (if your OAuth 2.0 library allows it).
240
+ - product: Should be specified if specifying `tenant` above
212
241
  - redirect_uri: This is where the user will be taken back once the authorization flow is complete
213
242
  - state: Use a randomly generated string as the state, this will be echoed back as a query parameter when taking the user back to the `redirect_uri` above. You should validate the state to prevent XSRF attacks
214
243
 
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.1-beta.152",
4
4
  "license": "Apache 2.0",
5
5
  "description": "SAML 2.0 service",
6
6
  "main": "src/index.js",
@@ -17,9 +17,9 @@
17
17
  "scripts": {
18
18
  "start": "cross-env IDP_ENABLED=true node src/jackson.js",
19
19
  "dev": "cross-env IDP_ENABLED=true nodemon src/jackson.js",
20
- "mongo": "cross-env DB_ENGINE=mongo DB_URL=mongodb://localhost:27017/jackson nodemon src/jackson.js",
21
- "pre-loaded": "cross-env DB_ENGINE=mem PRE_LOADED_CONFIG='./_config' nodemon src/jackson.js",
22
- "pre-loaded-db": "cross-env PRE_LOADED_CONFIG='./_config' nodemon src/jackson.js",
20
+ "mongo": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=mongo DB_URL=mongodb://localhost:27017/jackson nodemon src/jackson.js",
21
+ "pre-loaded": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=mem PRE_LOADED_CONFIG='./_config' nodemon src/jackson.js",
22
+ "pre-loaded-db": "cross-env JACKSON_API_KEYS=secret PRE_LOADED_CONFIG='./_config' nodemon src/jackson.js",
23
23
  "test": "tap --timeout=100 src/**/*.test.js",
24
24
  "dev-dbs": "docker-compose -f ./_dev/docker-compose.yml up -d",
25
25
  "dev-dbs-destroy": "docker-compose -f ./_dev/docker-compose.yml down --volumes --remove-orphans"
@@ -57,10 +57,11 @@
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": {
63
64
  "*.js": "eslint --cache --fix",
64
65
  "*.{js,css,md}": "prettier --write"
65
66
  }
66
- }
67
+ }
@@ -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,21 +46,34 @@ 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;
61
63
 
62
- if (
64
+ if (tenant && product) {
65
+ const samlConfigs = await configStore.getByIndex({
66
+ name: indexNames.tenantProduct,
67
+ value: dbutils.keyFromParts(tenant, product),
68
+ });
69
+
70
+ if (!samlConfigs || samlConfigs.length === 0) {
71
+ throw new JacksonError('SAML configuration not found.', 403);
72
+ }
73
+
74
+ // TODO: Support multiple matches
75
+ samlConfig = samlConfigs[0];
76
+ } else if (
63
77
  client_id &&
64
78
  client_id !== '' &&
65
79
  client_id !== 'undefined' &&
@@ -74,7 +88,7 @@ const authorize = async (req, res) => {
74
88
  });
75
89
 
76
90
  if (!samlConfigs || samlConfigs.length === 0) {
77
- return res.status(403).send('SAML configuration not found.');
91
+ throw new JacksonError('SAML configuration not found.', 403);
78
92
  }
79
93
 
80
94
  // TODO: Support multiple matches
@@ -83,25 +97,18 @@ const authorize = async (req, res) => {
83
97
  samlConfig = await configStore.get(client_id);
84
98
  }
85
99
  } else {
86
- const samlConfigs = await configStore.getByIndex({
87
- name: indexNames.tenantProduct,
88
- value: dbutils.keyFromParts(tenant, product),
89
- });
90
-
91
- if (!samlConfigs || samlConfigs.length === 0) {
92
- return res.status(403).send('SAML configuration not found.');
93
- }
94
-
95
- // TODO: Support multiple matches
96
- samlConfig = samlConfigs[0];
100
+ throw new JacksonError(
101
+ 'You need to specify client_id or tenant & product',
102
+ 403
103
+ );
97
104
  }
98
105
 
99
106
  if (!samlConfig) {
100
- return res.status(403).send('SAML configuration not found.');
107
+ throw new JacksonError('SAML configuration not found.', 403);
101
108
  }
102
109
 
103
110
  if (!allowed.redirect(redirect_uri, samlConfig.redirectUrl)) {
104
- return res.status(403).send('Redirect URL is not allowed.');
111
+ throw new JacksonError('Redirect URL is not allowed.', 403);
105
112
  }
106
113
 
107
114
  const samlReq = saml.request({
@@ -121,24 +128,26 @@ const authorize = async (req, res) => {
121
128
  code_challenge_method,
122
129
  });
123
130
 
124
- return redirect.success(res, samlConfig.idpMetadata.sso.redirectUrl, {
131
+ const redirectUrl = redirect.success(samlConfig.idpMetadata.sso.redirectUrl, {
125
132
  RelayState: relayStatePrefix + sessionId,
126
133
  SAMLRequest: Buffer.from(samlReq.request).toString('base64'),
127
134
  });
135
+
136
+ return { redirect_url: redirectUrl };
128
137
  };
129
138
 
130
- const samlResponse = async (req, res) => {
131
- const { SAMLResponse } = req.body; // RelayState will contain the sessionId from earlier quasi-oauth flow
139
+ const samlResponse = async (body) => {
140
+ const { SAMLResponse } = body; // RelayState will contain the sessionId from earlier quasi-oauth flow
132
141
 
133
- let RelayState = req.body.RelayState || '';
142
+ let RelayState = body.RelayState || '';
134
143
 
135
144
  if (!options.idpEnabled && !RelayState.startsWith(relayStatePrefix)) {
136
145
  // 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
- );
146
+
147
+ throw new JacksonError(
148
+ 'IdP (Identity Provider) flow has been disabled. Please head to your Service Provider to login.',
149
+ 403
150
+ );
142
151
  }
143
152
 
144
153
  if (!RelayState.startsWith(relayStatePrefix)) {
@@ -157,7 +166,7 @@ const samlResponse = async (req, res) => {
157
166
  });
158
167
 
159
168
  if (!samlConfigs || samlConfigs.length === 0) {
160
- return res.status(403).send('SAML configuration not found.');
169
+ throw new JacksonError('SAML configuration not found.', 403);
161
170
  }
162
171
 
163
172
  // TODO: Support multiple matches
@@ -168,10 +177,9 @@ const samlResponse = async (req, res) => {
168
177
  if (RelayState !== '') {
169
178
  session = await sessionStore.get(RelayState);
170
179
  if (!session) {
171
- return redirect.error(
172
- res,
173
- samlConfig.defaultRedirectUrl,
174
- 'Unable to validate state from the origin request.'
180
+ throw new JacksonError(
181
+ 'Unable to validate state from the origin request.',
182
+ 403
175
183
  );
176
184
  }
177
185
  }
@@ -207,7 +215,7 @@ const samlResponse = async (req, res) => {
207
215
  session.redirect_uri &&
208
216
  !allowed.redirect(session.redirect_uri, samlConfig.redirectUrl)
209
217
  ) {
210
- return res.status(403).send('Redirect URL is not allowed.');
218
+ throw new JacksonError('Redirect URL is not allowed.', 403);
211
219
  }
212
220
 
213
221
  let params = {
@@ -218,45 +226,48 @@ const samlResponse = async (req, res) => {
218
226
  params.state = session.state;
219
227
  }
220
228
 
221
- return redirect.success(
222
- res,
229
+ const redirectUrl = redirect.success(
223
230
  (session && session.redirect_uri) || samlConfig.defaultRedirectUrl,
224
231
  params
225
232
  );
233
+
234
+ return { redirect_url: redirectUrl };
226
235
  };
227
236
 
228
- const token = async (req, res) => {
237
+ const token = async (body) => {
229
238
  const {
230
239
  client_id,
231
240
  client_secret,
232
241
  code_verifier,
233
242
  code,
234
243
  grant_type = 'authorization_code',
235
- } = req.body;
244
+ } = body;
236
245
 
237
246
  if (grant_type !== 'authorization_code') {
238
- return res.status(400).send('Unsupported grant_type');
247
+ throw new JacksonError('Unsupported grant_type', 400);
239
248
  }
240
249
 
241
250
  if (!code) {
242
- return res.status(400).send('Please specify code');
251
+ throw new JacksonError('Please specify code', 400);
243
252
  }
244
253
 
245
254
  const codeVal = await codeStore.get(code);
246
255
  if (!codeVal || !codeVal.profile) {
247
- return res.status(403).send('Invalid code');
256
+ throw new JacksonError('Invalid code', 403);
248
257
  }
249
258
 
250
259
  if (client_id && client_secret) {
251
260
  // check if we have an encoded client_id
252
- const sp = getEncodedClientId(client_id);
253
- if (!sp) {
254
- // OAuth flow
255
- if (
256
- client_id !== codeVal.clientID ||
257
- client_secret !== codeVal.clientSecret
258
- ) {
259
- return res.status(401).send('Invalid client_id or client_secret');
261
+ if (client_id !== 'dummy' && client_secret !== 'dummy') {
262
+ const sp = getEncodedClientId(client_id);
263
+ if (!sp) {
264
+ // OAuth flow
265
+ if (
266
+ client_id !== codeVal.clientID ||
267
+ client_secret !== codeVal.clientSecret
268
+ ) {
269
+ throw new JacksonError('Invalid client_id or client_secret', 401);
270
+ }
260
271
  }
261
272
  }
262
273
  } else if (code_verifier) {
@@ -267,12 +278,13 @@ const token = async (req, res) => {
267
278
  }
268
279
 
269
280
  if (codeVal.session.code_challenge !== cv) {
270
- return res.status(401).send('Invalid code_verifier');
281
+ throw new JacksonError('Invalid code_verifier', 401);
271
282
  }
272
283
  } else if (codeVal && codeVal.session) {
273
- return res
274
- .status(401)
275
- .send('Please specify client_secret or code_verifier');
284
+ throw new JacksonError(
285
+ 'Please specify client_secret or code_verifier',
286
+ 401
287
+ );
276
288
  }
277
289
 
278
290
  // store details against a token
@@ -280,24 +292,17 @@ const token = async (req, res) => {
280
292
 
281
293
  await tokenStore.put(token, codeVal.profile);
282
294
 
283
- res.json({
295
+ return {
284
296
  access_token: token,
285
297
  token_type: 'bearer',
286
298
  expires_in: options.db.ttl,
287
- });
299
+ };
288
300
  };
289
301
 
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);
302
+ const userInfo = async (token) => {
303
+ const { claims } = await tokenStore.get(token);
299
304
 
300
- res.json(profile.claims);
305
+ return claims;
301
306
  };
302
307
 
303
308
  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
+ });