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/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/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 89d0b39317..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); } 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 45a3db6bab..586c4cd2dd 100644 --- a/packages/@ionic/cli/src/commands/package/deploy.ts +++ b/packages/@ionic/cli/src/commands/package/deploy.ts @@ -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 31a18ab691..5d876c26ee 100644 --- a/packages/@ionic/cli/src/commands/start.ts +++ b/packages/@ionic/cli/src/commands/start.ts @@ -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)}`); diff --git a/packages/@ionic/cli/src/definitions.ts b/packages/@ionic/cli/src/definitions.ts index ec41f58e62..4b19d30827 100644 --- a/packages/@ionic/cli/src/definitions.ts +++ b/packages/@ionic/cli/src/definitions.ts @@ -233,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; @@ -269,6 +285,7 @@ export interface IConfig extends BaseConfig { getGitHost(): string; getGitPort(): number; getHTTPConfig(): CreateRequestOptions; + getOpenIDOAuthConfig(): OAuthServerConfig; } export interface ProjectPersonalizationDetails { @@ -387,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 { @@ -452,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; @@ -509,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/sso.ts b/packages/@ionic/cli/src/lib/oauth/oauth.ts similarity index 51% rename from packages/@ionic/cli/src/lib/sso.ts rename to packages/@ionic/cli/src/lib/oauth/oauth.ts index 4c79899548..e66bf57210 100644 --- a/packages/@ionic/cli/src/lib/sso.ts +++ b/packages/@ionic/cli/src/lib/oauth/oauth.ts @@ -4,11 +4,13 @@ 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 { IClient } from '../definitions'; - -import { openUrl } from './open'; +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'; @@ -22,56 +24,81 @@ export interface TokenParameters { } export interface OAuth2FlowOptions { - readonly authorizationUrl: string; - readonly tokenUrl: string; - readonly clientId: string; readonly redirectHost?: string; readonly redirectPort?: number; + readonly accessTokenRequestContentType?: ContentType; } export interface OAuth2FlowDeps { readonly client: IClient; + readonly config: IConfig; } -export abstract class OAuth2Flow { - readonly authorizationUrl: string; - readonly tokenUrl: string; - readonly clientId: string; +export abstract class OAuth2Flow { + abstract readonly flowName: string; + readonly oauthConfig: OAuthServerConfig; readonly redirectHost: string; readonly redirectPort: number; + readonly accessTokenRequestContentType: ContentType; - constructor({ authorizationUrl, tokenUrl, clientId, redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { - this.authorizationUrl = authorizationUrl; - this.tokenUrl = tokenUrl; - this.clientId = clientId; + 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 { + async run(): Promise { const verifier = this.generateVerifier(); const challenge = this.generateChallenge(verifier); const authorizationParams = this.generateAuthorizationParameters(challenge); - const authorizationUrl = `${this.authorizationUrl}?${qs.stringify(authorizationParams)}`; + const authorizationUrl = `${this.oauthConfig.authorizationUrl}?${qs.stringify(authorizationParams)}`; await openUrl(authorizationUrl); const authorizationCode = await this.getAuthorizationCode(); - const token = await this.getAccessToken(authorizationCode, verifier); + 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, 'sso', 'success', 'index.html'); + const p = path.resolve(ASSETS_DIRECTORY, 'oauth', 'success', 'index.html'); const contents = await readFile(p, { encoding: 'utf8' }); return contents; @@ -90,7 +117,7 @@ export abstract class OAuth2Flow { const params = qs.parse(req.url.substring(req.url.indexOf('?') + 1)); if (params.code) { - res.writeHead(200, { 'Content-Type': 'text/html' }); + res.writeHead(200, { 'Content-Type': ContentType.HTML }); res.end(successHtml); req.socket.destroy(); server.close(); @@ -106,13 +133,18 @@ export abstract class OAuth2Flow { }); } - protected async getAccessToken(authorizationCode: string, verifier: string): Promise { + protected async exchangeAuthForAccessToken(authorizationCode: string, verifier: string): Promise { const params = this.generateTokenParameters(authorizationCode, verifier); - const { req } = await this.e.client.make('POST', this.tokenUrl); + const { req } = await this.e.client.make('POST', this.oauthConfig.tokenUrl, this.accessTokenRequestContentType); const res = await req.send(params); - - return res.body.access_token; + 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 { @@ -130,50 +162,3 @@ export abstract class OAuth2Flow { .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/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/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/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}`); }