Skip to content

Commit b7bb36d

Browse files
glasstigerclaude
andcommitted
Harden OIDC token flow, store lock, name escaping
A batch of moderate review findings across the OIDC device flow, the token store, the ILP-over-HTTP flush path, and the QWP senders, plus the test-quality issues they surfaced. OidcDeviceAuth: - getToken()'s peer-wait budget was one httpTimeoutMillis, but a lock holder doing a silent refresh can legitimately hold for up to LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE times that, so a concurrent caller threw on a refresh that was going to succeed. The peer now waits the holder's own worst-case hold. - getToken() no longer forecloses a silent refresh when the served-kind token is null but a refresh token exists (the partial grant a groups-in-token sign-in with no id_token leaves behind): it attempts the refresh instead of throwing "no token has been obtained yet". - close()'s javadoc claimed it returns after at most one HTTP request timeout; corrected to describe the in-flight refresh's real worst case, an OS-bounded connect stall. FileTokenStore: - inLock() now serializes same-identity critical sections within one JVM with a process-wide lock, so two OidcDeviceAuth instances for one identity cannot both run the read-refresh-write when the cross-process file lock degrades and double-POST the same rotating refresh token, which a reuse-detecting IdP revokes the whole family for. - warnNoPosixPermsOnce/warnPersistence log via SLF4J instead of System.err, so a host application can filter and redirect them. ILP over HTTP: - The response-body reads inherited the raw request_timeout instead of the per-flush budget (base plus the throughput extension), so a tuned-low request_timeout with request_min_throughput could abort a large, still-progressing chunked error body and turn it into a retry of a non-retryable status. The body reads now use the per-flush budget. QWP: - A rejected table or column name was spliced raw into the error message (QwpWebSocketSender, QwpUdpSender, QwpTableBuffer); it now routes through putAsPrintable, matching the ILP name/error render, so a BOM/bidi/control char in a hostile name cannot reorder or forge the displayed text. Tests: - A swallowed Assert.fail on a mock-server thread, a mutual-exclusion test that passed if a contender died silently (now with a barrier and a run counter), a vacuous enum valueOf cross-check, and a sleep-gated negative assertion that could pass before its waiter thread started are all fixed to assert on the main thread and to prove the thread is genuinely blocked. - Added coverage for a unicode escape at the end of a JSON value, the TokenStore.inLock default, putRawMessage's token stamp, and the null-served-kind refresh; each new production fix's test is proven to fail when the fix is reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3250b3e commit b7bb36d

13 files changed

Lines changed: 296 additions & 80 deletions

File tree

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

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
import io.questdb.client.std.str.DirectUtf8Sink;
3535
import io.questdb.client.std.str.StringSink;
3636

37+
import org.slf4j.Logger;
38+
import org.slf4j.LoggerFactory;
39+
3740
import java.io.IOException;
3841
import java.lang.management.ManagementFactory;
3942
import java.nio.ByteBuffer;
@@ -57,7 +60,9 @@
5760
import java.util.EnumSet;
5861
import java.util.Set;
5962
import java.util.UUID;
63+
import java.util.concurrent.ConcurrentHashMap;
6064
import java.util.concurrent.atomic.AtomicBoolean;
65+
import java.util.concurrent.locks.ReentrantLock;
6166

6267
/**
6368
* The default {@link TokenStore}: one plaintext JSON file per identity under a directory, with the
@@ -131,6 +136,7 @@ public final class FileTokenStore implements TokenStore {
131136
private static final int JSON_LEXER_CACHE_SIZE = 1024;
132137
private static final int JSON_LEXER_MAX_VALUE_BYTES = 1 << 20;
133138
private static final long LOCK_POLL_SLICE_MILLIS = 50L;
139+
private static final Logger LOG = LoggerFactory.getLogger(FileTokenStore.class);
134140
// reject a token file larger than this; a real entry is a few KB even with a group-laden id token, so
135141
// anything past this is corrupt or hostile and is not read into memory
136142
private static final long MAX_FILE_BYTES = 1 << 20;
@@ -144,6 +150,14 @@ public final class FileTokenStore implements TokenStore {
144150
// attacker-writable directory as the token file, and a real owner stamp (pid@host + millis + UUID) is a
145151
// few hundred bytes, so anything past this cap is corrupt or hostile and is not read into memory
146152
private static final int MAX_LOCK_FILE_BYTES = 1 << 12;
153+
// Serializes same-identity critical sections WITHIN this JVM, keyed on the identity fingerprint. Two
154+
// OidcDeviceAuth instances for one identity in a single process (e.g. an ILP Sender and a QwpQueryClient)
155+
// have separate instance locks, so only this shared lock stops them running the read-refresh-write
156+
// concurrently and double-POSTing the same parent refresh token - which a reuse-detecting IdP revokes the
157+
// whole token family for. The cross-process file lock's lock-free degrade must not license an intra-process
158+
// race, so this in-process lock is taken first and is never subject to that degrade. Bounded by identity
159+
// count (a handful), so it never grows unbounded.
160+
private static final ConcurrentHashMap<String, ReentrantLock> PROCESS_LOCKS = new ConcurrentHashMap<>();
147161
// Windows can fail the atomic token-file rename with a transient AccessDeniedException (a sharing violation)
148162
// when a concurrent reader in any process holds the target open; retry the rename this many times on a short
149163
// backoff before giving up, so a routine read/write overlap does not needlessly degrade persistence. Kept
@@ -255,27 +269,39 @@ public void clear(TokenStoreKey key) {
255269

256270
@Override
257271
public boolean inLock(TokenStoreKey key, CriticalSection action) {
258-
Path lock = null;
259-
// the unique owner nonce stamped into the lock when we acquired it, or null if we did not (or could
260-
// not) acquire one and are running lock-free; releaseLock deletes the lock only when it still carries
261-
// this nonce, so we never delete a lock a peer has since stolen
262-
String nonce = null;
272+
// First serialize other threads of THIS JVM sharing the same identity: the cross-process file lock below
273+
// degrades to lock-free after lockAcquireBudgetMillis, which is a fine cross-process fallback but must
274+
// not let two threads of one process run the critical section at once (they would double-POST the same
275+
// rotating refresh token and get the whole family revoked on a reuse-detecting IdP). This lock is not
276+
// subject to the file lock's degrade. ReentrantLock is safe even though inLock's contract forbids
277+
// nesting - a mistaken re-entry cannot self-deadlock.
278+
final ReentrantLock processLock = PROCESS_LOCKS.computeIfAbsent(key.hash(), k -> new ReentrantLock());
279+
processLock.lock();
263280
try {
264-
ensureDirectory();
265-
lock = lockFile(key);
266-
nonce = acquireLock(lock);
267-
} catch (IOException e) {
268-
// could not prepare the lock directory or file; run without the lock. Layer-1 atomic
269-
// replacement still keeps every reader consistent - only a rotating-refresh-token race across
270-
// processes is left unguarded for this one refresh.
271-
nonce = null;
272-
}
273-
try {
274-
return action.run();
275-
} finally {
276-
if (nonce != null) {
277-
releaseLock(lock, nonce);
281+
Path lock = null;
282+
// the unique owner nonce stamped into the lock when we acquired it, or null if we did not (or could
283+
// not) acquire one and are running lock-free; releaseLock deletes the lock only when it still carries
284+
// this nonce, so we never delete a lock a peer has since stolen
285+
String nonce = null;
286+
try {
287+
ensureDirectory();
288+
lock = lockFile(key);
289+
nonce = acquireLock(lock);
290+
} catch (IOException e) {
291+
// could not prepare the lock directory or file; run without the cross-process lock. Layer-1
292+
// atomic replacement still keeps every reader consistent - only a rotating-refresh-token race
293+
// across processes is left unguarded for this one refresh.
294+
nonce = null;
295+
}
296+
try {
297+
return action.run();
298+
} finally {
299+
if (nonce != null) {
300+
releaseLock(lock, nonce);
301+
}
278302
}
303+
} finally {
304+
processLock.unlock();
279305
}
280306
}
281307

@@ -638,9 +664,9 @@ private static void warnNoPosixPermsOnce() {
638664
if (!warnedNoPosixPerms.compareAndSet(false, true)) {
639665
return;
640666
}
641-
System.err.println("questdb client: the OIDC token store could not enforce owner-only (0600/0700) "
642-
+ "permissions on this filesystem; the persisted refresh token is protected only by the "
643-
+ "directory's default ACL. Back the store with an OS keychain for at-rest encryption.");
667+
LOG.warn("the OIDC token store could not enforce owner-only (0600/0700) permissions on this "
668+
+ "filesystem; the persisted refresh token is protected only by the directory's default ACL. "
669+
+ "Back the store with an OS keychain for at-rest encryption.");
644670
}
645671

646672
private static void writeAndFlush(Path file, byte[] content) throws IOException {

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

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
import io.questdb.client.std.str.DirectUtf8Sequence;
4646
import io.questdb.client.std.str.StringSink;
4747

48+
import org.slf4j.Logger;
49+
import org.slf4j.LoggerFactory;
50+
4851
import java.io.UnsupportedEncodingException;
4952
import java.net.URLEncoder;
5053
import java.util.Locale;
@@ -145,6 +148,7 @@ public class OidcDeviceAuth implements QuietCloseable {
145148
// connect instead). build() requires the FileTokenStore staleness window to exceed this multiple as a floor;
146149
// the default window adds ample headroom for a typical connection stall on top of it (see build())
147150
private static final int LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE = 4;
151+
private static final Logger LOG = LoggerFactory.getLogger(OidcDeviceAuth.class);
148152
// upper bound on the device code lifetime (the device authorization response's expires_in), so a
149153
// hostile or buggy provider cannot make the client poll for an absurd duration; matches the Python client
150154
private static final int MAX_DEVICE_CODE_TTL_SECONDS = 1800;
@@ -436,8 +440,12 @@ public void clearCache() {
436440
* {@link OidcAuthException} instead of polling until the device code expires. The signal is observed
437441
* between polls (within ~100ms while waiting out a poll interval); a poll request already in flight
438442
* is not interrupted, so {@code close()} acquires the lock - and returns - only once that request
439-
* finishes or times out, i.e. after at most one HTTP request timeout
440-
* (see {@link Builder#httpTimeoutMillis(int)}), not the full device-code lifetime. The exception is a
443+
* finishes or times out, not the full device-code lifetime. That bound is the in-flight operation's own
444+
* worst case, which is NOT a single HTTP request timeout: a silent refresh under the lock runs a send, an
445+
* await and a body parse (each bounded by {@link Builder#httpTimeoutMillis(int)}), and its connection phase -
446+
* DNS, TCP connect, TLS handshake - is bounded by the OS, not by that timeout, so a black-holed token
447+
* endpoint can hold the lock, and this {@code close()}, for the OS connect timeout (commonly ~2 minutes on
448+
* Linux) rather than a single httpTimeoutMillis. The exception is a
441449
* {@link DeviceCodePrompt} that blocks in {@code promptUser} - for example the default
442450
* {@link DeviceCodePrompt#openBrowser()} prompt while it hands the verification URL to the OS browser,
443451
* which is not bounded by the HTTP timeout: the flow holds the lock across that one-off prompt, so a
@@ -509,13 +517,17 @@ public String getToken() {
509517
throwIfClosed();
510518
maybeLoadFromStore();
511519
final String cachedToken = groupsInToken ? idToken : accessToken;
520+
if (cachedToken != null && System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) {
521+
return cachedToken;
522+
}
523+
// The served-kind token is absent or expired. Try a silent refresh whenever a refresh token is
524+
// available - including the case where a prior grant returned only the OTHER kind, leaving the served
525+
// kind null: a refresh may yield the served kind and avoid forcing an interactive sign-in. selectToken()
526+
// reports a clear error if the refresh still did not produce the kind the server expects.
527+
if (refreshToken != null && tryRefreshCoordinated()) {
528+
return selectToken();
529+
}
512530
if (cachedToken != null) {
513-
if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) {
514-
return cachedToken;
515-
}
516-
if (refreshToken != null && tryRefreshCoordinated()) {
517-
return selectToken();
518-
}
519531
throw new OidcAuthException("the cached token expired and could not be refreshed without an interactive sign-in; call signIn() to sign in again");
520532
}
521533
throw new OidcAuthException("no token has been obtained yet; call signIn() to sign in before using getToken()");
@@ -1062,9 +1074,15 @@ private void acquireForGetToken() {
10621074
// wait behind such a refresh - so poll for the lock in short slices rather than fail every concurrent
10631075
// caller sharing this instance on each token refresh (the old unconditional tryLock() did exactly that).
10641076
// Polling, not one blocking acquire, lets us still fail fast the moment an interactive sign-in - or
1065-
// close() - begins while we wait. Bound the total wait by httpTimeoutMillis so a stuck or pathologically
1066-
// slow holder degrades to a retryable failure instead of stalling the flush path without bound.
1067-
final long deadline = System.currentTimeMillis() + httpTimeoutMillis;
1077+
// close() - begins while we wait. Bound the total wait so a stuck or pathologically slow holder degrades
1078+
// to a retryable failure instead of stalling the flush path without bound - but size the bound to the
1079+
// holder's OWN worst-case hold, not a single httpTimeoutMillis. A legitimate silent refresh under the
1080+
// lock runs a send, an await and a body parse, each bounded by httpTimeoutMillis
1081+
// (LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE x in total, the same figure the FileTokenStore lock-stale floor is
1082+
// derived from), so a peer that waited only one httpTimeoutMillis would fail every concurrent caller
1083+
// behind a refresh that is going to succeed.
1084+
final long deadline = System.currentTimeMillis()
1085+
+ (long) LOCK_HOLD_HTTP_TIMEOUT_MULTIPLE * httpTimeoutMillis;
10681086
while (true) {
10691087
throwIfClosed();
10701088
if (interactiveSignInInProgress) {
@@ -1625,8 +1643,8 @@ private void warnPersistence(String operation, Throwable cause) {
16251643
// could itself hold terminal-spoofing characters - sanitize the detail before printing, as every other
16261644
// untrusted display string is sanitized (sanitizeForDisplay is null-safe).
16271645
String detail = sanitizeForDisplay(cause.getMessage());
1628-
System.err.println("questdb client: OIDC token store " + operation
1629-
+ " failed; continuing without persistence" + (detail != null ? " [" + detail + ']' : ""));
1646+
LOG.warn("OIDC token store {} failed; continuing without persistence{}",
1647+
operation, detail != null ? " [" + detail + ']' : "");
16301648
}
16311649

16321650
/**

0 commit comments

Comments
 (0)