Skip to content

Commit e0010d6

Browse files
glasstigerclaude
andcommitted
Cap token-store lock budget; add persistence tests
Address the moderate findings from the OIDC token-persistence review. getToken() runs on the latency-sensitive flush and reconnect path, but persistence routed it through FileTokenStore's cross-process lock, which could spin up to the acquire budget before a silent refresh. The advanced FileTokenStore constructor accepted an unbounded budget, so a misconfiguration could stall a flush. Cap the acquire budget at 30s, and reconcile the HttpTokenProvider SPI and the OidcDeviceAuth class docs, which still claimed getToken() never blocks: it never waits behind an interactive sign-in, but a coordinated silent refresh may briefly wait for the store lock. Add the missing tests on the security-critical persistence branches (CI had not covered these files): schema-version mismatch, per-field fingerprint mismatch (including groups_in_token and the audience nullableEquals path), the httpTimeoutMillis cap, a control-char JSON round-trip, the 0600 lock-file permissions, getToken() degrading to a lock-free refresh while a peer holds the lock, and a golden-hash anchor pinning the cross-language on-disk file-name contract. Also assert the previously-unchecked load/clear/lock store counters, the load-once guarantee, and clearCache() not reloading a just-cleared entry. FileTokenStoreTest 19 -> 26, OidcDeviceAuthPersistenceTest 14 -> 18; all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 65f590b commit e0010d6

5 files changed

Lines changed: 312 additions & 5 deletions

File tree

core/src/main/java/io/questdb/client/HttpTokenProvider.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@
3636
* <p>
3737
* {@link #getToken()} runs on the sender's flush and reconnect paths: it must return promptly and must
3838
* not block on interactive input. A quick silent token refresh is fine, but it must not start an
39-
* interactive sign-in. An exception from {@link #getToken()} fails the in-flight flush (HTTP) or the
40-
* connection attempt (WebSocket).
39+
* interactive sign-in; a provider that coordinates a shared token store across processes (for example
40+
* {@code OidcDeviceAuth} with a {@code FileTokenStore}) may add a brief, bounded wait to acquire that
41+
* store's cross-process lock before such a refresh, which still counts as a quick silent refresh. An
42+
* exception from {@link #getToken()} fails the in-flight flush (HTTP) or the connection attempt (WebSocket).
4143
*
4244
* @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider)
4345
*/

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ public final class FileTokenStore implements TokenStore {
105105
// reject a token file larger than this; a real entry is a few KB even with a group-laden id token, so
106106
// anything past this is corrupt or hostile and is not read into memory
107107
private static final long MAX_FILE_BYTES = 1 << 20;
108+
// upper bound on the configurable lock acquire budget. getToken() can take this lock on the
109+
// latency-sensitive flush path, so a caller-supplied budget is kept short: a real peer refresh is
110+
// sub-second, and bounding the wait stops a misconfigured budget from stalling a flush before it degrades
111+
// to a lock-free refresh (Layer 1 still guards integrity). Stays well below DEFAULT_LOCK_STALE_MILLIS so a
112+
// waiter degrades long before it could begin stealing live locks.
113+
private static final long MAX_LOCK_ACQUIRE_BUDGET_MILLIS = 30_000L;
108114
private static final int SCHEMA_VERSION = 1;
109115
// set once if the platform cannot enforce owner-only POSIX permissions on the token files (e.g. Windows),
110116
// so the at-rest protection falls back to the directory's inherited ACL; warns the user once
@@ -124,7 +130,9 @@ public FileTokenStore(Path directory) {
124130
*
125131
* @param directory the directory to hold the token files
126132
* @param lockAcquireBudgetMillis how long {@code inLock} waits to acquire a peer's lock before degrading
127-
* to a lock-free refresh rather than stalling a sign-in
133+
* to a lock-free refresh rather than stalling a sign-in. Must be positive
134+
* and at most 30_000 (30s): {@code getToken()} can wait it out on the
135+
* latency-sensitive flush path, so it is kept short
128136
* @param lockStaleMillis a lock older than this is treated as abandoned by a crashed holder and
129137
* stolen. It MUST exceed the longest a live holder can hold the lock: one
130138
* refresh under the lock runs send + await + parse plus a body drain, each
@@ -142,6 +150,12 @@ public FileTokenStore(Path directory, long lockAcquireBudgetMillis, long lockSta
142150
if (lockAcquireBudgetMillis <= 0) {
143151
throw new OidcAuthException("the token store lockAcquireBudgetMillis must be positive");
144152
}
153+
if (lockAcquireBudgetMillis > MAX_LOCK_ACQUIRE_BUDGET_MILLIS) {
154+
// getToken() can wait out this budget on the latency-sensitive flush path, so an unbounded value
155+
// would let a misconfiguration stall a flush; keep it short - it degrades to a lock-free refresh
156+
throw new OidcAuthException()
157+
.put("the token store lockAcquireBudgetMillis must not exceed ").put(MAX_LOCK_ACQUIRE_BUDGET_MILLIS);
158+
}
145159
// a non-positive staleness window makes every freshly created lock look abandoned, so acquirers would
146160
// steal each other's live locks; keep it well above one refresh round-trip (see the default)
147161
if (lockStaleMillis <= 0) {

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@
8585
* exists, otherwise re-runs the interactive flow. An instance lock serializes calls, so two
8686
* sign-ins never start at once. A sign-in waiting for the user holds that lock for the device code
8787
* lifetime (up to 30 minutes), so a concurrent {@link #signIn()} or {@link #clearCache()} blocks
88-
* behind it - but {@link #getToken()} never waits: it fails fast with an
89-
* {@link OidcAuthException} so a request/flush path never stalls. To abort a waiting sign-in, call
88+
* behind it - but {@link #getToken()} never waits behind an interactive sign-in: it fails fast with an
89+
* {@link OidcAuthException} rather than stall a request/flush path (a needed silent refresh still runs,
90+
* bounded by {@link Builder#httpTimeoutMillis(int)} plus, with a coordinating {@link TokenStore}, a brief
91+
* cross-process lock wait - see {@link #getToken()}). To abort a waiting sign-in, call
9092
* {@link #close()} from another thread; it signals the flow to stop, which then fails with an
9193
* {@link OidcAuthException} rather than polling until the device code expires. Cancellation is seen
9294
* between polls (within ~100ms while waiting out an interval); a poll already in flight is not

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

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,53 @@ public void testAdvancedConstructorRejectsNonPositiveTimings() throws Exception
8585
});
8686
}
8787

88+
@Test
89+
public void testAdvancedConstructorRejectsOverCapAcquireBudget() throws Exception {
90+
assertMemoryLeak(() -> {
91+
Path dir = storeDir();
92+
// the acquire budget is capped: getToken() can wait it out on the latency-sensitive flush path, so
93+
// an unbounded budget would let a misconfiguration stall a flush; the cap also keeps a waiter
94+
// degrading well before it could begin stealing live locks
95+
try {
96+
new FileTokenStore(dir, 30_001, 600_000);
97+
Assert.fail("an over-cap lock acquire budget must be rejected");
98+
} catch (OidcAuthException expected) {
99+
Assert.assertTrue(expected.getMessage(), expected.getMessage().contains("lockAcquireBudgetMillis"));
100+
}
101+
// the cap boundary itself is accepted
102+
new FileTokenStore(dir, 30_000, 600_000);
103+
});
104+
}
105+
106+
@Test
107+
public void testAudienceNullVersusEmptyFingerprint() throws Exception {
108+
assertMemoryLeak(() -> {
109+
Path dir = storeDir();
110+
FileTokenStore store = new FileTokenStore(dir);
111+
TokenStoreKey nullAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
112+
"https://idp.example.com:443/device", "openid", null, false);
113+
TokenStoreKey withAud = new TokenStoreKey("questdb", "https://idp.example.com:443/token",
114+
"https://idp.example.com:443/device", "openid", "api://billing", false);
115+
116+
// a null audience round-trips: the writer omits the member, and nullableEquals matches an absent
117+
// file value against a null key audience
118+
store.save(nullAud, sampleToken("ACCESS-1", "REFRESH-1"));
119+
Assert.assertNotNull(store.load(nullAud));
120+
byte[] nullAudBytes = Files.readAllBytes(tokenFile(dir, nullAud));
121+
122+
store.save(withAud, sampleToken("ACCESS-2", "REFRESH-2"));
123+
byte[] withAudBytes = Files.readAllBytes(tokenFile(dir, withAud));
124+
125+
// place each file under the *other* key's name to isolate the in-file audience fingerprint check
126+
// from the hash-based file naming: a recorded audience must not match a null-audience key, and an
127+
// absent audience must not match an audience-bearing key
128+
Files.write(tokenFile(dir, nullAud), withAudBytes);
129+
Assert.assertNull("a recorded audience must not match a null-audience key", store.load(nullAud));
130+
Files.write(tokenFile(dir, withAud), nullAudBytes);
131+
Assert.assertNull("an absent audience must not match an audience-bearing key", store.load(withAud));
132+
});
133+
}
134+
88135
@Test
89136
public void testClearDeletesFile() throws Exception {
90137
assertMemoryLeak(() -> {
@@ -102,6 +149,26 @@ public void testClearDeletesFile() throws Exception {
102149
});
103150
}
104151

152+
@Test
153+
public void testControlCharactersRoundTrip() throws Exception {
154+
assertMemoryLeak(() -> {
155+
FileTokenStore store = new FileTokenStore(storeDir());
156+
TokenStoreKey key = sampleKey();
157+
// a refresh token carrying every control-escape branch of the JSON writer - the short escapes
158+
// (\b \f \n \r \t) and the \\u00XX arm - plus a quote and a backslash must round-trip byte for byte;
159+
// the served-token char check lives in OidcDeviceAuth, so the store itself must preserve these
160+
String refresh = "R\b\f\n\r\t\"\\Z";
161+
// also exercise the hex-escape branch: control chars below 0x20 that are not one of the short escapes
162+
refresh = refresh + (char) 0x01 + (char) 0x1f;
163+
store.save(key, new PersistedToken("ACCESS-1", null, refresh, 1L, 1000L));
164+
165+
PersistedToken loaded = store.load(key);
166+
Assert.assertNotNull(loaded);
167+
Assert.assertEquals("ACCESS-1", loaded.getAccessToken());
168+
Assert.assertEquals(refresh, loaded.getRefreshToken());
169+
});
170+
}
171+
105172
@Test
106173
public void testCorruptFileReturnsNull() throws Exception {
107174
assertMemoryLeak(() -> {
@@ -163,6 +230,34 @@ public void testFingerprintMismatchReturnsNull() throws Exception {
163230
});
164231
}
165232

233+
@Test
234+
public void testHashMatchesFrozenCrossLanguageContract() throws Exception {
235+
assertMemoryLeak(() -> {
236+
// the file name is a frozen cross-language contract (the Python client mirrors it byte for byte):
237+
// lowercase-hex SHA-256 of "questdb-oidc-token-v1" and the six identity fields, NUL-separated, a
238+
// null audience rendered as "" and groups_in_token as '1'/'0'. Pin it to golden values so a change
239+
// to the prefix, separator, field order, or null/boolean encoding that would silently stop two
240+
// clients sharing one file is caught here.
241+
TokenStoreKey withAudience = new TokenStoreKey("questdb",
242+
"https://idp.example.com:443/as/token", "https://idp.example.com:443/as/device",
243+
"openid", "api://billing", false);
244+
Assert.assertEquals("eee1a742a27499d176bcdaed8635c14a3edbdef1d68b61c05c3c2158a5bfbcca", withAudience.hash());
245+
246+
// a null audience hashes as an empty field, not the literal "null"
247+
TokenStoreKey nullAudience = new TokenStoreKey("questdb",
248+
"https://idp.example.com:443/as/token", "https://idp.example.com:443/as/device",
249+
"openid", null, false);
250+
Assert.assertEquals("1dca0e8192ae529b94c1ac5493f09f8a45e641e4e0ec316333c0cbfeeccfef0e", nullAudience.hash());
251+
252+
// groups_in_token participates in the identity, so it flips the hash to a different file
253+
TokenStoreKey groups = new TokenStoreKey("questdb",
254+
"https://idp.example.com:443/as/token", "https://idp.example.com:443/as/device",
255+
"openid", "api://billing", true);
256+
Assert.assertEquals("5193f668130b28cd9430f5271011f1044b3b1c1e78bfc4f45d7688a3d9b1ceb0", groups.hash());
257+
Assert.assertNotEquals(withAudience.hash(), groups.hash());
258+
});
259+
}
260+
166261
@Test
167262
public void testInLockDegradesWhenDirectoryUnusable() throws Exception {
168263
assertMemoryLeak(() -> {
@@ -340,6 +435,29 @@ public void testLoadMissingReturnsNull() throws Exception {
340435
});
341436
}
342437

438+
@Test
439+
public void testLockFilePermissionsOwnerOnly() throws Exception {
440+
Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
441+
assertMemoryLeak(() -> {
442+
Path dir = storeDir();
443+
FileTokenStore store = new FileTokenStore(dir);
444+
TokenStoreKey key = sampleKey();
445+
Path lock = lockFile(dir, key);
446+
// the lock file is created owner-only too: it briefly records pid@host and sits beside the 0600
447+
// token file, so it must not widen the directory's exposure. Assert while the lock is held; inLock
448+
// deletes it on return and propagates a thrown AssertionError after releasing it.
449+
store.inLock(key, () -> {
450+
try {
451+
Assert.assertEquals("the lock file must be owner-only (0600)",
452+
PosixFilePermissions.fromString("rw-------"), Files.getPosixFilePermissions(lock));
453+
} catch (java.io.IOException e) {
454+
throw new AssertionError(e);
455+
}
456+
return true;
457+
});
458+
});
459+
}
460+
343461
@Test
344462
public void testNoLeftoverTempFileAfterSave() throws Exception {
345463
assertMemoryLeak(() -> {
@@ -376,6 +494,39 @@ public void testOversizedFileReturnsNull() throws Exception {
376494
});
377495
}
378496

497+
@Test
498+
public void testPerFieldFingerprintMismatchReturnsNull() throws Exception {
499+
assertMemoryLeak(() -> {
500+
Path dir = storeDir();
501+
FileTokenStore store = new FileTokenStore(dir);
502+
TokenStoreKey saved = sampleKey();
503+
store.save(saved, sampleToken("ACCESS-1", "REFRESH-1"));
504+
byte[] bytes = Files.readAllBytes(tokenFile(dir, saved));
505+
506+
// each key differs from the saved fingerprint in exactly one field; writing the saved bytes under
507+
// the differing key's file name isolates the in-file fingerprint re-check (the file is found, but
508+
// its recorded identity does not match), so a hash collision or a copied file never serves another
509+
// identity's token. groups_in_token (id-token vs access-token credential) and audience (the
510+
// distinct nullableEquals path) are the riskiest fields.
511+
TokenStoreKey[] mismatches = {
512+
new TokenStoreKey("questdb", "https://idp.example.com:443/OTHER-token",
513+
"https://idp.example.com:443/device", "openid", null, false),
514+
new TokenStoreKey("questdb", "https://idp.example.com:443/token",
515+
"https://idp.example.com:443/OTHER-device", "openid", null, false),
516+
new TokenStoreKey("questdb", "https://idp.example.com:443/token",
517+
"https://idp.example.com:443/device", "openid groups", null, false),
518+
new TokenStoreKey("questdb", "https://idp.example.com:443/token",
519+
"https://idp.example.com:443/device", "openid", "api://other", false),
520+
new TokenStoreKey("questdb", "https://idp.example.com:443/token",
521+
"https://idp.example.com:443/device", "openid", null, true),
522+
};
523+
for (TokenStoreKey other : mismatches) {
524+
Files.write(tokenFile(dir, other), bytes);
525+
Assert.assertNull("a fingerprint mismatch must be rejected: " + other.hash(), store.load(other));
526+
}
527+
});
528+
}
529+
379530
@Test
380531
public void testPermissionsOwnerOnly() throws Exception {
381532
Assume.assumeTrue(FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
@@ -409,6 +560,31 @@ public void testSaveThenLoadRoundTrip() throws Exception {
409560
});
410561
}
411562

563+
@Test
564+
public void testSchemaVersionMismatchReturnsNull() throws Exception {
565+
assertMemoryLeak(() -> {
566+
Path dir = storeDir();
567+
FileTokenStore store = new FileTokenStore(dir);
568+
TokenStoreKey key = sampleKey();
569+
Files.createDirectories(dir);
570+
// a future schema version with an otherwise-matching fingerprint must be ignored, not served: the
571+
// version is the forward-compat guard the frozen cross-language contract rests on
572+
String v2 = "{\"v\":2,\"client_id\":\"questdb\","
573+
+ "\"token_endpoint\":\"https://idp.example.com:443/token\","
574+
+ "\"device_authorization_endpoint\":\"https://idp.example.com:443/device\","
575+
+ "\"scope\":\"openid\",\"groups_in_token\":false,"
576+
+ "\"access_token\":\"ACCESS-1\",\"refresh_token\":\"REFRESH-1\","
577+
+ "\"expires_at_millis\":1730000000000,\"token_ttl_millis\":300000}";
578+
Files.write(tokenFile(dir, key), v2.getBytes(StandardCharsets.UTF_8));
579+
Assert.assertNull("a future schema version must be rejected", store.load(key));
580+
581+
// sanity: the identical body at the live version IS accepted, proving the rejection is the version
582+
// and not a malformed document
583+
Files.write(tokenFile(dir, key), v2.replace("\"v\":2", "\"v\":1").getBytes(StandardCharsets.UTF_8));
584+
Assert.assertNotNull("the same body at the live schema version must load", store.load(key));
585+
});
586+
}
587+
412588
@Test
413589
public void testSpecialCharactersAndNullsRoundTrip() throws Exception {
414590
assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)