Implementing OpenID Connect (OIDC) Authentication with Nuxt 3
Welker Arantes Ferreira
Posted on January 30, 2023
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
Then access the project folder and install the dependencies with the command:
npm install
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>
In addition to creating the pages, in App.vue we need to use the NuxtPage component like this:
<template>
<div>
<NuxtPage />
</div>
</template>
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
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',
}
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()
}
}
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(),
}
}
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>
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>
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>
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
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"
}
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'],
})
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))
}
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)
}
})
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
}
}
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
)
};
};
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()
And thus we come to the end of this tutorial, I hope you enjoyed ;)
Edited
If interested, you can access the Github repository
Posted on January 30, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.