Skip to content

Commit 7ba71a7

Browse files
glasstigerclaude
andcommitted
Add OIDC token-provider tests and cleanups
Address minor code-review findings on the OIDC device flow: fill test gaps around the token provider and tidy comments and dead code. No production behavior change beyond simplifying unreachable branches. Tests: - Add testRefreshedTokenWithControlCharFallsBackToInteractiveFlow: a refresh whose 200 response carries a served token with an escaped control char (JsonLexer now decodes it into a real CR) is rejected by validateTokenChars, and tryRefresh must fall back to the interactive flow rather than propagate. Guards the tryRefresh storeTokens catch; verified to fail without it. - Add testThrowingProviderOnReconnectIsRetriedAndRecovers and testPersistentlyThrowingProviderOnReconnectTerminatesTheSender: a token provider that throws on a WebSocket reconnect (the background I/O thread) is retried within the reconnect budget and recovers on a transient failure, or terminates the sender once the budget is exhausted on a persistent one. - Replace the vacuous testMalformedEndpointDoesNotLeakNativeMemory (which fed "not-a-url", rejected before the lexer is allocated) with testRejectedBuildDoesNotLeakNativeMemory (a parseable-but-rejected endpoint) and testSuccessfulBuildAndCloseDoNotLeakNativeMemory - the path that actually allocates and frees the native lexer. The new guard fails if close() stops freeing the lexer. - Add @test(timeout=30_000) to testShortAllDigitStatusNotTreatedAsTransientOrTerminal so a guard regression fails fast instead of polling to the device-code deadline. Comments and dead code: - Reword the writeLockHolder and acquireLock comments to match releaseLock's honest framing: the read-then-write and size-check-then-delete narrow, but do not close, the peer-stamp clobber window; the residual degrades to a double-refresh, never a torn credential. - Reduce pathOnly() to return Endpoint.parse(url).path and drop the now-unreachable ?/# arms in Endpoint.parse's authority terminator and path construction (Endpoint.parse rejects ? and # up front). - Expand the refreshUnderLock comment: the equals(refreshToken, lastPersistedRefreshToken) guard no longer strictly means "unsaved newer token" once adopt() keeps a live token the file lacked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent acfa6e6 commit 7ba71a7

4 files changed

Lines changed: 210 additions & 46 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/auth/FileTokenStore.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -647,11 +647,14 @@ private static boolean writeLockHolder(Path lock, String nonce) {
647647
// acquireLock created this lock empty; if it already carries a stamp, a peer judged it stale and
648648
// stole+restamped it in the create->stamp gap (a long GC/suspend pause between the two syscalls, or
649649
// a cross-machine clock skew wider than the empty-lock grace). A plain WRITE|TRUNCATE_EXISTING has no
650-
// exclusivity and would overwrite the peer's stamp, leaving two processes each believing they hold
651-
// the lock. Refuse instead - honouring releaseLock's own-stamp ownership rule - so acquireLock
652-
// degrades to a lock-free refresh (the documented best-effort residual) rather than clobber a live
653-
// peer's stamp. A readLockHolder that throws (our file was moved away during the peer's steal) is
654-
// caught below and likewise fails the stamp.
650+
// exclusivity and would overwrite that stamp, leaving two processes each believing they hold the
651+
// lock, so refuse when a stamp is already present - honouring releaseLock's own-stamp ownership rule.
652+
// This read-then-write is two syscalls, so like releaseLock's own read-then-delete it only NARROWS
653+
// the clobber window (a steal landing between the read and the write is still overwritten); it does
654+
// not close it. There is no atomic "write-only-if-still-empty" primitive to close it with. The
655+
// residual degrades to at most the documented double-refresh (a re-prompt on a rotating IdP), never
656+
// a torn or forged credential - Layer-1's atomic rename holds regardless. A readLockHolder that
657+
// throws (our file was moved away during the peer's steal) is caught below and likewise fails the stamp.
655658
if (readLockHolder(lock) != null) {
656659
return false;
657660
}
@@ -679,7 +682,10 @@ private String acquireLock(Path lock) {
679682
// created and degrade to a lock-free refresh rather than hold an unverifiable lock. Remove it
680683
// only while it is still the empty file we created: writeLockHolder also returns false when a
681684
// peer stole and restamped this path in the create->stamp gap, and deleting that peer's non-empty
682-
// live lock by bare path would admit a third holder (mirrors releaseLock's own-stamp rule).
685+
// live lock by bare path would admit a third holder (mirrors releaseLock's own-stamp rule). Like
686+
// writeLockHolder's read-then-write, this size-check-then-delete is two syscalls, so it narrows
687+
// but does not fully close that window; the residual degrades to the documented double-refresh,
688+
// never a torn credential.
683689
try {
684690
if (Files.size(lock) == 0) {
685691
Files.deleteIfExists(lock);

core/src/main/java/io/questdb/client/cutlass/auth/OidcDeviceAuth.java

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -855,16 +855,10 @@ private static int parseIntOrZero(CharSequence value) {
855855
}
856856

857857
private static String pathOnly(String url) {
858-
// the path component only (drop any ?query / #fragment); a ;matrix parameter stays part of the path,
859-
// so a traversal hidden in it (.../token;..%2f..) is still scanned
860-
String path = Endpoint.parse(url).path;
861-
for (int i = 0, n = path.length(); i < n; i++) {
862-
char c = path.charAt(i);
863-
if (c == '?' || c == '#') {
864-
return path.substring(0, i);
865-
}
866-
}
867-
return path;
858+
// the path component only; Endpoint.parse rejects a url carrying a ?query or #fragment up front, so the
859+
// returned path never contains one. A ;matrix parameter, by contrast, stays part of the path, so a
860+
// traversal hidden in it (.../token;..%2f..) is still scanned by the issuer-path check.
861+
return Endpoint.parse(url).path;
868862
}
869863

870864
private static String percentDecodeOnce(String s) {
@@ -1334,11 +1328,17 @@ private boolean refreshUnderLock() {
13341328
// entry and skip the network when it already yields a valid token; otherwise refresh with the freshest
13351329
// known refresh token (the one just adopted, so a rotated token is not replayed).
13361330
//
1337-
// Only re-read when the in-memory refresh token still matches what we last persisted. If they differ,
1338-
// a previous save failed (persistence is best-effort), so the in-memory token is newer than the
1339-
// on-disk one; re-adopting would regress it to the stale - and, on a rotating identity provider,
1340-
// already-revoked - on-disk token and force a needless re-prompt. In that case keep the in-memory
1341-
// token and refresh with it.
1331+
// Only re-read when the in-memory refresh token still matches what we last persisted. A mismatch no
1332+
// longer strictly means "in-memory is a newer unsaved token": it covers two cases, and re-adopting
1333+
// would regress in both, so keep the in-memory token and refresh with it. (1) A previous save failed
1334+
// (persistence is best-effort), so the in-memory token is genuinely newer than the on-disk one;
1335+
// re-adopting would regress it to the stale - and, on a rotating identity provider, already-revoked -
1336+
// on-disk token and force a needless re-prompt. (2) adopt() kept a live in-memory token that the loaded
1337+
// file did not carry (a cross-language peer that never received a refresh_token), leaving
1338+
// lastPersistedRefreshToken null; here the trade-off is that if a rotating-IdP peer has since revoked
1339+
// our token and written a fresher one, we skip that fresher on-disk token this round and fall back to an
1340+
// interactive re-prompt. Both are benign (never a stale/wrong served token; the pre-fix alternative in
1341+
// case 2 was an uncaught urlEncode(null) NPE) and cross-process-only.
13421342
if (Objects.equals(refreshToken, lastPersistedRefreshToken)) {
13431343
PersistedToken fresh;
13441344
try {
@@ -1982,27 +1982,20 @@ static Endpoint parse(String url) {
19821982
throw new OidcAuthException().put("invalid url, expected http or https [url=").put(url).put(']');
19831983
}
19841984
int hostStart = schemeEnd + 3;
1985-
// the authority ([userinfo@]host[:port]) ends at the first '/', '?' or '#'; splitting only on
1986-
// '/' (as before) folded a query/fragment - or userinfo - into the host on a path-less url
1985+
// the authority ([userinfo@]host[:port]) ends at the first '/', or at the end of the url for a
1986+
// path-less endpoint. A ?query or #fragment was already rejected above, so neither can fold into the
1987+
// host or the path here (this used to also split on '?'/'#' to guard that, now handled up front).
19871988
int authorityEnd = url.length();
19881989
for (int i = hostStart, n = url.length(); i < n; i++) {
1989-
char c = url.charAt(i);
1990-
if (c == '/' || c == '?' || c == '#') {
1990+
if (url.charAt(i) == '/') {
19911991
authorityEnd = i;
19921992
break;
19931993
}
19941994
}
19951995
String hostPort = url.substring(hostStart, authorityEnd);
1996-
// a path-less url uses '/'; a query/fragment with no path is prefixed with '/' so the request
1997-
// line stays well-formed (a '/'-terminated authority already carries its own leading slash)
1998-
String path;
1999-
if (authorityEnd == url.length()) {
2000-
path = "/";
2001-
} else if (url.charAt(authorityEnd) == '/') {
2002-
path = url.substring(authorityEnd);
2003-
} else {
2004-
path = "/" + url.substring(authorityEnd);
2005-
}
1996+
// a path-less url uses '/'; otherwise the authority is '/'-terminated and the path starts at that
1997+
// slash, which already carries its own leading slash
1998+
String path = authorityEnd == url.length() ? "/" : url.substring(authorityEnd);
20061999
if (hostPort.indexOf('@') >= 0) {
20072000
// userinfo (user[:pass]@host) is unsupported: the HTTP layer would connect to the literal
20082001
// "user@host". Reject it clearly rather than mis-resolve it or surface a misleading port error

core/src/test/java/io/questdb/client/test/cutlass/auth/OidcDeviceAuthTest.java

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2043,24 +2043,49 @@ public void testPlaintextSettingsWithAdvertisedEndpointsRequiresPin() throws Exc
20432043
}
20442044

20452045
@Test(timeout = 30_000)
2046-
public void testMalformedEndpointDoesNotLeakNativeMemory() {
2047-
// build() parses the endpoints up front (for the co-location / issuer-pin checks) and throws on
2048-
// this malformed url before the constructor allocates the native JSON lexer, so the never-returned
2049-
// instance cannot leak it. Measure the parser tag directly - the module's assertMemoryLeak does not
2050-
// flag a single-tag growth.
2046+
public void testRejectedBuildDoesNotLeakNativeMemory() {
2047+
// A build rejected during validation must not leak. build() parses and validates every endpoint
2048+
// BEFORE the constructor runs, and the constructor allocates the native JSON lexer LAST (after
2049+
// urlEncode and the TokenStoreKey build, either of which can throw), so a rejected build never
2050+
// allocates the lexer and the never-returned instance cannot be closed to free it. Use a
2051+
// parseable-but-rejected config - endpoints that parse cleanly but fail the https requirement - so the
2052+
// rejection lands AFTER endpoint parsing, exercising more of build() than a syntactically bad url
2053+
// would. testSuccessfulBuildAndCloseDoNotLeakNativeMemory covers the complementary lexer-allocated
2054+
// path. Measure the parser tag directly; the module's assertMemoryLeak does not flag a single-tag growth.
20512055
long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS);
20522056
try (OidcDeviceAuth ignored = OidcDeviceAuth.builder()
20532057
.clientId("c")
2054-
.deviceAuthorizationEndpoint("not-a-url")
2058+
.deviceAuthorizationEndpoint("http://idp.example/device") // parses fine, but plaintext http to a non-loopback host
20552059
.tokenEndpoint("https://idp.example/token")
2056-
.allowInsecureTransport(true)
2060+
.allowInsecureTransport(false)
20572061
.build()
20582062
) {
2059-
Assert.fail("expected Endpoint.parse to reject the malformed url");
2063+
Assert.fail("expected the https requirement to reject the plaintext device endpoint");
20602064
} catch (OidcAuthException e) {
2061-
Assert.assertTrue(e.getMessage(), e.getMessage().contains("expected a scheme"));
2065+
Assert.assertTrue(e.getMessage(), e.getMessage().contains("use an https url"));
20622066
}
2063-
Assert.assertEquals("the JSON lexer native buffer leaked",
2067+
Assert.assertEquals("a rejected build must not leak the JSON lexer native buffer",
2068+
parserMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS));
2069+
}
2070+
2071+
@Test(timeout = 30_000)
2072+
public void testSuccessfulBuildAndCloseDoNotLeakNativeMemory() {
2073+
// The complement to the rejected-build case: a SUCCESSFUL build is the only path that allocates the
2074+
// native JSON lexer, so this is the block that actually exercises a lexer-allocated instance, and
2075+
// close() must free it. Loop a few build->close cycles so any per-cycle leak accrues, then assert the
2076+
// parser tag returns to its baseline. build() does no network I/O (discovery is separate), so valid
2077+
// co-located https endpoints construct offline.
2078+
long parserMemBefore = Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS);
2079+
for (int i = 0; i < 4; i++) {
2080+
try (OidcDeviceAuth auth = OidcDeviceAuth.builder()
2081+
.clientId("c")
2082+
.deviceAuthorizationEndpoint("https://idp.example/device")
2083+
.tokenEndpoint("https://idp.example/token")
2084+
.build()) {
2085+
Assert.assertNotNull(auth);
2086+
}
2087+
}
2088+
Assert.assertEquals("close() must free the JSON lexer native buffer allocated by a successful build",
20642089
parserMemBefore, Unsafe.getMemUsedByTag(MemoryTag.NATIVE_TEXT_PARSER_RSS));
20652090
}
20662091

@@ -2502,6 +2527,45 @@ public void testRefreshErrorFallsBackToInteractiveFlow() throws Exception {
25022527
});
25032528
}
25042529

2530+
@Test(timeout = 30_000)
2531+
public void testRefreshedTokenWithControlCharFallsBackToInteractiveFlow() throws Exception {
2532+
assertMemoryLeak(() -> {
2533+
// A silent refresh whose 200 response carries a served token with a control character - here an
2534+
// escaped \r that JsonLexer now decodes into a real CR byte - must be rejected by storeTokens ->
2535+
// validateTokenChars, and tryRefresh must SWALLOW that rejection and fall back to the interactive
2536+
// device flow rather than let it propagate out of signIn()/getToken(). Guards the tryRefresh
2537+
// storeTokens try/catch: without it, this signIn() throws instead of returning the fallback token.
2538+
AtomicInteger deviceCalls = new AtomicInteger();
2539+
AtomicInteger deviceCodeGrants = new AtomicInteger();
2540+
MockOidcServer.Handler handler = (method, path, body) -> {
2541+
if (DEVICE_PATH.equals(path)) {
2542+
deviceCalls.incrementAndGet();
2543+
return MockOidcServer.json(200, deviceAuthorizationJson(1, 300));
2544+
}
2545+
if (body.contains("grant_type=refresh_token")) {
2546+
// valid-JSON 200, but the access_token carries an escaped CR (\r on the wire); the served
2547+
// kind is validated, so validateTokenChars must reject it before it is cached
2548+
return MockOidcServer.json(200, tokenJson("ACCESS\\r2", null, "REFRESH-2", 3600));
2549+
}
2550+
// the initial device-code grant uses a short TTL so the next signIn() triggers a refresh
2551+
if (deviceCodeGrants.getAndIncrement() == 0) {
2552+
return MockOidcServer.json(200, tokenJson("ACCESS-1", null, "REFRESH-1", 1));
2553+
}
2554+
// the fallback interactive grant, after the poisoned refresh is rejected
2555+
return MockOidcServer.json(200, tokenJson("ACCESS-3", null, "REFRESH-3", 3600));
2556+
};
2557+
try (MockOidcServer server = new MockOidcServer(handler);
2558+
OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
2559+
Assert.assertEquals("ACCESS-1", auth.signIn());
2560+
expireCachedToken(auth);
2561+
// the refresh returns a control-char token -> rejected -> fall back to a fresh interactive sign-in
2562+
Assert.assertEquals("ACCESS-3", auth.signIn());
2563+
Assert.assertEquals("the interactive flow must run twice (initial + fallback after the rejected refresh)",
2564+
2, deviceCalls.get());
2565+
}
2566+
});
2567+
}
2568+
25052569
@Test(timeout = 30_000)
25062570
public void testRefreshKeepsExistingRefreshTokenWhenOmitted() throws Exception {
25072571
assertMemoryLeak(() -> {
@@ -3306,7 +3370,7 @@ public void testShortAllDigitStatusIsNotTreatedAsSuccess() throws Exception {
33063370
});
33073371
}
33083372

3309-
@Test
3373+
@Test(timeout = 30_000)
33103374
public void testShortAllDigitStatusNotTreatedAsTransientOrTerminal() throws Exception {
33113375
// a real HTTP status is exactly 3 digits. A malformed 1-digit "5" must not be read as a transient 5xx
33123376
// (which would poll on to the device-code deadline), nor a 1-digit "4" as a terminal 4xx, by the leading

0 commit comments

Comments
 (0)