Hilla with Keycloak and the Authorization Code Flow of OAuth 2.0

René Wilby | Feb 13, 2024 min read

Authentication with Hilla

Hilla is a full-stack web framework from Vaadin. In the backend, Spring Boot is used together with Java. In the frontend, TypeScript is used with either React or Lit. To me, Hilla is an excellent choice for the development of business web apps.

Authentication plays a very important role for web apps in a corporate context. Users must authenticate themselves to a web app and then work in the context of certain authorizations, which are usually expressed by roles.

Fortunately, Hilla supports many different authentication mechanisms, as it relies on Spring Security in the backend and provides many useful extensions in the frontend. The official Hilla documentation contains a very comprehensive Guide, which describes very well how to use Spring Security.

In a corporate context, user credentials and data are usually stored in a central identity provider, such as an Active Directory. In my experience, business web apps usually do not have direct access to this identity provider, for example to check the user’s login information directly as part of the authentication process. Instead, many companies rely on authentication procedures based on OAuth 2.0 or OpenID Connect (OIDC).

OAuth 2.0 and the Authorization Code Flow

This article describes how authentication based on the Authorization Code Flow of OAuth 2.0 can be implemented for Hilla. The Authorization Code Flow is very well suited for authenticating users of web apps. An introduction to OAuth 2.0 is not part of this article. I would therefore like to refer to the documentation from auth0.com at this point. It contains a very good overview of OAuth 2.0 and the various flows and grant types. The Authorization Server used in this article is Keycloak. With reference to the following example, which will be developed further on, the Authorization Code Flow in combination with Hilla and Keycloak works as follows:

UUsse(er1r)RequestHilla(W3e)b(-R4Ae)HpdHipiAilrull(etla2cha)ten(Rtt(6(eoi5)8qc))uLaR(eotAe7R(sgeuq)e9tituq)nheAuAosceUurtcsstietehzAsroacsU-rtc-sIiieTenzosorfansk-ot--eIiCTnnooofndko-eeCnKoKedeyeyccllooaakk
  1. A user opens the Hilla web app in the browser via http://localhost:8080.
  2. Hilla makes a request to Keyloak to obtain an authorization code. In this example, Keyloak runs at http://localhost:11111.
GET http://localhost:11111/realms/test/protocol/openid-connect/auth?response_type=code&client_id=hilla&scope=openid&state=<STATE>&redirect_uri=http://localhost:8080/login/oauth2/code/keycloak&nonce=<NONCE>
  1. Keycloak redirects the request so that a Keycloak login page is now displayed in the browser. This is where the user logs in.

Keycloak Login

  1. The login page is sent to Keycloak with the login information. Keycloak authenticates the user.
POST http://localhost:11111/realms/test/login-actions/authenticate?session_code=<SESSION_CODE>&execution=<EXECUTION>&client_id=hilla&tab_id=<TAB_ID>
  username=user
  password=**** 
  1. Keycloak sends an authorization code to Hilla.
GET http://localhost:8080/login/oauth2/code/keycloak?state=<STATE>&session_state=<SESSION_STATE>&iss=http://localhost:11111/realms/test&code=<AUTHORIZATION_CODE>
  1. Hilla requests an access token for this authorization code.
POST http://localhost:11111/realms/test/protocol/openid-connect/token
  grant_type=authorization_code
  code=<AUTHORIZATION_CODE>
  client_id=hilla
  client_secret=****
  redirect_uri=http://localhost:8080/login/oauth2/code/keycloak
  1. Keycloak sends the requested access token to Hilla in the form of a JSON Web Token (JWT). The payload of the JWT contains the following data, among others:
{
  ...
  "exp": 1705659894,
  "iat": 1705659594,
  "auth_time": 1705659567,
  "iss": "http://localhost:11111/realms/test",
  "aud": "account",
  "sub": "cd026591-5cb2-4089-970b-f317153ae6c8",
  "typ": "Bearer",
  "azp": "hilla",
  "scope": "openid profile email"
  ...
}
  1. Hilla calls the user info endpoint of Keycloak. The received access token is included in the HTTP header Authorization: Bearer <ACCESS_TOKEN>.
GET http://localhost:11111/realms/test/protocol/openid-connect/userinfo
  1. Keycloak sends the user information back to Hilla. This user information can look like the following example:
{
  "sub":"cd026591-5cb2-4089-970b-f317153ae6c8",
  "email_verified": true,
  "name": "John Doe",
  "preferred_username": "jd",
  "given_name": "John",
  "family_name": "Doe",
  "email": "john.doe@example.com"
}

The rest of the article describes how to configure Keycloak and Hilla in order to be able to reproduce the Authorization Code Flow shown.

Setting up Keycloak

Starting as a container

For this example, Keycloak is executed locally in a container. This requires an appropriate runtime environment for containers. For example, Docker Desktop or Podman Desktop can be used here.

After the runtime environment has been installed and started, a new container with a running Keycloak instance can be started as follows:

docker|podman run --name keycloak --detach --publish 11111:8080 --env KEYCLOAK_ADMIN=admin --env KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:latest start-dev

The username and password are passed as environment variables. The Keycloak instance can then be accessed via http://localhost:11111/admin.

The following commands can be used to stop and delete the container if required:

docker|podman stop keycloak && docker|podman rm keycloak

Creating a realm

The required configurations, which will be made in Keycloak later on, are made in a realm. It is advisable to create a new realm with the name test. To do this, you must log in to the Keycloak web interface as user admin at http://localhost:11111/admin.

The exact procedure for creating a realm is described in the Keycloak documentation. After the realm has been created, Keycloak automatically switches to the new test realm.

Create a client

Then a new client must be created in the test realm. The name hilla should be used as the client ID. In addition, Client authentication must be activated for this client. This causes Keycloak to create a client secret for this client. The client ID and secret are required later on for the configuration of Spring Security. The Standard flow must be selected as the Authentication flow, as this corresponds to the Authorization Code Flow shown above.

Keycloak - Create client - Authentication

Last but not least, the value http://localhost:8080/login/oauth2/code/keycloak must be entered for Valid redirect URI and the value http://localhost:8080 for Valid post logout redirect URI. This information is also required later for the configuration of Spring Security.

Keycloak - Create client - Redirect

For further details on creating and configuring clients, please refer to the official Keycloak documentation.

Create a user

As described at the beginning, Keycloak is usually linked to an identity provider in a corporate context. To avoid increasing the complexity of the example too much, this is not used here. Instead, a user is created directly in Keycloak in the test realm.

Keycloak - Create user

After the user has been created, credentials are assigned to it. In this example, the value password is used as the password. To save further manual steps, the password is marked as non-temporary.

Keycloak - Create user - Password

Further information on creating and managing users can be found in the Keycloak documentation.

Export a realm

The realm created and the contained client and user can be exported to a JSON file. This way they can be reused later, if required. To do this, execute the following command in the terminal:

docker|podman exec -it keycloak /opt/keycloak/bin/kc.sh export --file /tmp/realm-export.json --realm test --users realm_file
docker|podman cp keycloak:/tmp/realm-export.json /path/to/folder/with/realm-export

The JSON file realm-export.json is then located in the directory /path/to/folder/with/realm-export. If you restart the Keycloak container at a later time, you can include the file via a volume and import it when you start Keycloak:

docker|podman run --name keycloak --detach --publish 11111:8080 --env KEYCLOAK_ADMIN=admin --env KEYCLOAK_ADMIN_PASSWORD=admin --volume /path/to/folder/with/realm-export:/opt/keycloak/data/import quay.io/keycloak/keycloak:latest start-dev --import-realm

Hilla project hilla-oauth-authorization-code-flow-keycloak

Now that the Keycloak instance is available with the required configuration, the next step is to create a new Hilla project. The Vaadin CLI can be used for this purpose:

npx create-vaadin hilla-oauth-authorization-code-flow-keycloak

The next step is to make the necessary extensions to the Hilla backend.

Spring Security configuration

The required dependencies for Spring Security and OAuth 2.0 are added in the pom.xml.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-jose</artifactId>
</dependency>

The next step is to create the configuration for Spring Security. To do this, the file SecurityConfig.java is created first:

import com.vaadin.flow.spring.security.VaadinWebSecurity;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;

@EnableWebSecurity
@Configuration
public class SecurityConfig extends VaadinWebSecurity {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        setOAuth2LoginPage(http, "/oauth2/authorization/keycloak");
        http.logout(logout -> logout.logoutSuccessHandler((new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))));
    }
}

This configuration extends VaadinWebSecurity. This ensures that the required basic configurations for Hilla are set correctly. Further details can be found here. With setOAuth2LoginPage Hilla receives the information about the URL where the login page is available. Finally, the HttpStatusReturningLogoutSuccessHandler ensures that there are no unwanted redirects after logout.

In addition to this configuration, the following information is required in application.properties:

spring.security.oauth2.client.registration.keycloak.client-id=hilla
spring.security.oauth2.client.registration.keycloak.client-secret=<CLIENT_SECRET>
spring.security.oauth2.client.registration.keycloak.scope=openid
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.redirectUri=http://localhost:8080/login/oauth2/code/keycloak

spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:11111/realms/test
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username

At this point, the application can already be started with ./mvnw. If the Hilla web app is opened in the browser at http://localhost:8080, the Authorization Code Flow is executed as described at the beginning of this article.

Extending the frontend

After logging in, it is currently not possible to see which user is logged-in in the Hilla web app. It is also not yet possible to log out. Both will be added next.

UserAuthenticationService

A service that provides the currently logged-in user is created in the Hilla backend. A user is represented by the following simple DTO User.java:

import com.vaadin.hilla.Nonnull;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;

public class User {

    @Nonnull
    private String username;

    @Nonnull
    private String firstname;

    @Nonnull
    private String lastname;

    @Nonnull
    private String email;
    
    public User(OidcUser oidcUser) {
        this.username = oidcUser.getPreferredUsername();
        this.firstname =oidcUser.getGivenName();
        this.lastname = oidcUser.getFamilyName();
        this.email = oidcUser.getEmail();
    }

    // Getter

    // Setter
}

An instance of the class OidcUser is used in the constructor. Spring Security provides us with such an object if the user has been successfully authenticated.

The next step is to implement the service in UserAuthenticationService.java:

import com.example.application.entities.User;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.Optional;

@BrowserCallable
@AnonymousAllowed
public class UserAuthenticationService {

    public Optional<User> getAuthenticatedUser() {
        SecurityContext context = SecurityContextHolder.getContext();
        Object principal = context.getAuthentication().getPrincipal();
        if (principal instanceof OidcUser oidcUser) {
            return Optional.of(new User(oidcUser));
        }
        return Optional.empty();
    }

    public String getLogoutUrl() {
        SecurityContext context = SecurityContextHolder.getContext();
        Object principal = context.getAuthentication().getPrincipal();
        if (principal instanceof OidcUser user) {
            return UriComponentsBuilder
                    .fromUriString(user.getIssuer() + "/protocol/openid-connect/logout")
                    .queryParam("id_token_hint", user.getIdToken().getTokenValue())
                    .queryParam("post_logout_redirect_uri", "http://localhost:8080").toUriString();
        }
        return "/logout";
    }
}

The annotation @BrowserCallable ensures that Hilla automatically generates the required endpoint and the associated client-side TypeScript code. This means that no further programming is required later, e.g. to establish the communication link between the frontend and backend and to serialize or deserialize data. The annotation @AnonymousAllowed ensures that the methods within this service can be called without authentication.

The getAuthenticatedUser method returns the currently logged-in user, which is held in the SecurityContext of Spring Security. If there is no instance of the class OidcUser in the SecurityContext, it means that no successful login via OAuth has taken place, or that the user has already logged out again. In this case, an empty Optional<User> is returned. This signals to the frontend that no user is logged in.

The getLogoutUrl method provides a Keycloak-compliant logout URL. The frontend will request this URL from the backend and use it for the logout via Keycloak.

Configuring authentication in the frontend

An AuthProvider is added to the Hilla frontend, which can retrieve the current authentication information from the backend. To do this, the file auth.ts is created in the src/main/frontend/util directory:

import { configureAuth } from '@vaadin/hilla-react-auth';
import { UserAuthenticationService } from 'Frontend/generated/endpoints';

const auth = configureAuth(UserAuthenticationService.getAuthenticatedUser);

export const useAuth = auth.useAuth;
export const AuthProvider = auth.AuthProvider;

Hilla provides the configureAuth function for this purpose. This function receives the information on the logged-in user from the UserAuthenticationService. The exported AuthProvider is then integrated into the App.tsx:

import router from 'Frontend/routes.js';
import { RouterProvider } from 'react-router-dom';
import { AuthProvider } from 'Frontend/util/auth';

export default function App() {
  return (
    <AuthProvider>
      <RouterProvider router={router} />
    </AuthProvider>
  )
}

This means that the information about the logged-in user is available in React.

Display user and enable logout

The file src/main/frontend/views/MainLayout.tsx can now be extended to display the currently logged-in user and to include a button that can be used to log out:

import { AppLayout } from '@vaadin/react-components/AppLayout.js';
import { Avatar } from '@vaadin/react-components/Avatar.js';
import { Button } from '@vaadin/react-components/Button.js';
import { DrawerToggle } from '@vaadin/react-components/DrawerToggle.js';
import Placeholder from 'Frontend/components/placeholder/Placeholder.js';
import { useRouteMetadata } from 'Frontend/util/routing.js';
import { Suspense, useEffect } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useAuth } from 'Frontend/util/auth.js';
import { UserAuthenticationService } from 'Frontend/generated/endpoints';

const navLinkClasses = ({ isActive }: any) => {
  return `block rounded-m p-s ${isActive ? 'bg-primary-10 text-primary' : 'text-body'}`;
};

export default function MainLayout() {
  const currentTitle = useRouteMetadata()?.title ?? 'My App';
  const { state, logout } = useAuth();
  useEffect(() => {
    document.title = currentTitle;
  }, [currentTitle]);

  return (
    <AppLayout primarySection="drawer">
      <div slot="drawer" className="flex flex-col justify-between h-full p-m">
        <header className="flex flex-col gap-m">
          <h1 className="text-l m-0">My App</h1>
          <nav>
            <NavLink className={navLinkClasses} to="/">
              Hello World
            </NavLink>
            <NavLink className={navLinkClasses} to="/about">
              About
            </NavLink>
          </nav>
        </header>
        <footer className="flex flex-col gap-s">
          {state.user ? (
            <>
              <div className="flex items-center gap-s">
                <Avatar theme="xsmall" name={state.user.username} />
                {state.user.firstname} {state.user.lastname}
              </div>
              <Button onClick={async () => {
                const logoutUrl = await UserAuthenticationService.getLogoutUrl()
                await logout()
                window.location.href = logoutUrl
              }}>Sign out</Button>
            </>
          ) : (
            <a href="/oauth2/authorization/keycloak">Sign in</a>
          )}
        </footer>
      </div>

      <DrawerToggle slot="navbar" aria-label="Menu toggle"></DrawerToggle>
      <h2 slot="navbar" className="text-l m-0">
        {currentTitle}
      </h2>

      <Suspense fallback={<Placeholder />}>
        <Outlet />
      </Suspense>
    </AppLayout>
  );
}

In the footer area of the menu of the Hilla web app, you will now find information about the logged-in user and a button to log out.

User information an logout

Role-based authorization

The authentication shown in the example can be flexibly extended both in the backend and in the frontend. Roles can be defined in Keycloak, which can then also be made available in the information about the logged-in user. This role information can then be taken into account in both the backend and the frontend, for example to make certain services or routes available only to certain roles.

Keycloak extension

Another user (Jane Roe) with a password is created in Keycloak first.

The configuration of the existing client hilla is then extended. The two roles MANAGER and IC are created in the Roles tab.

Keycloak - Enhance client - Roles

The client scope with the name hilla-dedicated must then be edited in the Client scopes tab. The mapper with the name client roles and the type User Client Role is added to the Mappers tab.

Keycloak - Enhance client - Mapper

The mapper must then be adjusted:

  • The value hilla should be selected in the Client ID field. This ensures that only the two roles MANAGER or IC are taken into account.
  • The Client Role Prefix field contains the value ROLE_.
  • In the Token Claim Name field, the value can be shortened to roles. This simplifies further processing in the Hilla backend.
  • The option Add to userinfo must be activated. This means that the claim roles is available in the instance of the class OidcUser in the SecurityContext of Spring Security.

Keycloak - User Client Role

Finally, the user John Doe is assigned the role IC. Jane Roe is assigned the role MANAGER.

Keycloak - Enhance user - Roles

This configuration adjustment means that the assigned role is contained in the roles claim of the access token and in the user info endpoint:

{
  ...
  "roles": [
    "ROLE_IC"
  ],
  "name": "John Doe",
  "preferred_username": "jd",
  "given_name": "John",
  "family_name": "Doe",
  "email": "john.doe@example.com"
}

Backend extension

In the backend, the existing Spring Security configuration in SecurityConfig.java is extended with a GrantedAuthoritiesMapper:

@Bean
GrantedAuthoritiesMapper userAuthoritiesMapper() {
    return (authorities) -> {
        return authorities.stream()
            .filter(authority -> OidcUserAuthority.class.isInstance(authority))
            .map(authority -> {
                OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;
                OidcUserInfo userInfo = oidcUserAuthority.getUserInfo();
                List<String> roles = userInfo.getClaim("roles");
                return roles.stream().map(r -> new SimpleGrantedAuthority(r));
            })
            .reduce(Stream.empty(), (joinedAuthorities, roleAuthorities) -> Stream.concat(joinedAuthorities, roleAuthorities))
            .collect(Collectors.toList());
    };
}

This Mapper ensures that all assigned roles of a user from the claim roles are converted into Spring Security specific authorities, which are then available in the SecurityContext of Spring Security.

The existing role information can now be used in the backend to determine, for example, which users are allowed to access which services. A service that is provided with the annotation @BrowserCallable can also have the annotation @RolesAllowed. This will restrict the frontend’s ability to call the backend to the roles of the currently logged-in user. For example, the class HelloWorldService and the method sayHello contained in it can be modified so that it can only be called by users with the roles ROLE_IC or ROLE_MANAGER:

import com.vaadin.hilla.BrowserCallable;
import jakarta.annotation.security.RolesAllowed;

@BrowserCallable
public class HelloWorldService {

    @RolesAllowed({"ROLE_IC", "ROLE_MANAGER"})
    public String sayHello(String name) {
        if (name.isEmpty()) {
            return "Hello stranger";
        } else {
            return "Hello " + name;
        }
    }
}

The DTO User.java must now be extended by a roles field so that the roles can also be used in the frontend:

import com.vaadin.hilla.Nonnull;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;

import java.util.Collection;

public class User {

    @Nonnull
    private String username;

    @Nonnull
    private String firstname;

    @Nonnull
    private String lastname;

    @Nonnull
    private String email;

    @Nonnull
    private Collection<String> roles;

    public User(OidcUser oidcUser, Collection<String> roles) {
        this.username = oidcUser.getPreferredUsername();
        this.firstname = oidcUser.getGivenName();
        this.lastname = oidcUser.getFamilyName();
        this.email = oidcUser.getEmail();
        this.roles = roles;
    }

    // Getter

    // Setter
}

In UserAuthenticationService.java the method getAuthenticatedUser is extended:

public Optional<User> getAuthenticatedUser() {
    SecurityContext context = SecurityContextHolder.getContext();
    Object principal = context.getAuthentication().getPrincipal();
    if (principal instanceof OidcUser oidcUser) {
        Collection<String> roles = context.getAuthentication().getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
        return Optional.of(new User(oidcUser, roles));
    }
    return Optional.empty();
}

All previously set authorities are read from the SecurityContext of Spring Security and written to the new roles field.

Frontend extension

The Hilla frontend will be extended with three additional views:

  • A ManagerView, which should only be accessible to users with the MANAGER role.
  • An ICView, which should only be accessible to users with the IC role.
  • An AccessDeniedView, which is displayed if access to the requested view or route is not permitted for the current user.

All three views do not contain any additional logic. They only show a text for identification, such as the ICView:

export default function ICView() {
    return (
      <div className="flex flex-col h-full items-center justify-center p-l text-center box-border">
        <h2>Individual contributor view</h2>
        <p>This view is only visible for users with the role 'IC'.</p>
      </div>
    );
  }

The route configuration for the frontend takes place in src/main/frontend/routes.tsx. The route configuration is secured with the help of protectRoutes. The required authentication or authorization is determined for each route:

import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js';
import MainLayout from 'Frontend/views/MainLayout.js';
import { lazy } from 'react';
import { createBrowserRouter, RouteObject } from 'react-router-dom';
import { protectRoutes } from '@vaadin/hilla-react-auth';

const AboutView = lazy(async () => import('Frontend/views/about/AboutView.js'));
const AccessDeniedView = lazy(async () => import('Frontend/views/error/AccessDeniedView.js'));
const ICView = lazy(async () => import('Frontend/views/ic/ICView.js'));
const ManagerView = lazy(async () => import('Frontend/views/manager/ManagerView.js'));

export const routes: RouteObject[] = protectRoutes([
  {
    element: <MainLayout />,
    handle: { title: 'Main' },
    children: [
      { path: '/', element: <HelloWorldView />, handle: { title: 'Hello World', requiresLogin: true } },
      { path: '/about', element: <AboutView />, handle: { title: 'About', requiresLogin: true } },
      { path: '/ic', element: <ICView />, handle: { title: 'IC', rolesAllowed: ['ROLE_IC'] } },
      { path: '/manager', element: <ManagerView />, handle: { title: 'Manager', rolesAllowed: ['ROLE_MANAGER'] } },
      { path: '/access-denied', element: <AccessDeniedView />, handle: { title: 'Access denied' } },
    ],
  },
], "/access-denied");

export default createBrowserRouter(routes);

Finally, the menu is expanded to include the additional entries for the new views. This also depends on the role of the current user. To do this, the file src/main/frontend/views/MainLayout.tsx is edited:

...
export default function MainLayout() {
  ...
  const { state, logout, hasAccess } = useAuth();
  ...
  return (
    ...
        <header className="flex flex-col gap-m">
          <h1 className="text-l m-0">My App</h1>
          <nav>
            <NavLink className={navLinkClasses} to="/">
              Hello World
            </NavLink>
            {hasAccess({ rolesAllowed: ['ROLE_IC'] }) && (
              <NavLink className={navLinkClasses} to="/ic">
                IC
              </NavLink>
            )}
            {hasAccess({ rolesAllowed: ['ROLE_MANAGER'] }) && (
              <NavLink className={navLinkClasses} to="/manager">
                Manager
              </NavLink>
            )}
            <NavLink className={navLinkClasses} to="/about">
              About
            </NavLink>
          </nav>
        </header>    
    ...
  )
}

If you start the application again and log in and out alternately with the two existing users, you can see the differences in the menu. Manual calls to the routes /ic or /manager are redirected to /access-denied if the user currently logged in does not have the appropriate role.

Further information on securing views in the Hilla frontend can be found in the Hilla documentation.

Summary

Hilla is a great full-stack framework for developing business web apps. The authentication and authorization of users can be implemented in the backend using the familiar mechanisms of Spring Security. The use of the OAuth 2.0 Authorization Code Flow in conjunction with Keycloak is quickly implemented and can be extended flexibly to include role-based authorization. In the frontend, Hilla offers many useful extensions to enrich the web app with information about the logged-in user and to display individual views or routes depending on the existing user roles.

The source code for the example is available at GitHub.