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

Add login and linking profile #111

Merged
merged 21 commits into from
Aug 14, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 = {
AssafKr marked this conversation as resolved.
Show resolved Hide resolved
'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')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth noting the expiry time in the message

}
}

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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use p-retry instead of 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}`)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a warning, and also include the error

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} 👌`)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the current implementations I know (gh, aws sso, gcloud auth) ignore the existing token and just follow the login flow, overwriting the token.

return
}
tokens = await deviceFlow(loginUrl, logger, clientId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be removed?

} 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}`)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should really use something like got to avoid this code duplication everywhere

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth including the body of the response, easier to troubleshoot/support


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

let chosenOrg: Org
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style nit - wrap in function chooseOrg(orgs, promptUserWithChooseOrg) to shorten and avoid let

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({
AssafKr marked this conversation as resolved.
Show resolved Hide resolved
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}`)
AssafKr marked this conversation as resolved.
Show resolved Hide resolved

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
Loading