Skip to content

Keycloak as authorization service with JWK

In this post, we switch our resource service from the previous post(https://akazakov.dev/?p=47) to the keycloak authorization server (https://www.keycloak.org). All source code is available at Github (https://github.com/kazakovav/spring-security-jwt/tree/master/keycloak)

Install and configure keycloak server

We will use docker-compose for starting and creating the keycloak authorization service. The docker-compose.yml is provided below:

version: "3.8"

services:
  postgres:
    container_name: postgres
    image: library/postgres
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-postgres}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
      POSTGRES_DB: keycloak_db
    ports:
      - "5432:5432"
    volumes:
      - ./pg-init-scripts:/docker-entrypoint-initdb.d
    restart: unless-stopped

  keycloak:
    image: jboss/keycloak
    container_name: keycloak
    environment:
      DB_VENDOR: POSTGRES
      DB_ADDR: postgres
      DB_DATABASE: keycloak_db
      DB_USER: ${POSTGRES_USER:-postgres}
      DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
      KEYCLOAK_USER: admin
      KEYCLOAK_PASSWORD: admin
      # Uncomment the line below if you want to specify JDBC parameters. The parameter below is just an example, and it shouldn't be used in production without knowledge. It is highly recommended that you read the PostgreSQL JDBC driver documentation in order to use it.
      #JDBC_PARAMS: "ssl=true"
    ports:
      - "8484:8080"
    depends_on:
      - postgres
    links:
      - "postgres:postgres"

Now, run docker-compose to start keycloak and database:

docker-compose up -d

Let’s configure the authorization server

Go to http://localhost:8484.

And enter to the admin console. Sign in via admin login and admin password (in our server is admin/admin):

Add a new realm:

Add client. All settings leave as default

Next, adding users and roles:

Role

And user:

Set user credentials:

And adding role “USER” for test_user:

Let’s try to log in:

POST <http://localhost:8484/auth/realms/spring-security-jwt/protocol/openid-connect/token>
Content-Type: application/x-www-form-urlencoded

client_id=spring-jwt-client&grant_type=password&scope=openid&username=test_user&password=test_user

> {% client.global.set("auth_token", response.body.access_token); %}

And get the result, we obtained access, id, and refresh tokens:

POST <http://localhost:8484/auth/realms/spring-security-jwt/protocol/openid-connect/token>

HTTP/1.1 200 OK
Cache-Control: no-store
Set-Cookie: KEYCLOAK_LOCALE=; Version=1; Comment=Expiring cookie; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Max-Age=0; Path=/auth/realms/spring-security-jwt/; HttpOnly
Set-Cookie: KC_RESTART=; Version=1; Expires=Thu, 01-Jan-1970 00:00:10 GMT; Max-Age=0; Path=/auth/realms/spring-security-jwt/; HttpOnly
X-XSS-Protection: 1; mode=block
Pragma: no-cache
X-Frame-Options: SAMEORIGIN
Referrer-Policy: no-referrer
Date: Fri, 13 Nov 2020 06:47:07 GMT
Connection: keep-alive
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
Content-Type: application/json
Content-Length: 3167

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJXMGx5OG1sa1VxRWxBcHk5dDNCNlhPa0haM0ppTjlYVDNHYTBsOFAySjhvIn0.eyJleHAiOjE2MDUyNTAzMjcsImlhdCI6MTYwNTI1MDAyNywianRpIjoiMmY0MDU0ZjUtMzAzNC00ZmQxLWFkZDEtZmNiMjFlZDQ0YTIzIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL3NwcmluZy1zZWN1cml0eS1qd3QiLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiMTIwYjU5ZDYtNzkwZi00YWM3LWIwNmUtODk0YTk0MjFiNjMyIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoic3ByaW5nLWp3dC1jbGllbnQiLCJzZXNzaW9uX3N0YXRlIjoiODc1ODdjZjctODgwMS00ZDM3LWIzZWEtMjI3MjE1NjIyNjNhIiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiVVNFUiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6InRlc3RfdXNlciIsImVtYWlsIjoidGVzdF91c2VyQGVtYWlsIn0.Cm1nyOWHAjrta8HNgN-x2I0d17CggvNG5304hzj38Kpc_6UJR0KY8vL5ELeqgI3PaXcuo7INjPJzSTS5Jx1RqrdcqNUv9oaEGJRzTRiZGchbLx2Y_Rxbba15sIjN3816yNDj9Jn1CpJtZlKulsp81XjKvop_QSLxkEQVbKcmnINGmViky_yLh8wMclbgUwLu96hXZJt3Rdgi8BRaiUkkR30yKH7nOv8j07Mq_STOqTOJRahCxo_bti7V8chKORRz8inVwaPuVuHUEp-6pqDpTBuNSQWuu-UcBZcU5HYiDBkj2QbBLuxNXdPR7YsghMdqm0VhNVPwS5sK6AH3ybCG-A",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0YmI0YjBkOC05ZTA2LTRiNTYtOWYzMi1mZDNiZjZlNjU0MWYifQ.eyJleHAiOjE2MDUyNTE4MjcsImlhdCI6MTYwNTI1MDAyNywianRpIjoiNTQzZjZmMDgtMzNmMy00MWIyLWExNTgtM2Y2ZWQ5ODUwYzM3IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4NDg0L2F1dGgvcmVhbG1zL3NwcmluZy1zZWN1cml0eS1qd3QiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0Ojg0ODQvYXV0aC9yZWFsbXMvc3ByaW5nLXNlY3VyaXR5LWp3dCIsInN1YiI6IjEyMGI1OWQ2LTc5MGYtNGFjNy1iMDZlLTg5NGE5NDIxYjYzMiIsInR5cCI6IlJlZnJlc2giLCJhenAiOiJzcHJpbmctand0LWNsaWVudCIsInNlc3Npb25fc3RhdGUiOiI4NzU4N2NmNy04ODAxLTRkMzctYjNlYS0yMjcyMTU2MjI2M2EiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIn0.qqfsDaSzINPNTjpznlUTQ-GhYbLdUXXNcL_iNggN9u8",
  "token_type": "bearer",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJXMGx5OG1sa1VxRWxBcHk5dDNCNlhPa0haM0ppTjlYVDNHYTBsOFAySjhvIn0.eyJleHAiOjE2MDUyNTAzMjcsImlhdCI6MTYwNTI1MDAyNywiYXV0aF90aW1lIjowLCJqdGkiOiJlYzRjNGVmNC0zYjE0LTQyYjUtYmIzZi1kYjRiYzczNTg3ZmEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0Ojg0ODQvYXV0aC9yZWFsbXMvc3ByaW5nLXNlY3VyaXR5LWp3dCIsImF1ZCI6InNwcmluZy1qd3QtY2xpZW50Iiwic3ViIjoiMTIwYjU5ZDYtNzkwZi00YWM3LWIwNmUtODk0YTk0MjFiNjMyIiwidHlwIjoiSUQiLCJhenAiOiJzcHJpbmctand0LWNsaWVudCIsInNlc3Npb25fc3RhdGUiOiI4NzU4N2NmNy04ODAxLTRkMzctYjNlYS0yMjcyMTU2MjI2M2EiLCJhdF9oYXNoIjoibW5tY3pRTzA5X3Z4dnl6c0tJRnQxUSIsImFjciI6IjEiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6InRlc3RfdXNlciIsImVtYWlsIjoidGVzdF91c2VyQGVtYWlsIn0.UgfyngpnREHRVHpWlsanEivvboULumN1Tf5wju2yT0SkryUlXCtoBGgkIc5uXlubg7IJQcK7BxUSwTLhXXexguoKGbdgy-zAEnwcMBc6XtFyABIT5MoCsvjTjmpNAsxdUgTs0cH7AgRubkbrqW-U8RhANDq4Q138Skreb4iNM1rr1LiI2fWP7MaH0UXov0AeTyazvlFe8tX-bzh-q0xMJwWAMd3NWydbhPZSX-jB_AjvNGrTNpIUIa4RBRRqjQC8HqYrdkhMUihZourdxyMwek4uYGvdgrkRHYlBAy0QymMjUigcnmjFYGtdSg7NJLYxE0ShDd77gUkkz11JXFBc7g",
  "not-before-policy": 0,
  "session_state": "87587cf7-8801-4d37-b3ea-22721562263a",
  "scope": "openid profile email"
}

Response code: 200 (OK); Time: 216ms; Content length: 3167 bytes

Modify Token parser

By default, keycloak provides realm roles in the “realm_access” part of the token. Parsed token example:

{
  "exp": 1605508164,
  "iat": 1605507864,
  "jti": "dd06e911-b791-49cd-a7ab-168484ff9ad8",
  "iss": "<http://localhost:8484/auth/realms/spring-security-jwt>",
  "aud": "account",
  "sub": "120b59d6-790f-4ac7-b06e-894a9421b632",
  "typ": "Bearer",
  "azp": "spring-jwt-client",
  "session_state": "e75278f3-4b58-4d16-9a5e-7433e58f248f",
  "acr": "1",
  "realm_access": {
    "roles": [
      "offline_access",
      "uma_authorization",
      "USER"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "openid profile email",
  "email_verified": false,
  "preferred_username": "test_user",
  "email": "test_user@email"
}

For this purpose we modify CustomJwtRolesConverter for parsing “realm_access.roles” values:

import net.minidev.json.JSONArray
import net.minidev.json.JSONObject
import org.springframework.core.convert.converter.Converter
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
import java.util.stream.Collectors

class KeycloakRealmRolesConverter : Converter<Jwt, Collection<GrantedAuthority>?> {
    private val jwtGrantedAuthoritiesConverter = JwtGrantedAuthoritiesConverter()

    override fun convert(jwt: Jwt): Collection<GrantedAuthority> {
        val grantedAuthorities: MutableCollection<GrantedAuthority> = jwtGrantedAuthoritiesConverter.convert(jwt)!!
        grantedAuthorities.addAll(extractRealmAuthorities(jwt))
        return grantedAuthorities
    }

    private fun extractRealmAuthorities(jwt: Jwt): Collection<GrantedAuthority> {
        return jwt.getClaim<JSONObject?>(REALM_ACCESS_KEY)?.let { realmAccess ->
            realmAccess[ROLES_KEY]?.let {
                val roles = it as JSONArray
                roles.stream().map { role: Any -> SimpleGrantedAuthority(ROLE_PREFIX + role) }.collect(Collectors.toList())
            }
        } ?: emptyList()
    }

    companion object {
        const val REALM_ACCESS_KEY = "realm_access"
        const val ROLES_KEY = "roles"
        const val ROLE_PREFIX = "ROLE_"
    }
}

and change the “jwk-set-uri” parameter in application.yml:

app.version: v1
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: ${KEYCLOAK_REALM_CERT_URL:<http://localhost:8484/auth/realms/spring-security-jwt/protocol/openid-connect/certs>}

let’s test the “/me” endpoint with keycloak authorization:

###
GET <http://localhost:8080/me>
Content-Type: application/json
Authorization: Bearer {{auth_token}}

Result:

GET <http://localhost:8080/me>

{
  "tokenValue": "eyJhbGciOiJSUzI1l...",
  "issuedAt": "2020-11-16T06:34:05Z",
  "expiresAt": "2020-11-16T06:39:05Z",
  "headers": {
    "kid": "W0ly8mlkUqElApy9t3B6XOkHZ3JiN9XT3Ga0l8P2J8o",
    "typ": "JWT",
    "alg": "RS256"
  },
  "claims": {
    "sub": "120b59d6-790f-4ac7-b06e-894a9421b632",
    "resource_access": {
      "account": {
        "roles": [
          "manage-account",
          "manage-account-links",
          "view-profile"
        ]
      }
    },
    "email_verified": false,
    "iss": "<http://localhost:8484/auth/realms/spring-security-jwt>",
    "typ": "Bearer",
    "preferred_username": "test_user",
    "aud": [
      "account"
    ],
    "acr": "1",
    "realm_access": {
      "roles": [
        "offline_access",
        "uma_authorization",
        "USER"
      ]
    },
    "azp": "spring-jwt-client",
    "scope": "openid profile email",
    "exp": "2020-11-16T06:39:05Z",
    "session_state": "55757a23-94af-4551-a283-6ec90cc2c8a0",
    "iat": "2020-11-16T06:34:05Z",
    "jti": "ea742168-b871-430a-85da-20a78525464f",
    "email": "test_user@email"
  },
  "id": "ea742168-b871-430a-85da-20a78525464f",
  "issuer": "<http://localhost:8484/auth/realms/spring-security-jwt>",
  "subject": "120b59d6-790f-4ac7-b06e-894a9421b632",
  "audience": [
    "account"
  ],
  "notBefore": null
}

Response code: 200; Time: 679ms; Content length: 2242 bytes

That’s all.

Thanks! Happy coding!