Nuxt 3 JWT authentication using $fetch and Pinia
Asked Answered
R

5

32

I'm trying to do a JWT authentication to a distinct API.

As @nuxtjs/auth-next doesn't seem to be up to date and as I read it was possible to use the new global method fetch in Nuxt 3 instead of @nuxtjs/axios (not up to date also), I thought it won't be too hard to code the authentication myself! But it stays a mystery to me and I only found documentation on Vue project (using Pinia to keep user logged in) and I'm a bit at a lost.

What I would like to achieve:

  • a login page with email and password, login request send to API (edit: done!)
  • get JWT token and user info from API (edit: done!) and store both (to keep user logged even if a page is refresh)
  • set the JWT token globally to header $fetch requests (?) so I don't have to add it to each request
  • don't allow access to other pages if user is not logged in

Any help on this?

Here is my login.vue page (I'll have to use Vuetify and vee-validate after that but again one step at a time!)

// pages/login.vue
<script setup lang="ts">
import { useAuthStore } from "~/store/auth";

const authStore = useAuthStore();

interface loginForm {
  email: string;
  password: string;
}

let loginForm: loginForm = {
  email: "",
  password: "",
};

function login() {
  authStore.login(loginForm);
}
</script>

<template>
  <v-container>
    <form @submit.prevent="login">
      <label>E-mail</label>
      <input v-model="loginForm.email" required type="email" />
      <label>Password</label>
      <input v-model="loginForm.password" required type="password" />
      <button type="submit">Login</button>
    </form>
  </v-container>
</template>

The store/auth.ts for now.

// store/auth.ts
import { defineStore } from 'pinia'
import { encodeURL } from '~~/services/utils/functions'

export const useAuthStore = defineStore({
  id: 'auth,
  state: () => ({
    // TODO Initialize state from local storage to enable user to stay logged in
    user: '',
    token: '',
  })
  actions: {
    async login(loginForm) {
      const URL_ENCODED_FORM = encodeURL({
        email: loginForm.email,
        password: loginForm.password,
      });
      return await $fetch('api_route', {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded"
        },
        method: 'POST',
        body: URL_ENCODED_FORM
      }
    } 
  }
})
Radloff answered 18/8, 2022 at 6:54 Comment(0)
R
13

With the help of @Nais_One I managed to do a manual authentication to a third-party API with Nuxt 3 app using client-side rendering (ssr: false, target: 'static' in nuxt.config.ts)

I still have to set the API URL somewhere else and to handle JWT token refresh but the authentication works, as well as getting data from a protected API route with the token in header and redirection when user is not logged.

Here are my finals files:

// pages/login.vue
<script setup lang="ts">
import { useAuthStore } from "~/store/auth";

const authStore = useAuthStore();
const router = useRouter();

interface loginForm {
  email: string;
  password: string;
}

let loginForm: loginForm = {
  email: "",
  password: "",
};

/**
 * If success: redirect to home page
 * Else display alert error
 */
function login() {
  authStore
    .login(loginForm)
    .then((_response) => router.push("/"))
    .catch((error) => console.log("API error", error));
}
</script>

<template>
  <v-container>
    <form @submit.prevent="login">
      <label>E-mail</label>
      <input v-model="loginForm.email" required type="email" />
      <label>Password</label>
      <input v-model="loginForm.password" required type="password" />
      <button type="submit">Login</button>
    </form>
  </v-container>
</template>

For the auth store:

// store/auth.ts
import { defineStore } from 'pinia'

const baseUrl = 'API_URL'

export const useAuthStore = defineStore({
  id: 'auth',
  state: () => ({
    /* Initialize state from local storage to enable user to stay logged in */
    user: JSON.parse(localStorage.getItem('user')),
    token: JSON.parse(localStorage.getItem('token')),
  }),
  actions: {
    async login(loginForm) {
      await $fetch(`${baseUrl}/login`, {
        method: 'POST',
        body: loginForm
      })
        .then(response => {
          /* Update Pinia state */
          this.user = response
          this.token = this.user.jwt_token
          /* Store user in local storage to keep them logged in between page refreshes */
          localStorage.setItem('user', JSON.stringify(this.user))
          localStorage.setItem('token', JSON.stringify(this.token))
        })
        .catch(error => { throw error })
    },
    logout() {
      this.user = null
      this.token = null
      localStorage.removeItem('user')
      localStorage.removeItem('token')
    }
  }
})

I also use the middleware/auth.global.ts proposed by Nais_One.

And this fetch-wrapper exemple I found here as well to avoid having to add token to every requests: https://jasonwatmore.com/post/2022/05/26/vue-3-pinia-jwt-authentication-tutorial-example and it seems to work perfectly. (I just didn't test yet the handleResponse() method).

Hope it can help others :)

Radloff answered 23/8, 2022 at 6:53 Comment(0)
A
22

i'm gonna share everything, even the parts you marked as done, for completeness sake.

Firstly, you will need something to generate a JWT in the backend, you can do that plainly without any packages, but i would recommend this package for that. Also i'll use objection.js for querying the database, should be easy to understand even if you don't know objection.js

Your login view needs to send a request for the login attempt like this

const token = await $fetch('/api/login', {
    method: 'post',
    body: {
      username: this.username,
      password: this.password,
    },
  });

in my case it requests login.post.ts in /server/api/

import jwt from 'jsonwebtoken';
import { User } from '../models';

export default defineEventHandler(async (event) => {
  const body = await useBody(event);
  const { id } = await User.query().findOne('username', body.username);
  const token: string = await jwt.sign({ id }, 'mysecrettoken');
  return token;
});

For the sake of simplicity i didn't query for a password here, this depends on how you generate a user password. 'mysecrettoken' is a token that your users should never get to know, because they could login as everybody else. of course this string can be any string you want, the longer the better.

now your user gets a token as the response, should just be a simple string. i'll write later on how to save this one for future requests.

To make authenticated requests with this token you will need to do requests like this:

$fetch('/api/getauthuser', {
            method: 'post',
            headers: {
              authentication: myJsonWebToken,
            },
          });

i prefer to add a middleware for accessing the authenticated user in my api endpoints easier. this middleware is named setAuth.ts and is inside the server/middleware folder. it looks like this:

import jwt from 'jsonwebtoken';

    export default defineEventHandler(async (event) => {
      if (event.req.headers.authentication) {
        event.context.auth = { id: await jwt.verify(event.req.headers.authentication, 'mysecrettoken').id };
      }
    });

What this does is verify that if an authentication header was passed, it checks if the token is valid (with the same secret token you signed the jwt with) and if it is valid, add the userId to the request context for easier endpoint access.

now, in my server/api/getauthuser.ts endpoint in can get the auth user like this

import { User } from '../models';

export default defineEventHandler(async (event) => {
  return await User.query().findById(event.context.auth.id)
});

since users can't set the requests context, you can be sure your middleware set this auth.id

you have your basic authentication now.

The token we generated has unlimited lifetime, this might not be a good idea. if this token gets exposed to other people, they have your login indefinitely, explaining further would be out of the scope of this answer tho.

you can save your auth token in the localStorage to access it again on the next pageload. some people consider this a bad practice and prefer cookies to store this. i'll keep it simple and use the localStorage tho.

now for the part that users shouldnt access pages other than login: i set a global middleware in middleware/auth.global.ts (you can also do one that isnt global and specify it for specific pages) auth.global.ts looks like this:

import { useAuthStore } from '../stores';

export default defineNuxtRouteMiddleware(async (to) => {
  const authStore = useAuthStore();

  if (to.name !== 'Login' && !localStorage.getItem('auth-token')) {
    return navigateTo('/login');
  } else if (to.name !== 'Login' && !authStore.user) {
    authStore.setAuthUser(await $fetch('/api/getauthuser', {
      headers: authHeader,
    }));
  }
});

I'm using pinia to store the auth user in my authStore, but only if the localstorage has an auth-token (jwt) in it. if it has one and it hasnt been fetched yet, fetch the auth user through the getauthuser endpoint. if it doesnt have an authtoken and the page is not the login page, redirect the user to it

Aqueous answered 18/8, 2022 at 15:54 Comment(9)
Thx a lot @Aqueous for taking the time to share! Really appreciated it. Should have told you I don't code the API, it's a distinct one but your code is really helpful anyway. I have more questions if you don't mind :) : in your middleware you use localStorage, but first I have to set the user and its token in the localStorage on login, how do you achieve that? If I use localStorage in my authStore, I have a 500 error localStorage is undefined, reckon it's coz it tried to access it on server side rendering. Don't get how I'm suppose to handle this, does it work right away in your middleware?Radloff
@Radloff it does work right away for me, probably because i have an SPA. i use ssr: false, in the nuxt config, that probably makes the difference. you need to use $fetch instead of useFetch if you make a SPAAqueous
Also dont confuse /middleware with /server/middleware. you want the first oneAqueous
Big thanks @Nais_One! You made me think and I actually don't need SSR as it's a back-office with login and I don't care about SEO. So I don't have the answer for accessing localStorage using SSR but I did as you said and everything works! I reckon I would have manage to get there on my own but speaking with you and watch your code helped a lot!!! I added a comment with a link on your SOF question, I speak of it again in my answer bellow for the fetch-wrapper, I reckon it will help you handle the token in one place and don't have to add it to each request.Radloff
@Aqueous The other way around, right? I think the idea is to add it to the context server side, so a middleware under the /server/middleware would be the correct locationMillstone
You could simply store the JWT in a httponly cookie by using setCookie provided by the Nuxt api. For example setCookie(event, 'token', jwt, { httpOnly: true }). This would have to be done on the server sideMillstone
you manage the jwt creation in the server side ? or in third part ? because i was looking for a tutorial with authentication only between client and server side on nuxtjs...@Aqueous ? bus as i don't find i used a spring bootFreelance
what's authHeader in headers: authHeader??Chancellorship
@Chancellorship authHeader is just the variable containing the JWT. its a composable to i dont need to type it out everytimeAqueous
R
13

With the help of @Nais_One I managed to do a manual authentication to a third-party API with Nuxt 3 app using client-side rendering (ssr: false, target: 'static' in nuxt.config.ts)

I still have to set the API URL somewhere else and to handle JWT token refresh but the authentication works, as well as getting data from a protected API route with the token in header and redirection when user is not logged.

Here are my finals files:

// pages/login.vue
<script setup lang="ts">
import { useAuthStore } from "~/store/auth";

const authStore = useAuthStore();
const router = useRouter();

interface loginForm {
  email: string;
  password: string;
}

let loginForm: loginForm = {
  email: "",
  password: "",
};

/**
 * If success: redirect to home page
 * Else display alert error
 */
function login() {
  authStore
    .login(loginForm)
    .then((_response) => router.push("/"))
    .catch((error) => console.log("API error", error));
}
</script>

<template>
  <v-container>
    <form @submit.prevent="login">
      <label>E-mail</label>
      <input v-model="loginForm.email" required type="email" />
      <label>Password</label>
      <input v-model="loginForm.password" required type="password" />
      <button type="submit">Login</button>
    </form>
  </v-container>
</template>

For the auth store:

// store/auth.ts
import { defineStore } from 'pinia'

const baseUrl = 'API_URL'

export const useAuthStore = defineStore({
  id: 'auth',
  state: () => ({
    /* Initialize state from local storage to enable user to stay logged in */
    user: JSON.parse(localStorage.getItem('user')),
    token: JSON.parse(localStorage.getItem('token')),
  }),
  actions: {
    async login(loginForm) {
      await $fetch(`${baseUrl}/login`, {
        method: 'POST',
        body: loginForm
      })
        .then(response => {
          /* Update Pinia state */
          this.user = response
          this.token = this.user.jwt_token
          /* Store user in local storage to keep them logged in between page refreshes */
          localStorage.setItem('user', JSON.stringify(this.user))
          localStorage.setItem('token', JSON.stringify(this.token))
        })
        .catch(error => { throw error })
    },
    logout() {
      this.user = null
      this.token = null
      localStorage.removeItem('user')
      localStorage.removeItem('token')
    }
  }
})

I also use the middleware/auth.global.ts proposed by Nais_One.

And this fetch-wrapper exemple I found here as well to avoid having to add token to every requests: https://jasonwatmore.com/post/2022/05/26/vue-3-pinia-jwt-authentication-tutorial-example and it seems to work perfectly. (I just didn't test yet the handleResponse() method).

Hope it can help others :)

Radloff answered 23/8, 2022 at 6:53 Comment(0)
C
3

Recently a new package was released that wraps NextAuth for Nuxt3. This means that it already supports many providers out of the box and may be a good alternative to look into.

You can install it via:

npm i -D @sidebase/nuxt-auth

Then it is pretty simple to add to your projects as you only need to include the module:

export default defineNuxtConfig({
  modules: ['@sidebase/nuxt-auth'],
})

And configure at least one provider (like this example with Github):

import GithubProvider from 'next-auth/providers/github'

export default defineNuxtConfig({
  modules: ['@sidebase/nuxt-auth'],
  auth: {
    nextAuth: {
      options: {
        providers: [GithubProvider({ clientId: 'enter-your-client-id-here', clientSecret: 'enter-your-client-secret-here' })]
      }
    }
  }
})

Afterwards you can then get access to all the user data and signin/signup functions!

If you want to have a look at how this package can be used in a "real world" example, look at the demo repo in which it has been fully integrated:

https://github.com/sidebase/nuxt-auth-example

Copper answered 4/11, 2022 at 10:57 Comment(1)
How do you get the active session's JWT though with this?Frasch
C
0

Stumbling on the same issue for a personal project and what I do is declare a composable importing my authStore which is basically a wrapper over $fetch Still a newb on Nuxt3 and Vue but it seems to work fine on development, still have to try and deploy it though

import { useAuthStore } from "../store/useAuthStore";

export const authFetch = (url: string, opts?: any | undefined | null) => {
  const { jwt } = useAuthStore();
  return $fetch(url, {
    ...(opts ? opts : {}),
    headers: {
      Authorization:`Bearer ${jwt}`,
    },
  });
};

And then I can just use it in my actions or components

// @/store/myStore.ts
export const useMyStore = defineStore('myStore', () => {
   async getSomething() {
      ...
      return authFetch('/api/something')
   }
})
// @components/myComponent.vue
...
<script setup lang="ts">
const handleSomething = () => {
   ...
   authFetch('/api/something')
}
</script>
Coastward answered 25/9, 2022 at 8:19 Comment(0)
D
0

i know it's late and you solved it yourself, but me and my team developed a library for the exact use case you described (we use Laravel as a backend).

Here is the github: nuxt-jwt-auth

With this library, you can define your api endpoint and route endpoints to redirect after login/logout/signup/kickout.

The token received from the backend after a successful login is stored in a cookie, making it available for both server and client side code and persistent when reloading the page.

See the readme for full documentation.

Probably it will need more work, but we plan to keep mantaining it, at least until an official/unofficial nuxt3 library will provide the same functionality.

Disjunction answered 22/6, 2023 at 9:7 Comment(4)
Seems a bit like a marketing effortFrieze
Do i gain something if someone uses my library? Nah. I'm just here to give valuable advice. Since the accepted answer implies having to write all the logic in your project, which is not very mantainable, i figured i'd propose something that already covers the use case and is much more simple to implement. Thank you for your commentDisjunction
Hi @DavideCattani , This is exactly the lib I was looking for! Thank you! Is it up to date ? I see no update since 3 months.Crapulent
We are currently using it for every nuxt project at my company, so yes we'll keep mantaining it in the upcoming future. If you use it and find an issue/bug please open one in github and we'll take a look.Disjunction

© 2022 - 2024 — McMap. All rights reserved.