Securing Static Web App Preview Environments in Azure

TL;DR

I use Azure Static Web Apps together with the Hugo static site generator as my blogging solution. It is backed by GitHub to make use of the preview environments of Azure Static Web Apps. I use the paid plan to enable adding a password to any environment that is not deployed from the main branch. If you just want to know how, skip ahead.

So, I got myself this blog

I have been wanting to start blogging for a while now. Due to various reasons I may address in future posts, it took me a bit to actually get there. For now, I will take you along on the journey to get my blog running.

Special thanks to Mart de Graaf for challenging me to finally get something out there.

I had a few considerations when I started out to get myself a blog.

When all is said and done I only want to worry about writing new posts, and choosing when they show up online.

After a bit of research and some discussion with my colleagues I settled on this combination:

Starting quick

First things first - I wanted to make sure that I had a running website, deployed from source control with a proper pipeline. Adding content with Hugo comes later, and I will cover that in another post.

To get a basic setup, all I had to do was follow the Quickstart for Azure Static Web apps from Microsoft. It really is a quickstart - get your own repo initialized by starting from a simple “vanilla” template repo from Github, then use the Azure Static Web Apps extension for Visual Studio Code to publish that base website as an Azure Static Web App. It will also add a Github Actions workflow to your repo that runs on pull request or push to your main branch.

Please make sure to select the right subscription when using the VS Code extension. I forgot to double-check and didn’t see my resources at first.

An unexpected bonus was that the “vanilla” repo includes Github Actions workflows for Playwright - a relatively new framework for scripted browser testing. I was planning on checking Playwright out - it’s nice to already have the setup in place.

Branching out

The combination of GitHub and Azure Static Web Apps provides the last thing to fix before getting into actually creating content - preview environments. When you create a change on a branch and then a pull request in Github, the GitHub Actions workflow that was generated during the Quickstart will create a preview environment in Azure and provide you with a link in the pull request conversation log when it is ready and available. This method can support up to 3 environments on the free tier, and the environment automatically gets cleaned up once the pull request is closed too.

To demonstrate: In Github I created a branch named Demo, and committed some changed text in /src/index.html. I then created a pull request, which automatically trigged the workflow that was created during the quickstart to run. The playwright tests also run at the same time. A preview environment was generated, and the URL that can be used to access the environment was posted in the Conversation log of the pull request.

Screenshot of Link to Preview Environment in GitHub Pull Request Conversation

Securing the preview environment

There is only one small issue with the default preview environments - they are NOT secured. Granted, the URL is pretty random, but before I release anything online I still would like some kind of password protection to be able to preview a blog post at my own pace without anybody finding out how many typos I make.

This is where Azure Static Web Apps comes in. It already supports using either a Twitter, GitHub or Azure Active Directory account as a login, but that only works if you manually write a routing file to specify which parts of the site you want to secure with a password. If for example, you want to secure only some subroutes on the production environment instead of the whole site in the preview, you have to adjust your pipeline to deploy a different routing config. And that will then also impact any automated tests that might run against the website.

I switched to the paid plan for Static Web Apps in Azure because it has the option to put Basic Authentication around your entire site. You can pick whether or not you only want that on preview branches, or your production branch as well. It also stacks with the other logins - after the basic auth challenge the other 3 login types work on the same routes as in production.

To start, check what plan you are on. Go to your Static Web App in Azure -> Configuration -> General Settings. If you are on the free plan, the general settings are all defaults and greyed out: password protection and staging environments are not configurable.

Screenshot of Azure Static Web App - General settings (Free plan)

To enable these settings, go to Hosting Plan and switch to the ‘Standard’ plan

Screenshot of Azure Static Web App - Change hosting plan

Then go to Configuration -> General Settings

Ensure the bottom toggles are active:

The final result should look like this:

Screenshot of Azure Static Web App - General settings (Paid plan)

When opening your preview environment, you are now greeted with a password prompt before you can continue.

Screenshot of Azure Web App password prompt when accessing the preview environment in a browser

After logging in, I get to see my website exactly as I would expect it to be in production.

Bonus tip: Disable unused logins

When using the paid plan password protection, it relies on Basic Authentication. This is fully controlled by Azure. The other three login types (Twitter, GitHub or Azure Active Directory account) are ENABLED by default - even if you do not use them to secure any route.

A good security practice is to disable what you do not use, and this can be done by adding a staticwebapp.config.json route file. This file can be placed anywhere in your static website directories, but the source root folder /src is the most logical place.

In the file all unused logins are set to return HTTP 401 “Not authorized”, and then catch all 401s and redirect them to the site root. This will also trigger the Basic Authentication login prompt if you try to navigate to the login URLs directly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "routes": [
    {
      "route": "/.auth/login/twitter", 
      "statusCode": 401   //Not authorized
    },
    {
      "route": "/.auth/login/github",
      "statusCode": 401   //Not authorized
    },
    {
      "route": "/.auth/login/aad",
      "statusCode": 401   //Not authorized
    }
  ],
  "responseOverrides": {
    "401": {              //Not authorized
      "statusCode": 302,  //Redirect  
      "redirect": "/"     //To site root
    }
  }
}
Download file

Adding playwright tests for verification

Mart de Graaf has gracefully assisted me with a couple of playwright scripts that verify that this is all configured correctly.

The playwright script can be pasted into the /tests directory.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { test, expect } from '@playwright/test';

test.describe('When unauthenticated', () => {
	test.use({ storageState: 'tests/emptyStorageState.json' });
	test('twitter should redirect to password', async ({ page }) => {
		await page.goto('/.auth/login/twitter');
		await expect(page).toHaveURL(/basicAuth/);
	});
	test('github should redirect to password', async ({ page }) => {
		await page.goto('/.auth/login/github');
		await expect(page).toHaveURL(/basicAuth/);
	});
	test('aad should redirect to password', async ({ page }) => {
		await page.goto('/.auth/login/aad');
		await expect(page).toHaveURL(/basicAuth/);
	});
});
Download file

The build needs to be adjusted to pass through the correct URL prefix, or the playwright tests won’t navigate to the correct URL. Add these lines to the workflow in /.github/workflows/playwright-onDemand.yml. Make sure to replace the root URL with the one for your specific Static Web App!

18
19
20
21
22
23
24
25
# /.github/workflows/playwright-onDemand.yml
  
# Make env var reflect presence of token
env:
  PAT_EXISTS: ${{ secrets.PAT_TOKEN }}
  IS_PRODUCTION: "${{ github.ref == 'main' }}"
  PRODUCTION_URL: "https://gray-river-04b46de03-release.westeurope.2.azurestaticapps.net/"
  STAGING_URL: "${{ format('https://gray-river-04b46de03-{0}.westeurope.2.azurestaticapps.net/', github.event.number) }}"

And then pass the PLAYWRIGHT_TEST_BASE_URL variable to the playwright tests.

35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# /.github/workflows/playwright-onDemand.yml

    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-node@v3
    - name: Install dependencies
      run: npm ci
    - name: Install Playwright
      run: npx playwright install --with-deps
    - name: Install Sirv
      run: npm install --g sirv-cli
    - name: Run playwright tests
      env:
          PLAYWRIGHT_TEST_BASE_URL: "${{ env.IS_PRODUCTION == 'true' && env.PRODUCTION_URL || env.STAGING_URL }}"
      run: npm run playwright_test
    - name: Get current date
      id: date
      run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
    - name: Upload HTML report as Artifact
      uses: actions/upload-artifact@v2
      env:
          TAG_NAME: test-report-${{ steps.date.outputs.date }}
      if: ${{ always() }}
      
      with: 
        name: onDemand
        path: pw-report/

Finally, adjust the /playwright.config.ts to use this new base URL.

37
38
39
40
41
42
43
44
45
// /playwright.config.ts
	use: {
		/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
		actionTimeout: 0,
		/* Base URL to use in actions like `await page.goto('/')`. */
		baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL, 
		/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
		trace: 'on',
	},

The tests will now be run during a pull request, and when merging to main, ensuring no unused logins are available in your production environment.