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.