DEV Community

Cover image for TestFly Part 15: Enterprise API Auth — RFC 7616 Digest, HMAC Signing & OAuth2 Refresh Tokens
Hakan GÜL
Hakan GÜL

Posted on Originally published at hakangul.lovable.app

TestFly Part 15: Enterprise API Auth — RFC 7616 Digest, HMAC Signing & OAuth2 Refresh Tokens

TL;DR: TestFly 1.1.0 replaces its old, non-functional "Digest" auth with a real RFC 7616 challenge/response implementation (MD5, SHA-256, SHA-512-256, -sess, qop=auth/auth-int), adds ApiAuth.hmacSigner() to sign the method, path+query, timestamp and body hash of every request, and adds ApiAuth.oauth2Refresh(...) for automatic refresh-token grants. A new ApiAuth.Intercepting interface lets these strategies run as the innermost interceptor so they see the exact request and response, and @UseAuth now accepts digest, hmac-signed and oauth2-refresh straight from testfly.yml.


Why Did TestFly's Digest Auth Need a Rewrite?

Before 1.1.0, ApiAuth.digest(username, password) did something that looked plausible but was simply wrong: it sent Authorization: Digest base64(user:password) on every request. No RFC 7616-compliant server accepts that — Digest auth isn't Basic auth with a different scheme name, it's a cryptographic challenge/response built from a nonce the server issues on a 401. Any test suite exercising a real Digest-protected API with the old code would fail at the auth layer, not the API layer.

The fix required building the actual protocol: parse the WWW-Authenticate header, compute HA1/HA2 per RFC 7616, track nonce counts, and know when a nonce has gone stale.

How Does TestFly Implement RFC 7616 Digest Authentication?

ApiAuth.digest(username, password) returns an ApiAuth.Intercepting implementation (DigestAuth) that is installed as the innermost interceptor — the one that actually sees the request going out and the response coming back, so it can resend a request after a challenge:

ApiClient.setGlobalAuth(ApiAuth.digest("qa-user", System.getenv("DIGEST_PASSWORD")));

apiClient().get("/api/secure/orders")
        .send()
        .assertStatus(200);
Enter fullscreen mode Exit fullscreen mode

The flow on the wire:

  1. The first request to an origin goes out without credentials.
  2. A 401 WWW-Authenticate: Digest ... response is parsed into a Challenge (realm, nonce, opaque, algorithm, qop set, stale flag). When a server advertises several schemes, TestFly picks the strongest supported algorithm (SHA-512-256 > SHA-256 > MD5).
  3. The request is resent once with a computed Authorization: Digest ... header.
  4. The challenge is cached per origin for the current test (ApiClient.challengeCache()), shared across sendAsync calls, so later requests authenticate preemptively with an incrementing nonce count (nc) instead of round-tripping a 401 every time.
  5. A stale nonce (server returns stale=true, or a new nonce on a later 401) triggers exactly one re-authentication with the fresh challenge. If the same nonce comes back unstaled, TestFly treats that as rejected credentials and returns the 401 to the caller instead of looping.

Supported algorithms are MD5, MD5-sess, SHA-256, SHA-256-sess, SHA-512-256 and SHA-512-256-sess, with qop=auth and qop=auth-int (which folds a hash of the request body into HA2). The core of the response hash, straight from the implementation:

String ha1 = hash(digestName, username + ":" + challenge.realm + ":" + password);
if (session) ha1 = hash(digestName, ha1 + ":" + challenge.nonce + ":" + cnonce);

String a2 = request.method() + ":" + digestUri;
if ("auth-int".equals(qop)) a2 += ":" + hash(digestName, request.body());
String ha2 = hash(digestName, a2);

String response = qop == null
        ? hash(digestName, ha1 + ":" + challenge.nonce + ":" + ha2)
        : hash(digestName, ha1 + ":" + challenge.nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2);
Enter fullscreen mode Exit fullscreen mode

-sess variants fold the client nonce into HA1 once per challenge, exactly as RFC 7616 specifies, instead of recomputing it on every request.

How Do You Sign Requests with HMAC?

Plenty of internal and partner APIs don't want Basic/Bearer at all — they want every request signed so a captured header can't be replayed against a different payload. TestFly 1.1.0 adds ApiAuth.hmacSigner(), a builder for ApiHmacSigner, another ApiAuth.Intercepting strategy:

ApiAuth signer = ApiAuth.hmacSigner()
        .apiKey(System.getenv("API_KEY"))
        .secret(System.getenv("API_SECRET"))
        .signatureHeader("X-Signature")
        .encoding(ApiHmacSigner.Encoding.HEX)
        .build();

ApiClient.setGlobalAuth(signer);
Enter fullscreen mode Exit fullscreen mode

By default it signs a canonical string built from the method, path+query, a timestamp and a SHA-256 hash of the body — joined by newlines:

public static String defaultCanonical(SigningContext ctx) {
    return ctx.method() + "\n" + ctx.pathAndQuery() + "\n" + ctx.timestamp() + "\n" + ctx.bodySha256();
}
Enter fullscreen mode Exit fullscreen mode

Everything about the signature is configurable through the builder: algorithm(...) (any JCA Mac algorithm, default HmacSHA256), keyHeader(...) / signatureHeader(...) / timestampHeader(...) (set any to null to omit it), encoding(...) (BASE64 or HEX), timestampFormat(...) (default epoch seconds), and canonical(...) to replace the whole signing string with a Function<SigningContext, String> for a server that expects, say, a different line order or an additional nonce. Because the signer runs as the innermost interceptor, it signs the exact request bytes about to hit the wire, including headers added by other ApiAuth strategies earlier in the chain — but not headers added afterward by legacy RequestInterceptor hooks, which run outside it.

The old ApiAuth.hmac(apiKey, secret, algorithm) is now deprecated: it only ever signed the API key string, never the request itself, so it can't match a server that verifies request signatures. It's kept for wire compatibility, but new code should use hmacSigner().

How Does OAuth2 Token Refresh Work?

ApiAuth.oauth2(...) already handled client-credentials tokens with caching; 1.1.0 adds ApiAuth.oauth2Refresh(tokenUrl, clientId, clientSecret, refreshToken) for flows where a long-lived refresh token exchanges for short-lived access tokens:

ApiClient.setGlobalAuth(ApiAuth.oauth2Refresh(
        "https://auth.example.com/token",
        System.getenv("CLIENT_ID"),
        System.getenv("CLIENT_SECRET"),
        System.getenv("REFRESH_TOKEN")));
Enter fullscreen mode Exit fullscreen mode

The process-wide OAuth2TokenCache is shared by all three OAuth2 factories (oauth2, oauth2Password, oauth2Refresh) and is keyed so a client-credentials token, a password token and a refresh token for the same tokenUrl/clientId never collide. On expiry it first tries a refresh_token grant with whatever refresh token it has cached; if that grant fails outright, it falls back to re-running the original grant rather than failing the test:

if (cached != null && cached.refreshToken != null) {
    try {
        fresh = refresh(tokenUrl, clientId, clientSecret, cached.refreshToken);
    } catch (RuntimeException refreshFailed) {
        fresh = null; // fall back to the original grant
    }
}
if (fresh == null) fresh = grant.get();
Enter fullscreen mode Exit fullscreen mode

Servers that rotate refresh tokens on every use are handled too: if the token response includes a new refresh_token, that's what gets cached for the next refresh; servers that omit it (no rotation) keep the one TestFly already has. ApiAuth.clearTokenCache() clears every cached token on demand, and the framework calls it automatically at suite end so tokens never leak between suites in the same JVM.

How Do You Wire This Up Declaratively with @UseAuth?

All three strategies are now first-class @UseAuth types, resolved from testfly.yml by the shared AuthStrategies class used by both the TestNG listener and the JUnit 5 extension:

# testfly.yml
api:
  auth:
    legacyPartner:
      type: digest
      username: ${DIGEST_USER}
      password: ${DIGEST_PASSWORD}
    signedService:
      type: hmac-signed
      apiKey: ${API_KEY}
      secret: ${API_SECRET}
      algorithm: HmacSHA256
      keyHeader: X-Api-Key
      signatureHeader: X-Signature
      timestampHeader: X-Timestamp
      encoding: base64
    refreshableAccount:
      type: oauth2-refresh
      tokenUrl: https://auth.example.com/token
      clientId: ${CLIENT_ID}
      clientSecret: ${CLIENT_SECRET}
      refreshToken: ${REFRESH_TOKEN}
Enter fullscreen mode Exit fullscreen mode
@Test
@UseAuth("signedService")
public void createOrderIsSigned() {
    apiClient().post("/api/orders").body(order).send().assertStatus(201);
}
Enter fullscreen mode Exit fullscreen mode

Setting keyHeader: none or timestampHeader: none (or leaving a header blank) omits that header entirely — useful when a server only wants the signature header and derives the key from an upstream gateway. The TestNG listener and JUnit 5 extension resolve @UseAuth through the exact same code path, so there's no divergence in behavior between the two runners.

What Else Changed Around API Auth in 1.1.0?

The ApiAuth.Intercepting interface itself is new infrastructure: any strategy that needs the final request bytes or the actual response — not just a header to attach before the request is built — implements it and ApiClient installs it as the innermost ApiInterceptor, closest to the transport. That's what lets DigestAuth resend a request after reading a 401, and lets ApiHmacSigner sign the request as it will actually leave the process. Both ship as package-private or final implementations; you reach them only through ApiAuth.digest(...) and ApiAuth.hmacSigner().

Two related fixes landed in the same release: ApiAuth.basicAuth now always encodes credentials as UTF-8 (previously platform-default), and the OAuth2 token cache is cleared at suite end instead of persisting for the life of the JVM — so a second suite in the same run can't silently reuse another suite's token.

FAQ

Does ApiAuth.digest(...) make two HTTP calls per request?
Only the first request to a given origin pays that cost. After the first 401/challenge round trip, the challenge is cached per origin for the current test, and later requests send a valid Authorization: Digest header preemptively with an incremented nonce count — no second request unless the nonce goes stale or rotates.

What happens if the Digest credentials are actually wrong?
TestFly resends once per fresh challenge. If the server returns the same non-stale nonce again, that's treated as a rejection of the credentials, not a recoverable challenge — the 401 is returned to the caller instead of retrying forever.

Can I use a custom HMAC canonical string instead of the default one?
Yes — ApiAuth.hmacSigner().canonical(ctx -> ...) takes a Function<SigningContext, String> with access to method, URI, timestamp, raw body bytes and headers, so you can match any server-side signing scheme, not just TestFly's default METHOD\npath?query\ntimestamp\nbodyHash.

Does oauth2Refresh break if the server never rotates refresh tokens?
No. If a refresh response omits refresh_token, TestFly keeps reusing the refresh token you supplied; it only swaps to a new one when the server actually returns one.


TestFly is a Java 21 Selenium/API test automation SDK. Previous entries in this series: Part 12 — Playwright-style assertions, Part 13 — actionability, Part 14 — step traces.

Top comments (0)