Skip to content

Commit a4875f2

Browse files
glasstigerclaude
andcommitted
Tighten OIDC token-kind and status validation
Three minor follow-ups to the device-flow review: - storeTokens validated both the access_token and the id_token for control/non-ASCII chars, but getToken() only ever serves (and sends) one of them. A stray char in the unused kind - which never reaches a header or a PG-wire password - aborted an otherwise-usable grant. Validate only the served kind (groupsInToken ? idToken : accessToken); the served kind is still strictly checked. - The HTTP status classifiers keyed off the first digit with only a length > 0 guard, so a malformed short all-digit status like "2" or "5" could be read as a 2xx/5xx. readResponse already guarantees bare digits; require exactly 3 of them, so a malformed-length status falls through to the terminal reject path instead of being trusted. - getTokenSilently's javadoc claimed it "never blocks". It does not wait on interactive input or behind another thread's sign-in, but it can make one synchronous refresh round-trip bounded by httpTimeoutMillis. Reword to say so, matching the HttpTokenProvider contract. Tests (both proven to fail without their fix): - a control char in the unused id_token (groupsInToken=false) no longer aborts the grant; - a 1-digit "2" status from the token endpoint is rejected, not accepted as success, and the smuggled token never surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0493b4c commit a4875f2

2 files changed

Lines changed: 94 additions & 20 deletions

File tree

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

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -440,16 +440,18 @@ public String getToken() {
440440
}
441441

442442
/**
443-
* Like {@link #getToken()} but never starts the interactive device flow and never blocks: returns
444-
* the cached token while valid, silently refreshes when a refresh token is available, otherwise
445-
* throws. Designed for the request/flush path of a long-lived client, for example
446-
* {@code Sender.builder(...).httpTokenProvider(auth::getTokenSilently)}, where an interactive prompt
447-
* is inappropriate and a stalled flush unacceptable. Call {@link #getToken()} once to sign in first.
443+
* Like {@link #getToken()} but never starts the interactive device flow, never prompts, and never waits
444+
* on interactive input: returns the cached token while valid, silently refreshes when a refresh token is
445+
* available, otherwise throws. Designed for the request/flush path of a long-lived client, for example
446+
* {@code Sender.builder(...).httpTokenProvider(auth::getTokenSilently)}, where an interactive prompt is
447+
* inappropriate. Call {@link #getToken()} once to sign in first.
448448
* <p>
449-
* To keep the flush path responsive it returns or throws promptly - it never waits for an interactive
450-
* {@link #getToken()} on another thread (which would stall the flush for the whole device-code
451-
* lifetime). While such a sign-in runs there is no token to return anyway, so it throws and the caller
452-
* should retry once the sign-in completes.
449+
* It does not wait behind an interactive {@link #getToken()} running on another thread (which would stall
450+
* the flush for the whole device-code lifetime): if such a sign-in holds the lock it fails fast, and the
451+
* caller should retry once the sign-in completes. It is not, however, instantaneous - when the cached
452+
* token has expired it makes one synchronous refresh round-trip to the token endpoint, bounded by
453+
* {@link Builder#httpTimeoutMillis(int)} (30s by default). That is the "quick silent refresh" the
454+
* {@code HttpTokenProvider} contract permits on the flush path, not an unbounded interactive wait.
453455
*
454456
* @return a non-null, non-empty token
455457
* @throws OidcAuthException if no token has been obtained yet, if the cached token expired and could
@@ -979,19 +981,23 @@ private HttpClient httpClient(boolean isTls) {
979981
}
980982

981983
private boolean isHttpStatusSuccess() {
982-
// responseStatus is the numeric HTTP status captured by readResponse; a 2xx starts with '2'
983-
return responseStatus.length() > 0 && responseStatus.charAt(0) == '2';
984+
// responseStatus is the bare-digit HTTP status captured by readResponse. A real status is exactly 3
985+
// digits, so require that before reading the leading digit: a malformed short status such as "2" must
986+
// not be mistaken for a 2xx success and accepted as a grant.
987+
return responseStatus.length() == 3 && responseStatus.charAt(0) == '2';
984988
}
985989

986990
private boolean isHttpStatusTerminal4xx() {
987-
// a 4xx other than 429 is a terminal client-error rejection (429 is a transient rate-limit)
988-
return responseStatus.length() > 0 && responseStatus.charAt(0) == '4' && !Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus);
991+
// a 4xx other than 429 is a terminal client-error rejection (429 is a transient rate-limit); require a
992+
// full 3-digit status so a malformed short "4" is not classified as a terminal 4xx
993+
return responseStatus.length() == 3 && responseStatus.charAt(0) == '4' && !Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus);
989994
}
990995

991996
private boolean isHttpStatusTransient() {
992997
// a 5xx server error or a 429 rate-limit is transient - keep polling; any other non-2xx (a 4xx
993-
// rejection) is terminal. Mirrors the Python client's _http_status_is_transient.
994-
return responseStatus.length() > 0 && (responseStatus.charAt(0) == '5' || Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus));
998+
// rejection) is terminal. Mirrors the Python client's _http_status_is_transient. Require a full
999+
// 3-digit status so a malformed short "5" is not classified as a transient 5xx.
1000+
return responseStatus.length() == 3 && (responseStatus.charAt(0) == '5' || Chars.equals(HTTP_STATUS_TOO_MANY_REQUESTS, responseStatus));
9951001
}
9961002

9971003
private void pollForToken(String deviceCode, int expiresInSeconds, int intervalSeconds) {
@@ -1241,11 +1247,16 @@ private void sleepBetweenPolls(long millis) {
12411247
}
12421248

12431249
private void storeTokens(TokenResponseParser parser) {
1244-
// reject a token with control or non-ASCII chars before caching: getToken() serves it verbatim as
1245-
// an HTTP Authorization header value and a PG-wire password, where a decoded CR/LF would inject
1246-
// into the request line sent to the trusted QuestDB server
1247-
validateTokenChars(parser.accessToken, "access_token");
1248-
validateTokenChars(parser.idToken, "id_token");
1250+
// reject a token with control or non-ASCII chars before caching: getToken() serves it verbatim as an
1251+
// HTTP Authorization header value and a PG-wire password, where a decoded CR/LF would inject into the
1252+
// request line sent to the trusted QuestDB server. Validate only the kind getToken() actually serves
1253+
// (the one that reaches the wire); the other kind is cached but never sent, so a stray char in it must
1254+
// not abort an otherwise-usable grant.
1255+
if (groupsInToken) {
1256+
validateTokenChars(parser.idToken, "id_token");
1257+
} else {
1258+
validateTokenChars(parser.accessToken, "access_token");
1259+
}
12491260
accessToken = parser.accessToken.length() > 0 ? parser.accessToken.toString() : null;
12501261
idToken = parser.idToken.length() > 0 ? parser.idToken.toString() : null;
12511262
// a refresh response usually omits a new refresh token; keep the current one in that case

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3176,6 +3176,69 @@ private static OidcDeviceAuth.DiscoveryOptions insecure() {
31763176
return new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true).prompt(noopPrompt());
31773177
}
31783178

3179+
@Test(timeout = 30_000)
3180+
public void testControlCharInUnusedTokenKindDoesNotAbortGrant() throws Exception {
3181+
assertMemoryLeak(() -> {
3182+
// groupsInToken=false, so getToken() serves and sends only the access_token; the id_token is
3183+
// cached but never placed in a header or a PG-wire password. A control char in that unused id_token
3184+
// must not reject an otherwise-usable grant - only the served kind is validated for wire safety
3185+
MockOidcServer.Handler handler = (method, path, body) -> {
3186+
if (DEVICE_PATH.equals(path)) {
3187+
return MockOidcServer.json(200, "{"
3188+
+ "\"device_code\":\"DEV\","
3189+
+ "\"user_code\":\"WDJB-MJHT\","
3190+
+ "\"verification_uri\":\"https://verify.example/device\","
3191+
+ "\"expires_in\":300,"
3192+
+ "\"interval\":1"
3193+
+ "}");
3194+
}
3195+
// a clean access_token (the served kind) alongside an id_token carrying a decoded control char
3196+
return MockOidcServer.json(200, tokenJson("CLEAN-ACCESS", "bad" + jsonUnicodeEscape(0x0001) + "id", null, 3600));
3197+
};
3198+
try (MockOidcServer server = new MockOidcServer(handler);
3199+
OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
3200+
Assert.assertEquals("CLEAN-ACCESS", auth.getToken());
3201+
}
3202+
});
3203+
}
3204+
3205+
@Test(timeout = 30_000)
3206+
public void testShortAllDigitStatusIsNotTreatedAsSuccess() throws Exception {
3207+
assertMemoryLeak(() -> {
3208+
// a real HTTP status is exactly 3 digits; a malformed 1-digit "2" (all digits, so readResponse
3209+
// accepts it) must not be classified as a 2xx success by its leading digit and accepted as a grant
3210+
String tokenBody = tokenJson("SHOULD-NOT-ACCEPT", null, null, 3600);
3211+
String rawToken = "HTTP/1.1 2 OK\r\n"
3212+
+ "Content-Type: application/json\r\n"
3213+
+ "Transfer-Encoding: chunked\r\n\r\n"
3214+
+ Integer.toHexString(tokenBody.length()) + "\r\n" + tokenBody + "\r\n"
3215+
+ "0\r\n\r\n";
3216+
MockOidcServer.Handler handler = (method, path, body) -> {
3217+
if (DEVICE_PATH.equals(path)) {
3218+
return MockOidcServer.json(200, "{"
3219+
+ "\"device_code\":\"DEV\","
3220+
+ "\"user_code\":\"WDJB-MJHT\","
3221+
+ "\"verification_uri\":\"https://verify.example/device\","
3222+
+ "\"expires_in\":300,"
3223+
+ "\"interval\":1"
3224+
+ "}");
3225+
}
3226+
return MockOidcServer.raw(rawToken);
3227+
};
3228+
try (MockOidcServer server = new MockOidcServer(handler);
3229+
OidcDeviceAuth auth = newAuth(server, false, noopPrompt())) {
3230+
try {
3231+
auth.getToken();
3232+
Assert.fail("expected a malformed 1-digit status to be rejected, not accepted as success");
3233+
} catch (OidcAuthException e) {
3234+
String msg = e.getMessage();
3235+
Assert.assertTrue(msg, msg.contains("rejected the request") || msg.contains("refusing to keep polling"));
3236+
Assert.assertFalse("the unaccepted token must not leak: " + msg, msg.contains("SHOULD-NOT-ACCEPT"));
3237+
}
3238+
}
3239+
});
3240+
}
3241+
31793242
// Forces the cached access/id token to look expired WITHOUT dropping the refresh token, so the next
31803243
// getToken()/getTokenSilently() takes the silent-refresh (or interactive re-sign-in) path. Reflection
31813244
// because the field is private and there is no configurable clock skew to lean on anymore; the client is

0 commit comments

Comments
 (0)