Skip to main content

Token Response Converter

The problemโ€‹

Spring Authorization Server's default token response returns only standard OAuth2 fields:

{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "launch openid patient/Patient.rs",
"refresh_token": "eyJ..."
}

SMART App Launch v2.2 requires patient, encounter, and need_patient_banner as top-level fields in this same JSON object. The SMART client's SmartTokenResponse reads them with Jackson's @JsonProperty("patient") โ€” if they are only inside the JWT payload (not in the response body), they will be null.

The solution: SmartTokenResponseConverterโ€‹

This class implements Spring Security's AuthenticationSuccessHandler and is registered as the token endpoint success handler in AuthorizationServerConfig:

http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.tokenEndpoint(token -> token
.accessTokenResponseHandler(tokenResponseConverter)
);

On every successful token exchange it:

  1. Builds the standard OAuth2 response fields (access_token, token_type, expires_in, scope, refresh_token)
  2. Decodes the JWT and extracts the SMART extras that SmartTokenCustomizer added to the claims
  3. Promotes them to top-level fields in the JSON response body
  4. Writes the complete JSON response

Full token responseโ€‹

{
"access_token": "eyJhbGciOiJSUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "launch openid fhirUser patient/Patient.rs patient/Condition.rs",
"refresh_token": "eyJhbGciOiJSUzI1NiJ9...",
"patient": "ePatient-8675309",
"encounter": "eEncounter-001",
"need_patient_banner": true,
"id_token": "eyJhbGciOiJSUzI1NiJ9..."
}

Standalone launchโ€‹

For standalone launch (no launch token in the authorize request), SmartTokenCustomizer does not add patient or encounter to the JWT. SmartTokenResponseConverter handles this gracefully โ€” extractSmartExtras() tries to read the claims and skips them if absent. The response only contains the fields that were set.

The extraction logicโ€‹

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);
}

This decodes the JWT without verifying the signature โ€” it is safe here because this converter is called only after Spring Authorization Server has already issued and signed the token. We are reading a token we just created.


Next: Discovery Endpoint โ†’