diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index d085781..de0eaa2 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -26,10 +26,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: ".nvmrc" diff --git a/.vscode/settings.json b/.vscode/settings.json index 576c908..a566399 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,8 +30,8 @@ }, "[typescript]": { "editor.codeActionsOnSave": { - "source.fixAll": true, - "source.organizeImports": false + "source.fixAll": "explicit", + "source.organizeImports": "never" }, "editor.defaultFormatter": "esbenp.prettier-vscode" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a09331..f205104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the terraform-toolbox extension will be documented in this file. +## [0.3.0] + +- (feat): New Spacelift spacectl authentication handling. Since Spacelift has changed its token validity (only 1 token per user is now allowed to be active), the extension now uses the spacectl with web browser login to authenticate the user. If the current token provided by spacectl has expried or is revoked, a status item will be shown. Clicking on the status item will prompt you to authenticate spacectl with your browser. + ## [0.2.3] - Update dependencies. diff --git a/README.md b/README.md index d921ab3..1b5240d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ To use any spacelift feature of this extension, [spacectl](https://github.com/sp spacectl profile login ``` +When creating a new profile, make sure to select the option `Login with a web browser`. This will open a browser window, where you can log-in to your Spacelift account. This method makes sure that the token used by spacectl is the same as the one used by your Webbrowser. Since Spacelift recently changed its token validity (only 1 token per user is now allowed to be active), this is the only way to make sure that the extension gets an valid access token without revoking your web browsers Spacelift token. Once the token used by spacectl has expired or has been revoked, a status item will be shown. By clicking on the status item, you will be prompted to authenticate spacectl again with your browser. + Regarding spacelift, no authentication is required in VSCode. The extension uses the `spacectl profile export-token` command to get an api token for the current userprofile. This token is then used to authenticate the extension with spacelift. If you don't want to use any spacelift features, you can simply not install the spacectl, this will disable all spacelift features of the extension. diff --git a/package.json b/package.json index fc6d037..293159f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "https://github.com/Noahnc/vscode-terraform-toolbox" }, "description": "VSCode extension adding a bunch of featurees regarding Terraform and Spacelift.", - "version": "0.2.3", + "version": "0.3.0", "icon": "Images/terraform_toolbox_icon.png", "engines": { "vscode": "^1.83.0" @@ -31,6 +31,10 @@ "command": "tftoolbox.spaceliftLocalPreviewCurrentStack", "title": "spacelift local-preview (current stack)" }, + { + "command": "tftoolbox.spaceliftLogin", + "title": "spacelift login" + }, { "command": "tftoolbox.setTerraformVersion", "title": "terraform set version (select from list)" diff --git a/src/constants.ts b/src/constants.ts index 74aec39..e8eeae6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,7 @@ export const SPACELIFT_BASE_DOMAIN = ".app.spacelift.io"; // commands export const COMMAND_LOCAL_PREVIEW = "tftoolbox.spaceliftLocalPreview"; +export const COMMAND_SPACELIFT_LOGIN = "tftoolbox.spaceliftLogin"; export const COMMAND_LOCAL_PREVIEW_CURRENT_STACK = "tftoolbox.spaceliftLocalPreviewCurrentStack"; export const COMMAND_SET_TERRAFORM_VERSION = "tftoolbox.setTerraformVersion"; export const COMMAND_DELETE_TERRAFORM_VERSIONS = "tftoolbox.deleteTerraformVersions"; diff --git a/src/extension.ts b/src/extension.ts index 43c79d7..13dc9ec 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -21,6 +21,8 @@ import { TerraformVersionProvieder } from "./utils/terraform/terraform_version_p import { VersionManager } from "./utils/version_manager"; import { Cli } from "./utils/cli"; import * as helpers from "./utils/helper_functions"; +import { IspaceliftAuthenticationHandler, SpaceliftAuthenticationHandler } from "./utils/spacelift/spacelift_authentication_handler"; +import { SpaceliftApiAuthenticationStatus } from "./view/statusbar/spacelift_auth_status"; export async function activate(context: vscode.ExtensionContext) { const settings = new Settings(); @@ -63,7 +65,7 @@ export async function activate(context: vscode.ExtensionContext) { // Init spacelift commands if spacelift is configured spacectlInit(settings) - .then(([spaceliftClient, spacectlInstance, tenantID]) => { + .then(([spaceliftClient, spacectlInstance, tenantID, authenticationHandler]) => { new RunSpacectlLocalPreviewCurrentStackCommand(context, { command: cst.COMMAND_LOCAL_PREVIEW_CURRENT_STACK }, spaceliftClient, spacectlInstance); new RunSpacectlLocalPreviewCommand(context, { command: cst.COMMAND_LOCAL_PREVIEW }, spaceliftClient, spacectlInstance); const openSpaceliftWebPortalCommand = "openSpaceliftWebPortal"; @@ -83,6 +85,25 @@ export async function activate(context: vscode.ExtensionContext) { }, spaceliftClient ).refresh(); + const spaceliftAuthStatusItem = new SpaceliftApiAuthenticationStatus( + context, + { + alignment: vscode.StatusBarAlignment.Left, + priority: 100, + refreshIntervalSeconds: settings.spaceliftStatusBarItemRefreshIntervalSeconds, + tooltip: "Log-in to Spacelift with spacectl and your Web browser", + onClickCommand: cst.COMMAND_SPACELIFT_LOGIN, + }, + authenticationHandler + ); + spaceliftAuthStatusItem.refresh(); + context.subscriptions.push( + vscode.commands.registerCommand(cst.COMMAND_SPACELIFT_LOGIN, async () => { + if (await authenticationHandler.login_interactive()) { + await spaceliftAuthStatusItem.refresh(); + } + }) + ); }) .catch((error) => { getLogger().error("Failed to initialize spacectl: " + error); @@ -156,7 +177,7 @@ export async function activate(context: vscode.ExtensionContext) { tfVersionItem.refresh(); } -async function spacectlInit(settings: Settings): Promise<[IspaceliftClient, Ispacectl, string]> { +async function spacectlInit(settings: Settings): Promise<[IspaceliftClient, Ispacectl, string, IspaceliftAuthenticationHandler]> { const spacectlProfileName = settings.spacectlProfileName; const spacectlInstance = new Spacectl(new Cli()); if (spacectlProfileName !== null && spacectlProfileName !== undefined) { @@ -171,7 +192,7 @@ async function spacectlInit(settings: Settings): Promise<[IspaceliftClient, Ispa spaceliftTenantID = settings.spaceliftTenantID; } const spaceliftEndpoint = "https://" + spaceliftTenantID + cst.SPACELIFT_BASE_DOMAIN + "/graphql"; - const spacelift = new SpaceliftClient(new GraphQLClient(spaceliftEndpoint), spacectlInstance.getExportedToken.bind(spacectlInstance)); - await spacelift.authenticate(); - return [spacelift, spacectlInstance, spaceliftTenantID]; + const authenticationHandler = new SpaceliftAuthenticationHandler(spacectlInstance, spacectlInstance, new GraphQLClient(spaceliftEndpoint)); + const spacelift = new SpaceliftClient(new GraphQLClient(spaceliftEndpoint), authenticationHandler); + return [spacelift, spacectlInstance, spaceliftTenantID, authenticationHandler]; } diff --git a/src/models/jwt.ts b/src/models/jwt.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/spacelift/spacectl.ts b/src/utils/spacelift/spacectl.ts index 7005888..b02dae7 100644 --- a/src/utils/spacelift/spacectl.ts +++ b/src/utils/spacelift/spacectl.ts @@ -11,6 +11,7 @@ export interface Ispacectl { setUserprofile(profileName: string): Promise; ensureSpacectlIsInstalled(): Promise; getExportedToken(): Promise; + loginInteractive(): Promise; } export class Spacectl implements Ispacectl { @@ -51,6 +52,13 @@ export class Spacectl implements Ispacectl { throw new UserShownError("spacectl not found in your shells path."); } + async loginInteractive(): Promise { + getLogger().debug("Performing interactive login with spacectl and web browser"); + const [success, stdout] = await this.runSpacectlCommand("profile login"); + getLogger().trace("spacectl profile login stdout: " + stdout); + return success; + } + async getExportedToken(): Promise { const [success, token] = await this.runSpacectlCommand("profile export-token"); if (success === false || token === "") { diff --git a/src/utils/spacelift/spacelift_authentication_handler.ts b/src/utils/spacelift/spacelift_authentication_handler.ts new file mode 100644 index 0000000..31cb8e7 --- /dev/null +++ b/src/utils/spacelift/spacelift_authentication_handler.ts @@ -0,0 +1,141 @@ +import { Ispacectl } from "./spacectl"; +import { SpaceliftJwt } from "../../models/spacelift/jwt"; +import { getLogger } from "../logger"; +import { GraphQLClient, gql } from "graphql-request"; +import * as vscode from "vscode"; + +interface ViewerId { + viewer: id; +} + +interface id { + id: string | undefined; +} + +export interface IspaceliftAuthenticationHandler { + get_token(): Promise; + check_token_valid(): Promise; + login_interactive(): Promise; +} + +export class SpaceliftAuthenticationHandler implements IspaceliftAuthenticationHandler { + private _spacectl: Ispacectl; + private _cli: Ispacectl; + private _spaceliftJwt: SpaceliftJwt | undefined; + private _graphQLClient: GraphQLClient; + + constructor(spacectl: Ispacectl, cli: Ispacectl, graphqlClient: GraphQLClient) { + this._spacectl = spacectl; + this._cli = cli; + this._graphQLClient = graphqlClient; + } + + async check_token_valid(): Promise { + if (this._spaceliftJwt === undefined) { + this._spaceliftJwt = await this._cli.getExportedToken(); + } + if (this._spaceliftJwt.isExpired()) { + return false; + } + return this.check_token_not_revoked(); + } + + async check_token_not_revoked(): Promise { + if (this._spaceliftJwt === undefined) { + return false; + } + + const query = gql` + { + viewer { + id + } + } + `; + + this._graphQLClient.setHeaders({ + authorization: `Bearer ${this._spaceliftJwt.rawToken}`, + }); + + const valid = await this._graphQLClient + .request(query) + .then((data) => { + if (data === undefined || data === null) { + return false; + } + // Check if data has an viewer.id filed (which is only present if the token is valid + if (data.viewer === undefined || data.viewer === null || data.viewer.id === undefined || data.viewer.id === null) { + return false; + } + getLogger().debug("Spacelift token is valid and not revoked"); + return true; + }) + .catch((error) => { + getLogger().debug("Failed to validate token: " + error); + return false; + }); + return valid; + } + + async login_interactive(): Promise { + // aks the user if he wants to login with the web browser + const result = await vscode.window.showWarningMessage("Spacectl not authenticated, do you want to login with the web browser?", "Yes", "No").then(async (selection) => { + if (selection === "Yes") { + return true; + } + return false; + }); + if (result === false) { + return false; + } + + const login_result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Waiting for spacectl login", + cancellable: true, + }, + async (progress, token) => { + token.onCancellationRequested(() => { + getLogger().debug("User has cancelled spacectl login"); + vscode.window.showWarningMessage("Spacectl login cancelled"); + return false; + }); + return await this._cli.loginInteractive(); + } + ); + if (login_result === false) { + return false; + } + this._spaceliftJwt = await this._cli.getExportedToken(); + return true; + } + + async get_token(): Promise { + if (this._spaceliftJwt === undefined) { + getLogger().debug("No spacelift token cached, trying export from spacectl"); + this._spaceliftJwt = await this._cli.getExportedToken(); + if (await this.check_token_valid()) { + getLogger().debug("Got valid spacelift token from spacectl"); + return this._spaceliftJwt; + } + getLogger().warn("Newly exported spacelift token from spacectl is not valid. Retruning null as token."); + return null; + } + + if (await this.check_token_valid()) { + getLogger().debug("Cached spacelift token is valid, returning it"); + return this._spaceliftJwt; + } + + getLogger().debug("Cached spacelift token is not valid, trying to get new token from spacectl"); + this._spaceliftJwt = await this._cli.getExportedToken(); + + if (await this.check_token_valid()) { + getLogger().debug("Got valid spacelift token from spacectl"); + return this._spaceliftJwt; + } + getLogger().warn("spacectl token is not valid"); + return null; + } +} diff --git a/src/utils/spacelift/spacelift_client.ts b/src/utils/spacelift/spacelift_client.ts index 3982943..5c56f19 100644 --- a/src/utils/spacelift/spacelift_client.ts +++ b/src/utils/spacelift/spacelift_client.ts @@ -1,23 +1,22 @@ import { GraphQLClient, Variables } from "graphql-request"; import { UserShownError } from "../../custom_errors"; -import { SpaceliftJwt } from "../../models/spacelift/jwt"; import { GET_SPACELIFT_STACKS, SpaceliftStacks, Stack } from "../../models/spacelift/stack"; import * as helper from "../helper_functions"; import { getLogger } from "../logger"; +import { IspaceliftAuthenticationHandler } from "./spacelift_authentication_handler"; export interface IspaceliftClient { getStacks(): Promise; - authenticate(): Promise; + isAuthenticated(): Promise; } export class SpaceliftClient implements IspaceliftClient { private _spaceliftEndpoint?: string; private _client!: GraphQLClient; - private _tokenRetrieverFunction: () => Promise; - private _token: SpaceliftJwt | undefined; + private _auth_handler: IspaceliftAuthenticationHandler; - constructor($client: GraphQLClient, tokenRetrieverFunction: () => Promise) { - this._tokenRetrieverFunction = tokenRetrieverFunction; + constructor($client: GraphQLClient, auth_handler: IspaceliftAuthenticationHandler) { + this._auth_handler = auth_handler; this._client = $client; } @@ -34,14 +33,12 @@ export class SpaceliftClient implements IspaceliftClient { } async authenticate() { - if (this._token !== undefined) { - if (!this._token.isExpired()) { - return; - } + const jwt = await this._auth_handler.get_token(); + if (jwt === null) { + throw new UserShownError("Spacectl not authenticated."); } - this._token = await this._tokenRetrieverFunction(); this._client.setHeaders({ - authorization: `Bearer ${this._token.rawToken}`, + authorization: `Bearer ${jwt.rawToken}`, }); } @@ -54,4 +51,8 @@ export class SpaceliftClient implements IspaceliftClient { getLogger().trace("Got stacks from spacelift: " + JSON.stringify(response.stacks)); return new SpaceliftStacks(response.stacks); } + + async isAuthenticated(): Promise { + return await this._auth_handler.check_token_valid(); + } } diff --git a/src/view/statusbar/spacelift_auth_status.ts b/src/view/statusbar/spacelift_auth_status.ts new file mode 100644 index 0000000..5a82fa8 --- /dev/null +++ b/src/view/statusbar/spacelift_auth_status.ts @@ -0,0 +1,25 @@ +import * as vscode from "vscode"; +import { getLogger } from "../../utils/logger"; +import { BaseStatusBarItem, IvscodeStatusBarItemSettings } from "./base_statusbar_item"; +import { IspaceliftAuthenticationHandler } from "../../utils/spacelift/spacelift_authentication_handler"; + +export class SpaceliftApiAuthenticationStatus extends BaseStatusBarItem { + private readonly _authHandler: IspaceliftAuthenticationHandler; + constructor(context: vscode.ExtensionContext, settings: IvscodeStatusBarItemSettings, authHandler: IspaceliftAuthenticationHandler) { + super(context, settings); + this._authHandler = authHandler; + } + + protected async run() { + if ((await this._authHandler.check_token_valid()) == false) { + getLogger().debug("No valid spacelift token, showing status bar item to show login required"); + this._statusBarItem.text = "$(error) authenticate spacectl"; + this._statusBarItem.color = "orange"; + this._statusBarItem.show(); + return; + } + getLogger().debug("Valid spacelift token, hiding status bar item"); + this._statusBarItem.hide(); + return; + } +} diff --git a/src/view/statusbar/spacelift_stack_confirmation_item.ts b/src/view/statusbar/spacelift_stack_confirmation_item.ts index 40a7c81..94bab69 100644 --- a/src/view/statusbar/spacelift_stack_confirmation_item.ts +++ b/src/view/statusbar/spacelift_stack_confirmation_item.ts @@ -13,6 +13,11 @@ export class SpaceliftPenStackConfCount extends BaseStatusBarItem { protected async run() { let stacks: SpaceliftStacks; + if ((await this._spaceliftClient.isAuthenticated()) === false) { + getLogger().debug("Spacelift not authenticated, hiding status bar item"); + this._statusBarItem.hide(); + return; + } try { stacks = await this._spaceliftClient.getStacks(); } catch (error) {