diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000000..993901c55b --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,34 @@ +name: CD + +on: + push: + branches: + - master + +jobs: + build: + name: Build, Test, and Deploy + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc + - uses: actions/setup-node@v1 + with: + node-version: 14.x + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Restore Dependency Cache + uses: actions/cache@v1 + with: + path: ~/.npm + key: ${{ runner.OS }}-dependency-cache-${{ hashFiles('**/package.json') }} + - run: npm install + - run: npm run bootstrap + - run: npm run publish:ci -- --yes + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + GIT_AUTHOR_NAME: Ionitron + GIT_AUTHOR_EMAIL: hi@ionicframework.com + GIT_COMMITTER_NAME: Ionitron + GIT_COMMITTER_EMAIL: hi@ionicframework.com diff --git a/packages/@ionic/cli-framework/CHANGELOG.md b/packages/@ionic/cli-framework/CHANGELOG.md index dae024ae9b..48aa76ab19 100644 --- a/packages/@ionic/cli-framework/CHANGELOG.md +++ b/packages/@ionic/cli-framework/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [4.2.0](https://github.com/ionic-team/ionic-cli/compare/@ionic/cli-framework@4.1.5...@ionic/cli-framework@4.2.0) (2020-06-02) + + +### Features + +* **help:** formatBeforeInputSummary and formatAfterInputSummary ([70ac3ab](https://github.com/ionic-team/ionic-cli/commit/70ac3ab633dc7dd65a2ac344c81a01f7914597b8)) + + + + + ## [4.1.5](https://github.com/ionic-team/ionic-cli/compare/@ionic/cli-framework@4.1.4...@ionic/cli-framework@4.1.5) (2020-05-12) diff --git a/packages/@ionic/cli-framework/package.json b/packages/@ionic/cli-framework/package.json index f7f51ef85b..664e72bccc 100644 --- a/packages/@ionic/cli-framework/package.json +++ b/packages/@ionic/cli-framework/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/cli-framework", - "version": "4.1.5", + "version": "4.2.0", "description": "The foundation framework of the Ionic CLI", "homepage": "https://ionicframework.com/", "author": "Ionic Team (https://ionicframework.com) ", @@ -46,7 +46,7 @@ "@types/lodash": "^4.14.104", "@types/minimist": "^1.2.0", "@types/node": "~10.17.13", - "@types/slice-ansi": "^2.0.0", + "@types/slice-ansi": "^4.0.0", "@types/wrap-ansi": "^3.0.0", "@types/write-file-atomic": "^3.0.0", "jest": "^25.1.0", diff --git a/packages/@ionic/cli-framework/src/definitions.ts b/packages/@ionic/cli-framework/src/definitions.ts index 42c29b7d3f..405dba8576 100644 --- a/packages/@ionic/cli-framework/src/definitions.ts +++ b/packages/@ionic/cli-framework/src/definitions.ts @@ -15,7 +15,6 @@ export interface CommandMetadataInput { name: string; summary: string; validators?: Validator[]; - private?: boolean; } export interface TextFootnote { diff --git a/packages/@ionic/cli-framework/src/lib/__tests__/help.ts b/packages/@ionic/cli-framework/src/lib/__tests__/help.ts index 9ecb02018c..f367d6d6fa 100644 --- a/packages/@ionic/cli-framework/src/lib/__tests__/help.ts +++ b/packages/@ionic/cli-framework/src/lib/__tests__/help.ts @@ -147,8 +147,8 @@ describe('@ionic/cli-framework', () => { Inputs: - input1 .......................... input1 summary - input2 .......................... input2 summary + input1 .......................... (optional) input1 summary + input2 .......................... (optional) input2 summary Options: diff --git a/packages/@ionic/cli-framework/src/lib/help.ts b/packages/@ionic/cli-framework/src/lib/help.ts index 0ee16e20ef..eb1f97b795 100644 --- a/packages/@ionic/cli-framework/src/lib/help.ts +++ b/packages/@ionic/cli-framework/src/lib/help.ts @@ -492,7 +492,7 @@ export class CommandStringHelpFormatter, N ext } async formatInputs(): Promise { - const { help: { title }, weak, input } = this.colors; + const { help: { title } } = this.colors; const metadata = await this.getCommandMetadata(); const inputs = metadata.inputs ? metadata.inputs : []; @@ -500,19 +500,28 @@ export class CommandStringHelpFormatter, N ext return ''; } - const fillStrings = generateFillSpaceStringList(inputs.map(i => i.name), this.dotswidth, weak('.')); - - const inputLineFn = ({ name, summary }: I, index: number) => { - const optionList = input(`${name}`); - const wrappedSummary = wordWrap(summary, { indentation: this.dotswidth + 6 }); - - return `${optionList} ${fillStrings[index]} ${wrappedSummary}`; - }; + const formattedInputs = await Promise.all(inputs.map(input => this.formatInput(input))); return ( `\n ${title('Inputs')}:` + - `\n\n ${inputs.map(inputLineFn).join('\n ')}\n` + `\n\n ${formattedInputs.join('\n ')}\n` + ); + } + + async formatInput(i: I): Promise { + const { input, weak } = this.colors; + const inputName = input(i.name); + const inputNameLength = stringWidth(inputName); + const fullLength = inputNameLength > this.dotswidth ? inputNameLength + 1 : this.dotswidth; + const fullDescription = ( + (await this.formatBeforeInputSummary(i)) + + i.summary + + (await this.formatAfterInputSummary(i)) ); + + const wrappedDescription = wordWrap(fullDescription, { indentation: this.dotswidth + 6 }); + + return `${inputName} ${weak('.').repeat(fullLength - inputNameLength)} ${wrappedDescription}`; } async formatOptionLine(opt: O) { @@ -534,7 +543,7 @@ export class CommandStringHelpFormatter, N ext } /** - * Insert text before the command's summary. + * Insert text that appears before the command's summary. * * @param meta The metadata of the command. */ @@ -543,7 +552,7 @@ export class CommandStringHelpFormatter, N ext } /** - * Insert text after the command's summary. + * Insert text that appears after the command's summary. * * @param meta The metadata of the command. */ @@ -551,6 +560,27 @@ export class CommandStringHelpFormatter, N ext return ''; } + /** + * Insert text that appears before the input's summary. + * + * @param input The metadata of the input. + */ + async formatBeforeInputSummary(input: I): Promise { + const { weak } = this.colors; + const required = input.validators && input.validators.includes(validators.required) ? true : false; + + return required ? '' : `${weak('(optional)')} `; + } + + /** + * Insert text that appears after the input's summary. + * + * @param input The metadata of the input. + */ + async formatAfterInputSummary(input: I): Promise { + return ''; + } + /** * Insert text that appears before an option's summary. * @@ -560,6 +590,11 @@ export class CommandStringHelpFormatter, N ext return formatHelpGroups(opt.groups, this.colors); } + /** + * Insert text that appears after the option's summary. + * + * @param opt The metadata of the option. + */ async formatAfterOptionSummary(opt: O): Promise { return this.formatOptionDefault(opt); } diff --git a/packages/@ionic/cli/CHANGELOG.md b/packages/@ionic/cli/CHANGELOG.md index 50790b75a4..e124956fea 100644 --- a/packages/@ionic/cli/CHANGELOG.md +++ b/packages/@ionic/cli/CHANGELOG.md @@ -3,6 +3,46 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [6.10.0](https://github.com/ionic-team/ionic-cli/compare/@ionic/cli@6.9.3...@ionic/cli@6.10.0) (2020-06-02) + + +### Bug Fixes + +* **cordova:** platform is a required input ([1bd3087](https://github.com/ionic-team/ionic-cli/commit/1bd30874f46d89248193c959dd8d769dd99503cf)) + + +### Features + +* **login:** default to browser login ([#4451](https://github.com/ionic-team/ionic-cli/issues/4451)) ([0738abd](https://github.com/ionic-team/ionic-cli/commit/0738abd4123b6b5e078cd7ff2f76ed1a26474cf0)) + + + + + +## [6.9.3](https://github.com/ionic-team/ionic-cli/compare/@ionic/cli@6.9.2...@ionic/cli@6.9.3) (2020-05-27) + + +### Bug Fixes + +* **start:** fix issue with starting the react conf app ([daf1f72](https://github.com/ionic-team/ionic-cli/commit/daf1f72372ca3966c57c7e32adc38001cf3a5fdb)) + + + + + +## [6.9.2](https://github.com/ionic-team/ionic-cli/compare/@ionic/cli@6.9.1...@ionic/cli@6.9.2) (2020-05-17) + + +### Bug Fixes + +* **link:** fix GitHub OAuth URL on WSL ([#4442](https://github.com/ionic-team/ionic-cli/issues/4442)) ([93c5bf5](https://github.com/ionic-team/ionic-cli/commit/93c5bf5ba0cc0549a4bf61f534ee30d465bfe35e)) +* **package:** fix name of the package deploy name ([#4435](https://github.com/ionic-team/ionic-cli/issues/4435)) ([6f6f9d7](https://github.com/ionic-team/ionic-cli/commit/6f6f9d7540fc064b3ffcbc05b56441f9b5c2efeb)) +* **start:** do not prompt for project type when cloning ([#4427](https://github.com/ionic-team/ionic-cli/issues/4427)) ([4ff1622](https://github.com/ionic-team/ionic-cli/commit/4ff162281e327b4740e92513925b6d37b9dfb71e)) + + + + + ## [6.9.1](https://github.com/ionic-team/ionic-cli/compare/@ionic/cli@6.9.0...@ionic/cli@6.9.1) (2020-05-12) diff --git a/packages/@ionic/cli/assets/oauth/success/index.html b/packages/@ionic/cli/assets/oauth/success/index.html new file mode 100644 index 0000000000..67bdc47711 --- /dev/null +++ b/packages/@ionic/cli/assets/oauth/success/index.html @@ -0,0 +1,106 @@ + + + + + Success + + + +
+ + + + + +
+
+
+

You are authenticated.

+

Please return to your terminal. You may close this window.

+
+
+ + diff --git a/packages/@ionic/cli/assets/sso/success/index.html b/packages/@ionic/cli/assets/sso/success/index.html deleted file mode 100644 index 7737fb406d..0000000000 --- a/packages/@ionic/cli/assets/sso/success/index.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - Success - - - -
-

You are authenticated.

-

Please return to your terminal. You may close this window.

-
- - diff --git a/packages/@ionic/cli/package.json b/packages/@ionic/cli/package.json index d07958a026..110bebdbef 100644 --- a/packages/@ionic/cli/package.json +++ b/packages/@ionic/cli/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/cli", - "version": "6.9.1", + "version": "6.10.0", "description": "A tool for creating and developing Ionic Framework mobile apps.", "homepage": "https://ionicframework.com", "author": "Ionic Team (https://ionicframework.com) ", @@ -40,7 +40,7 @@ }, "license": "MIT", "dependencies": { - "@ionic/cli-framework": "4.1.5", + "@ionic/cli-framework": "4.2.0", "@ionic/cli-framework-prompts": "2.1.3", "@ionic/utils-array": "2.1.3", "@ionic/utils-fs": "3.1.3", @@ -55,7 +55,7 @@ "elementtree": "^0.1.7", "leek": "0.0.24", "lodash": "^4.17.5", - "open": "^7.0.0", + "open": "^7.0.4", "os-name": "^3.1.0", "semver": "^7.1.1", "split2": "^3.0.0", @@ -66,7 +66,7 @@ "tar": "^6.0.1", "through2": "^3.0.0", "tslib": "1.11.2", - "uuid": "^7.0.1" + "uuid": "^8.1.0" }, "devDependencies": { "@types/debug": "^4.1.1", @@ -83,7 +83,7 @@ "@types/superagent-proxy": "^2.0.0", "@types/tar": "^4.0.0", "@types/through2": "^2.0.33", - "@types/uuid": "^7.0.0", + "@types/uuid": "^8.0.0", "jest": "^25.1.0", "jest-cli": "^26.0.1", "lint-staged": "^10.0.2", diff --git a/packages/@ionic/cli/src/commands/cordova/run.ts b/packages/@ionic/cli/src/commands/cordova/run.ts index 1e9190ed3a..69548adc75 100644 --- a/packages/@ionic/cli/src/commands/cordova/run.ts +++ b/packages/@ionic/cli/src/commands/cordova/run.ts @@ -1,4 +1,4 @@ -import { Footnote, MetadataGroup } from '@ionic/cli-framework'; +import { Footnote, MetadataGroup, validators } from '@ionic/cli-framework'; import { onBeforeExit, sleepForever } from '@ionic/utils-process'; import * as Debug from 'debug'; import * as lodash from 'lodash'; @@ -149,6 +149,7 @@ Just like with ${input('ionic cordova build')}, you can pass additional options { name: 'platform', summary: `The platform to run (e.g. ${['android', 'ios'].map(v => input(v)).join(', ')})`, + validators: [validators.required], }, ], options, @@ -213,7 +214,7 @@ Just like with ${input('ionic cordova build')}, you can pass additional options const [ platform ] = inputs; - if (options['native-run'] && !SUPPORTED_PLATFORMS.includes(platform)) { + if (platform && options['native-run'] && !SUPPORTED_PLATFORMS.includes(platform)) { this.env.log.warn(`${input(platform)} is not supported by ${input('native-run')}. Using Cordova to run the app.`); options['native-run'] = false; } diff --git a/packages/@ionic/cli/src/commands/deploy/build.ts b/packages/@ionic/cli/src/commands/deploy/build.ts index 3148e86c77..47eab1988e 100644 --- a/packages/@ionic/cli/src/commands/deploy/build.ts +++ b/packages/@ionic/cli/src/commands/deploy/build.ts @@ -87,7 +87,7 @@ Apart from ${input('--commit')}, every option can be specified using the full na throw new FatalException(`Cannot run ${input('ionic deploy build')} outside a project directory.`); } - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const appflowId = await this.project.requireAppflowId(); if (!options.commit) { diff --git a/packages/@ionic/cli/src/commands/git/index.ts b/packages/@ionic/cli/src/commands/git/index.ts index de84b16920..59550c89ee 100644 --- a/packages/@ionic/cli/src/commands/git/index.ts +++ b/packages/@ionic/cli/src/commands/git/index.ts @@ -1,10 +1,13 @@ +import { MetadataGroup } from '@ionic/cli-framework'; + import { CommandMap, Namespace } from '../../lib/namespace'; export class GitNamespace extends Namespace { async getMetadata() { return { name: 'git', - summary: 'Commands relating to git', + summary: 'Commands relating to managing Appflow git', + groups: [MetadataGroup.PAID], }; } diff --git a/packages/@ionic/cli/src/commands/git/remote.ts b/packages/@ionic/cli/src/commands/git/remote.ts index ceb3e93119..0318ae1103 100644 --- a/packages/@ionic/cli/src/commands/git/remote.ts +++ b/packages/@ionic/cli/src/commands/git/remote.ts @@ -33,7 +33,7 @@ ${input('ionic git remote')} will check the local repository for whether or not throw new FatalException(`Cannot run ${input('ionic git remote')} outside a project directory.`); } - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const id = await this.project.requireAppflowId(); const appClient = new AppClient(token, this.env); const app = await appClient.load(id); diff --git a/packages/@ionic/cli/src/commands/link.ts b/packages/@ionic/cli/src/commands/link.ts index fb41242865..3aa1729774 100644 --- a/packages/@ionic/cli/src/commands/link.ts +++ b/packages/@ionic/cli/src/commands/link.ts @@ -230,13 +230,13 @@ If you are having issues linking, please get in touch with our Support[^support- private async getAppClient() { const { AppClient } = await import('../lib/app'); - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); return new AppClient(token, this.env); } private async getUserClient() { const { UserClient } = await import('../lib/user'); - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); return new UserClient(token, this.env); } @@ -397,7 +397,7 @@ If you are having issues linking, please get in touch with our Support[^support- } const url = await userClient.oAuthGithubLogin(userId); - await openUrl(url, { encode: false }); + await openUrl(url); confirm = await this.env.prompt({ type: 'confirm', diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 123e59c36d..f7b2a90065 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -1,4 +1,4 @@ -import { MetadataGroup, combine, validators } from '@ionic/cli-framework'; +import { combine, validators } from '@ionic/cli-framework'; import * as chalk from 'chalk'; import * as readline from 'readline'; @@ -15,19 +15,23 @@ export class LoginCommand extends Command implements CommandPreRun { type: 'global', summary: 'Log in to Ionic', description: ` -Authenticate with Ionic and retrieve a user token, which is stored in the CLI config. The most secure way to log in is running ${input('ionic login')} without arguments, which will prompt you for your credentials. +Authenticate with Ionic and retrieve a user token, which is stored in the CLI config. The most secure way to log in is running ${input('ionic login')} without arguments, which will open a browser where you can submit your credentials. -If the ${input('IONIC_TOKEN')} environment variable is set, the CLI will automatically authenticate you. To retrieve your user token, first use ${input('ionic login')}, then print the token by running the ${input('ionic config get -g tokens.user')} command. +If the ${input('IONIC_TOKEN')} environment variable is set, the CLI will automatically authenticate you. To retrieve your user token, first use ${input('ionic login ')} to log in, then use ${input('ionic config get -g tokens.user')} to print the token. (${strong('Note')}: Tokens retrieved from the browser login are short-lived and not recommended for use with ${input('IONIC_TOKEN')}.) ${input('ionic login')} will also accept ${input('password')} through stdin, e.g.: ${input('echo "" | ionic login ')}. -If you need to create an Ionic account, use ${input('ionic signup')}. +If you need to create an Ionic account, use ${input('ionic signup')} or the Ionic Website[^signup]. You can reset your password in the Dashboard[^reset-password]. If you are having issues logging in, please get in touch with our Support[^support-request]. `, footnotes: [ + { + id: 'signup', + url: 'https://ionicframework.com/signup', + }, { id: 'reset-password', url: 'https://dashboard.ionicframework.com/reset-password', @@ -43,31 +47,18 @@ If you are having issues logging in, please get in touch with our Support[^suppo { name: 'email', summary: 'Your email address', - validators: [validators.required, validators.email], private: true, }, { name: 'password', summary: 'Your password', - // this is a hack since sso is hidden, no need to make password not required for it - validators: process.argv.includes('--sso') ? [] : [validators.required], private: true, }, ], - options: [ - { - name: 'sso', - type: Boolean, - summary: 'Open a window to log in with the SSO provider associated with your email', - groups: [MetadataGroup.HIDDEN], - }, - ], }; } async preRun(inputs: CommandLineInputs, options: CommandLineOptions): Promise { - const sso = !!options['sso']; - if (options['email'] || options['password']) { throw new FatalException( `${input('email')} and ${input('password')} are command arguments, not options. Please try this:\n` + @@ -75,13 +66,22 @@ If you are having issues logging in, please get in touch with our Support[^suppo ); } - const askForEmail = !inputs[0]; - const askForPassword = !sso && !inputs[1]; + if (options['sso']) { + this.env.log.warn( + `The ${strong('--sso')} flag is no longer necessary.\n` + + `SSO login has been upgraded to OpenID login, which is now the new default authentication flow of ${input('ionic login')}. Refresh tokens are used to automatically re-authenticate sessions.` + ); + this.env.log.nl(); + } + + // ask for password only if the user specifies an email + const validateEmail = !!inputs[0]; + const askForPassword = inputs[0] && !inputs[1]; if (this.env.session.isLoggedIn()) { const email = this.env.config.get('user.email'); - const extra = askForEmail || askForPassword + const extra = askForPassword ? (this.env.flags.interactive ? `Prompting for new credentials.\n\nUse ${chalk.yellow('Ctrl+C')} to cancel and remain logged in.` : '') : 'You will be logged out beforehand.'; @@ -105,18 +105,23 @@ If you are having issues logging in, please get in touch with our Support[^suppo } // TODO: combine with promptToLogin ? - - if (askForEmail) { - const email = await this.env.prompt({ - type: 'input', - name: 'email', - message: 'Email:', - validate: v => combine(validators.required, validators.email)(v), - }); - - inputs[0] = email; + if (validateEmail) { + const validatedEmail = validators.email(inputs[0]); + if (validatedEmail !== true) { + this.env.log.warn(`${validatedEmail}. \n Please enter a valid email address.`); + if (this.env.flags.interactive) { + const email = await this.env.prompt({ + type: 'input', + name: 'email', + message: 'Email:', + validate: v => combine(validators.required, validators.email)(v), + }); + inputs[0] = email; + } else { + throw new FatalException('Invalid email'); + } + } } - if (askForPassword) { if (this.env.flags.interactive) { const password = await this.env.prompt({ @@ -150,25 +155,44 @@ If you are having issues logging in, please get in touch with our Support[^suppo async run(inputs: CommandLineInputs, options: CommandLineOptions): Promise { const [ email, password ] = inputs; - const sso = !!options['sso']; - if (this.env.session.isLoggedIn()) { - await this.env.session.logout(); - this.env.config.set('tokens.telemetry', generateUUID()); - } + if (email && password) { + await this.logout(); + await this.env.session.login(email, password); + } else { + if (!this.env.flags.interactive) { + throw new FatalException( + 'Refusing to attempt browser login in non-interactive mode.\n' + + `If you are attempting to log in, make sure you are using a modern, interactive terminal. Otherwise, you can log in using inline username and password with ${input('ionic login ')}. See ${input('ionic login --help')} for more details.` + ); + } - if (sso) { - this.env.log.info( - `Ionic SSO Login\n` + - `During this process, a browser window will open to authenticate you with the identity provider for ${input(email)}. Please leave this process running until authentication is complete.` - ); + this.env.log.info(`During this process, a browser window will open to authenticate you. Please leave this process running until authentication is complete.`); this.env.log.nl(); - await this.env.session.ssoLogin(email); - } else { - await this.env.session.login(email, password); + const login = await this.env.prompt({ + type: 'confirm', + name: 'continue', + message: 'Open the browser to log in to your Ionic account?', + default: true, + }); + + if (login) { + await this.logout(); + await this.env.session.webLogin(); + } else { + return ; + } + } this.env.log.ok(success(strong('You are logged in!'))); } + + async logout() { + if (this.env.session.isLoggedIn()) { + await this.env.session.logout(); + this.env.config.set('tokens.telemetry', generateUUID()); + } + } } diff --git a/packages/@ionic/cli/src/commands/monitoring/syncmaps.ts b/packages/@ionic/cli/src/commands/monitoring/syncmaps.ts index b6dabcc46f..edfcd9474b 100644 --- a/packages/@ionic/cli/src/commands/monitoring/syncmaps.ts +++ b/packages/@ionic/cli/src/commands/monitoring/syncmaps.ts @@ -45,7 +45,7 @@ By default, ${input('ionic monitoring syncmaps')} will upload the sourcemap file throw new FatalException(`Cannot run ${input('ionic monitoring syncmaps')} outside a project directory.`); } - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const appflowId = await this.project.requireAppflowId(); const [ snapshotId ] = inputs; diff --git a/packages/@ionic/cli/src/commands/package/build.ts b/packages/@ionic/cli/src/commands/package/build.ts index 529717ed1d..fe501949fa 100644 --- a/packages/@ionic/cli/src/commands/package/build.ts +++ b/packages/@ionic/cli/src/commands/package/build.ts @@ -213,7 +213,7 @@ This can be used only together with build type ${input('release')} for Android a throw new FatalException(`Cannot run ${input('ionic package build')} outside a project directory.`); } - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const appflowId = await this.project.requireAppflowId(); const [ platform, buildType ] = inputs; diff --git a/packages/@ionic/cli/src/commands/package/deploy.ts b/packages/@ionic/cli/src/commands/package/deploy.ts index 04dc2e8750..586c4cd2dd 100644 --- a/packages/@ionic/cli/src/commands/package/deploy.ts +++ b/packages/@ionic/cli/src/commands/package/deploy.ts @@ -46,7 +46,7 @@ export class DeployCommand extends Command { const dashUrl = this.env.config.getDashUrl(); return { - name: 'build', + name: 'deploy', type: 'project', summary: 'Deploys a binary to a destination, such as an app store using Appflow', description: ` @@ -115,7 +115,7 @@ Both can be retrieved from the Dashboard[^dashboard]. ); } - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const appflowId = await this.project.requireAppflowId(); const [buildId, destination] = inputs; diff --git a/packages/@ionic/cli/src/commands/ssh/add.ts b/packages/@ionic/cli/src/commands/ssh/add.ts index 1099461fd5..071e1b237e 100644 --- a/packages/@ionic/cli/src/commands/ssh/add.ts +++ b/packages/@ionic/cli/src/commands/ssh/add.ts @@ -79,7 +79,7 @@ export class SSHAddCommand extends SSHBaseCommand implements CommandPreRun { } const user = this.env.session.getUser(); - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const sshkeyClient = new SSHKeyClient({ client: this.env.client, token, user }); try { diff --git a/packages/@ionic/cli/src/commands/ssh/delete.ts b/packages/@ionic/cli/src/commands/ssh/delete.ts index 9f99bd4a9b..39ef5b73ef 100644 --- a/packages/@ionic/cli/src/commands/ssh/delete.ts +++ b/packages/@ionic/cli/src/commands/ssh/delete.ts @@ -26,7 +26,7 @@ export class SSHDeleteCommand extends SSHBaseCommand implements CommandPreRun { if (!inputs[0]) { const user = this.env.session.getUser(); - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const sshkeyClient = new SSHKeyClient({ client: this.env.client, user, token }); const paginator = sshkeyClient.paginate(); @@ -56,7 +56,7 @@ export class SSHDeleteCommand extends SSHBaseCommand implements CommandPreRun { const [ id ] = inputs; const user = this.env.session.getUser(); - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const sshkeyClient = new SSHKeyClient({ client: this.env.client, user, token }); await sshkeyClient.delete(id); diff --git a/packages/@ionic/cli/src/commands/ssh/generate.ts b/packages/@ionic/cli/src/commands/ssh/generate.ts index 0829ca7d84..0ad3c45313 100644 --- a/packages/@ionic/cli/src/commands/ssh/generate.ts +++ b/packages/@ionic/cli/src/commands/ssh/generate.ts @@ -50,7 +50,7 @@ export class SSHGenerateCommand extends SSHBaseCommand implements CommandPreRun async preRun(inputs: CommandLineInputs, options: CommandLineOptions): Promise { await this.checkForOpenSSH(); - this.env.session.getUserToken(); + await this.env.session.getUserToken(); if (!options['annotation']) { options['annotation'] = this.env.config.get('user.email'); diff --git a/packages/@ionic/cli/src/commands/ssh/list.ts b/packages/@ionic/cli/src/commands/ssh/list.ts index bfb696d9ec..7583dbbb39 100644 --- a/packages/@ionic/cli/src/commands/ssh/list.ts +++ b/packages/@ionic/cli/src/commands/ssh/list.ts @@ -32,7 +32,7 @@ export class SSHListCommand extends SSHBaseCommand implements CommandPreRun { const { json } = options; const user = this.env.session.getUser(); - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const sshkeyClient = new SSHKeyClient({ client: this.env.client, user, token }); const paginator = sshkeyClient.paginate(); diff --git a/packages/@ionic/cli/src/commands/start.ts b/packages/@ionic/cli/src/commands/start.ts index ffe7799850..5d876c26ee 100644 --- a/packages/@ionic/cli/src/commands/start.ts +++ b/packages/@ionic/cli/src/commands/start.ts @@ -257,7 +257,7 @@ Use the ${input('--type')} option to start projects using older versions of Ioni return; } - const projectType = options['type'] ? String(options['type']) : await this.getProjectType(); + const projectType = isValidURL(inputs[1]) ? 'custom' : options['type'] ? String(options['type']) : await this.getProjectType(); if (options['cordova']) { const { checkForUnsupportedProject } = await import('../lib/integrations/cordova/utils'); @@ -273,7 +273,7 @@ Use the ${input('--type')} option to start projects using older versions of Ioni if (!inputs[0]) { if (appflowId) { const { AppClient } = await import('../lib/app'); - const token = this.env.session.getUserToken(); + const token = await this.env.session.getUserToken(); const appClient = new AppClient(token, this.env); const tasks = this.createTaskChain(); tasks.next(`Looking up app ${input(appflowId)}`); @@ -703,7 +703,7 @@ Use the ${input('--type')} option to start projects using older versions of Ioni async validateProjectType(type: string) { const projectTypes = getStarterProjectTypes(); - if (!projectTypes.includes(type)) { + if (!['custom', ...projectTypes].includes(type)) { throw new FatalException( `${input(type)} is not a valid project type.\n` + `Please choose a different ${input('--type')}. Use ${input('ionic start --list')} to list all available starter templates.` diff --git a/packages/@ionic/cli/src/definitions.ts b/packages/@ionic/cli/src/definitions.ts index 41faef1761..4b19d30827 100644 --- a/packages/@ionic/cli/src/definitions.ts +++ b/packages/@ionic/cli/src/definitions.ts @@ -4,7 +4,7 @@ import { CommandLineInputs, CommandLineOptions, CommandMetadata as FrameworkCommandMetadata, - CommandMetadataInput, + CommandMetadataInput as FrameworkCommandMetadataInput, CommandMetadataOption as FrameworkCommandMetadataOption, HydratedCommandMetadata as FrameworkHydratedCommandMetadata, ICommand as FrameworkCommand, @@ -22,7 +22,6 @@ import * as fs from 'fs'; export { CommandLineInputs, CommandLineOptions, - CommandMetadataInput, NamespaceMetadata, } from '@ionic/cli-framework'; @@ -234,6 +233,22 @@ export interface OAuthIdentityDetails { html_url: string; } +export interface OAuthServerConfig { + authorizationUrl: string; + tokenUrl: string; + clientId: string; + apiAudience: string; +} + +export interface OpenIdToken { + access_token: string; + expires_in: number; + id_token?: string; + refresh_token?: string; + scope: 'openid profile email offline_access'; + token_type: 'Bearer'; +} + export interface Snapshot { id: string; sha: string; @@ -270,6 +285,7 @@ export interface IConfig extends BaseConfig { getGitHost(): string; getGitPort(): number; getHTTPConfig(): CreateRequestOptions; + getOpenIDOAuthConfig(): OAuthServerConfig; } export interface ProjectPersonalizationDetails { @@ -363,6 +379,10 @@ export interface PackageVersions { [key: string]: string; } +export interface CommandMetadataInput extends FrameworkCommandMetadataInput { + private?: boolean; +} + export interface CommandMetadataOption extends FrameworkCommandMetadataOption { private?: boolean; hint?: string; @@ -384,11 +404,12 @@ export interface ISession { login(email: string, password: string): Promise; ssoLogin(email: string): Promise; tokenLogin(token: string): Promise; + webLogin(): Promise; logout(): Promise; isLoggedIn(): boolean; getUser(): { id: number; }; - getUserToken(): string; + getUserToken(): Promise; } export interface IShellSpawnOptions extends SpawnOptions { @@ -449,6 +470,16 @@ export interface ConfigFile { 'user.email'?: string; 'tokens.user'?: string; 'tokens.telemetry'?: string; + 'tokens.refresh'?: string; + 'tokens.issuedOn'?: string; + 'tokens.expiresInSeconds'?: number; + 'tokens.flowName'?: string; + + // oauth configs + 'oauth.openid.authorization_url'?: string; + 'oauth.openid.token_url'?: string; + 'oauth.openid.client_id'?: string; + 'oauth.openid.api_audience'?: string; // Features 'features.ssl-commands'?: boolean; @@ -506,10 +537,16 @@ export interface APIResponsePageTokenMeta extends APIResponseMeta { export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'PURGE' | 'HEAD' | 'OPTIONS'; +export const enum ContentType { + JSON = 'application/json', + FORM_URLENCODED = 'application/x-www-form-urlencoded', + HTML = 'text/html', +} + export interface IClient { config: IConfig; - make(method: HttpMethod, path: string): Promise<{ req: SuperAgentRequest; }>; + make(method: HttpMethod, path: string, contentType?: ContentType): Promise<{ req: SuperAgentRequest; }>; do(req: SuperAgentRequest): Promise; paginate>(args: PaginateArgs): IPaginator; } diff --git a/packages/@ionic/cli/src/guards.ts b/packages/@ionic/cli/src/guards.ts index 7bcaa4dcfd..09a79c3259 100644 --- a/packages/@ionic/cli/src/guards.ts +++ b/packages/@ionic/cli/src/guards.ts @@ -1,5 +1,35 @@ -import { APIResponse, APIResponseError, APIResponseSuccess, App, AppAssociation, BitbucketCloudRepoAssociation, BitbucketServerRepoAssociation, CommandPreRun, CordovaAndroidBuildOutputEntry, CordovaPackageJson, ExitCodeException, GithubBranch, GithubRepo, GithubRepoAssociation, ICommand, IMultiProjectConfig, IProjectConfig, IntegrationName, Login, Org, Response, SSHKey, SecurityProfile, Snapshot, StarterManifest, SuperAgentError, TreatableAilment, User } from './definitions'; -import { AuthConnection } from './lib/auth'; +import { + APIResponse, + APIResponseError, + APIResponseSuccess, + App, + AppAssociation, + BitbucketCloudRepoAssociation, + BitbucketServerRepoAssociation, + CommandPreRun, + CordovaAndroidBuildOutputEntry, + CordovaPackageJson, + ExitCodeException, + GithubBranch, + GithubRepo, + GithubRepoAssociation, + ICommand, + IMultiProjectConfig, + IProjectConfig, + IntegrationName, + Login, + OpenIdToken, + Org, + Response, + SSHKey, + SecurityProfile, + Snapshot, + StarterManifest, + SuperAgentError, + TreatableAilment, + User +} from './definitions'; +import { AuthConnection } from './lib/oauth/auth'; export const INTEGRATION_NAMES: IntegrationName[] = ['capacitor', 'cordova', 'enterprise']; @@ -171,6 +201,20 @@ export function isOAuthLoginResponse(res: any): res is Response { return isAPIResponseSuccess(res) && isOAuthLogin(res.data); } +export function isOpenIDToken(tokenObj: any): tokenObj is OpenIdToken { + return tokenObj + && typeof tokenObj.access_token === 'string' + && typeof tokenObj.expires_in === 'number' + && (tokenObj.id_token ? typeof tokenObj.id_token === 'string' : true) + && (tokenObj.refresh_token ? typeof tokenObj.refresh_token === 'string' : true) + && tokenObj.scope === 'openid profile email offline_access' + && tokenObj.token_type === 'Bearer'; +} + +export function isOpenIDTokenExchangeResponse(res: any): res is Response { + return res && typeof res.body === 'object' && isOpenIDToken(res.body); +} + export function isSnapshot(snapshot: any): snapshot is Snapshot { return snapshot && typeof snapshot.id === 'string' diff --git a/packages/@ionic/cli/src/lib/config.ts b/packages/@ionic/cli/src/lib/config.ts index 1a926dd280..0e8464bf27 100644 --- a/packages/@ionic/cli/src/lib/config.ts +++ b/packages/@ionic/cli/src/lib/config.ts @@ -2,7 +2,7 @@ import { BaseConfig, BaseConfigOptions, MetadataGroup, ParsedArgs, metadataOptio import * as os from 'os'; import * as path from 'path'; -import { CommandMetadataOption, ConfigFile, CreateRequestOptions, IConfig } from '../definitions'; +import { CommandMetadataOption, ConfigFile, CreateRequestOptions, IConfig, OAuthServerConfig } from '../definitions'; export const GLOBAL_OPTIONS: readonly CommandMetadataOption[] = [ { @@ -116,6 +116,15 @@ export class Config extends BaseConfig implements IConfig { proxy: c['proxy'], }; } + + getOpenIDOAuthConfig(): OAuthServerConfig { + return { + authorizationUrl: this.get('oauth.openid.authorization_url', 'https://ionicframework.com/oauth/authorize'), + tokenUrl: this.get('oauth.openid.token_url', 'https://api.ionicjs.com/oauth/token'), + clientId: this.get('oauth.openid.client_id', 'cli'), + apiAudience: this.get('oauth.openid.api_audience', 'https://api.ionicjs.com'), + }; + } } export function parseGlobalOptions(pargv: string[]): ParsedArgs { diff --git a/packages/@ionic/cli/src/lib/doctor/ailments/index.ts b/packages/@ionic/cli/src/lib/doctor/ailments/index.ts index c9d6e0ed54..6addc087b5 100644 --- a/packages/@ionic/cli/src/lib/doctor/ailments/index.ts +++ b/packages/@ionic/cli/src/lib/doctor/ailments/index.ts @@ -157,7 +157,7 @@ export class GitConfigInvalid extends Ailment { return true; } - const token = this.session.getUserToken(); + const token = await this.session.getUserToken(); const appClient = new AppClient(token, { client: this.client }); const app = await appClient.load(appflowId); diff --git a/packages/@ionic/cli/src/lib/http.ts b/packages/@ionic/cli/src/lib/http.ts index 74327c0a4b..9f27646be1 100644 --- a/packages/@ionic/cli/src/lib/http.ts +++ b/packages/@ionic/cli/src/lib/http.ts @@ -2,7 +2,25 @@ import * as chalk from 'chalk'; import * as lodash from 'lodash'; import * as util from 'util'; -import { APIResponse, APIResponsePageTokenMeta, APIResponseSuccess, HttpMethod, IClient, IConfig, IPaginator, PagePaginatorState, PaginateArgs, PaginatorDeps, PaginatorGuard, PaginatorRequestGenerator, ResourceClientRequestModifiers, Response, SuperAgentError, TokenPaginatorState } from '../definitions'; +import { + APIResponse, + APIResponsePageTokenMeta, + APIResponseSuccess, + ContentType, + HttpMethod, + IClient, + IConfig, + IPaginator, + PagePaginatorState, + PaginateArgs, + PaginatorDeps, + PaginatorGuard, + PaginatorRequestGenerator, + ResourceClientRequestModifiers, + Response, + SuperAgentError, + TokenPaginatorState +} from '../definitions'; import { isAPIResponseError, isAPIResponseSuccess } from '../guards'; import { failure, strong } from './color'; @@ -13,7 +31,6 @@ export type SuperAgentRequest = import('superagent').SuperAgentRequest; export type SuperAgentResponse = import('superagent').Response; const FORMAT_ERROR_BODY_MAX_LENGTH = 1000; -export const CONTENT_TYPE_JSON = 'application/json'; export const ERROR_UNKNOWN_CONTENT_TYPE = 'UNKNOWN_CONTENT_TYPE'; export const ERROR_UNKNOWN_RESPONSE_FORMAT = 'UNKNOWN_RESPONSE_FORMAT'; @@ -21,13 +38,13 @@ export const ERROR_UNKNOWN_RESPONSE_FORMAT = 'UNKNOWN_RESPONSE_FORMAT'; export class Client implements IClient { constructor(public config: IConfig) {} - async make(method: HttpMethod, path: string): Promise<{ req: SuperAgentRequest; }> { + async make(method: HttpMethod, path: string, contentType: ContentType = ContentType.JSON): Promise<{ req: SuperAgentRequest; }> { const url = path.startsWith('http://') || path.startsWith('https://') ? path : `${this.config.getAPIUrl()}${path}`; const { req } = await createRequest(method, url, this.config.getHTTPConfig()); req - .set('Content-Type', CONTENT_TYPE_JSON) - .set('Accept', CONTENT_TYPE_JSON); + .set('Content-Type', contentType) + .set('Accept', ContentType.JSON); return { req }; } @@ -212,7 +229,7 @@ export function transformAPIResponse(r: SuperAgentResponse): APIResponse { r.body = { meta: { status: 204, version: '', request_id: '' } }; } - if (r.status !== 204 && r.type !== CONTENT_TYPE_JSON) { + if (r.status !== 204 && r.type !== ContentType.JSON) { throw ERROR_UNKNOWN_CONTENT_TYPE; } diff --git a/packages/@ionic/cli/src/lib/integrations/enterprise/index.ts b/packages/@ionic/cli/src/lib/integrations/enterprise/index.ts index 0f921c8f4a..b7be0e47ff 100644 --- a/packages/@ionic/cli/src/lib/integrations/enterprise/index.ts +++ b/packages/@ionic/cli/src/lib/integrations/enterprise/index.ts @@ -115,7 +115,7 @@ export class Integration extends BaseIntegration { } protected async registerKey(key: ProductKey, appId: string) { - const token = this.e.session.getUserToken(); + const token = await this.e.session.getUserToken(); const { req } = await this.e.client.make('PATCH', `/orgs/${key.org.id}/keys/${key.id}`); req.set('Authorization', `Bearer ${token}`); req.send({ app_id: appId }); @@ -138,7 +138,7 @@ export class Integration extends BaseIntegration { protected async getAppClient() { const { AppClient } = await import('../../../lib/app'); - const token = this.e.session.getUserToken(); + const token = await this.e.session.getUserToken(); return new AppClient(token, this.e); } @@ -184,7 +184,7 @@ export class Integration extends BaseIntegration { } protected async getPK(pk: string): Promise { - const token = this.e.session.getUserToken(); + const token = await this.e.session.getUserToken(); const { req } = await this.e.client.make('GET', '/keys/self'); req.set('Authorization', `Bearer ${token}`).set('Product-Key-ID', pk); diff --git a/packages/@ionic/cli/src/lib/auth.ts b/packages/@ionic/cli/src/lib/oauth/auth.ts similarity index 81% rename from packages/@ionic/cli/src/lib/auth.ts rename to packages/@ionic/cli/src/lib/oauth/auth.ts index f437b657ae..6c09d496f6 100644 --- a/packages/@ionic/cli/src/lib/auth.ts +++ b/packages/@ionic/cli/src/lib/oauth/auth.ts @@ -1,7 +1,6 @@ -import { IClient, ResourceClientLoad } from '../definitions'; -import { isAuthConnectionResponse } from '../guards'; - -import { ResourceClient, createFatalAPIFormat } from './http'; +import { IClient, ResourceClientLoad } from '../../definitions'; +import { isAuthConnectionResponse } from '../../guards'; +import { ResourceClient, createFatalAPIFormat } from '../http'; export interface AuthConnection { readonly uuid: string; diff --git a/packages/@ionic/cli/src/lib/oauth/oauth.ts b/packages/@ionic/cli/src/lib/oauth/oauth.ts new file mode 100644 index 0000000000..e66bf57210 --- /dev/null +++ b/packages/@ionic/cli/src/lib/oauth/oauth.ts @@ -0,0 +1,164 @@ +import { readFile } from '@ionic/utils-fs'; +import { isPortAvailable } from '@ionic/utils-network'; +import * as crypto from 'crypto'; +import * as http from 'http'; +import * as path from 'path'; +import * as qs from 'querystring'; +import { Response } from 'superagent'; + +import { ASSETS_DIRECTORY } from '../../constants'; +import { ContentType, IClient, IConfig, OAuthServerConfig } from '../../definitions'; +import { FatalException } from '../errors'; +import { formatResponseError } from '../http'; +import { openUrl } from '../open'; + +const REDIRECT_PORT = 8123; +const REDIRECT_HOST = 'localhost'; + +export interface AuthorizationParameters { + [key: string]: string; +} + +export interface TokenParameters { + [key: string]: string; +} + +export interface OAuth2FlowOptions { + readonly redirectHost?: string; + readonly redirectPort?: number; + readonly accessTokenRequestContentType?: ContentType; +} + +export interface OAuth2FlowDeps { + readonly client: IClient; + readonly config: IConfig; +} + +export abstract class OAuth2Flow { + abstract readonly flowName: string; + readonly oauthConfig: OAuthServerConfig; + readonly redirectHost: string; + readonly redirectPort: number; + readonly accessTokenRequestContentType: ContentType; + + constructor({ redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT, accessTokenRequestContentType = ContentType.JSON }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { + this.oauthConfig = this.getAuthConfig(); + this.redirectHost = redirectHost; + this.redirectPort = redirectPort; + this.accessTokenRequestContentType = accessTokenRequestContentType; + } + + get redirectUrl(): string { + return `http://${this.redirectHost}:${this.redirectPort}`; + } + + async run(): Promise { + const verifier = this.generateVerifier(); + const challenge = this.generateChallenge(verifier); + + const authorizationParams = this.generateAuthorizationParameters(challenge); + const authorizationUrl = `${this.oauthConfig.authorizationUrl}?${qs.stringify(authorizationParams)}`; + + await openUrl(authorizationUrl); + + const authorizationCode = await this.getAuthorizationCode(); + const token = await this.exchangeAuthForAccessToken(authorizationCode, verifier); + + return token; + } + + async exchangeRefreshToken(refreshToken: string): Promise { + const params = this.generateRefreshTokenParameters(refreshToken); + const { req } = await this.e.client.make('POST', this.oauthConfig.tokenUrl, this.accessTokenRequestContentType); + + const res = await req.send(params); + + // check the response status code first here + if (!res.ok) { + throw new FatalException( + 'API request to refresh token was not successful.\n' + + 'Please try to login again.\n' + + formatResponseError(req, res.status) + ); + } + + if (!this.checkValidExchangeTokenRes(res)) { + throw new FatalException( + 'API request was successful, but the refreshed token was unrecognized.\n' + + 'Please try to login again.\n' + ); + } + return res.body; + } + + protected abstract generateAuthorizationParameters(challenge: string): AuthorizationParameters; + protected abstract generateTokenParameters(authorizationCode: string, verifier: string): TokenParameters; + protected abstract generateRefreshTokenParameters(refreshToken: string): TokenParameters; + protected abstract checkValidExchangeTokenRes(res: Response): boolean; + protected abstract getAuthConfig(): OAuthServerConfig; + + protected async getSuccessHtml(): Promise { + const p = path.resolve(ASSETS_DIRECTORY, 'oauth', 'success', 'index.html'); + const contents = await readFile(p, { encoding: 'utf8' }); + + return contents; + } + + protected async getAuthorizationCode(): Promise { + if (!(await isPortAvailable(this.redirectPort))) { + throw new Error(`Cannot start local server. Port ${this.redirectPort} is in use.`); + } + + const successHtml = await this.getSuccessHtml(); + + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + if (req.url) { + const params = qs.parse(req.url.substring(req.url.indexOf('?') + 1)); + + if (params.code) { + res.writeHead(200, { 'Content-Type': ContentType.HTML }); + res.end(successHtml); + req.socket.destroy(); + server.close(); + + resolve(Array.isArray(params.code) ? params.code[0] : params.code); + } + + // TODO, timeout, error handling + } + }); + + server.listen(this.redirectPort, this.redirectHost); + }); + } + + protected async exchangeAuthForAccessToken(authorizationCode: string, verifier: string): Promise { + const params = this.generateTokenParameters(authorizationCode, verifier); + const { req } = await this.e.client.make('POST', this.oauthConfig.tokenUrl, this.accessTokenRequestContentType); + + const res = await req.send(params); + if (!this.checkValidExchangeTokenRes(res)) { + throw new FatalException( + 'API request was successful, but the response format was unrecognized.\n' + + formatResponseError(req, res.status) + ); + } + return res.body; + } + + protected generateVerifier(): string { + return this.base64URLEncode(crypto.randomBytes(32)); + } + + protected generateChallenge(verifier: string): string { + return this.base64URLEncode(crypto.createHash('sha256').update(verifier).digest()); + } + + protected base64URLEncode(buffer: Buffer) { + return buffer.toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + } +} diff --git a/packages/@ionic/cli/src/lib/oauth/openid.ts b/packages/@ionic/cli/src/lib/oauth/openid.ts new file mode 100644 index 0000000000..262e0e34c9 --- /dev/null +++ b/packages/@ionic/cli/src/lib/oauth/openid.ts @@ -0,0 +1,64 @@ +import { Response } from 'superagent'; + +import { ContentType, OAuthServerConfig, OpenIdToken } from '../../definitions'; +import { isOpenIDTokenExchangeResponse } from '../../guards'; + +import { + AuthorizationParameters, + OAuth2Flow, + OAuth2FlowDeps, + OAuth2FlowOptions, + TokenParameters +} from './oauth'; + +export interface OpenIDFlowOptions extends Partial { + readonly accessTokenRequestContentType?: ContentType; +} + +export class OpenIDFlow extends OAuth2Flow { + readonly flowName = 'open_id'; + + constructor({ accessTokenRequestContentType = ContentType.FORM_URLENCODED, ...options }: OpenIDFlowOptions, readonly e: OAuth2FlowDeps) { + super({ accessTokenRequestContentType, ...options }, e); + } + + protected generateAuthorizationParameters(challenge: string): AuthorizationParameters { + return { + audience: this.oauthConfig.apiAudience, + scope: 'openid profile email offline_access', + response_type: 'code', + client_id: this.oauthConfig.clientId, + code_challenge: challenge, + code_challenge_method: 'S256', + redirect_uri: this.redirectUrl, + nonce: this.generateVerifier(), + }; + } + + protected generateTokenParameters(code: string, verifier: string): TokenParameters { + return { + grant_type: 'authorization_code', + client_id: this.oauthConfig.clientId, + code_verifier: verifier, + code, + redirect_uri: this.redirectUrl, + }; + } + + protected generateRefreshTokenParameters(refreshToken: string): TokenParameters { + return { + refresh_token: refreshToken, + grant_type: 'refresh_token', + client_id: this.oauthConfig.clientId, + }; + } + + protected checkValidExchangeTokenRes(res: Response): boolean { + return isOpenIDTokenExchangeResponse(res); + } + + protected getAuthConfig(): OAuthServerConfig { + return this.e.config.getOpenIDOAuthConfig(); + } + +} diff --git a/packages/@ionic/cli/src/lib/open.ts b/packages/@ionic/cli/src/lib/open.ts index 1237d72615..cb910a4120 100644 --- a/packages/@ionic/cli/src/lib/open.ts +++ b/packages/@ionic/cli/src/lib/open.ts @@ -4,12 +4,11 @@ const debug = Debug('ionic:lib:open'); export interface OpenUrlOptions { app?: string | readonly string[]; - encode?: boolean; } -export async function openUrl(target: string, { app, encode: url = true }: OpenUrlOptions = {}): Promise { +export async function openUrl(target: string, options: OpenUrlOptions = {}): Promise { const o = await import ('open'); - const p = await o(target, { wait: false, app, url }); + const p = await o(target, { ...options, wait: false, url: true }); const e = (err: Error) => debug('Error during open: %O', err); const n = p.on.bind(p); diff --git a/packages/@ionic/cli/src/lib/session.ts b/packages/@ionic/cli/src/lib/session.ts index b6f3db3ffd..83d3c16bf4 100644 --- a/packages/@ionic/cli/src/lib/session.ts +++ b/packages/@ionic/cli/src/lib/session.ts @@ -1,5 +1,3 @@ -import { combine } from '@ionic/cli-framework'; - import { IClient, IConfig, ISession, IonicEnvironment } from '../definitions'; import { isLoginResponse, isSuperAgentError } from '../guards'; @@ -21,7 +19,12 @@ export class BaseSession { this.e.config.unset('user.id'); this.e.config.unset('user.email'); this.e.config.unset('tokens.user'); + this.e.config.unset('tokens.refresh'); + this.e.config.unset('tokens.expiresInSeconds'); + this.e.config.unset('tokens.issuedOn'); + this.e.config.unset('tokens.flowName'); this.e.config.set('git.setup', false); + } isLoggedIn(): boolean { @@ -40,9 +43,12 @@ export class BaseSession { return { id: userId }; } +} + +export class ProSession extends BaseSession implements ISession { - getUserToken(): string { - const userToken = this.e.config.get('tokens.user'); + async getUserToken(): Promise { + let userToken = this.e.config.get('tokens.user'); if (!userToken) { throw new SessionException( @@ -51,11 +57,30 @@ export class BaseSession { ); } + const tokenIssuedOn = this.e.config.get('tokens.issuedOn'); + const tokenExpirationSeconds = this.e.config.get('tokens.expiresInSeconds'); + const refreshToken = this.e.config.get('tokens.refresh'); + const flowName = this.e.config.get('tokens.flowName'); + + // if there is the possibility to refresh the token, try to do it + if (tokenIssuedOn && tokenExpirationSeconds && refreshToken && flowName) { + if (!this.isTokenValid(tokenIssuedOn, tokenExpirationSeconds)) { + userToken = await this.refreshLogin(refreshToken, flowName); + } + } + + // otherwise simply return the token return userToken; } -} -export class ProSession extends BaseSession implements ISession { + private isTokenValid(tokenIssuedOn: string, tokenExpirationSeconds: number): boolean { + const tokenExpirationMilliSeconds = tokenExpirationSeconds * 1000; + // 15 minutes in milliseconds of margin + const marginExpiration = 15 * 60 * 1000; + const tokenValid = new Date() < new Date(new Date(tokenIssuedOn).getTime() + tokenExpirationMilliSeconds - marginExpiration); + return tokenValid; + } + async login(email: string, password: string): Promise { const { req } = await this.e.client.make('POST', '/login'); req.send({ email, password, source: 'cli' }); @@ -94,19 +119,8 @@ export class ProSession extends BaseSession implements ISession { } } - async ssoLogin(email: string): Promise { - const { AuthClient } = await import('./auth'); - const { Auth0OAuth2Flow } = await import('./sso'); - - const authClient = new AuthClient(this.e); - const { uuid: connection } = await authClient.connections.load(email); - - const flow = new Auth0OAuth2Flow({ audience: this.e.config.get('urls.api'), email, connection }, this.e); - const token = await flow.run(); - - await this.tokenLogin(token); - - this.e.config.set('org.id', connection); + async ssoLogin(email?: string): Promise { + await this.webLogin(); } async tokenLogin(token: string): Promise { @@ -133,33 +147,61 @@ export class ProSession extends BaseSession implements ISession { throw e; } } + + async webLogin(): Promise { + const { OpenIDFlow } = await import('./oauth/openid'); + const flow = new OpenIDFlow({}, this.e); + const token = await flow.run(); + + await this.tokenLogin(token.access_token); + + this.e.config.set('tokens.refresh', token.refresh_token); + this.e.config.set('tokens.expiresInSeconds', token.expires_in); + this.e.config.set('tokens.issuedOn', (new Date()).toJSON()); + this.e.config.set('tokens.flowName', flow.flowName); + } + + async refreshLogin(refreshToken: string, flowName: string): Promise { + let oauthflow; + // having a generic way to access the right refresh token flow + switch (flowName) { + case 'open_id': + const { OpenIDFlow } = await import('./oauth/openid'); + oauthflow = new OpenIDFlow({}, this.e); + break; + default: + oauthflow = undefined; + } + if (!oauthflow) { + throw new FatalException('Token cannot be refreshed'); + } + + const token = await oauthflow.exchangeRefreshToken(refreshToken); + await this.tokenLogin(token.access_token); + this.e.config.set('tokens.expiresInSeconds', token.expires_in); + this.e.config.set('tokens.issuedOn', (new Date()).toJSON()); + + return token.access_token; + } } export async function promptToLogin(env: IonicEnvironment): Promise { - const { validators } = await import('@ionic/cli-framework'); - env.log.nl(); env.log.msg( `Log in to your Ionic account!\n` + `If you don't have one yet, create yours by running: ${input(`ionic signup`)}\n` ); - const email = await env.prompt({ - type: 'input', - name: 'email', - message: 'Email:', - validate: v => combine(validators.required, validators.email)(v), - }); - - const password = await env.prompt({ - type: 'password', - name: 'password', - message: 'Password:', - mask: '*', - validate: v => validators.required(v), + const login = await env.prompt({ + type: 'confirm', + name: 'login', + message: 'Open the browser to log in to your Ionic account?', + default: true, }); - await env.session.login(email, password); + if (login) { + await env.session.webLogin(); + } } export async function promptToSignup(env: IonicEnvironment): Promise { diff --git a/packages/@ionic/cli/src/lib/sso.ts b/packages/@ionic/cli/src/lib/sso.ts deleted file mode 100644 index 21c1cb1884..0000000000 --- a/packages/@ionic/cli/src/lib/sso.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { readFile } from '@ionic/utils-fs'; -import { isPortAvailable } from '@ionic/utils-network'; -import * as crypto from 'crypto'; -import * as http from 'http'; -import * as path from 'path'; -import * as qs from 'querystring'; - -import { ASSETS_DIRECTORY } from '../constants'; -import { IClient } from '../definitions'; - -import { openUrl } from './open'; - -const REDIRECT_PORT = 8123; -const REDIRECT_HOST = 'localhost'; - -export interface AuthorizationParameters { - [key: string]: string; -} - -export interface TokenParameters { - [key: string]: string; -} - -export interface OAuth2FlowOptions { - readonly authorizationUrl: string; - readonly tokenUrl: string; - readonly clientId: string; - readonly redirectHost?: string; - readonly redirectPort?: number; -} - -export interface OAuth2FlowDeps { - readonly client: IClient; -} - -export abstract class OAuth2Flow { - readonly authorizationUrl: string; - readonly tokenUrl: string; - readonly clientId: string; - readonly redirectHost: string; - readonly redirectPort: number; - - constructor({ authorizationUrl, tokenUrl, clientId, redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { - this.authorizationUrl = authorizationUrl; - this.tokenUrl = tokenUrl; - this.clientId = clientId; - this.redirectHost = redirectHost; - this.redirectPort = redirectPort; - } - - get redirectUrl(): string { - return `http://${this.redirectHost}:${this.redirectPort}`; - } - - async run(): Promise { - const verifier = this.generateVerifier(); - const challenge = this.generateChallenge(verifier); - - const authorizationParams = this.generateAuthorizationParameters(challenge); - const authorizationUrl = `${this.authorizationUrl}?${qs.stringify(authorizationParams)}`; - - // don't doubly encode params, since `stringify` already did it - await openUrl(authorizationUrl, { encode: false }); - - const authorizationCode = await this.getAuthorizationCode(); - const token = await this.getAccessToken(authorizationCode, verifier); - - return token; - } - - protected abstract generateAuthorizationParameters(challenge: string): AuthorizationParameters; - protected abstract generateTokenParameters(authorizationCode: string, verifier: string): TokenParameters; - - protected async getSuccessHtml(): Promise { - const p = path.resolve(ASSETS_DIRECTORY, 'sso', 'success', 'index.html'); - const contents = await readFile(p, { encoding: 'utf8' }); - - return contents; - } - - protected async getAuthorizationCode(): Promise { - if (!(await isPortAvailable(this.redirectPort))) { - throw new Error(`Cannot start local server. Port ${this.redirectPort} is in use.`); - } - - const successHtml = await this.getSuccessHtml(); - - return new Promise((resolve, reject) => { - const server = http.createServer((req, res) => { - if (req.url) { - const params = qs.parse(req.url.substring(req.url.indexOf('?') + 1)); - - if (params.code) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(successHtml); - req.socket.destroy(); - server.close(); - - resolve(Array.isArray(params.code) ? params.code[0] : params.code); - } - - // TODO, timeout, error handling - } - }); - - server.listen(this.redirectPort, this.redirectHost); - }); - } - - protected async getAccessToken(authorizationCode: string, verifier: string): Promise { - const params = this.generateTokenParameters(authorizationCode, verifier); - const { req } = await this.e.client.make('POST', this.tokenUrl); - - const res = await req.send(params); - - return res.body.access_token; - } - - protected generateVerifier(): string { - return this.base64URLEncode(crypto.randomBytes(32)); - } - - protected generateChallenge(verifier: string): string { - return this.base64URLEncode(crypto.createHash('sha256').update(verifier).digest()); - } - - protected base64URLEncode(buffer: Buffer) { - return buffer.toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); - } -} - -const AUTHORIZATION_URL = 'https://auth.ionicframework.com/authorize'; -const TOKEN_URL = 'https://auth.ionicframework.com/oauth/token'; -const CLIENT_ID = '0kTF4wm74vppjImr11peCjQo2PIQDS3m'; -const API_AUDIENCE = 'https://api.ionicjs.com'; - -export interface Auth0OAuth2FlowOptions extends Partial { - readonly email: string; - readonly connection: string; - readonly audience?: string; -} - -export class Auth0OAuth2Flow extends OAuth2Flow { - readonly email: string; - readonly audience: string; - readonly connection: string; - - constructor({ email, connection, audience = API_AUDIENCE, authorizationUrl = AUTHORIZATION_URL, tokenUrl = TOKEN_URL, clientId = CLIENT_ID, ...options }: Auth0OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { - super({ authorizationUrl, tokenUrl, clientId, ...options }, e); - this.email = email; - this.connection = connection; - this.audience = audience; - } - - protected generateAuthorizationParameters(challenge: string): AuthorizationParameters { - return { - audience: this.audience, - scope: 'openid profile email offline_access', - response_type: 'code', - connection: this.connection, - client_id: this.clientId, - code_challenge: challenge, - code_challenge_method: 'S256', - redirect_uri: this.redirectUrl, - }; - } - - protected generateTokenParameters(code: string, verifier: string): TokenParameters { - return { - grant_type: 'authorization_code', - client_id: this.clientId, - code_verifier: verifier, - code, - redirect_uri: this.redirectUrl, - }; - } -} diff --git a/packages/@ionic/cli/src/lib/telemetry.ts b/packages/@ionic/cli/src/lib/telemetry.ts index 07d0f5f186..62a813b09b 100644 --- a/packages/@ionic/cli/src/lib/telemetry.ts +++ b/packages/@ionic/cli/src/lib/telemetry.ts @@ -104,7 +104,7 @@ export async function sendCommand({ config, client, getInfo, ctx, session, proje const isLoggedIn = session.isLoggedIn(); if (isLoggedIn) { - const token = session.getUserToken(); + const token = await session.getUserToken(); req.set('Authorization', `Bearer ${token}`); } diff --git a/packages/@ionic/lab/CHANGELOG.md b/packages/@ionic/lab/CHANGELOG.md index d0f028ce31..47bec03f11 100644 --- a/packages/@ionic/lab/CHANGELOG.md +++ b/packages/@ionic/lab/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [3.1.7](https://github.com/ionic-team/ionic-cli/compare/@ionic/lab@3.1.6...@ionic/lab@3.1.7) (2020-06-02) + +**Note:** Version bump only for package @ionic/lab + + + + + ## [3.1.6](https://github.com/ionic-team/ionic-cli/compare/@ionic/lab@3.1.5...@ionic/lab@3.1.6) (2020-05-12) diff --git a/packages/@ionic/lab/package.json b/packages/@ionic/lab/package.json index dd40e32110..27101a3530 100644 --- a/packages/@ionic/lab/package.json +++ b/packages/@ionic/lab/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/lab", - "version": "3.1.6", + "version": "3.1.7", "description": "Ionic Lab utility for developing Ionic apps, used by Ionic CLI", "bin": { "ionic-lab": "./bin/ionic-lab" @@ -41,7 +41,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@ionic/cli-framework": "4.1.5", + "@ionic/cli-framework": "4.2.0", "@ionic/utils-fs": "3.1.3", "chalk": "^4.0.0", "express": "^4.16.2", diff --git a/packages/@ionic/v1-toolkit/CHANGELOG.md b/packages/@ionic/v1-toolkit/CHANGELOG.md index e0aa688b47..a0bb29f353 100644 --- a/packages/@ionic/v1-toolkit/CHANGELOG.md +++ b/packages/@ionic/v1-toolkit/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [3.2.3](https://github.com/ionic-team/ionic-cli/compare/@ionic/v1-toolkit@3.2.2...@ionic/v1-toolkit@3.2.3) (2020-06-02) + +**Note:** Version bump only for package @ionic/v1-toolkit + + + + + ## [3.2.2](https://github.com/ionic-team/ionic-cli/compare/@ionic/v1-toolkit@3.2.1...@ionic/v1-toolkit@3.2.2) (2020-05-12) diff --git a/packages/@ionic/v1-toolkit/package.json b/packages/@ionic/v1-toolkit/package.json index ff6767042d..c4e004ed71 100644 --- a/packages/@ionic/v1-toolkit/package.json +++ b/packages/@ionic/v1-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/v1-toolkit", - "version": "3.2.2", + "version": "3.2.3", "description": "Utility CLI for Ionic 1.x apps, used by Ionic CLI", "bin": { "ionic-v1": "./bin/ionic-v1" @@ -36,7 +36,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@ionic/cli-framework": "4.1.5", + "@ionic/cli-framework": "4.2.0", "@ionic/utils-fs": "3.1.3", "chalk": "^4.0.0", "chokidar": "^3.0.1", diff --git a/packages/cli-scripts/CHANGELOG.md b/packages/cli-scripts/CHANGELOG.md index b1c8a37156..c96962c317 100644 --- a/packages/cli-scripts/CHANGELOG.md +++ b/packages/cli-scripts/CHANGELOG.md @@ -3,6 +3,30 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [2.1.17](https://github.com/ionic-team/ionic-cli/compare/cli-scripts@2.1.16...cli-scripts@2.1.17) (2020-06-02) + +**Note:** Version bump only for package cli-scripts + + + + + +## [2.1.16](https://github.com/ionic-team/ionic-cli/compare/cli-scripts@2.1.15...cli-scripts@2.1.16) (2020-05-27) + +**Note:** Version bump only for package cli-scripts + + + + + +## [2.1.15](https://github.com/ionic-team/ionic-cli/compare/cli-scripts@2.1.14...cli-scripts@2.1.15) (2020-05-17) + +**Note:** Version bump only for package cli-scripts + + + + + ## [2.1.14](https://github.com/ionic-team/ionic-cli/compare/cli-scripts@2.1.13...cli-scripts@2.1.14) (2020-05-12) diff --git a/packages/cli-scripts/package.json b/packages/cli-scripts/package.json index 29b466145b..01de2a8072 100644 --- a/packages/cli-scripts/package.json +++ b/packages/cli-scripts/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "cli-scripts", - "version": "2.1.14", + "version": "2.1.17", "bin": { "ionic-cli-scripts": "./bin/ionic-cli-scripts" }, @@ -16,8 +16,8 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { - "@ionic/cli": "6.9.1", - "@ionic/cli-framework": "4.1.5", + "@ionic/cli": "6.10.0", + "@ionic/cli-framework": "4.2.0", "@ionic/utils-fs": "3.1.3", "ansi-styles": "^4.0.0", "chalk": "^4.0.0",