Skip to content

Commit 71e01f2

Browse files
glasstigerclaude
andcommitted
Harden OIDC token store lock and expiry clamp
FileTokenStore released its cross-process lock by bare path, so a hold that outran lockStaleMillis (and was therefore stolen and recreated by a peer) deleted the peer's live lock on release, admitting a third acquirer alongside it and breaking the mutual exclusion the lock guards. The release now verifies ownership: acquireLock stamps a unique nonce (pid@host + timestamp + UUID) into the lock and returns it, and releaseLock deletes the lock only when it still carries that nonce. A stamp that cannot be written degrades to a lock-free refresh rather than holding an unverifiable lock. The frozen cross-language contract in design/oidc-token-persistence.md now documents the owner stamp and the ownership-verified release so other clients mirror it. OidcDeviceAuth.adopt() capped a loaded expiry but never floored it, so a tampered expires_at_millis near Long.MIN_VALUE made the validity check (now < expiresAtMillis - skew) underflow to a large positive and serve a garbage-expiry token as valid forever. adopt() now clamps the expiry to [0, now + maxLife], keeping the check underflow-safe while an already-expired entry still falls through to a refresh. Add tests: a peer-stolen lock survives our release; a tampered far-past expiry is refreshed rather than served; the parseLast() truncated- document reject; and a real FileTokenStore.save rename failure throws OidcAuthException and leaves no .tmp file. Both regression guards were proven to fail with their fix reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 148d895 commit 71e01f2

5 files changed

Lines changed: 196 additions & 36 deletions

File tree

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

Lines changed: 67 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import java.nio.file.attribute.PosixFilePermission;
5353
import java.nio.file.attribute.PosixFilePermissions;
5454
import java.util.Set;
55+
import java.util.UUID;
5556

5657
/**
5758
* The default {@link TokenStore}: one plaintext JSON file per identity under a directory, with the
@@ -199,26 +200,25 @@ public void clear(TokenStoreKey key) {
199200
@Override
200201
public boolean inLock(TokenStoreKey key, CriticalSection action) {
201202
Path lock = null;
202-
boolean held;
203+
// the unique owner nonce stamped into the lock when we acquired it, or null if we did not (or could
204+
// not) acquire one and are running lock-free; releaseLock deletes the lock only when it still carries
205+
// this nonce, so we never delete a lock a peer has since stolen
206+
String nonce = null;
203207
try {
204208
ensureDirectory();
205209
lock = lockFile(key);
206-
held = acquireLock(lock);
210+
nonce = acquireLock(lock);
207211
} catch (IOException e) {
208212
// could not prepare the lock directory or file; run without the lock. Layer-1 atomic
209213
// replacement still keeps every reader consistent - only a rotating-refresh-token race across
210214
// processes is left unguarded for this one refresh.
211-
held = false;
215+
nonce = null;
212216
}
213217
try {
214218
return action.run();
215219
} finally {
216-
if (held) {
217-
try {
218-
Files.deleteIfExists(lock);
219-
} catch (IOException ignore) {
220-
// best-effort release; a leftover lock goes stale and the next acquirer steals it
221-
}
220+
if (nonce != null) {
221+
releaseLock(lock, nonce);
222222
}
223223
}
224224
}
@@ -281,6 +281,15 @@ private static void createLockFile(Path lock) throws IOException {
281281
}
282282
}
283283

284+
private static String newLockNonce() {
285+
// a per-acquisition owner stamp: the pid@host and the acquire time are human-readable debugging aids,
286+
// and the random UUID guarantees two acquisitions never share a stamp even within one pid and one
287+
// millisecond, so releaseLock's ownership check is exact rather than probabilistic
288+
return ManagementFactory.getRuntimeMXBean().getName() // typically pid@host
289+
+ ' ' + System.currentTimeMillis()
290+
+ ' ' + UUID.randomUUID();
291+
}
292+
284293
private static boolean nullableEquals(String keyValue, StringSink fileValue) {
285294
boolean fileHasValue = fileValue.length() > 0;
286295
if (keyValue == null) {
@@ -402,6 +411,27 @@ private static void putStringMember(StringSink sink, String name, CharSequence v
402411
putString(sink, value);
403412
}
404413

414+
private static void releaseLock(Path lock, String nonce) {
415+
// release our own lock only: re-read it and delete it solely when it still carries our nonce. A hold
416+
// that outran lockStaleMillis may have been judged stale and stolen (deleted and recreated) by a peer;
417+
// deleting by bare path would then remove the peer's live lock and admit a third acquirer alongside it,
418+
// defeating the mutual exclusion this lock exists to provide. A microscopic window remains if a steal
419+
// lands between the read and the delete, but that is bounded to one syscall gap rather than the whole
420+
// hold, so a misconfigured staleness window degrades to at most the documented double-refresh rather
421+
// than corrupting a peer's lock state.
422+
try {
423+
byte[] content = Files.readAllBytes(lock);
424+
if (nonce.equals(new String(content, StandardCharsets.UTF_8))) {
425+
Files.deleteIfExists(lock);
426+
}
427+
// otherwise a peer now owns this lock file; leave it for that owner (or staleness) to reclaim
428+
} catch (NoSuchFileException e) {
429+
// already gone (stolen and not yet recreated, or removed elsewhere); nothing to release
430+
} catch (IOException ignore) {
431+
// best-effort release; a leftover lock goes stale and the next acquirer steals it
432+
}
433+
}
434+
405435
private static void restrictToOwner(Path directory) {
406436
// best-effort: the at-rest protection of the plaintext token files is exactly these owner-only
407437
// directory permissions, so tighten a pre-existing directory rather than trust whatever it had. On a
@@ -467,25 +497,40 @@ private static void writeAndFlush(Path file, byte[] content) throws IOException
467497
}
468498
}
469499

470-
private static void writeLockHolder(Path lock) {
471-
// record the holder (pid@host) and a creation timestamp for debugging only; never fail acquisition
472-
// over it. Staleness is judged by the file's mtime, not by parsing this content
500+
private static boolean writeLockHolder(Path lock, String nonce) {
501+
// stamp the lock with the owner nonce. Unlike the staleness mtime (which only needs to be recent),
502+
// this content is what releaseLock checks before deleting, so it must be written reliably; report a
503+
// failure so acquireLock drops an unverifiable lock rather than hold one it cannot safely release.
504+
// Writing also refreshes the mtime, which is what isStale reads
473505
try {
474-
String holder = ManagementFactory.getRuntimeMXBean().getName() // typically pid@host
475-
+ ' ' + System.currentTimeMillis();
476-
Files.write(lock, holder.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
477-
} catch (Exception ignore) {
478-
// best-effort metadata only
506+
Files.write(lock, nonce.getBytes(StandardCharsets.UTF_8), StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
507+
return true;
508+
} catch (Exception e) {
509+
return false;
479510
}
480511
}
481512

482-
private boolean acquireLock(Path lock) {
513+
private String acquireLock(Path lock) {
514+
// returns the unique owner nonce stamped into the lock on success, or null if it could not be acquired
515+
// within the budget. releaseLock uses the nonce to verify ownership before deleting, so a hold that
516+
// outran lockStaleMillis (and was stolen by a peer) never deletes the peer's lock on release
517+
final String nonce = newLockNonce();
483518
final long deadline = System.currentTimeMillis() + lockAcquireBudgetMillis;
484519
while (true) {
485520
try {
486521
createLockFile(lock);
487-
writeLockHolder(lock);
488-
return true;
522+
if (writeLockHolder(lock, nonce)) {
523+
return nonce;
524+
}
525+
// the exclusive create won the lock but the owner nonce could not be stamped, so releaseLock
526+
// could not later prove ownership and would risk deleting a peer's lock; drop the file we just
527+
// created and degrade to a lock-free refresh rather than hold an unverifiable lock
528+
try {
529+
Files.deleteIfExists(lock);
530+
} catch (IOException ignore) {
531+
// another acquirer may have removed it; the next createLockFile settles the race
532+
}
533+
return null;
489534
} catch (FileAlreadyExistsException e) {
490535
if (isStale(lock)) {
491536
// a crashed holder left the lock behind; steal it
@@ -498,11 +543,11 @@ private boolean acquireLock(Path lock) {
498543
// between several acquirers (or a misconfigured tiny lockStaleMillis) must not hot-spin
499544
}
500545
if (System.currentTimeMillis() >= deadline) {
501-
return false; // give up and run without the lock rather than stall a sign-in
546+
return null; // give up and run without the lock rather than stall a sign-in
502547
}
503548
Os.sleep(LOCK_POLL_SLICE_MILLIS);
504549
} catch (IOException e) {
505-
return false; // unexpected IO; degrade to no lock
550+
return null; // unexpected IO; degrade to no lock
506551
}
507552
}
508553
}

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,11 +1031,15 @@ private boolean adopt(PersistedToken token) {
10311031
refreshToken = token.getRefreshToken();
10321032
// the file is attacker-writable (and may have been written under a skewed clock), so bound how long
10331033
// the loaded token is trusted exactly as storeTokens() bounds a token from the wire: never past
1034-
// MAX_EXPIRES_IN_SECONDS from now. Capping (not flooring) the expiry preserves an already-expired
1035-
// entry, so a stale access token still falls through to a refresh rather than being served forever.
1034+
// MAX_EXPIRES_IN_SECONDS from now. Clamp the expiry to [0, now + maxLife]: the ceiling stops a tampered
1035+
// far-future expiry from being trusted for decades, and the floor of 0 keeps a tampered far-past expiry
1036+
// in the past (1970, well before now) while keeping the validity check (now < expiresAtMillis - skew)
1037+
// underflow-safe - a near-Long.MIN_VALUE expiry would otherwise wrap that subtraction to a huge
1038+
// positive and serve a garbage-expiry token as valid forever. An already-expired entry still reads as
1039+
// expired and falls through to a refresh rather than being served.
10361040
long maxTokenLifeMillis = MAX_EXPIRES_IN_SECONDS * 1000L;
10371041
tokenTtlMillis = Math.max(0L, Math.min(token.getTokenTtlMillis(), maxTokenLifeMillis));
1038-
expiresAtMillis = Math.min(token.getExpiresAtMillis(), System.currentTimeMillis() + maxTokenLifeMillis);
1042+
expiresAtMillis = Math.max(0L, Math.min(token.getExpiresAtMillis(), System.currentTimeMillis() + maxTokenLifeMillis));
10391043
// it is already on disk, so a later non-rotating refresh must not rewrite the file
10401044
lastPersistedRefreshToken = refreshToken;
10411045
return true;

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,37 @@ public void testInLockIsMutuallyExclusiveAcrossInstances() throws Exception {
342342
});
343343
}
344344

345+
@Test
346+
public void testInLockReleaseDoesNotDeleteAStolenLock() throws Exception {
347+
assertMemoryLeak(() -> {
348+
Path dir = storeDir();
349+
Files.createDirectories(dir);
350+
// a tiny staleness window so our own in-progress hold is judged stale and a peer can steal it
351+
FileTokenStore store = new FileTokenStore(dir, 1000, 50);
352+
TokenStoreKey key = sampleKey();
353+
Path lock = lockFile(dir, key);
354+
355+
// our critical section outlives the 50ms staleness window; while we are still inside it, a peer
356+
// process judges our lock stale, steals it (deletes and recreates) and writes its own owner stamp.
357+
// releaseLock must verify ownership and leave the peer's live lock intact, not delete it by bare
358+
// path - otherwise a third acquirer could enter alongside the peer, defeating mutual exclusion.
359+
store.inLock(key, () -> {
360+
Os.sleep(120);
361+
try {
362+
Files.deleteIfExists(lock);
363+
Files.write(lock, "peer-owner-stamp".getBytes(StandardCharsets.UTF_8));
364+
} catch (Exception e) {
365+
throw new RuntimeException(e);
366+
}
367+
return true;
368+
});
369+
370+
Assert.assertTrue("releaseLock must not delete a lock a peer has stolen", Files.exists(lock));
371+
Assert.assertEquals("the peer's lock content must survive our release",
372+
"peer-owner-stamp", new String(Files.readAllBytes(lock), StandardCharsets.UTF_8));
373+
});
374+
}
375+
345376
@Test
346377
public void testInLockReleasesLockWhenActionThrows() throws Exception {
347378
assertMemoryLeak(() -> {
@@ -542,6 +573,37 @@ public void testPermissionsOwnerOnly() throws Exception {
542573
});
543574
}
544575

576+
@Test
577+
public void testSaveFailureLeavesNoTempFileAndThrows() throws Exception {
578+
assertMemoryLeak(() -> {
579+
Path dir = storeDir();
580+
Files.createDirectories(dir);
581+
FileTokenStore store = new FileTokenStore(dir);
582+
TokenStoreKey key = sampleKey();
583+
// make the atomic rename fail: the target path already exists as a NON-EMPTY directory, which a
584+
// file-over-directory replace cannot overwrite, driving save() down its IOException path
585+
Path target = tokenFile(dir, key);
586+
Files.createDirectories(target);
587+
Files.createFile(target.resolve("blocker"));
588+
589+
try {
590+
store.save(key, sampleToken("ACCESS-1", "REFRESH-1"));
591+
Assert.fail("save must throw when it cannot replace the target");
592+
} catch (OidcAuthException expected) {
593+
// the write-temp / flush / atomic-rename protocol must surface a wrapped failure, never a raw
594+
// IOException, and never a half-written credential
595+
}
596+
597+
// the temp file is the durability point of the protocol; a failed save must clean it up rather than
598+
// leave a *.tmp credential fragment behind
599+
boolean hasTmp;
600+
try (java.nio.file.DirectoryStream<Path> entries = Files.newDirectoryStream(dir, "*.tmp")) {
601+
hasTmp = entries.iterator().hasNext();
602+
}
603+
Assert.assertFalse("a failed save must not leave a *.tmp file behind", hasTmp);
604+
});
605+
}
606+
545607
@Test
546608
public void testSaveThenLoadRoundTrip() throws Exception {
547609
assertMemoryLeak(() -> {
@@ -604,6 +666,22 @@ public void testSpecialCharactersAndNullsRoundTrip() throws Exception {
604666
});
605667
}
606668

669+
@Test
670+
public void testTruncatedJsonReturnsNull() throws Exception {
671+
assertMemoryLeak(() -> {
672+
Path dir = storeDir();
673+
Files.createDirectories(dir);
674+
FileTokenStore store = new FileTokenStore(dir);
675+
TokenStoreKey key = sampleKey();
676+
// a crash mid-write on a filesystem without atomic rename, or a torn read, can leave a valid JSON
677+
// prefix cut off before the closing brace. parseLast() must reject the truncated document rather
678+
// than serve a half-parsed credential
679+
Files.write(tokenFile(dir, key),
680+
"{\"v\":1,\"client_id\":\"questdb\"".getBytes(StandardCharsets.UTF_8));
681+
Assert.assertNull("a truncated-but-prefix-valid file must be ignored", store.load(key));
682+
});
683+
}
684+
607685
private static TokenStoreKey sampleKey() {
608686
return new TokenStoreKey("questdb", "https://idp.example.com:443/token",
609687
"https://idp.example.com:443/device", "openid", null, false);

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,31 @@ public void testTamperedFarFutureExpiryIsBoundedNotTrustedForever() throws Excep
480480
});
481481
}
482482

483+
@Test(timeout = 30_000)
484+
public void testTamperedFarPastExpiryIsNotServedAsValid() throws Exception {
485+
assertMemoryLeak(() -> {
486+
AtomicInteger device = new AtomicInteger();
487+
AtomicInteger token = new AtomicInteger();
488+
MockOidcServer.Handler handler = countingHandler(device, token, "ACCESS-FRESH", "REFRESH-FRESH", "ACCESS-2", "REFRESH-2");
489+
try (MockOidcServer server = new MockOidcServer(handler)) {
490+
FakeTokenStore fake = new FakeTokenStore();
491+
// a tampered entry claims an absurd, far-PAST expiry near Long.MIN_VALUE. adopt() clamps the
492+
// expiry to [0, now + maxLife]; flooring at 0 is what keeps the validity check
493+
// (now < expiresAtMillis - skew) underflow-safe - without the floor a near-Long.MIN_VALUE
494+
// expiry wraps that subtraction to a huge positive and would serve the garbage-expiry token as
495+
// valid forever. It must instead read as expired and fall back to a silent refresh.
496+
fake.loadReturns = new PersistedToken("ACCESS-1", null, "REFRESH-1", Long.MIN_VALUE, Long.MIN_VALUE);
497+
try (OidcDeviceAuth auth = baseBuilder(server).tokenStore(fake).build()) {
498+
String result = auth.signIn();
499+
Assert.assertEquals("a far-past expiry must not be served; the refresh token supplies a fresh one", "ACCESS-2", result);
500+
Assert.assertNotEquals("a garbage-expiry token must never be served as valid", "ACCESS-1", result);
501+
}
502+
Assert.assertEquals("a valid refresh token needs no device flow", 0, device.get());
503+
Assert.assertTrue("the expired persisted token must trigger a token-endpoint refresh", token.get() >= 1);
504+
}
505+
});
506+
}
507+
483508
@Test(timeout = 30_000)
484509
public void testTamperedFileWithCrlfTokenFallsBackToDeviceFlow() throws Exception {
485510
assertMemoryLeak(() -> {

design/oidc-token-persistence.md

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -369,16 +369,24 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i
369369
`Files.createFile`, Python `os.open(..., O_CREAT|O_EXCL)` / `open(p,"x")`) is a plain
370370
filesystem primitive that interoperates trivially. The contract mandates the lock-file
371371
scheme; OS advisory locks are out.
372-
- **Lock file:** `<hex>.lock` beside the token file, containing the holder's
373-
`pid@host` and a creation timestamp for debugging. Acquire by exclusive-create; on
374-
contention, spin with short backoff up to a small acquire budget (~3s); if it still
375-
cannot be acquired, **proceed without it** (degrade to Layer 1) rather than fail a
376-
sign-in. A lock older than a staleness timeout (10 minutes) is treated as abandoned
377-
and stolen, so a crashed holder cannot wedge others. The window must dominate the
378-
worst-case time a live holder can hold the lock: the refresh under the lock runs
379-
send + await + parse, plus a body drain on a parse failure, each separately bounded by
380-
the HTTP timeout (capped at 120s), so up to ~4×120s = ~480s — never the interactive
381-
wait, which is not held under the lock. 10 minutes stays safely above that ~480s.
372+
- **Lock file:** `<hex>.lock` beside the token file, containing a unique per-acquisition
373+
owner stamp — the holder's `pid@host`, a creation timestamp, and a random nonce.
374+
Acquire by exclusive-create; on contention, spin with short backoff up to a small
375+
acquire budget (~3s); if it still cannot be acquired, **proceed without it** (degrade to
376+
Layer 1) rather than fail a sign-in. A lock older than a staleness timeout (10 minutes)
377+
is treated as abandoned and stolen, so a crashed holder cannot wedge others. The window
378+
must dominate the worst-case time a live holder can hold the lock: the refresh under the
379+
lock runs send + await + parse, plus a body drain on a parse failure, each separately
380+
bounded by the HTTP timeout (capped at 120s), so up to ~4×120s = ~480s — never the
381+
interactive wait, which is not held under the lock. 10 minutes stays safely above that
382+
~480s.
383+
- **Release verifies ownership.** A holder releases by re-reading the lock and deleting it
384+
**only when it still carries that holder's own owner stamp**, never by bare path. Should
385+
a hold ever outrun the staleness window and be stolen and recreated by a peer, the
386+
original holder must not delete the peer's live lock on release (which would admit a
387+
third acquirer alongside the peer and break mutual exclusion). Each implementation
388+
checks only its own stamp; it never has to parse another implementation's stamp, so the
389+
random nonce keeps the check exact without coupling the language clients.
382390
- **Protocol (under the existing in-process `ReentrantLock`, only when a refresh is
383391
needed):**
384392
1. acquire `<hex>.lock`;
@@ -387,7 +395,7 @@ re-prompt. To eliminate it, serialise the *read-modify-write* of a refresh per i
387395
`validateTokenChars`) and **skip the network**;
388396
4. else POST the refresh with the current refresh token; `storeTokens()` writes the
389397
new token atomically *inside* the lock;
390-
5. release (delete `<hex>.lock`).
398+
5. release (delete `<hex>.lock` only if it still carries our own owner stamp).
391399

392400
The interactive device flow does **not** hold the lock file (coordinating human prompts
393401
across processes is overkill and would hold a cross-process lock for up to 30 min); two

0 commit comments

Comments
 (0)