Skip to main content

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โ€‹

ClassRole
SmartTokenCustomizerAdds SMART extras as JWT claims (inside the access token)
SmartTokenResponseConverterReads 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:

  1. Writes standard fields: access_token, token_type, expires_in, scope, refresh_token
  2. Decodes the JWT (without verifying โ€” it was just signed by this server)
  3. Extracts patient, encounter, need_patient_banner from JWT claims
  4. Writes them as top-level JSON fields
  5. Copies id_token from additionalParameters (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.