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:
- Find token with
used = false - Check not expired
- Set
used = trueand save - 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โ
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Auto-generated |
token | String (unique) | 43-char URL-safe base64 random token |
patient_fhir_id | String | FHIR Patient resource ID |
encounter_fhir_id | String (nullable) | FHIR Encounter resource ID |
need_patient_banner | boolean | Default: true |
client_id | String | Registered app that initiated this launch |
launched_by | String | Clinician username |
expires_at | Instant | now + 5 minutes |
used | boolean | false initially, true after resolution |
created_at | Instant | Timestamp for audit |