# Keycloak Integration End to End

**GitHub:** [github.com/mnafshin/oauth](https://github.com/mnafshin/oauth)

* * *

## Goal

Stand up Keycloak locally, create realm clients, and connect the demos from posts 6–9:

*   **Thymeleaf Spring Boot** (`orders-web` on port 8080)
    
*   **Plain Java** (`orders-web` on port 8082)
    
*   **Shared SPA BFF** (`spa-bff` on port 8083) for React and Angular
    

Okta and Auth0 follow the same OIDC steps with different admin UIs — application configuration changes only `issuer-uri`, `client-id`, and secrets.

* * *

## Quick start (demo repo)

If you cloned [oauth-demo](https://github.com/mnafshin/oauth), skip manual realm setup — import runs on first start:

```bash
make start-keycloak
# or:
cd modules/keycloak && docker compose up -d
```

This starts Keycloak **26.6.3** on port **8081** and imports `realms/oauth-demo-realm.json`:

| Resource | Value |
| --- | --- |
| Admin console | http://localhost:8081/admin (`admin` / `admin`) |
| Realm | `oauth-demo` |
| Client `orders-web` | confidential — Thymeleaf + plain Java |
| Client `spa-bff` | confidential — shared BFF for React + Angular |
| Client secret `orders-web` | `orders-web-secret` (local demo only) |
| Client secret `spa-bff` | `spa-bff-secret` (local demo only) |
| Test user | `alice` / `password` |
| Client role | `app-user` on both clients (assigned to `alice`) |

### Redirect URIs (must match exactly)

| App | Redirect URI |
| --- | --- |
| Thymeleaf Spring Boot (:8080) | `http://localhost:8080/login/oauth2/code/keycloak` |
| Plain Java (:8082) | `http://localhost:8082/callback` |
| SPA BFF (:8083) | `http://localhost:8083/login/oauth2/code/keycloak` |

### Run the Thymeleaf demo

```bash
export KEYCLOAK_CLIENT_SECRET=orders-web-secret
make start-spring-boot
```

Visit `http://localhost:8080/dashboard` and log in as `alice`.

### Run the SPA stack

```bash
export KEYCLOAK_SPA_BFF_SECRET=spa-bff-secret
make start-bff        # terminal 1
make start-react      # or: make start-angular
```

Open http://localhost:5173 or http://localhost:4200 — details in SPA post.

`start-dev` is for local development only — not production topology.

* * *

## Manual setup (understand what the import contains)

Use this path if you are not using the repo import, or you want to learn the admin console steps behind the JSON file.

### Start Keycloak with Docker

```bash
docker run --name keycloak -p 8081:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.6.3 start-dev
```

| URL | Purpose |
| --- | --- |
| `http://localhost:8081/admin` | Admin console |
| `http://localhost:8081/realms/oauth-demo/...` | Realm endpoints (after creation) |

* * *

## Create realm

1.  Admin console → **Create realm**
    
2.  Name: `oauth-demo`
    
3.  Enabled: ON
    

Realms isolate users, clients, and keys. Use separate realms per environment (or separate Keycloak instances).

* * *

## Create client for Spring Boot (`orders-web`)

**Clients → Create client**

| Setting | Value |
| --- | --- |
| Client ID | `orders-web` |
| Client type | OpenID Connect |
| Client authentication | **ON** (confidential — has secret) |

**Capability config:**

*   Client authentication: ON
    
*   Authorization: OFF (we use simple OIDC login first)
    
*   Standard flow: **ON** (Authorization Code)
    
*   Direct access grants: OFF (no password grant)
    

**Login settings:**

| Field | Value |
| --- | --- |
| Root URL | `http://localhost:8080` |
| Valid redirect URIs | `http://localhost:8080/login/oauth2/code/keycloak`, `http://localhost:8082/callback` |
| Valid post logout redirect URIs | `http://localhost:8080/` |
| Web origins | `http://localhost:8080` |

Save → **Credentials** tab → copy **Client secret** → `export KEYCLOAK_CLIENT_SECRET=...`

**Clients → orders-web → Roles → Create** `app-user` (application role for the Thymeleaf dashboard).

* * *

## Create client for the SPA BFF (`spa-bff`)

Repeat the same steps with a **second client** for the shared BFF (post 9):

| Setting | Value |
| --- | --- |
| Client ID | `spa-bff` |
| Client authentication | ON (confidential) |
| Standard flow | ON |
| Root URL | `http://localhost:8083` |
| Valid redirect URIs | `http://localhost:8083/login/oauth2/code/keycloak` |
| Valid post logout redirect URIs | `http://localhost:5173/*`, `http://localhost:4200/*` |
| Web origins | `http://localhost:8083`, `http://localhost:5173`, `http://localhost:4200` |

**Credentials** → copy secret → `export KEYCLOAK_SPA_BFF_SECRET=...`

**Roles → Create** `app-user` on `spa-bff`. Assign it to `alice` under **Users → Role mapping → Client roles**.

Why a separate client? The BFF runs on a different port, uses REST instead of Thymeleaf, and serves multiple SPA origins. Keeping clients separate mirrors how teams split server-rendered apps from API gateways in production.

* * *

## Create a test user

**Users → Create user**

*   Username: `alice`
    
*   Email verified: ON  
    **Credentials** tab → set password (e.g. `password`) → Temporary: OFF  
    **Role mapping → Client roles** → select `orders-web` → assign `app-user`
    

Without `app-user`, `alice` can log in but the app returns **403** on protected routes (`@PreAuthorize("hasRole('app-user')")` in posts 6 and 9).

## Verify OIDC discovery

```bash
curl -s http://localhost:8081/realms/oauth-demo/.well-known/openid-configuration | jq .
```

Confirm:

*   `authorization_endpoint`
    
*   `token_endpoint`
    
*   `jwks_uri`
    
*   `issuer` = `http://localhost:8081/realms/oauth-demo`
    

Spring `issuer-uri` must match `issuer` exactly (including host and port).

* * *

## Spring configuration (recap)

**Thymeleaf app** (`modules/spring-boot`):

```properties
spring.security.oauth2.client.registration.keycloak.client-id=orders-web
spring.security.oauth2.client.registration.keycloak.client-secret=${KEYCLOAK_CLIENT_SECRET:orders-web-secret}
spring.security.oauth2.client.registration.keycloak.scope=openid,profile,email
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8081/realms/oauth-demo
```

**SPA BFF** (`modules/frontend/bff`):

```properties
spring.security.oauth2.client.registration.keycloak.client-id=spa-bff
spring.security.oauth2.client.registration.keycloak.client-secret=${KEYCLOAK_SPA_BFF_SECRET:spa-bff-secret}
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8081/realms/oauth-demo
app.cors-allowed-origins[0]=http://localhost:5173
app.cors-allowed-origins[1]=http://localhost:4200
app.allowed-return-urls[0]=http://localhost:5173
app.allowed-return-urls[1]=http://localhost:4200
```

Run from the repo root:

```bash
export KEYCLOAK_CLIENT_SECRET=orders-web-secret
make start-spring-boot

export KEYCLOAK_SPA_BFF_SECRET=spa-bff-secret
make start-bff
```

* * *

## Custom scopes and API audience

When your API validates access tokens:

### 1\. Create client scope (optional)

**Client scopes → Create**

*   Name: `orders:read`
    
*   Include in token scope: ON
    

Assign to `orders-web` under **Client scopes** tab.

### 2\. Create resource server client (API)

Some teams use a separate **audience** client or Keycloak **authorization services**. Minimal approach:

*   API validates JWTs minted by the same realm
    
*   Check `scope` claim contains `orders:read`
    
*   Or map client roles to service permissions
    

### 3\. Spring resource server (snippet)

For a stateless API module:

```gradle
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
```

```properties
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8081/realms/oauth-demo
```

```java
http.authorizeHttpRequests(auth -> auth
    .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders:read")
    .anyRequest().authenticated()
).oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
```

* * *

## Client roles in tokens

The manual steps above create `app-user` on `orders-web` and assign it to `alice`. The imported realm JSON does the same under `roles.client` and `users[].clientRoles`.

Assigned client roles show up under `resource_access.<client-id>.roles` in the **access token** by default. Keycloak does not always copy them into the ID token unless you enable **Add to ID token** on the client-roles protocol mapper (the imported realm JSON does this for both `orders-web` and `spa-bff`). The plain Java demo checks the access token; Spring maps from the ID token — enable the mapper if protected routes return 403 after login.

* * *

## Logout (complete the story)

The demo repo configures RP-initiated OIDC logout in `SecurityConfig`:

```java
@Bean
LogoutSuccessHandler oidcLogoutSuccessHandler(
        ClientRegistrationRepository clientRegistrationRepository) {
    OidcClientInitiatedLogoutSuccessHandler handler =
            new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
    handler.setPostLogoutRedirectUri("{baseUrl}/");
    return handler;
}
```

Wire it on the filter chain with `.logout(logout -> logout.logoutSuccessHandler(oidcLogoutSuccessHandler))`.

Keycloak must allow the post-logout redirect URI on the `orders-web` client (`http://localhost:8080/`). The imported realm JSON sets this via the client attribute `post.logout.redirect.uris`.

Under the hood, Spring redirects the browser to:

```text
http://localhost:8081/realms/oauth-demo/protocol/openid-connect/logout
  ?client_id=orders-web
  &post_logout_redirect_uri=http://localhost:8080/
  &id_token_hint=...
```

Without this, Alice logs out of your app but her Keycloak SSO session remains.

* * *

## Export realm for reproducibility

The demo repo already ships `modules/keycloak/realms/oauth-demo-realm.json`. To regenerate after UI changes:

**Realm settings → Action → Partial export** (or use [Keycloak Terraform provider](https://registry.terraform.io/providers/keycloak/keycloak/latest/docs) for teams).

Check into git under `modules/keycloak/realms/` — exclude production secrets; inject via env or CI. The imported demo file includes a fixed local secret for convenience; treat that as dev-only.

* * *

## Keycloak vs Okta (same integration, different ops)

| Task | Keycloak | Okta |
| --- | --- | --- |
| Create app | Clients → Create | Applications → App integrations |
| Issuer | `/realms/{realm}` | `https://{org}.okta.com/oauth2/default` |
| Get secret | Credentials tab | Client secret in app settings |
| Local dev | Docker on laptop | Okta developer org (cloud) |

Spring `application.properties` structure is identical; only URLs and IDs change.

* * *

## What Keycloak still does not do

Even with a working realm:

*   Your business authorization rules
    
*   Session storage scaling (Redis, JDBC)
    
*   Production HA, backups, upgrades
    
*   User onboarding emails and product UX
    

Re-read the identity providers post before go-live.

* * *

## Go deeper

### Identity brokering

Keycloak can front Google or Azure AD — users still see Keycloak realm URLs; brokers handle federation.

### Organizations and multi-tenancy

Multiple realms vs single realm with attributes — choose based on isolation requirements.

### Harderning for production

*   `start` with optimized profile, PostgreSQL backend
    
*   Reverse proxy TLS termination
    
*   Disable admin console on public internet
    
*   Brute force detection enabled (Realm settings → Security defenses)
    

* * *

## What you should know after this post

*   \[ \] You can run Keycloak locally and create confidential OIDC clients with client roles
    
*   \[ \] Thymeleaf and BFF apps log in via discovered OIDC endpoints
    
*   \[ \] You know how to assign client roles (e.g. `app-user`) and map them in Spring
    
*   \[ \] You know how to add scopes and resource server JWT validation
    
*   \[ \] You have a path to export realm config and plan production hardening
