Keycloak OAuth2 PKCE#
Why Keycloak as authentication server#
Keycloak comes with several handy features built-in:
Two-factor authentication
Bruteforce detection
Social login (Facebook, Twitter, Google…)
LDAP/AD integration
Setting up a Keycloak server#
$ curl https://downloads.jboss.org/keycloak/9.0.3/keycloak-9.0.3.zip --output keycloak-9.0.3.zip
$ unzip keycloak-9.0.3.zip
$ cd keycloak-9.0.3/bin/
$ ./standalone
Creating a new realm#
Name: Security
Creating a client#
Client ID:
spa-heroes
Valid Redirect URIs:
http://localhost:4200/*
Standard flow is another name for the Authorization Code Flow as defined in the OAuth 2.0 specification.
Direct Access Grants Enabled may remain enabled for now. It will be easy to test our configuration later.
Creating roles and scopes#
Roles:
user
Client Scopes:
heroes
Clients -> Client Scopes -> Add heroes
Creating a user#
Users -> Add User
Setting up the front end and back end applications#
Angular app: tour of heroes#
$ curl -LO https://angular.io/generated/zips/toh-pt6/toh-pt6.zip
$ unzip toh-pt6.zip -d toh
$ cd toh
$ npm i
hero.service.ts
export class HeroService {
private heroesUrl = 'http://localhost:18095/api/heroes';
}
ng serve
http://localhost:4200/
Implementing security#
Implicit flow versus code flow + PKCE#
In this example, we will use the authorization code grant flow with Proof Key for Code Exchange (PKCE) to secure the Angular app. It’s a very long name for what could be shortened to “code flow + PKCE” which is more secure than the implicit flow.
In fact, the implicit flow was never very secure to begin with.
The implicit flow was the easiest to understand, since it required one step less than the standard code flow:
PKCE is an addition on top of the standard code flow to make it usable for public clients. It is already in use for native and mobile clients. PKCE boils down to this:
Give
hash
of random value to authorization server when logging in to ask forcode
Hand over the
random value
to authorization server when exchangingcode
foraccess token
Authorization server returns
access token
after verifying thathash
belongs torandom value
.
If a fraudster were to intercept our authorization grant (the code), he or she would still not have the code_verifier
, which is stored in our SPA client.
Json web token (JWT)#
In its compact form, JSON Web Tokens consist of three parts separated by dots (.), which are:
Header
: the type of the token and the signing algorithm being usedPayload
: the payload, which contains the claims and additional dataSignature
: to verify if the token was not tampered with
Therefore, a JWT typically looks like the following: xxxxx.yyyyy.zzzzz
The header (xxx) and payload (yyy) are base64 encoded. An access token is a good example of a JWT:
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJkdE9JZkY2NGZRYnlwWVRFdGV2eV83NUdIWTdQMmNHU1o2a2ZXWDdFblBJIn0.eyJqdGkiOiIxY2EzZTZkYS1kM2Y2LTRiYTMtYjNjZC1iMDExYmRlM2JmNmIiLCJleHAiOjE1NjYzMjk1NTYsIm5iZiI6MCwiaWF0IjoxNTY2MzI5MjU2LCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvaGVyb2VzIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjBkNjg0OWI4LWUyZmEtNGU3My04NjlhLTE1ZDVhOTE1YzdhMiIsInR5cCI6IkJlYXJlciIsImF6cCI6InNwYS1oZXJvZXMiLCJhdXRoX3RpbWUiOjAsInNlc3Npb25fc3RhdGUiOiI4NWRjYTg0Ny00YmQzLTRiOTUtOTNiYy01MjE5ZjUzYWNiMzciLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbIioiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iLCJ1c2VyIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBoZXJvZXMgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiSmVyb2VuIE1leXMiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqZXJvZW4iLCJnaXZlbl9uYW1lIjoiSmVyb2VuIiwiZmFtaWx5X25hbWUiOiJNZXlzIiwiZW1haWwiOiJtZUBhY21lLmJlIn0.cvn79d-n0aFYqDB3p-htDNeeYOdkvqEsmDhGKp9V3a4i6nJx7dU0_r7zicQe26ZgDsM65ILx_X-buWv-e5_eraFo1OOveCGtBbrrLwrQ0Z7SlVMHJrDooJrLEE_m8Qlz_-iLcEC2-ODroEwyLRej_Du626B48QL2bcq-8ADqGSaLf7Y4ZTVMiP_p6dsCi4GDQLq1WOy-g6--z47FKTJVuAl2yY_JNNuEd5aofw0FTE38EoEinIdcy5NXCXDhtGHr_k5lA2Swu4JvK84YB6usECigCb1_zO_c6LhZQkRTCcCojxC6Qn1trQH9epcFEKTkDCHrNf6BLp4X9rH2URWJcA
We can easily decode them using online tools like jwt.io.
Be careful with online tools to analyze JWT tokens. You are exposing access tokens to the world!
Resource server in spring boot#
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-oauth2-resource-server'
implementation 'org.springframework.security:spring-security-oauth2-jose'
spring-boot-starter-security
: starter dependency for Spring Securityspring-security-oauth2-resource-server
: dependency to use our application as a Resource Serverspring-security-oauth2-jose
: support for the Javascript Object Signing and Encryption framework
Configuration of the resource server#
@EnableWebSecurity
public class ResourceServerConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and()
.authorizeRequests()
.mvcMatchers("/api/heroes/**").hasAuthority("SCOPE_heroes")
.anyRequest().denyAll()
.and()
.oauth2ResourceServer()
.jwt();
}
}
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8080/auth/realms/Security/protocol/openid-connect/certs
Testing the setup#
$ curl -i http://localhost:18095/api/heroes
Without a token, the server responds with HTTP 401. This means we are not authorized. As we don’t have a login form available just yet, we can use the Direct Access Grants flow to obtain a token. This can come in very handy for testing different scenarios as well.
$export TOKEN=$(curl -H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=spa-heroes" \
-d "username=tester" \
-d "password=123456" \
-d "grant_type=password" \
-X POST http://localhost:8080/auth/realms/Security/protocol/openid-connect/token | jq -r .access_token)
$echo $TOKEN
$ curl -i -X GET -H "Authorization: Bearer $TOKEN" http://localhost:18095/api/heroes
Make sure ‘Direct Access Grants Enabled’ is enabled in the Keycloak Client settings
Securing the Angular application#
$ npm i angular-oauth2-oidc --save
app.module.ts
import { HttpClientModule } from '@angular/common/http';
import { OAuthModule } from 'angular-oauth2-oidc';
@NgModule({
imports: [
HttpClientModule,
OAuthModule.forRoot({
resourceServer: {
allowedUrls: ['http://localhost:18095/api'],
sendAccessToken: true
}
})
app.component.html
<button class="btn btn-default" (click)="login()">
Login
</button>
<button class="btn btn-default" (click)="logoff()">
Logout
</button>
app.component.ts
import { Component } from '@angular/core';
import { OAuthService, NullValidationHandler, AuthConfig } from 'angular-oauth2-oidc';
import { JwksValidationHandler } from 'angular-oauth2-oidc';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Tour of Heroes';
constructor(private oauthService: OAuthService) {
this.configure();
}
authConfig: AuthConfig = {
issuer: 'http://localhost:8080/auth/realms/Security',
redirectUri: window.location.origin + "/Security",
clientId: 'spa-heroes',
scope: 'openid profile email offline_access heroes',
responseType: 'code',
// at_hash is not present in JWT token
disableAtHashCheck: true,
showDebugInformation: true
}
public login() {
this.oauthService.initLoginFlow();
}
public logoff() {
this.oauthService.logOut();
}
private configure() {
this.oauthService.configure(this.authConfig);
this.oauthService.tokenValidationHandler = new NullValidationHandler();
this.oauthService.loadDiscoveryDocumentAndTryLogin();
}
}