Keycloak Authentication in Grails 8

Sign users in with Keycloak and enforce USER and ADMIN client roles.

Authors: Aaron

Grails Version: 8

1 Getting Started

This guide connects a Grails 8.0.0 application to Keycloak with OpenID Connect. Users, passwords, and roles live in Keycloak. The Grails application stores nothing of its own and uses an embedded H2 database.

The finished sample is the grails8 branch of grails-guides/grails-keycloak. initial/ is a plain Grails web application. complete/ adds Spring Security OAuth2 login, a client-role mapper, GSP security tags, and pages for the USER and ADMIN roles.

What you will build

  • A public home page at /.

  • An Account page that requires the Keycloak client role USER.

  • An Admin page that requires the Keycloak client role ADMIN.

  • A login redirect to Keycloak, and a logout that returns the browser to http://localhost:8080.

What you will need

  • JDK 21, with JAVA_HOME set.

  • Docker, to run Keycloak 26.8.0.

  • A browser.

The sample’s Gradle wrapper downloads Grails 8.0.0 and Gradle 9.8.0. You do not need a separate Grails install to run it. The Grails application does not need PostgreSQL. Keycloak start-dev keeps its own embedded database.

The sample repository also contains KEYCLOAK.md. That file is a longer Keycloak setup (PostgreSQL, systemd, and SMTP). This guide is enough to sign in and prove the two roles.

How to follow this guide

git clone -b grails8 https://github.com/grails-guides/grails-keycloak.git
cd grails-keycloak

Open complete/ to run the finished application. Open initial/ to apply the steps yourself. The chapters below show the files from complete/.

2 Run a Local Keycloak

Run Keycloak on port 8081 so it does not collide with the Grails application on port 8080.

docker run --rm --name grails-keycloak \
  -p 8081:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.8.0 \
  start-dev

Open http://localhost:8081 and sign in as admin / admin. start-dev reads the browser’s host, so the issuer for this guide is http://localhost:8081/realms/example.

Realm and client

Create a realm named example.

In that realm, create an OpenID Connect client:

Setting Value

Client type

OpenID Connect

Client ID

example-client

Client authentication

On

Standard flow

On

Valid redirect URIs

http://localhost:8080/login/oauth2/code/keycloak

Valid post logout redirect URIs

http://localhost:8080

Web origins

http://localhost:8080

The redirect URI is the Spring Security login callback. The post-logout URI is {baseUrl} from the application, which is http://localhost:8080 when Grails listens on that port. Keycloak 18 and later rejects a logout redirect that is not registered. In the Keycloak 26 client settings this field is under Logout settings.

Copy the client secret from Clients → example-client → Credentials. The application reads it from KEYCLOAK_CLIENT_SECRET. Do not commit the secret.

Client roles

Create USER and ADMIN under Clients → example-client → Roles. The mapper in this guide reads resource_access.example-client.roles from the ID token. Roles created under Realm roles are not in that claim, so a user can sign in and still fail every @PreAuthorize check.

Open ADMIN and add USER as an associated role so ADMIN is a composite role. A user who is assigned only ADMIN then receives both ADMIN and USER in the client-role claim. The Account page requires USER, and the Admin page requires ADMIN, so an administrator can open both.

Put the client roles on the ID token

Open Clients → example-client → Client scopes → dedicated scope and add a mapper of type User Client Role.

Setting Value

Name

example-client-roles

Client ID

example-client

Client Role prefix

leave blank

Multivalued

On

Token Claim Name

resource_access.example-client.roles

Claim JSON Type

String

Add to ID token

On

Add to access token

On

Add to lightweight access token

Off

Add to userinfo

On

Add to token introspection

On

Token Claim Name is required and has no default. That value is the only claim KeycloakAuthoritiesMapper reads. Leave Client Role prefix blank so the token contains USER and ADMIN. The Grails mapper adds ROLE_.

Check the result before starting Grails. Open Client scopes → Evaluate, choose the test user, and generate an ID token. A user with the USER client role should show:

{
  "resource_access": {
    "example-client": {
      "roles": ["USER"]
    }
  }
}

An ADMIN user should show both ADMIN and USER in that same roles array.

Users

Create two users in the example realm. Set a non-temporary password on each.

Assign client roles from Role mapping → Assign role, with the filter set to clients:

  • One user gets example-client / USER only.

  • The other user gets example-client / ADMIN only. Composite membership supplies USER.

Environment variables

The Grails application reads three variables. There is no database variable.

export KEYCLOAK_CLIENT_ID=example-client
export KEYCLOAK_CLIENT_SECRET=paste-the-client-secret
export KEYCLOAK_ISSUER=http://localhost:8081/realms/example

On PowerShell:

$env:KEYCLOAK_CLIENT_ID = "example-client"
$env:KEYCLOAK_CLIENT_SECRET = "paste-the-client-secret"
$env:KEYCLOAK_ISSUER = "http://localhost:8081/realms/example"

3 Configure the Grails Application

Add the Spring Security client libraries to build.gradle. Grails 8.0.0 manages the Spring Boot 4.1.1 line through grails-bom, so these two dependencies do not need their own versions:

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

complete/gradle.properties sets grailsVersion=8.0.0. Keep initial/gradle.properties on that same version.

application.yml

The datasource stays on embedded H2. The Keycloak registration is the keycloak client under Spring Security’s OAuth2 client properties. The registration id is the last segment of the login callback, /login/oauth2/code/keycloak.

info:
  app:
    name: '@info.app.name@'
    version: '@info.app.version@'
    grailsVersion: '@info.app.grailsVersion@'
dataSource:
  driverClassName: org.h2.Driver
  username: sa
  password: ''
  pooled: true
  jmxExport: true
environments:
  development:
    dataSource:
      dbCreate: create-drop
      url: jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
  test:
    dataSource:
      dbCreate: update
      url: jdbc:h2:mem:testDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
  production:
    dataSource:
      dbCreate: none
      url: jdbc:h2:./prodDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
hibernate:
  cache:
    queries: false
    use_second_level_cache: false
    use_query_cache: false
grails:
  codegen:
    defaultPackage: com.grails.example
  profile: web
  views:
    gsp:
      encoding: UTF-8
      htmlcodec: xml
      codecs:
        scriptlet: html
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            provider: keycloak
            client-id: ${KEYCLOAK_CLIENT_ID}
            client-secret: ${KEYCLOAK_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope:
              - openid
              - profile
              - email
        provider:
          keycloak:
            issuer-uri: ${KEYCLOAK_ISSUER}

KEYCLOAK_ISSUER is the realm issuer, http://localhost:8081/realms/example, not the Keycloak root URL.

Register the role mapper

KeycloakAuthoritiesMapper is not a Spring stereotype. Register it in grails-app/conf/spring/resources.groovy so SecurityConfig can inject it:

import com.grails.example.KeycloakAuthoritiesMapper

// Place your Spring DSL code here
beans = {
    keycloakAuthoritiesMapper(KeycloakAuthoritiesMapper)
}

Place SecurityConfig and KeycloakAuthoritiesMapper in src/main/groovy/com/grails/example/. Place SecurityTagLib in grails-app/taglib/com/grails/example/. The next chapter walks through each class.

4 Understand the Security Integration

A protected request that has no session is redirected to Keycloak. After a successful login, Keycloak sends the browser to /login/oauth2/code/keycloak with an authorization code. Spring Security exchanges the code for an ID token. KeycloakAuthoritiesMapper turns client roles from that token into ROLE_* authorities. Controller annotations and GSP tags then read those authorities.

SecurityConfig

package com.grails.example

import groovy.util.logging.Slf4j

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.web.SecurityFilterChain

@Configuration
@Slf4j
@EnableMethodSecurity
class SecurityConfig {

    @Autowired
    KeycloakAuthoritiesMapper keycloakAuthoritiesMapper

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http,
                                            ClientRegistrationRepository clientRegistrationRepository) throws Exception {
        def oidcLogoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository)

        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}")
        http
                .authorizeHttpRequests { auth ->
                    auth
                            .requestMatchers(
                                    "/",
                                    "/home/**",
                                    "/logout/**",
                                    "/error",
                                    "/assets/**",
                                    "/images/**",
                                    "/css/**",
                                    "/js/**",
                                    "/404",
                                    "/403"
                            ).permitAll()
                            .anyRequest().authenticated()
                }
                .oauth2Login { oauth ->
                    oauth.userInfoEndpoint { userInfo ->
                        userInfo.userAuthoritiesMapper(keycloakAuthoritiesMapper)
                    }
                }
                .logout { logout ->
                    logout.logoutSuccessHandler(oidcLogoutSuccessHandler)
                }
        return http.build()
    }
}

/ and the static asset paths are public. Every other request requires an authenticated user. oauth2Login installs the Keycloak login and runs KeycloakAuthoritiesMapper when the ID token arrives.

Logout uses OidcClientInitiatedLogoutSuccessHandler with post_logout_redirect_uri set to {baseUrl}. Locally that is http://localhost:8080, which must be listed as a valid post-logout redirect URI on the client. /logout/** is permitAll, which does not turn off CSRF. The logout request still has to be a POST that carries Spring Security’s CSRF token.

KeycloakAuthoritiesMapper

package com.grails.example

import grails.core.GrailsApplication
import jakarta.annotation.PostConstruct
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority

class KeycloakAuthoritiesMapper implements GrantedAuthoritiesMapper {

    @Autowired
    GrailsApplication grailsApplication

    private String clientId

    @PostConstruct
    void init() {
        clientId = grailsApplication.config.getProperty(
                'spring.security.oauth2.client.registration.keycloak.client-id'
        )
    }

    @Override
    Collection<? extends GrantedAuthority> mapAuthorities(
            Collection<? extends GrantedAuthority> authorities) {

        def mappedAuthorities = new HashSet<GrantedAuthority>()

        authorities.each { authority ->
            if (authority instanceof OidcUserAuthority) {
                addKeycloakRoles(
                        mappedAuthorities,
                        authority.idToken.claims
                )
            } else {
                mappedAuthorities.add(authority)
            }
        }

        return mappedAuthorities
    }

    private void addKeycloakRoles(
            Set<GrantedAuthority> authorities,
            Map<String, Object> claims) {

        def resourceAccess = claims['resource_access']

        if (!(resourceAccess instanceof Map)) {
            return
        }

        def clientAccess = resourceAccess[clientId]

        if (!(clientAccess instanceof Map)) {
            return
        }

        def roles = clientAccess['roles']

        if (!(roles instanceof Collection)) {
            return
        }

        roles.each { role ->
            authorities.add(
                    new SimpleGrantedAuthority(
                            "ROLE_${role.toString().toUpperCase()}"
                    )
            )
        }
    }
}

init() reads spring.security.oauth2.client.registration.keycloak.client-id. For an ID token, addKeycloakRoles looks only at resource_access.<clientId>.roles and prefixes each role with ROLE_. A token that has realm roles under realm_access and no resource_access entry for example-client produces no ROLE_* authorities from this mapper.

KeycloakAuthoritiesMapperSpec covers that contract: USER and ADMIN become ROLE_USER and ROLE_ADMIN, a realm-only token contributes no ROLE_* authority, and a missing client id or a missing resource_access claim does the same.

Controllers

Home is public:

package com.grails.example

class HomeController {

    def index() {
        render(view: 'index', model: [])
    }

}

Account requires USER. Admin requires ADMIN.

package com.grails.example

import org.springframework.security.access.prepost.PreAuthorize

@PreAuthorize("hasRole('USER')")
class AccountController {
    def index() {
        render(view: 'index')
    }
}
package com.grails.example

import org.springframework.security.access.prepost.PreAuthorize;

@PreAuthorize("hasRole('ADMIN')")
class AdminController {

    def index() {
        render(view: 'index')
    }

}

Use hasRole('USER') and hasRole('ADMIN'), matching the controllers. With the default ROLE_ prefix, those checks are ROLE_USER and ROLE_ADMIN, which is what the mapper stores.

An administrator who only has the composite ADMIN role still receives USER in resource_access.example-client.roles, so both annotations pass. A user with only USER can open Account and is denied on Admin.

Security tags

package com.grails.example

import org.springframework.security.authentication.AnonymousAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.core.oidc.user.OidcUser

class SecurityTagLib {

    static namespace = "app"

    def loggedIn = {attrs, body ->
        def authentication = SecurityContextHolder.context.authentication

        if(authentication && authentication.authenticated &&
           !(authentication instanceof AnonymousAuthenticationToken)) {
            out << body()
        }
    }

    def hasRole = {attrs, body ->
        def authentication = SecurityContextHolder.context.authentication

        if(!authentication || !authentication.authenticated || authentication instanceof AnonymousAuthenticationToken) {
            return
        }

        def role = attrs.role?.toString()?.toUpperCase()

        if(!role) {
            return
        }

        def requiredAuthority = role.startsWith('ROLE_') ? role:"ROLE_${role}"

        if(authentication.authorities.any {
            it.authority == requiredAuthority
        }) {
            out << body()
        }
    }

    def ifNotLoggedIn = {attrs, body ->
        def authentication = SecurityContextHolder.context.authentication

        if(!authentication || !authentication.authenticated || authentication instanceof AnonymousAuthenticationToken) {
            out << body()
        }
    }

    def currentUser = {attrs, body ->
        def authentication = SecurityContextHolder.context.authentication

        if(authentication && authentication.authenticated &&
           !(authentication instanceof AnonymousAuthenticationToken)) {

            def principal = authentication.principal

            if(principal instanceof OidcUser) {
                def user = [username : principal.preferredUsername,
                            firstName: principal.givenName,
                            lastName : principal.familyName,
                            email    : principal.email]

                out << body(user)
            }
        }
    }
}

The tags use the app namespace. <app:hasRole role="USER"> applies the same ROLE_ prefix as hasRole. <app:currentUser> reads given_name, family_name, and email from the OidcUser.

Logout form

The header posts to Spring Security’s /logout endpoint and sends the CSRF token from the request. useToken="true" on <g:form> submits Grails' synchronizer token, which is a different value and does not satisfy the CSRF filter.

<header class="site-header">
    <nav class="site-nav">
        <div class="nav-left">
            <g:link controller="home" class="nav-link">
                <g:message code='header.btn.home' />
            </g:link>
            <app:hasRole role="USER">
                <g:link controller="account" class="nav-link">
                    <g:message code='header.btn.account' />
                </g:link>
            </app:hasRole>
            <app:hasRole role="ADMIN">
                <g:link controller="admin" class="nav-link">
                    <g:message code='header.btn.admin' />
                </g:link>
            </app:hasRole>
        </div>
        <div class="nav-right">
            <app:loggedIn>
                <span class="user-name">
                    <app:currentUser>
                        ${it.firstName} ${it.lastName}
                    </app:currentUser>
                </span>
                <g:set var="csrfToken" value="${request.getAttribute('org.springframework.security.web.csrf.CsrfToken')}"/>
                <g:if test="${csrfToken instanceof java.util.function.Supplier}">
                    <g:set var="csrfToken" value="${csrfToken.get()}"/>
                </g:if>
                <form action="${createLink(uri: '/logout')}" method="post" class="logout-form">
                    <g:if test="${csrfToken}">
                        <g:hiddenField name="${csrfToken.parameterName}" value="${csrfToken.token}"/>
                    </g:if>
                    <button type="submit" class="logout-button">
                        <g:message code='header.btn.logout' />
                    </button>
                </form>
            </app:loggedIn>
             <app:ifNotLoggedIn>
                <g:link controller="account" class="login-button">
                    <g:message code='header.btn.login' />
                </g:link>
            </app:ifNotLoggedIn>
        </div>
    </nav>
</header>

Spring Security stores the token on the request under org.springframework.security.web.csrf.CsrfToken. On this Grails line that attribute may be a Supplier, so the page calls get() before writing parameterName and token. The parameter name is _csrf. The login link is a normal GET of /account, which sends an anonymous user to Keycloak.

5 Prove USER and ADMIN

Start Keycloak, export KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET, and KEYCLOAK_ISSUER, then start Grails from complete/:

cd complete
./gradlew bootRun

On Windows run gradlew.bat bootRun from the same directory. The application listens on http://localhost:8080.

Anonymous

Open /. The home page renders and the header shows Login. /account and /admin redirect to Keycloak.

USER

Sign in as the user who has only the USER client role.

  • The header shows Account and Logout. It does not show Admin.

  • /account renders the account page.

  • /admin is denied. The mapping for AuthorizationDeniedException handles that response.

ADMIN

Sign out, then sign in as the user who has the composite ADMIN client role.

  • The header shows Account and Admin.

  • /account and /admin both render.

If Admin is missing for that user, evaluate the ID token again. ADMIN must be a composite that includes USER, and both roles must appear under resource_access.example-client.roles.

Logout

Logout posts to /logout with the _csrf token. Keycloak ends its session and redirects the browser to http://localhost:8080. A missing post-logout redirect URI leaves the browser on a Keycloak error page instead.

The unit and integration tests do not call Keycloak. KeycloakAuthoritiesMapperSpec checks the claim mapping, and the controller integration specs plant an authentication with ROLE_USER or ROLE_ADMIN to prove @PreAuthorize. Run them with:

./gradlew test integrationTest

6 Do You Need Help With Grails?

Help with Apache Grails

Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.