Social media links

Securing a Spring Boot REST API with OAuth 2.0, Keycloak, and Angular

In this article, I'll demonstrate how to secure a Spring Boot REST API using OAuth2.0, Keycloak, and how to consume it with an Angular client.

This is articulated around three mains components:

  • Authorisation server: Keycloak
  • Resource server (the REST API): Spring Boot
  • Web client: Angular

Step 1: Set up Keyloack

Install and run Keyloack

There are multiple way to install and run Keyloack, inside a Docker container for example. But for the sake of simplicity in this article, we'll use the OpenJDK example.

Once you've downloaded and extracted the Keycloak archive, go to the directory and start Keycloak.

bin/kc.sh start-dev

Go to http://localhost:8080 and create an admin user and follow the instruction in https://www.keycloak.org/getting-started/getting-started-zip to configure Keycloak.

  • Create a Realm
  • Create a Client
  • Set up Identity Providers

Step 2: Set up the Spring Boot application

Initialise the Spring Boot project

Use Spring Initializr (https://start.spring.io/) to create a new Spring Boot project with the following dependencies:

  • Spring Web
  • Spring Security
  • OAuth2 Resource Server

Configure the application properties

server:
	# make sure it's not the same port where Keycloak is running
	port: 8081

spring:
	security:
		oauth2:
			resourceserver:
				jwt:
					issuer-uri: "http://localhost:8080/auth/realms/MyRealm"
					jwt-set-uri: "${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs"
						

Create a REST controller

Create a simple REST controller to test that the API route will be protected.

package com.example.demo;

import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api")
public class ApiController {

	@GetMapping("/hello")
	public String hello() {
		return "Hello Wolrd!";
	}
}

Create a Security configuration

package com.example.demo;

import org.springframework.context.annotation.Bean;  
import org.springframework.context.annotation.Configuration;  
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;  
import org.springframework.security.web.authentication.HttpStatusEntryPoint;

@Configuration
public class SecurityConfig {
	@Bean
	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http.authorizeHttpRequests((authorize) -> authorize.requestMatchers("/api/**")
			.authenticated()  
		    .anyRequest()  
		    .permitAll()  
		)
		.exceptionHandling(exception -> exception  
                    .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))  
            );  
return http.build();
	}
}

Step 3: Setup the Angular client

Create the project and install the dependencies

Once you have initialised the Angular application, install the angular-oauth2-oidc library npm i angular-oauth2-oidc --save

We will implement the Authorization Clode Flow with PKCE, based on the provided example of implementation in https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/ .

The Angular client should be able to obtain an access token (+ id_token for OIDC) from the Keycloak Authorisation server, and send the access token to the header of the http request perform to the Spring Boot resource server.

The example below has been generated using Angular 18 and standalone components.

Configure the authentication settings

auth-config.ts

import { AuthConfig } from 'angular-oauth2-oidc';  

export const authConfig: AuthConfig = {
	issuer: 'http://localhost:8080/realms/MyRealm',
	clientId: 'MyClientId', // The "Auth Code + PKCE" client
	responseType: 'code',
	redirectUri: window.location.origin,
	silentRefreshRedirectUri: window.location.origin + '/silent-refresh.html',
	scope: 'openid profile email', // Ask offline_access to support refresh token refreshes
	useSilentRefresh: true, // Needed for Code Flow to suggest using iframe-based refreshes
	silentRefreshTimeout: 20000, // For faster testing
	timeoutFactor: 0.25, // For faster testing
	sessionChecksEnabled: true,
	showDebugInformation: true, // Also requires enabling "Verbose" level in devtools
	clearHashAfterLogin: false, // https://github.com/manfredsteyer/angular-oauth2-oidc/issues/457#issuecomment-431807040,
	nonceStateSeparator : 'semicolon', // Real semicolon gets mangled by Duende ID Server's URI encoding,
	strictDiscoveryDocumentValidation: false, // https://manfredsteyer.github.io/angular-oauth2-oidc/docs/additional-documentation/using-an-id-provider-that-fails-discovery-document-validation.html
};

Create a the library module config

auth-oauth-module-config.ts

import {OAuthModuleConfig} from 'angular-oauth2-oidc';

export const authOAuthModuleConfig: OAuthModuleConfig = {
	resourceServer: {
		allowedUrls: ['http://localhost:8081/api'], // list of URLs  to which the library interceptor will automatically add the access token in the request authorization header
		sendAccessToken: true
	}
};

Create the authentication service

authentication.service.ts Please note that this is a basic exemple and does not handle the refresh token / automatic and silent refresh.

import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { OAuthErrorEvent, OAuthService } from 'angular-oauth2-oidc';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators'; 

@Injectable({ providedIn: 'root' })
export class AuthService {  
	private isAuthenticatedSubject$ = new BehaviorSubject<boolean>(false);
	public isAuthenticated$ = this.isAuthenticatedSubject$.asObservable();
	private isDoneLoadingSubject$ = new BehaviorSubject<boolean>(false);
	public isDoneLoading$ = this.isDoneLoadingSubject$.asObservable();
	
	/**
	* Publishes `true` if and only if (a) all the asynchronous initial
	* login calls have completed or errorred, and (b) the user ended up
	* being authenticated.
	*
	* In essence, it combines:
	*
	* - the latest known state of whether the user is authorized
	* - whether the ajax calls for initial log in have all been done
	*/
	public canActivateProtectedRoutes$: Observable<boolean> = combineLatest([
		this.isAuthenticated$,
		this.isDoneLoading$
	]).pipe(map(values => values.every(b => b)));
	
	constructor(
		private oauthService: OAuthService,
		private router: Router,
	) {
		// Useful for debugging:
		this.oauthService.events.subscribe(event => {
		if (event instanceof OAuthErrorEvent) {
			console.error('OAuthErrorEvent Object:', event);
		} else {
			console.warn('OAuthEvent Object:', event);
		}
	});
	
	  
	
	this.oauthService.events
	.subscribe(_ => {
			this.isAuthenticatedSubject$.next(this.oauthService.hasValidAccessToken());
		});
		
		this.isAuthenticatedSubject$.next(this.oauthService.hasValidAccessToken());
		
		this.oauthService.events
			.pipe(filter(e => ['token_received'].includes(e.type)))
			.subscribe(e => this.oauthService.loadUserProfile());
			
		this.oauthService.events
			.pipe(filter(e => ['session_terminated', 'session_error'].includes(e.type)))
			.subscribe(e => this.logout());
	}
	
	public runInitialLoginSequence(): Promise<void> {
		if (location.hash) {
			console.log('Encountered hash fragment, plotting as table...');
			console.table(location.hash.substr(1).split('&').map(kvp => kvp.split('=')));
		}
	
		// 0. LOAD CONFIG:
		// First we have to check to see how the IdServer is
		// currently configured:
		return this.oauthService.loadDiscoveryDocument()
			// 1. HASH LOGIN:
			// Try to log in via hash fragment after redirect back
			// from IdServer from initImplicitFlow:
			.then(() => this.oauthService.tryLogin())
			.then(() => {
				if (this.oauthService.hasValidAccessToken()) {
					return Promise.resolve();
				} else {
					return Promise.reject(new Error());
				}
			})
		
		.then(() => {
			this.isDoneLoadingSubject$.next(true);
			// Check for the strings 'undefined' and 'null' just to be sure. Our current
			// login(...) should never have this, but in case someone ever calls
			// initImplicitFlow(undefined | null) this could happen.
			if (this.oauthService.state && this.oauthService.state !== 'undefined' && this.oauthService.state !== 'null') {
				let stateUrl = this.oauthService.state;
				if (stateUrl.startsWith('/') === false) {
					stateUrl = decodeURIComponent(stateUrl);
				}
				console.log(`There was state of ${this.oauthService.state}, so we are sending you to: ${stateUrl}`);
				this.router.navigateByUrl(stateUrl);
			}
		})
		.catch(() => this.isDoneLoadingSubject$.next(true));
	}
	
	public logout() {
		this.oauthService.logOut();
		this.router.navigateByUrl("/");
	}
	
	public login(targetUrl?: string) {
		// Note: before version 9.1.0 of the library you needed to
		// call encodeURIComponent on the argument to the method.
		this.oauthService.initLoginFlow(targetUrl ?? this.router.url);
	}
}

App bootstrapping config

We configure the OAuthClient and prepare the authentication mechanism during the application bootstrapping.

main.ts

import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component'; 

bootstrapApplication(AppComponent, appConfig)
	.catch((err) => console.error(err));

app.config.ts

import { ApplicationConfig, provideZoneChangeDetection, APP_INITIALIZER } from '@angular/core';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { provideOAuthClient, OAuthService } from 'angular-oauth2-oidc';
import { authOAuthModuleConfig } from './auth-oauth-module-config';
import { AuthService } from "./auth.service";
import { authConfig } from './auth-config';

import { routes } from './app.routes';  

export function authAppInitializerFactory(authService: AuthService, oauthService: OAuthService): () => Promise<void> {
	oauthService.configure(authConfig);
	return () => authService.runInitialLoginSequence();
}  

export const appConfig: ApplicationConfig = {
	providers: [
		provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes),
		provideHttpClient(withInterceptorsFromDi()),
		provideOAuthClient(authOAuthModuleConfig),
		{
			provide: APP_INITIALIZER,
			useFactory: authAppInitializerFactory,
			deps: [AuthService, OAuthService],
			multi: true
		}
	]
};

Test the calls to the API

Set up a login button and button to perform an API call in your application to test that everything is working as expected.

app.component.html

<main>
	<button (click)="login()">login</button>
	<button (click)="requestApi()">Request API</button>
</main> 
<router-outlet />

app.component.ts

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { RouterOutlet } from '@angular/router';
import { AuthService } from './auth.service';  

@Component({
	selector: 'app-root',
	standalone: true,
	imports: [RouterOutlet],
	templateUrl: './app.component.html',
	styleUrl: './app.component.scss'
})

export class AppComponent implements OnInit {
	constructor(private http: HttpClient, private authService: AuthService) {}  

	ngOnInit() {}
	
	login() {
		console.log("login clicked");
		this.authService.login();
	}

	requestApi() {
		this.http.get<string>("http://localhost:8081/api/hello").subscribe({
			next: succ => {
				console.log(succ);
			},
			error: err => {
				console.error(err);
			}
		})
	}
}

First time that you click on "requestApi" button, the Api should return a 403 because you are not authenticated. If you click on "Login", you should be redirected to the Keycloak authentication screen, and after performing a successful authentication, the "requestApi" call should now correctly return "Hello world!";

Conclusion

The snippets above have shown how to secure a Spring Boot REST API , using OAuth2.0 and Keycloak for authentication. Those examples are just a basis and need to be extended, to use the refresh token to renew new access token, or to add permission / roles check.