Using WPGraphQL, Nuxt 3 and Apollo with JWT
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:
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
}
})