Skip to content

Commit 8d38d4f

Browse files
glasstigerclaude
andcommitted
OIDC device-flow review follow-ups
Follow-ups from the device-flow review: - README: the OIDC quick-start still called the pre-rename API (getToken() as the interactive sign-in, auth::getTokenSilently). Use signIn() for the one-time sign-in and auth::getToken for the token provider, matching the renamed methods. - OidcDeviceAuth: pre-encode the invariant form params (client_id, scope, audience) once in the constructor and the device_code once in pollForToken, instead of re-running URLEncoder on every poll (~once/5s through a sign-in) and every silent refresh. Mirrors the existing GRANT_TYPE_*_ENCODED constants; the wire output is unchanged. Add appendEncodedParam for the pre-encoded values and keep appendParam for the dynamic refresh_token. - Reorder getToken() before signIn() and rename the lagging testGetTokenSilently* / testConcurrentGetToken* methods to match the renamed API. - QwpQueryClientTokenProviderTest: cover the real connect path, not just the test hook - a throwing provider fails the connection attempt, the pulled token reaches the actual upgrade Authorization header, and a null, empty or blank provider return is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3d10a4e commit 8d38d4f

4 files changed

Lines changed: 192 additions & 73 deletions

File tree

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,15 @@ import io.questdb.client.cutlass.auth.OidcDeviceAuth;
168168

169169
// Discover the client id, scope and endpoints from the QuestDB server's /settings:
170170
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.com:9000")) {
171-
auth.getToken(); // sign in once: prompts on first use, then caches and refreshes
171+
auth.signIn(); // sign in once: prompts on first use, then caches and refreshes
172172

173173
// Pass a token provider, not a fixed string: the sender pulls a freshly refreshed token on each
174-
// request, so a long-lived sender keeps working as the token rotates. getTokenSilently() refreshes
174+
// request, so a long-lived sender keeps working as the token rotates. getToken() refreshes
175175
// silently and never prompts on the flush path.
176176
try (Sender sender = Sender.builder(Sender.Transport.HTTP)
177177
.address("questdb.example.com:9000")
178178
.enableTls()
179-
.httpTokenProvider(auth::getTokenSilently)
179+
.httpTokenProvider(auth::getToken)
180180
.build()) {
181181
sender.table("trades")
182182
.symbol("symbol", "ETH-USD")
@@ -186,7 +186,7 @@ try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB("https://questdb.example.c
186186
}
187187
```
188188

189-
Prefer `httpTokenProvider(auth::getTokenSilently)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history.
189+
Prefer `httpTokenProvider(auth::getToken)` for a long-lived sender: it pulls a freshly refreshed token on every request, so the sender keeps working as the token rotates. A fixed `httpToken(token)` captures the token once, so a sender that outlives the token's lifetime starts failing with 401s. Either way, hand the token to the client through the builder (or the header/password fields below), not by embedding it in a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history.
190190

191191
By default the prompt prints the verification URL and code to `System.out` **and** tries to open the URL in your default browser. The browser open is best-effort: it only opens an `http(s)` URL, is skipped on a headless host or a JVM without the `java.desktop` module, and never blocks sign-in — the URL and code are always printed too, so a remote or browserless process still works. To disable the browser launch for a whole process (a server, automation, CI), set the system property `-Dquestdb.client.oidc.open.browser=false`. To print only (no browser) for a single client, pass `DeviceCodePrompt.SYSTEM_OUT`; to render the challenge yourself (a clickable link or QR code in a notebook), pass any `DeviceCodePrompt`:
192192

@@ -195,7 +195,7 @@ By default the prompt prints the verification URL and code to `System.out` **and
195195
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
196196
"https://questdb.example.com:9000",
197197
new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT))) {
198-
auth.getToken();
198+
auth.signIn();
199199
}
200200
```
201201

@@ -222,7 +222,7 @@ Discovery via `fromQuestDB(...)` reads the OIDC client id, scope, audience and e
222222
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
223223
"https://questdb.example.com:9000",
224224
new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.example.com"))) {
225-
auth.getToken();
225+
auth.signIn();
226226
}
227227
```
228228

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

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

48-
import java.io.UnsupportedEncodingException;
4948
import java.net.URLEncoder;
49+
import java.nio.charset.StandardCharsets;
5050
import java.util.concurrent.locks.ReentrantLock;
5151

5252
/**
@@ -146,8 +146,8 @@ public class OidcDeviceAuth implements QuietCloseable {
146146
private static final int SLOW_DOWN_INCREMENT_SECONDS = 5;
147147
private static final String USER_AGENT = "questdb/java-client-oidc";
148148
private static final String WELL_KNOWN_OPENID_CONFIGURATION_PATH = "/.well-known/openid-configuration";
149-
private final String audience;
150-
private final String clientId;
149+
private final String audienceEncoded;
150+
private final String clientIdEncoded;
151151
private final DeviceAuthorizationResponseParser deviceAuthParser = new DeviceAuthorizationResponseParser();
152152
private final Endpoint deviceAuthorizationEndpoint;
153153
private final StringSink formSink = new StringSink();
@@ -158,7 +158,7 @@ public class OidcDeviceAuth implements QuietCloseable {
158158
private final ReentrantLock lock = new ReentrantLock();
159159
private final DeviceCodePrompt prompt;
160160
private final StringSink responseStatus = new StringSink();
161-
private final String scope;
161+
private final String scopeEncoded;
162162
private final ClientTlsConfiguration tlsConfig;
163163
private final Endpoint tokenEndpoint;
164164
private final TokenResponseParser tokenParser = new TokenResponseParser();
@@ -175,11 +175,16 @@ public class OidcDeviceAuth implements QuietCloseable {
175175
private long tokenTtlMillis;
176176

177177
private OidcDeviceAuth(Builder builder, ClientTlsConfiguration tlsConfig) {
178-
this.clientId = builder.clientId;
178+
String clientId = builder.clientId;
179+
// pre-encode the invariant form params once here, so the poll loop and silent refresh do not
180+
// re-run URLEncoder on every request (mirrors the pre-encoded GRANT_TYPE_* constants)
181+
this.clientIdEncoded = urlEncode(clientId);
179182
this.deviceAuthorizationEndpoint = Endpoint.parse(builder.deviceAuthorizationEndpoint);
180183
this.tokenEndpoint = Endpoint.parse(builder.tokenEndpoint);
181-
this.scope = builder.scope;
182-
this.audience = builder.audience;
184+
String scope = builder.scope;
185+
this.scopeEncoded = urlEncode(scope);
186+
String audience = builder.audience;
187+
this.audienceEncoded = audience != null ? urlEncode(audience) : null;
183188
this.groupsInToken = builder.groupsInToken;
184189
this.httpTimeoutMillis = builder.httpTimeoutMillis;
185190
this.prompt = builder.prompt;
@@ -406,39 +411,6 @@ public String getAuthorizationHeaderValue() {
406411
return "Bearer " + signIn();
407412
}
408413

409-
/**
410-
* Returns a valid token to present to QuestDB: the cached token while still valid, otherwise a
411-
* silent refresh when possible, otherwise the interactive device flow. The token is the id token
412-
* when the server expects groups encoded in the token, the access token otherwise.
413-
*
414-
* @return a non-null, non-empty token
415-
* @throws OidcAuthException if the interactive flow fails, times out, or the identity provider
416-
* does not return the expected token
417-
*/
418-
public String signIn() {
419-
lock.lock();
420-
try {
421-
throwIfClosed();
422-
// only the kind of token signIn() actually serves counts as a cache hit; a grant that
423-
// returned the other kind (access token when the server wants the id token, or vice versa)
424-
// leaves the served token null, so re-run the flow rather than report the unusable grant as
425-
// valid and have selectToken() throw on this and every later call
426-
final String cachedToken = groupsInToken ? idToken : accessToken;
427-
if (cachedToken != null) {
428-
if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) {
429-
return cachedToken;
430-
}
431-
if (refreshToken != null && tryRefresh()) {
432-
return selectToken();
433-
}
434-
}
435-
runDeviceFlow();
436-
return selectToken();
437-
} finally {
438-
lock.unlock();
439-
}
440-
}
441-
442414
/**
443415
* Like {@link #signIn()} but never starts the interactive device flow, never prompts, and never waits
444416
* on interactive input: returns the cached token while valid, silently refreshes when a refresh token is
@@ -485,6 +457,39 @@ public String getToken() {
485457
}
486458
}
487459

460+
/**
461+
* Returns a valid token to present to QuestDB: the cached token while still valid, otherwise a
462+
* silent refresh when possible, otherwise the interactive device flow. The token is the id token
463+
* when the server expects groups encoded in the token, the access token otherwise.
464+
*
465+
* @return a non-null, non-empty token
466+
* @throws OidcAuthException if the interactive flow fails, times out, or the identity provider
467+
* does not return the expected token
468+
*/
469+
public String signIn() {
470+
lock.lock();
471+
try {
472+
throwIfClosed();
473+
// only the kind of token signIn() actually serves counts as a cache hit; a grant that
474+
// returned the other kind (access token when the server wants the id token, or vice versa)
475+
// leaves the served token null, so re-run the flow rather than report the unusable grant as
476+
// valid and have selectToken() throw on this and every later call
477+
final String cachedToken = groupsInToken ? idToken : accessToken;
478+
if (cachedToken != null) {
479+
if (System.currentTimeMillis() < expiresAtMillis - effectiveSkewMillis()) {
480+
return cachedToken;
481+
}
482+
if (refreshToken != null && tryRefresh()) {
483+
return selectToken();
484+
}
485+
}
486+
runDeviceFlow();
487+
return selectToken();
488+
} finally {
489+
lock.unlock();
490+
}
491+
}
492+
488493
private static String appendSettingsPath(String basePath) {
489494
String trimmed = basePath;
490495
while (trimmed.length() > 1 && trimmed.charAt(trimmed.length() - 1) == '/') {
@@ -887,13 +892,8 @@ private static boolean settingsChannelIsPlaintext(Endpoint server) {
887892
}
888893

889894
private static String urlEncode(String value) {
890-
try {
891-
// the Charset overload is Java 10; the client targets Java 8, so use the String-charset form
892-
return URLEncoder.encode(value, "UTF-8");
893-
} catch (UnsupportedEncodingException e) {
894-
// UTF-8 is guaranteed present on every JVM, so this is unreachable; rethrow defensively
895-
throw new OidcAuthException(e).put("UTF-8 encoding is not supported");
896-
}
895+
// the Charset overload is Java 10; the client targets Java 8, so use the String-charset form
896+
return URLEncoder.encode(value, StandardCharsets.UTF_8);
897897
}
898898

899899
private static void validateEndpointOrigins(Endpoint tokenEndpoint, Endpoint deviceAuthorizationEndpoint, Endpoint issuer) {
@@ -953,6 +953,10 @@ private static String wellKnownUrl(String issuer) {
953953
return trimmed + WELL_KNOWN_OPENID_CONFIGURATION_PATH;
954954
}
955955

956+
private void appendEncodedParam(StringSink sink, String name, String encodedValue) {
957+
sink.putAscii('&').putAscii(name).putAscii('=').putAscii(encodedValue);
958+
}
959+
956960
private void appendParam(StringSink sink, String name, String value) {
957961
sink.putAscii('&').putAscii(name).putAscii('=').putAscii(urlEncode(value));
958962
}
@@ -1001,6 +1005,9 @@ private boolean isHttpStatusTransient() {
10011005
}
10021006

10031007
private void pollForToken(String deviceCode, int expiresInSeconds, int intervalSeconds) {
1008+
// url-encode the opaque device code once here, not on every poll: it is invariant for the whole
1009+
// poll loop (the grant_type and client_id are likewise pre-encoded)
1010+
final String deviceCodeEncoded = urlEncode(deviceCode);
10041011
final long deadlineNanos = System.nanoTime() + expiresInSeconds * 1_000_000_000L;
10051012
long intervalMillis = (long) intervalSeconds * 1000L;
10061013
while (true) {
@@ -1011,7 +1018,7 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS
10111018
throw new OidcAuthException("timed out waiting for authorization, the device code expired; please retry");
10121019
}
10131020
try {
1014-
int result = pollOnce(deviceCode);
1021+
int result = pollOnce(deviceCodeEncoded);
10151022
if (result == POLL_SUCCESS) {
10161023
return;
10171024
}
@@ -1040,11 +1047,11 @@ private void pollForToken(String deviceCode, int expiresInSeconds, int intervalS
10401047
}
10411048
}
10421049

1043-
private int pollOnce(String deviceCode) {
1050+
private int pollOnce(String deviceCodeEncoded) {
10441051
formSink.clear();
10451052
formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_DEVICE_CODE_ENCODED);
1046-
appendParam(formSink, "device_code", deviceCode);
1047-
appendParam(formSink, "client_id", clientId);
1053+
appendEncodedParam(formSink, "device_code", deviceCodeEncoded);
1054+
appendEncodedParam(formSink, "client_id", clientIdEncoded);
10481055

10491056
tokenParser.clear();
10501057
// a transport failure here propagates to pollForToken, which keeps polling (a transient blip) until
@@ -1161,10 +1168,10 @@ private void readResponse(HttpClient client, HttpClient.ResponseHeaders response
11611168

11621169
private void runDeviceFlow() {
11631170
formSink.clear();
1164-
formSink.putAscii("client_id=").putAscii(urlEncode(clientId));
1165-
appendParam(formSink, "scope", scope);
1166-
if (audience != null) {
1167-
appendParam(formSink, "audience", audience);
1171+
formSink.putAscii("client_id=").putAscii(clientIdEncoded);
1172+
appendEncodedParam(formSink, "scope", scopeEncoded);
1173+
if (audienceEncoded != null) {
1174+
appendEncodedParam(formSink, "audience", audienceEncoded);
11681175
}
11691176

11701177
deviceAuthParser.clear();
@@ -1281,12 +1288,12 @@ private boolean tryRefresh() {
12811288
formSink.clear();
12821289
formSink.putAscii("grant_type=").putAscii(GRANT_TYPE_REFRESH_TOKEN_ENCODED);
12831290
appendParam(formSink, "refresh_token", refreshToken);
1284-
appendParam(formSink, "client_id", clientId);
1285-
if (scope != null) {
1286-
appendParam(formSink, "scope", scope);
1291+
appendEncodedParam(formSink, "client_id", clientIdEncoded);
1292+
if (scopeEncoded != null) {
1293+
appendEncodedParam(formSink, "scope", scopeEncoded);
12871294
}
1288-
if (audience != null) {
1289-
appendParam(formSink, "audience", audience);
1295+
if (audienceEncoded != null) {
1296+
appendEncodedParam(formSink, "audience", audienceEncoded);
12901297
}
12911298

12921299
tokenParser.clear();

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ public void testCloseCancelsInFlightSignIn() throws Exception {
564564
}
565565

566566
@Test(timeout = 30_000)
567-
public void testConcurrentGetTokenStartsSingleSignIn() throws Exception {
567+
public void testConcurrentSignInStartsSingleSignIn() throws Exception {
568568
assertMemoryLeak(() -> {
569569
// several callers race signIn() on a fresh instance; the synchronized method must serialize
570570
// them so exactly one interactive sign-in runs and the rest get the cached token
@@ -1388,7 +1388,7 @@ public void testGarbledRefreshResponseFallsBackToInteractiveFlow() throws Except
13881388
}
13891389

13901390
@Test(timeout = 30_000)
1391-
public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exception {
1391+
public void testGetTokenDoesNotBlockBehindInteractiveSignIn() throws Exception {
13921392
assertMemoryLeak(() -> {
13931393
// an interactive signIn() is parked polling (authorization_pending), holding the instance
13941394
// lock for the whole device-code lifetime. A flush-path getToken() on another thread
@@ -1436,7 +1436,7 @@ public void testGetTokenSilentlyDoesNotBlockBehindInteractiveSignIn() throws Exc
14361436
}
14371437

14381438
@Test(timeout = 30_000)
1439-
public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Exception {
1439+
public void testGetTokenDoesNotBlockBehindSilentRefresh() throws Exception {
14401440
assertMemoryLeak(() -> {
14411441
// the flush-path contract also holds when the lock is held by another thread's SILENT REFRESH, not
14421442
// just an interactive sign-in: getToken() must fail fast rather than queue behind it. The
@@ -1503,7 +1503,7 @@ public void testGetTokenSilentlyDoesNotBlockBehindSilentRefresh() throws Excepti
15031503
}
15041504

15051505
@Test(timeout = 30_000)
1506-
public void testGetTokenSilentlyRefreshesWithoutPrompting() throws Exception {
1506+
public void testGetTokenRefreshesWithoutPrompting() throws Exception {
15071507
assertMemoryLeak(() -> {
15081508
// getToken() returns the cached token, silently refreshes it when it expires, and never
15091509
// prompts; if it cannot produce a token without an interactive sign-in, it throws

0 commit comments

Comments
 (0)