Skip to content

Commit ffea10a

Browse files
authored
feat: evict a bearer token on a 401 challenge (#101)
PR: #101
1 parent 49cb382 commit ffea10a

4 files changed

Lines changed: 333 additions & 6 deletions

File tree

sdk-core/api/sdk-core.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,8 @@ public class org/dexpace/sdk/core/http/pipeline/steps/BearerTokenAuthStep : org/
761761
public fun <init> (Lorg/dexpace/sdk/core/http/auth/BearerTokenProvider;Ljava/util/List;Ljava/time/Duration;Lorg/dexpace/sdk/core/util/Clock;)V
762762
public synthetic fun <init> (Lorg/dexpace/sdk/core/http/auth/BearerTokenProvider;Ljava/util/List;Ljava/time/Duration;Lorg/dexpace/sdk/core/util/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
763763
protected fun authorizeRequest (Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/http/request/Request;
764+
protected fun authorizeRequestOnChallenge (Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/Response;)Lorg/dexpace/sdk/core/http/request/Request;
765+
protected fun bearerHeaderValue (Ljava/lang/String;)Ljava/lang/String;
764766
}
765767

766768
public final class org/dexpace/sdk/core/http/pipeline/steps/DefaultAsyncInstrumentationStep : org/dexpace/sdk/core/http/pipeline/AsyncHttpStep {

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/pipeline/steps/BearerTokenAuthStep.kt

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77

88
package org.dexpace.sdk.core.http.pipeline.steps
99

10+
import org.dexpace.sdk.core.http.auth.AuthChallengeParser
1011
import org.dexpace.sdk.core.http.auth.BearerToken
1112
import org.dexpace.sdk.core.http.auth.BearerTokenProvider
1213
import org.dexpace.sdk.core.http.common.HttpHeaderName
1314
import org.dexpace.sdk.core.http.request.Request
15+
import org.dexpace.sdk.core.http.response.Response
1416
import org.dexpace.sdk.core.util.Clock
1517
import java.time.Duration
1618
import java.util.concurrent.locks.ReentrantLock
@@ -37,6 +39,29 @@ import kotlin.concurrent.withLock
3739
* to decide when to refresh pre-emptively (default 30 seconds). A token whose `expiresAt`
3840
* is `now + 29s` with the default margin is considered expired and is refreshed.
3941
*
42+
* ## Eviction on 401
43+
*
44+
* The refresh margin only covers *local* expiry. A server may revoke a token before its
45+
* advertised expiry, and the cached token would otherwise keep being stamped until the
46+
* margin elapsed. To handle this, [authorizeRequestOnChallenge] evicts the rejected token
47+
* on a 401 + `WWW-Authenticate` response and re-stamps the request with a freshly fetched
48+
* one, so the single retry driven by [AuthStep] carries a new credential. Eviction is an
49+
* *additional* trigger layered on top of the margin check — it does not change pre-emptive
50+
* refresh. Only the token that produced the 401 is evicted; a token a concurrent request
51+
* already refreshed in the meantime is left in place. The retry runs regardless of HTTP
52+
* method (the in-step hook is not gated by retry-safety), so a non-idempotent POST is
53+
* re-authenticated too.
54+
*
55+
* Eviction only fires when the `WWW-Authenticate` header actually advertises a `Bearer`
56+
* challenge. A 401 carrying only a non-bearer challenge (e.g. `Basic`) — or an unparseable
57+
* one — is surfaced unchanged, because refreshing the bearer token would not satisfy it: the
58+
* retry would just be rejected again, at the cost of a wasted token fetch and round trip.
59+
*
60+
* A request that reaches the challenge hook carrying **no** `Authorization` header is a
61+
* cross-origin redirect re-issue whose credential the AUTH stage deliberately suppressed
62+
* (see [CrossOriginRedirectMarker]). In that case the hook re-stamps nothing and surfaces the
63+
* 401 unchanged, so the caller's token is never attached to a server-chosen foreign host.
64+
*
4065
* ## Errors from [provider]
4166
*
4267
* - Throws → propagated. The exception is **not** cached, so a subsequent request
@@ -46,8 +71,11 @@ import kotlin.concurrent.withLock
4671
*
4772
* ## Open for subclassing
4873
*
49-
* Users wanting to override [authorizeRequestOnChallenge] (e.g. force a token refresh on
50-
* 401) can extend this class.
74+
* Eviction-on-401 is built in. Users wanting to customise challenge handling further (e.g.
75+
* inspect the `WWW-Authenticate` scheme before deciding to refresh) can override
76+
* [authorizeRequestOnChallenge]. A subclass that changes the stamped header format by
77+
* overriding [authorizeRequest] must also override [bearerHeaderValue] so the eviction match
78+
* keeps recognising the rejected token.
5179
*
5280
* Thread-safety: see Caching above.
5381
* Cancellation: token fetch may block; the [provider] is expected to respect interrupts.
@@ -68,10 +96,84 @@ public open class BearerTokenAuthStep
6896
override fun authorizeRequest(request: Request): Request {
6997
val token = currentToken()
7098
return request.newBuilder()
71-
.setHeader(HttpHeaderName.AUTHORIZATION.caseSensitiveName, "Bearer ${token.token}")
99+
.setHeader(HttpHeaderName.AUTHORIZATION.caseSensitiveName, bearerHeaderValue(token.token))
72100
.build()
73101
}
74102

103+
/**
104+
* On a 401 challenge, evict the token that was just rejected and re-stamp [request]
105+
* with a freshly fetched one so [AuthStep]'s single retry carries a new credential.
106+
*
107+
* Returns `null` — surfacing the 401 unchanged with no retry — in two cases:
108+
*
109+
* - [request] carried no `Authorization` header. A request reaches this hook
110+
* credential-free only when the AUTH stage deliberately suppressed stamping, i.e. a
111+
* cross-origin redirect re-issue (see [CrossOriginRedirectMarker]). Re-stamping there
112+
* would attach the caller's bearer token to a server-chosen foreign host and re-drive it
113+
* through the chain, leaking the token cross-origin and bypassing the HTTPS guard that
114+
* only [AuthStep.process]'s first pass enforces. Refusing to re-stamp preserves that
115+
* suppression.
116+
* - [response]'s `WWW-Authenticate` header does not advertise a `Bearer` challenge.
117+
* Refreshing the bearer token cannot satisfy a `Basic`/`Digest`-only (or unparseable)
118+
* challenge, so the retry would only earn a second 401 after a wasted fetch and round
119+
* trip; the original 401 is surfaced instead.
120+
*
121+
* Otherwise eviction is scoped to the exact token that produced the 401 (matched against
122+
* the `Authorization` header [request] carried): if a concurrent request already refreshed
123+
* the cache, that newer token is preserved and reused for the retry rather than being
124+
* discarded. The subsequent [currentToken] call then re-fetches only when the cache is
125+
* actually empty, so a 401 storm across threads still funnels through the same
126+
* double-checked-locking fetch as the expiry-margin path.
127+
*/
128+
override fun authorizeRequestOnChallenge(
129+
request: Request,
130+
response: Response,
131+
): Request? {
132+
// No credential on the rejected request means stamping was suppressed (cross-origin
133+
// redirect). Do not re-attach one: surface the 401 unchanged.
134+
val rejectedHeader = request.headers.get(HttpHeaderName.AUTHORIZATION) ?: return null
135+
// A token refresh can only satisfy a Bearer challenge; surface anything else unchanged
136+
// rather than burning a fetch + retry that the server will just reject again.
137+
if (!offersBearerChallenge(response)) return null
138+
evictRejectedToken(rejectedHeader)
139+
return authorizeRequest(request)
140+
}
141+
142+
/**
143+
* Returns `true` when [response]'s `WWW-Authenticate` header advertises a `Bearer`
144+
* challenge. A header with only non-bearer challenges (or one that does not parse) returns
145+
* `false`. [AuthStep] guarantees the header is present before this hook runs; the explicit
146+
* null-guard keeps the method correct if called from elsewhere.
147+
*/
148+
private fun offersBearerChallenge(response: Response): Boolean {
149+
val header = response.headers.get(HttpHeaderName.WWW_AUTHENTICATE) ?: return false
150+
return AuthChallengeParser.parse(header).any { it.scheme == BEARER_SCHEME }
151+
}
152+
153+
/**
154+
* Clears [cachedToken] iff it is still the token whose stamped header is [rejectedHeader].
155+
* Guarded by the same [lock] as the refresh path so the read-compare-clear is atomic
156+
* against a concurrent refresh. Routes the comparison through [bearerHeaderValue] so it
157+
* stays in lock-step with the value [authorizeRequest] stamps.
158+
*/
159+
private fun evictRejectedToken(rejectedHeader: String) {
160+
lock.withLock {
161+
val current = cachedToken ?: return
162+
if (bearerHeaderValue(current.token) == rejectedHeader) {
163+
cachedToken = null
164+
}
165+
}
166+
}
167+
168+
/**
169+
* The `Authorization` header value for [token]. Single source of truth shared by
170+
* [authorizeRequest] (stamping) and [evictRejectedToken] (the eviction match), so the two
171+
* never drift. A subclass that overrides [authorizeRequest] to emit a different header
172+
* format must override this too, or eviction will stop matching and a rejected token will
173+
* be re-fetched but never cleared.
174+
*/
175+
protected open fun bearerHeaderValue(token: String): String = "Bearer $token"
176+
75177
private fun currentToken(): BearerToken {
76178
// Fast path: lock-free volatile read; return the cached token if it's still valid.
77179
cachedToken?.takeIf { !it.isExpiredAt(clock.now(), refreshMargin) }?.let { return it }
@@ -122,5 +224,9 @@ public open class BearerTokenAuthStep
122224
// Default refresh margin: refresh the bearer token 30 seconds before its expiry
123225
// so an in-flight request never carries a near-expired credential.
124226
private const val DEFAULT_REFRESH_MARGIN_SECONDS = 30L
227+
228+
// Lower-cased `Bearer` scheme name; AuthChallengeParser normalises schemes to lower
229+
// case, so the eviction gate compares against this constant.
230+
private const val BEARER_SCHEME = "bearer"
125231
}
126232
}

sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/pipeline/steps/AuthStepTest.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -519,15 +519,16 @@ class AuthStepTest {
519519
// ----------------- 401 + WWW-Authenticate handling -----------------
520520

521521
@Test
522-
fun `401 with WWW-Authenticate returns the 401 unchanged by default`() {
523-
val provider = BearerTokenProvider { _, _ -> BearerToken("tk", null) }
522+
fun `401 with WWW-Authenticate returns the 401 unchanged when the step does not override the challenge hook`() {
523+
// KeyCredentialAuthStep does not override authorizeRequestOnChallenge, so it exercises
524+
// the AuthStep base default (returns null → no retry). The 401 surfaces unchanged.
524525
val fake =
525526
FakeHttpClient()
526527
.enqueue { status(401).header("WWW-Authenticate", "Bearer realm=\"x\"") }
527528

528529
val pipeline =
529530
HttpPipelineBuilder(fake)
530-
.append(BearerTokenAuthStep(provider, listOf("scope")))
531+
.append(KeyCredentialAuthStep(KeyCredential("k")))
531532
.build()
532533

533534
val response = pipeline.send(getHttpsRequest())

0 commit comments

Comments
 (0)