Skip to content

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 on TestScriptEngine.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 pluggable SmartAuthProvider strategies that obtain a SMART on FHIR access token, then hand it to requestInterceptor via 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-smart sits in the module map
  • JUnit — wiring TestScriptEngine/FhirFrogExtension into a test suite
  • Best Practices — general TestScript authoring guidance