Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix new login flow #930

Merged
merged 7 commits into from Aug 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Expand Up @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- [vtex login] Use new VTEX ID login flow:
- Fix free TCP port retrieval: use `detect-port` package instead of `get-port`.
- Fix issue on WSL2 by specifying host binding to `127.0.0.1` when starting the server.

## [2.108.0] - 2020-08-11
### Added
- [vtex workspace promote] Conflict handling.
Expand Down
8 changes: 6 additions & 2 deletions package.json
Expand Up @@ -5,8 +5,7 @@
"bin": "bin/run",
"main": "build/api/index.js",
"scripts": {
"exe": "node scripts/make-executable.js",
"watch": "bash ./scripts/symlink.sh && yarn nodemon && yarn oclif-dev manifest",
"watch": "yarn build-clean && bash ./scripts/symlink.sh && yarn nodemon",
"format": "prettier --config ./.prettierrc --write \"./src/**/*.{ts,tsx,js,jsx,json}\"",
"lint:node": "yarn eslint ./src --cache --ext ts --config .eslintrc",
"format-lint": "yarn format && yarn lint:node",
Expand Down Expand Up @@ -75,9 +74,11 @@
"cli-table": "~0.3.1",
"cli-table2": "~0.2.0",
"clipboardy": "~2.1.0",
"co-body": "^6.0.0",
"configstore": "^5.0.1",
"csvtojson": "~2.0.10",
"debounce": "~1.2.0",
"detect-port": "^1.3.0",
"diff": "~3.5.0",
"enquirer": "~2.3.2",
"eventsource": "~1.0.7",
Expand All @@ -90,6 +91,7 @@
"is-wsl": "^2.1.1",
"js-yaml": "~3.13.1",
"jsonwebtoken": "~8.5.1",
"koa": "^2.13.0",
"latest-version": "^4.0.0",
"moment": "~2.24.0",
"node-notifier": "^6.0.0",
Expand Down Expand Up @@ -123,8 +125,10 @@
"devDependencies": {
"@oclif/dev-cli": "^1",
"@types/async-retry": "1.4.1",
"@types/co-body": "^5.1.0",
"@types/configstore": "^4.0.0",
"@types/debounce": "^1.2.0",
"@types/detect-port": "^1.3.0",
"@types/eventsource": "^1.1.2",
"@types/fs-extra": "5.0.4",
"@types/jest": "24.0.23",
Expand Down
34 changes: 34 additions & 0 deletions src/api/clients/IOClients/external/VTEXID.ts
@@ -1,5 +1,6 @@
import { InstanceOptions, IOClient, IOContext } from '@vtex/api'
import opn from 'opn'
import querystring from 'querystring'
import { storeUrl } from '../../../storeUrl'
import { IOClientFactory } from '../IOClientFactory'

Expand All @@ -8,6 +9,7 @@ export class VTEXID extends IOClient {
private static readonly DEFAULT_RETRIES = 2
private static readonly BASE_URL = 'https://vtexid.vtex.com.br'
private static readonly API_PATH_PREFIX = '/api/vtexid'
private static readonly TOOLBELT_API_PATH_PREFIX = `${VTEXID.API_PATH_PREFIX}/toolbelt`
private static readonly VTEX_ID_AUTH_COOKIE = 'VtexIdClientAutCookie'

public static createClient(customContext: Partial<IOContext> = {}, customOptions: Partial<InstanceOptions> = {}) {
Expand All @@ -30,6 +32,25 @@ export class VTEXID extends IOClient {
})
}

public startToolbeltLogin({ account, secretHash, loopbackUrl }: StartToolbeltLoginInput) {
const body = querystring.stringify({
secretHash,
loopbackUrl,
})

return this.http.post<string>(`${VTEXID.TOOLBELT_API_PATH_PREFIX}/start?an=${account}`, body)
}

public validateToolbeltLogin({ account, state, secret, ott }: ValidateToolbeltLoginInput) {
const body = querystring.stringify({
state,
secret,
ott,
})

return this.http.post<{ token: string }>(`${VTEXID.TOOLBELT_API_PATH_PREFIX}/validate?an=${account}`, body)
}

public invalidateToolbeltToken(token: string) {
return this.http.get(`/api/vtexid/pub/logout?scope=`, {
headers: {
Expand All @@ -38,3 +59,16 @@ export class VTEXID extends IOClient {
})
}
}

interface StartToolbeltLoginInput {
account: string
secretHash: string
loopbackUrl: string
}

interface ValidateToolbeltLoginInput {
account: string
state: string
secret: string
ott: string
}
1 change: 1 addition & 0 deletions src/api/error/ErrorKinds.ts
Expand Up @@ -16,4 +16,5 @@ export const ErrorKinds = {
APP_LOGS_PARSE_ERROR: 'LogsParseError',
STICKY_HOST_ERROR: 'StickyHostError',
FLOW_ISSUE_ERROR: 'FlowIssue',
LOGIN_SERVER_START_ERROR: 'LoginServerStartError',
tiagonapoli marked this conversation as resolved.
Show resolved Hide resolved
}
64 changes: 0 additions & 64 deletions src/lib/auth/AuthProviders/OAuthAuthenticator.ts

This file was deleted.

185 changes: 185 additions & 0 deletions src/lib/auth/AuthProviders/OAuthAuthenticator/LoginServer.ts
@@ -0,0 +1,185 @@
import asyncRetry from 'async-retry'
import coBody from 'co-body'
import detectPort from 'detect-port'
import { Server } from 'http'
import Koa from 'koa'
import { ErrorKinds, logger } from '../../../../api'
import { VTEXID } from '../../../../api/clients/IOClients/external/VTEXID'
import { ErrorReport } from '../../../../api/error/ErrorReport'

const SUCCESS_PAGE = `
<!doctype html>
<html>
<head>
<title>Success</title>
<meta charset="utf-8">
</head>
<body>
<p> Você já pode fechar essa janela. </p>
<p> You may now close this window. </p>
<p> Ahora puedes cerrar esta ventana. </p>
</body>
</html>`

export class LoginServer {
private static readonly LOGIN_CALLBACK_PATH = '/login_callback'
private static readonly SERVER_START_RETRIES = 1
private static readonly HOSTNAME = '127.0.0.1'

public static async create(loginConfig: LoginConfig) {
const loginServer = new LoginServer(loginConfig)
await loginServer.start()
return loginServer
}

private app: Koa
private port: number
private server: Server

private loginState?: string

private tokenPromise: Promise<string>
private resolveTokenPromise: (val: string) => void
private rejectTokenPromise: (err: any) => void

constructor(private loginConfig: LoginConfig) {
this.app = new Koa()
this.registerLoginHandler()

this.tokenPromise = new Promise((resolve, reject) => {
this.resolveTokenPromise = resolve
this.rejectTokenPromise = reject
})
}

get token() {
return this.tokenPromise
}

get loginCallbackUrl() {
if (!this.port) {
throw new Error('LoginServer not initialized')
}

return `http://${LoginServer.HOSTNAME}:${this.port}${LoginServer.LOGIN_CALLBACK_PATH}`
}

public setLoginState(val: string) {
this.loginState = val
}

public start() {
return asyncRetry(
async (bail, attemptNumber) => {
try {
// detectPort will get the specified port or, if it's in use, another ramdom unnused port
this.port = await detectPort(3000)
tiagonapoli marked this conversation as resolved.
Show resolved Hide resolved
this.server = await this.initServer(this.port)
logger.debug(`LoginServer started on http://${LoginServer.HOSTNAME}:${this.port}`)
} catch (err) {
logger.debug(`LoginServer failed to start on port:${this.port}. Reason: ${err.message}.`)
if (err.code !== 'EADDRINUSE') {
return bail(err)
}

if (attemptNumber < LoginServer.SERVER_START_RETRIES + 1) {
logger.debug(`Retrying to start LoginServer...`)
}

throw ErrorReport.createAndMaybeRegisterOnTelemetry({
originalError: err,
kind: ErrorKinds.LOGIN_SERVER_START_ERROR,
details: { attemptNumber },
})
}
},
{ retries: LoginServer.SERVER_START_RETRIES, maxTimeout: 100, minTimeout: 100 }
)
}

public close() {
this.server.unref()
this.server.close()
}

private initServer(port: number): Promise<Server> {
return new Promise((resolve, reject) => {
const server = this.app.listen(port, LoginServer.HOSTNAME, () => {
tiagonapoli marked this conversation as resolved.
Show resolved Hide resolved
server.on('connection', socket => {
socket.unref()
})

server.removeListener('error', reject)
resolve(server)
})

server.on('error', reject)
tiagonapoli marked this conversation as resolved.
Show resolved Hide resolved
})
}

private registerLoginHandler() {
this.app.use(async ctx => {
ctx.set('connection', 'close')

if (ctx.path !== LoginServer.LOGIN_CALLBACK_PATH) {
ctx.status = 404
ctx.body = 'Not found'
return
}

ctx.socket.ref()
logger.debug(`Received ${ctx.method} login callback`)
if (!this.loginState) {
return this.handleError(ctx, new Error('Received login callback before setting login state'))
}

let body
if (ctx.method.toLowerCase() === 'post') {
try {
body = await coBody(ctx.req)
} catch (err) {
return this.handleError(ctx, err)
}
} else {
body = { ott: ctx.query?.ott }
}

if (!body.ott) {
return this.handleError(ctx, new Error('Missing ott on VTEX ID callback call'), { body })
}

const vtexId = VTEXID.createClient({ account: this.loginConfig.account })
try {
const { token } = await vtexId.validateToolbeltLogin({
account: this.loginConfig.account,
secret: this.loginConfig.secret,
ott: body.ott,
state: this.loginState,
})

this.resolveTokenPromise(token)
ctx.status = 200
ctx.set('content-type', 'text/html')
ctx.body = SUCCESS_PAGE
} catch (err) {
return this.handleError(ctx, err)
}
})
}

private handleError(ctx: Koa.ParameterizedContext, err: any, details?: Record<string, any>) {
const errReport = ErrorReport.createAndMaybeRegisterOnTelemetry({ originalError: err, details })
ctx.status = 500
ctx.body = {
errorId: errReport.metadata.errorId,
message: errReport.message,
}

this.rejectTokenPromise(errReport)
}
}

interface LoginConfig {
account: string
secret: string
}