Securing a Vue.js app using OpenID Connect Code Flow with PKCE and IdentityServer4

This article shows how to setup a Vue.js SPA application to authenticate and authorize using OpenID Connect Code flow with PKCE. This is good solution when implementing SPA apps requesting data from APIs on separate domains. The oidc-client-js npm package is used to implement the client side authentication logic and validation logic. IdentityServer4 and ASP.NET Core Identity are used to implement the secure token server.

Code https://github.com/damienbod/IdentityServer4VueJs

Other posts in this series

Building and securing an ASP.NET Core API with a hosted Vue.js UI

History

2020-09-12 Update to .NET Core 3.1.8, latest STS

IdentityServer4 Client configuration

The secure token server was implemented using IdentityServer4 with ASP.NET Core Identity and an Entity Framework Core database. A client configuration was added for the Vue.js application. This configures the code flow with PKCE and supports the callback and the silent-renew redirects.

new Client
{
	ClientName = "vuejs_code_client",
	ClientId = "vuejs_code_client",
	AccessTokenType = AccessTokenType.Reference,
	// RequireConsent = false,
	AccessTokenLifetime = 330,// 330 seconds, default 60 minutes
	IdentityTokenLifetime = 300,

	RequireClientSecret = false,
	AllowedGrantTypes = GrantTypes.Code,
	RequirePkce = true,

	AllowAccessTokensViaBrowser = true,
	RedirectUris = new List<string>
	{
		"https://localhost:44357",
		"https://localhost:44357/callback.html",
		"https://localhost:44357/silent-renew.html"
	},
	PostLogoutRedirectUris = new List<string>
	{
		"https://localhost:44357/",
		"https://localhost:44357"
	},
	AllowedCorsOrigins = new List<string>
	{
		"https://localhost:44357"
	},
	AllowedScopes = new List<string>
	{
		"openid",
		"dataEventRecords",
		"dataeventrecordsscope",
		"role",
		"profile",
		"email"
	}

Vue.js Client setup

The Vue.js client is implemented using Vue.js CLI to create a new typescript application. The STS is configured to run with HTTPS. This needs to be configured in the Vue.js app.

This can be done in the package.json file. The serve script was changed to support HTTPS. The oidc-client and the axios npm packages were added to the solution as well as the standard Vue.js npm packages.

axios is used for the API requests which uses the access token after a successful login.

{
  "name": "vue-js-oidc-client",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve --https --port 44357",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "build-production": "vue-cli-service build --mode production",
    "build-watch": "vue-cli-service build --watch"
  },
  "dependencies": {
    "axios": "^0.19.0",
    "core-js": "^3.6.5",
    "oidc-client": "^1.9.1",
    "vue": "^2.6.10",
    "vue-class-component": "^7.1.0",
    "vue-property-decorator": "^8.2.2",
    "vue-router": "^3.1.3"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "^4.5.6",
    "@vue/cli-plugin-typescript": "^4.5.6",
    "@vue/cli-service": "^4.5.6",
    "typescript": "~3.9.3",
    "vue-template-compiler": "^2.6.10"
  }
}

The authentication was implemented following the blog from Jerrie Pelser and the samples from the oidc-client github repo.

An AuthService typescript class was implemented with the oidc-client settings. The code flow is configured here, as well as the silent renew. Then some functions were implemented for login, logout and getting an access token.

import { UserManager, WebStorageStateStore, User } from 'oidc-client';

export default class AuthService {
    private userManager: UserManager;

    constructor() {
        const STS_DOMAIN: string = 'https://localhost:44356';

        const settings: any = {
            userStore: new WebStorageStateStore({ store: window.localStorage }),
            authority: STS_DOMAIN,
            client_id: 'vuejs_code_client',
            redirect_uri: 'https://localhost:44357/callback.html',
            automaticSilentRenew: true,
            silent_redirect_uri: 'https://localhost:44357/silent-renew.html',
            response_type: 'code',
            scope: 'openid profile dataEventRecords',
            post_logout_redirect_uri: 'https://localhost:44357/',
            filterProtocolClaims: true,
        };

        this.userManager = new UserManager(settings);
    }

    public getUser(): Promise<User> {
        return this.userManager.getUser();
    }

    public login(): Promise<void> {
        return this.userManager.signinRedirect();
    }

    public logout(): Promise<void> {
        return this.userManager.signoutRedirect();
    }

    public getAccessToken(): Promise<string> {
        return this.userManager.getUser().then((data: any) => {
            return data.access_token;
        });
    }
}

The AuthService was then used in the Home.vue file just like Jerrie Pelser did it. Thanks for this blog. An API call was added to the component which gets the access token from the oidc lib, and requests data from a secure API. The data is then displayed in the UI.

<template>
    <div class="home">
        <img alt="Vue logo" src="../assets/logo.png">
        <div class="home">
            <p v-if="isLoggedIn">User: {{ username }}</p>
            <button class="btn" @click="login" v-if="!isLoggedIn">Login</button>
            <button class="btn" @click="logout" v-if="isLoggedIn">Logout</button>
            <button class="btn" @click="getProtectedApiData" v-if="isLoggedIn">Get API data</button>
        </div>

        <div v-if="dataEventRecordsItems && dataEventRecordsItems.length">
            <div v-for="dataEventRecordsItem of dataEventRecordsItems">
                <p><em>Id:</em> {{dataEventRecordsItem.Id}} <em>Details:</em> {{dataEventRecordsItem.Name}}  - {{dataEventRecordsItem.Description}} - {{dataEventRecordsItem.Timestamp}}</p>
            </div>
            <br />
        </div>

    </div>
</template>
<script lang="ts">
    import { Component, Vue } from 'vue-property-decorator';
    import AuthService from '@/services/auth.service';

    import axios from 'axios';

    const auth = new AuthService();

    @Component({
        components: {
        },
    })

    export default class Home extends Vue {
        public currentUser: string = '';
        public accessTokenExpired: boolean | undefined = false;
        public isLoggedIn: boolean = false;

        public dataEventRecordsItems: [] = [];

        get username(): string {
            return this.currentUser;
        }

        public login() {
            auth.login();
        }

        public logout() {
            auth.logout();
        }

        public mounted() {
            auth.getUser().then((user) => {
                this.currentUser = user.profile.name;
                this.accessTokenExpired = user.expired;

                this.isLoggedIn = (user !== null && !user.expired);
            });
        }

        public getProtectedApiData() {

            const authorizationHeader = 'Authorization';
            auth.getAccessToken().then((userToken: string) => {
                axios.defaults.headers.common[authorizationHeader] = `Bearer ${userToken}`;

                axios.get('https://localhost:44355/api/DataEventRecords/')
                    .then((response: any) => {
                        this.dataEventRecordsItems = response.data;
                    })
                    .catch((error: any) => {
                        alert(error);
                    });
            });
        }
    }
</script>
<style>

    .btn {
        color: #42b983;
        font-weight: bold;
        background-color: #007bff;
        border-color: #007bff;
        display: inline-block;
        font-weight: 400;
        text-align: center;
        vertical-align: middle;
        -webkit-user-select: none;
        -moz-user-select: none;
        -ms-user-select: none;
        user-select: none;
        background-color: transparent;
        border: 1px solid #42b983;
        padding: .375rem .75rem;
        margin: 10px;
        font-size: 1rem;
        line-height: 1.5;
        border-radius: .25rem;
        transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
    }

</style>

Two html files are required in the root web folder. In Vue.js, this is the public folder. A callback.html file is required to handle the code redirect.

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Waiting...</title>
</head>
<body>
    <script src="js/oidc-client.min.js"></script>
    <script>

        var mgr = new Oidc.UserManager({ response_mode: 'query', userStore: new Oidc.WebStorageStateStore() }).signinRedirectCallback().then(function (user) {
            console.log("signin response success", user);
            window.location.href = '../';
        }).catch(function (err) {
            console.log(err);
            });

    </script>
</body>
</html>

The silent-renew.html is used for the silent renew in the iframe.

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Waiting...</title>
</head>
<body>
    <script src="js/oidc-client.min.js"></script>
    <script>

        var mgr = new Oidc.UserManager().signinSilentCallback();
    </script>
</body>
</html>

Both of these html files require a reference to the oidc-client.min.js which is copied to the js folder.

When the application is run, the user can login and get the secure data. Start the two dotnet core applications from Visual Studio and start the Vue.js application from the cmd using ‘npm run serve’

When the application starts login:

Give your consent:

request the API data:

And view the data:

Silent renew will also work using the iframe which has to be allowed on the server. This can be viewed in the F12 network tab in Chrome.

It is pretty easy to setup a secure Vue.js application using OIDC code Flow with PKCE.

Links

https://cli.vuejs.org

https://www.jerriepelser.com/blog/using-auth0-with-vue-oidc-client-js/

https://github.com/joaojosefilho/vuejsOidcClient

https://www.scottbrady91.com/Angular/Migrating-oidc-client-js-to-use-the-OpenID-Connect-Authorization-Code-Flow-and-PKCE

https://github.com/IdentityModel/oidc-client-js/

https://tools.ietf.org/html/rfc7636

https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest

9 comments

  1. […] Securing a Vue.js app using OpenID Connect Code Flow with PKCE and IdentityServer4 (Damien Bowden) […]

  2. What about using Azure Active Directory as the STS?

    1. Azure AD should work now that it supports OpenID Connect, but you will not have complete control of your tokens, and the applications become directly dependent on Azure AD and it’s breaking changes.

      Greetings Damien

  3. […] Securing a Vue.js app using OpenID Connect Code Flow with PKCE and IdentityServer4 […]

  4. when logout, not redirect to the home page,Have you ever had this situation

  5. Hi Damien, do you have example of signinPopup instead of redirect? I try to fork your solution and implement signinpopup but it seems the popup windows is not closing upon authenticated

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.