From 9dceed485383f08f4856af1da45d1da2bac2dccf Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Tue, 19 May 2020 14:28:23 -0400 Subject: [PATCH 01/21] OpenID login flow --- packages/@ionic/cli/src/commands/login.ts | 24 +++++++++-- packages/@ionic/cli/src/definitions.ts | 9 +++- packages/@ionic/cli/src/lib/http.ts | 29 ++++++++++--- packages/@ionic/cli/src/lib/login-web.ts | 51 +++++++++++++++++++++++ packages/@ionic/cli/src/lib/session.ts | 7 ++++ packages/@ionic/cli/src/lib/sso.ts | 12 +++--- 6 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 packages/@ionic/cli/src/lib/login-web.ts diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 123e59c36d..c97b457852 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -43,14 +43,14 @@ 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], + validators: process.argv.includes('--web') ? [] : [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], + validators: process.argv.includes('--sso') || process.argv.includes('--web') ? [] : [validators.required], private: true, }, ], @@ -61,12 +61,19 @@ If you are having issues logging in, please get in touch with our Support[^suppo summary: 'Open a window to log in with the SSO provider associated with your email', groups: [MetadataGroup.HIDDEN], }, + { + name: 'web', + type: Boolean, + summary: 'Open a window to log in using the Ionic Website', + groups: [MetadataGroup.ADVANCED], + }, ], }; } async preRun(inputs: CommandLineInputs, options: CommandLineOptions): Promise { const sso = !!options['sso']; + const web = !!options['web']; if (options['email'] || options['password']) { throw new FatalException( @@ -75,8 +82,8 @@ If you are having issues logging in, please get in touch with our Support[^suppo ); } - const askForEmail = !inputs[0]; - const askForPassword = !sso && !inputs[1]; + const askForEmail = !web && !inputs[0]; + const askForPassword = !web && !sso && !inputs[1]; if (this.env.session.isLoggedIn()) { const email = this.env.config.get('user.email'); @@ -151,6 +158,7 @@ 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']; + const web = !!options['web']; if (this.env.session.isLoggedIn()) { await this.env.session.logout(); @@ -165,6 +173,14 @@ If you are having issues logging in, please get in touch with our Support[^suppo this.env.log.nl(); await this.env.session.ssoLogin(email); + } else if (web) { + this.env.log.info( + `Ionic Web Login\n` + + `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.webLogin(); } else { await this.env.session.login(email, password); } diff --git a/packages/@ionic/cli/src/definitions.ts b/packages/@ionic/cli/src/definitions.ts index ec41f58e62..25947776cf 100644 --- a/packages/@ionic/cli/src/definitions.ts +++ b/packages/@ionic/cli/src/definitions.ts @@ -387,6 +387,7 @@ export interface ISession { login(email: string, password: string): Promise; ssoLogin(email: string): Promise; tokenLogin(token: string): Promise; + webLogin(): Promise; logout(): Promise; isLoggedIn(): boolean; @@ -509,10 +510,16 @@ export interface APIResponsePageTokenMeta extends APIResponseMeta { export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'PURGE' | 'HEAD' | 'OPTIONS'; +export enum ContentTypes { + json = 'application/json', + formUrlencoded = '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?: ContentTypes): Promise<{ req: SuperAgentRequest; }>; do(req: SuperAgentRequest): Promise; paginate>(args: PaginateArgs): IPaginator; } diff --git a/packages/@ionic/cli/src/lib/http.ts b/packages/@ionic/cli/src/lib/http.ts index 74327c0a4b..2ec0d197f2 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, + ContentTypes, + 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: ContentTypes = ContentTypes.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', ContentTypes.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 !== ContentTypes.json) { throw ERROR_UNKNOWN_CONTENT_TYPE; } diff --git a/packages/@ionic/cli/src/lib/login-web.ts b/packages/@ionic/cli/src/lib/login-web.ts new file mode 100644 index 0000000000..d4859b275d --- /dev/null +++ b/packages/@ionic/cli/src/lib/login-web.ts @@ -0,0 +1,51 @@ +import { ContentTypes } from '../definitions'; + +import { + AuthorizationParameters, + OAuth2Flow, + OAuth2FlowDeps, + OAuth2FlowOptions, + TokenParameters +} from './sso'; + +const AUTHORIZATION_URL = 'https://staging.ionicframework.com/oauth/authorize'; +const TOKEN_URL = 'https://api-staging.ionicjs.com/oauth/token'; +const CLIENT_ID = 'cli'; +const API_AUDIENCE = 'https://api.ionicjs.com'; + +export interface OpenIDFlowOptions extends Partial { + readonly audience?: string; + readonly accessTokenRequestContentType?: ContentTypes; +} + +export class OpenIDFlow extends OAuth2Flow { + readonly audience: string; + + constructor({ audience = API_AUDIENCE, accessTokenRequestContentType = ContentTypes.formUrlencoded, authorizationUrl = AUTHORIZATION_URL, tokenUrl = TOKEN_URL, clientId = CLIENT_ID, ...options }: OpenIDFlowOptions, readonly e: OAuth2FlowDeps) { + super({ authorizationUrl, tokenUrl, clientId, accessTokenRequestContentType, ...options }, e); + this.audience = audience; + } + + protected generateAuthorizationParameters(challenge: string): AuthorizationParameters { + return { + audience: this.audience, + scope: 'openid profile email offline_access', + response_type: 'code', + client_id: this.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.clientId, + code_verifier: verifier, + code, + redirect_uri: this.redirectUrl, + }; + } +} diff --git a/packages/@ionic/cli/src/lib/session.ts b/packages/@ionic/cli/src/lib/session.ts index b6f3db3ffd..f4f6f0ab7b 100644 --- a/packages/@ionic/cli/src/lib/session.ts +++ b/packages/@ionic/cli/src/lib/session.ts @@ -133,6 +133,13 @@ export class ProSession extends BaseSession implements ISession { throw e; } } + + async webLogin(): Promise { + const { OpenIDFlow } = await import('./login-web'); + const flow = new OpenIDFlow({ audience: this.e.config.get('urls.api') }, this.e); + const token = await flow.run(); + await this.tokenLogin(token); + } } export async function promptToLogin(env: IonicEnvironment): Promise { diff --git a/packages/@ionic/cli/src/lib/sso.ts b/packages/@ionic/cli/src/lib/sso.ts index 4c79899548..94782306b0 100644 --- a/packages/@ionic/cli/src/lib/sso.ts +++ b/packages/@ionic/cli/src/lib/sso.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import * as qs from 'querystring'; import { ASSETS_DIRECTORY } from '../constants'; -import { IClient } from '../definitions'; +import { ContentTypes, IClient } from '../definitions'; import { openUrl } from './open'; @@ -27,6 +27,7 @@ export interface OAuth2FlowOptions { readonly clientId: string; readonly redirectHost?: string; readonly redirectPort?: number; + readonly accessTokenRequestContentType?: ContentTypes; } export interface OAuth2FlowDeps { @@ -39,13 +40,15 @@ export abstract class OAuth2Flow { readonly clientId: string; readonly redirectHost: string; readonly redirectPort: number; + readonly accessTokenRequestContentType: ContentTypes; - constructor({ authorizationUrl, tokenUrl, clientId, redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { + constructor({ authorizationUrl, tokenUrl, clientId, redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT, accessTokenRequestContentType = ContentTypes.json }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { this.authorizationUrl = authorizationUrl; this.tokenUrl = tokenUrl; this.clientId = clientId; this.redirectHost = redirectHost; this.redirectPort = redirectPort; + this.accessTokenRequestContentType = accessTokenRequestContentType; } get redirectUrl(): string { @@ -90,7 +93,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': ContentTypes.html }); res.end(successHtml); req.socket.destroy(); server.close(); @@ -108,8 +111,7 @@ export abstract class OAuth2Flow { 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 { req } = await this.e.client.make('POST', this.tokenUrl, this.accessTokenRequestContentType); const res = await req.send(params); return res.body.access_token; From ede15400a865125ea713cee5f33157afbb97df6b Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Wed, 20 May 2020 10:19:51 -0400 Subject: [PATCH 02/21] Moved oauth files in subdirectory --- packages/@ionic/cli/src/guards.ts | 2 +- packages/@ionic/cli/src/lib/{ => oauth}/auth.ts | 7 +++---- packages/@ionic/cli/src/lib/{ => oauth}/login-web.ts | 2 +- packages/@ionic/cli/src/lib/{ => oauth}/sso.ts | 7 +++---- packages/@ionic/cli/src/lib/session.ts | 6 +++--- 5 files changed, 11 insertions(+), 13 deletions(-) rename packages/@ionic/cli/src/lib/{ => oauth}/auth.ts (81%) rename packages/@ionic/cli/src/lib/{ => oauth}/login-web.ts (97%) rename packages/@ionic/cli/src/lib/{ => oauth}/sso.ts (97%) diff --git a/packages/@ionic/cli/src/guards.ts b/packages/@ionic/cli/src/guards.ts index 7bcaa4dcfd..fb2053514a 100644 --- a/packages/@ionic/cli/src/guards.ts +++ b/packages/@ionic/cli/src/guards.ts @@ -1,5 +1,5 @@ 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 { AuthConnection } from './lib/oauth/auth'; export const INTEGRATION_NAMES: IntegrationName[] = ['capacitor', 'cordova', 'enterprise']; 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/login-web.ts b/packages/@ionic/cli/src/lib/oauth/login-web.ts similarity index 97% rename from packages/@ionic/cli/src/lib/login-web.ts rename to packages/@ionic/cli/src/lib/oauth/login-web.ts index d4859b275d..b189448bc9 100644 --- a/packages/@ionic/cli/src/lib/login-web.ts +++ b/packages/@ionic/cli/src/lib/oauth/login-web.ts @@ -1,4 +1,4 @@ -import { ContentTypes } from '../definitions'; +import { ContentTypes } from '../../definitions'; import { AuthorizationParameters, diff --git a/packages/@ionic/cli/src/lib/sso.ts b/packages/@ionic/cli/src/lib/oauth/sso.ts similarity index 97% rename from packages/@ionic/cli/src/lib/sso.ts rename to packages/@ionic/cli/src/lib/oauth/sso.ts index 94782306b0..84612b15d1 100644 --- a/packages/@ionic/cli/src/lib/sso.ts +++ b/packages/@ionic/cli/src/lib/oauth/sso.ts @@ -5,10 +5,9 @@ import * as http from 'http'; import * as path from 'path'; import * as qs from 'querystring'; -import { ASSETS_DIRECTORY } from '../constants'; -import { ContentTypes, IClient } from '../definitions'; - -import { openUrl } from './open'; +import { ASSETS_DIRECTORY } from '../../constants'; +import { ContentTypes, IClient } from '../../definitions'; +import { openUrl } from '../open'; const REDIRECT_PORT = 8123; const REDIRECT_HOST = 'localhost'; diff --git a/packages/@ionic/cli/src/lib/session.ts b/packages/@ionic/cli/src/lib/session.ts index f4f6f0ab7b..ebb20b99be 100644 --- a/packages/@ionic/cli/src/lib/session.ts +++ b/packages/@ionic/cli/src/lib/session.ts @@ -95,8 +95,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 } = await import('./oauth/auth'); + const { Auth0OAuth2Flow } = await import('./oauth/sso'); const authClient = new AuthClient(this.e); const { uuid: connection } = await authClient.connections.load(email); @@ -135,7 +135,7 @@ export class ProSession extends BaseSession implements ISession { } async webLogin(): Promise { - const { OpenIDFlow } = await import('./login-web'); + const { OpenIDFlow } = await import('./oauth/login-web'); const flow = new OpenIDFlow({ audience: this.e.config.get('urls.api') }, this.e); const token = await flow.run(); await this.tokenLogin(token); From 40bf11aae1b4ecd76b605be3f5323aa358e07942 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Wed, 20 May 2020 15:50:52 -0400 Subject: [PATCH 03/21] detatched generic oauth code from sso --- .../@ionic/cli/src/lib/oauth/login-web.ts | 2 +- packages/@ionic/cli/src/lib/oauth/oauth.ts | 133 +++++++++++++++++ packages/@ionic/cli/src/lib/oauth/sso.ts | 140 +----------------- 3 files changed, 141 insertions(+), 134 deletions(-) create mode 100644 packages/@ionic/cli/src/lib/oauth/oauth.ts diff --git a/packages/@ionic/cli/src/lib/oauth/login-web.ts b/packages/@ionic/cli/src/lib/oauth/login-web.ts index b189448bc9..3733194059 100644 --- a/packages/@ionic/cli/src/lib/oauth/login-web.ts +++ b/packages/@ionic/cli/src/lib/oauth/login-web.ts @@ -6,7 +6,7 @@ import { OAuth2FlowDeps, OAuth2FlowOptions, TokenParameters -} from './sso'; +} from './oauth'; const AUTHORIZATION_URL = 'https://staging.ionicframework.com/oauth/authorize'; const TOKEN_URL = 'https://api-staging.ionicjs.com/oauth/token'; 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..15045a7670 --- /dev/null +++ b/packages/@ionic/cli/src/lib/oauth/oauth.ts @@ -0,0 +1,133 @@ +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 { ContentTypes, 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; + readonly accessTokenRequestContentType?: ContentTypes; +} + +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; + readonly accessTokenRequestContentType: ContentTypes; + + constructor({ authorizationUrl, tokenUrl, clientId, redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT, accessTokenRequestContentType = ContentTypes.json }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { + this.authorizationUrl = authorizationUrl; + this.tokenUrl = tokenUrl; + this.clientId = clientId; + 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.authorizationUrl}?${qs.stringify(authorizationParams)}`; + + await openUrl(authorizationUrl); + + 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': ContentTypes.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, this.accessTokenRequestContentType); + 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, ''); + } +} diff --git a/packages/@ionic/cli/src/lib/oauth/sso.ts b/packages/@ionic/cli/src/lib/oauth/sso.ts index 84612b15d1..4c6236d827 100644 --- a/packages/@ionic/cli/src/lib/oauth/sso.ts +++ b/packages/@ionic/cli/src/lib/oauth/sso.ts @@ -1,136 +1,10 @@ -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 { ContentTypes, 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; - readonly accessTokenRequestContentType?: ContentTypes; -} - -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; - readonly accessTokenRequestContentType: ContentTypes; - - constructor({ authorizationUrl, tokenUrl, clientId, redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT, accessTokenRequestContentType = ContentTypes.json }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { - this.authorizationUrl = authorizationUrl; - this.tokenUrl = tokenUrl; - this.clientId = clientId; - 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.authorizationUrl}?${qs.stringify(authorizationParams)}`; - - await openUrl(authorizationUrl); - - 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': ContentTypes.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, this.accessTokenRequestContentType); - 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, ''); - } -} +import { + AuthorizationParameters, + OAuth2Flow, + OAuth2FlowDeps, + OAuth2FlowOptions, + TokenParameters +} from './oauth'; const AUTHORIZATION_URL = 'https://auth.ionicframework.com/authorize'; const TOKEN_URL = 'https://auth.ionicframework.com/oauth/token'; From 53111df66361f20ba2b126ff64c3be1a6e538ff5 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Thu, 21 May 2020 21:32:40 -0400 Subject: [PATCH 04/21] Token auto refreshes if needed and if possible --- .../@ionic/cli/src/commands/deploy/build.ts | 2 +- .../@ionic/cli/src/commands/git/remote.ts | 2 +- packages/@ionic/cli/src/commands/link.ts | 4 +- .../cli/src/commands/monitoring/syncmaps.ts | 2 +- .../@ionic/cli/src/commands/package/build.ts | 2 +- .../@ionic/cli/src/commands/package/deploy.ts | 2 +- packages/@ionic/cli/src/commands/ssh/add.ts | 2 +- .../@ionic/cli/src/commands/ssh/delete.ts | 4 +- .../@ionic/cli/src/commands/ssh/generate.ts | 2 +- packages/@ionic/cli/src/commands/ssh/list.ts | 2 +- packages/@ionic/cli/src/commands/start.ts | 2 +- packages/@ionic/cli/src/definitions.ts | 15 +++- packages/@ionic/cli/src/guards.ts | 46 ++++++++++++- .../cli/src/lib/doctor/ailments/index.ts | 2 +- .../src/lib/integrations/enterprise/index.ts | 6 +- packages/@ionic/cli/src/lib/oauth/oauth.ts | 44 ++++++++++-- .../src/lib/oauth/{login-web.ts => openid.ts} | 20 +++++- packages/@ionic/cli/src/lib/oauth/sso.ts | 14 +++- packages/@ionic/cli/src/lib/session.ts | 68 +++++++++++++++++-- packages/@ionic/cli/src/lib/telemetry.ts | 2 +- 20 files changed, 208 insertions(+), 35 deletions(-) rename packages/@ionic/cli/src/lib/oauth/{login-web.ts => openid.ts} (73%) 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/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 25947776cf..4764e87168 100644 --- a/packages/@ionic/cli/src/definitions.ts +++ b/packages/@ionic/cli/src/definitions.ts @@ -233,6 +233,15 @@ export interface OAuthIdentityDetails { html_url: 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; @@ -392,7 +401,7 @@ export interface ISession { isLoggedIn(): boolean; getUser(): { id: number; }; - getUserToken(): string; + getUserToken(): Promise; } export interface IShellSpawnOptions extends SpawnOptions { @@ -453,6 +462,10 @@ export interface ConfigFile { 'user.email'?: string; 'tokens.user'?: string; 'tokens.telemetry'?: string; + 'tokens.refresh'?: string; + 'tokens.issuedOn'?: string; + 'tokens.expiresInSeconds'?: number; + 'tokens.flowName'?: string; // Features 'features.ssl-commands'?: boolean; diff --git a/packages/@ionic/cli/src/guards.ts b/packages/@ionic/cli/src/guards.ts index fb2053514a..09a79c3259 100644 --- a/packages/@ionic/cli/src/guards.ts +++ b/packages/@ionic/cli/src/guards.ts @@ -1,4 +1,34 @@ -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 { + 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/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/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/oauth/oauth.ts b/packages/@ionic/cli/src/lib/oauth/oauth.ts index 15045a7670..4ccf9d8191 100644 --- a/packages/@ionic/cli/src/lib/oauth/oauth.ts +++ b/packages/@ionic/cli/src/lib/oauth/oauth.ts @@ -4,9 +4,12 @@ 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 { ContentTypes, IClient } from '../../definitions'; +import { FatalException } from '../errors'; +import { formatResponseError } from '../http'; import { openUrl } from '../open'; const REDIRECT_PORT = 8123; @@ -33,7 +36,8 @@ export interface OAuth2FlowDeps { readonly client: IClient; } -export abstract class OAuth2Flow { +export abstract class OAuth2Flow { + abstract readonly flowName: string; readonly authorizationUrl: string; readonly tokenUrl: string; readonly clientId: string; @@ -54,7 +58,7 @@ export abstract class OAuth2Flow { return `http://${this.redirectHost}:${this.redirectPort}`; } - async run(): Promise { + async run(): Promise { const verifier = this.generateVerifier(); const challenge = this.generateChallenge(verifier); @@ -64,13 +68,35 @@ export abstract class OAuth2Flow { 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.tokenUrl, this.accessTokenRequestContentType); + + const res = await req.send(params); + + // check the response status code first here + if (!res || !res.body || !res.status || res.status < 200 || res.status >= 300) { + throw new FatalException( + 'API request was to refresh token was not successful.\n' + + formatResponseError(req, res.status) + ); + } + + if (!this.checkValidExchangeTokenRes(res)) { + throw new FatalException('API request was successful, but the refreshed token was unrecognized.\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 async getSuccessHtml(): Promise { const p = path.resolve(ASSETS_DIRECTORY, 'sso', 'success', 'index.html'); @@ -108,12 +134,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, this.accessTokenRequestContentType); - const res = await req.send(params); - return res.body.access_token; + 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 { diff --git a/packages/@ionic/cli/src/lib/oauth/login-web.ts b/packages/@ionic/cli/src/lib/oauth/openid.ts similarity index 73% rename from packages/@ionic/cli/src/lib/oauth/login-web.ts rename to packages/@ionic/cli/src/lib/oauth/openid.ts index 3733194059..c3aa170850 100644 --- a/packages/@ionic/cli/src/lib/oauth/login-web.ts +++ b/packages/@ionic/cli/src/lib/oauth/openid.ts @@ -1,4 +1,7 @@ -import { ContentTypes } from '../../definitions'; +import { Response } from 'superagent'; + +import { ContentTypes, OpenIdToken } from '../../definitions'; +import { isOpenIDTokenExchangeResponse } from '../../guards'; import { AuthorizationParameters, @@ -18,7 +21,8 @@ export interface OpenIDFlowOptions extends Partial { readonly accessTokenRequestContentType?: ContentTypes; } -export class OpenIDFlow extends OAuth2Flow { +export class OpenIDFlow extends OAuth2Flow { + readonly flowName = 'open_id'; readonly audience: string; constructor({ audience = API_AUDIENCE, accessTokenRequestContentType = ContentTypes.formUrlencoded, authorizationUrl = AUTHORIZATION_URL, tokenUrl = TOKEN_URL, clientId = CLIENT_ID, ...options }: OpenIDFlowOptions, readonly e: OAuth2FlowDeps) { @@ -48,4 +52,16 @@ export class OpenIDFlow extends OAuth2Flow { redirect_uri: this.redirectUrl, }; } + + protected generateRefreshTokenParameters(refreshToken: string): TokenParameters { + return { + refresh_token: refreshToken, + grant_type: 'refresh_token', + client_id: this.clientId, + }; + } + + protected checkValidExchangeTokenRes(res: Response): boolean { + return isOpenIDTokenExchangeResponse(res); + } } diff --git a/packages/@ionic/cli/src/lib/oauth/sso.ts b/packages/@ionic/cli/src/lib/oauth/sso.ts index 4c6236d827..8b43ed3166 100644 --- a/packages/@ionic/cli/src/lib/oauth/sso.ts +++ b/packages/@ionic/cli/src/lib/oauth/sso.ts @@ -1,3 +1,5 @@ +import { Response } from 'superagent'; + import { AuthorizationParameters, OAuth2Flow, @@ -17,7 +19,8 @@ export interface Auth0OAuth2FlowOptions extends Partial { readonly audience?: string; } -export class Auth0OAuth2Flow extends OAuth2Flow { +export class Auth0OAuth2Flow extends OAuth2Flow { + readonly flowName = 'sso'; readonly email: string; readonly audience: string; readonly connection: string; @@ -51,4 +54,13 @@ export class Auth0OAuth2Flow extends OAuth2Flow { redirect_uri: this.redirectUrl, }; } + + protected generateRefreshTokenParameters(refreshToken: string): TokenParameters { + return {}; + } + + protected checkValidExchangeTokenRes(res: Response): boolean { + return true; + } + } diff --git a/packages/@ionic/cli/src/lib/session.ts b/packages/@ionic/cli/src/lib/session.ts index ebb20b99be..7ea4392e3c 100644 --- a/packages/@ionic/cli/src/lib/session.ts +++ b/packages/@ionic/cli/src/lib/session.ts @@ -21,7 +21,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 +45,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 +59,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' }); @@ -135,10 +162,39 @@ export class ProSession extends BaseSession implements ISession { } async webLogin(): Promise { - const { OpenIDFlow } = await import('./oauth/login-web'); + const { OpenIDFlow } = await import('./oauth/openid'); const flow = new OpenIDFlow({ audience: this.e.config.get('urls.api') }, this.e); const token = await flow.run(); - await this.tokenLogin(token); + + 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({ audience: this.e.config.get('urls.api') }, 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; } } 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}`); } From c59a05bf5be44a963ef4f891d2c084605f281423 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Fri, 22 May 2020 08:52:11 -0400 Subject: [PATCH 05/21] SSO login removed and incorporated into openid login --- packages/@ionic/cli/src/commands/login.ts | 6 +-- packages/@ionic/cli/src/lib/oauth/sso.ts | 66 ----------------------- packages/@ionic/cli/src/lib/session.ts | 13 +---- 3 files changed, 4 insertions(+), 81 deletions(-) delete mode 100644 packages/@ionic/cli/src/lib/oauth/sso.ts diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index c97b457852..703c9f49a4 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -43,7 +43,7 @@ If you are having issues logging in, please get in touch with our Support[^suppo { name: 'email', summary: 'Your email address', - validators: process.argv.includes('--web') ? [] : [validators.required, validators.email], + validators: process.argv.includes('--sso') || process.argv.includes('--web') ? [] : [validators.required, validators.email], private: true, }, { @@ -59,7 +59,7 @@ If you are having issues logging in, please get in touch with our Support[^suppo name: 'sso', type: Boolean, summary: 'Open a window to log in with the SSO provider associated with your email', - groups: [MetadataGroup.HIDDEN], + groups: [MetadataGroup.HIDDEN, MetadataGroup.DEPRECATED], }, { name: 'web', @@ -82,7 +82,7 @@ If you are having issues logging in, please get in touch with our Support[^suppo ); } - const askForEmail = !web && !inputs[0]; + const askForEmail = !web && !sso && !inputs[0]; const askForPassword = !web && !sso && !inputs[1]; if (this.env.session.isLoggedIn()) { diff --git a/packages/@ionic/cli/src/lib/oauth/sso.ts b/packages/@ionic/cli/src/lib/oauth/sso.ts deleted file mode 100644 index 8b43ed3166..0000000000 --- a/packages/@ionic/cli/src/lib/oauth/sso.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Response } from 'superagent'; - -import { - AuthorizationParameters, - OAuth2Flow, - OAuth2FlowDeps, - OAuth2FlowOptions, - TokenParameters -} from './oauth'; - -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 flowName = 'sso'; - 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, - }; - } - - protected generateRefreshTokenParameters(refreshToken: string): TokenParameters { - return {}; - } - - protected checkValidExchangeTokenRes(res: Response): boolean { - return true; - } - -} diff --git a/packages/@ionic/cli/src/lib/session.ts b/packages/@ionic/cli/src/lib/session.ts index 7ea4392e3c..ca304b545e 100644 --- a/packages/@ionic/cli/src/lib/session.ts +++ b/packages/@ionic/cli/src/lib/session.ts @@ -122,18 +122,7 @@ export class ProSession extends BaseSession implements ISession { } async ssoLogin(email: string): Promise { - const { AuthClient } = await import('./oauth/auth'); - const { Auth0OAuth2Flow } = await import('./oauth/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); + await this.webLogin(); } async tokenLogin(token: string): Promise { From 6a33fc74e209dc338abdb8164c6c26c341f502f3 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Fri, 22 May 2020 16:10:54 -0400 Subject: [PATCH 06/21] 'ionic login' opens a browser by default and '--sso' does nothing special --- packages/@ionic/cli/src/commands/login.ts | 60 +++++++++++------------ packages/@ionic/cli/src/lib/session.ts | 2 +- 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 703c9f49a4..8f7a4256ea 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -43,14 +43,11 @@ If you are having issues logging in, please get in touch with our Support[^suppo { name: 'email', summary: 'Your email address', - validators: process.argv.includes('--sso') || process.argv.includes('--web') ? [] : [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') || process.argv.includes('--web') ? [] : [validators.required], private: true, }, ], @@ -61,19 +58,12 @@ If you are having issues logging in, please get in touch with our Support[^suppo summary: 'Open a window to log in with the SSO provider associated with your email', groups: [MetadataGroup.HIDDEN, MetadataGroup.DEPRECATED], }, - { - name: 'web', - type: Boolean, - summary: 'Open a window to log in using the Ionic Website', - groups: [MetadataGroup.ADVANCED], - }, ], }; } async preRun(inputs: CommandLineInputs, options: CommandLineOptions): Promise { const sso = !!options['sso']; - const web = !!options['web']; if (options['email'] || options['password']) { throw new FatalException( @@ -82,13 +72,14 @@ If you are having issues logging in, please get in touch with our Support[^suppo ); } - const askForEmail = !web && !sso && !inputs[0]; - const askForPassword = !web && !sso && !inputs[1]; + // ask for password only if the user specifies an email + const validateEmail = !!inputs[0]; + const askForPassword = !sso && 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.'; @@ -112,18 +103,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({ @@ -158,31 +154,31 @@ 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']; - const web = !!options['web']; if (this.env.session.isLoggedIn()) { await this.env.session.logout(); this.env.config.set('tokens.telemetry', generateUUID()); } - if (sso) { + if (email && password) { + await this.env.session.login(email, password); + } else 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.` + `Ionic SSO Login (DEPRECATED)\n` + + `Please run ${input('ionic login')} instead.\n` + + `During this process, a browser window will open to authenticate you with the identity provider. Please leave this process running until authentication is complete.` ); this.env.log.nl(); await this.env.session.ssoLogin(email); - } else if (web) { + } else { this.env.log.info( - `Ionic Web Login\n` + + `Ionic Login\n` + `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.webLogin(); - } else { - await this.env.session.login(email, password); } this.env.log.ok(success(strong('You are logged in!'))); diff --git a/packages/@ionic/cli/src/lib/session.ts b/packages/@ionic/cli/src/lib/session.ts index ca304b545e..b0bfdbd0a3 100644 --- a/packages/@ionic/cli/src/lib/session.ts +++ b/packages/@ionic/cli/src/lib/session.ts @@ -121,7 +121,7 @@ export class ProSession extends BaseSession implements ISession { } } - async ssoLogin(email: string): Promise { + async ssoLogin(email?: string): Promise { await this.webLogin(); } From 2ea28b015f4226efa14ffd79db44c2a35ee55d5f Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Fri, 22 May 2020 16:38:27 -0400 Subject: [PATCH 07/21] Rearrenged login help --- packages/@ionic/cli/src/commands/login.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 8f7a4256ea..9351551331 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -15,19 +15,25 @@ 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 ')}, then print the token by running the ${input('ionic config get -g tokens.user')} command. +If you logged in using the more secure ${input('ionic login')}, if you retrieve your token using ${input('ionic config get -g tokens.user')} you will get a short lived 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', @@ -42,12 +48,12 @@ If you are having issues logging in, please get in touch with our Support[^suppo inputs: [ { name: 'email', - summary: 'Your email address', + summary: '[Optional] Your email address', private: true, }, { name: 'password', - summary: 'Your password', + summary: '[Optional] Your password', private: true, }, ], From fef354cd8feb3dd401f4faa1012bd728f2251a85 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Tue, 26 May 2020 17:58:22 -0400 Subject: [PATCH 08/21] Prompt for confirmation before opening the browser --- packages/@ionic/cli/src/commands/login.ts | 13 +++++++++++- packages/@ionic/cli/src/lib/session.ts | 26 +++++++---------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 9351551331..4868ed14ce 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -184,7 +184,18 @@ If you are having issues logging in, please get in touch with our Support[^suppo ); this.env.log.nl(); - await this.env.session.webLogin(); + const login = await this.env.prompt({ + type: 'confirm', + name: 'continue', + message: 'Open the browser to login to your Ionic account?', + default: true, + }); + + if (login) { + await this.env.session.webLogin(); + } else { + return ; + } } this.env.log.ok(success(strong('You are logged in!'))); diff --git a/packages/@ionic/cli/src/lib/session.ts b/packages/@ionic/cli/src/lib/session.ts index b0bfdbd0a3..6e0aa955ab 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'; @@ -188,30 +186,22 @@ export class ProSession extends BaseSession implements ISession { } 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 login 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 { From af2ad95a685a9c2acba0b4ba801015c60a235849 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Thu, 28 May 2020 13:52:26 -0400 Subject: [PATCH 09/21] openid config coming from config --- packages/@ionic/cli/src/commands/login.ts | 28 ++++++++++----------- packages/@ionic/cli/src/definitions.ts | 14 +++++++++++ packages/@ionic/cli/src/lib/config.ts | 11 +++++++- packages/@ionic/cli/src/lib/oauth/oauth.ts | 26 +++++++++---------- packages/@ionic/cli/src/lib/oauth/openid.ts | 27 +++++++++----------- packages/@ionic/cli/src/lib/session.ts | 4 +-- 6 files changed, 64 insertions(+), 46 deletions(-) diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 4868ed14ce..c77af82185 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -168,21 +168,20 @@ If you are having issues logging in, please get in touch with our Support[^suppo if (email && password) { await this.env.session.login(email, password); - } else if (sso) { - this.env.log.info( - `Ionic SSO Login (DEPRECATED)\n` + - `Please run ${input('ionic login')} instead.\n` + - `During this process, a browser window will open to authenticate you with the identity provider. Please leave this process running until authentication is complete.` - ); - this.env.log.nl(); - - await this.env.session.ssoLogin(email); } else { - this.env.log.info( - `Ionic Login\n` + - `During this process, a browser window will open to authenticate you. Please leave this process running until authentication is complete.` - ); - this.env.log.nl(); + if (sso) { + this.env.log.warn( + `Ionic SSO Login (DEPRECATED)\n` + + `Please run ${input('ionic login')} instead.\n` + + `During this process, a browser window will open to authenticate you with the identity provider. Please leave this process running until authentication is complete.` + ); + } else { + this.env.log.info( + `Ionic Login\n` + + `During this process, a browser window will open to authenticate you. Please leave this process running until authentication is complete.` + ); + this.env.log.nl(); + } const login = await this.env.prompt({ type: 'confirm', @@ -196,6 +195,7 @@ If you are having issues logging in, please get in touch with our Support[^suppo } else { return ; } + } this.env.log.ok(success(strong('You are logged in!'))); diff --git a/packages/@ionic/cli/src/definitions.ts b/packages/@ionic/cli/src/definitions.ts index 4764e87168..5fe0963bb5 100644 --- a/packages/@ionic/cli/src/definitions.ts +++ b/packages/@ionic/cli/src/definitions.ts @@ -233,6 +233,13 @@ 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; @@ -278,6 +285,7 @@ export interface IConfig extends BaseConfig { getGitHost(): string; getGitPort(): number; getHTTPConfig(): CreateRequestOptions; + getOpenIDOAuthConfig(): OAuthServerConfig; } export interface ProjectPersonalizationDetails { @@ -467,6 +475,12 @@ export interface ConfigFile { '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; } 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/oauth/oauth.ts b/packages/@ionic/cli/src/lib/oauth/oauth.ts index 4ccf9d8191..b05e80320e 100644 --- a/packages/@ionic/cli/src/lib/oauth/oauth.ts +++ b/packages/@ionic/cli/src/lib/oauth/oauth.ts @@ -7,7 +7,7 @@ import * as qs from 'querystring'; import { Response } from 'superagent'; import { ASSETS_DIRECTORY } from '../../constants'; -import { ContentTypes, IClient } from '../../definitions'; +import { ContentTypes, IClient, IConfig, OAuthServerConfig } from '../../definitions'; import { FatalException } from '../errors'; import { formatResponseError } from '../http'; import { openUrl } from '../open'; @@ -24,9 +24,9 @@ export interface TokenParameters { } export interface OAuth2FlowOptions { - readonly authorizationUrl: string; - readonly tokenUrl: string; - readonly clientId: string; + readonly authorizationUrl?: string; + readonly tokenUrl?: string; + readonly clientId?: string; readonly redirectHost?: string; readonly redirectPort?: number; readonly accessTokenRequestContentType?: ContentTypes; @@ -34,21 +34,18 @@ export interface OAuth2FlowOptions { export interface OAuth2FlowDeps { readonly client: IClient; + readonly config: IConfig; } export abstract class OAuth2Flow { abstract readonly flowName: string; - readonly authorizationUrl: string; - readonly tokenUrl: string; - readonly clientId: string; + readonly oauthConfig: OAuthServerConfig; readonly redirectHost: string; readonly redirectPort: number; readonly accessTokenRequestContentType: ContentTypes; - constructor({ authorizationUrl, tokenUrl, clientId, redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT, accessTokenRequestContentType = ContentTypes.json }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { - this.authorizationUrl = authorizationUrl; - this.tokenUrl = tokenUrl; - this.clientId = clientId; + constructor({ redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT, accessTokenRequestContentType = ContentTypes.json }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { + this.oauthConfig = this.getAuthConfig(); this.redirectHost = redirectHost; this.redirectPort = redirectPort; this.accessTokenRequestContentType = accessTokenRequestContentType; @@ -63,7 +60,7 @@ export abstract class OAuth2Flow { 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); @@ -75,7 +72,7 @@ export abstract class OAuth2Flow { async exchangeRefreshToken(refreshToken: string): Promise { const params = this.generateRefreshTokenParameters(refreshToken); - const { req } = await this.e.client.make('POST', this.tokenUrl, this.accessTokenRequestContentType); + const { req } = await this.e.client.make('POST', this.oauthConfig.tokenUrl, this.accessTokenRequestContentType); const res = await req.send(params); @@ -97,6 +94,7 @@ export abstract class OAuth2Flow { 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'); @@ -136,7 +134,7 @@ export abstract class OAuth2Flow { protected async exchangeAuthForAccessToken(authorizationCode: string, verifier: string): Promise { const params = this.generateTokenParameters(authorizationCode, verifier); - const { req } = await this.e.client.make('POST', this.tokenUrl, this.accessTokenRequestContentType); + const { req } = await this.e.client.make('POST', this.oauthConfig.tokenUrl, this.accessTokenRequestContentType); const res = await req.send(params); if (!this.checkValidExchangeTokenRes(res)) { diff --git a/packages/@ionic/cli/src/lib/oauth/openid.ts b/packages/@ionic/cli/src/lib/oauth/openid.ts index c3aa170850..01b27fdcc0 100644 --- a/packages/@ionic/cli/src/lib/oauth/openid.ts +++ b/packages/@ionic/cli/src/lib/oauth/openid.ts @@ -1,6 +1,6 @@ import { Response } from 'superagent'; -import { ContentTypes, OpenIdToken } from '../../definitions'; +import { ContentTypes, OAuthServerConfig, OpenIdToken } from '../../definitions'; import { isOpenIDTokenExchangeResponse } from '../../guards'; import { @@ -11,31 +11,23 @@ import { TokenParameters } from './oauth'; -const AUTHORIZATION_URL = 'https://staging.ionicframework.com/oauth/authorize'; -const TOKEN_URL = 'https://api-staging.ionicjs.com/oauth/token'; -const CLIENT_ID = 'cli'; -const API_AUDIENCE = 'https://api.ionicjs.com'; - export interface OpenIDFlowOptions extends Partial { - readonly audience?: string; readonly accessTokenRequestContentType?: ContentTypes; } export class OpenIDFlow extends OAuth2Flow { readonly flowName = 'open_id'; - readonly audience: string; - constructor({ audience = API_AUDIENCE, accessTokenRequestContentType = ContentTypes.formUrlencoded, authorizationUrl = AUTHORIZATION_URL, tokenUrl = TOKEN_URL, clientId = CLIENT_ID, ...options }: OpenIDFlowOptions, readonly e: OAuth2FlowDeps) { - super({ authorizationUrl, tokenUrl, clientId, accessTokenRequestContentType, ...options }, e); - this.audience = audience; + constructor({ accessTokenRequestContentType = ContentTypes.formUrlencoded, ...options }: OpenIDFlowOptions, readonly e: OAuth2FlowDeps) { + super({ accessTokenRequestContentType, ...options }, e); } protected generateAuthorizationParameters(challenge: string): AuthorizationParameters { return { - audience: this.audience, + audience: this.oauthConfig.apiAudience, scope: 'openid profile email offline_access', response_type: 'code', - client_id: this.clientId, + client_id: this.oauthConfig.clientId, code_challenge: challenge, code_challenge_method: 'S256', redirect_uri: this.redirectUrl, @@ -46,7 +38,7 @@ export class OpenIDFlow extends OAuth2Flow { protected generateTokenParameters(code: string, verifier: string): TokenParameters { return { grant_type: 'authorization_code', - client_id: this.clientId, + client_id: this.oauthConfig.clientId, code_verifier: verifier, code, redirect_uri: this.redirectUrl, @@ -57,11 +49,16 @@ export class OpenIDFlow extends OAuth2Flow { return { refresh_token: refreshToken, grant_type: 'refresh_token', - client_id: this.clientId, + 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 6e0aa955ab..bc9f2863b5 100644 --- a/packages/@ionic/cli/src/lib/session.ts +++ b/packages/@ionic/cli/src/lib/session.ts @@ -150,7 +150,7 @@ export class ProSession extends BaseSession implements ISession { async webLogin(): Promise { const { OpenIDFlow } = await import('./oauth/openid'); - const flow = new OpenIDFlow({ audience: this.e.config.get('urls.api') }, this.e); + const flow = new OpenIDFlow({}, this.e); const token = await flow.run(); await this.tokenLogin(token.access_token); @@ -167,7 +167,7 @@ export class ProSession extends BaseSession implements ISession { switch (flowName) { case 'open_id': const { OpenIDFlow } = await import('./oauth/openid'); - oauthflow = new OpenIDFlow({ audience: this.e.config.get('urls.api') }, this.e); + oauthflow = new OpenIDFlow({}, this.e); break; default: oauthflow = undefined; From 1476097a71716727e19e9269f991cd4c99f72ebb Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Thu, 28 May 2020 14:23:26 -0400 Subject: [PATCH 10/21] removed unised parameters in interface --- packages/@ionic/cli/src/lib/oauth/oauth.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/@ionic/cli/src/lib/oauth/oauth.ts b/packages/@ionic/cli/src/lib/oauth/oauth.ts index b05e80320e..35b0866159 100644 --- a/packages/@ionic/cli/src/lib/oauth/oauth.ts +++ b/packages/@ionic/cli/src/lib/oauth/oauth.ts @@ -24,9 +24,6 @@ export interface TokenParameters { } export interface OAuth2FlowOptions { - readonly authorizationUrl?: string; - readonly tokenUrl?: string; - readonly clientId?: string; readonly redirectHost?: string; readonly redirectPort?: number; readonly accessTokenRequestContentType?: ContentTypes; From 01756e430e7c77c582a17cc29a0eab834e6d156c Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Mon, 1 Jun 2020 12:25:05 -0700 Subject: [PATCH 11/21] remove [Optional] in favor of new built-in help format --- packages/@ionic/cli/src/commands/login.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index c77af82185..95969a17ca 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -48,12 +48,12 @@ If you are having issues logging in, please get in touch with our Support[^suppo inputs: [ { name: 'email', - summary: '[Optional] Your email address', + summary: 'Your email address', private: true, }, { name: 'password', - summary: '[Optional] Your password', + summary: 'Your password', private: true, }, ], From ab0430d9e31d540bc72aba66081ece1c603dfaf9 Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Mon, 1 Jun 2020 13:50:18 -0700 Subject: [PATCH 12/21] remove sso option and print a warning when it's used --- packages/@ionic/cli/src/commands/login.ts | 38 +++++++---------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 95969a17ca..8f96cd0a38 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'; @@ -57,20 +57,10 @@ If you are having issues logging in, please get in touch with our Support[^suppo 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, MetadataGroup.DEPRECATED], - }, - ], }; } 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` + @@ -78,9 +68,17 @@ If you are having issues logging in, please get in touch with our Support[^suppo ); } + 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 = !sso && inputs[0] && !inputs[1]; + const askForPassword = inputs[0] && !inputs[1]; if (this.env.session.isLoggedIn()) { const email = this.env.config.get('user.email'); @@ -159,7 +157,6 @@ 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(); @@ -169,19 +166,8 @@ If you are having issues logging in, please get in touch with our Support[^suppo if (email && password) { await this.env.session.login(email, password); } else { - if (sso) { - this.env.log.warn( - `Ionic SSO Login (DEPRECATED)\n` + - `Please run ${input('ionic login')} instead.\n` + - `During this process, a browser window will open to authenticate you with the identity provider. Please leave this process running until authentication is complete.` - ); - } else { - this.env.log.info( - `Ionic Login\n` + - `During this process, a browser window will open to authenticate you. Please leave this process running until authentication is complete.` - ); - this.env.log.nl(); - } + 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(); const login = await this.env.prompt({ type: 'confirm', From 0539988ba0fd182eaf76f15b23d6f4aa09ec77cb Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Mon, 1 Jun 2020 13:50:42 -0700 Subject: [PATCH 13/21] copy updates --- packages/@ionic/cli/src/commands/login.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 8f96cd0a38..70d201a692 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -17,9 +17,7 @@ export class LoginCommand extends Command implements CommandPreRun { 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 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 you logged in using the more secure ${input('ionic login')}, if you retrieve your token using ${input('ionic config get -g tokens.user')} you will get a short lived token. +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 ')}. @@ -172,7 +170,7 @@ If you are having issues logging in, please get in touch with our Support[^suppo const login = await this.env.prompt({ type: 'confirm', name: 'continue', - message: 'Open the browser to login to your Ionic account?', + message: 'Open the browser to log in to your Ionic account?', default: true, }); From e049a35df303745a42c8d1eb57bfef824f4b619a Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Mon, 1 Jun 2020 13:50:49 -0700 Subject: [PATCH 14/21] fail in non-interactive mode with a nice message --- packages/@ionic/cli/src/commands/login.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 70d201a692..8d01f1cc34 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -164,6 +164,13 @@ If you are having issues logging in, please get in touch with our Support[^suppo if (email && password) { 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.` + ); + } + 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(); From 1dfa67dfb64f3747c1dedc6127056cc3d61ef54f Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Mon, 1 Jun 2020 14:09:42 -0700 Subject: [PATCH 15/21] enum naming --- packages/@ionic/cli/src/definitions.ts | 10 +++++----- packages/@ionic/cli/src/lib/http.ts | 8 ++++---- packages/@ionic/cli/src/lib/oauth/oauth.ts | 10 +++++----- packages/@ionic/cli/src/lib/oauth/openid.ts | 6 +++--- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/@ionic/cli/src/definitions.ts b/packages/@ionic/cli/src/definitions.ts index 5fe0963bb5..46bc6ee0ad 100644 --- a/packages/@ionic/cli/src/definitions.ts +++ b/packages/@ionic/cli/src/definitions.ts @@ -537,16 +537,16 @@ export interface APIResponsePageTokenMeta extends APIResponseMeta { export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'PURGE' | 'HEAD' | 'OPTIONS'; -export enum ContentTypes { - json = 'application/json', - formUrlencoded = 'application/x-www-form-urlencoded', - html = 'text/html', +export 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, contentType?: ContentTypes): 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/lib/http.ts b/packages/@ionic/cli/src/lib/http.ts index 2ec0d197f2..9f27646be1 100644 --- a/packages/@ionic/cli/src/lib/http.ts +++ b/packages/@ionic/cli/src/lib/http.ts @@ -6,7 +6,7 @@ import { APIResponse, APIResponsePageTokenMeta, APIResponseSuccess, - ContentTypes, + ContentType, HttpMethod, IClient, IConfig, @@ -38,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, contentType: ContentTypes = ContentTypes.json): 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', contentType) - .set('Accept', ContentTypes.json); + .set('Accept', ContentType.JSON); return { req }; } @@ -229,7 +229,7 @@ export function transformAPIResponse(r: SuperAgentResponse): APIResponse { r.body = { meta: { status: 204, version: '', request_id: '' } }; } - if (r.status !== 204 && r.type !== ContentTypes.json) { + if (r.status !== 204 && r.type !== ContentType.JSON) { throw ERROR_UNKNOWN_CONTENT_TYPE; } diff --git a/packages/@ionic/cli/src/lib/oauth/oauth.ts b/packages/@ionic/cli/src/lib/oauth/oauth.ts index 35b0866159..00dee64aa2 100644 --- a/packages/@ionic/cli/src/lib/oauth/oauth.ts +++ b/packages/@ionic/cli/src/lib/oauth/oauth.ts @@ -7,7 +7,7 @@ import * as qs from 'querystring'; import { Response } from 'superagent'; import { ASSETS_DIRECTORY } from '../../constants'; -import { ContentTypes, IClient, IConfig, OAuthServerConfig } from '../../definitions'; +import { ContentType, IClient, IConfig, OAuthServerConfig } from '../../definitions'; import { FatalException } from '../errors'; import { formatResponseError } from '../http'; import { openUrl } from '../open'; @@ -26,7 +26,7 @@ export interface TokenParameters { export interface OAuth2FlowOptions { readonly redirectHost?: string; readonly redirectPort?: number; - readonly accessTokenRequestContentType?: ContentTypes; + readonly accessTokenRequestContentType?: ContentType; } export interface OAuth2FlowDeps { @@ -39,9 +39,9 @@ export abstract class OAuth2Flow { readonly oauthConfig: OAuthServerConfig; readonly redirectHost: string; readonly redirectPort: number; - readonly accessTokenRequestContentType: ContentTypes; + readonly accessTokenRequestContentType: ContentType; - constructor({ redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT, accessTokenRequestContentType = ContentTypes.json }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { + constructor({ redirectHost = REDIRECT_HOST, redirectPort = REDIRECT_PORT, accessTokenRequestContentType = ContentType.JSON }: OAuth2FlowOptions, readonly e: OAuth2FlowDeps) { this.oauthConfig = this.getAuthConfig(); this.redirectHost = redirectHost; this.redirectPort = redirectPort; @@ -113,7 +113,7 @@ export abstract class OAuth2Flow { const params = qs.parse(req.url.substring(req.url.indexOf('?') + 1)); if (params.code) { - res.writeHead(200, { 'Content-Type': ContentTypes.html }); + res.writeHead(200, { 'Content-Type': ContentType.HTML }); res.end(successHtml); req.socket.destroy(); server.close(); diff --git a/packages/@ionic/cli/src/lib/oauth/openid.ts b/packages/@ionic/cli/src/lib/oauth/openid.ts index 01b27fdcc0..262e0e34c9 100644 --- a/packages/@ionic/cli/src/lib/oauth/openid.ts +++ b/packages/@ionic/cli/src/lib/oauth/openid.ts @@ -1,6 +1,6 @@ import { Response } from 'superagent'; -import { ContentTypes, OAuthServerConfig, OpenIdToken } from '../../definitions'; +import { ContentType, OAuthServerConfig, OpenIdToken } from '../../definitions'; import { isOpenIDTokenExchangeResponse } from '../../guards'; import { @@ -12,13 +12,13 @@ import { } from './oauth'; export interface OpenIDFlowOptions extends Partial { - readonly accessTokenRequestContentType?: ContentTypes; + readonly accessTokenRequestContentType?: ContentType; } export class OpenIDFlow extends OAuth2Flow { readonly flowName = 'open_id'; - constructor({ accessTokenRequestContentType = ContentTypes.formUrlencoded, ...options }: OpenIDFlowOptions, readonly e: OAuth2FlowDeps) { + constructor({ accessTokenRequestContentType = ContentType.FORM_URLENCODED, ...options }: OpenIDFlowOptions, readonly e: OAuth2FlowDeps) { super({ accessTokenRequestContentType, ...options }, e); } From c2b5bfaa4c93b436de09936210591c20cabf1ba2 Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Mon, 1 Jun 2020 14:17:45 -0700 Subject: [PATCH 16/21] logout after prompt confirmation --- packages/@ionic/cli/src/commands/login.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/@ionic/cli/src/commands/login.ts b/packages/@ionic/cli/src/commands/login.ts index 8d01f1cc34..f7b2a90065 100644 --- a/packages/@ionic/cli/src/commands/login.ts +++ b/packages/@ionic/cli/src/commands/login.ts @@ -156,12 +156,8 @@ 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; - 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) { @@ -182,6 +178,7 @@ If you are having issues logging in, please get in touch with our Support[^suppo }); if (login) { + await this.logout(); await this.env.session.webLogin(); } else { return ; @@ -191,4 +188,11 @@ If you are having issues logging in, please get in touch with our Support[^suppo 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()); + } + } } From 909681949dec4d69f4dea523cf18bfbb627e9b02 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Tue, 2 Jun 2020 15:09:46 -0400 Subject: [PATCH 17/21] Comments on the PR --- packages/@ionic/cli/src/definitions.ts | 2 +- packages/@ionic/cli/src/lib/oauth/oauth.ts | 10 +++++++--- packages/@ionic/cli/src/lib/session.ts | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/@ionic/cli/src/definitions.ts b/packages/@ionic/cli/src/definitions.ts index 46bc6ee0ad..4b19d30827 100644 --- a/packages/@ionic/cli/src/definitions.ts +++ b/packages/@ionic/cli/src/definitions.ts @@ -537,7 +537,7 @@ export interface APIResponsePageTokenMeta extends APIResponseMeta { export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'PURGE' | 'HEAD' | 'OPTIONS'; -export enum ContentType { +export const enum ContentType { JSON = 'application/json', FORM_URLENCODED = 'application/x-www-form-urlencoded', HTML = 'text/html', diff --git a/packages/@ionic/cli/src/lib/oauth/oauth.ts b/packages/@ionic/cli/src/lib/oauth/oauth.ts index 00dee64aa2..d1bd6a41a4 100644 --- a/packages/@ionic/cli/src/lib/oauth/oauth.ts +++ b/packages/@ionic/cli/src/lib/oauth/oauth.ts @@ -74,15 +74,19 @@ export abstract class OAuth2Flow { const res = await req.send(params); // check the response status code first here - if (!res || !res.body || !res.status || res.status < 200 || res.status >= 300) { + if (!res.ok) { throw new FatalException( - 'API request was to refresh token was not successful.\n' + + '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'); + throw new FatalException( + 'API request was successful, but the refreshed token was unrecognized.\n' + + 'Please try to login again.\n' + ); } return res.body; } diff --git a/packages/@ionic/cli/src/lib/session.ts b/packages/@ionic/cli/src/lib/session.ts index bc9f2863b5..83d3c16bf4 100644 --- a/packages/@ionic/cli/src/lib/session.ts +++ b/packages/@ionic/cli/src/lib/session.ts @@ -195,7 +195,7 @@ export async function promptToLogin(env: IonicEnvironment): Promise { const login = await env.prompt({ type: 'confirm', name: 'login', - message: 'Open the browser to login to your Ionic account?', + message: 'Open the browser to log in to your Ionic account?', default: true, }); From f708d5a18d8a40b5b02e0047abe324463791d3ea Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Tue, 2 Jun 2020 12:23:02 -0700 Subject: [PATCH 18/21] new success page design --- .../@ionic/cli/assets/sso/success/index.html | 76 ++++++++++++++++--- .../@ionic/cli/assets/sso/success/logo.svg | 3 + 2 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 packages/@ionic/cli/assets/sso/success/logo.svg diff --git a/packages/@ionic/cli/assets/sso/success/index.html b/packages/@ionic/cli/assets/sso/success/index.html index 7737fb406d..09f72eb83f 100644 --- a/packages/@ionic/cli/assets/sso/success/index.html +++ b/packages/@ionic/cli/assets/sso/success/index.html @@ -4,9 +4,27 @@ Success -
-

You are authenticated.

-

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

-
+
+ + + +
+
+
+

You are authenticated.

+

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

+
+
diff --git a/packages/@ionic/cli/assets/sso/success/logo.svg b/packages/@ionic/cli/assets/sso/success/logo.svg new file mode 100644 index 0000000000..83ea537e2e --- /dev/null +++ b/packages/@ionic/cli/assets/sso/success/logo.svg @@ -0,0 +1,3 @@ + + + From 21a9b312911ce8fea42288624374b77b8415d311 Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Tue, 2 Jun 2020 12:24:58 -0700 Subject: [PATCH 19/21] prefer local eina font --- packages/@ionic/cli/assets/sso/success/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@ionic/cli/assets/sso/success/index.html b/packages/@ionic/cli/assets/sso/success/index.html index 09f72eb83f..f6432b1da9 100644 --- a/packages/@ionic/cli/assets/sso/success/index.html +++ b/packages/@ionic/cli/assets/sso/success/index.html @@ -7,7 +7,8 @@ @font-face { font-family: Eina; font-display: swap; - src: url(https://ionicframework.com/fonts/eina/eina-01-bold.woff2) format("woff2"), + src: local("Eina Bold"), + url(https://ionicframework.com/fonts/eina/eina-01-bold.woff2) format("woff2"), url(https://ionicframework.com/fonts/eina/eina-01-bold.woff) format("woff"), url(https://ionicframework.com/fonts/eina/eina-01-bold.ttf) format("ttf"); font-weight: 700; From a9533499af66f6f2127212fcc22b97af3c8bdfc8 Mon Sep 17 00:00:00 2001 From: Giovanni Di Milia Date: Tue, 2 Jun 2020 15:28:46 -0400 Subject: [PATCH 20/21] Renamed directory with oauth landing page --- packages/@ionic/cli/assets/{sso => oauth}/success/index.html | 0 packages/@ionic/cli/assets/{sso => oauth}/success/logo.svg | 0 packages/@ionic/cli/src/lib/oauth/oauth.ts | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename packages/@ionic/cli/assets/{sso => oauth}/success/index.html (100%) rename packages/@ionic/cli/assets/{sso => oauth}/success/logo.svg (100%) diff --git a/packages/@ionic/cli/assets/sso/success/index.html b/packages/@ionic/cli/assets/oauth/success/index.html similarity index 100% rename from packages/@ionic/cli/assets/sso/success/index.html rename to packages/@ionic/cli/assets/oauth/success/index.html diff --git a/packages/@ionic/cli/assets/sso/success/logo.svg b/packages/@ionic/cli/assets/oauth/success/logo.svg similarity index 100% rename from packages/@ionic/cli/assets/sso/success/logo.svg rename to packages/@ionic/cli/assets/oauth/success/logo.svg diff --git a/packages/@ionic/cli/src/lib/oauth/oauth.ts b/packages/@ionic/cli/src/lib/oauth/oauth.ts index d1bd6a41a4..e66bf57210 100644 --- a/packages/@ionic/cli/src/lib/oauth/oauth.ts +++ b/packages/@ionic/cli/src/lib/oauth/oauth.ts @@ -98,7 +98,7 @@ export abstract class OAuth2Flow { 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; From 759393275519f985d2ca7c07e480e995bf8c8c00 Mon Sep 17 00:00:00 2001 From: Daniel Imhoff Date: Tue, 2 Jun 2020 13:23:11 -0700 Subject: [PATCH 21/21] inline logo --- packages/@ionic/cli/assets/oauth/success/index.html | 4 +++- packages/@ionic/cli/assets/oauth/success/logo.svg | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) delete mode 100644 packages/@ionic/cli/assets/oauth/success/logo.svg diff --git a/packages/@ionic/cli/assets/oauth/success/index.html b/packages/@ionic/cli/assets/oauth/success/index.html index f6432b1da9..67bdc47711 100644 --- a/packages/@ionic/cli/assets/oauth/success/index.html +++ b/packages/@ionic/cli/assets/oauth/success/index.html @@ -91,7 +91,9 @@
- + + +
diff --git a/packages/@ionic/cli/assets/oauth/success/logo.svg b/packages/@ionic/cli/assets/oauth/success/logo.svg deleted file mode 100644 index 83ea537e2e..0000000000 --- a/packages/@ionic/cli/assets/oauth/success/logo.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -