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

URL: https://hakangul.lovable.app/blog/testfly-part-15-enterprise-api-auth-digest-hmac-oauth2
Author: Hakan Gül — Team Lead Senior Test Automation Engineer
Published: 2026-10-12
Updated: 2026-10-12
Category: api-testing
Tags: TestFly, API Testing, Security, Java, SDET

> TestFly 1.1.0 replaces its broken Digest auth with a real RFC 7616 challenge/response implementation, adds ApiAuth.hmacSigner() for signed requests, and ApiAuth.oauth2Refresh(...) for automatic refresh-token grants.

## English

**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:

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

apiClient().get("/api/secure/orders")
        .send()
        .assertStatus(200);
```

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:

```java
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);
```

`-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:

```java
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);
```

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:

```java
public static String defaultCanonical(SigningContext ctx) {
    return ctx.method() + "\n" + ctx.pathAndQuery() + "\n" + ctx.timestamp() + "\n" + ctx.bodySha256();
}
```

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:

```java
ApiClient.setGlobalAuth(ApiAuth.oauth2Refresh(
        "https://auth.example.com/token",
        System.getenv("CLIENT_ID"),
        System.getenv("CLIENT_SECRET"),
        System.getenv("REFRESH_TOKEN")));
```

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:

```java
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();
```

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:

```yaml
# 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}
```

```java
@Test
@UseAuth("signedService")
public void createOrderIsSigned() {
    apiClient().post("/api/orders").body(order).send().assertStatus(201);
}
```

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](https://hakangul.lovable.app/blog/testfly-part-12-playwright-style-assertions-aria-snapshots-strict-mode), Part 13 — actionability, Part 14 — step traces.*

## Türkçe

**Özet:** TestFly 1.1.0, eskiden işe yaramayan "Digest" auth'u gerçek bir RFC 7616 challenge/response implementasyonuyla değiştiriyor (MD5, SHA-256, SHA-512-256, `-sess`, `qop=auth`/`auth-int`); her isteğin method, path+query, timestamp ve body hash'ini imzalayan `ApiAuth.hmacSigner()`'ı ekliyor; ve otomatik refresh-token grant'leri için `ApiAuth.oauth2Refresh(...)` getiriyor. Yeni `ApiAuth.Intercepting` arayüzü, bu stratejilerin gerçek isteği ve yanıtı görecek şekilde en içteki interceptor olarak çalışmasını sağlıyor, `@UseAuth` ise artık `digest`, `hmac-signed` ve `oauth2-refresh`'i doğrudan `testfly.yml`'den kabul ediyor.

---

## TestFly'ın Digest Auth'u Neden Yeniden Yazılmaya İhtiyaç Duydu?

1.1.0 öncesinde, `ApiAuth.digest(username, password)` makul görünen ama tamamen yanlış bir şey yapıyordu: her istekte `Authorization: Digest base64(user:password)` gönderiyordu. RFC 7616 uyumlu hiçbir sunucu bunu kabul etmez — Digest auth, farklı bir şema adına sahip Basic auth değildir; sunucunun bir `401` üzerinde yayınladığı bir nonce'tan inşa edilen kriptografik bir challenge/response'tur. Eski kodla gerçek bir Digest korumalı API'yi test eden herhangi bir test seti, API katmanında değil auth katmanında başarısız olurdu.

Düzeltme, gerçek protokolün inşa edilmesini gerektirdi: `WWW-Authenticate` başlığını ayrıştırmak, RFC 7616'ya göre `HA1`/`HA2` hesaplamak, nonce sayaçlarını takip etmek ve bir nonce'un ne zaman eskidiğini (stale) bilmek.

## TestFly, RFC 7616 Digest Kimlik Doğrulamasını Nasıl Uyguluyor?

`ApiAuth.digest(username, password)`, en içteki interceptor olarak kurulan bir `ApiAuth.Intercepting` implementasyonu (`DigestAuth`) döndürür — giden isteği ve gelen yanıtı gerçekten gören, bu yüzden bir challenge'dan sonra isteği yeniden gönderebilen interceptor:

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

apiClient().get("/api/secure/orders")
        .send()
        .assertStatus(200);
```

Tel üzerindeki akış:

1. Bir origin'e ilk istek, kimlik bilgisi **olmadan** gönderilir.
2. Bir `401 WWW-Authenticate: Digest ...` yanıtı bir `Challenge`'a (realm, nonce, opaque, algorithm, `qop` kümesi, `stale` bayrağı) ayrıştırılır. Bir sunucu birden fazla şema duyurduğunda, TestFly desteklenen en güçlü algoritmayı seçer (`SHA-512-256` > `SHA-256` > `MD5`).
3. İstek, hesaplanmış bir `Authorization: Digest ...` başlığıyla bir kez yeniden gönderilir.
4. Challenge, **geçerli test için origin başına önbelleğe alınır** (`ApiClient.challengeCache()`), `sendAsync` çağrıları arasında paylaşılır, böylece sonraki istekler her seferinde bir 401'i gidip gelmek yerine artan bir nonce sayısıyla (`nc`) **önceden (preemptive)** kimlik doğrular.
5. **Eskimiş bir nonce** (sunucu `stale=true` döndürür, ya da sonraki bir 401'de *yeni* bir nonce gelir) tam olarak bir yeniden kimlik doğrulamayı tetikler. Aynı nonce eskimemiş olarak geri gelirse, TestFly bunu reddedilmiş kimlik bilgileri olarak değerlendirir ve sonsuz döngü yerine 401'i çağırana döndürür.

Desteklenen algoritmalar `MD5`, `MD5-sess`, `SHA-256`, `SHA-256-sess`, `SHA-512-256` ve `SHA-512-256-sess`'tir; `qop=auth` ve istek body'sinin hash'ini `HA2`'ye katan `qop=auth-int` ile birlikte. Response hash'inin çekirdeği, doğrudan implementasyondan:

```java
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);
```

`-sess` varyantları, RFC 7616'nın belirttiği gibi, client nonce'u her istekte yeniden hesaplamak yerine challenge başına bir kez `HA1`'e katar.

## İsteklerinizi HMAC ile Nasıl İmzalarsınız?

Pek çok iç ve partner API, Basic/Bearer istemez — yakalanan bir başlığın farklı bir payload'a karşı tekrar oynatılamaması için her isteğin imzalanmasını ister. TestFly 1.1.0, `ApiHmacSigner` için bir builder olan `ApiAuth.hmacSigner()`'ı ekliyor; bu da başka bir `ApiAuth.Intercepting` stratejisi:

```java
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);
```

Varsayılan olarak, method, path+query, bir timestamp ve body'nin SHA-256 hash'inden oluşan — satır sonlarıyla birleştirilmiş — kanonik bir string imzalar:

```java
public static String defaultCanonical(SigningContext ctx) {
    return ctx.method() + "\n" + ctx.pathAndQuery() + "\n" + ctx.timestamp() + "\n" + ctx.bodySha256();
}
```

İmzayla ilgili her şey builder üzerinden yapılandırılabilir: `algorithm(...)` (herhangi bir JCA `Mac` algoritması, varsayılan `HmacSHA256`), `keyHeader(...)` / `signatureHeader(...)` / `timestampHeader(...)` (herhangi birini atlamak için `null` ayarlayın), `encoding(...)` (`BASE64` ya da `HEX`), `timestampFormat(...)` (varsayılan epoch saniye), ve farklı bir satır sırası ya da ek bir nonce bekleyen bir sunucu için tüm imzalama string'ini bir `Function<SigningContext, String>` ile değiştirmek üzere `canonical(...)`. İmzalayıcı en içteki interceptor olarak çalıştığından, tel üzerine çıkmak üzere olan **tam** istek baytlarını imzalar — zincirde daha önce gelen diğer `ApiAuth` stratejilerinin eklediği başlıklar dahil — ama bunun dışında çalışan eski `RequestInterceptor` hook'larının sonradan eklediği başlıklar *hariç*.

Eski `ApiAuth.hmac(apiKey, secret, algorithm)` artık deprecated: yalnızca API key string'ini imzalıyordu, isteğin kendisini asla imzalamıyordu, bu yüzden istek imzalarını doğrulayan bir sunucuyla eşleşemez. Tel uyumluluğu için korunuyor, ama yeni kod `hmacSigner()`'ı kullanmalı.

## OAuth2 Token Yenileme Nasıl Çalışır?

`ApiAuth.oauth2(...)` zaten client-credentials token'larını önbellekleme ile işliyordu; 1.1.0, uzun ömürlü bir refresh token'ın kısa ömürlü access token'larla değiştirildiği akışlar için `ApiAuth.oauth2Refresh(tokenUrl, clientId, clientSecret, refreshToken)`'ı ekliyor:

```java
ApiClient.setGlobalAuth(ApiAuth.oauth2Refresh(
        "https://auth.example.com/token",
        System.getenv("CLIENT_ID"),
        System.getenv("CLIENT_SECRET"),
        System.getenv("REFRESH_TOKEN")));
```

Proje genelindeki `OAuth2TokenCache`, üç OAuth2 fabrika metodu (`oauth2`, `oauth2Password`, `oauth2Refresh`) tarafından paylaşılır ve aynı `tokenUrl`/`clientId` için bir client-credentials token'ının, bir password token'ının ve bir refresh token'ının asla çakışmaması için anahtarlanır. Süresi dolduğunda önce önbellekte sahip olduğu refresh token ile bir `refresh_token` grant'i dener; bu grant tamamen başarısız olursa, testi başarısız kılmak yerine orijinal grant'i yeniden çalıştırmaya geri döner:

```java
if (cached != null && cached.refreshToken != null) {
    try {
        fresh = refresh(tokenUrl, clientId, clientSecret, cached.refreshToken);
    } catch (RuntimeException refreshFailed) {
        fresh = null; // orijinal grant'e geri dön
    }
}
if (fresh == null) fresh = grant.get();
```

Her kullanımda refresh token'ı döndüren (rotate eden) sunucular da ele alınır: token yanıtı yeni bir `refresh_token` içeriyorsa, sonraki yenileme için önbelleğe alınan odur; bunu atlayan (rotasyon yapmayan) sunucular TestFly'ın zaten sahip olduğunu korur. `ApiAuth.clearTokenCache()`, talep üzerine önbelleğe alınan her token'ı temizler, ve framework bunu suite sonunda otomatik olarak çağırır, böylece token'lar aynı JVM'deki suite'ler arasında sızmaz.

## @UseAuth ile Bunu Deklaratif Olarak Nasıl Bağlarsınız?

Üç strateji de artık hem TestNG listener hem de JUnit 5 extension tarafından kullanılan paylaşımlı `AuthStrategies` sınıfı tarafından `testfly.yml`'den çözülen birinci sınıf `@UseAuth` tipleridir:

```yaml
# 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}
```

```java
@Test
@UseAuth("signedService")
public void createOrderIsSigned() {
    apiClient().post("/api/orders").body(order).send().assertStatus(201);
}
```

`keyHeader: none` ya da `timestampHeader: none` ayarlamak (ya da bir başlığı boş bırakmak), o başlığı tamamen atlar — bir sunucu yalnızca imza başlığını istiyorsa ve key'i upstream bir gateway'den türetiyorsa kullanışlıdır. TestNG listener ve JUnit 5 extension, `@UseAuth`'u tamamen aynı kod yolundan çözer, bu yüzden iki runner arasında davranış farkı yoktur.

## 1.1.0'da API Auth Etrafında Başka Neler Değişti?

`ApiAuth.Intercepting` arayüzünün kendisi yeni bir altyapı: istek oluşturulmadan önce ekleyecek bir başlıktan daha fazlasına — nihai istek baytlarına ya da gerçek yanıta — ihtiyaç duyan herhangi bir strateji bunu uygular ve `ApiClient` onu, transport'a en yakın, en içteki `ApiInterceptor` olarak kurar. `DigestAuth`'un bir `401` okuduktan sonra isteği yeniden göndermesini, ve `ApiHmacSigner`'ın isteği süreçten gerçekten çıkacağı haliyle imzalamasını sağlayan budur. İkisi de package-private ya da final implementasyonlar olarak gelir; onlara yalnızca `ApiAuth.digest(...)` ve `ApiAuth.hmacSigner()` üzerinden ulaşırsınız.

Aynı sürümde iki ilgili düzeltme de geldi: `ApiAuth.basicAuth` artık kimlik bilgilerini her zaman UTF-8 olarak kodluyor (önceden platform varsayılanıydı), ve OAuth2 token önbelleği JVM'in ömrü boyunca kalıcı olmak yerine suite sonunda temizleniyor — böylece aynı çalıştırmadaki ikinci bir suite, başka bir suite'in token'ını sessizce yeniden kullanamaz.

## SSS

### `ApiAuth.digest(...)` istek başına iki HTTP çağrısı mı yapıyor?

Yalnızca bir origin'e ilk istek bu bedeli öder. İlk 401/challenge gidiş-dönüşünden sonra, challenge geçerli test için origin başına önbelleğe alınır, ve sonraki istekler artan bir nonce sayısıyla geçerli bir `Authorization: Digest` başlığını önceden gönderir — nonce eskimedikçe ya da rotasyona uğramadıkça ikinci bir istek yoktur.

### Digest kimlik bilgileri gerçekten yanlışsa ne olur?

TestFly her yeni challenge başına bir kez yeniden gönderir. Sunucu *aynı* eskimemiş nonce'u tekrar döndürürse, bu kurtarılabilir bir challenge değil kimlik bilgilerinin reddi olarak değerlendirilir — sonsuza dek yeniden denemek yerine 401 çağırana döndürülür.

### Varsayılan yerine özel bir HMAC kanonik string'i kullanabilir miyim?

Evet — `ApiAuth.hmacSigner().canonical(ctx -> ...)`, method, URI, timestamp, ham body bayt'ları ve başlıklara erişimi olan bir `Function<SigningContext, String>` alır, böylece yalnızca TestFly'ın varsayılanı `METHOD\npath?query\ntimestamp\nbodyHash` değil, herhangi bir sunucu taraflı imzalama şemasıyla eşleşebilirsiniz.

### `oauth2Refresh`, sunucu hiçbir zaman refresh token rotasyonu yapmazsa bozulur mu?

Hayır. Bir refresh yanıtı `refresh_token`'ı atlıyorsa, TestFly sizin sağladığınız refresh token'ı yeniden kullanmaya devam eder; yalnızca sunucu gerçekten yeni bir tane döndürdüğünde yenisine geçer.

---

*TestFly, Java 21 Selenium/API test otomasyon SDK'sıdır. Bu serinin önceki yazıları: [Bölüm 12 — Playwright tarzı assertion'lar](https://hakangul.lovable.app/blog/testfly-part-12-playwright-style-assertions-aria-snapshots-strict-mode), Bölüm 13 — actionability, Bölüm 14 — step trace'ler.*
