Skip to main content

SMART Launch

SmartLaunchService owns the entire SMART App Launch v2.2 handshake. No Spring Security OAuth2 client is used — the handshake is implemented directly for precise control over the SMART extras (patient, encounter, need_patient_banner) in the token response.

Launch flow

GET /launch?iss=http://localhost:8080/fhir&launch=TOKEN

├─ 1. SmartLaunchService.discover(iss)
│ GET iss/.well-known/smart-configuration
│ → { authorization_endpoint, token_endpoint, jwks_uri, issuer }
│ Cached by ISS, 10-minute TTL

├─ 2. generateCodeVerifier()
│ 96 random bytes → Base64URL (no padding)
│ 768-bit entropy — well above RFC 7636 minimum

├─ 3. generateCodeChallenge(verifier)
│ SHA-256(verifier) → Base64URL

├─ 4. generateState() + generateNonce()
│ 32 random bytes each → Base64URL

├─ 5. Store in SmartLaunchContext → HTTP session

└─ 6. 302 → authorization_endpoint
?response_type=code
&client_id=aj-fhir-immunization
&redirect_uri=http://localhost:8084/callback
&scope=launch+openid+fhirUser+patient/Patient.rs+patient/Immunization.rs+...
&aud=http://localhost:8080/fhir ← required by Epic and our auth server
&launch=TOKEN
&code_challenge=S256_HASH
&code_challenge_method=S256
&state=RANDOM_STATE
&nonce=RANDOM_NONCE

Callback flow

GET /callback?code=AUTH_CODE&state=STATE

├─ 1. Constant-time state comparison
│ MessageDigest.isEqual(saved, received)
│ → 302 /launch?error=invalid_state if mismatch

├─ 2. SmartLaunchService.exchangeCode(code, ctx)
│ POST token_endpoint
│ grant_type=authorization_code
│ code=AUTH_CODE
│ redirect_uri=http://localhost:8084/callback
│ client_id=aj-fhir-immunization
│ code_verifier=VERIFIER ← PKCE proof

├─ 3. Parse token response
│ access_token, refresh_token, expires_in, scope
│ patient, encounter, need_patient_banner ← SMART extras (top-level)

├─ 4. Validate id_token (RS256)
│ Fetch JWKS from jwks_uri
│ Check: alg=RS256, iss, aud=client_id, exp, nonce
│ Extract: name, fhirUser → SmartLaunchContext

├─ 5. Clear pkceVerifier from session

└─ 6. 302 → /dashboard

PKCE implementation

// 96 random bytes — well above RFC 7636 minimum of 32 bytes
byte[] verifierBytes = new byte[96];
secureRandom.nextBytes(verifierBytes);
String codeVerifier = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(verifierBytes);

// S256 challenge
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(verifier.getBytes(StandardCharsets.US_ASCII));
String codeChallenge = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(digest);

id_token validation

The extractIdTokenClaims private method uses Nimbus JOSE:

ConfigurableJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();

// Fetch JWKS from auth server and use RS256 key selector
processor.setJWSKeySelector(new JWSVerificationKeySelector<>(
JWSAlgorithm.RS256,
new RemoteJWKSet<>(new URL(jwksUri))));

// Require iss, aud, sub, iat, exp + nonce
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
new JWTClaimsSet.Builder()
.issuer(issuer)
.audience(props.clientId())
.build(),
Set.of("sub", "iat", "exp", "iss", "aud")
));

JWTClaimsSet claims = processor.process(idToken, null);

id_token validation is non-fatal — if it fails, the access token is still used and the clinician name / fhirUser are simply absent from the context. FHIR data access is not blocked.

Proactive token refresh

TokenRefreshFilter intercepts every /api/** request and calls SmartLaunchService.refreshIfNeeded():

Instant threshold = ctx.getExpiresAt()
.minusSeconds(props.tokenRefreshBufferSeconds()); // default 120s

if (Instant.now().isAfter(threshold)) {
// POST token_endpoint with grant_type=refresh_token
// Update ctx.accessToken, ctx.expiresAt
// If refresh_token rotated: update ctx.refreshToken
}

The 120-second buffer means a clinician working in a long consultation never sees a 401 — the token refreshes before they click the next FHIR request.


Next: FHIR Queries →