Using WPGraphQL, Nuxt 3 and Apollo with JWT

/
82

Create your directory structure

Mine will be like this:

wpgraphql-nuxt-apollo-with-jwt/
 ├── app/ (Nuxt) 
 └── wordpress/ (WordPress) 

Easily created (on macOS with zsh)

$ mkdir -p wpgraphql-nuxt-apollo-with-jwt/{app,wordpress}

Set up your WordPress installation and MySQL Database

Check out this post for instructions how how to set this up using the WP-CLI utility.

Installing the required WordPress plugins

You will need the following WordPress plugins:

  1. WPGraphQL
  2. WPGraphQL JWT Authentication

Using WP-CLI to install and activate:

$ wp plugin install https://github.com/wp-graphql/wp-graphql/releases/download/v1.13.8/wp-graphql.zip
$ wp plugin install https://github.com/wp-graphql/wp-graphql-jwt-authentication/archive/refs/tags/v0.6.0.zip
$ wp plugin activate  wp-graphql
$ wp plugin activate  wp-graphql-jwt-authentication

Now you should be able to hit the graphql endpoint and get an error from WPGraphQL:

$ curl -i http://tutorialwp.l0cal/graphql | grep GraphQL

X-GraphQL-URL: http://tutorialwp.l0cal/graphql
X-GraphQL-Query-ID:
X-GraphQL-Keys: graphql:Query

{"errors":[{"message":"GraphQL Request must include at least one of those two parameters: \"query\" or \"queryId\"","extensions":{"category":"request"}}],"extensions":{"debug":[{"type":"DEBUG_LOGS_INACTIVE","message":"GraphQL Debug logging is not active. To see debug logs, GRAPHQL_DEBUG must be enabled."}]}}%

We need to set a secret key for the JWT plugin, I use OpenSSL to generate mine (can be anything you like as long as it's unique), we'll also set GRAPHQL_DEBUG to true

$ wp config set GRAPHQL_JWT_AUTH_SECRET_KEY "`openssl rand -base64 64`"
$ wp config set GRAPHQL_DEBUG true --raw

Setting up the Nuxt application and modules

We'll need to install Nuxt and some other packages (Nuxt Apollo, graphql, Pinia, etc)

Note: Not sure all of these packages are required, I'll go through and clean this up later.

$ cd ..
$ npx nuxi init app
$ cd app
$ npm i --save @nuxtjs/apollo@next @pinia/nuxt @vue/apollo-composable @apollo/client pinia-plugin-persistedstate-2 graphql js-cookie cookie --legacy-peer-deps

You may need to do this:

$ npm i pinia -f

Create our folder structure:

$ mkdir {gql,pages,layouts,composables,components,plugins,store}

We'll set up three plugins, persistedstate.universal.ts, apollo.ts and user.client.ts

This first plugin will allow your pinia stores to persist across reloads:

import {createPersistedStatePlugin} from "pinia-plugin-persistedstate-2"
import Cookies from "js-cookie"
import cookie from "cookie"

export default defineNuxtPlugin(function ({$pinia, ssrContext}) {
  $pinia.use(
    createPersistedStatePlugin({
      storage: {
        getItem: (key) => {
          if (process.server && ssrContext) {
            const parsedCookies = cookie.parse(ssrContext.req.headers.cookie)
            return parsedCookies[key]
          } else {
            return Cookies.get(key)
          }
        },
        setItem: (key, value) => {
          Cookies.set(key, value, {expires: 365, secure: false})
        },
        removeItem: (key) => Cookies.remove(key),
      }
    })
  )
})

This plugin catches the apollo:auth hook and checks your token expiry, and will try to refresh automatically if needed. It is also responsible for inserting your Authorization header for subsequent requests so that you can access the WPGraphQL endpoint as the user you've logged in as.

import {useUserStore} from "~/store/user"
import {parseJwt} from "~/src/StringUtils"
import refreshToken from "~/gql/refreshJwtAuthToken.gql"
import {_AsyncData} from "#app/composables/asyncData"

export default defineNuxtPlugin((nuxtApp) => {
  let refreshingToken = false
  const userStore = useUserStore()

  const tryRefresh = async () => {
    if (!refreshingToken && userStore?.refreshToken) {
      const expiryDate = new Date(parseJwt(userStore.refreshToken).exp * 1000)
      const beforeExpiry = new Date(expiryDate.getTime() - 30 * 1000)
      const shouldRenew = new Date().getTime() > beforeExpiry.getTime()
      if (shouldRenew) {
        console.log("expired! renewing...")
        refreshingToken = true
        await useAsyncQuery<any>(refreshToken, {jwtRefreshToken: userStore.refreshToken})
          .then((result: _AsyncData<any, Error>) => {
              if (result.error.value) {
                const message = result.error.value?.message
                console.error(message)
              } else if (result?.data) {
                const data = result.data.value.refreshJwtAuthToken
                userStore.update({
                  authToken: data.authToken,
                  refreshToken: data.refreshToken,
                })
              }
            }
          )
      }
    }
  }

  nuxtApp.hook("apollo:auth", async ({client, token}) => {
    await tryRefresh()
    token.value = userStore.authToken || null
  })
})

This plugin provides some user functionality to the app (as app.$user)

import {useUserStore} from "~/store/user"
import {defineNuxtPlugin, useNuxtApp, useState} from "#app"
import {_AsyncData} from "#app/composables/asyncData"
// @ts-ignore
import userLogin from "~/gql/userLogin.gql"


export default defineNuxtPlugin(function () {

  const user = {
    ...useUserStore(),
    doLogin: async (username: string, password: string) => {

      const app = useNuxtApp()
      const errorMessage = useState("errorMessage")
      const userStore = useUserStore()

      await useAsyncQuery<any>(userLogin, {username, password})
        .then((result: _AsyncData<any, Error>) => {
            if (result.error.value) {
              const message = result.error.value?.message
              if (message === "incorrect_password") {
                errorMessage.value = "Sorry, please try again."
              } else if (message === "empty_username") {
                errorMessage.value = "You must specify a username."
              } else if (message === "invalid_username") {
                errorMessage.value = "Please check your details and try again."
              } else {
                errorMessage.value = message
              }
            } else if (result?.data) {
              const data = result.data.value.login
              userStore.update({
                username: data.user.username,
                name: data.user.name,
                userId: data.user.userId,
                authToken: data.authToken,
                refreshToken: data.authToken,
                email: data.user.email,
                loggedIn: true
              })

              userStore.loggedIn = true
              app.$router.push("/profile")
            }
          }
        )
    }
  }

  return {
    provide: {
      user
    }
  }
})

Next we'll update our nuxt.config.ts with some configuration for apollo and our plugins / modules.

export default defineNuxtConfig({
    build: {
        transpile: [
            "graphql",
            "@apollo/client/core",
            "@vue/apollo-composable"
        ],
    },
    modules: [
        "@nuxtjs/apollo",
        "@pinia/nuxt",
    ],
    plugins: [
        "~/plugins/persistedstate.universal.ts",
        "~/plugins/user.client.ts"
    ],
    runtimeConfig: {
        graphqlApiEndpoint: "/graphql"
    },
    vite: {
        // Set up vite to hit the WPGraphQL endpoint using a proxy, to avoid CORS issues 
        server: {
            proxy: {
                "/graphql": "http://tutorialwp.l0cal",
            }
        }
    },
    // Note: Apollo Configuration is not yet set up to extend NuxtConfig
    // @ts-ignore
    apollo: {
        autoImports: true,
        authType: "Bearer",
        authHeader: "Authorization",
        tokenStorage: "cookie",
        proxyCookies: true,
        clients: {
            default: {
                httpEndpoint: "/graphql",
                httpLinkOptions: {
                    httpOnly: false
                }
            },
        },
        cookieAttributes: {
            maxAge: 60 * 60 * 24
        },
    },
})

Add GraphQL Queries/Mutations

mutation login($username: String!, $password: String!) {
  login(input: {username: $username, password: $password}) {
    authToken
    refreshToken
    user { userId name username email }
  }
}
mutation registerUser(
    $username: String!, 
    $lastName: String, 
    $firstName: String, 
    $email: String!, 
    $password: String!
    ) {
        registerUser(
            input: {
            username: $username, 
            email: $email, 
            lastName: $lastName,  
            firstName: $firstName, 
            password: $password
           }) {
             user {
                id
                }
            }
}
mutation refreshJwtAuthToken($jwtRefreshToken: String!) {
  refreshJwtAuthToken(input: {jwtRefreshToken: $jwtRefreshToken}) {
    authToken
  }
}
query viewer {
  jwtRefreshToken
}
query viewer {
    firstName
    email
    lastName
    name
    nicename
    nickname
    registeredDate
    username
}

Create our app.vue and default layout

<template>
  <div>
    <NuxtLayout />
  </div>
</template>
<style>
body {
    font-family: Helvetica, Arial, sans;
}
</style>
<template>
  <div>
    <NuxtLayout />
  </div>
</template>
<style>
body {
    font-family: Helvetica, Arial, sans;
}
</style>

Create our pages

<template>
  <Register/>
</template>
<script setup lang="ts">
import Register from "~/components/forms/Register.vue"
</script>
<template>
  <LoginForm/>
</template>
<script setup lang="ts">
import LoginForm from "~/components/forms/Login.vue"
</script>
<template>
  <LoginForm/>
</template>
<script setup lang="ts">
import LoginForm from "~/components/forms/Login.vue"
</script>
<template>
  <LoginForm/>
</template>
<script setup lang="ts">
import LoginForm from "~/components/forms/Login.vue"
</script>
<template>
    <h1>Profile</h1>
    <ClientOnly>
        <template v-if="profile.registeredDate">
            <span v-if="profile.registeredDate">Registered: {{ registeredDate }}</span>
            <div>
                <label for="firstName">First Name</label>
                <input id="firstName" v-model="profile.firstName" aria-label="First Name" type="text"/>
            </div>
            <div>
                <label for="lastName">Last Name</label>
                <input v-model="profile.lastName" aria-label="Last Name" type="text"/>
            </div>
            <div>
                <label for="firstName">Email</label>
                <input v-model="profile.email" aria-label="Email" type="text"/>
            </div>
            <div>
                <label for="username">Username</label>
                <input v-model="profile.username" aria-label="Last Name" type="text"/>
            </div>
        </template>
    </ClientOnly>
</template>

<script setup>
import {useUserStore} from "~/store/user"
import userInfo from "~/gql/userInfo.gql"
import {useNuxtApp} from "#app"

const userStore = useUserStore()
const {$router} = useNuxtApp()
let profile = ref({}),
    results = false,
    domain = userStore?.domain || "Not Configured",
    loading = ref(true),
    error = false,
    registeredDate = computed(() => new Date(profile.value.registeredDate).toLocaleDateString())


onMounted(async () => {
    if (userStore.loggedIn) {
        await useAsyncQuery(userInfo).then(async (result) => {
            await result.execute()
            if (result.error.value) {
                error = result.error.value
            } else {
                profile.value = result?.data.value.viewer
            }
            loading.value = false
        }).catch(error => console.error)
    } else {
        $router.push("/login")
    }
})
</script>
<style lang="scss" scoped>
.loading {
  display: block;
  width: 100%;
  padding: 4rem;
  text-align: center;
}

input {
  display: block;

  &:before {
    content: attr(label);
    display: block;
  }
}

div {
  margin-top: 1rem;
}
</style>

And now some components

<template>
    <nav>
        <nuxt-link to="/">Home</nuxt-link>

        <ClientOnly>
            <nuxt-link v-if="!userStore.loggedIn" to="/login">Login</nuxt-link>
            <nuxt-link v-else @click="userStore.logout">Logout</nuxt-link>
        </ClientOnly>
    </nav>
</template>

<script setup>
import {useUserStore} from "~/store/user"

const userStore = useUserStore()
</script>

<style lang="scss" scoped>
nav {
  background-color: #ccc;
  padding: 0.8rem 1rem;
  border-radius: 0.25rem;
  display: flex;
  gap: 1rem;
  margin-bottom: 2rem;
}
</style>
<template>
    <template v-if="!userStore.loggedIn || !userStore.username">
        <form action="#" @submit.prevent="doRegister">
            <div>
                <label for="username">Username</label>
                <input id="username" v-model="form.username" data-lpignore="true" name="username" type="text">
            </div>
            <div>
                <label for="password">Email</label>
                <input id="email" v-model="form.email" data-lpignore="true" name="email" type="email">
            </div>
            <div>
                <label for="password">Password</label>
                <input id="password" v-model="form.password" data-lpignore="true" name="password" type="password">
            </div>
            <div>
                <label for="password">First Name</label>
                <input id="firstName" v-model="form.firstName" data-lpignore="true" name="firstName" type="text">
            </div>
            <div>
                <label for="password">Last Name</label>
                <input id="lastName" v-model="form.lastName" data-lpignore="true" name="lastName" type="text">
            </div>
            <input type="submit" value="Register">
        </form>
        <div class="errorMessage">{{ errorMessage }}</div>
    </template>
</template>
<script lang="ts" setup>
import { useNuxtApp, useState } from "#app"
import { useUserStore } from "~/store/user"

const errorMessage = useState("errorMessage")
const userStore = useUserStore()
const {$user} = useNuxtApp()

const form = ref({
    username: "",
    password: "",
    firstName: "",
    lastName: "",
    email: ""
})

async function doRegister() {
    const {username, password, firstName, lastName, email} = form.value
    await $user.register(username, password, firstName, lastName, email)
}

</script>
<style lang="scss" scoped>
form {
  display: flex;
  gap: 1rem;
  flex-direction: column;
  max-width: 320px;
  justify-content: center;
  margin: 0 auto;

  > div {
    display: flex;
    gap: .5rem;

    input {
      width: 100%;
    }
  }

}
</style>

<template>
    <template v-if="!userStore.loggedIn || !userStore.username">
        <form action="#" @submit.prevent="doLogin">
            <div>
                <label for="username">Username</label>
                <input id="username" v-model="form.username" data-lpignore="true" name="username" type="text">
            </div>
            <div>
                <label for="password">Password</label>
                <input id="password" v-model="form.password" data-lpignore="true" name="password" type="password">
            </div>
            <input type="submit" value="Login">
        </form>
        <div class="errorMessage">{{ errorMessage }}</div>
    </template>
    <div v-else>
        Logged in as {{ userStore.username }}, proceed to
        <nuxt-link to="/urls">view your URLs</nuxt-link>
    </div>
</template>
<script>
import {useNuxtApp, useState} from "#app"
import {useUserStore} from "~/store/user"


export default {
    name: "LoginForm",
    setup() {
        return {
            errorMessage: useState('errorMessage'),
            userStore: useUserStore(),
            app: useNuxtApp()
        }
    },
    data() {
        return {
            form: {
                username: "",
                password: "",
            }
        }
    },
    mounted() {

    },
    methods: {
        async doLogin() {
            const {username, password} = this.form
            let result = await this.$user.doLogin(username, password)
        }
    }
}

</script>
<style lang="scss" scoped>
form {
  display: flex;
  gap: 1rem;
  flex-direction: column;
  max-width: 320px;
  justify-content: center;
  margin: 0 auto;

  > div {
    display: flex;
    gap: .5rem;

    input {
      width: 100%;
    }
  }

}
</style>

And a composable for handling JWT Refresh Token parsing

import { UserInformation } from "~/store/user"
import { UnwrapRef } from "vue"
import { _ExtractStateFromSetupStore } from "pinia"

export function parseJwt(token: UnwrapRef<_ExtractStateFromSetupStore<{
    logout: () => void;
    domain?: Ref<string>;
    authToken?: Ref<string>;
    loggedIn?: Ref<boolean>;
    name?: Ref<string>;
    update: (values: UserInformation) => void;
    userId?: Ref<number>;
    email?: Ref<string>;
    username?: Ref<string>;
    refreshToken?: Ref<string>
}>["refreshToken"]> | undefined) {
    if(!token) return "";
    var base64Url = token.split(".")[1]
    var base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/")
    var jsonPayload = decodeURIComponent(window.atob(base64).split("").map(function (c) {
        return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)
    }).join(""))

    return JSON.parse(jsonPayload)
}

And finally our pinia store

import { Ref, ref } from "vue"
import { defineStore } from "pinia"

export interface UserInformationRefs {
    userId?: Ref<number>
    username?: Ref<string>
    name?: Ref<string>
    firstName?: Ref<string>
    lastName?: Ref<string>
    email?: Ref<string>
    domain?: Ref<string>
    authToken?: Ref<string>
    refreshToken?: Ref<string>
    loggedIn?: Ref<boolean>
}

export interface UserInformation {
    userId?: number
    username?: string
    name?: string
    firstName?: string
    lastName?: string
    email?: string
    domain?: string
    authToken?: string
    refreshToken?: string
    loggedIn?: boolean
}


export const useUserStore = defineStore("user", () => {

    const app = useNuxtApp()
    const userId = ref(0)
    const username = ref("")
    const name = ref("")
    const firstName = ref("")
    const lastName = ref("")
    const email = ref("")
    const domain = ref("")
    const authToken = ref("")
    const refreshToken = ref("")
    const loggedIn = ref(false)

    const userInfo: UserInformationRefs = {
        authToken,
        refreshToken,
        userId,
        username,
        firstName,
        lastName,
        domain,
        name,
        email,
        loggedIn
    }

    function logout() {
        update({
            userId: undefined,
            username: undefined,
            name: undefined,
            firstName: undefined,
            lastName: undefined,
            email: undefined,
            domain: undefined,
            authToken: undefined,
            refreshToken: undefined,
            loggedIn: false
        })
        app.$router.push("/login")

    }

    function update(values: UserInformation) {
        Object.keys(values).forEach(function (key) {
            if (key in userInfo) { // or obj1.hasOwnProperty(key)
                // @ts-ignore
                userInfo[key].value = values[key]
            }
        })
    }

    return {
        update,
        logout,
        ...userInfo
    }
})

TOY MODE
π