ID tokens and UserInfo

Scope-aware identity claims in the ID token and the UserInfo endpoint, and a real OIDC flow to prove them.

  • Part 10
  • advanced
  • about 45 minutes

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:

  1. create a dedicated user;
  2. create an OIDC-capable test client;
  3. request openid profile email;
  4. redeem with PKCE;
  5. parse the returned ID Token;
  6. assert preferred_username and email;
  7. call /userinfo with the access token;
  8. 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:

  • 9e4edaf feat: 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

Next: revocation and introspection, including the subtle problem of revoking a self-contained JWT.