Skip to main content

Discovery Endpoint

What SMART clients expectโ€‹

When a SMART client receives ?iss=http://localhost:8080/fhir, it fetches:

GET http://localhost:8080/fhir/.well-known/smart-configuration

This must return a JSON document identifying the auth server endpoints. The HAPI FHIR JPA server on port 8080 does not know about this โ€” it needs to be forwarded to this auth server on port 9000.


SmartDiscoveryControllerโ€‹

SmartDiscoveryController serves GET /.well-known/smart-configuration on this server at port 9000.

The response contains exactly the fields our SMART client's SmartConfiguration record maps:

config.put("authorization_endpoint", issuer + "/oauth2/authorize");
config.put("token_endpoint", issuer + "/oauth2/token");
config.put("token_endpoint_auth_methods_supported", List.of("none"));
config.put("scopes_supported", List.of("launch", "launch/patient", "openid", ...));
config.put("response_types_supported", List.of("code"));
config.put("capabilities", List.of(
"launch-ehr", "launch-standalone", "client-public",
"context-ehr-patient", "context-ehr-encounter",
"permission-patient", "sso-openid-connect"
));
config.put("code_challenge_methods_supported", List.of("S256"));
config.put("jwks_uri", issuer + "/oauth2/jwks");
config.put("issuer", issuer);

Why capabilities matters: the SMART client's SmartConfiguration.supportsEhrLaunch() checks for "launch-ehr" in this list. Missing it causes a warning but doesn't block the flow.

Why S256 matters: the SMART client's SmartConfiguration.supportsPkceS256() checks for "S256" in code_challenge_methods_supported. Missing it would skip PKCE โ€” which this server rejects.


SmartDiscoveryProxyFilterโ€‹

The SMART client sends discovery requests to port 8080 (the ISS). This filter intercepts those requests on the HAPI server and forwards them to port 9000. Responses are cached for 60 seconds to avoid an outbound HTTP call on every SMART launch request โ€” the discovery document almost never changes between deployments.

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().endsWith("/.well-known/smart-configuration");
}

@Override
protected void doFilterInternal(...) {
// Serve from 60-second cache if valid
CachedResponse cached = cache.get();
if (cached != null && cached.isValid()) {
writeResponse(response, cached.statusCode, cached.body);
return;
}

// Cache miss โ€” proxy to auth server
String targetUrl = authServerUrl + "/.well-known/smart-configuration";
HttpResponse<String> proxyResponse = httpClient.send(
HttpRequest.newBuilder(URI.create(targetUrl)).GET().build(),
HttpResponse.BodyHandlers.ofString()
);
if (proxyResponse.statusCode() == 200) {
cache.set(new CachedResponse(proxyResponse.statusCode(), proxyResponse.body()));
}
writeResponse(response, proxyResponse.statusCode(), proxyResponse.body());
}

The cache is an AtomicReference<CachedResponse> โ€” thread-safe with no locking overhead. Only successful (200) responses are cached; error responses are not, so a temporary auth server outage does not poison the cache.


How to register the proxy filterโ€‹

Choose one of two options:

=== "Option A โ€” Spring filter registration (on HAPI server)"

Add this to your HAPI FHIR JPA server's Spring configuration:

    @Bean
public FilterRegistrationBean<SmartDiscoveryProxyFilter> discoveryProxy() {
FilterRegistrationBean<SmartDiscoveryProxyFilter> reg =
new FilterRegistrationBean<>();
reg.setFilter(new SmartDiscoveryProxyFilter("http://localhost:9000"));
reg.addUrlPatterns("/.well-known/*");
reg.setOrder(1);
return reg;
}

Copy SmartDiscoveryProxyFilter.java from this project into your HAPI server. The only dependency is spring-web which HAPI already includes.

=== "Option B โ€” nginx reverse proxy"

If you run nginx in front of both servers, add this location block for the FHIR server:

    server {
listen 8080;

# Forward SMART discovery to the auth server
location /.well-known/smart-configuration \{
proxy_pass http://localhost:9000/.well-known/smart-configuration;
proxy_set_header Host $host;
\}

# All other requests go to HAPI FHIR
location / \{
proxy_pass http://localhost:8081; # HAPI internal port
\}
}

=== "Option C โ€” Direct ISS (local dev only)"

For quick local testing, you can point the SMART client's ISS directly at the auth server instead of the FHIR server. This bypasses the proxy requirement but is not SMART-spec-compliant:

    # application-server.yml on the SMART client
smart:
epic:
# ISS = auth server directly โ€” skips proxy requirement
# Discovery works but this is non-standard

This only works locally. In production the ISS must be the FHIR base URL.


Testing the discovery endpointโ€‹

# Directly on the auth server
curl http://localhost:9000/.well-known/smart-configuration | python3 -m json.tool

# Via the HAPI proxy (after registering the filter)
curl http://localhost:8080/fhir/.well-known/smart-configuration | python3 -m json.tool

Both should return the same JSON document. If the proxy is working, the second URL will transparently return the auth server's response.