Skip to content

Commit 3dc5ed9

Browse files
authored
Merge pull request #119 from rapid7/multiple-role-support
Add multiple role support
2 parents 5f45c37 + bff6321 commit 3dc5ed9

File tree

21 files changed

+664
-79
lines changed

21 files changed

+664
-79
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,24 @@ Replace the "arn:aws:iam:role" value with the ARN of the role in AWS you
187187
created. Replace the "arn:aws:iam:provider" value with the ARN of the identity
188188
provider in AWS your created.
189189

190+
191+
##### Multiple Role Support
192+
To support multiple roles, add multiple values to the `https://aws.amazon.com/SAML/Attributes/Role`
193+
attribute. For example:
194+
195+
```
196+
arn:aws:iam:role1,arn:aws:iam:provider
197+
arn:aws:iam:role2,arn:aws:iam:provider
198+
arn:aws:iam:role3,arn:aws:iam:provider
199+
```
200+
201+
*Special note for Okta users*: Multiple roles must be passed as multiple values to a single
202+
attribute key. By default, Okta serializes multiple values into a single value using commas.
203+
To support multiple roles, you must contact Okta support and request that the
204+
`SAML_SUPPORT_ARRAY_ATTRIBUTES` feature flag be enabled on your Okta account. For more details
205+
see [this post](https://devforum.okta.com/t/multivalued-attributes/179).
206+
207+
190208
### 5. Run Awsaml and give it your application's metadata.
191209
You can find a prebuilt binary for Awsaml on [the releases page][releases]. Grab
192210
the appropriate binary for your architecture and run the Awsaml application. It

api/routes/auth.js

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,73 @@ module.exports = (app, auth) => {
88
failureFlash: true,
99
failureRedirect: app.get('configureUrl'),
1010
}), (req, res) => {
11-
const arns = req.user['https://aws.amazon.com/SAML/Attributes/Role'].split(',');
12-
13-
/* eslint-disable no-param-reassign */
14-
req.session.passport.samlResponse = req.body.SAMLResponse;
15-
req.session.passport.roleArn = arns[0];
16-
req.session.passport.principalArn = arns[1];
17-
req.session.passport.accountId = arns[0].split(':')[4]; // eslint-disable-line rapid7/static-magic-numbers
18-
/* eslint-enable no-param-reassign */
11+
let roleAttr = req.user['https://aws.amazon.com/SAML/Attributes/Role'];
1912
let frontend = process.env.ELECTRON_START_URL || app.get('baseUrl');
2013

2114
frontend = new url.URL(frontend);
22-
frontend.searchParams.set('auth', 'true');
15+
16+
// Convert roleAttr to an array if it isn't already one
17+
if (!Array.isArray(roleAttr)) {
18+
roleAttr = [roleAttr];
19+
}
20+
21+
const roles = roleAttr.map((arns, i) => {
22+
const [roleArn, principalArn] = arns.split(',');
23+
const roleArnSegments = roleArn.split(':');
24+
const accountId = roleArnSegments[4];
25+
const roleName = roleArnSegments[5].replace('role/', '');
26+
27+
return {
28+
accountId,
29+
index: i,
30+
principalArn,
31+
roleArn,
32+
roleName,
33+
};
34+
});
35+
36+
const session = req.session.passport;
37+
38+
session.samlResponse = req.body.SAMLResponse;
39+
session.roles = roles;
40+
41+
if (roles.length > 1) {
42+
// If the session has a previous role, see if it matches
43+
// the latest roles from the current SAML assertion. If it
44+
// doesn't match, wipe it from the session.
45+
if (session.roleArn && session.principalArn) {
46+
const found = roles.find((role) =>
47+
role.roleArn === session.roleArn && role.principalArn === session.principalArn
48+
);
49+
50+
if (!found) {
51+
session.showRole = undefined;
52+
session.roleArn = undefined;
53+
session.roleName = undefined;
54+
session.principalArn = undefined;
55+
session.accountId = undefined;
56+
}
57+
}
58+
59+
// If the session still has a previous role, proceed directly to auth.
60+
// Otherwise ask the user to select a role.
61+
if (session.roleArn && session.principalArn && session.roleName && session.accountId) {
62+
frontend.searchParams.set('auth', 'true');
63+
} else {
64+
frontend.searchParams.set('select-role', 'true');
65+
}
66+
} else {
67+
const role = roles[0];
68+
69+
frontend.searchParams.set('auth', 'true');
70+
71+
session.showRole = false;
72+
session.roleArn = role.roleArn;
73+
session.roleName = role.roleName;
74+
session.principalArn = role.principalArn;
75+
session.accountId = role.accountId;
76+
}
77+
2378
res.redirect(frontend);
2479
});
2580

api/routes/configure.js

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,25 @@ const HTTP_OK = 200;
1111
const Errors = {
1212
invalidMetadataErr: 'The SAML metadata is invalid.',
1313
urlInvalidErr: 'The SAML metadata URL is invalid.',
14+
uuidInvalidError: 'The profile is invalid.',
1415
};
1516
const ResponseObj = require('./../response');
1617

1718
module.exports = (app, auth) => {
1819
router.get('/', (req, res) => {
19-
const storedMetadataUrls = Storage.get('metadataUrls') || [];
20-
2120
// Migrate metadataUrls to include a profileUuid. This makes
2221
// profile deletes/edits a little safer since they will no longer be
2322
// based on the iteration index.
2423
let migrated = false;
25-
26-
storedMetadataUrls.forEach((metadata) => {
24+
const storedMetadataUrls = (Storage.get('metadataUrls') || []).map((metadata) => {
2725
if (metadata.profileUuid === undefined) {
2826
migrated = true;
2927
metadata.profileUuid = uuidv4();
3028
}
29+
30+
return metadata;
3131
});
32+
3233
if (migrated) {
3334
Storage.set('metadataUrls', storedMetadataUrls);
3435
}
@@ -59,7 +60,11 @@ module.exports = (app, auth) => {
5960
});
6061

6162
router.post('/', (req, res) => {
63+
const profileUuid = req.body.profileUuid;
64+
const profileName = req.body.profileName;
6265
const metadataUrl = req.body.metadataUrl;
66+
let storedMetadataUrls = Storage.get('metadataUrls') || [];
67+
let profile;
6368

6469
if (!metadataUrl) {
6570
Storage.set('metadataUrlValid', false);
@@ -71,23 +76,44 @@ module.exports = (app, auth) => {
7176
}));
7277
}
7378

74-
const origin = req.body.origin;
75-
const metaDataResponseObj = Object.assign({}, ResponseObj, {defaultMetadataUrl: metadataUrl});
79+
// If a profileUuid is passed, validate it and update storage
80+
// with the submitted profile name.
81+
if (profileUuid) {
82+
profile = storedMetadataUrls.find((metadata) => metadata.profileUuid === profileUuid);
7683

77-
let storedMetadataUrls = Storage.get('metadataUrls') || [];
78-
const profileName = req.body.profileName === '' ? metadataUrl : req.body.profileName;
79-
const profile = storedMetadataUrls.find((profile) => profile.url === metadataUrl);
84+
if (!profile) {
85+
return res.status(404).json(Object.assign({}, ResponseObj, {
86+
error: Errors.uuidInvalidErr,
87+
uuidUrlValid: false,
88+
}));
89+
}
90+
91+
if (profile.url !== metadataUrl) {
92+
return res.status(422).json(Object.assign({}, ResponseObj, {
93+
error: Errors.urlInvalidErr,
94+
metadataUrlValid: false,
95+
}));
96+
}
8097

81-
storedMetadataUrls = storedMetadataUrls.map((storedMetadataUrl) => {
82-
if (profileName && storedMetadataUrl.url === metadataUrl && storedMetadataUrl.name !== profileName) {
83-
storedMetadataUrl.name = profileName;
98+
if (profileName) {
99+
storedMetadataUrls = storedMetadataUrls.map((metadata) => {
100+
if (metadata.profileUuid === profileUuid && metadata.name !== profileName) {
101+
metadata.name = profileName;
102+
}
103+
104+
return metadata;
105+
});
106+
Storage.set('metadataUrls', storedMetadataUrls);
84107
}
108+
} else {
109+
profile = storedMetadataUrls.find((metadata) => metadata.url === metadataUrl);
110+
}
85111

86-
return storedMetadataUrl;
87-
});
88-
Storage.set('metadataUrls', storedMetadataUrls);
89112
app.set('metadataUrl', metadataUrl);
90113

114+
const origin = req.body.origin;
115+
const metaDataResponseObj = Object.assign({}, ResponseObj, {defaultMetadataUrl: metadataUrl});
116+
91117
const xmlReq = https.get(metadataUrl, (xmlRes) => {
92118
let xml = '';
93119

@@ -141,18 +167,22 @@ module.exports = (app, auth) => {
141167

142168
if (cert && issuer && entryPoint) {
143169
Storage.set('previousMetadataUrl', metadataUrl);
144-
const metadataUrls = Storage.get('metadataUrls') || [];
145-
146-
Storage.set(
147-
'metadataUrls',
148-
profile ? metadataUrls : metadataUrls.concat([
149-
{
150-
name: profileName || metadataUrl,
151-
profileUuid: uuidv4(),
152-
url: metadataUrl,
153-
},
154-
])
155-
);
170+
171+
// Add a profile for this URL if one does not already exist
172+
if (!profile) {
173+
const metadataUrls = Storage.get('metadataUrls') || [];
174+
175+
Storage.set(
176+
'metadataUrls',
177+
metadataUrls.concat([
178+
{
179+
name: profileName || metadataUrl,
180+
profileUuid: uuidv4(),
181+
url: metadataUrl,
182+
},
183+
])
184+
);
185+
}
156186

157187
app.set('entryPointUrl', config.auth.entryPoint);
158188
auth.configure(config.auth);

api/routes/refresh.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ module.exports = (app) => {
2020

2121
const refreshResponseObj = Object.assign({}, ResponseObj, {
2222
accountId: session.accountId,
23+
roleName: session.roleName,
24+
showRole: session.showRole,
2325
});
2426

2527
sts.assumeRoleWithSAML({
@@ -42,17 +44,28 @@ module.exports = (app) => {
4244

4345
const profileName = `awsaml-${session.accountId}`;
4446
const metadataUrl = app.get('metadataUrl');
45-
// If the stored metadataUrl label value is the same as the URL default to the profile name!
46-
const metadataUrls = Storage.get('metadataUrls', []).map((storedMetadataUrl) => {
47-
if (storedMetadataUrl.url === metadataUrl && storedMetadataUrl.name === metadataUrl) {
48-
storedMetadataUrl.name = profileName;
47+
48+
// Update the stored profile with account number(s) and profile names
49+
const metadataUrls = (Storage.get('metadataUrls') || []).map((metadata) => {
50+
if (metadata.url === metadataUrl) {
51+
// If the stored metadataUrl label value is the same as the URL
52+
// default to the profile name!
53+
if (metadata.name === metadataUrl) {
54+
metadata.name = profileName;
55+
}
56+
metadata.roles = session.roles.map((role) => role.roleArn);
4957
}
5058

51-
return storedMetadataUrl;
59+
return metadata;
5260
});
5361

5462
Storage.set('metadataUrls', metadataUrls);
5563

64+
// Fetch the metadata profile name for this URL
65+
const profile = metadataUrls.find((metadata) => metadata.url === metadataUrl);
66+
67+
credentialResponseObj.profileName = profile.name;
68+
5669
credentials.save(data.Credentials, profileName, (credSaveErr) => {
5770
if (credSaveErr) {
5871
res.json(Object.assign({}, credentialResponseObj, {

api/routes/select-role.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const express = require('express');
2+
3+
const router = express.Router();
4+
5+
module.exports = () => {
6+
router.get('/', (req, res) => {
7+
const session = req.session.passport;
8+
9+
if (!session) {
10+
return res.status(401).json({
11+
error: 'Invalid session',
12+
});
13+
}
14+
15+
res.json({
16+
roles: session.roles,
17+
});
18+
});
19+
20+
router.post('/', (req, res) => {
21+
const session = req.session.passport;
22+
23+
if (!session) {
24+
return res.status(401).json({
25+
error: 'Invalid session',
26+
});
27+
}
28+
29+
if (req.body.index === undefined) {
30+
return res.status(422).json({
31+
error: 'Missing role',
32+
});
33+
}
34+
35+
const role = session.roles[req.body.index];
36+
37+
session.showRole = true;
38+
session.roleArn = role.roleArn;
39+
session.roleName = role.roleName;
40+
session.principalArn = role.principalArn;
41+
session.accountId = role.accountId;
42+
43+
res.json({
44+
status: 'selected',
45+
});
46+
});
47+
48+
return router;
49+
};

api/server.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const app = require('./server-config')(auth, config, sessionSecret);
2222
}, {
2323
name: '/profile',
2424
route: require('./routes/profile')(),
25+
}, {
26+
name: '/select-role',
27+
route: require('./routes/select-role')(),
2528
}, {
2629
name: '/',
2730
route: require('./routes/static')(),

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
},
1313
"scripts": {
1414
"electron": "electron electron/electron.js",
15-
"electron-dev": "NODE_ENV=development; ELECTRON_START_URL=http://localhost:3000 electron electron/electron.js",
15+
"electron-dev": "NODE_ENV=development ELECTRON_START_URL=http://localhost:3000 electron electron/electron.js",
1616
"react-start": "BROWSER=none; NODE_ENV=development react-scripts start",
1717
"react-build": "react-scripts build",
1818
"test": "mocha",

0 commit comments

Comments
 (0)