Skip to main content

Launch Tokens

What launch tokens areโ€‹

A launch token is a short-lived, opaque, single-use string that binds a specific patient (and optionally an encounter) to an OAuth2 authorize request. This is the mechanism Epic uses in Hyperspace โ€” when a clinician clicks an app button, Epic generates a launch token and passes it to the app as ?launch=xxx.

This server implements the same pattern without Epic.


Token lifecycleโ€‹

stateDiagram-v2
[*] --> Created: POST /portal/launch
Created --> Sent: redirect to SMART client
Sent --> Resolved: POST /oauth2/token (SmartTokenCustomizer)
Resolved --> Used: markUsed()
Used --> Purged: @Scheduled every 10 minutes
Created --> Expired: expiresAt < now (5 minutes)
Expired --> Purged: @Scheduled

Creating a launch tokenโ€‹

LaunchContextService.createLaunchToken() is called by LaunchPortalController when a clinician clicks Launch App:

public String createLaunchToken(String patientFhirId,
String encounterFhirId,
boolean needPatientBanner,
String clientId,
String launchedBy) {
// 32 random bytes โ†’ 43-char URL-safe base64 (256-bit entropy)
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);

LaunchContext context = new LaunchContext(
token, patientFhirId, encounterFhirId,
needPatientBanner, clientId, launchedBy
);
// expiresAt = now + 5 minutes (set in LaunchContext constructor)
repository.save(context);

return token; // passed to SMART client as ?launch=xxx
}

The token is then embedded in the redirect URL sent to the SMART client:

http://localhost:8080/launch
?iss=http://localhost:8080/fhir
&launch=Xk9mR2pL... โ† 43-char random token

Resolving a launch tokenโ€‹

Resolution happens inside SmartTokenCustomizer during the token exchange (POST /oauth2/token):

public LaunchContext resolveLaunchToken(String token) {
LaunchContext context = repository.findByTokenAndUsedFalse(token)
.orElseThrow(() -> new LaunchTokenException(
"Launch token not found, already used, or expired"));

if (context.isExpired()) {
throw new LaunchTokenException("Launch token expired");
}

// Mark used โ€” prevents replay
context.markUsed();
repository.save(context);

return context;
}

After resolution, SmartTokenCustomizer adds the patient/encounter data to the JWT claims:

context.getClaims().claim("patient",  launchContext.getPatientFhirId());
context.getClaims().claim("encounter", launchContext.getEncounterFhirId()); // if present
context.getClaims().claim("need_patient_banner", launchContext.isNeedPatientBanner());

Single-use enforcementโ€‹

The repository query uses findByTokenAndUsedFalse() โ€” a used token returns Optional.empty() immediately, throwing LaunchTokenException before any patient data is accessed.

The sequence is:

  1. Find token with used = false
  2. Check not expired
  3. Set used = true and save
  4. Return context

Step 3 happens before step 4 is returned. Even if two concurrent requests arrive with the same token, only one will find it with used = false โ€” database constraints and JPA transaction isolation handle the race condition.


Token expiry and purgingโ€‹

The expiresAt field is set to now + 300 seconds in the LaunchContext constructor. This is checked explicitly in resolveLaunchToken() as a defence-in-depth measure even though the findByTokenAndUsedFalse() query would normally prevent expired tokens from being returned.

Expired tokens are purged every 10 minutes by a @Scheduled method:

@Scheduled(fixedDelay = 600_000)
public void purgeExpiredTokens() {
int deleted = repository.deleteExpiredBefore(Instant.now());
if (deleted > 0) {
log.debug("Purged {} expired launch tokens", deleted);
}
}

@EnableScheduling on SmartFhirServerApplication is required for this to fire.


LaunchContext JPA entityโ€‹

ColumnTypeDescription
idUUID (PK)Auto-generated
tokenString (unique)43-char URL-safe base64 random token
patient_fhir_idStringFHIR Patient resource ID
encounter_fhir_idString (nullable)FHIR Encounter resource ID
need_patient_bannerbooleanDefault: true
client_idStringRegistered app that initiated this launch
launched_byStringClinician username
expires_atInstantnow + 5 minutes
usedbooleanfalse initially, true after resolution
created_atInstantTimestamp for audit