Skip to content

Commit

Permalink
Merge pull request #637 from particle-iot/feature/sc-117249/add-a-sso…
Browse files Browse the repository at this point in the history
…-flag-to-cli-login

feature/sc-117249/add-a-sso-flag-to-cli-login
  • Loading branch information
hugomontero authored Apr 12, 2023
2 parents b51aa57 + e7f89fc commit 93403e4
Show file tree
Hide file tree
Showing 10 changed files with 344 additions and 6 deletions.
4 changes: 2 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,14 @@ workflows:
matrix:
parameters:
os: [linux, macos]
node-version: ["10", "12", "14", "16"]
node-version: ["12", "16"] # Node 18 doesn't work due to serialport dependency
- test-windows:
<<: *tag_filters
context:
- particle-ci-private
matrix:
parameters:
node-version: ["10", "12", "14", "16"]
node-version: ["12", "16"] # Node 18 doesn't work due to serialport dependency
- test-e2e:
<<: *tag_filters
name: test-e2e-linux
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ accept/tmp

.nyc_output
.env
.idea/
51 changes: 51 additions & 0 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@
"glob": "^7.1.6",
"handlebars": "^4.1.2",
"inquirer": "^6.5.2",
"jose": "^4.13.1",
"latest-version": "^2.0.0",
"lodash": "^4.17.15",
"moment": "^2.24.0",
"node-wifiscanner2": "^1.2.0",
"openurl": "^1.1.1",
"particle-api-js": "^9.1.2",
"particle-commands": "0.3.0",
"particle-library-manager": "^0.1.14",
Expand All @@ -92,6 +94,7 @@
"github-api": "^3.3.0",
"mocha": "^6.2.2",
"mock-fs": "^4.10.4",
"nock": "^13.3.0",
"nyc": "^14.1.1",
"proxyquire": "^2.1.3",
"sinon": "^7.5.0",
Expand Down
15 changes: 15 additions & 0 deletions settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,21 @@ settings.saveProfileData = () => {
fs.writeFileSync(proFile, JSON.stringify(settings.profile_json, null, 2), { mode: '600' });
};

settings.ssoAuthConfig = () => {
const isProduction = settings.apiUrl === 'https://api.particle.io';
if (isProduction) {
return {
ssoAuthUri: 'https://id.particle.io/oauth2/default/v1',
ssoClientId: '0oa19uiy26XIs3XW55d7'
};
} else {
return {
ssoAuthUri: 'https://id.staging.particle.io/oauth2/default/v1',
ssoClientId: '0oa19umyki69O4Kvb5d7'
};
}
};

// this is here instead of utilities to prevent a require-loop
// when files that utilties requires need settings
function matchKey(needle, obj, caseInsensitive) {
Expand Down
7 changes: 6 additions & 1 deletion src/cli/cloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ module.exports = ({ commandProcessor, root }) => {
description: 'an existing Particle access token to use',
alias: 'token'
},
sso: {
description: 'Enterprise sso login',
boolean: true
},
otp: {
description: 'the login code if two-step authentication is enabled'
}
Expand All @@ -142,7 +146,8 @@ module.exports = ({ commandProcessor, root }) => {
examples: {
'$0 $command': 'prompt for credentials and log in',
'$0 $command --username user@example.com --password test': 'log in with credentials provided on the command line',
'$0 $command --token <my-api-token>': 'log in with an access token provided on the command line'
'$0 $command --token <my-api-token>': 'log in with an access token provided on the command line',
'$0 $command --sso ': 'log in with Enterprise sso'
}
});

Expand Down
2 changes: 2 additions & 0 deletions src/cli/cloud.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -399,12 +399,14 @@ describe('Cloud Command-Line Interface', () => {
' -u, --username your username [string]',
' -p, --password your password [string]',
' -t, --token an existing Particle access token to use [string]',
' --sso Enterprise sso login [boolean]',
' --otp the login code if two-step authentication is enabled [string]',
'',
'Examples:',
' particle cloud login prompt for credentials and log in',
' particle cloud login --username user@example.com --password test log in with credentials provided on the command line',
' particle cloud login --token <my-api-token> log in with an access token provided on the command line',
' particle cloud login --sso log in with Enterprise sso',
''
].join('\n'));
});
Expand Down
23 changes: 20 additions & 3 deletions src/cmd/cloud.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const fs = require('fs-extra');
const path = require('path');
const extend = require('xtend');
const chalk = require('chalk');
const { ssoLogin, waitForLogin, getLoginMessage } = require('../lib/sso');

const arrow = chalk.green('>');
const alert = chalk.yellow('!');
Expand Down Expand Up @@ -390,11 +391,16 @@ module.exports = class CloudCommand extends CLICommandBase {
});
}

login({ username, password, token, otp } = {}){
const shouldRetry = !((username && password) || token && !this.tries);
login({ username, password, token, otp, sso } = {}){

const shouldRetry = !((username && password) || (token || sso) && !this.tries);


return Promise.resolve()
.then(() => {
if (sso) {
return { sso };
}
if (token){
return { token, username, password };
}
Expand All @@ -404,12 +410,23 @@ module.exports = class CloudCommand extends CLICommandBase {
return prompts.getCredentials(username, password);
})
.then(credentials => {
const { token, username, password } = credentials;
const { token, username, password, sso } = credentials;
const msg = 'Sending login details...';
const api = new ApiClient();

this._usernameProvided = username;

if (sso) {
const ssoMessage = 'SSO login in progress...';
return ssoLogin().then(({ deviceCode, verificationUriComplete }) => {
getLoginMessage(verificationUriComplete).map(msg => {
this.ui.stdout.write(`${msg}${os.EOL}`);
});
return this.ui.showBusySpinnerUntilResolved(ssoMessage, waitForLogin({ deviceCode }))
.then(response => ({ token: response.token, username: response.username }));
});
}

if (token){
return this.ui.showBusySpinnerUntilResolved(msg, api.getUser(token))
.then(response => ({ token, username: response.username }));
Expand Down
94 changes: 94 additions & 0 deletions src/lib/sso.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const request = require('request');
const jose = require('jose');
const openurl = require('openurl');
const settings = require('../../settings');
const WAIT_BETWEEN_REQUESTS = 5000;


const sleep = ms => new Promise(r => setTimeout(r, ms));
const _makeRequest = async ({ url, method, form }) => {
return new Promise((resolve, reject) => {
const requestData = { url, method, form };

request(requestData, function cb(error, response, body) {
if (error) {
return reject(error);
}
return resolve(JSON.parse(body));
});

});
};

const _getKeySet = (url) => {
return jose.createRemoteJWKSet(new URL(`${url}/keys`));
};

const _validateJwtToken = async (accessToken, url) => {
return jose.jwtVerify(accessToken, _getKeySet(url));
};

const waitForLogin = async ({ deviceCode, waitTime }) => {
let canRequest = true;
const ssoConfig = settings.ssoAuthConfig();
const url = `${ssoConfig.ssoAuthUri}/token`;
const clientId = ssoConfig.ssoClientId;
const form = {
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
client_id: clientId,
};

while (canRequest) {
const response = await _makeRequest({ url, form, method: 'POST' });
if (response.error === 'authorization_pending') {
await sleep(waitTime || WAIT_BETWEEN_REQUESTS);
} else {
canRequest = false;
if (response.error) {
throw new Error(response.error_description);
}
if (response.access_token) {
const { payload } = await _validateJwtToken(response.access_token, ssoConfig.ssoAuthUri);
return { token: payload.particle_profile, username: payload.sub };
}
throw new Error('Unable to login through sso. Try again');
}
}
};

const getLoginMessage = (verificationUriComplete) => {
return [
'Opening the SSO authorization page in your default browser.',
'If the browser does not open or you wish to use a different device to authorize this request, open the following URL:',
verificationUriComplete
];
};

const ssoLogin = async () => {
const ssoConfig = settings.ssoAuthConfig();
const form = {
client_id: ssoConfig.ssoClientId,
scope: 'openid profile'
};

const response = await _makeRequest({
url: `${ssoConfig.ssoAuthUri}/device/authorize`,
form,
method: 'POST'
});

openurl.open(response.verification_uri_complete);

return { deviceCode: response.device_code, verificationUriComplete: response.verification_uri_complete };
};



module.exports = {
ssoLogin,
_makeRequest,
waitForLogin,
getLoginMessage

};
Loading

0 comments on commit 93403e4

Please sign in to comment.