Skip to content

Commit

Permalink
Merge pull request #75 from Noahnc/feat/refactor_spacelift_auth_handling
Browse files Browse the repository at this point in the history
Feat/refactor_spacelift_auth_handling
  • Loading branch information
Noahnc authored Dec 10, 2023
2 parents 7aa65b4 + 912b1cf commit ecb3a4d
Show file tree
Hide file tree
Showing 13 changed files with 234 additions and 22 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
},
"[typescript]": {
"editor.codeActionsOnSave": {
"source.fixAll": true,
"source.organizeImports": false
"source.fixAll": "explicit",
"source.organizeImports": "never"
},
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ To use any spacelift feature of this extension, [spacectl](https://github.com/sp
spacectl profile login <profile_name>
```

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.
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)"
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
31 changes: 26 additions & 5 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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];
}
Empty file added src/models/jwt.ts
Empty file.
8 changes: 8 additions & 0 deletions src/utils/spacelift/spacectl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface Ispacectl {
setUserprofile(profileName: string): Promise<void>;
ensureSpacectlIsInstalled(): Promise<void>;
getExportedToken(): Promise<SpaceliftJwt>;
loginInteractive(): Promise<boolean>;
}

export class Spacectl implements Ispacectl {
Expand Down Expand Up @@ -51,6 +52,13 @@ export class Spacectl implements Ispacectl {
throw new UserShownError("spacectl not found in your shells path.");
}

async loginInteractive(): Promise<boolean> {
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<SpaceliftJwt> {
const [success, token] = await this.runSpacectlCommand("profile export-token");
if (success === false || token === "") {
Expand Down
141 changes: 141 additions & 0 deletions src/utils/spacelift/spacelift_authentication_handler.ts
Original file line number Diff line number Diff line change
@@ -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<SpaceliftJwt | null>;
check_token_valid(): Promise<boolean>;
login_interactive(): Promise<boolean>;
}

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<boolean> {
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<boolean> {
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<ViewerId>(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<boolean> {
// 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<SpaceliftJwt | null> {
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;
}
}
25 changes: 13 additions & 12 deletions src/utils/spacelift/spacelift_client.ts
Original file line number Diff line number Diff line change
@@ -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<SpaceliftStacks>;
authenticate(): Promise<void>;
isAuthenticated(): Promise<boolean>;
}

export class SpaceliftClient implements IspaceliftClient {
private _spaceliftEndpoint?: string;
private _client!: GraphQLClient;
private _tokenRetrieverFunction: () => Promise<SpaceliftJwt>;
private _token: SpaceliftJwt | undefined;
private _auth_handler: IspaceliftAuthenticationHandler;

constructor($client: GraphQLClient, tokenRetrieverFunction: () => Promise<SpaceliftJwt>) {
this._tokenRetrieverFunction = tokenRetrieverFunction;
constructor($client: GraphQLClient, auth_handler: IspaceliftAuthenticationHandler) {
this._auth_handler = auth_handler;
this._client = $client;
}

Expand All @@ -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}`,
});
}

Expand All @@ -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<boolean> {
return await this._auth_handler.check_token_valid();
}
}
25 changes: 25 additions & 0 deletions src/view/statusbar/spacelift_auth_status.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
5 changes: 5 additions & 0 deletions src/view/statusbar/spacelift_stack_confirmation_item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit ecb3a4d

Please sign in to comment.