OAuth 2 with Spring, Angular, Keycloak – Spring for Resource Server

1. Intro

This article covers the integration of OAuth2 into a Single Page Application (SPA) where Spring is the backend and Angular is the frontend. Keycloak, the common choice in the Java ecosystem, will take over the role of the Authorization server.

The reader is not required to know about OAuth2 but should be familiar with Spring and Angular.

OAuth2 is a de facto standard on how to authorize users. Bluntly put, authorization means that the application knows what the user is allowed to do and what not.

To know with whom the application is communicating, the user authenticates themselves.

From the specification perspective, it is a wrong statement, but in practice, we use OAuth2 quite often for both authentication and authorization.

This complete walkthrough will explain the setup of Keykloak and the necessary implementation in Spring and Angular.

The OAuth2 standard provides different constellations depending on the context. For example, mobile applications require a different setup than a Micro-Services architecture.

We will follow the current best practices as of OAuth 2.1, which is still in a draft version at the time of this writing.

2. OAuth 2 in a Nutshell

OAuth 2 differentiates between different roles. In our use case, Spring takes on the Resource Server role. The main OAuth 2 communication happens between Angular and Keycloak. Angular's OAuth 2 role name is "Client" and Keycloak is the "Authentication Server".

As "Client", Angular's task is to initiate the authorization. It sends the end user to Keycloak and includes some metadata so that Keycloak knows from which client the user came.

Keycloak provides a login screen to the user where they type in their credentials. This has the nice side effect, that neither Angular nor Spring need to know or store the credentials. Also, the user must consent to allowing the client to act on their behalf.

After a bit of "chit-chat" between Angular and Keyckloak, the Angular client finally receives an access token that allows to talk with the resource server on behalf of the user. However, the Acess Token does not inform the client about the user.

For this reason, OpenId Connect (OIDC) was defined. It runs on top of OAuth 2, and Keycloak provides the client with user information by also issung an Id Token. It's a JSON Web Token (JWT) that contains so called "claims".

That "chit-chat" is everything else casual but highly standardized and has security concerns as a top priority. As daunting as it might be, never implement this flow on your own, but use - as in this article - the authorization server's built-in library or an OAuth2-validated generic library like https://github.com/manfredsteyer/angular-oauth2-oidc.

In a further article, we discuss how to increase security by using OAuth 2, OIDC and the issued tokes only at the server-side behind a Gateway.

Whenever Angular sends an HTTP request to Spring, it adds the JWT to the HTTP header. Spring parses the JWT and maps it into Spring Security. Spring has to be aware and needs to trust the Keycloak server. Therefore, Spring fetches the public key or keyset from Keycloak and uses them to verify that the signature of the JWT is valid. As it is common in asymmetric encryption, Keycloak signs every JWT with its private key.

3. Keycloak Setup

3.1. Realms

The fastest way to run Keycloak is to use Docker. This would be the content for docker-compose.yml, which starts Keycloak with an admin user eternal and password eternal123.

oauth2:
  image: quay.io/keycloak/keycloak:22.0.5
  command:
    - start-dev
  environment:
    - KEYCLOAK_ADMIN=eternal
    - KEYCLOAK_ADMIN_PASSWORD=eternal123
  ports:
    - "8081:8080"

An alternative is to start it directly from the command line:

docker run -it -p "8081:8080" -e 'KEYCLOAK_ADMIN=eternal' -e 'KEYCLOAK_ADMIN_PASSWORD=eternal123' quay.io/keycloak/keycloak:21.0.2 start-dev

Navigate to http://localhost:8081/admin. You should see Keycloak's login screen. Enter eternal as username and eternal123 as password. This should lead you to the Administration UI.

Keycloak Login Screen

Keycloak Login

Admin Screen

Keycloak Admin Screen

The dropdown in the top left shows a list of existing realms. You see that as isolated instances of Keycloak with their own users, claims, etc.

If you are familiar with databases, it is the same concept. You run one Oracle, SQL Server, etc., and can set up multiple databases.

Click on the Realm's dropdown and create a new realm named "eternal."

3.2. Client Setup

To create a new client, select "Clients" from the menu. The Client Type should show "OpenID Connect" as the default value in the form. That's what we need.

The second mandatory field is the client ID. You can choose whatever you find fit. In our use case, we decided to use "eternal."

Client Setup 1

Client Setup 1

In the second step, we need to provide detailed information. We disable "Client authentication." This is an OIDC setting under the name "public access type." That means that as the client and running in a browser, Angular can directly communicate with Keycloak.

"Direct access grants" is disabled. This setting would allow Angular to ask for the username and password and send it to Keycloak. Angular should never possess these credentials. That is also in accordance with the "OAuth2 Best Practices".

We can leave the rest of the form fields as they are. So, there is no "Implicit Flow" or "OAuth 2.0 Device Authorization grant". These are settings which increase the risk of a security breach.

Client Setup 2

Client Setup 2

Next, we define the URL of Angular. Since Angular sends the user to Keycloak and Keycloak should send the user back to Angular, we will use "http://localhost:4200/" (mind the ending slash) for both "Valid redirect URIs" and "Web origins."

Client Setup 3

Client Setup 3

3.3. User Setup

We create two users:

  • John List: An authenticated customer who can view holidays.
  • Lucy Sanders: An administrator who can edit holidays.

Create User

Create User

We also create two client roles, i.e., they only apply to our Eternal application.

These are:

  • view-holidays
  • admin-holidays

Navigate via "Clients" -> Select "eternal" -> "Roles" -> "Create role".

Create Role

Create Role

Since we don't want to assign these roles directly to our users, we create two groups:

  • customer
  • admin

Navigate via "Groups" -> "Create Group".

Create Group

Create Group

To assign the client roles to groups

Finally, we assign the user "John List" to the group "customer", and "Lucy Sanders" to "admin".

Navigate via "Groups" -> Select "customer" -> "Role Mapping" -> "Assign Role" -> Select "Filter by clients" instead "Filter by realm roles" -> Search for "eternal".

Assign Role

Assign Role

4. Angular as OAuth2 Client

Angular should connect to Keycloak. We install the official Keycloak library for Javascript via

npm i keycloak-js@22.0.5

It is crucial that the version of the npm packages matches one of the server instances (see docker-compose.yml).

Specific community-based libraries for Angular are available as well. Since only a few lines of TypeScript are necessary, we stay with the JavaScript version.

4.1. KeycloakService: Wrapping "keycloak-js"

We create a KeycloakService which acts as a wrapper:

import { Injectable } from "@angular/core";
import Keycloak from "keycloak-js";

export interface UserProfile {
  sub: string;
  email: string;
  given_name: string;
  family_name: string;
  token: string;
}

@Injectable({ providedIn: "root" })
export class KeycloakService {
  _keycloak: Keycloak | undefined;
  profile: UserProfile | undefined;

  get keycloak() {
    if (!this._keycloak) {
      this._keycloak = new Keycloak({
        url: "http://localhost:8081",
        realm: "eternal",
        clientId: "eternal",
      });
    }
    return this._keycloak;
  }

  async init() {
    const authenticated = await this.keycloak.init({
      onLoad: "check-sso",
      silentCheckSsoRedirectUri:
        window.location.origin + "/assets/silent-check-sso.html",
    });

    if (!authenticated) {
      return authenticated;
    }
    this.profile =
      (await this.keycloak.loadUserInfo()) as unknown as UserProfile;
    this.profile.token = this.keycloak.token || "";

    return true;
  }

  login() {
    return this.keycloak.login();
  }

  logout() {
    return this.keycloak.logout({ redirectUri: "http://localhost:8081" });
  }
}

KeycloakService exposes an instance of keycloak. As soon as there is access to that property, the service generates the instance and stores it as Singleton. To instantiate, we need to provide the values for the realm, the ID of the client, and Keycloak's URL.

The first access to the keycloak property happens in the KeycloakService::init. First, there runs a check if the user is already signed in from a former session. Second, we need to define an HTML file for the silent SSO. That one is required to automatically re-login, once the current JWT is about to expire.

The re-login would require forwarding to Keycloak. "keycloak-js" runs this redirection inside the iframe, so the user is unaware of it. The user doesn't need to enter their credentials because Keycloak adds an HTTP-only cookie, which allows it to recognize the user.

silent-check-sso.html is very small:

<html>
  <body>
    <script>
      parent.postMessage(location.href, location.origin);
    </script>
  </body>
</html>

KeycloakService::init also sets the user's profile. Due to OIDC, the fields are standardized. We need to add the JWT as an HTTP header whenever we send a request to Spring. Because of that, we add the JWT itself to the profile.

Finally, KeycloakService exposes methods for the login and logout.

For simplicity, we didn't load the credentials from an Angular environment file. In real life, this is exactly what we should do.

4.2: KeycloakStore - Stateful service.

The next step is to use the KeycloakService inside a stateful service. We want to have Signals that change whenever the user is logged in or logged out.

@Injectable({ providedIn: "root" })
export class SecurityStore {
  #keycloakService = inject(KeycloakService);

  loaded = signal(false);
  user = signal<User | undefined>(undefined);

  loadedUser = computed(() => (this.loaded() ? this.user() : undefined));
  signedIn = computed(() => this.loaded() && !this.user()?.anonymous);

  constructor() {
    this.onInit();
  }

  async onInit() {
    const isServer = isPlatformServer(inject(PLATFORM_ID));
    const keycloakService = inject(KeycloakService);
    if (isServer) {
      this.user.set(ANONYMOUS_USER);
      this.loaded.set(true);
      return;
    }

    const isLoggedIn = await keycloakService.init();
    if (isLoggedIn && keycloakService.profile) {
      const { sub, email, given_name, family_name, token } =
        keycloakService.profile;
      const user = {
        id: sub,
        email,
        name: `${given_name} ${family_name}`,
        anonymous: false,
        bearer: token,
      };
      this.user.set(user);
      this.loaded.set(true);
    } else {
      this.user.set(ANONYMOUS_USER);
      this.loaded.set(true);
    }
  }

  async signIn() {
    await this.#keycloakService.login();
  }

  async signOut() {
    await this.#keycloakService.logout();
  }
}

SecurityStore uses the KeycloakService as its proxy for the rest of the application.

As such, it also can change some terms to fit the application's domain model. For example, "login" becomes "signIn", the same with "logout". SecurityStore also maps the OIDC properties to User with slightly different names.

Additionally, it provides loadedUser and signedId, which are computed Signals.

This version already deals with Server-Side Rendinger (SSR). If the application runs on a server, getting credentials from the user is impossible. So, if it runs on the server, we return to the default ANONYMOUS_USER.

securityInterceptor: JWT as HTTP Header

To include the JWT in every request, we use an interceptor function.

export const securityInterceptor: HttpInterceptorFn = (req, next) => {
  const keycloakService = inject(SecurityStore);

  const bearer = keycloakService.user()?.bearer;

  if (!bearer) {
    return next(req);
  }

  return next(
    req.clone({
      headers: req.headers.set("Authorization", `Bearer ${bearer}`),
    })
  );
};

securityInterceptor is straightforward. It checks if user of SecurityStore has a valid JWT (bearer property) and, if yes, adds that one as a header to the current request.

For larger applications that communicate with different endpoints, one might also check for the domain itself before sending everyone the JWT.

Login Button: Bringing it all together

Our application shows the login button in its header. The HeaderComponent injects the SecurityStore and comes with the following template:

<div class="security">
  @if (user(); as userValue) { @if (userValue.anonymous) {
  <button
    data-testid="btn-sign-in"
    mat-raised-button
    (click)="securityStore.signIn()"
  >
    Sign In
  </button>
  } @else {
  <div class="flex items-center">
    <p class="profile" data-testid="p-username">Welcome {{ userValue.name }}</p>
    <button
      (click)="securityStore.signOut()"
      data-testid="btn-sign-out"
      mat-raised-button
    >
      Sign Out
    </button>
  </div>
  } }
</div>

The template toggles between the "Sign In" and "Sign Out" buttons, depending on the current user's state.

Try it out! Start Angular and click on "Sign In". You should find yourself at the login screen of Keycloak. Type in the credentials for user "Jost List", and Keycloak should redirect you to Angular.

Open the network tab. You should see that the request to the holiday's endpoint also contains the new HTTP header, which contains the JWT.

You can take a look at the JWT data. https://jwt.io/ provides an online service that parses it.

5. Spring as Resource

We have to install the necessary dependencies to configure Spring as an OAuth Resource server.

Our application is using Gradle Therefore, we add the following to the dependencies:

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
}

The next step is the registration of the Keycloak instance. We can use the application.yml for that to add the URI:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: "http://localhost:8081/realms/eternal"

Spring will now connect to Keycloak, request its keys, and use them to verify any JWT that Angular sends.

The next step is to configure Spring Security and integrate OAuth2.

For that, we create a SecurityConfiguration.java, which contains the necessary setup:

@Configuration
public class SecurityConfiguration {

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
      .cors()
      .configurationSource(request -> {
        var cors = new CorsConfiguration();
        cors.setAllowedOrigins(List.of("http://localhost:4200"));
        cors.setAllowedMethods(
          List.of("GET", "POST", "OPTIONS", "PUT", "DELETE")
        );
        cors.setAllowedHeaders(List.of("*"));
        cors.setAllowCredentials(true);

        return cors;
      })
      .and()
      .authorizeHttpRequests(authorize -> {
        try {
          authorize
            .anyRequest()
            .authenticated()
            .and()
            .oauth2ResourceServer()
            .jwt()
            .jwtAuthenticationConverter(
              new KeycloakJwtAuthenticationConverter(List.of("account"))
            );
        } catch (Exception e) {
          throw new RuntimeException(e);
        }
      });
    return http.build();
  }
}

Next to the obligatory configuration of CORS, we use authorizeHttpRequests and demand that any request be authenticated.

We also configure a converter, which will map the roles in the JWT to Spring Security's model.

The Converter looks very simple:

KeycloakJwtAuthenticationConverter.java

public class KeycloakJwtAuthenticationConverter
    implements Converter<Jwt, AbstractAuthenticationToken> {
  @Override
  public AbstractAuthenticationToken convert(Jwt source) {
    return new JwtAuthenticationToken(
        source,
        Stream.concat(
                new JwtGrantedAuthoritiesConverter().convert(source).stream(),
                extractResourceRoles(source).stream())
            .collect(toSet()));
  }

  private Collection<? extends GrantedAuthority> extractResourceRoles(Jwt jwt) {
    var resourceAccess = new HashMap<>(jwt.getClaim("resource_access"));

    var eternal = (Map<String, List<String>>) resourceAccess.get("eternal");

    var roles = (ArrayList<String>) eternal.get("roles");

    return roles.stream()
        .map(role -> new SimpleGrantedAuthority("ROLE_" + role.replace("-", "_")))
        .collect(toSet());
  }
}

From the JWT, we fetch the claim "resource_access" and explicitly those for our client "eternal".

Since we only want to migrate the roles to Spring Security, we get the "roles" and instantiate a new SimpleGrantedAuthority for each role.

This allows us to refer to the for example, via @Secured.

We add it to the endpoint of the HolidaysController, which is responsible for adding a new entry. The required role is "ROLE_ADMIN_HOLIDAYS."

Restart Spring. In Angular, make sure you are signed in as "John List" and click on "Add Holiday". You should see that the server responds with an error code of 401, meaning the request is unauthorized.

Next, sign in as "Lucy Sanders" and try again to add a new holiday. This time, it should work.

6. Summary

This article has shown a minimalistic but secure setup that integrates OAuth2 into a classic SPA with Angular and Spring.

The repository is available at: https://github.com/rainerhahnekamp/eternal/tree/article/2024-02-14-oauth2-spring-resource-angular-client

Spring acted as a resource server, meaning the front end contains the JWT.

The other approach is that Spring takes over the OAuth2 "Client" role. In that setting, Spring sets a simple, old-fashioned cookie in Angular, which is HTTP-only.

That setup requires more configuration on Spring's side, which becomes complicated if we deal with a microservices architecture.

Either way, an upcoming article will discuss the Client Role with Spring.