Skip to content

Commit

Permalink
Add login and linking profile (#111)
Browse files Browse the repository at this point in the history
* saving progress

* saving progress

* ux improvements

* change flag name

* some minor polish

* re organize login

* replace return with error

* replace magic string with env vars

* replace exec with open

* pass clientId to deviceFlow

* remove format from prk

* pr suggestion

* replace pem with jwk

* add await

* change default api url

* change urls and routes

* change link api uri to slug instead of id
  • Loading branch information
AssafKr authored Aug 14, 2023
1 parent af83c67 commit f4c5de3
Show file tree
Hide file tree
Showing 14 changed files with 313 additions and 31 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"editor.tabSize": 2,
"cSpell.words": [
"Fastify",
"Livecycle",
"Preevy",
"Snapshotter"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
"scripts": {
"test": "yarn -s run lerna -- run test --",
"lint": "yarn -s run lerna -- run lint --parallel --",
"clean": "yarn lerna run -- clean --parallel --",
"build": "yarn -s run lerna run build --",
"clean": "yarn -s run lerna -- run clean --parallel --",
"check-mismatches": "syncpack list-mismatches",
"fix-mismatches": "syncpack fix-mismatches",
"prepare": "husky install"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,4 @@
"preview"
],
"types": "dist/index.d.ts"
}
}
19 changes: 19 additions & 0 deletions packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Flags } from '@oclif/core'
import { BaseCommand } from '@preevy/cli-common'
import { login } from '@preevy/core'
import { LC_API_URL, LC_AUTH_URL, LC_CLIENT_ID } from '../defaults'

// eslint-disable-next-line no-use-before-define
export default class Login extends BaseCommand<typeof Login> {
static description = 'Login to the Livecycle SaaS'

static flags = {
'lc-auth-url': Flags.string({ required: false, default: LC_AUTH_URL, env: 'LC_AUTH_URL', description: 'The login URL' }),
'lc-api-url': Flags.string({ required: false, default: LC_API_URL, env: 'LC_API_URL', description: "The Livecycle API URL'" }),
'lc-client-id': Flags.string({ required: false, default: LC_CLIENT_ID, env: 'LC_CLIENT_ID', description: 'The client ID for the OAuth app' }),
}

async run(): Promise<void> {
await login(this.config.dataDir, this.flags['lc-auth-url'], this.flags['lc-api-url'], this.flags['lc-client-id'], this.logger)
}
}
22 changes: 22 additions & 0 deletions packages/cli/src/commands/profile/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { link, Org } from '@preevy/core'
import { Flags } from '@oclif/core'
import inquirer from 'inquirer'
import ProfileCommand from '../../profile-command'
import { LC_API_URL } from '../../defaults'

// eslint-disable-next-line no-use-before-define
export default class Link extends ProfileCommand<typeof Link> {
static flags = {
'lc-api-url': Flags.string({ required: false, default: LC_API_URL, env: 'LC_API_URL', description: "The Livecycle API URL'" }),
}

static description = "Link the profile to the logged in user's organization"

async run(): Promise<unknown> {
await link(this.store, this.config.dataDir, this.flags['lc-api-url'], this.logger, async (orgs: Org[]) => {
const { org } = await inquirer.prompt<{org: string}>({ type: 'list', name: 'org', message: 'Choose the organization to link the profile to', choices: orgs.map(o => o.name) })
return orgs.find(o => o.name === org) as Org
})
return undefined
}
}
3 changes: 3 additions & 0 deletions packages/cli/src/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const LC_CLIENT_ID = 'BHXcVtapfKPEpZtYO3AJ2Livmz6j7xK0'
export const LC_API_URL = 'https://app.livecycle.run'
export const LC_AUTH_URL = 'https://livecycle.us.auth0.com'
1 change: 1 addition & 0 deletions packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"include": [
"src/**/*"
],
"exclude": ["dist"],
"references": [
{ "path": "../core" },
{ "path": "../compose-tunnel-agent" },
Expand Down
6 changes: 5 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
"is-stream": "^2.0.1",
"iter-tools-es": "^7.5.1",
"jose": "^4.14.4",
"jsonwebtoken": "^9.0.0",
"node-forge": "^1.3.1",
"open": "^8.4.2",
"lodash": "^4.17.21",
"node-fetch": "2.6.9",
"ora": "5.4.1",
Expand All @@ -31,7 +34,8 @@
"ssh2": "^1.12.0",
"sshpk": "^1.17.0",
"tar": "^6.1.15",
"tar-stream": "3.0.0",
"tar-stream": "^3.0.0",
"zod": "^3.21.4",
"yaml": "^2.3.1"
},
"devDependencies": {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export {
ForwardSocket,
machineStatusNodeExporterCommand,
} from './driver'
export { profileStore, Profile, ProfileStore } from './profile'
export { profileStore, Profile, ProfileStore, link, Org } from './profile'
export { telemetryEmitter, registerEmitter, wireProcessExit, createTelemetryEmitter } from './telemetry'
export { fsTypeFromUrl, Store, VirtualFS, localFsFromUrl } from './store'
export { localComposeClient, ComposeModel, resolveComposeFiles, getExposedTcpServicePorts, remoteUserModel, NoComposeFilesError } from './compose'
Expand Down Expand Up @@ -63,4 +63,5 @@ export { ensureDefined, extractDefined, HasRequired } from './nulls'
export { pSeries } from './p-series'
export * as git from './git'
export * as config from './config'
export { login } from './login'
export * from './url'
141 changes: 141 additions & 0 deletions packages/core/src/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/* eslint-disable no-await-in-loop */
import fetch from 'node-fetch'
import * as jose from 'jose'
import { z } from 'zod'
import open from 'open'
import { VirtualFS, localFs } from './store'
import { Logger } from './log'
import { withSpinner } from './spinner'
import { childProcessPromise } from './child-process'

export class TokenExpiredError extends Error {
constructor() {
super('Token is expired')
}
}

const PERSISTENT_TOKEN_FILE_NAME = 'lc-access-token.json'

const wait = (timeInMs: number) => new Promise<void>(resolve => {
setTimeout(() => { resolve() }, timeInMs)
})

const tokensResponseDataSchema = z.object({ access_token: z.string(), id_token: z.string() })

export type TokesFileSchema = z.infer<typeof tokensResponseDataSchema>

const pollTokensFromAuthEndpoint = async (
loginUrl: string,
deviceCode: string,
logger: Logger,
interval: number,
clientId: string
) => {
try {
while (true) {
const tokenResponse = await fetch(`${loginUrl}/oauth/token`, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: deviceCode,
client_id: clientId }) })

if (tokenResponse.status !== 403) {
if (!tokenResponse.ok) throw new Error(`Bad response from token endpoint: ${tokenResponse.status}: ${tokenResponse.statusText}`)

return tokensResponseDataSchema.parse(await tokenResponse.json())
}

await wait(interval)
}
} catch (e) {
logger.info('Error getting tokens', e)
throw e
}
}

const deviceCodeSchema = z.object({ device_code: z.string(),
user_code: z.string(),
verification_uri: z.string(),
expires_in: z.number(),
interval: z.number(),
verification_uri_complete: z.string() })

const deviceFlow = async (loginUrl: string, logger: Logger, clientId: string) => {
const deviceCodeResponse = await fetch(`${loginUrl}/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: new URLSearchParams({
client_id: clientId,
scope: 'email openid profile',
audience: 'https://livecycle-preevy-saas',
}),
})

const responseData = deviceCodeSchema.parse(await deviceCodeResponse.json())

logger.info('Opening browser for authentication')
try { await childProcessPromise(await open(responseData.verification_uri_complete)) } catch (e) {
logger.info(`Could not open browser at ${responseData.verification_uri_complete}`)
logger.info('Please try entering the URL manually')
}

logger.info('Make sure code is ', responseData.user_code)
return await withSpinner(
() => pollTokensFromAuthEndpoint(
loginUrl,
responseData.device_code,

logger,

responseData.interval * 1000,
clientId
),
{ opPrefix: 'Waiting for approval', successText: 'Done!' }
)
}

export const getTokensFromLocalFs = async (fs: VirtualFS) : Promise<TokesFileSchema | undefined> => {
const tokensFile = await fs.read(PERSISTENT_TOKEN_FILE_NAME)
if (tokensFile === undefined) return undefined

const tokens: TokesFileSchema = JSON.parse(tokensFile.toString())
const accessToken = jose.decodeJwt(tokens.access_token)
if (accessToken.exp === undefined || (accessToken.exp < Math.floor(Date.now() / 1000))) {
throw new TokenExpiredError()
}
return tokens
}

export const login = async (dataDir: string, loginUrl: string, lcUrl: string, clientId: string, logger: Logger) => {
const fs = localFs(dataDir)
let tokens: TokesFileSchema
try {
const tokensMaybe = await getTokensFromLocalFs(fs)
if (tokensMaybe !== undefined) {
logger.info(`Already logged in as: ${jose.decodeJwt(tokensMaybe.id_token).email} 👌`)
return
}
tokens = await deviceFlow(loginUrl, logger, clientId)
} catch (e) {
if (!(e instanceof TokenExpiredError)) {
throw e
}

tokens = await deviceFlow(loginUrl, logger, clientId)
}

await fs.write(PERSISTENT_TOKEN_FILE_NAME, JSON.stringify(tokens))

const postLoginResponse = await fetch(
`${lcUrl}/api/cli/post-login`,
{ method: 'POST',
body: JSON.stringify({ id_token: tokens.id_token }),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens.access_token}` } }
)

if (postLoginResponse.ok) {
logger.info(`Logged in successfully as: ${jose.decodeJwt(tokens.id_token).email} 👌`)
} else {
throw new Error(`Bad response from post-login endpoint ${postLoginResponse.status}: ${postLoginResponse.statusText}`)
}
}
1 change: 1 addition & 0 deletions packages/core/src/profile/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './store'
export * from './profile'
export * from './config'
export * from './link'
89 changes: 89 additions & 0 deletions packages/core/src/profile/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { parseKey } from '@preevy/common'
import * as jose from 'jose'
import crypto from 'crypto'
import fetch from 'node-fetch'
import { TokenExpiredError, TokesFileSchema, getTokensFromLocalFs } from '../login'
import { profileStore } from './store'
import { Store, localFs } from '../store'
import { Logger } from '../log'

export type Org = {id: string; name: string; role: string; slug: string}

const keyTypeToArgs = {
rsa: 'RS256',
ed25519: 'EdDSA',
}

export const link = async (
store: Store,
dataDir: string,
lcUrl: string,
logger: Logger,
promptUserWithChooseOrg: (orgs: Org[]) => Promise<Org>
) => {
let tokens: TokesFileSchema | undefined
try {
tokens = await getTokensFromLocalFs(localFs(dataDir))
} catch (e) {
if (e instanceof TokenExpiredError) {
throw new Error('Session is expired, please log in again')
}
throw e
}

if (tokens === undefined) {
throw new Error('Please log in to link profile')
}

const orgsResponse = await fetch(
`${lcUrl}/api/user/orgs`,
{ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens.access_token}` } }
)

if (!orgsResponse.ok) throw new Error(`Could not fetch orgs from Livecycle API. ${orgsResponse.status}: ${orgsResponse.statusText}`)

const orgs = await orgsResponse.json() as Org[]

let chosenOrg: Org
if (orgs.length === 0) {
throw new Error("Couldn't find any organization for current logged in user")
} else if (orgs.length === 1) {
[chosenOrg] = orgs
} else {
chosenOrg = await promptUserWithChooseOrg(orgs)
}

logger.info(`Linking to org "${chosenOrg.name}"`)

const tunnelingKey = await profileStore(store).getTunnelingKey()
if (tunnelingKey === undefined) {
throw new Error('Could not find tunneling key in profile store')
}

const parsed = parseKey(tunnelingKey)

const prk = crypto.createPrivateKey({
key: parsed.getPrivatePEM(),
})

const pk = crypto.createPublicKey(prk)
if (pk.asymmetricKeyType === undefined) throw new Error('Error getting type of public ket')
if (!(pk.asymmetricKeyType in keyTypeToArgs)) throw new Error(`Unsupported key algorithm: ${pk.asymmetricKeyType}`)

const tokenSignedByTunnelingPrivateKey = await new jose.SignJWT({})
.setProtectedHeader({ alg: keyTypeToArgs[pk.asymmetricKeyType as keyof typeof keyTypeToArgs] })
.setIssuedAt()
.setExpirationTime('5m')
.sign(prk)

const linkResponse = await fetch(
`${lcUrl}/api/org/${chosenOrg.slug}/profiles`,
{ method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens.access_token}` },
body: JSON.stringify({ profileTunnellingPublicKey: pk.export({ format: 'jwk' }), tokenSignedByTunnelingPrivateKey, idToken: tokens.id_token }) }
)

if (!linkResponse.ok) throw new Error(`Error while requesting to link ${linkResponse.status}: ${linkResponse.statusText}`)

logger.info(`Linked current profile to org "${chosenOrg.name}" successfully! 🤘`)
}
3 changes: 1 addition & 2 deletions packages/core/src/store/fs/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ const isNotFoundError = (e: unknown) => (e as { code?: unknown })?.code === 'ENO
export const localFs = (baseDir: string): VirtualFS => ({
read: async (filename: string) => {
const filepath = path.join(baseDir, filename)
const f = () => fs.readFile(filepath)
try {
return await f()
return await fs.readFile(filepath)
} catch (e) {
if (isNotFoundError(e)) {
return undefined
Expand Down
Loading

0 comments on commit f4c5de3

Please sign in to comment.