@@ -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