@boxyhq/saml-jackson 0.1.6-beta.145 → 0.2.0-beta.151
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 +38 -9
- package/package.json +6 -5
- package/src/controller/error.js +12 -0
- package/src/controller/oauth/redirect.js +4 -3
- package/src/controller/oauth.js +64 -61
- package/src/jackson.js +35 -8
- package/src/test/data/metadata/boxyhq.js +6 -0
- package/src/test/data/metadata/boxyhq.xml +30 -0
- package/src/test/data/saml_response +1 -0
- package/src/test/oauth.test.js +342 -0
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
|
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
|
78
|
+
const { redirect_url } = await oauthController.authorize(req.query);
|
79
|
+
|
80
|
+
res.redirect(redirect_url);
|
79
81
|
} catch (err) {
|
80
|
-
|
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
|
90
|
+
const { redirect_url } = await oauthController.samlResponse(req.body);
|
91
|
+
|
92
|
+
res.redirect(redirect_url);
|
87
93
|
} catch (err) {
|
88
|
-
|
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
|
102
|
+
const result = await oauthController.token(req.body);
|
103
|
+
|
104
|
+
res.json(result);
|
95
105
|
} catch (err) {
|
96
|
-
|
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
|
-
|
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
|
-
|
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.
|
3
|
+
"version": "0.2.0-beta.151",
|
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: (
|
10
|
-
|
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
|
-
|
16
|
+
return url.href;
|
16
17
|
},
|
17
18
|
};
|
package/src/controller/oauth.js
CHANGED
@@ -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
|
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 (
|
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
|
-
} =
|
49
|
+
} = body;
|
49
50
|
|
50
51
|
if (!redirect_uri) {
|
51
|
-
|
52
|
+
throw new JacksonError('Please specify a redirect URL.', 400);
|
52
53
|
}
|
53
54
|
|
54
55
|
if (!state) {
|
55
|
-
|
56
|
-
.
|
57
|
-
|
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
|
-
|
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
|
-
|
87
|
-
|
88
|
-
|
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
|
-
|
107
|
+
throw new JacksonError('SAML configuration not found.', 403);
|
101
108
|
}
|
102
109
|
|
103
110
|
if (!allowed.redirect(redirect_uri, samlConfig.redirectUrl)) {
|
104
|
-
|
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
|
-
|
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 (
|
131
|
-
const { SAMLResponse } =
|
139
|
+
const samlResponse = async (body) => {
|
140
|
+
const { SAMLResponse } = body; // RelayState will contain the sessionId from earlier quasi-oauth flow
|
132
141
|
|
133
|
-
let 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
|
-
|
138
|
-
|
139
|
-
.
|
140
|
-
|
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
|
-
|
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
|
-
|
172
|
-
|
173
|
-
|
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
|
-
|
218
|
+
throw new JacksonError('Redirect URL is not allowed.', 403);
|
211
219
|
}
|
212
220
|
|
213
221
|
let params = {
|
@@ -218,33 +226,34 @@ const samlResponse = async (req, res) => {
|
|
218
226
|
params.state = session.state;
|
219
227
|
}
|
220
228
|
|
221
|
-
|
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 (
|
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
|
-
} =
|
244
|
+
} = body;
|
236
245
|
|
237
246
|
if (grant_type !== 'authorization_code') {
|
238
|
-
|
247
|
+
throw new JacksonError('Unsupported grant_type', 400);
|
239
248
|
}
|
240
249
|
|
241
250
|
if (!code) {
|
242
|
-
|
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
|
-
|
256
|
+
throw new JacksonError('Invalid code', 403);
|
248
257
|
}
|
249
258
|
|
250
259
|
if (client_id && client_secret) {
|
@@ -256,7 +265,7 @@ const token = async (req, res) => {
|
|
256
265
|
client_id !== codeVal.clientID ||
|
257
266
|
client_secret !== codeVal.clientSecret
|
258
267
|
) {
|
259
|
-
|
268
|
+
throw new JacksonError('Invalid client_id or client_secret', 401);
|
260
269
|
}
|
261
270
|
}
|
262
271
|
} else if (code_verifier) {
|
@@ -267,12 +276,13 @@ const token = async (req, res) => {
|
|
267
276
|
}
|
268
277
|
|
269
278
|
if (codeVal.session.code_challenge !== cv) {
|
270
|
-
|
279
|
+
throw new JacksonError('Invalid code_verifier', 401);
|
271
280
|
}
|
272
281
|
} else if (codeVal && codeVal.session) {
|
273
|
-
|
274
|
-
|
275
|
-
|
282
|
+
throw new JacksonError(
|
283
|
+
'Please specify client_secret or code_verifier',
|
284
|
+
401
|
285
|
+
);
|
276
286
|
}
|
277
287
|
|
278
288
|
// store details against a token
|
@@ -280,24 +290,17 @@ const token = async (req, res) => {
|
|
280
290
|
|
281
291
|
await tokenStore.put(token, codeVal.profile);
|
282
292
|
|
283
|
-
|
293
|
+
return {
|
284
294
|
access_token: token,
|
285
295
|
token_type: 'bearer',
|
286
296
|
expires_in: options.db.ttl,
|
287
|
-
}
|
297
|
+
};
|
288
298
|
};
|
289
299
|
|
290
|
-
const userInfo = async (
|
291
|
-
|
292
|
-
|
293
|
-
// check for query param
|
294
|
-
if (!token) {
|
295
|
-
token = req.query.access_token;
|
296
|
-
}
|
297
|
-
|
298
|
-
const profile = await tokenStore.get(token);
|
300
|
+
const userInfo = async (token) => {
|
301
|
+
const { claims } = await tokenStore.get(token);
|
299
302
|
|
300
|
-
|
303
|
+
return claims;
|
301
304
|
};
|
302
305
|
|
303
306
|
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
|
20
|
+
const { redirect_url } = await oauthController.authorize(req.query);
|
21
|
+
|
22
|
+
res.redirect(redirect_url);
|
21
23
|
} catch (err) {
|
22
|
-
|
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
|
32
|
+
const { redirect_url } = await oauthController.samlResponse(req.body);
|
33
|
+
|
34
|
+
res.redirect(redirect_url);
|
29
35
|
} catch (err) {
|
30
|
-
|
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
|
44
|
+
const result = await oauthController.token(req.body);
|
45
|
+
|
46
|
+
res.json(result);
|
37
47
|
} catch (err) {
|
38
|
-
|
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
|
-
|
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
|
-
|
71
|
+
const { message, statusCode = 500 } = err;
|
72
|
+
|
73
|
+
res.status(statusCode).json({ message });
|
47
74
|
}
|
48
75
|
});
|
49
76
|
|
@@ -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
|
+
});
|