ID tokens and UserInfo
Scope-aware identity claims in the ID token and the UserInfo endpoint, and a real OIDC flow to prove them.
You will build
an OIDC claims service and a token customizer that treats access tokens and ID tokens differently
You will understand
why claims follow the authorised scopes rather than the user table, what nonce is for, and why UserInfo takes the access token
- one service, one customizer
Our server already has OIDC enabled, but an ID Token is much more useful when it exposes intentional identity claims.
We will support:
openid → subject / OIDC flow
profile → preferred_username
email → email
We will not put password/account-state data into identity claims, and we keep roles primarily in the access token because they are application authorization data.
Add email to the development client
The development client should request:
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope(OidcScopes.EMAIL)
.scope("read")
.scope("write")
If the client already exists in PostgreSQL, update the existing registered client instead of assuming a development database is always empty.
Build a small OIDC claims service
@Service
public class OidcUserClaimsService {
private final UserRepository userRepository;
public Map<String, Object> loadClaims(
String username,
Set<String> authorizedScopes
) {
UserEntity user = userRepository
.findByUsername(username)
.orElseThrow(() ->
new IllegalStateException(
"OIDC user no longer exists"
)
);
Map<String, Object> claims = new LinkedHashMap<>();
if (authorizedScopes.contains(OidcScopes.PROFILE)) {
claims.put("preferred_username", user.getUsername());
}
if (authorizedScopes.contains(OidcScopes.EMAIL)) {
claims.put("email", user.getEmail());
}
return claims;
}
}
The scope check matters. If the client did not receive email, do not expose email merely because the server happens to know it.
Customize access tokens and ID Tokens differently
Our existing token customizer already adds roles to access tokens.
Extend it:
return context -> {
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
// add roles
return;
}
if (OidcParameterNames.ID_TOKEN.equals(
context.getTokenType().getValue()
)) {
Map<String, Object> claims =
oidcUserClaimsService.loadClaims(
context.getPrincipal().getName(),
context.getAuthorizedScopes()
);
context.getClaims().claims(target ->
target.putAll(claims)
);
}
};
This keeps token purpose explicit:
flowchart TD
Context[JwtEncodingContext]
T{Token type}
A[Access token]
I[ID Token]
Roles[roles claim]
Identity[preferred_username / email]
Context --> T
T -->|access_token| A --> Roles
T -->|id_token| I --> Identity
Run a complete OIDC flow
Generate a fresh PKCE pair and open:
open "http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A8081%2Fcallback&scope=openid%20profile%20email%20read&state=oidc123&nonce=test-nonce-123&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"
Why nonce? In OIDC, it lets the client associate the resulting ID Token with the authentication request and provides replay-related protection for that context.
Redeem the code exactly as in Part 3.
Check:
echo "$TOKEN_RESPONSE" | jq -r '.scope'
It must include:
openid
Then:
ID_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.id_token')
export ID_TOKEN
Decode the ID Token safely
python3 - <<'PY'
import os, json, base64
token = os.environ["ID_TOKEN"]
parts = token.split(".")
if len(parts) != 3:
raise SystemExit(f"ID_TOKEN is not a JWT: {token!r}")
payload = parts[1]
payload += "=" * (-len(payload) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
PY
Expect claims like:
{
"iss": "http://localhost:9000",
"sub": "user",
"aud": ["demo-client"],
"nonce": "test-nonce-123",
"preferred_username": "user",
"email": "user@example.com"
}
Call UserInfo
Use the access token, not the ID Token:
curl -i \
-H "Authorization: Bearer $ACCESS_TOKEN" \
http://localhost:9000/userinfo
Expected body:
{
"sub": "user",
"preferred_username": "user",
"email": "user@example.com"
}
Scope filtering test
Repeat the flow without email:
scope=openid profile read
The resulting identity response should not expose the email claim.
This is a small but important privacy principle:
Claims should be driven by the authorization context, not by whatever fields happen to exist in the user table.
Troubleshooting id_token = null
The first diagnostic is:
echo "$TOKEN_RESPONSE" | jq -r '.scope'
If openid is missing, the final authorization was OAuth-only.
If openid is present but there is still no ID Token, verify OIDC is enabled:
authorizationServer.oidc(Customizer.withDefaults());
Old development consent can also confuse manual retesting. During local development, you can remove the existing consent/authorization rows for the demo principal/client and run a completely fresh flow.
Integration test
Your protocol integration test should:
- create a dedicated user;
- create an OIDC-capable test client;
- request
openid profile email; - redeem with PKCE;
- parse the returned ID Token;
- assert
preferred_usernameandemail; - call
/userinfowith the access token; - assert the same authorized identity claims.
This test verifies the actual OIDC protocol behavior, not just the service method.
The code
This part corresponds to these commits in the repository:
9e4edaffeat: enforce OAuth token revocation
The identity claims were committed together with the revocation work from Part 11, so this checkout includes both:
git checkout 9e4edaf
References
- OpenID Connect Core 1.0: https://openid.net/specs/openid-connect-core-1_0.html
- Spring Authorization Server UserInfo guide: https://docs.spring.io/spring-authorization-server/reference/guides/how-to-userinfo.html
- Spring Authorization Server protocol endpoints: https://docs.spring.io/spring-authorization-server/reference/protocol-endpoints.html
Next: revocation and introspection, including the subtle problem of revoking a self-contained JWT.