From d96a13e318aa4695c1436ba5fde9dbf0694a0ea1 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 26 Oct 2026 14:39:14 +0100 Subject: [PATCH] Start the authentication fallback over once every scheme has failed Fixes #212. With an ordered fallback list, FallbackAuthScheme kept its start index on the last scheme once that one was rejected too, so every later request only retried it: a client that had moved from NTLM to a disabled Basic, or from Kerberos to a blocked NTLM, failed for good until it was recreated. advance() and the all-failed path of authenticate() now rewind to the first scheme. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 11 ++++++ .../winrm/light/FallbackAuthScheme.java | 10 ++++- src/site/markdown/authentication.md | 4 +- .../winrm/light/FallbackAuthSchemeTest.java | 38 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99450c9..bb10d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project are documented in this file. +## 3.2.00 + +### Fixed + +- **A client no longer stays stuck on a failing authentication scheme** (#212). With an ordered + fallback list such as `(KERBEROS, NTLM)` or `(NTLM, BASIC)`, a client that had moved past a + scheme never went back to it: once the last scheme failed too (Basic disabled on the host, NTLM + blocked by policy), every later request failed until the client was recreated, even after a + transient failure of the first scheme (an unreachable KDC, a host restarting) was over. Once + every scheme has failed, the next request now starts over from the first one. + ## 3.1.00 ### Fixed diff --git a/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java b/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java index f11619f..c853a93 100644 --- a/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java +++ b/src/main/java/org/metricshub/winrm/light/FallbackAuthScheme.java @@ -25,7 +25,8 @@ /** * Tries several {@link AuthScheme}s in the caller's order, using the first whose handshake succeeds * (e.g. {@code [KERBEROS, NTLM]}: attempt Kerberos, fall back to NTLM). Once a scheme authenticates - * it becomes the active one for the rest of the connection; on reconnect the fallback runs again. + * it becomes the active one for the rest of the connection; on reconnect the fallback runs again + * from that scheme. Once every scheme has failed, the next request starts over from the first one. *

* Fallback triggers on a failed handshake — the common case being Kerberos unavailable (no TGT, * no reachable KDC, unconfigured realm), which fails client-side before any SOAP is sent. @@ -68,6 +69,9 @@ public String authenticate(final HttpTransport transport) throws Exception { } } } + // Every remaining candidate failed: rewind so the next request starts over from the first + // scheme instead of staying on the last one for good (issue #212). + startIndex = 0; throw new IllegalStateException( "All requested authentication schemes failed" + (lastFailure == null ? "" : " (last: " + lastFailure.getMessage() + ")"), @@ -108,6 +112,10 @@ public boolean advance() { startIndex++; return true; } + // The last candidate was rejected too: rewind so the next request starts over from the first + // scheme instead of staying on the last one for good (issue #212). + active = null; + startIndex = 0; return false; } diff --git a/src/site/markdown/authentication.md b/src/site/markdown/authentication.md index 9dfde7a..ff92066 100644 --- a/src/site/markdown/authentication.md +++ b/src/site/markdown/authentication.md @@ -29,7 +29,9 @@ When `authentication(...)` is not called, **NTLM** is used. Several schemes form an **ordered fallback list**: each is tried in the given order until one succeeds. `authentication(KERBEROS, NTLM)` attempts Kerberos first and falls back to NTLM — for -example when the KDC is unreachable or the clock skew is too large. +example when the KDC is unreachable or the clock skew is too large. The scheme that succeeded is +retried first when the connection is re-established; once every scheme has failed, the next request +starts over from the first one. ## User name and domain diff --git a/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java b/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java index c334346..6964bd9 100644 --- a/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java +++ b/src/test/java/org/metricshub/winrm/light/FallbackAuthSchemeTest.java @@ -137,6 +137,44 @@ void allSchemesFailingThrows() { assertThrows(IllegalStateException.class, () -> fallback.authenticate(dummyTransport())); } + @Test + void rejectionOfEverySchemeRewindsToTheFirstOne() throws Exception { + // Issue #212: [NTLM, Basic] where the server rejects both (e.g. a transient logon failure, + // then Basic disabled on the host). The next request must start over from NTLM, not stay on + // Basic for the rest of the client's life. + final FakeScheme ntlm = new FakeScheme("ntlm", NEVER); + final FakeScheme basic = new FakeScheme("basic", NEVER, "Basic dXNlcjpwYXNz"); + final FallbackAuthScheme fallback = new FallbackAuthScheme(List.of(ntlm, basic)); + final HttpTransport transport = dummyTransport(); + + assertEquals("Negotiate ntlm", fallback.authenticate(transport)); + assertTrue(fallback.advance()); // 401 on ntlm -> basic + assertEquals("Negotiate basic", fallback.authenticate(transport)); + fallback.reset(); + assertFalse(fallback.advance()); // 401 on basic too -> nothing left, rewound + assertFalse(fallback.isAuthenticated()); + + assertEquals("Negotiate ntlm", fallback.authenticate(transport)); // starts over from ntlm + assertEquals(2, ntlm.authenticateCalls); + assertEquals(1, basic.authenticateCalls); + } + + @Test + void clientSideFailureOfEverySchemeRewindsToTheFirstOne() throws Exception { + // Issue #212, the other path: the last scheme fails client-side inside authenticate(), so + // advance() is never called. The next handshake must still start over from the first scheme. + final FakeScheme kerberos = new FakeScheme("kerberos", NEVER); + final FakeScheme ntlm = new FakeScheme("ntlm", ALWAYS); + final FallbackAuthScheme fallback = new FallbackAuthScheme(List.of(kerberos, ntlm)); + final HttpTransport transport = dummyTransport(); + + assertEquals("Negotiate kerberos", fallback.authenticate(transport)); + assertTrue(fallback.advance()); // 401 on kerberos -> ntlm + assertThrows(IllegalStateException.class, () -> fallback.authenticate(transport)); // ntlm fails + assertEquals("Negotiate kerberos", fallback.authenticate(transport)); // rewound to kerberos + assertEquals(2, kerberos.authenticateCalls); + } + @Test void requestAuthorizationForwardsToTheActiveStatelessScheme() throws Exception { // A stateless candidate (e.g. Basic) repeats its Authorization header on EVERY request, so