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_HOMEset. -
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 |
|
Client authentication |
On |
Standard flow |
On |
Valid redirect URIs |
|
Valid post logout redirect URIs |
|
Web origins |
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 |
|
Client ID |
|
Client Role prefix |
leave blank |
Multivalued |
On |
Token Claim Name |
|
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/USERonly. -
The other user gets
example-client/ADMINonly. Composite membership suppliesUSER.
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.
-
/accountrenders the account page. -
/adminis denied. The mapping forAuthorizationDeniedExceptionhandles that response.
ADMIN
Sign out, then sign in as the user who has the composite ADMIN client role.
-
The header shows Account and Admin.
-
/accountand/adminboth 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.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.