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

feat: add config field and override param for require login #5502

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
18 changes: 15 additions & 3 deletions core/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { generateBasicDebugInfoReport } from "../commands/get/get-debug-info.js"
import type { AnalyticsHandler } from "../analytics/analytics.js"
import type { GardenPluginReference } from "../plugin/plugin.js"
import type { CloudApiFactory } from "../cloud/api.js"
import { CloudApi, CloudApiTokenRefreshError, getGardenCloudDomain } from "../cloud/api.js"
import { CloudApi, CloudApiNoTokenError, CloudApiTokenRefreshError, getGardenCloudDomain } from "../cloud/api.js"
import { findProjectConfig } from "../config/base.js"
import { pMemoizeDecorator } from "../lib/p-memoize.js"
import { getCustomCommands } from "../commands/custom.js"
Expand Down Expand Up @@ -256,9 +256,19 @@ ${renderCommands(commands)}
const distroName = getCloudDistributionName(cloudDomain)

try {
cloudApi = await this.cloudApiFactory({ log, cloudDomain, globalConfigStore })
cloudApi = await this.cloudApiFactory({
log,
cloudDomain,
globalConfigStore,
projectId: config?.id,
requireLogin: config?.requireLogin,
})
} catch (err) {
if (err instanceof CloudApiTokenRefreshError) {
if (err instanceof CloudApiNoTokenError) {
// this means the user has no token stored for the domain and we should just continue
// without a cloud api instance
gardenInitLog?.debug(`Cloud not configured since no token found for ${distroName} at ${cloudDomain}`)
} else if (err instanceof CloudApiTokenRefreshError) {
log.warn(dedent`
Unable to authenticate against ${distroName} with the current session token.
Command results for this command run will not be available in ${distroName}. If this not a
Expand All @@ -272,6 +282,8 @@ ${renderCommands(commands)}
}
} else {
// unhandled error when creating the cloud api
gardenInitLog?.info("")

throw err
}
}
Expand Down
96 changes: 76 additions & 20 deletions core/src/cloud/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,21 @@ import { RequestError } from "got"
const gardenClientName = "garden-core"
const gardenClientVersion = getPackageVersion()

// Thrown when trying to create a project with a name that already exists
export class CloudApiDuplicateProjectsError extends CloudApiError {}

// A user token can be refreshed, thrown when the refresh fails
export class CloudApiTokenRefreshError extends CloudApiError {}

// The access token passed via GARDEN_AUTH_TOKEN was not valid
export class CloudApiAccessTokenInvalidError extends CloudApiError {}

// Thrown when the user is not logged in with a cloud connected project
export class CloudApiLoginRequiredError extends CloudApiError {}

// Thrown there is no auth or access token
export class CloudApiNoTokenError extends CloudApiError {}

function extractErrorMessageBodyFromGotError(error: any): error is GotHttpError {
return error?.response?.body?.message
}
Expand Down Expand Up @@ -167,6 +178,8 @@ export interface CloudApiFactoryParams {
cloudDomain: string
globalConfigStore: GlobalConfigStore
skipLogging?: boolean
projectId: string | undefined
requireLogin: boolean | undefined
}

export type CloudApiFactory = (params: CloudApiFactoryParams) => Promise<CloudApi | undefined>
Expand Down Expand Up @@ -210,38 +223,58 @@ export class CloudApi {
* Optionally skip logging during initialization. Useful for noProject commands that need to use the class
* without all the "flair".
*/
static async factory({ log, cloudDomain, globalConfigStore, skipLogging = false }: CloudApiFactoryParams) {
static async factory({
log,
cloudDomain,
globalConfigStore,
skipLogging = false,
projectId = undefined,
requireLogin = undefined,
}: CloudApiFactoryParams): Promise<CloudApi> {
const distroName = getCloudDistributionName(cloudDomain)
const fixLevel = skipLogging ? LogLevel.silly : undefined
const cloudFactoryLog = log.createLog({ fixLevel, name: getCloudLogSectionName(distroName), showDuration: true })

cloudFactoryLog.debug("Initializing Garden Cloud API client.")
cloudFactoryLog.debug(`Initializing ${distroName} API client.`)

const token = await CloudApi.getStoredAuthToken(log, globalConfigStore, cloudDomain)

if (!token && !gardenEnv.GARDEN_AUTH_TOKEN) {
log.debug(
const hasNoToken = !token && !gardenEnv.GARDEN_AUTH_TOKEN

// fallback to false if no variables are set
// TODO-0.14: requireLogin should default to true
const isLoginRequired: boolean =
gardenEnv.GARDEN_REQUIRE_LOGIN_OVERRIDE !== undefined
? gardenEnv.GARDEN_REQUIRE_LOGIN_OVERRIDE
: projectId !== undefined && requireLogin === true

// Base case when the user is not logged in to cloud and the
// criteria for cloud login is not required:
// - The config parameter requiredLogin is false
// - The user is not running a project scoped command (no projectId)
if (hasNoToken && !isLoginRequired) {
cloudFactoryLog.debug(
`No auth token found, proceeding without access to ${distroName}. Command results for this command run will not be available in ${distroName}.`
)
return
throw new CloudApiNoTokenError({ message: `No auth token available for ${distroName} at ${cloudDomain}` })
}

const api = new CloudApi({ log, domain: cloudDomain, globalConfigStore })
const tokenIsValid = await api.checkClientAuthToken()
// Try to auth towards cloud
try {
const api = new CloudApi({ log, domain: cloudDomain, globalConfigStore })
const tokenIsValid = await api.checkClientAuthToken()

cloudFactoryLog.debug("Authorizing...")
cloudFactoryLog.debug("Authorizing...")

if (gardenEnv.GARDEN_AUTH_TOKEN) {
// Throw if using an invalid "CI" access token
if (!tokenIsValid) {
throw new CloudApiError({
if (gardenEnv.GARDEN_AUTH_TOKEN && !tokenIsValid) {
throw new CloudApiAccessTokenInvalidError({
message: deline`
The provided access token is expired or has been revoked, please create a new
one from the ${distroName} UI.`,
The provided access token is expired or has been revoked, please create a new
one from the ${distroName} UI.`,
})
}
} else {
// Refresh the token if it's invalid.

// Try to refresh the token if it's invalid.
if (!tokenIsValid) {
cloudFactoryLog.debug({ msg: `Current auth token is invalid, refreshing` })

Expand All @@ -252,9 +285,23 @@ export class CloudApi {
// Start refresh interval if using JWT
cloudFactoryLog.debug({ msg: `Starting refresh interval.` })
api.startInterval()
}

return api
return api
} catch (err) {
if (err instanceof CloudApiError) {
// If there is an ID in the project config and the user is not logged in (no cloudApi)
// 0.13 => check if login is required based on the `requireLogin` config value
if (projectId && isLoginRequired) {
const message = dedent`
You are running this in a project with a Garden Cloud ID and logging in is required.
Please log in via the ${styles.command("garden login")} command.`

throw new CloudApiLoginRequiredError({ message })
}
}

throw err
}
}

static async saveAuthToken(
Expand Down Expand Up @@ -471,8 +518,17 @@ export class CloudApi {
throw err
}

this.log.debug({ msg: `Failed to refresh the token.` })
throw new CloudApiTokenRefreshError({
this.log.debug({ msg: `Failed to refresh the auth token, response status code: ${err.response.statusCode}` })

// The token was invalid and could not be refreshed
if (err.response.statusCode === 401) {
throw new CloudApiTokenRefreshError({
message: `The auth token could not be refreshed for ${getCloudDistributionName(this.domain)}`,
})
}

// Unhandled cloud api error
throw new CloudApiError({
message: `An error occurred while verifying client auth token with ${getCloudDistributionName(this.domain)}: ${
err.message
}. Response status code: ${err.response.statusCode}`,
Expand Down
36 changes: 22 additions & 14 deletions core/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Command } from "./base.js"
import { printHeader } from "../logger/util.js"
import dedent from "dedent"
import type { AuthTokenResponse } from "../cloud/api.js"
import { CloudApi, getGardenCloudDomain } from "../cloud/api.js"
import { CloudApi, CloudApiNoTokenError, CloudApiTokenRefreshError, getGardenCloudDomain } from "../cloud/api.js"
import type { Log } from "../logger/log-entry.js"
import { ConfigurationError, TimeoutError, InternalError, CloudApiError } from "../exceptions.js"
import { AuthRedirectServer } from "../cloud/auth.js"
Expand Down Expand Up @@ -86,29 +86,37 @@ export class LoginCommand extends Command<{}, Opts> {
// should use the default domain or not. The token lifecycle ends on logout.
const cloudDomain: string = getGardenCloudDomain(projectConfig?.domain)

const distroName = getCloudDistributionName(cloudDomain)

try {
const cloudApi = await CloudApi.factory({ log, cloudDomain, skipLogging: true, globalConfigStore })

if (cloudApi) {
log.success({ msg: `You're already logged in to ${cloudDomain}.` })
cloudApi.close()
return {}
}
const cloudApi = await CloudApi.factory({
log,
cloudDomain,
skipLogging: true,
globalConfigStore,
projectId: undefined,
requireLogin: undefined,
})

log.success({ msg: `You're already logged in to ${cloudDomain}.` })
cloudApi.close()
return {}
} catch (err) {
if (!(err instanceof CloudApiError)) {
throw err
}
if (err.responseStatusCode === 401) {

if (err instanceof CloudApiTokenRefreshError) {
const msg = dedent`
Looks like your session token is invalid. If you were previously logged into a different instance
of ${distroName}, log out first before logging in.
Your login token for ${cloudDomain} has expired and could not be refreshed.
Try to log out first before logging in.
`
log.warn(msg)
log.info("")
}
throw err

// This is expected if the user has not logged in
if (!(err instanceof CloudApiNoTokenError)) {
throw err
}
}

log.info({ msg: `Logging in to ${cloudDomain}...` })
Expand Down
15 changes: 8 additions & 7 deletions core/src/commands/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import type { CommandParams, CommandResult } from "./base.js"
import { Command } from "./base.js"
import { printHeader } from "../logger/util.js"
import { CloudApi, getGardenCloudDomain } from "../cloud/api.js"
import { CloudApi, CloudApiNoTokenError, getGardenCloudDomain } from "../cloud/api.js"
import { getCloudDistributionName } from "../util/cloud.js"
import { dedent, deline } from "../util/string.js"
import { ConfigurationError } from "../exceptions.js"
Expand Down Expand Up @@ -78,19 +78,20 @@ export class LogOutCommand extends Command<{}, Opts> {
cloudDomain,
skipLogging: true,
globalConfigStore: garden.globalConfigStore,
projectId: undefined,
requireLogin: undefined,
})

if (!cloudApi) {
return {}
}

await cloudApi.post("token/logout", { headers: { Cookie: `rt=${token?.refreshToken}` } })
cloudApi.close()
} catch (err) {
const msg = dedent`
// This is expected if the user never logged in
if (!(err instanceof CloudApiNoTokenError)) {
const msg = dedent`
The following issue occurred while logging out from ${distroName} (your session will be cleared regardless): ${err}\n
`
log.warn(msg)
log.warn(msg)
}
} finally {
await CloudApi.clearAuthToken(log, garden.globalConfigStore, cloudDomain)
log.success(`Successfully logged out from ${cloudDomain}.`)
Expand Down
8 changes: 7 additions & 1 deletion core/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,13 @@ export class ServeCommand<
}

try {
const cloudApi = await manager.getCloudApi({ log, cloudDomain, globalConfigStore: garden.globalConfigStore })
const cloudApi = await manager.getCloudApi({
log,
cloudDomain,
globalConfigStore: garden.globalConfigStore,
projectId: projectConfig?.id,
requireLogin: projectConfig?.requireLogin,
})
const isLoggedIn = !!cloudApi
const isCommunityEdition = cloudDomain === DEFAULT_GARDEN_CLOUD_DOMAIN

Expand Down
7 changes: 7 additions & 0 deletions core/src/config/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export interface ProjectConfig extends BaseGardenResource {
path: string
id?: string
domain?: string
requireLogin?: boolean
configPath?: string
proxy?: ProxyConfig
defaultEnvironment: string
Expand Down Expand Up @@ -323,6 +324,12 @@ export const projectSchema = createSchema({
.uri()
.meta({ internal: true })
.description("The domain to use for cloud features. Should be the full API/backend URL."),
// TODO: Refer to enterprise documentation for more details.
requireLogin: joi
.boolean()
.meta({ internal: true })
.description("Whether the project requires login to Garden Cloud."),

// Note: We provide a different schema below for actual validation, but need to define it this way for docs
// because joi.alternatives() isn't handled well in the doc generation.
environments: joi
Expand Down
1 change: 1 addition & 0 deletions core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,5 @@ export const gardenEnv = {
// GARDEN_CLOUD_BUILDER will always override the config; That's why it doesn't have a default.
// FIXME: If the environment variable is not set, asBool returns undefined, unlike the type suggests. That's why we cast to `boolean | undefined`.
GARDEN_CLOUD_BUILDER: env.get("GARDEN_CLOUD_BUILDER").required(false).asBool() as boolean | undefined,
GARDEN_REQUIRE_LOGIN_OVERRIDE: env.get("GARDEN_REQUIRE_LOGIN_OVERRIDE").required(false).asBool(),
}
15 changes: 13 additions & 2 deletions core/src/server/instance-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Autocompleter } from "../cli/autocomplete.js"
import { parseCliVarFlags } from "../cli/helpers.js"
import type { ParameterObject, ParameterValues } from "../cli/params.js"
import type { CloudApiFactory, CloudApiFactoryParams } from "../cloud/api.js"
import { CloudApi, getGardenCloudDomain } from "../cloud/api.js"
import { CloudApi, CloudApiNoTokenError, getGardenCloudDomain } from "../cloud/api.js"
import type { Command } from "../commands/base.js"
import { getBuiltinCommands, flattenCommands } from "../commands/commands.js"
import { getCustomCommands } from "../commands/custom.js"
Expand Down Expand Up @@ -202,7 +202,16 @@ export class GardenInstanceManager {
let api = this.cloudApis.get(cloudDomain)

if (!api) {
api = await this.cloudApiFactory(params)
try {
api = await this.cloudApiFactory(params)
} catch (err) {
// handles the case when the user should not be logged in
// otherwise we throw any error that can occure while
// authenticating
if (!(err instanceof CloudApiNoTokenError)) {
throw err
}
}
api && this.cloudApis.set(cloudDomain, api)
}

Expand Down Expand Up @@ -366,6 +375,8 @@ export class GardenInstanceManager {
log,
cloudDomain: getGardenCloudDomain(projectConfig.domain),
globalConfigStore,
projectId: projectConfig.id,
requireLogin: projectConfig.requireLogin,
})
}

Expand Down
2 changes: 2 additions & 0 deletions core/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,8 @@ export class GardenServer extends EventEmitter {
log,
cloudDomain: getGardenCloudDomain(garden.cloudDomain),
globalConfigStore: garden.globalConfigStore,
projectId: garden.projectId,
requireLogin: undefined, // the user should be logged in for this
})

// Use the server session ID. That is, the "main" session ID that belongs to the parent serve command.
Expand Down
Loading