Authentication¶
By default, FHIR Frog talks to a FHIR server as a plain, unauthenticated REST client. Many real servers — including any SMART on FHIR-protected endpoint — reject that outright. Two pieces work together to fix this:
requestInterceptor— a generic hook onTestScriptEngine.builder()that attaches custom headers (a bearer token, a correlation ID, anything else) to every outgoing request, independent of how that value was obtained.fhir-frog-smart— a module of pluggableSmartAuthProviderstrategies that obtain a SMART on FHIR access token, then hand it torequestInterceptorvia one line of glue.
Why two separate pieces, not one smartLaunch() method
The core engine has no business knowing what SMART, PKCE, or OAuth2 are — it only needs
to know "attach these headers to requests." Keeping the hook generic means the next
header-dependent target (a mandatory X-Request-ID header for a sandbox environment,
for example) reuses requestInterceptor directly, with zero engine changes.
The requestInterceptor Hook¶
ClientRequestInterceptor lives in fhir-frog-library (org.fhirfrog.frog.api) and is
registered once on TestScriptEngine.builder(). It applies uniformly to every operation
type — read, create, update, delete, search, batch, transaction, history —
since they all share the same underlying HAPI client.
public interface ClientRequestInterceptor {
ClientRequestInterceptor NOOP = request -> { /* default: no-op */ };
void intercept(IHttpRequest request);
}
The default is NOOP, so existing callers that never set an interceptor see no behaviour
change. A minimal custom header example:
TestScriptEngine engine = TestScriptEngine.builder()
.serverUrl("http://localhost:8080/fhir")
.requestInterceptor(request -> request.addHeader("X-Request-ID", UUID.randomUUID().toString()))
.build();
engine.execute(testScript);
A sharp tool
requestInterceptor attaches its headers to every outgoing request, including HAPI's
own internal GET /metadata conformance-check preflight that runs on first use.
Against a SMART-scoped bearer token whose scopes don't cover /metadata, that preflight
can fail with 403 even though the actual FHIR operation would succeed. FHIR Frog
disables that preflight (ServerValidationModeEnum.NEVER) whenever a non-default
interceptor is set, so the hook only has to make sense for the operations you actually
asked for. Callers that never set an interceptor are unaffected.
fhir-frog-smart: Pluggable SMART Auth Strategies¶
Add the dependency alongside fhir-frog-library:
<dependency>
<groupId>org.fhirfrog</groupId>
<artifactId>fhir-frog-smart</artifactId>
<version>${fhir-frog.version}</version>
<scope>test</scope>
</dependency>
SmartAuthProvider is a small, single-method interface — it only knows how to obtain a
token, deliberately decoupled from how that token gets attached to requests:
public interface SmartAuthProvider {
String obtainAccessToken();
default ClientRequestInterceptor asRequestInterceptor() {
return request -> request.addHeader("Authorization", "Bearer " + obtainAccessToken());
}
}
asRequestInterceptor() is the one bit of glue between a SmartAuthProvider and the
engine's requestInterceptor hook — feed one straight into the other.
StaticTokenProvider¶
For a token obtained out-of-band — pasted in from a manual browser login, or fetched by some other tool entirely outside FHIR Frog:
SmartAuthProvider auth = new StaticTokenProvider(System.getenv("FHIR_ACCESS_TOKEN"));
TestScriptEngine engine = TestScriptEngine.builder()
.serverUrl("http://localhost:9090/fhir")
.requestInterceptor(auth.asRequestInterceptor())
.build();
FormLoginPkceProvider¶
Drives a real SMART App Launch 2.0 standalone-launch flow — PKCE authorize → form login →
token exchange — over plain java.net.http.HttpClient. No browser automation, no OAuth2
client dependency: the whole flow is six sequential HTTP calls against a test IdP whose
login page is a plain HTML form (not a JS-heavy SPA), modelled directly on
aehrc-quokka-hapi-smart's own StandaloneLaunchIT test helper.
SmartAuthProvider auth = new FormLoginPkceProvider(
"http://localhost:9090", // baseUrl — no trailing slash, no /fhir suffix
"standalone-client", // clientId — public PKCE client
"http://localhost:9090/callback", // redirectUri registered for that client
"openid launch/patient patient/*.read", // scopes
"testuser", "testpass"); // username, password
TestScriptEngine engine = TestScriptEngine.builder()
.serverUrl("http://localhost:9090/fhir")
.requestInterceptor(auth.asRequestInterceptor())
.build();
engine.execute(testScript); // every outgoing request now carries Authorization: Bearer <token>
The obtained token is cached for the lifetime of the provider — obtainAccessToken() only
runs the login flow once, on first call, rather than re-authenticating on every request.
Construct a new instance to force a fresh login.
Choosing a Strategy¶
| Strategy | Use case |
|---|---|
StaticTokenProvider |
Token already obtained out-of-band. |
FormLoginPkceProvider |
A test IdP with a plain HTML login form and a test-user bypass, shaped like aehrc-quokka-hapi-smart. |
Not built (real needs, but nothing in this org's test suites currently requires them — see
fhir-frog-smart's README for the rationale): a HeadlessBrowserProvider for a real
external IdP whose login page is a JS-heavy SPA, and a ClientCredentialsProvider for
system-to-system targets with no user/login step. SmartAuthProvider is a single-method
interface — implement it directly if you need either, rather than waiting for this module
to grow one.
Worked Example¶
fhir-frog-sample/smart-launch proves the whole flow end to end against a real, local
aehrc-quokka-hapi-smart SMART
test server (Docker Compose, seeded with test users and a standalone-client PKCE client).
Two tests run the same TestScript through the same TestScriptEngine, differing only in
whether a requestInterceptor is attached:
| Test | requestInterceptor |
Result |
|---|---|---|
| Without interceptor | none (ClientRequestInterceptor.NOOP) |
401 Unauthorized |
| With interceptor attached | FormLoginPkceProvider.asRequestInterceptor() |
200 OK |
See fhir-frog-sample/smart-launch/README.md in the main repository for the full setup
steps.
Next Steps¶
- Module Structure — where
fhir-frog-smartsits in the module map - JUnit — wiring
TestScriptEngine/FhirFrogExtensioninto a test suite - Best Practices — general TestScript authoring guidance