Laravel Sanctum with Nuxt and Vuetify SPA

Sanctum allows, among other things, to authenticate an SPA via cookies using the web authentication guard. The following discussion shows how to create a single page application using Nuxt.js and VuetifyJs to connect it (sign up, sign in and logout a user) to a backend API through Laravel Sanctum. Laravel 8 is the version used below.

1. Backend

Below creates a new Laravel project named ‘server’:

composer create-project --prefer-dist laravel/laravel server

Once the database settings are done in server/.env file, migrations can be run:

php artisan migrate

At this point, Laravel Sanctum can be installed and set as per the official documentation:

# Install Sanctum
composer require laravel/sanctum

# Publish its configuration and migration files 
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

# Run Sanctum migrations
php artisan migrate

# Add Sanctum's middleware to API middleware group in server/app/Http/Kernel.php file
'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    'throttle:api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

More serious things start at this point: In routes/api.php, auth:sanctum should be used instead of the default auth:api:

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

The above route api/user is used to request the identity of the currently logged in user, i.d. after the authentication is successful.

In .env these 2 values have to be added:

# Used in config/session.php
SESSION_DOMAIN=localhost

# Used in config/sanctum.php. This should match our client
SANCTUM_STATEFUL_DOMAINS=localhost:3000

Browsers do not allow HTTP requests between different domains. With CORS, a server can agree about which client it can receive requests. This is done in Laravel in config/cors.php file by setting the following key to true:

'supports_credentials' => true,

It would be nice to create a user in the database at this stage. This can be done from the command line quite fast:

php artisan tinker
Psy Shell v0.10.8 (PHP 7.4.22 — cli) by Justin Hileman
>>> $user = new App\Models\User;
=> App\Models\User {#3391}
>>> $user->name = 'begueradj';
=> "begueradj"
>>> $user->email = 'billal@begueradj.com';
=> "begueradj@gmail.com"
>>> $user->password = bcrypt('begueradj');
=> "$2y$10$vXdKSDE9Qud/cy4urMrRieeUED2kNSnmZLGfMx3xZls7.69FtFCUe"
>>> $user->save();
=> true

Next, 2 controllers can be created to login and to register a user:

1.1. LoginController

php artisan make:controller LoginController

The below code is not perfect and does not comply with best practices but the goal is to just make things function:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Auth;

class LoginController extends Controller
{
    public function login(Request $request)
    {
        $request->validate([
            'email' => ['required'],
            'password' => ['required']
        ]);

        if (Auth::attempt($request->only('email', 'password'))) {
            return response()->json(Auth::user(), 200);
        }

        /* throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.']
        ]); */
    }

    public function logout()
    {
        Auth::logout();
    }
}

1.2 RegisterController

It could be done this way:

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Auth;

class RegisterController extends Controller
{
    public function register(Request $request)
    {
        // var_dump($request->name);
        $request->validate([
             'name' => ['required'],
             'email' => ['required', 'email', 'unique:users'],
             'password' => ['required', 'min:8', 'confirmed']
         ]);

         User::create([
             'name' => $request->name,
             'email' => $request->email,
             'password' => Hash::make($request->password)
         ]);
    }
}

The new functions can be reached out through routes/api.php:

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

use App\Http\Controllers\LoginController;
use App\Http\Controllers\RegisterController;

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

Route::post('/login', [LoginController::class, 'login']);
Route::post('/register', [RegisterController::class, 'register']);
Route::post('/logout', [LoginController::class, 'logout']);

2. Frontend

A SPA can be created using Nuxt.js. Below, VuetifyJs as well as axios are going to be used during the creation process of the client project:

yarn create nuxt-app client

The @nuxtjs/auth-next module can be installed with:

yarn add --exact @nuxtjs/auth-next

Both @nuxtjs/auth-next and @nuxtjs/axios should be declared in nuxt.config.js:

modules: [
  '@nuxtjs/axios',
  '@nuxtjs/auth-next'
]

The settings for both modules are as follows:

axios: {
  /**
    When issuing a request to baseURL that needs to pass authentication headers to
    the backend, 'credentials' should be set to 'true'
  */
  credentials: true, // default value of withCredentials is fale
  
  // This is where to hit the server
  baseUrl: 'http://localhost:8000'
},

And:

 1auth: {
 2  redirect: {
 3    login: '/login',
 4    logout: '/',
 5    callback: '/login',
 6    home: '/'
 7  },
 8  strategies: {
 9    laravelSanctum: {
10        provider: 'laravel/sanctum',
11        url: 'http://localhost:8000',
12        endpoints: {
13          login: { url: '/api/login', method: 'post' }
14        },
15        tokenRequired: false,
16        tokenType: false
17      }
18  },
19  localStorage: false
20},

Lines 2 - 7 can be removed as that’s already the default behavior of nuxt auth package.

If the project is mainly admin focused with only few public pages, the authenticaion module can be set globally:

router: {
  middleware: ['auth']
},

And later it can be disabled on indidual public pages (routes, to be exact) as follows:

export default {
  auth: false
}

2.1. Login component and route

Login component can be designed as follows:

 1<template>
 2  <v-container fill-height>
 3    <v-row justify="center" align="center">
 4      <v-col cols="12" sm="6">
 5        <v-form ref="form">
 6          <v-text-field
 7            v-model="form.email"
 8            :counter="10"
 9            label="Email"
10            color="green"
11            required
12          >
13            <v-icon slot="prepend" color="grey">
14              email
15            </v-icon>
16          </v-text-field>
17
18          <v-text-field
19            v-model="form.password"
20            label="Password"
21            type="password"
22            color="green"
23            required
24          >
25            <v-icon slot="prepend" color="grey">
26              lock
27            </v-icon>
28          </v-text-field>
29
30          <v-btn color="blue-grey" class="ml-0" @click="login">
31            Login
32          </v-btn>
33        </v-form>
34      </v-col>
35    </v-row>
36  </v-container>
37</template>
38
39<script>
40export default {
41  name: 'Login',
42  data () {
43    return {
44      form: {
45        email: '',
46        password: ''
47      }
48    }
49  },
50
51  methods: {
52    async login () {
53       // this is managed automatically in the background
54      // await this.$axios.$get('/sanctum/csrf-cookie')
55      try {
56        let response = await this.$auth.loginWith('laravelSanctum', {
57          data: this.form
58        })
59        // console.log(response)
60        this.$router.push('/dashboard')
61      } catch (err) {
62        console.log(err)
63      }
64    }
65  }
66}
67</script>

Hence this component can be used in the login route:

<template>
  <login />
</template>
<script>
import Login from '~/components/authentication/Login.vue'
export default {
  components: {
    Login
  }
}
</script>

The output should look as follows:

file upload

2.2. Register component and route

The component should provide a user interface to populate the user migration file of the backend server:

 1<template>
 2  <v-container fill-height>
 3    <v-row justify="center" align="center">
 4      <v-col cols="12" sm="6">
 5        <v-form ref="form">
 6          <v-text-field
 7            v-model="form.name"
 8            :counter="10"
 9            label="Name"
10            color="green"
11            required
12          >
13            <v-icon slot="prepend" color="grey">
14              account_circle
15            </v-icon>
16          </v-text-field>
17
18          <v-text-field
19            v-model="form.email"
20            :counter="10"
21            label="Email"
22            color="green"
23            required
24          >
25            <v-icon slot="prepend" color="grey">
26              email
27            </v-icon>
28          </v-text-field>
29
30          <v-text-field
31            v-model="form.password"
32            label="Password"
33            type="password"
34            color="green"
35            required
36          >
37            <v-icon slot="prepend" color="grey">
38              lock
39            </v-icon>
40          </v-text-field>
41
42          <v-text-field
43            v-model="form.password_confirmation"
44            label="Confirm password"
45            type="password"
46            color="green"
47            required
48          >
49            <v-icon slot="prepend" color="grey">
50              lock
51            </v-icon>
52          </v-text-field>
53
54          <v-btn color="blue-grey" class="ml-0" @click="login">
55            Register
56          </v-btn>
57        </v-form>
58      </v-col>
59    </v-row>
60  </v-container>
61</template>
62
63<script>
64export default {
65  name: 'Register',
66  data () {
67    return {
68      form: {
69        name: '',
70        email: '',
71        password: '',
72        password_confirmation: ''
73      },
74      errors: {}
75    }
76  },
77
78  methods: {
79    login () {
80      this.$axios
81        .$post('/api/register', this.form)
82        .then(function (response) {
83          console.log(response)
84        })
85        .catch(function (error) {
86          console.log(error)
87        })
88    }
89  }
90}
91</script>

The register route can use the above component:

<template>
  <register />
</template>
<script>
import Register from '~/components/authentication/Register.vue'
export default {
  auth: false,
  components: {
    Register
  }
}
</script>

Hitting the register route would result in this user interface:

file upload

2.3. Authenticated user

For the authenticate user, a Dashboard component could be created.

 1<template>
 2  <v-container fill-height>
 3    <v-row justify="center" align="center">
 4      <v-col cols="12" sm="6">
 5        Hello
 6        <span v-if="user"> {{ user.name }}</span>
 7      </v-col>
 8    </v-row>
 9    <v-row justify="center" align="center">
10      <v-col cols="12" sm="6">
11        <v-btn @click="logout">
12          Logout
13        </v-btn>
14      </v-col>
15    </v-row>
16  </v-container>
17</template>
18
19<script>
20export default {
21  name: 'Welcome',
22  data () {
23    return {
24      user: ''
25    }
26  },
27  created () {
28    this.getAuthenticatedUser()
29  },
30
31  methods: {
32    async getAuthenticatedUser () {
33      console.log('loggedIn : ' + this.$auth.loggedIn)
34      try {
35        let response = await this.$axios.$get('/api/user')
36        this.user = response
37        console.log(response.name)
38      } catch (err) {
39        console.log(err)
40      }
41    },
42    async logout () {
43      console.log('logout')
44      await this.$axios.$post('/api/logout')
45      this.$router.push('/')
46    }
47  }
48}
49</script>

Line 34 requests the /api/user route in server/routes/api.php declared earlier and which is guarded by Sanctum.

This component displays the name of the user who is signed in successfully to the application, and offers a logout button to click and exit.

The corresponding dashboard route can use the above component as follows:

<template>
  <v-container fill-height>
    <v-row justify="center" align="center">
      <v-col cols="12" sm="6">
        <welcome />
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
import Welcome from '~/components/Welcome.vue'
export default {
  name: 'Dashboard',
  components: {
    Welcome
  }
}
</script>