77
88package org.dexpace.sdk.core.http.pipeline.steps
99
10+ import org.dexpace.sdk.core.http.auth.AuthChallengeParser
1011import org.dexpace.sdk.core.http.auth.BearerToken
1112import org.dexpace.sdk.core.http.auth.BearerTokenProvider
1213import org.dexpace.sdk.core.http.common.HttpHeaderName
1314import org.dexpace.sdk.core.http.request.Request
15+ import org.dexpace.sdk.core.http.response.Response
1416import org.dexpace.sdk.core.util.Clock
1517import java.time.Duration
1618import 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 }
0 commit comments