diff --git a/core/src/cloud/api.ts b/core/src/cloud/api.ts index f0625b656f9..a6f4412b0c1 100644 --- a/core/src/cloud/api.ts +++ b/core/src/cloud/api.ts @@ -39,10 +39,21 @@ import { styles } from "../logger/styles.js" 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 } @@ -244,7 +255,7 @@ export class CloudApi { 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}` }) } // Try to auth towards cloud @@ -254,29 +265,26 @@ export class CloudApi { cloudFactoryLog.debug("Authorizing...") - if (gardenEnv.GARDEN_AUTH_TOKEN) { - // Throw if using an invalid "CI" access token - if (!tokenIsValid) { - throw new CloudApiError({ - message: deline` + 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.`, - }) - } - } else { - // Refresh the token if it's invalid. - if (!tokenIsValid) { - cloudFactoryLog.debug({ msg: `Current auth token is invalid, refreshing` }) + }) + } - // We can assert the token exists since we're not using GARDEN_AUTH_TOKEN - await api.refreshToken(token!) - } + // Try to refresh the token if it's invalid. + if (!tokenIsValid) { + cloudFactoryLog.debug({ msg: `Current auth token is invalid, refreshing` }) - // Start refresh interval if using JWT - cloudFactoryLog.debug({ msg: `Starting refresh interval.` }) - api.startInterval() + // We can assert the token exists since we're not using GARDEN_AUTH_TOKEN + await api.refreshToken(token!) } + // Start refresh interval if using JWT + cloudFactoryLog.debug({ msg: `Starting refresh interval.` }) + api.startInterval() + return api } catch (err) { if (err instanceof CloudApiError) { @@ -287,15 +295,7 @@ export class CloudApi { 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 CloudApiError({ message }) - } else { - cloudFactoryLog.warn( - `Warning: You are not logged in to Garden Cloud. Please log in via the ${styles.command( - "garden login" - )} command.` - ) - - return + throw new CloudApiLoginRequiredError({ message }) } } @@ -517,8 +517,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}`, diff --git a/core/src/commands/login.ts b/core/src/commands/login.ts index b2d92b99647..51f4ed9c135 100644 --- a/core/src/commands/login.ts +++ b/core/src/commands/login.ts @@ -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" @@ -86,8 +86,6 @@ 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, @@ -98,24 +96,27 @@ export class LoginCommand extends Command<{}, Opts> { requireLogin: undefined, }) - if (cloudApi) { - log.success({ msg: `You're already logged in to ${cloudDomain}.` }) - cloudApi.close() - return {} - } + 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}...` }) diff --git a/core/src/config/project.ts b/core/src/config/project.ts index 80d20c57303..e2c0d7ed223 100644 --- a/core/src/config/project.ts +++ b/core/src/config/project.ts @@ -325,7 +325,6 @@ export const projectSchema = createSchema({ // TODO: Refer to enterprise documentation for more details. requireLogin: joi .boolean() - .default(false) .meta({ internal: true }) .description("Whether the project requires login to Garden Cloud."), diff --git a/core/test/unit/src/cloud/api.ts b/core/test/unit/src/cloud/api.ts index 271446821b4..0d0766dab1f 100644 --- a/core/test/unit/src/cloud/api.ts +++ b/core/test/unit/src/cloud/api.ts @@ -97,18 +97,23 @@ describe("CloudApi", () => { scope.get("/api/token/verify").reply(404) - const api = await CloudApi.factory({ - log, - globalConfigStore, - cloudDomain, - projectId: undefined, - requireLogin: undefined, - }) + await expectError( + async () => + await CloudApi.factory({ + log, + globalConfigStore, + cloudDomain, + projectId: undefined, + requireLogin: undefined, + }), + { + type: "cloud-api", + contains: "No auth token available for", + } + ) // we don't expect a request to verify the token expect(scope.isDone()).to.be.false - - expect(api).to.be.undefined }) it("should return a CloudApi instance if there is a valid token", async () => { @@ -136,17 +141,22 @@ describe("CloudApi", () => { scope.get("/api/token/verify").reply(200, {}) - const api = await CloudApi.factory({ - log, - globalConfigStore, - cloudDomain, - projectId: "test", - requireLogin: false, - }) + await expectError( + async () => + await CloudApi.factory({ + log, + globalConfigStore, + cloudDomain, + projectId: "test", + requireLogin: false, + }), + { + type: "cloud-api", + contains: "No auth token available for", + } + ) expect(scope.isDone()).to.be.false - - expect(api).to.be.undefined }) it("should not return a CloudApi instance with an invalid token when require login is false", async () => { @@ -157,17 +167,22 @@ describe("CloudApi", () => { scope.get("/api/token/verify").reply(401, {}) scope.get("/api/token/refresh").reply(401, {}) - const api = await CloudApi.factory({ - log, - globalConfigStore, - cloudDomain, - projectId: "test", - requireLogin: false, - }) + await expectError( + async () => + await CloudApi.factory({ + log, + globalConfigStore, + cloudDomain, + projectId: "test", + requireLogin: false, + }), + { + type: "cloud-api", + contains: "The auth token could not be refreshed for", + } + ) expect(scope.isDone()).to.be.true - - expect(api).to.be.undefined }) it("should throw an error when the token is invalid and require login is true", async () => { @@ -187,7 +202,10 @@ describe("CloudApi", () => { projectId: "test", requireLogin: true, }), - "cloud-api" + { + type: "cloud-api", + contains: "You are running this in a project with a Garden Cloud ID and logging in is required.", + } ) expect(scope.isDone()).to.be.true @@ -206,16 +224,22 @@ describe("CloudApi", () => { gardenEnv.GARDEN_REQUIRE_LOGIN_OVERRIDE = false try { - const api = await CloudApi.factory({ - log, - globalConfigStore, - cloudDomain, - projectId: "test", - requireLogin: true, - }) + await expectError( + async () => + await CloudApi.factory({ + log, + globalConfigStore, + cloudDomain, + projectId: "test", + requireLogin: true, + }), + { + type: "cloud-api", + contains: "The auth token could not be refreshed for", + } + ) expect(scope.isDone()).to.be.true - expect(api).to.be.undefined } finally { gardenEnv.GARDEN_REQUIRE_LOGIN_OVERRIDE = overrideEnvBackup } @@ -243,7 +267,10 @@ describe("CloudApi", () => { projectId: "test", requireLogin: true, }), - "cloud-api" + { + type: "cloud-api", + contains: "You are running this in a project with a Garden Cloud ID and logging in is required.", + } ) expect(scope.isDone()).to.be.true diff --git a/core/test/unit/src/commands/login.ts b/core/test/unit/src/commands/login.ts index 129b306d9ea..bb260abb2d0 100644 --- a/core/test/unit/src/commands/login.ts +++ b/core/test/unit/src/commands/login.ts @@ -14,10 +14,9 @@ import { AuthRedirectServer } from "../../../../src/cloud/auth.js" import { LoginCommand } from "../../../../src/commands/login.js" import { dedent, randomString } from "../../../../src/util/string.js" -import { CloudApi } from "../../../../src/cloud/api.js" +import { CloudApi, CloudApiTokenRefreshError } from "../../../../src/cloud/api.js" import { LogLevel } from "../../../../src/logger/logger.js" import { DEFAULT_GARDEN_CLOUD_DOMAIN, gardenEnv } from "../../../../src/constants.js" -import { CloudApiError } from "../../../../src/exceptions.js" import { getLogMessages } from "../../../../src/util/testing.js" import { GlobalConfigStore } from "../../../../src/config-store/global.js" import { makeDummyGarden } from "../../../../src/garden.js" @@ -244,7 +243,7 @@ describe("LoginCommand", () => { await CloudApi.saveAuthToken(garden.log, garden.globalConfigStore, testToken, garden.cloudDomain!) td.replace(CloudApi.prototype, "checkClientAuthToken", async () => false) td.replace(CloudApi.prototype, "refreshToken", async () => { - throw new CloudApiError({ message: "bummer", responseStatusCode: 401 }) + throw new CloudApiTokenRefreshError({ message: "bummer" }) }) const savedToken = await CloudApi.getStoredAuthToken(garden.log, garden.globalConfigStore, garden.cloudDomain!) @@ -259,8 +258,8 @@ describe("LoginCommand", () => { const logOutput = getLogMessages(garden.log, (entry) => entry.level <= LogLevel.info).join("\n") expect(logOutput).to.include(dedent` - Looks like your session token is invalid. If you were previously logged into a different instance - of Garden Enterprise, log out first before logging in. + Your login token for https://example.invalid has expired and could not be refreshed. + Try to log out first before logging in. `) })