DEV Community

Cover image for Implementing OpenID Connect (OIDC) Authentication with Nuxt 3
Welker Arantes Ferreira
Welker Arantes Ferreira

Posted on • Updated on

Implementing OpenID Connect (OIDC) Authentication with Nuxt 3

Intro

Recently i started migrating an application from Nuxt 2 to Nuxt 3, but when i faced the authentication part i got a little lost since there is still no official module for Nuxt 3. Moreover, when researching this topic i couldn't find much. So now that i have managed to overcome this challenge i decided to write a post explaining the step by step that i followed to implement the authentication flow. This tutorial includes:

  • The service class responsible for authenticating the user;
  • The Store responsible for storing the logged in user data in memory;
  • The service class responsible for communicating with the application back-end;
  • The middleware responsible for managing access to the application routes.

It is worth mentioning that here I am implementing an authentication flow that uses the authentication server as a Single Sign On tool, so the user will be redirected to a global authentication page and after being authenticated he will be redirected back to the application.

Requirements

To create a Nuxt 3 project you need to have a newer version of NodeJS installed, preferably version 16 or later. In addition, I recommend that if you are going to use VS Code as a code editor, install the Vue Language Features extension.

Creating the Nuxt 3 Project

Nuxt 3 comes with a CLI called Nuxi and it is through it that we will create the project with the command:

npx nuxi init nuxt-3-oidc
Enter fullscreen mode Exit fullscreen mode

Then access the project folder and install the dependencies with the command:

npm install
Enter fullscreen mode Exit fullscreen mode

To enable routing in nuxt 3 you need to create a directory called pages with at least one file index.vue inside. So let's create the application's home page:

// /pages/index.vue
<template>
  <div>
    <h3>Você está logado</h3>
    <NuxtLink to="logout">Sair</NuxtLink>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

In addition to creating the pages, in App.vue we need to use the NuxtPage component like this:

<template>
  <div>
    <NuxtPage />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Now that we already have the base of the application working, let's implement the authentication class

Creating AuthService

To help us with the authentication flow, we are going to use the oidc-client-ts library, to install it run the following command:

npm install oidc-client-ts
Enter fullscreen mode Exit fullscreen mode

Before implementing the service class, create a directory called services and inside it create the environment.ts file which will store the authentication settings:

// /services/environment.ts
export const environment = {
  production: false,
  authorityUrl: 'https://auth.papo-digital.net.br',
  clientId: 'papo-digital-app',
  clientSecret: 'blog-client',
  clientScope: 'openid profile posts',
  applicationUrl: 'https://api.papo-digital.net.br',
}
Enter fullscreen mode Exit fullscreen mode

It is worth mentioning that this is not the most secure way to define the authentication settings. Here i am leaving in a file within the application just to make the example simpler, in a real application it is ideal to use environment variables.

Now, let's implement the AuthService:

// /services/auth-service.ts
import { User, UserManager, WebStorageStateStore } from 'oidc-client-ts'
import { environment } from './environment'

export default class AuthService {
  userManager: UserManager

  constructor() {
    const settings = {
      authority: environment.authorityUrl,
      client_id: environment.clientId,
      client_secret: environment.clientSecret,
      redirect_uri: `${window.location.origin}/auth`,
      silent_redirect_uri: `${window.location.origin}/silent-refresh`,
      post_logout_redirect_uri: `${window.location.origin}`,
      response_type: 'code',
      scope: environment.clientScope,
      userStore: new WebStorageStateStore(),
      loadUserInfo: true,
    }
    this.userManager = new UserManager(settings)
  }

  public signInRedirect() {
    return this.userManager.signinRedirect()
  }

  public signInCallback() {
    return this.userManager.signinCallback()
  }

  public renewToken(): Promise<void> {
    return this.userManager.signinSilentCallback()
  }

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

  public getUser(): Promise<User | null> {
    return this.userManager.getUser()
  }
}
Enter fullscreen mode Exit fullscreen mode

In the constructor we mount the configuration object that is used in the initialization of the UserManager class, from the oidc-client-ts library. Explaining the methods better:

  • signInRedirect: This method redirects the user to the authentication server's login page
  • signInCallback: This method will be used in the auth.vue file that we will create later. It is responsible for receiving the tokens and user data after authentication
  • renewToken: This method will be used in the silent-refresh.vue file that we will create later. It is responsible for obtaining a new access_token when the user's token expires
  • logout: This method signals to the authentication server that the user is ending his session and redirects the user to the login page
  • getUser: This method allows access to the logged in user data stored in the storage of the oidc-client-ts library

Creating complementary pages of the authentication flow

Before we create the complementary pages, let's create a composable called useServices that will facilitate access to the service layer:

// /composables/useServices.ts
import AuthService from '@/services/auth-service'

export const useServices = () => {
  return {
    $auth: new AuthService(),
  }
}
Enter fullscreen mode Exit fullscreen mode

Now that we have implemented the useServices, we need to create the complementary pages of the authentication flow that will use the AuthService methods. Let's start by creating the auth.vue page that will receive and store the user's data after authentication:

// /pages/auth.vue
<template>
  <h3>Carregando...</h3>
</template>

<script lang="ts" setup>
import { useServices } from '@/composables/useServices'

const services = useServices()
const router = useRouter()

const authenticateOidc = async () => {
  try {
    await services.$auth.signInCallback()
    router.push('/')
  } catch (error) {
    console.log(error)
  }
}

await authenticateOidc()
</script>
Enter fullscreen mode Exit fullscreen mode

Next, let's create the logout.vue page:

// /pages/logout.vue
<template>
  <h3>Deslogando...</h3>
</template>

<script lang="ts" setup>
import { useServices } from '@/composables/useServices'
import { useAuth } from '@/stores/auth'

const services = useServices()
const authStore = useAuth()

const logOutOidc = async () => {
  try {
    authStore.clearUserSession()
    await services.$auth.logout()
  } catch (error) {
    console.log(error)
  }
}

await logOutOidc()
</script>
Enter fullscreen mode Exit fullscreen mode

In the first line of the logOutOidc method we are calling a store method that doesn't exist yet, it will be implemented in the next topic.

Finally, we'll create the silent-refresh.vue page:

// /pages/silent-refresh.vue
<template>
  <h3>Carregando...</h3>
</template>

<script lang="ts" setup>
import { useServices } from '@/composables/useServices'

const services = useServices()
const router = useRouter()

const silentRefreshOidc = async () => {
  try {
    await services.$auth.renewToken()
    router.push('/')
  } catch (error) {
    console.log(error)
  }
}

await silentRefreshOidc()
</script>
Enter fullscreen mode Exit fullscreen mode

Installing and configuring Pinia in Nuxt 3

now that we have the authentication service and the complementary pages let's configure the store, which will give us immediate access to the logged in user's data. In this case we are giving preference to Pinia instead of Vuex because the Nuxt documentation itself recommends that we use Pinia. Install it by running the command:

npm install pinia @pinia/nuxt
Enter fullscreen mode Exit fullscreen mode

If there is any installation error you can add the dependencies manually, to do this just open the package.json file and in the dependencies section add these lines:

"dependencies": {
  "@pinia/nuxt": "^0.4.6",
  "pinia": "2.0.28"
}
Enter fullscreen mode Exit fullscreen mode

After adding the dependencies, run the npm install command and wait until the installation is complete.

Next we need to register the Pinia module in the nuxt.config.ts file:

export default defineNuxtConfig({
  ...,
  modules: ['@pinia/nuxt'],
})
Enter fullscreen mode Exit fullscreen mode

Now let's create the authStore that will allow us to transform localStorage data into reactive data:

// /stores/auth/index.ts
import { acceptHMRUpdate, defineStore } from 'pinia'
import { User } from 'oidc-client-ts'

export const useAuth = defineStore('auth', () => {
  const authUser = ref<User | null>(null)

  const access_token = computed(() => authUser.value?.access_token ?? '')

  const isLoggedIn = computed(() => !!authUser.value)

  const setUpUserCredentials = (user: User) => {
    authUser.value = user
  }

  const clearUserSession = () => {
    authUser.value = null
  }

  return {
    access_token,
    isLoggedIn,
    tenantId,
    setUpUserCredentials,
    clearUserSession,
  }
})

if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useAuth, import.meta.hot))
}
Enter fullscreen mode Exit fullscreen mode

Here is a valuable tip, the acceptHMRUpdate method enables Hot Module Replacement support in pinia allowing changes made to the Store code to be applied automatically without having to restart the application.

Configuring pages access control

The last part of the authentication flow consists of checking if the user is logged in before allowing him to access a certain page. To do this check we will create a middleware that will always be executed before rendering the application pages. In Nuxt 3 there are 3 ways to create a middleware, these are:

  • Inline: It is a function defined directly in the page where the middleware is used
  • Named: It is a .ts or .js file created in the middleware folder and needs to be invoked within the page that will be used
  • Global: It is a file created in the middleware folder similar to the named middleware, but this one has the .global suffix. Ex: user.global.ts

Here we will use the global middleware, because all pages require the user to be authenticated

// /middleware/auth.global.ts
import { User } from 'oidc-client-ts'
import { useAuth } from '@/stores/auth'
import { useSettings } from '@/stores/settings'

const authFlowRoutes = ['/auth', '/silent-refresh', '/logout']

export default defineNuxtRouteMiddleware(async (to, from) => {
  const authStore = useAuth()
  const services = useServices()
  const user = (await services.$auth.getUser()) as User

  if (!user && !authFlowRoutes.includes(to.path)) {
    services.$auth.signInRedirect()
  } else {
    authStore.setUpUserCredentials(user)
  }
})
Enter fullscreen mode Exit fullscreen mode

Basically what is happening in this middleware is:

1 - If the user is not authenticated and is not accessing any complementary page of the authentication flow we redirect him to the login page (Single Sign On)
2 - If the user is logged in we pass his data to the store and let him access the requested page

Creating ApplicationService

To finish this tutorial we will implement the service class responsible for communicating with the back-end:

// /services/application-service.ts
import { environment } from './environment'

export default class ApplicationService {
  constructor(private readonly acessToken: string) {}

  getDefaultHeader() {
    return { Authorization: `Bearer ${this.acessToken}` }
  }

  async getPosts() {
    const headers = this.getDefaultHeader()

    const result = await $fetch(`${environment.applicationUrl}/v1/Posts/List`, {
      method: 'get',
      headers,
      query: { page: 1, size: 10 },
    })

    return result
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we receive in the constructor of the class the user's access_token that we store in the authStore and the getDefaultHeader method assembles the authentication header that will be sent in the requests. You may have noticed that we are not using Axios to make requests, but there is a reason. The Nuxt developers recommend that we use the Fetch API instead of axios in conjunction with Nuxt 3, they even provide a global method called $fetch that has a syntax very similar to Fetch API with the difference that this method works well in the Browser, in Nodejs and also in Web Workers, in other words it has good support for Server Side Rendering.

Now in order to use the ApplicationService in our pages we need to make a small adjustment in the composable useServices

// /composables/useServices.ts
...
import { useAuth } from "@/stores/auth";
import ApplicationService from "@/services/application-service";

export const useServices = () => {
  const authStore = useAuth();

  return {
    ...
    $application: new ApplicationService(
      authStore.access_token
    )
  };
};
Enter fullscreen mode Exit fullscreen mode

Since the composables are executed within the context of Vue just like our pages and components, we can have access to the access_token stored in the authStore and thus pass it to the service class that communicates with the back-end. In addition, our middleware takes care of always saving the most updated token in the store, so when the authService executes the silent-refresh method the new token will already be saved in the store before the user accesses the requested page and that is why we access the service layer through this composable instead of instantiating the classes directly. Now we can access the methods of the ApplicationService class like this:

const services = useServices()
const posts = await services.$application.getPosts()
Enter fullscreen mode Exit fullscreen mode

And thus we come to the end of this tutorial, I hope you enjoyed ;)

Edited

If interested, you can access the Github repository

Latest comments (11)

Collapse
 
victorneves profile image
Victor Neves

Hi Welker

There is any way to make this work also with SSR?

Thanks

Collapse
 
mauricio_andrade profile image
Maurício Andrade Albuquerque

I wrote another article with some modifications:

Collapse
 
radonyizsolt profile image
Zsolt Rádonyi

Hi!
I am having a small trouble with this. I am trying to get the user profile data in my components, but it seems like when I first login for a short time it is not available yet. Once I refresh everything is available. Is there any way I can make sure that the authStore.setUpUserCredentials is finished before the middleware let's it through?

Thank you!

Collapse
 
kingside88 profile image
Kingside88 • Edited

Thank you for your're tutorial.
For now I am strugeling to implement it because in this file:
// /middleware/auth.global.ts
It redirects me the whole time. Because the user is always null.
if (!user && !authFlowRoutes.includes(to.path)) {
services.$auth.signInRedirect()
} else {
authStore.setUpUserCredentials(user)
}

I use keycloak for this and implemented it with KeycloakJs and sidebase/nuxt-auth. But I would prefer to use it server side.
If someone is interested in Nuxt3 with KeycloakJs, you could check my ready to go demo: github.com/Kingside88/nuxt3-primev...

Any chance you could provide a demo Github repo?

Collapse
 
taikio profile image
Welker Arantes Ferreira

Hi,

I will provide a Github repo as soon as possible

Collapse
 
kingside88 profile image
Kingside88

I did it for you, if you want you can begin there.
But in the example I get the error: window is not defined

Image description
github.com/Kingside88/nuxt3-oidc-c...

Thread Thread
 
taikio profile image
Welker Arantes Ferreira

I published an example project on my Github, you can access it through this link: github.com/taikio/nuxt-3-oidc-auth

Collapse
 
gavinie profile image
GavinIE

Hi Welker, thanks for the article. Did you ever switch to using environment variables? I am struggling to get that working correctly. Thanks

Collapse
 
taikio profile image
Welker Arantes Ferreira

Hi Gavin,

To use environment variables in Nuxt 3 you need to follow these steps:

1 - create an .env file with the environment variables. Ex:

BASE_URL=http://api.identity-server.com
Enter fullscreen mode Exit fullscreen mode

2 - in the nuxt.config file you need to set the values ​​inside the runtimeConfig property. By default these values ​​are only accessible on the server side, if you want to access the values ​​in Vuejs components define them inside the public property.
EX:

export default defineNuxtConfig({
  runtimeConfig: {
    baseUrl: process.env.BASE_URL,
    public: {
      publicValue: ' '
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

3 - In the Vuejs component or in the endpoint access the values ​​through the composable useRuntimeConfig.
Ex:

const config = useRuntimeConfig()
Enter fullscreen mode Exit fullscreen mode

For more detailed information take a look at the official Nuxt documentation:

nuxt.com/docs/api/composables/use-...

Collapse
 
gavinie profile image
GavinIE

Thanks very much. I have it configured like this currently and was just wondering if there was a way to implement this solution without needing to place that information in the public runtimeConfig.

I will keep looking and check in if I find a way to move that config to a more secure place.

Thread Thread
 
taikio profile image
Welker Arantes Ferreira

In my case, to make it safer, I did it like this:

1 - I created an endpoint within the Nuxt project itself and check if there is a Secret Key in the request header:

// /server/api/settings.ts
import { sendError, createError } from "h3";

export default defineEventHandler((event) => {
  const authorizationHeader = getHeader(event, "authorization");

  if (
    !authorizationHeader ||
    authorizationHeader !== "SECRET_KEY"
  ) {
    return sendError(
      event,
      createError({ statusCode: 401, statusMessage: "Missing API key" })
    );
  }

  const runtimeConfig = useRuntimeConfig();

  return runtimeConfig;
});
Enter fullscreen mode Exit fullscreen mode

2 - I created a service to fetch this data. Then I make the request in the application startup middleware and store it in the store:

// /services/settings/index.ts
import { ISettings } from "./types";

export default class SettingsService {
  private apiKey: string;

  constructor() {
    this.apiKey = "SECRET_KEY";
  }

  async get(): Promise<ISettings> {
    const headers = { authorization: this.apiKey };

    const result = await $fetch<ISettings>("/api/settings", {
      method: "get",
      headers,
    });

    return result;
  }
}
Enter fullscreen mode Exit fullscreen mode
// /middleware/auth-middleware.global.ts
export default defineNuxtRouteMiddleware(async (to, _from) => {
    ...
    const settings = await services.$settings.get()
    settingsStore.setSettings(settings)
})
Enter fullscreen mode Exit fullscreen mode