Integrate GitHub Login with OAuth Device Flow in Your JS CLI
Debajyati Dey

Debajyati Dey @ddebajyati

About: Web Developer, linux enthusiast, always eager to learn new technologies

Location:
West Bengal, India
Joined:
Feb 25, 2024

Integrate GitHub Login with OAuth Device Flow in Your JS CLI

Publish Date: May 20
18 12

Command-line utilities are the most basic and beautiful apps ever created, the apps that started it all.

Building a CLI in JavaScript is just a piece of cake nowadays. Yeah, it’s NOT that hard!

Lots of powerful arg parsing libraries are available in the npm registry, that make the whole difficult part much easier so that you can just focus on building the core features.

But when it’s the time of authentication, most people get confused, can’t relate and make things wrong.

There are many things to understand before adding authentication in CLI apps. In this article, I am going to show you how to add OAuth in your JS cli using GitHub as a provider.

CLIs are generally created targeting developers as their user base. That’s why GitHub would be the most accurate option from OAuth providers. And, most importantly, GitHub supports the Device Login Flow, a secure authentication method for Headless Apps.

Enough talk! Let’s go through it!

Summary of The Content Prior to Reading the Article

This article provides a step-by-step guide to adding OAuth authentication to a JavaScript CLI using GitHub as the provider. It covers the implementation of GitHub's Device Login Flow, detailing the necessary libraries, setup, and code structure to securely manage authentication tokens. The approach emphasizes modular design, error handling, and security, with practical examples for building a robust CLI authentication system. The article is geared towards developers looking for secure and efficient ways to authenticate users in a CLI context.

A Demo App

Let’s write a demo project from starting, so you don’t be confused…

Libraries We Will Need

We are going to use -

  1. @octokit/oauth-methods for implementing GitHub OAuth in our App.

  2. @octokit/rest which is the official GitHub REST API client for JavaScript.

  3. commander for arg parsing.

  4. open for opening URL in Browser.

  5. chalk for rich terminal colors.

Tree Structure of Demo Project

.
|- auth/oauth.js
|- auth/authObject.js
|- auth/unauthentication.js
|- auth/tokenHelpers.js
|- auth/config.js
|- auth/index.js
|- base64.js
|- index.js
|- package.json
|- package-lock.json
Enter fullscreen mode Exit fullscreen mode

Let’s Begin

Start a node project.

mkdir demo-cli
cd demo-cli
npm init -y
Enter fullscreen mode Exit fullscreen mode

Install necessary dependencies -

npm i @octokit/oauth-methods open commander chalk
Enter fullscreen mode Exit fullscreen mode

Add “type”: “module” entry in the package.json file, we will be dealing with ES Modules all the time.

You need to a client ID to add authentication in your app. For that you will need to create an OAuth Application in Your GitHub Account.

Head over to your GitHub profile and go to settings, - Click on Developer Settings, and then you will see the option OAuth Apps. Click on it. Press the button New OAuth App.

Screenshot of a GitHub interface showing

Now a form for your new OAuth Application for GitHub will open.

Form interface for registering a new OAuth app. Fields include

Fill up the necessary textboxes. As this article is focused on the Device Flow, make sure you enable it (the checkbox shown in the image above). Otherwise, our auth implementation won’t work. Register the Application.

Get the Client ID from the OAuth Application created at your GitHub (Yes, only the client ID is required, client secret is not needed in Device Flow, this is why it is more secure and has no headache in managing credentials, actually you don’t have to manage anything at all), and put it in a config file.

I would prefer a JS file. Thus, it can be easily imported, and we won’t need dotenv package because we aren’t using .env files. Also, a client id is a public information. Even in websites in authentication flows the client id remains in the URL. You are free to push the file in your GitHub Repository, no security issues.

You can READ more about it here - How should OAuth Client IDs be distributed to headless apps?

You can give whatever name to the file. I am keeping it as config.js. in the auth/ directory.

mkdir auth && touch auth/config.js
Enter fullscreen mode Exit fullscreen mode
import path from "node:path";
import os from "os";

const config = {
  CLIENT_ID: "<paste-the-client-id-here>",
  TOKEN_FILE: path.join(os.homedir(), '.demo-cli-data.json'),
}

export default config;
Enter fullscreen mode Exit fullscreen mode

The TOKEN_FILE property in the config object is the path to the dotfile that will be storing the user GitHub token. It will be located in the home directory of user’s operating system.

Now let’s implement the core part of the Authentication. Login via Browser using OAuth Device Flow.

Create a file named oauth.js in auth/ directory.

touch auth/oauth.js
Enter fullscreen mode Exit fullscreen mode

We need all of these imports in our file -

import open, { apps } from "open";
import { createDeviceCode, exchangeDeviceCode } from "@octokit/oauth-methods";
import config from "./config.js";
import { promisify } from "node:util";
import { type as osType } from "os";
const sleep = promisify(setTimeout);
Enter fullscreen mode Exit fullscreen mode

And this is the function that will be exported -

async function getOAuthObject() {
  const {
    data: { device_code, user_code, verification_uri, interval },
  } = await createDeviceCode({
    clientType: "oauth-app",
    clientId: config.CLIENT_ID,
    scopes: ["read:user"], // oauth scopes
  });

  console.log(`\nYour OAuth User Code is - \n\t${user_code}\n`);
  await sleep(
    1500,
    console.log("Opening the Browser Window to Enter the User Code"),
  );
  console.log(
    "Waiting for the user to grant access through the browser ...",
  );
  try {
    if (osType() === "Windows_NT") {
      await open(verification_uri, { wait: true, app: { name: apps.browser } });
    } else {
      await open(verification_uri, { wait: true });
    }
  } catch (error) {
    console.error("Error opening browser:", error.message);
    console.log("Please manually open the following URL in your browser:");
    console.log(verification_uri);
    sleep(3000);
  }

  let currentInterval = interval;
  let remainingAttempts = 150;
  while (true) {
    remainingAttempts -= 1;
    if (remainingAttempts < 0) {
      console.error("User took too long to respond");
      return { error: "Request Timeout, try again"};
    }
    try {
      const { authentication } = await exchangeDeviceCode({
        clientType: "oauth-app",
        clientId: config.CLIENT_ID,
        code: device_code,
      });
      return authentication; // Exit loop and return authentication object
    } catch (error) {

      if (error.status === 400) {
        const errorCode = error.response.data.error;

        if (errorCode === "authorization_pending") {
          console.log("Authorization still pending... waiting before retrying");
          await sleep(currentInterval * 1000);
        } else if (errorCode === "slow_down") {
          console.log("Received slow_down response, increasing interval");
          currentInterval += 5; // Increase interval as per GitHub's requirement
          await sleep(currentInterval * 1000);
        } else if (errorCode === "expired_token") {
          return { error: errorCode }; // Exit loop as a new device code is needed
        } else if (errorCode === "incorrect_device_code") {
          return { error: errorCode }; // Exit loop as there is a fundamental error
        } else if (errorCode === "access_denied") {
          return { error: errorCode }; // Exit loop as the process cannot continue
        } else {
          return {error: `Unexpected 400 status error: ${errorCode}`}; // 400 status unknown errors
        }
      } else {
        console.error("An unexpected error occurred:", error.message);
        throw error; // Re-throw non-400 status unknown errors
      }
    }
  }
}

export { getOAuthObject };
Enter fullscreen mode Exit fullscreen mode

We define the only function that will be exported which is getOAuthObject().

Well, the idea is that as this is a utility function it must avoid any kind of premature termination, for resilience. It is mostly for contextual error handling, - the outermost scope often has the most context to decide what to do with an error. Should it retry? Log? Display an error message to the user? Return a specific error response?

If you see the catch block you’ll realise that based of different error codes different error objects are returned. To make sure the type is always the same(object), even if an error is thrown. If there is an error, the object has an error key, otherwise it returns the authentication object.

Well this isn't a single, universally accepted design pattern but the technique used here is a well established practice.

It's closely related to the Result Object Pattern and aligns with the principles of Railway-Oriented Programming and error handling in functional programming. This gives us better control on how we want to handle the errors at which point and the flow of the code execution.

In the code we first generate device_code, user_code, verification_uri, interval of requests by calling the createDeviceCode() function.

Setting clientType is required because there are slight differences between "oauth-app" and "github-app". Most importantly, GitHub Apps do not support scopes. I have used read:user scope in the scopes option. You should choose wisely based on your app requirements.

Well the code might seem unoptimised, based on the multiple else if statements you can see in the catch block. They could be reduced to a single if statement by utilizing a set. I am leaving that up to you. Anyways, let’s focus on the flow.

  1. After getting the device_code, user_code, verification_uri, and interval, we display the user_code to the user and instruct them to open the verification_uri in their browser. We then use the open package to attempt to open the URL in the user's default browser. We handle potential errors during browser opening and provide the user with the URL to manually open if necessary.

  2. Next, we enter a while loop to continuously poll GitHub for the access token using the exchangeDeviceCode() function. This function takes the device_code obtained earlier and our CLIENT_ID.

  3. Inside the loop, we have a retry mechanism with a maximum of 150 attempts. If the user doesn't authorize within the expected timeframe, we return a Request Timeout error.

  4. In the catch block, we handle potential errors from the exchangeDeviceCode() call. We specifically check for a 400 status code, which indicates an OAuth-related error. Based on the error.response.data.error property, we handle different scenarios:

    1. authorization_pending: This is the most common case where the user hasn't yet authorized the application. We log a message and wait for the duration specified by the interval before retrying.
    2. slow_down: GitHub might ask us to slow down our polling frequency. We increase the currentInterval and wait accordingly.
    3. expired_token, incorrect_device_code, access_denied: These are terminal errors. We return an error object with the specific error code, indicating that the authentication process cannot continue with the current device code.
    4. Any other 400 error: We return a generic error message with the specific error code.
    5. Non-400 errors: These are unexpected errors, so we log an error message and re-throw the error to be handled by the calling code.

The use of a while(true) loop with specific exit conditions based on successful authentication or terminal errors allows us to continuously poll until a result is obtained or a non-recoverable error occurs.

Now you might be thinking, why maximum 150 attempts? Why not any more or less? Is there anything special with the number?

Why maximum of 150 attempts?

Total Time Allowed=15 minutes=15×60 seconds=900 seconds \text{Total Time Allowed} = 15 \text{ minutes} = 15 \times 60 \text{ seconds} = 900 \text{ seconds}

The GitHub REST API documentation specifies that a user has a maximum of 15 minutes (or 900 seconds) to enter the device code and authorize the application. After this time, the device authorization code expires, and a new one must be requested.

The typical interval between polling requests to GitHub to check if the user has authorized the device is 5 seconds. However, to account for a potential request-response promise resolution lag (estimated at approximately 1 second), the effective interval between consecutive attempts is considered to be around 6 seconds.

Effective Interval per Attempt5 seconds (polling)+1 second (lag)=6 seconds \text{Effective Interval per Attempt} \approx 5 \text{ seconds (polling)} + 1 \text{ second (lag)} = 6 \text{ seconds}

Therefore, to calculate the approximate number of attempts within the 15-minute window, we can use the following relationship:

Number of Attempts×Effective Interval per AttemptTotal Time Allowed \text{Number of Attempts} \times \text{Effective Interval per Attempt} \approx \text{Total Time Allowed}

Substituting the values -

Number of Attempts×6 seconds900 seconds \text{Number of Attempts} \times 6 \text{ seconds} \approx 900 \text{ seconds}

Solving for the number of attempts -

Number of Attempts900 seconds6 seconds/attempt=150 attempts \text{Number of Attempts} \approx \frac{900 \text{ seconds}}{6 \text{ seconds/attempt}} = 150 \text{ attempts}

The generalized equation would be -

Number of Attempts(Number of Minutes×10) seconds \text{Number of Attempts} \approx (\text{Number of Minutes} \times 10) \text{ seconds}

Thus, a retry mechanism with a maximum of 150 attempts is implemented to cover the entire 15-minute window during which the user can authorize the device, considering an approximate 6-second effective interval between each polling request.

Also I must mention the "slow down" signal from the GitHub API, which increases the polling interval by 10 seconds each time it's received.

While this can reduce the number of attempts within the 15-minute window, it is expected to be an infrequent occurrence. The initial 1-second lag consideration in our effective interval helps to provide a buffer that can partially compensate for occasional "slow down" signals, ensuring that we still have a reasonable number of attempts within the allowed timeframe.

The 150 attempts limit is derived from the maximum authorization time of 15 minutes and an estimated effective polling interval of 6 seconds, aiming to maximize the chances of successfully retrieving the access token within the allowed timeframe while respecting the API's rate limits and potential "slow down" signals.

Why exactly Device Login Flow Method is more secure?

The Device Login Flow method offers enhanced security, particularly for headless applications like CLIs, due to several reasons:

  1. No Exposure of Client Secrets in the Application: Unlike traditional OAuth flows where the client secret is embedded within the application code, the Device Login Flow does not require a client secret to be present in the CLI application itself. In this flow, the sensitive step of exchanging the device code for an access token happens directly between the user's browser and the OAuth provider's server.

    The CLI only handles the initial request for a device code and the subsequent polling for the access token.

    This eliminates the risk of a malicious actor extracting the client secret from the CLI application, which is a significant vulnerability in public client applications.

  2. User Interaction in a Secure Browser Environment: The crucial step of granting authorization happens entirely within the user's web browser. This is a secure environment managed by the operating system and the browser vendor, offering protections against local application interference. The CLI application itself never handles the user's credentials (like passwords) or directly sees the authorization grant. The user authenticates directly with GitHub through a familiar and trusted interface.

  3. Reduced Attack Surface: By offloading the sensitive authentication steps to the browser and the OAuth provider's server, the attack surface of the CLI application is significantly reduced. A compromised CLI application, even if it gains access to the device code, cannot directly obtain the access token without the user's explicit authorization through the browser.

  4. Mitigation of Credential Stuffing and Phishing: Since the user enters their GitHub credentials directly on the official GitHub website in their browser, the Device Login Flow inherently mitigates the risks of credential stuffing attacks (using stolen username/password pairs) and phishing attempts that might try to trick users into entering their credentials within the CLI application itself.

  5. Suitability for Headless Environments: The Device Login Flow is specifically designed for devices that lack a full web browser or have limited input capabilities (hence "headless"). This makes it ideal for CLI applications where a traditional redirect-based OAuth flow would be impractical or impossible.

Essentially, yes, the security of the Device Login Flow relies on the strong security measures implemented by the OAuth provider (GitHub in this case) and the user's web browser. By keeping sensitive credentials and the authorization process outside the CLI application itself, it improves the overall security posture compared to methods that might involve embedding secrets or handling credentials directly within the application. The reliance on user interaction within a secure browser environment is a cornerstone of its increased security.

Why Follow This Opinionated Pattern? Why Not Just Throw Errors Where They Occur?

Well, it is opinionated, but it comes with benefits. You are free to write the auth flow in your application in whatever way you prefer; this technique might not be effective for your app based on your requirements. This is what I prefer because it improves developer experience with a functional paradigm and a modular approach. Ah well, I think that in particularly this kind of application and based on our toolsets, this is the most suitable and appropriate option.

There’s reasons I opted for it :-

  1. JavaScript doesn't have types in the sense of types(TypeScript has, but here we are using JavaScript so just forget TS for now),

  2. Given JavaScript's dynamic typing, enforcing a consistent return object structure (either success with an authentication object or failure with an error object) provides a predictable interface for the calling code, improving code clarity and reducing the risk of runtime errors due to unexpected return types in different scenarios, without relying on static type enforcement.

  3. Adhering to a well-defined schema for the returned object enhances code modularity and maintainability by providing a clear contract between the authentication utility function and its callers, making the code easier to understand, test, integrate, maintain and evolve.

The Benefits You Want to Know

This technique offers several advantages in the CLI context:

  1. Centralized Error Handling: It allows the calling code to have a unified way of handling authentication outcomes. Instead of dealing with various potential exceptions thrown from deep within the authentication logic, the caller always receives an object to inspect. This promotes cleaner and more predictable error management.

  2. Better Control Flow: The calling code can explicitly decide how to react to different error conditions (e.g., retry, log, display a specific message, exit).

  3. Alignment with Functional Programming: This approach aligns well with functional programming principles where functions ideally return values rather than causing side effects like throwing exceptions. It makes the function's outcome more explicit and easier to reason about.

  4. Clear Separation of Concerns: The utility function focuses on performing the authentication steps and reporting the outcome. The calling code is responsible for interpreting and acting upon that outcome. This separation makes the code more modular and maintainable.

Also, Functions returning predictable result objects are generally easier to test. We can directly assert the returned object's properties to verify both success and various failure scenarios.

Let’s Go Back to Coding

Here comes our unauthentication.js file.

touch auth/unauthentication.js
Enter fullscreen mode Exit fullscreen mode
/*
 * for handling all unauthentication and authentication error related issues 
 * */

export default {
  type: "unauthenticated",
  reason: (errorCode) => {
    if (errorCode === "access-denied") {
      return "User has denied the request. The authorization process has been canceled.";
    } else if (errorCode === "incorrect_device_code") {
      return "The device code provided is not valid.";
    } else if (errorCode === "expired_token") {
      return "The device code has expired. Please start the process again.";
    } else {
      return "The app's access has been revoked by GitHub!\n\t Or Unknown Error : (";
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

I think you are starting to realise which way we are heading to.

Now we need a function to combine the outcomes of exports of these two files.

touch auth/authObject.js
Enter fullscreen mode Exit fullscreen mode
import unauthentication from "./unauthentication.js";
import { getOAuthObject } from "./oauth.js";

async function getAuthenticationObject() {
  try {
    const oAuthObject = await getOAuthObject();
    if (oAuthObject.hasOwnProperty("error")) {
      return {
        authStatus: unauthentication.type,
        reason: unauthentication.reason(oAuthObject.error),
        token: "NA",
      };
    } else {
      return {
        authStatus: "authenticated",
        reason: "user authorized via Device Flow in Browser",
        token: oAuthObject.token,
      };
    }
  } catch (err) {
    return {
      authStatus: unauthentication.type,
      reason: err.message,
      token: "NA",
    };
  }
}

export { getAuthenticationObject };
Enter fullscreen mode Exit fullscreen mode

Yes, you got it right. The unauthentication.js file defines a simple object that encapsulates information about unauthenticated states. It has a type property set to "unauthenticated" and a reason function.

This reason function takes an error code as input and returns a user-friendly message explaining why the authentication failed. This file serves as a centralized source for generating consistent unauthentication messages.

The authObject.js file imports the unauthentication object and the getOAuthObject function from oauth.js. Its primary purpose is to create a unified authentication status object. The getAuthenticationObject function orchestrates the authentication process.

It first calls getOAuthObject() to initiate the OAuth device flow. It then checks the returned oAuthObject. If it has an error property (indicating authentication failure as per our opinionated design), it returns a new object with:

  • authStatus: set to the type from the unauthentication object ("unauthenticated").

  • reason: generated by calling the reason function from unauthentication with the received error code.

  • token: set to "NA" (Not Applicable) as authentication failed.

If getOAuthObject() returns successfully (without an error property), it means the user authorized the application. In this case, it returns an object with:

  • authStatus: set to "authenticated".

  • reason: a success message indicating user authorization.

  • token: the access token received from getOAuthObject().

The try...catch block handles any unexpected errors that might occur during the getOAuthObject() call itself (for example, - network issues). If such an error happens, it returns an unauthenticated object with the error message as the reason.

Thus getAuthenticationObject() abstracts away the details of the OAuth flow, providing a consistent authentication status object to the rest of the application, adhering to the design pattern of returning objects with either success or error information.

Now our last file related to authentication -

touch auth/index.js
Enter fullscreen mode Exit fullscreen mode

We would need one extra package for validating the user.

npm i @octokit/rest
Enter fullscreen mode Exit fullscreen mode
import { getAuthenticationObject } from "./authObject.js";
import { Octokit } from "@octokit/rest";
import chalk from "chalk";

const getLogin = async (token) => {
  const octokit = new Octokit({auth:token});
  const response = await octokit.rest.users.getAuthenticated();
  return response.data.login;
}

async function checkTokenValidity(token) {
  try {
    const userName = await getLogin(token); // If this works without errors, the token is valid
    if (userName.length > 0) {
      return true;      
    } else {
      return false;
    }
  } catch (error) {
    if (error.status >= 401) {
      return false;
    } else {
      throw new Error("Error checking token:", error.message);
    }
  }
}

async function verifyAuthStatus(token, authStatus, reason) {
  try {
    if (token !== "NA") {
      console.log(chalk.greenBright(reason));
      return authStatus;
    } else {
      // condition for unsuccessful oauth
      console.error(reason);
      return authStatus;
    }
  } catch (err) {
    // condition for unknown errors
    console.error(err.message);
    process.exit(1);
  }
}

async function getVerifiedAuthToken() {
  const { token, authStatus, reason } = await getAuthenticationObject();
  const currentAuthStatus = await verifyAuthStatus(token, authStatus, reason);
  if (currentAuthStatus === "unauthenticated") {
    process.exit(1);
  } else {
    const tokenData = { token: token, type: "oauth" };
    return tokenData;
  }
}

export {
  getVerifiedAuthToken, checkTokenValidity,
}
Enter fullscreen mode Exit fullscreen mode

The auth/index.js file serves as the main entry point for the authentication logic. It imports getAuthenticationObject to initiate the authentication flow and Octokit for interacting with the GitHub API.

The getLogin function takes an authentication token and uses Octokit to fetch the authenticated user's login from GitHub. This function is used to verify the token's validity.

The checkTokenValidity function takes a token and attempts to retrieve the user's login using getLogin. If successful, it implies the token is valid and returns true. If it encounters an error with a 401 or higher status code (indicating authentication failure), it returns false. Other errors during the API call are re-thrown.

The verifyAuthStatus function takes the token, authentication status, and reason as input. If the token is not "NA" (meaning authentication was successful), it logs the success reason in green and returns the authStatus. If the token is "NA" (unsuccessful authentication), it logs the reason as an error and returns the authStatus. It also includes a catch block for handling unexpected errors, logging the error and exiting the process.

The getVerifiedAuthToken function orchestrates the entire process. It calls getAuthenticationObject to get the initial authentication status, token, and reason. It then uses verifyAuthStatus to log the outcome and check if the authentication was successful. If the currentAuthStatus is "unauthenticated", the process exits. Otherwise, it creates an object containing the valid token and its type ("oauth") and returns it.

Finally, it exports getVerifiedAuthToken (the main function to get a verified authentication token) and checkTokenValidity (for explicitly checking token validity).

Now you might be thinking that why not just return the token as it is? Why again return an object?

The answer is simple. You may need to add an option (feature) for the user to be able to authenticate with a personal access token in future. Or you might already have it in your app. You can store your user’s personal access token like this object but in that case the type property will be “PAT” or anything you want, that separates it from “oauth”. In that case you may need to change some functions little bit. That’s up to you.

All I mean, how the user authenticated, that data must be important to you.

Now you have the token so you can easily send authenticated requests by calling the Octokit constructor.

const octokit = new Octokit({ auth: token })
Enter fullscreen mode Exit fullscreen mode

As the amount of the data we need to store is small, well I should say tiny, I am demonstrating this using a JSON file as our database. Well calling it a database will be too overkill.

If your application already is using a database, make a new collection or table and store the tokenData in there. Use your database sdk utilities to access and manage the data.

Well if you use a JSON data, you need some util function manage token as a JSON Data - tokenHelpers.js -

touch auth/tokenHelpers.js
Enter fullscreen mode Exit fullscreen mode
import { existsSync, readFileSync, writeFileSync } from "fs";
import converter from "../base64.js";

function getStoredToken(TOKEN_FILE) {
  if (existsSync(TOKEN_FILE)) {
    const tokenData = JSON.parse(readFileSync(TOKEN_FILE, "utf-8"));
    if (tokenData && tokenData.token) {
      return converter.btoa(tokenData.token);
    }
  } else {
    return null;
  }
}

// Save token and the authType to a file with type key
function saveToken(tokenData, TOKEN_FILE) {
  tokenData.token = converter.atob(tokenData.token);
  writeFileSync(TOKEN_FILE, JSON.stringify(tokenData, null, 2));
}

// Clear token from storage and set type to unauthenticated
function clearToken(TOKEN_FILE) {
  if (existsSync(TOKEN_FILE)) {
    const dataToSave = {
      token: null,
      type: "unauthenticated",
    };
    writeFileSync(TOKEN_FILE, JSON.stringify(dataToSave, null, 2));
  }
}

export { getStoredToken, saveToken, clearToken };
Enter fullscreen mode Exit fullscreen mode

Here I am using a base64 converter to encode the tokens before saving them. Actually you need to encrypt them. Base64 encoding won’t make the token storage much secure, encryption will.

Use the node:crypto module or bcrypt.js whatever you prefer.

Anyways this is the base64.js file -

touch base64.js
Enter fullscreen mode Exit fullscreen mode
export default {
  atob: (token) => Buffer.from(token).toString("base64"),
  btoa: (base64token) => Buffer.from(base64token, "base64").toString("ascii"),
};
Enter fullscreen mode Exit fullscreen mode

So I don’t recommend you just copy and paste the code blindly. You can take the idea, make it your own on yourself.

After all, knowledge is easily transferable from idea to idea.

Most likely you would need a robust and effective encryption algorithm or library to store and access the token securely.

let’s write the entry point of our cli.

touch index.js
Enter fullscreen mode Exit fullscreen mode
import { Command } from "commander";
import config from "./auth/config.js";
import { getStoredToken, saveToken, clearToken } from "./auth/tokenHelpers.js";
import { getVerifiedAuthToken, checkTokenValidity } from "./auth/index.js";
import chalk from "chalk";

const packageJson = await (async () => {
  const { createRequire } = await import("node:module");
  const require = createRequire(import.meta.url);
  return require("./package.json");
})();
const program = new Command();

program
  .name(`${packageJson.name}`)
  .description(`${packageJson.description}`)
  .version(`${packageJson.version}`);

program
  .command("login")
  .description("authorize demo-cli with your GitHub")
  .action(async () => {
    const storedToken = getStoredToken(config.TOKEN_FILE);
    console.log("checking if already authorized...");

    try {
      if (storedToken !== null) {
        if (await checkTokenValidity(storedToken)) {
          console.log("User is already authorized!\n");
        } else {
          console.error(
            "\nLooks Like Your Token Expired! Initiating Authorization\n",
          );
          clearToken();

          const tokenData = await getVerifiedAuthToken();
          saveToken(tokenData, config.TOKEN_FILE);
        }
      } else {
        const tokenData = await getVerifiedAuthToken();
        saveToken(tokenData, config.TOKEN_FILE);
      }
    } catch (error) {
      console.log(chalk.red(error.message));
    }
  });

program.parseAsync();
Enter fullscreen mode Exit fullscreen mode

In our index.js, the entry point of our CLI application, we utilize the commander library to handle command-line arguments. We define a single login command that initiates the authentication process with GitHub.

When the login command is executed, we first check if a token is already stored using getStoredToken.

If a token exists, we use checkTokenValidity to verify if it's still valid by making a simple API call to GitHub. If the token is valid, we inform the user that they are already authorized. If the token is invalid (likely expired), we clear the existing token using clearToken and then proceed to obtain a new verified token using getVerifiedAuthToken. The newly obtained token is then saved using saveToken.

If no token is found initially, we directly call getVerifiedAuthToken to start the authentication flow and save the resulting token.

We wrap the core logic in a try...catch block to handle any potential errors during the authentication or token handling process and display an error message to the user.

Finally, program.parseAsync(process.argv) processes the command-line arguments, making our login command executable.

Congrats! You have implemented a robust authentication system for your CLI using the OAuth Device Login Flow with GitHub as the provider. This approach ensures a secure and user-friendly authentication experience for developers using your CLI tool.

The Result

After coding is done let’s run the code -

Console session showing a sequence of commands for creating and editing JavaScript files. Directories and files include

The help information is auto-generated based on the information commander already knows about your program.

Screenshot of a terminal window showing a Node.js command line interface for a demo CLI, demonstrating GitHub Device Flow Authentication. It includes usage instructions, options for version and help, and commands for login and help.

The whole login process will look like this -

After authentication, the token is stored in the device, so repetitive login commands won’t make it re-authenticate.

Terminal screen showing a user running a command for OAuth login using . The process involves waiting for user authorization through a browser. A user code

After a successful login, the cli will recognise the existence of token stored in config file (~/.demo-cli-data.json), check if it is valid, if yes it will show the message - “User is already authorized!“ and exit.

Concluding

Building a CLI application with authentication might seem daunting initially, but as we've demonstrated, leveraging the right tools and understanding the underlying principles can make the process quite manageable.

Remember to always prioritize security when handling sensitive information like access tokens, and consider implementing proper encryption for persistent storage in real-world applications.

Feel free to adapt and expand upon this implementation to suit the specific needs and features of your own projects.

Thank you

I would really appreciate your feedback on this tutorial. How would you rate it? How can I improve it?

If you found this project or tutorial helpful, please consider sharing some love to the article. It will encourage me to create more content like this.

If you found this POST helpful, if this blog added some value to your time and energy, please show some love by giving the article some likes and share it with your developer friends.

Feel free to connect with me at - Twitter, LinkedIn or GitHub :)

Happy Coding 🧑🏽‍💻👩🏽‍💻! Have a nice day ahead! 🚀

Comments 12 total

Add comment