Basic authentication for Storybook

Basic authentication for Storybook

For xmCloud and many other projects we are using Storybook, in this article I discuss how to secure the storybook deployed on vercel using basic auth and other available options.
The same architecture can be used with any other host provider and also any other Identity provider like okta/auth0.

Disclaimer: I am not a frontend developer, please check the code with your frontend expert.

Unfortunately, the storybook does not have support for basic auth: https://github.com/storybookjs/storybook/issues/5193

Hosting on Vercel

If you are using Vercel you have two easy options:

  • Vercel Authentication: It is now activated by default, which means you need to have a Vercel account and be a team member to access the deployed app and it is not an option for the hobby accounts.
  • Password protection: Anyone with a password can access the app, which will cost $ 150$ per month.

So, let's step back and think about the Storybook. Storybook build result is a static HTML website. so if we have a server that can authenticate the incoming requests, we can secure the storybook.

Basic Authentication and express

The idea is to use the Express server as a wrapper around the storybook, so it can take care of the basic authentication (or any kind of authentication with any external provider like okta or auth0) and after the request is authenticated return the static assets.

there are two approaches based on how you want to deploy your application to Vercel:

  • Build the storybook on an external build agent like Azure DevOps and then deploy the build result to the vercel.
  • Build on the vercel, which can be more complicated.

Preparation for Deployment

I will use Azure DevOps for this post because recently Vercel has created two Tasks for Azure DevOps integration and it makes it much easier. If you use an unsupported system you can always use Vercel CLI for deployment.


I assume you already have an Azure DevOps project and you have also created a Vercel project for a storybook (vercel project add MyStorybook) and you have vercel access token.

Under the Library create a new variable group and name it storybook-vercel and add the following variables to it:

  • VERCEL_ORG_ID: Vercel Organization ID
  • VERCEL_PROJECT_STORYBOOK_ID: Project ID for Storybook
  • VERCEL_TOKEN: Vercel Access token for deployment.

On the Vercel Project, make sure your Project type is set to Other and the root directory is empty:

Under the environment variables of your project, add the following variables:

  • PREVIEW_USER: username for basic auth.
  • PREVIEW_PASS: Password for basic auth

Study the following documentation for Vercels Azure tasks:
https://github.com/vercel/vercel-azure-devops-extension
https://vercel.com/docs/deployments/git/vercel-for-azure-pipelines
documentation

The provided storybook example is created using a sandbox template:
npx storybook@latest sandbox

Building using an external Build System

In this approach we build the storybook on an external build system like Azure DevOps, then we will add the express server to the storybook build result and deploy it to the Vercel.

In the root of the solution, I have added the azure folder which contains the deployment pipelines, for this approach I use the build-storybook-azure.yaml

In the root of the frontend application, I added the storybookvercel which contains the express server and build configuration needed for the Vercel deployment.

Express App Logic is quite simple:

import * as path from 'path';
import express from 'express';
const app = express();
// Basic auth logic
app.use(function (req, res, next) {
    var auth;

    // check whether an autorization header was send    
    if (req.headers.authorization) {
        // only accepting basic auth, so:
        // * cut the starting "Basic " from the header
        // * decode the base64 encoded username:password
        // * split the string at the colon
        // -> should result in an array
        auth = new Buffer.from(req.headers.authorization.substring(6), 'base64').toString().split(':');
        // use Buffer.from in with node v5.10.0+ 
        // auth = Buffer.from(req.headers.authorization.substring(6), 'base64').toString().split(':');
    }

    const username = process.env.PREVIEW_USER;
    const password = process.env.PREVIEW_PASS;
    // checks if:
    // * auth array exists 
    // * first value matches the expected user 
    // * second value the expected password
    if (!auth || auth[0] !== username || auth[1] !== password) {
        // any of the tests failed
        // send an Basic Auth request (HTTP Code: 401 Unauthorized)
        res.statusCode = 401;
        // MyRealmName can be changed to anything, will be prompted to the user
        res.setHeader('WWW-Authenticate', 'Basic realm="RsnFeSbpwvlexp"');
        // this will displayed in the browser when authorization is cancelled
        res.end('Unauthorized');
    } else {
        // continue with processing, user was authenticated
        next();
    }
});


app.use(express.static(path.join(process.cwd(), 'public')));

app.get('/', async (req, res) => {
    res.sendFile(path.join(process.cwd(), 'public', 'home.html'));
});


const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
    console.log(`Server is running on port ${PORT}`);
});

We are checking the headers for basic authentication using a simple middleware, if the user is authenticated we will server the storybook from the public folder.

In the vercel.json we notify the Vercel about the nodejs application and configure all roots to server the requests from the index.js:

{
    "version": 2,    
    "builds": [
      {
        "src": "index.js",
        "use": "@vercel/node"
      }
    ],
    "routes": [
      {
        "src": "/(.*)",
        "dest": "index.js"
      }
    ]
  }

And in the packages.json we need to configure the type to module to prevent vercel from converting the storybook code to commonjs during the deployment.

{
  "name": "build",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2"
  }
}

The folder structure will look like this:

Now for the pipelines:

Now for the pipeline:

trigger:
  branches:
    include:
      - "*"
    exclude:
      - feature/*

  paths:
    include:
      - fe-app/*

variables:
  - group: storybook-vercel

stages:
  - stage: Build
    displayName: Build Storybook
    jobs:
      - template: /azure/templates/jobs-build-storybook-azure.yml

And the template:

jobs:
  - job: BuildStorybook
    displayName: Build Storybook
    variables:
      rootDirectory: src/fe-app
      verbose: false
      buildDirectoryStorybook: $(rootDirectory)/storybook-static
      vercelDirectoryStorybook: $(rootDirectory)/storybookvercel
      isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]

    steps:
      # Install NPM Packages
      - task: Npm@1
        displayName: "Run npm install"
        inputs:
          command: "ci"
          workingDir: "$(rootDirectory)"
          verbose: $(verbose)

      # Build Storybook
      - task: Npm@1
        displayName: "Build Storybook"
        inputs:
          command: "custom"
          customCommand: "run storybook:build"
          workingDir: "$(rootDirectory)"
          verbose: $(verbose)

      # Copy Build Result to vercel directory for deployment
      - task: CopyFiles@2
        displayName: 'Prepare assets for vercel deployment. Copying to: $(vercelDirectoryStorybook)'
        condition: or(eq(variables.isMain, true), eq(variables['Build.Reason'], 'PullRequest'))
        inputs:
          SourceFolder: '$(buildDirectoryStorybook)'
          Contents: '**/*'
          TargetFolder: '$(vercelDirectoryStorybook)/public'
  
      # Storybook Vercel
      - task: vercel-deployment-task@1
        name: 'DeployStorybook'
        displayName: "Deploy Storybook"
        inputs:
          vercelProjectId: $(VERCEL_PROJECT_STORYBOOK_ID)
          vercelOrgId: $(VERCEL_ORG_ID)
          vercelToken: $(VERCEL_TOKEN)
          production: $(isMain)
          vercelCWD: $(vercelDirectoryStorybook)
          debug: $(verbose)
        condition: or(eq(variables.isMain, true), eq(variables['Build.Reason'], 'PullRequest'))
      
      # Add the vercel preview url to the pull request
      - task: vercel-azdo-pr-comment-task@1
        displayName: "Update Pull Request"
        inputs:
          azureToken: $(AZURE_TOKEN)
          deploymentTaskMessage: $(DeployStorybook.deploymentTaskMessage)

It is quite simple, we are running the following steps:

  • Installing npm packages insde the fe root
  • Building the storybook
  • Copying build result to the storybookvercel/public folder, this folder is the source of deployment to vercel
  • Deploying the express app inside the storybookvercel folder to vercel
  • Adding a comment to the pull-request containing the deployed app url

And that is it, now when you open the deployed url you will get the basic auth pop-up.

Second Approach: Building using Vercel Build

This one is much more complicated and needs a deeper understanding of how Vercel build and deployment work.

This time we are using the official serverless function for the vercel. To prevent any effect on the main repository on the deployment time in azure devops, I will copy the index.js to api folder inside the root of FE-App. Then I need to register that as a function in the vercel.json

{
  "functions": {
    "api/index.js": { 
      "maxDuration": 30,
      "includeFiles": "storybook-static/public/**/*"
    }
  },
  "routes": [
    {
      "src": "/(.*)",
      "dest": "/api"
    }
  ]
}

Keep in mind the api will be run on the aws, so it needs to have access to the storybook build result. That is the reason for the includeFiles entry.

In this approach the storybook build will be run on the Vercel, which means the only way to change something is through the main package.json , so I added two new scripts to it:

After storybook:build:

    "storybook:build-preview": "npm-run-all --serial build-storybook storybook-move",
    "storybook-move": "cd storybook-static && mkdir public && ls | grep -v public | xargs mv -t public && cd .. && rm ./package.json && cp ./storybookvercelsecondapproach/package.json ./",

When the storybook is finished, everything inside the storybook-static will be moved to storybook-static/public folder and the package.json in the root will be replaced by package.json from storybookvercelsecondapproach folder. The reason behind is to prevent storybook scripts to be converted to commonjs and also preventing the installation of unnecessary npm packages on the AWS.

When the vercel is finished building the storybook, it deploy the api on AWS based on the package.json

Now the pipeline:

Now the pipeline (build-storybook-vercel.yaml):

trigger:
  branches:
    include:
      - "*"
    exclude:
      - feature/*

  paths:
    include:
      - fe-app/*

variables:
  - group: storybook-vercel

stages:
  - stage: Build
    displayName: Build Storybook
    jobs:
      - template: /azure/templates/jobs-build-storybook-vercel.yml

And the Jobs (jobs-build-storybook-vercel.yml):

jobs:
  - job: BuildStorybook
    displayName: Build Storybook
    variables:
      rootDirectory: src/fe-app
      verbose: false
      isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]

    steps:
      # Copying vercel build config to the root
      - task: CopyFiles@2
        displayName: "Copying vercel configuration to root"
        inputs:
          SourceFolder: "$(rootDirectory)/storybookvercelsecondapproach"
          Contents: 'vercel.json'
          TargetFolder: "$(rootDirectory)"

      # Copying vercel api
      - task: CopyFiles@2
        displayName: "Copying express api"
        inputs:
          SourceFolder: "$(rootDirectory)/storybookvercelsecondapproach"
          Contents: '*.js'
          TargetFolder: "$(rootDirectory)/api"

      # Storybook Vercel
      - task: vercel-deployment-task@1
        name: 'DeployStorybook'
        displayName: "Deploy Storybook"
        inputs:
          vercelProjectId: $(VERCEL_PROJECT_STORYBOOK_ID)
          vercelOrgId: $(VERCEL_ORG_ID)
          vercelToken: $(VERCEL_TOKEN)
          vercelCWD: "$(rootDirectory)"
          production: $(isMain)
          debug: $(verbose)
        condition: or(eq(variables.isMain, true), eq(variables['Build.Reason'], 'PullRequest'))

      # Add the vercel preview url to the pull request
      - task: vercel-azdo-pr-comment-task@1
        displayName: "Update Pull Request"
        inputs:
          azureToken: $(AZURE_TOKEN)
          deploymentTaskMessage: $(DeployStorybook.deploymentTaskMessage)

It is simple:

  • Copying vercel configuration to the root of FE folder
  • Copying the express api to the api folder in the root
  • Deploying to the vercel
  • Adding a comment to the pull-request containing the deployed app url

On the Vercel Project we need to configure the correct build command:

With that, on the deployment time the whole source code of your FE App will be uploaded to the vercel, then vercel will run the configured npm run storybook:build-preview command.

Fingers crossed when it is done, you will be able to see the basic auth pop-up.

The GitHub repository is here.

References: