1
0
Files
spring-auth-demo/docs/17-keycloak-setup.md
Ankur Mhatre e9381dc5be Add Spring Authorization Server project: OAuth2/OIDC provider, client and resource server
Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.

Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:

  - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
    in 7.0, and both configuration classes moved into spring-security-config
  - ClientSettings.requireProofKey flipped from false to true, on the authorization server
    (1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
  - requireProofKey(false) does not make PKCE optional for a public client; the code
    verifier is that client's only authentication at the token endpoint
  - MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
    API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called

Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
2026-08-24 08:20:38 +05:30

5.6 KiB

17 — Keycloak setup, and the three ways the realm import bites

← what an unknown kid costs · next: resource server checklist →

docker compose -f oauth2-resource-server/docker/compose.yaml up -d
cd oauth2-resource-server && ./scripts/run-rs.sh keycloak,roles
./scripts/keycloak-demo.sh

Transcript: rs-keycloak.txt. Keycloak 26.7.2, released 19 August 2026.

The point of running against a real issuer is that nothing in the resource server changes. The application code is identical to the stub runs; one property differs:

spring.security.oauth2.resourceserver.jwt.issuer-uri: http://localhost:8080/realms/demo

Pin KC_HOSTNAME

Keycloak derives the iss claim, and the issuer in its discovery document, from the request host unless you pin it. A token fetched through localhost:8080 and the same token fetched through keycloak:8080 from inside a Docker network carry different issuers, and chapter 12 explains why JwtIssuerValidator will refuse one of them.

environment:
  KC_HOSTNAME: http://localhost:8080
  KC_HOSTNAME_STRICT: "false"

This is the fix for the majority of “the token works in curl but not from the application” reports. Both must agree with the value your resource servers are configured with, from wherever they run.

Keycloak does not add an aud for you

An access token from a bare Keycloak client has no aud naming your resource server. Since chapter 12 argues you should be validating aud, you need a mapper:

{
  "name": "reports-api-audience",
  "protocolMapper": "oidc-audience-mapper",
  "config": {
    "included.client.audience": "reports-api",
    "access.token.claim": "true"
  }
}

Note included.client.audience for a client that exists in the realm, versus included.custom.audience for an arbitrary string. Using the former means the audience value is checked against a real client at configuration time.

A clientScopes key in the import replaces the built-ins

This one cost a rebuild. A realm export/import that declares:

"clientScopes": [ { "name": "reports:read", ... } ]

does not add that scope. It replaces the entire set, and Keycloak's built-in profile, email, roles, acr, basic and web-origins scopes are never created. Tokens from that realm then have:

  • no realm_access claim, because the roles scope is what adds it
  • no preferred_username, because the profile scope is what adds it

which looks exactly like a broken authorities converter, and sends you to chapter 14 to debug something that is not wrong. Verified on 26.7.2 by listing the realm's client scopes through the admin API after import:

=== realm client scopes available ===
  offline_access
  reports:read

realm-demo.json therefore declares no clientScopes at all, and gets permissions across using realm roles and client roles instead.

Users need a name

A user in a realm import with no firstName and lastName fails the password grant with a message that names nothing useful:

{"error":"invalid_grant","error_description":"Account is not fully set up"}

The realm's default required actions want a complete profile. Supply the names, and "requiredActions": [].

Read the JWK Set before assuming it holds one key

keys published: 2
 kid=drdWA3YaK3PfH8uKORPsqYsf30mlkxtLKJdvYFzWqO4 alg=RSA-OAEP use=enc kty=RSA
 kid=B8LKu8nKy9b_CCTMqaZBdRH7dH1ASVjg5Do5hElKpQE alg=RS256 use=sig kty=RSA

A JWK Set contains keys you must not verify signatures with. Nimbus's JWSVerificationKeySelector filters on use and alg before matching kid, so this is handled — but if you are writing anything that reads a JWK Set yourself, filter on use: "sig" rather than taking keys[0].

typ is a claim as well as a header

A Keycloak access token has typ: "JWT" in the JOSE header and typ: "Bearer" in the claim set. JwtTypeValidator reads the header, so Keycloak passes the default type check. Nothing validates the claim. Do not write a validator that reads jwt.getClaimAsString("typ") expecting the header value.

start-dev resets everything

Including the signing keys. Every restart is a new realm from the import, and a new kid. Convenient for the rotation work in chapter 15; a surprise if you were expecting yesterday's tokens to still verify.

What a real access token looks like here

{
  "iss": "http://localhost:8080/realms/demo",
  "aud": "reports-api",
  "typ": "Bearer",
  "scope": "email profile",
  "preferred_username": "alice",
  "realm_access":    { "roles": ["USER"] },
  "resource_access": { "reports-api": { "roles": ["reports-reader"] } }
}

Which produces, with the converter from chapter 14:

"authorities": ["FACTOR_BEARER", "ROLE_USER", "ROLE_reports-reader", "SCOPE_email", "SCOPE_profile"]

There is no client_id claim, which is why JwtValidators.createAtJwtValidator() — which requires one — refuses Keycloak tokens unless reconfigured. Keycloak puts the client in azp.


← what an unknown kid costs · next: resource server checklist →


For the comparison Keycloak invites — what it costs to run the equivalent yourself in Spring — see authorization-server/, and in particular 10 — Should you run one at all.