-
Notifications
You must be signed in to change notification settings - Fork 78
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add login and linking profile (#111)
* 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
Showing
14 changed files
with
313 additions
and
31 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
{ | ||
"editor.tabSize": 2, | ||
"cSpell.words": [ | ||
"Fastify", | ||
"Livecycle", | ||
"Preevy", | ||
"Snapshotter" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -101,4 +101,4 @@ | |
"preview" | ||
], | ||
"types": "dist/index.d.ts" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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! 🤘`) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.