Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/cli-access-token-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@e2b/cli': minor
---

`E2B_ACCESS_TOKEN` authenticates the CLI with an OAuth access token, for CI and other places without a login. It needs `E2B_PROJECT_ID`, which is the project for every request, like `E2B_API_KEY` decides the project on its own; setting both credentials is refused, and a stored login is ignored while the token is set. `e2b workspace list` and `e2b project list` work with the token alone, to find the project to set; `e2b project use` doesn't work with it. The token is used as is: once it expires, set a new one. The variable once held the dashboard's access tokens, which no longer work: such a value is ignored with a warning, as if the variable were unset.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"commander": "~15.0.0",
"e2b": "workspace:^",
"handlebars": "^4.7.10",
"jose": "^6.2.16",
"openapi-fetch": "^0.17.0",
"proper-lockfile": "^4.1.2",
"simple-update-notifier": "^2.0.0",
Expand Down
154 changes: 136 additions & 18 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import * as e2b from 'e2b'
import * as packageJSON from '../package.json'
import { getUserConfig, UserConfig } from './user'
import { asBold, asPrimary } from './utils/format'
import { ensureValidAccessToken, updateUserConfig } from './utils/token-refresh'
import {
decodeJwtExp,
ensureValidAccessToken,
updateUserConfig,
} from './utils/token-refresh'
import { fetchMe, findProject, Me } from './workspaces'

// Must run before any ConnectionConfig is constructed (including the
Expand All @@ -13,14 +17,32 @@ e2b.ConnectionConfig.setIntegration(`e2b-cli/${packageJSON.version}`)

export const projectId = process.env.E2B_PROJECT_ID || process.env.E2B_TEAM_ID

/**
* An OAuth access token given instead of logging in. It is used as is (there
* is no refresh token to renew it with), and the stored login is ignored.
*
* The variable used to hold the dashboard's access tokens, which no longer
* work and which some shells and CI jobs still export. A value that isn't an
* OAuth access token (a JWT) is ignored with a warning, as if it were unset.
*/
export const envAccessToken = (() => {
const token = process.env.E2B_ACCESS_TOKEN
if (!token) return undefined
if (decodeJwtExp(token)) return token
console.warn(
`${asBold('E2B_ACCESS_TOKEN')} is not an OAuth access token and is ignored: access tokens from the dashboard are no longer supported. Unset it to remove this warning.`
)
return undefined
})()

const authErrorBox = () => {
const body = `You must be logged in to use this command. Run ${asBold(
'e2b auth login'
)}.

If you are seeing this message in CI/CD you may need to set the ${asBold(
'E2B_API_KEY'
)} environment variable.
)} (or ${asBold('E2B_ACCESS_TOKEN')}) environment variable.
Visit ${asPrimary('https://e2b.dev/dashboard?tab=keys')} to get the API key.`
return boxen.default(body, {
width: 70,
Expand All @@ -45,20 +67,68 @@ let me: Promise<Me> | undefined

/**
* The project commands run in: `E2B_PROJECT_ID`, else the one selected with
* `e2b project use`; `undefined` when signed out without `E2B_PROJECT_ID`.
* `e2b project use`. `undefined` when signed out, or with `E2B_ACCESS_TOKEN`
* (which ignores the stored login), without `E2B_PROJECT_ID`.
*/
export function currentProject(): string | undefined {
return projectId || getUserConfig()?.projectId
if (projectId) return projectId
return envAccessToken ? undefined : getUserConfig()?.projectId
}

/**
* What is wrong with the credentials in the environment, if anything.
* `E2B_API_KEY` and `E2B_ACCESS_TOKEN` select different projects (the key's
* own, `E2B_PROJECT_ID`), so both at once is a mistake, not a precedence.
*/
export function envCredentialsError(): string | undefined {
if (process.env.E2B_API_KEY && envAccessToken) {
return `Both ${asBold('E2B_API_KEY')} and ${asBold(
'E2B_ACCESS_TOKEN'
)} are set. Unset one of them.`
}
Comment thread
mishushakov marked this conversation as resolved.
Resolved
return undefined
}

/**
* `E2B_ACCESS_TOKEN` needs the project named, like the key names its own,
* before any request goes to a project. Listing workspaces and projects
* doesn't, so `e2b project list` can find it.
*/
export const accessTokenNeedsProject = () =>
`${asBold('E2B_ACCESS_TOKEN')} needs ${asBold(
'E2B_PROJECT_ID'
)}: set it to the project requests go to (its ID, prj_ ID, slug or name). Run ${asBold(
'e2b project list'
)} to see your projects.`

/**
* `E2B_ACCESS_TOKEN`, checked not to have expired, since it can't be
* refreshed.
*/
function checkedEnvAccessToken(): string {
const exp = decodeJwtExp(envAccessToken!)!
if (Math.floor(Date.now() / 1000) >= exp) {
throw new Error(
`${asBold('E2B_ACCESS_TOKEN')} has expired. Set a current one, or unset it and run ${asBold(
'e2b auth login'
)}.`
)
}
return envAccessToken!
}

/** The logged-in user's workspaces and projects, fetched once per process. */
/** The user's workspaces and projects, fetched once per process. */
export function getMe(): Promise<Me> {
me ??= (async () => {
const { accessToken } = await ensureValidAccessToken()
const accessToken = envAccessToken
? checkedEnvAccessToken()
: (await ensureValidAccessToken()).accessToken
Comment thread
mishushakov marked this conversation as resolved.
Resolved
Comment thread
mishushakov marked this conversation as resolved.
Resolved
const result = await fetchMe(accessToken)
if (!result) {
throw new Error(
'Your session was not accepted. Run `e2b auth login` again.'
envAccessToken
? `${asBold('E2B_ACCESS_TOKEN')} was rejected. Set a current access token.`
: 'Your session was not accepted. Run `e2b auth login` again.'
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
)
}
return result
Expand All @@ -68,19 +138,41 @@ export function getMe(): Promise<Me> {

/**
* {@link getMe} for commands about workspaces and projects. workspace-api
* only accepts the login's access token, so these need a login even when
* `E2B_API_KEY` is set.
* only accepts an access token, so these need a login or `E2B_ACCESS_TOKEN`
* even when `E2B_API_KEY` is set. `E2B_PROJECT_ID` is not needed with the
* token: listing projects is how to find one.
*/
export async function requireMe(): Promise<Me> {
if (!getUserConfig()) {
const envError = envCredentialsError()
if (envError) {
console.error(envError)
process.exit(1)
}
if (!envAccessToken && !getUserConfig()) {
console.error(
`You must be logged in to see your workspaces and projects. Run ${asBold(
'e2b auth login'
)}.`
)} or set ${asBold('E2B_ACCESS_TOKEN')}.`
)
process.exit(1)
}
return getMe()
try {
return await getMe()
} catch (err) {
console.error(err instanceof Error ? err.message : err)
process.exit(1)
}
}

/**
* The project every request with `E2B_ACCESS_TOKEN` goes to: `E2B_PROJECT_ID`
* (required, checked in {@link ensureAuth}), resolved through
* `/v1/me` for its UUID and control plane. `--project` plays no part, as
* with `E2B_API_KEY`.
*/
async function resolveEnvProject(): Promise<{ id: string; domain: string }> {
const { project } = findProject(await getMe(), projectId!)
return { id: project.id, domain: project.control_plane_domain }
}

/**
Expand Down Expand Up @@ -115,17 +207,43 @@ async function resolveProject(
/**
* Resolve credentials for an E2B API call.
*
* `E2B_API_KEY` wins, for CI/CD and other non-interactive use. Otherwise the
* logged-in user's OAuth access token is used, refreshed first when it is
* about to expire, with `X-Team-ID` selecting the project and requests sent
* to that project's control plane (`domain`). Call this right before each
* request (not once per command) so long-running commands keep working after
* the hour-long access token expires.
* `E2B_API_KEY` or `E2B_ACCESS_TOKEN` with `E2B_PROJECT_ID`, for CI/CD and
* other non-interactive use (setting both credentials is refused), each
* ignoring the stored login and `cliProjectId`. Otherwise the logged-in
* user's OAuth access token is used, refreshed first when it is about to
* expire. With a token, `X-Team-ID`
* selects the project and requests go to that project's control plane
* (`domain`). Call this right before each request (not once per command) so
* long-running commands keep working after the hour-long access token
* expires.
*/
export async function ensureAuth(cliProjectId?: string): Promise<Auth> {
const envError =
envCredentialsError() ??
(envAccessToken && !projectId ? accessTokenNeedsProject() : undefined)
if (envError) {
console.error(envError)
process.exit(1)
}
const apiKey = process.env.E2B_API_KEY
if (apiKey) return { apiKey }

if (envAccessToken) {
try {
const project = await resolveEnvProject()
return {
apiHeaders: {
Authorization: `Bearer ${checkedEnvAccessToken()}`,
'X-Team-ID': project.id,
},
domain: project.domain,
}
} catch (err) {
console.error(err instanceof Error ? err.message : err)
process.exit(1)
}
}

if (!getUserConfig()) {
console.error(authErrorBox())
process.exit(1)
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/commands/auth/configure.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as commander from '@commander-js/extra-typings'

import { getMe } from 'src/api'
import { envAccessToken, getMe } from 'src/api'
import { getConfigRefreshTimestamp, getUserConfig } from 'src/user'
import { updateUserConfig } from 'src/utils/token-refresh'
import { blockedWarning, pickProject } from 'src/workspaces'
Expand All @@ -11,6 +11,19 @@ export const configureCommand = new commander.Command('configure')
.action(async () => {
console.log('Configuring user...\n')

// The token's projects are not the login's, and the token ignores the
// stored selection anyway.
if (envAccessToken) {
console.error(
asFormattedError(
`${asBold('E2B_ACCESS_TOKEN')} is set, so the stored project is not used. Choose the project with ${asBold(
'E2B_PROJECT_ID'
)} instead.`
)
)
process.exit(1)
}

// A file that exists but fails validation counts as signed out too —
// getUserConfig returns null for it (and prints the deprecation message).
const userConfig = getUserConfig()
Expand Down
26 changes: 25 additions & 1 deletion packages/cli/src/commands/auth/info.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import * as commander from '@commander-js/extra-typings'

import {
accessTokenNeedsProject,
envAccessToken,
envCredentialsError,
projectId,
} from 'src/api'
import { getUserConfig } from 'src/user'
import { asFormattedConfig, asFormattedError } from 'src/utils/format'

Expand All @@ -13,8 +19,26 @@ export const infoCommand = new commander.Command('info')
console.error(asFormattedError('Failed to read user config', err))
}

const envError = envCredentialsError()
if (envError) {
console.error(asFormattedError(envError))
process.exit(1)
}
if (process.env.E2B_API_KEY) {
console.log(
`Authenticating with E2B_API_KEY${userConfig ? '; the login below is not used' : ''}.`
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
} else if (envAccessToken) {
console.log(
`Authenticating with E2B_ACCESS_TOKEN${userConfig ? '; the login below is not used' : ''}.`
)
// Reported, not fatal: listing workspaces and projects works without it.
if (!projectId) console.warn(accessTokenNeedsProject())
}
if (!userConfig) {
console.log('Not logged in')
if (!envAccessToken && !process.env.E2B_API_KEY) {
console.log('Not logged in')
}
return
}

Expand Down
37 changes: 27 additions & 10 deletions packages/cli/src/commands/project/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import * as commander from '@commander-js/extra-typings'

import { currentProject, projectId, requireMe } from 'src/api'
import {
accessTokenNeedsProject,
currentProject,
envAccessToken,
projectId,
requireMe,
} from 'src/api'
import { Format, parseFormat } from 'src/options'
import { asBold, asFormattedError } from 'src/utils/format'
import { renderTable } from 'src/utils/table'
Expand Down Expand Up @@ -45,12 +51,20 @@ function projectStatus({ status, blocked, block_reasons }: Project) {
: 'blocked'
}

/** The current project, if it is one `me` lists. */
function findCurrent(me: Me): ProjectEntry | undefined {
/** The project commands run in, which `me` has to list. */
function resolveCurrent(me: Me): ProjectEntry {
const current = currentProject()
if (!current) return undefined
if (current) return findProject(me, current)
// `e2b project use` refuses with the token, which ignores the stored login.
if (envAccessToken) throw new Error(accessTokenNeedsProject())
throw new Error(
`No project selected. Run ${asBold('e2b project use <project>')}.`
)
}

function findCurrent(me: Me): ProjectEntry | undefined {
try {
return findProject(me, current)
return resolveCurrent(me)
} catch {
return undefined
}
Expand Down Expand Up @@ -115,6 +129,13 @@ const useCommand = new commander.Command('use')
.addOption(workspaceOption('the workspace to pick from or look in'))
.action(async (ref, opts) => {
try {
if (envAccessToken) {
fail(
`${asBold('E2B_ACCESS_TOKEN')} is set, so the stored project is not used. Choose the project with ${asBold(
'E2B_PROJECT_ID'
)} instead.`
)
}
const me = await requireMe()
const workspace = opts.workspace
? findWorkspace(me, opts.workspace)
Expand Down Expand Up @@ -174,11 +195,7 @@ const infoCommand = new commander.Command('info')
.addOption(formatOption)
.action(async (opts) => {
try {
const current = currentProject()
if (!current) {
fail(`No project selected. Run ${asBold('e2b project use <project>')}.`)
}
const { project, workspace } = findProject(await requireMe(), current)
const { project, workspace } = resolveCurrent(await requireMe())

if (opts.format === Format.JSON) {
console.log(
Expand Down
11 changes: 4 additions & 7 deletions packages/cli/src/utils/token-refresh.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as fs from 'fs'
import { decodeJwt } from 'jose'

import {
getUserConfig,
Expand All @@ -20,14 +21,10 @@ const CONFIG_LOCK = {
timeoutMs: 45_000,
}

function decodeJwtExp(token: string): number | undefined {
const payload = token.split('.')[1]
if (!payload) return undefined
/** The `exp` claim of a JWT, or `undefined` for anything else. */
export function decodeJwtExp(token: string): number | undefined {
try {
const decoded = JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8')
) as { exp?: number }
return decoded.exp
return decodeJwt(token).exp
} catch {
return undefined
}
Expand Down
Loading
Loading