DEV Community

Cover image for TestFly Part 15: Enterprise API auth with RFC 7616 Digest, HMAC signing and OAuth2 refresh tokens
Hakan GÜL
Hakan GÜL

Posted on Edited on Originally published at hakangul.lovable.app

TestFly Part 15: Enterprise API auth with RFC 7616 Digest, HMAC signing and OAuth2 refresh tokens

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

Why Digest auth had to be rewritten

Before 1.1.0, ApiAuth.digest(username, password) looked plausible but was wrong: it sent Authorization: Digest base64(user:password) on every request. No RFC 7616-compliant server accepts that. Digest auth is a cryptographic challenge/response built from a nonce the server issues with a 401, and renaming the Basic scheme does not produce it. A suite testing a real Digest-protected API with the old code would fail at the auth layer before it ever reached the API.

Fixing it meant implementing the protocol: parse the WWW-Authenticate header, compute HA1/HA2 per RFC 7616, track nonce counts, and detect when a nonce has gone stale.

How TestFly implements RFC 7616 Digest authentication

ApiAuth.digest(username, password) returns an ApiAuth.Intercepting implementation (DigestAuth) that is installed as the innermost interceptor. That position lets it see the outgoing request and the incoming response, 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()) and shared across sendAsync calls. Later requests authenticate preemptively with an incrementing nonce count (nc) instead of going through a 401 every time.
  5. A stale nonce (the 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 without the stale flag, TestFly treats it 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). This is the core of the response hash, taken 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

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

Signing requests with HMAC

Many internal and partner APIs don't use Basic or Bearer auth at all. They want every request signed, so a captured header can't be replayed with a different payload. TestFly 1.1.0 adds ApiAuth.hmacSigner(), a builder for ApiHmacSigner, which is 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 made of 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

The builder controls every part of the signature:

  • algorithm(...): any JCA Mac algorithm, default HmacSHA256
  • keyHeader(...) / signatureHeader(...) / timestampHeader(...): set any of them to null to omit that header
  • encoding(...): BASE64 or HEX
  • timestampFormat(...): default epoch seconds
  • canonical(...): replaces the whole signing string with a Function<SigningContext, String>, for a server that expects, say, a different line order or an extra nonce

Because the signer runs as the innermost interceptor, it signs the exact request bytes about to go on the wire. That includes headers added earlier in the chain by other ApiAuth strategies. It does not include headers that legacy RequestInterceptor hooks add afterward, because those run outside it.

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

How OAuth2 token refresh works

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 is exchanged 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

All three OAuth2 factories (oauth2, oauth2Password, oauth2Refresh) share the process-wide OAuth2TokenCache. Its keys keep a client-credentials token, a password token and a refresh token for the same tokenUrl/clientId from colliding. When a token expires, the cache first tries a refresh_token grant with the refresh token it holds. If that grant fails outright, it re-runs the original grant instead of 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 supported. If the token response includes a new refresh_token, that one is cached for the next refresh. If the server omits it (no rotation), TestFly keeps the one it 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.

Declarative setup with @UseAuth

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

# 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

keyHeader: none or timestampHeader: none (or a blank header value) omits that header entirely. This is useful when a server only wants the signature header and gets the key from an upstream gateway. The TestNG listener and the JUnit 5 extension resolve @UseAuth through the same code path, so both runners behave identically.

Other API auth changes in 1.1.0

The ApiAuth.Intercepting interface is new infrastructure. A strategy that needs the final request bytes or the actual response, and not only a header to attach before the request is built, implements it. ApiClient then installs it as the innermost ApiInterceptor, closest to the transport. This is what lets DigestAuth resend a request after reading a 401 and lets ApiHmacSigner sign the request exactly as it will leave the process. Both ship as package-private or final implementations, and 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 it used the platform default). The OAuth2 token cache is now cleared at suite end instead of living as long as 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 does. 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. A second request happens only if the nonce goes stale or rotates.

What happens if the Digest credentials are wrong?

TestFly resends once per fresh challenge. If the server returns the same non-stale nonce again, TestFly treats that as rejected credentials and returns the 401 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 the method, URI, timestamp, raw body bytes and headers. You can match any server-side signing scheme this way; TestFly's default METHOD\npath?query\ntimestamp\nbodyHash is only one option.

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 switches to a new one only when the server 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)