Token Customizer
The problemโ
Spring Authorization Server's default token response contains only standard OAuth2 fields:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "launch openid patient/Patient.rs",
"refresh_token": "eyJ..."
}
SMART on FHIR requires additional top-level fields:
{
"access_token": "eyJ...",
"patient": "ePatient-123", โ must be top-level
"encounter": "eEnc-456", โ must be top-level
"need_patient_banner": true, โ must be top-level
"id_token": "eyJ..."
}
The SMART client's SmartTokenResponse reads patient, encounter, and need_patient_banner directly from the raw JSON response body using Jackson. If they are only inside the JWT claims, SmartTokenResponse.patient() will be null.
Two classes, two rolesโ
| Class | Role |
|---|---|
SmartTokenCustomizer | Adds SMART extras as JWT claims (inside the access token) |
SmartTokenResponseConverter | Reads them back from the JWT and writes them as top-level response body fields |
Both are needed. SmartTokenCustomizer ensures the data is in the JWT for any client that inspects the token directly. SmartTokenResponseConverter ensures the data is in the response body for clients (like ours) that read the raw JSON.
SmartTokenCustomizerโ
SmartTokenCustomizer implements OAuth2TokenCustomizer<JwtEncodingContext> and is called by Spring Authorization Server before signing every JWT.
Extracting the launch tokenโ
The SMART client sent ?launch=abc123 in the authorize request. Spring Authorization Server stores the full OAuth2AuthorizationRequest object as an attribute on the OAuth2Authorization. The correct API to retrieve it is:
OAuth2AuthorizationRequest authRequest = context.getAuthorization()
.getAttribute(OAuth2AuthorizationRequest.class.getName());
String launchToken = null;
if (authRequest != null) {
Object lt = authRequest.getAdditionalParameters().get("launch");
if (lt != null) launchToken = lt.toString();
}
Do not use "additional_parameters" as a string attribute key. That is an internal implementation detail of Spring Authorization Server that is not part of the public API and is not reliably populated in all versions. OAuth2AuthorizationRequest.class.getName() is the documented stable API for accessing the original authorization request object.
Adding claimsโ
if (launchToken != null) {
LaunchContext ctx = launchContextService.resolveLaunchToken(launchToken);
context.getClaims().claim("patient", ctx.getPatientFhirId());
if (ctx.getEncounterFhirId() != null)
context.getClaims().claim("encounter", ctx.getEncounterFhirId());
context.getClaims().claim("need_patient_banner", ctx.isNeedPatientBanner());
}
id_token customizationโ
When id_token is being issued (OIDC flow), the customizer calls IdTokenBuilder:
if (context.getTokenType().getValue().equals("id_token")) {
var claims = idTokenBuilder.buildClaims(context.getPrincipal().getName());
claims.forEach((key, value) -> context.getClaims().claim(key, value));
}
This adds name (clinician display name) and fhirUser (e.g. Practitioner/eProvider-ABC123) to the id_token.
SmartTokenResponseConverterโ
Registered as the token endpoint AuthenticationSuccessHandler in AuthorizationServerConfig:
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.tokenEndpoint(token -> token
.accessTokenResponseHandler(tokenResponseConverter)
);
On every successful token exchange, onAuthenticationSuccess() runs instead of Spring's default writer. It:
- Writes standard fields:
access_token,token_type,expires_in,scope,refresh_token - Decodes the JWT (without verifying โ it was just signed by this server)
- Extracts
patient,encounter,need_patient_bannerfrom JWT claims - Writes them as top-level JSON fields
- Copies
id_tokenfromadditionalParameters(Spring puts it there for OIDC flows)
private void extractSmartExtras(String jwtValue, Map<String, Object> target) {
SignedJWT jwt = SignedJWT.parse(jwtValue);
JWTClaimsSet claims = jwt.getJWTClaimsSet();
String patient = claims.getStringClaim("patient");
if (patient != null) target.put("patient", patient);
String encounter = claims.getStringClaim("encounter");
if (encounter != null) target.put("encounter", encounter);
Object banner = claims.getClaim("need_patient_banner");
if (banner != null) target.put("need_patient_banner", banner);
}
What the final token response looks likeโ
{
"access_token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJkci5zbWl0aCIsInBhdGllbnQiOiJlWFhYIn0...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "launch openid fhirUser patient/Patient.rs patient/Condition.rs",
"refresh_token": "eyJhbGciOiJSUzI1NiJ9...",
"patient": "ePatient-ABC123",
"encounter": "eEnc-789",
"need_patient_banner": true,
"id_token": "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJkci5zbWl0aCIsIm5hbWUiOiJEci4gSmFuZSBTbWl0aCJ9..."
}
This is exactly what the SMART client's SmartTokenResponse expects and what SmartContextExtractor reads to build SmartLaunchContext.