Skip to content

Latest commit

 

History

History
1098 lines (951 loc) · 26.9 KB

File metadata and controls

1098 lines (951 loc) · 26.9 KB

Build a CRUD App with Spring Boot and Vue

Today, I’ll show you how to build Spring Boot and Vue CRUD app and secure it all with Auth0.

The Vue frontend client will use the Quasar framework for the presentation. OAuth 2.0 and OpenID Connect (OIDC) will secure the Spring Boot API and the Vue client with Auth0 as the security provider.

Check the description below this video for links to the blog post and this demo script.

Prerequisites:

  • Java 17: I recommend using SDKMAN! to manage and install multiple versions

  • Auth0 CLI: the Auth0 command-line interface

  • HTTPie: a simple tool for making HTTP requests from a terminal

  • Node 16+

  • Vue CLI: to bootstrap the Vue client

Tip

The brackets at the end of some steps indicate the IntelliJ Live Templates to use. You can find the template definitions at mraible/idea-live-templates.

You can also expand the file names to see the full code.

Fast Track: Clone the repo and follow the instructions in its README to configure everything.

Create an OpenID Connect app

  1. Open a terminal and create a parent directory for your project.

    take spring-boot-vue-crud
  2. Create an OIDC application using the Auth0 CLI.

    auth0 apps create
    • Name: vue-spring-boot

    • Type: Single Page Web Application

    • All the URLs: http://localhost:8080

  3. Create a .env file. Fill in the OIDC Client ID and Auth0 domain.

    VUE_APP_CLIENT_ID=<your-client-id>
    VUE_APP_AUTH0_DOMAIN=<your-auth0-domain>
    VUE_APP_AUTH0_AUDIENCE=http://my-api
    VUE_APP_SERVER_URI=http://localhost:9000
  4. Create a test Auth0 API. The Auth0 API is what exposes identity functionality for all authentication and authorization protocols, such as OpenID Connect and OAuth.

    auth0 apis create -n myapi --identifier http://my-api

Bootstrap a Spring Boot app using Spring Initializr

  1. Use the Spring Initializr to create a starter project for the resource server.

    curl https://start.spring.io/starter.tgz \
      -d bootVersion=3.0.2 \
      -d javaVersion=17 \
      -d dependencies=web,data-rest,lombok,data-jpa,h2,oauth2-resource-server \
      -d type=gradle-project \
      -d baseDir=resource-server \
    | tar -xzvf - && cd resource-server

Configure Spring Security

  1. You can run the bootstrapped project right now and see if it starts. It should start but won’t do much.

    ./gradlew bootRun
  2. Create a SecurityConfiguration class to configure Spring Security. [sbv-security-config]

    SecurityConfiguration.java
    package com.example.demo;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.web.SecurityFilterChain;
    
    @Configuration
    public class SecurityConfiguration {
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http.authorizeHttpRequests()
                .anyRequest().permitAll();
            return http.build();
        }
    }
  3. Replace the DemoApplication.java file with the following. [sbv-demo]

    DemoApplication.java
    package com.example.demo;
    
    import org.springframework.boot.ApplicationRunner;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.boot.web.servlet.FilterRegistrationBean;
    import org.springframework.context.annotation.Bean;
    import org.springframework.core.Ordered;
    import org.springframework.data.rest.core.config.RepositoryRestConfiguration;
    import org.springframework.data.rest.webmvc.config.RepositoryRestConfigurer;
    import org.springframework.stereotype.Component;
    import org.springframework.web.cors.CorsConfiguration;
    import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
    import org.springframework.web.filter.CorsFilter;
    import org.springframework.web.servlet.config.annotation.CorsRegistry;
    
    import java.util.Collections;
    import java.util.Random;
    import java.util.stream.Stream;
    
    @SpringBootApplication
    public class DemoApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(DemoApplication.class, args);
        }
    
        // Bootstrap some test data into the in-memory database
        @Bean
        ApplicationRunner init(TodoRepository repository) {
            return args -> {
                Random rd = new Random();
                Stream.of("Buy milk", "Eat pizza", "Update tutorial", "Study Vue", "Go kayaking").forEach(name -> {
                    Todo todo = new Todo();
                    todo.setTitle(name);
                    todo.setCompleted(rd.nextBoolean());
                    repository.save(todo);
                });
                repository.findAll().forEach(System.out::println);
            };
        }
    
        // Fix the CORS errors
        @Bean
        public FilterRegistrationBean simpleCorsFilter() {
            UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
            CorsConfiguration config = new CorsConfiguration();
            config.setAllowCredentials(true);
            // *** URL below needs to match the Vue client URL and port ***
            config.setAllowedOrigins(Collections.singletonList("http://localhost:8080"));
            config.setAllowedMethods(Collections.singletonList("*"));
            config.setAllowedHeaders(Collections.singletonList("*"));
            source.registerCorsConfiguration("/**", config);
            FilterRegistrationBean bean = new FilterRegistrationBean<>(new CorsFilter(source));
            bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
            return bean;
        }
    
        // Expose IDs of Todo items
        @Component
        class RestRepositoryConfigurator implements RepositoryRestConfigurer {
            public void configureRepositoryRestConfiguration(RepositoryRestConfiguration config, CorsRegistry cors) {
                config.exposeIdsFor(Todo.class);
            }
        }
    }
  4. Create a Todo.java class to hold data. [sbv-todo]

    Todo.java

    +

    package com.example.demo;
    
    import lombok.*;
    
    import jakarta.persistence.Id;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.Entity;
    
    @Entity
    @Data
    @NoArgsConstructor
    public class Todo {
    
        @Id
        @GeneratedValue
        private Long id;
    
        @NonNull
        private String title;
    
        private Boolean completed = false;
    }
  5. Create a TodoRepository interface to persist the data model.

    package com.example.demo;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    import org.springframework.data.rest.core.annotation.RepositoryRestResource;
    
    @RepositoryRestResource
    interface TodoRepository extends JpaRepository<Todo, Long> {}

Test your Spring Boot API

  1. Run the app using Gradle from the resource-server subdirectory.

    ./gradlew bootRun
  2. Open a new shell and use HTTPie to test the resource server.

    http :8080/todos
  3. You should see a response like the following:

    HTTP/1.1 200
    ...
    
    {
      "_embedded": {
        "todos": [
          {
            "_links": {
              "self": {
                "href": "http://localhost:9000/todos/1"
              },
              "todo": {
                "href": "http://localhost:9000/todos/1"
              }
            },
            "completed": false,
            "id": 1,
            "title": "Buy milk"
          },
          {
            "_links": {
              "self": {
                "href": "http://localhost:9000/todos/2"
              },
              "todo": {
                "href": "http://localhost:9000/todos/2"
              }
            },
            "completed": true,
            "id": 2,
            "title": "Eat pizza"
          },
          ...
        ]
      },
      ...
    }
  4. Stop the resource server using CTRL + C.

Secure your Spring Boot API

  1. Edit the SecurityConfiguration.java file and change the filter chain’s bean definition to enable a resource server.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
            .anyRequest().authenticated()
            .and()
            .oauth2ResourceServer().jwt();
        return http.build();
    }
  2. Add a JWT decoder bean that does audience validation. [sbv-decoder]

    SecurityConfiguration.java
    @Value("${auth0.audience}")
    private String audience;
    
    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    private String issuer;
    
    @Bean
    JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder = JwtDecoders.fromOidcIssuerLocation(issuer);
    
        OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(audience);
        OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
        OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);
    
        jwtDecoder.setJwtValidator(withAudience);
    
        return jwtDecoder;
    }
  3. Create an AudienceValidator in the same package to validate JWTs. [sbv-validator]

    AudienceValidator.java
    package com.example.demo;
    
    import org.springframework.security.oauth2.core.OAuth2Error;
    import org.springframework.security.oauth2.core.OAuth2TokenValidator;
    import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
    import org.springframework.security.oauth2.jwt.Jwt;
    
    class AudienceValidator implements OAuth2TokenValidator<Jwt> {
        private final String audience;
    
        AudienceValidator(String audience) {
            this.audience = audience;
        }
    
        public OAuth2TokenValidatorResult validate(Jwt jwt) {
            OAuth2Error error = new OAuth2Error("invalid_token", "The required audience is missing", null);
    
            if (jwt.getAudience().contains(audience)) {
                return OAuth2TokenValidatorResult.success();
            }
            return OAuth2TokenValidatorResult.failure(error);
        }
    }
  4. Open the application.properties properties file and update it. The server port is changed so it doesn’t conflict with the default Vue local server (which also defaults 8080).

    server.port=9000
    auth0.audience=http://my-api
    spring.security.oauth2.resourceserver.jwt.issuer-uri=https://<your-auth0-domain>/
  5. Restart the server. Use CTRL + C to stop it if it’s running.

    ./gradlew bootRun
  6. Use HTTPie again to try and request the todo items.

    http :9000/todos

    You will get an error.

    HTTP/1.1 401
    ...
    
    401 Unauthorized

    The resource server is finished. The next step is to create the Vue client.

Create a Vue JavaScript client

  1. Install the Vue CLI if you don’t have it installed with npm i -g @vue/cli@5.

    vue create client

    Pick Default ([Vue 3] babel, eslint) when prompted. Wait for it to finish.

    cd client
  2. Add the Quasar framework.

    vue add quasar

    You can just accept the defaults.

    • Allow Quasar to replace App.vue, About.vue, Home.vue and (if available) router.js? Yes

    • Pick your favorite CSS preprocessor: Sass with indented syntax

    • Choose Quasar Icon Set: Material Icons (recommended)

    • Default Quasar language pack: en-US

    • Use RTL support? No

    • Select features: Enter to select none

  3. Add additional dependencies for HTTP requests, logging, routing, and authentication.

  4. Move the .env file you created earlier to the client directory.

    mv ../.env .
  5. Update src/main.js. This configures and installs the Auth0 plugin for Vue. [sbv-main]

    main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    import { Quasar } from 'quasar'
    import quasarUserOptions from './quasar-user-options'
    import VueLogger from 'vuejs3-logger'
    import router from './router'
    import createApi from './Api'
    
    import { createAuth0 } from '@auth0/auth0-vue';
    
    const options = {
      isEnabled: true,
      logLevel: 'debug',
      stringifyArguments: false,
      showLogLevel: true,
      showMethodName: false,
      separator: '|',
      showConsoleColors: true
    };
    
    const app = createApp(App)
      .use(Quasar, quasarUserOptions)
      .use(VueLogger, options)
      .use(router)
      .use(createAuth0({
          domain: process.env.VUE_APP_AUTH0_DOMAIN,
          clientId: process.env.VUE_APP_CLIENT_ID,
          authorizationParams: {
            redirect_uri: window.location.origin,
            audience: process.env.VUE_APP_AUTH0_AUDIENCE
          }
        })
      );
    
    // pass auth0 to the api (to get a JWT), which is set as a global property
    app.config.globalProperties.$api = createApi(app.config.globalProperties.$auth0)
    
    app.mount('#app')
  6. Replace App.vue with the following. [sbv-app]

    App.vue
    <template>
      <q-layout view="hHh lpR fFf">
    
        <q-header elevated class="bg-primary text-white">
          <q-toolbar>
            <q-toolbar-title>
              <q-avatar>
                <q-icon name="kayaking" size="30px"></q-icon>
              </q-avatar>
              Todo App
            </q-toolbar-title>
            {{ isAuthenticated ? user.email : "" }}
            <q-btn flat round dense icon="logout" v-if='isAuthenticated' @click="logout"/>
            <q-btn flat round dense icon="account_circle" v-else @click="login"/>
          </q-toolbar>
        </q-header>
    
        <q-page-container>
          <router-view></router-view>
        </q-page-container>
    
      </q-layout>
    </template>
    
    <script>
    
    import { useAuth0 } from '@auth0/auth0-vue';
    
    export default {
      setup() {
    
        const { loginWithRedirect, user, isAuthenticated, logout } = useAuth0();
    
        return {
          login: () => {
            loginWithRedirect();
          },
          logout: () => {
            logout({ returnTo: window.location.origin });
          },
          user,
          isAuthenticated
        };
      }
    }
    </script>
  7. Create a new src/Api.js file to encapsulate the resource server access logic. [sbv-api]

    Api.js
    import axios from 'axios'
    
    const instance = axios.create({
      baseURL: process.env.VUE_APP_SERVER_URI,
      timeout: 2000
    });
    
    const createApi = (auth) => {
    
      instance.interceptors.request.use(async function (config) {
        const accessToken = await auth.getAccessTokenSilently();
        config.headers = {
          Authorization: `Bearer ${accessToken}`
        }
        return config;
      }, function (error) {
        return Promise.reject(error);
      });
    
      return {
    
        // (C)reate
        createNew(text, completed) {
          return instance.post('/todos', {title: text, completed: completed})
        },
    
        // (R)ead
        getAll() {
          return instance.get('/todos', {
            transformResponse: [function (data) {
              return data ? JSON.parse(data)._embedded.todos : data;
            }]
          })
        },
    
        // (U)pdate
        updateForId(id, text, completed) {
          return instance.put('todos/' + id, {title: text, completed: completed})
        },
    
        // (D)elete
        removeForId(id) {
          return instance.delete('todos/' + id)
        }
      }
    }
    
    export default createApi
  8. Create a router file at src/router/index.js. [sbv-router]

    index.js
    import { createRouter, createWebHistory } from 'vue-router'
    import Todos from '@/components/Todos';
    import Home from '@/components/Home';
    
    const routes = [
      {
        path: '/',
        component: Home
      },
      {
        path: '/todos',
        component: Todos,
        meta: {
          requiresAuth: true
        }
      },
    ]
    
    const router = createRouter({
      history: createWebHistory(process.env.BASE_URL),
      routes,
    })
    
    export default router
  9. Create a src/components/Home.vue component. [sbv-home]

    Home.vue
    <template>
      <div class="column justify-center items-center" id="row-container">
        <q-card class="my-card">
          <q-card-section style="text-align: center">
            <div v-if='isAuthenticated'>
              <h6>You are logged in as {{user.email}}</h6>
              <q-btn flat color="primary" @click="todo">Go to Todo app</q-btn>
              <q-btn flat @click="logout">Log out</q-btn>
            </div>
            <div v-else>
              <h6>Please <a href="#" @click.prevent="login">log in</a> to access Todo app</h6>
            </div>
          </q-card-section>
        </q-card>
      </div>
    </template>
    
    <script>
    
    import { useAuth0 } from '@auth0/auth0-vue';
    import { useRouter } from 'vue-router'
    
    export default {
      name: 'HomeComponent',
      setup() {
    
        const { loginWithRedirect, user, isAuthenticated, logout } = useAuth0();
        const router = useRouter()
    
        return {
          login: () => {
            loginWithRedirect();
          },
          logout: () => {
            logout({ returnTo: window.location.origin });
          },
          todo() {
            router.push('/todos')
          },
          user,
          isAuthenticated
        };
      }
    }
    </script>
  10. Create a TodoItem component. [sbv-todo-item]

    TodoItem.vue
    <template>
      <q-item-section avatar class="check-icon" v-if="this.item.completed">
        <q-icon color="green" name="done" @click="handleClickSetCompleted(false)"/>
      </q-item-section>
      <q-item-section avatar class="check-icon" v-else>
        <q-icon color="gray" name="check_box_outline_blank" @click="handleClickSetCompleted(true)"/>
      </q-item-section>
      <q-item-section v-if="!editing">{{ this.item.title }}</q-item-section>
      <q-item-section v-else>
        <input
            class="list-item-input"
            type="text"
            name="textinput"
            ref="input"
            v-model="editingTitle"
            @change="handleDoneEditing"
            @blur="handleCancelEditing"
        />
      </q-item-section>
      <q-item-section avatar class="hide-icon" @click="handleClickEdit">
        <q-icon color="primary" name="edit"/>
      </q-item-section>
      <q-item-section avatar class="hide-icon close-icon" @click="handleClickDelete">
        <q-icon color="red" name="close"/>
      </q-item-section>
    </template>
    <script>
    
    import { nextTick } from 'vue'
    
    export default {
      name: 'TodoItem',
      props: {
        item: Object,
        deleteMe: Function,
        showError: Function,
        setCompleted: Function,
        setTitle: Function
      },
      data: function () {
        return {
          editing: false,
          editingTitle: this.item.title,
        }
      },
      methods: {
        handleClickEdit() {
          this.editing = true
          this.editingTitle = this.item.title
          nextTick(function () {
            this.$refs.input.focus()
          }.bind(this))
        },
        handleCancelEditing() {
          this.editing = false
        },
        handleDoneEditing() {
          this.editing = false
          this.$api.updateForId(this.item.id, this.editingTitle, this.item.completed).then((response) => {
            this.setTitle(this.item.id, this.editingTitle)
            this.$log.info('Item updated:', response.data);
          }).catch((error) => {
            this.showError('Failed to update todo title')
            this.$log.debug(error)
          });
        },
        handleClickSetCompleted(value) {
          this.$api.updateForId(this.item.id, this.item.title, value).then((response) => {
            this.setCompleted(this.item.id, value)
            this.$log.info('Item updated:', response.data);
          }).catch((error) => {
            this.showError('Failed to update todo completed status')
            this.$log.debug(error)
          });
        },
        handleClickDelete() {
          this.deleteMe(this.item.id)
        }
      }
    }
    </script>
    
    <style scoped>
    .todo-item .close-icon {
      min-width: 0px;
      padding-left: 5px !important;
    }
    
    .todo-item .hide-icon {
      opacity: 0.1;
    }
    
    .todo-item:hover .hide-icon {
      opacity: 0.8;
    }
    
    .check-icon {
      min-width: 0px;
      padding-right: 5px !important;
    }
    
    input.list-item-input {
      border: none;
    }
    </style>
  11. Create a Todos component. [sbv-todos]

    Todos.vue
    <template>
      <div class="column justify-center items-center" id="row-container">
        <q-card class="my-card">
          <q-card-section>
            <div class="text-h4">Todos</div>
            <q-list padding>
              <q-item
                  v-for="item in filteredTodos" :key="item.id"
                  clickable
                  v-ripple
                  rounded
                  class="todo-item"
              >
                <TodoItem
                    :item="item"
                    :deleteMe="handleClickDelete"
                    :showError="handleShowError"
                    :setCompleted="handleSetCompleted"
                    :setTitle="handleSetTitle"
                    v-if="filter === 'all' || (filter === 'incomplete' && !item.completed) || (filter === 'complete' && item.completed)"
                ></TodoItem>
              </q-item>
            </q-list>
          </q-card-section>
          <q-card-section>
            <q-item>
              <q-item-section avatar class="add-item-icon">
                <q-icon color="green" name="add_circle_outline"/>
              </q-item-section>
              <q-item-section>
                <input
                    type="text"
                    ref="newTodoInput"
                    v-model="newTodoTitle"
                    @change="handleDoneEditingNewTodo"
                    @blur="handleCancelEditingNewTodo"
                />
              </q-item-section>
            </q-item>
          </q-card-section>
          <q-card-section style="text-align: center">
            <q-btn color="amber" text-color="black" label="Remove Completed" style="margin-right: 10px"
                   @click="handleDeleteCompleted"></q-btn>
            <q-btn-group>
              <q-btn glossy :color="filter === 'all' ? 'primary' : 'white'" text-color="black" label="All"
                     @click="handleSetFilter('all')"/>
              <q-btn glossy :color="filter === 'complete' ? 'primary' : 'white'" text-color="black" label="Completed"
                     @click="handleSetFilter('complete')"/>
              <q-btn glossy :color="filter === 'incomplete' ? 'primary' : 'white'" text-color="black" label="Incomplete"
                     @click="handleSetFilter('incomplete')"/>
              <q-tooltip>
                Filter the todos
              </q-tooltip>
            </q-btn-group>
          </q-card-section>
        </q-card>
        <div v-if="error" class="error">
          <q-banner inline-actions class="text-white bg-red" @click="handleErrorClick">
            ERROR: {{ this.error }}
          </q-banner>
        </div>
      </div>
    </template>
    
    <script>
    
    import TodoItem from '@/components/TodoItem';
    import { ref } from 'vue'
    
    export default {
      name: 'LayoutDefault',
      components: {
        TodoItem
      },
    
      data: function() {
        return {
          todos: [],
          newTodoTitle: '',
          visibility: 'all',
          loading: true,
          error: "",
          filter: "all"
        }
      },
    
      setup() {
        return {
          alert: ref(false),
        }
      },
      mounted() {
        this.$api.getAll()
            .then(response => {
              this.$log.debug("Data loaded: ", response.data)
              this.todos = response.data
            })
            .catch(error => {
              this.$log.debug(error)
              this.error = "Failed to load todos"
            })
            .finally(() => this.loading = false)
      },
    
      computed: {
        filteredTodos() {
          if (this.filter === 'all') return this.todos
          else if (this.filter === 'complete') return this.todos.filter(todo => todo.completed)
          else if (this.filter === 'incomplete') return this.todos.filter(todo => !todo.completed)
          else return []
        }
      },
    
      methods: {
    
        handleSetFilter(value) {
          this.filter = value
        },
    
        handleClickDelete(id) {
          const todoToRemove = this.todos.find(todo => todo.id === id)
          this.$api.removeForId(id).then(() => {
            this.$log.debug("Item removed:", todoToRemove);
            this.todos.splice(this.todos.indexOf(todoToRemove), 1)
          }).catch((error) => {
            this.$log.debug(error);
            this.error = "Failed to remove todo"
          });
        },
    
        handleDeleteCompleted() {
          const completed = this.todos.filter(todo => todo.completed)
          Promise.all(completed.map(todoToRemove => {
            return this.$api.removeForId(todoToRemove.id).then(() => {
              this.$log.debug("Item removed:", todoToRemove);
              this.todos.splice(this.todos.indexOf(todoToRemove), 1)
            }).catch((error) => {
              this.$log.debug(error);
              this.error = "Failed to remove todo"
              return error
            })
          }))
        },
    
        handleDoneEditingNewTodo() {
          const value = this.newTodoTitle && this.newTodoTitle.trim()
          if (!value) {
            return
          }
          this.$api.createNew(value, false).then((response) => {
            this.$log.debug("New item created:", response)
            this.newTodoTitle = ""
            this.todos.push({
              id: response.data.id,
              title: value,
              completed: false
            })
            this.$refs.newTodoInput.blur()
          }).catch((error) => {
            this.$log.debug(error);
            this.error = "Failed to add todo"
          });
        },
        handleCancelEditingNewTodo() {
          this.newTodoTitle = ""
        },
    
        handleSetCompleted(id, value) {
          let todo = this.todos.find(todo => id === todo.id)
          todo.completed = value
        },
    
        handleSetTitle(id, value) {
          let todo = this.todos.find(todo => id === todo.id)
          todo.title = value
        },
    
        handleShowError(message) {
          this.error = message
        },
    
        handleErrorClick() {
          this.error = null;
        },
      },
    }
    </script>
    
    <style>
    #row-container {
      margin-top: 100px;
    }
    
    .my-card {
      min-width: 600px;
    }
    
    .error {
      color: red;
      text-align: center;
      min-width: 600px;
      margin-top: 10px;
    }
    </style>

Confirm your Spring Boot and Vue todo app works

  1. Make sure the Spring Boot API is still running. If not, start it again.

    ./gradlew bootRun
  2. Start the Vue app using the embedded development server. From the client directory:

    npm run serve
  3. Open a browser and navigate to http://localhost:8080. Log into the app using Auth0.

  4. You should be able to delete items, add new items, rename, and filter items. All data is stored on the Spring Boot resource server and is presented by the Vue + Quasar frontend.

Test your API with an Access Token

  1. Use the Auth0 CLI to create a token.

    auth0 test token -a http://my-api
  2. Save the token in a shell variable.

    TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im5yMWZw...
  3. Verify that the endpoint is protected.

    http :9000/todos
  4. Test the protected endpoint using the token.

    http :9000/todos "Authorization: Bearer $TOKEN"

Giddyup with Spring Boot, Vue, and Auth0!

I hope you enjoyed this demo, and it helped you learn how you can integrate Vue with Spring Boot.

💡️ Find the code on GitHub: @oktadev/okta-spring-boot-vue-crud-example