Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
203 commits
Select commit Hold shift + click to select a range
86d65ff
feat(core): OIDC device flow
glasstiger Jun 17, 2026
ca59c01
Merge branch 'main' into ia_oidc_device_flow
glasstiger Jun 17, 2026
c92ee56
Sanitize bidi in OIDC prompt, defer token pull
glasstiger Jun 17, 2026
c3a4749
Harden OIDC parser: null, port range, token TTL
glasstiger Jun 17, 2026
4048273
Merge branch 'main' into ia_oidc_device_flow
glasstiger Jun 18, 2026
2e08fa8
Merge branch 'ia_oidc_device_flow' of https://github.com/questdb/java…
glasstiger Jun 18, 2026
2a1ce7d
fix test
glasstiger Jun 18, 2026
c036642
Fix sender corruption when the token provider throws
glasstiger Jun 18, 2026
eadc63f
Stop getTokenSilently blocking the flush path
glasstiger Jun 18, 2026
58920aa
Sanitize display text per code point
glasstiger Jun 18, 2026
1db99c1
Reject tokens from error or non-2xx responses
glasstiger Jun 18, 2026
c0ff593
Reject a null or empty provider token
glasstiger Jun 18, 2026
9824d69
improved tests
glasstiger Jun 18, 2026
faa6e47
Speed up JSON unescape and validate URL hosts
glasstiger Jun 18, 2026
19a9966
Add OIDC issuer pin and .well-known discovery
glasstiger Jun 18, 2026
dc02c16
Validate OIDC URLs and enforce the discoveryUrl pin
glasstiger Jun 19, 2026
e523db2
Reject display-unsafe characters in OIDC URLs
glasstiger Jun 19, 2026
caa5087
Strip unpaired surrogates from OIDC display text
glasstiger Jun 19, 2026
7266d47
Clamp slow_down interval and reset parser fields
glasstiger Jun 19, 2026
6f02ccf
Simplify JSON unescape and tidy method ordering
glasstiger Jun 19, 2026
9da26c1
Test the OIDC response body size cap
glasstiger Jun 19, 2026
697f49a
Harden OIDC device-flow status and timeout checks
glasstiger Jun 19, 2026
6e97d14
Pin OIDC discovery to the discoveryUrl origin
glasstiger Jun 21, 2026
4dbce9e
Reject a non-numeric OIDC HTTP status code
glasstiger Jun 21, 2026
ddd3e62
Escape control chars in ILP error messages
glasstiger Jun 21, 2026
c0ed8b4
Test the plaintext-channel OIDC pin firing path
glasstiger Jun 21, 2026
8421148
Fix OIDC Windows test, use try-with-resources
glasstiger Jun 22, 2026
49becd9
Reject OIDC tokens with control or non-ASCII chars
glasstiger Jun 22, 2026
bd37dc8
Bound chunked response reads to the call timeout
glasstiger Jun 22, 2026
64933dc
Escape bidi and format chars in error messages
glasstiger Jun 22, 2026
619a3fc
Tighten OIDC device-flow comments and javadoc
glasstiger Jun 22, 2026
7b9d20f
Add OIDC browser-open prompt and DiscoveryOptions
glasstiger Jun 23, 2026
6ac442b
Make OIDC browser-open the default
glasstiger Jun 23, 2026
2126e22
Send OIDC audience on device and refresh requests
glasstiger Jun 23, 2026
b8f073e
Tighten OIDC IdP transport and issuer-path trust
glasstiger Jun 23, 2026
0a31d49
Clamp OIDC device-code lifetime to 600s/1800s
glasstiger Jun 23, 2026
28dc110
reduce max poll interval to 60s
glasstiger Jun 23, 2026
8e721d4
Treat OIDC token-poll 429 as a transient backoff
glasstiger Jun 23, 2026
62403f3
Remove OIDC poll-error budget; match Python model
glasstiger Jun 23, 2026
f0cd84f
Accept token provider over WebSocket transport
glasstiger Jun 23, 2026
63487c1
Make OIDC clock skew fixed and lifetime-capped
glasstiger Jun 23, 2026
aab512b
Harden client response reads and display escaping
glasstiger Jun 23, 2026
4430a50
Fix HTTP client leak on lexer alloc failure
glasstiger Jun 23, 2026
a654fbb
Sort static helpers and pre-encode grant types
glasstiger Jun 23, 2026
0865d0e
Drop the OIDC connection on a bounded-read abort
glasstiger Jun 24, 2026
7d52ad5
Harden OIDC URL parsing and address review nits
glasstiger Jun 24, 2026
94da999
Reject control/non-ASCII chars in provider tokens
glasstiger Jun 24, 2026
15067f7
Fix build-time pull claim in token provider docs
glasstiger Jun 24, 2026
67c78d9
Harden issuer-path scope and fix review nits
glasstiger Jun 24, 2026
d5bcf93
Escape control/bidi chars in flush error messages
glasstiger Jun 24, 2026
23b6656
Unify display-safety classifier and add tests
glasstiger Jun 24, 2026
cea40a5
Trust discovered OIDC endpoints; drop discoveryUrl
glasstiger Jun 25, 2026
5dd82b4
Merge remote-tracking branch 'origin/main' into ia_oidc_device_flow
glasstiger Jun 25, 2026
c35d931
Fix Java 8 build breaks in OIDC device flow
glasstiger Jun 25, 2026
0493b4c
Sanitize HTTP status and probe text in errors
glasstiger Jun 25, 2026
a4875f2
Tighten OIDC token-kind and status validation
glasstiger Jun 25, 2026
e1f3d53
Fix Java 8 build: replace String.repeat in test
glasstiger Jun 25, 2026
3d10a4e
QWP egress token provider and OIDC API rename
glasstiger Jun 25, 2026
8d38d4f
OIDC device-flow review follow-ups
glasstiger Jun 25, 2026
272d704
Fix Java 8 build: use URLEncoder String charset
glasstiger Jun 25, 2026
1b7fecd
Fail fast on QWP token-provider failures
glasstiger Jun 25, 2026
a4e928f
opt-in token persistence
glasstiger Jun 29, 2026
65f590b
Merge remote-tracking branch 'origin/main' into ia_oidc_device_flow
glasstiger Jun 29, 2026
e0010d6
Cap token-store lock budget; add persistence tests
glasstiger Jun 29, 2026
148d895
Merge remote-tracking branch 'origin/main' into ia_oidc_device_flow
glasstiger Jun 29, 2026
71e01f2
Harden OIDC token store lock and expiry clamp
glasstiger Jun 29, 2026
9be7c28
Harden token-store load and key validation
glasstiger Jun 29, 2026
2128e54
Tighten token-store hardening and hygiene
glasstiger Jun 29, 2026
aba8399
Refine token-store cross-process and load semantics
glasstiger Jun 29, 2026
8469be9
Enforce OIDC token store lock staleness floor
glasstiger Jun 30, 2026
b016dd0
Harden OIDC token-store lock steal and release
glasstiger Jun 30, 2026
ea7c1a8
Harden token-store cross-process lock edge cases
glasstiger Jun 30, 2026
a454ac0
Fix OIDC refresh NPE and harden display safety
glasstiger Jul 1, 2026
2ce1ca0
Reject OIDC endpoint fragment, harden token lock
glasstiger Jul 1, 2026
9e0a756
Fix WS per-endpoint token pull and getToken doc
glasstiger Jul 1, 2026
1d67163
Reject non-ASCII OIDC hosts and fix review nits
glasstiger Jul 1, 2026
6ced352
Fix flaky token-store steal-contention test
glasstiger Jul 1, 2026
f3e62ed
Address code-review findings on OIDC device flow
glasstiger Jul 1, 2026
acfa6e6
Serialize token-store long fields as digits
glasstiger Jul 1, 2026
7ba71a7
Add OIDC token-provider tests and cleanups
glasstiger Jul 1, 2026
8b91ecb
token persistence example
glasstiger Jul 1, 2026
6334bba
Fix OIDC clock-skew collapse for stored tokens
glasstiger Jul 1, 2026
4b385ee
Harden OIDC device-flow input validation
glasstiger Jul 1, 2026
9daaba6
Merge remote-tracking branch 'origin/main' into ia_oidc_device_flow
glasstiger Jul 14, 2026
c726c13
Fix three OIDC device-flow token-provider defects
glasstiger Jul 14, 2026
1418554
Cover untested OIDC load-bearing guards
glasstiger Jul 14, 2026
304663f
Address moderate OIDC review findings
glasstiger Jul 14, 2026
07f809b
Build ILP token-provider request once per flush
glasstiger Jul 14, 2026
82ae4af
Address remaining moderate OIDC review findings
glasstiger Jul 14, 2026
82b8bab
Fix OIDC cancelRow crash and SF drainer terminal
glasstiger Jul 15, 2026
3250b3e
Harden OIDC token validation, lock and issuer-path pin
glasstiger Jul 15, 2026
b7bb36d
Harden OIDC token flow, store lock, name escaping
glasstiger Jul 15, 2026
2acba17
Fix OIDC blank-refresh gate and correct docs
glasstiger Jul 15, 2026
7ed1aa1
Correct misleading OIDC docs and mislabeled test
glasstiger Jul 15, 2026
bea586d
Merge remote-tracking branch 'origin/main' into ia_oidc_device_flow
glasstiger Aug 5, 2026
f2f9cf9
Validate the row before at() writes any bytes
glasstiger Aug 5, 2026
e0b2037
Report a credential outage to the error handler
glasstiger Aug 5, 2026
5ec1c21
Let close() break a drainer stuck in a token pull
glasstiger Aug 5, 2026
e06be8c
Correct the frozen token-store interop contract
glasstiger Aug 5, 2026
a606bce
Stop a hostile chunk size spinning the read loop
glasstiger Aug 6, 2026
17a811f
Keep the token store working under an interrupt
glasstiger Aug 6, 2026
14df48b
Let an orphan drainer ride out a rotating 401
glasstiger Aug 6, 2026
0aa7ca0
Keep a persisted refresh token with no served kind
glasstiger Aug 6, 2026
fde8db5
Harden the token store lock and on-disk format
glasstiger Aug 6, 2026
4b52bf6
Ride out a rotating 401 that lands mid-drain
glasstiger Aug 6, 2026
fa13d4d
Surface mock handler errors; leak-check QWP tests
glasstiger Aug 6, 2026
1a0bad9
Harden rotating auth retry and provider failures
glasstiger Aug 6, 2026
7a95bb3
Merge branch 'main' into ia_oidc_device_flow
glasstiger Aug 6, 2026
d34f05b
Merge remote-tracking branch 'origin/main' into ia_oidc_device_flow
glasstiger Aug 9, 2026
d4f2887
Merge branch 'main' into ia_oidc_device_flow
bluestreak01 Aug 18, 2026
2d08b59
feat: support token providers for pooled clients
glasstiger Aug 18, 2026
9a5eb88
update review-pr skill
glasstiger Aug 18, 2026
29ae3a0
fix Java 8 build
glasstiger Aug 18, 2026
9947e68
Report a credential outage from the orphan drainer
glasstiger Aug 18, 2026
3223cc6
Cover token providers on SF pooled senders
glasstiger Aug 18, 2026
efa5d90
Cover the fixed-vs-rotating credential tag
glasstiger Aug 18, 2026
54a364e
Restore the exported API signatures this branch broke
glasstiger Aug 18, 2026
7681b96
Stop a device grant inheriting the previous user's refresh token
glasstiger Aug 18, 2026
6246c3f
Make the token store's lock waits interruptible
glasstiger Aug 18, 2026
97fa287
Reject every overflowing chunk size, not just negative ones
glasstiger Aug 18, 2026
6674c6e
Tighten parseHexLong itself instead of adding a checked sibling
glasstiger Aug 18, 2026
67061c2
Require a 2xx before trusting a discovery body
glasstiger Aug 18, 2026
823498c
Stop a response-body read abort from re-sending a flush
glasstiger Aug 18, 2026
47f0210
Degrade around a throwing TokenStore instead of failing the sign-in
glasstiger Aug 18, 2026
c287f89
Roll back HttpClient construction when it fails partway
glasstiger Aug 18, 2026
fcd8859
Reject a store entry carrying only a refresh token
glasstiger Aug 19, 2026
793db46
Assert the store directory instead of assuming it
glasstiger Aug 19, 2026
d3d3650
Keep a carried interrupt from swallowing a sign-out
glasstiger Aug 19, 2026
8cf04f9
Handle a malformed response head from the provider
glasstiger Aug 19, 2026
71c04f9
Bound the rotating-credential 401 ride-out
glasstiger Aug 19, 2026
6b7ccc4
Check the store directory on the read path too
glasstiger Aug 19, 2026
c0b341d
Retry an unparseable response head on the ILP flush
glasstiger Aug 19, 2026
4b7e4ed
Restart the rotating-401 dwell after a transient
glasstiger Aug 19, 2026
45f87aa
Back off after a failed silent refresh
glasstiger Aug 19, 2026
e900d94
Say SLF4J where the docs promised System.err
glasstiger Aug 19, 2026
d7416d5
Decline a silent refresh on a cancelled caller
glasstiger Aug 19, 2026
3f94ec2
Clamp the rotating-401 dwell instead of capping attempts
glasstiger Aug 19, 2026
1a9f765
Correct what the rotating-401 comments promise
glasstiger Aug 19, 2026
b71f8b2
Restart the 401 dwell on a capability gap too
glasstiger Aug 19, 2026
36935a7
Hold the ride-out budgets per drain, not per call
glasstiger Aug 19, 2026
a974adb
Roll back ResponseHeaders' own buffer too
glasstiger Aug 19, 2026
cda4a81
Report an empty buffer as address zero, not -1
glasstiger Aug 19, 2026
75ad4b8
Close three security residuals in the token store
glasstiger Aug 19, 2026
6bc8528
Throttle a failing token store read instead of retrying per flush
glasstiger Aug 19, 2026
131a78c
Give the weak tests teeth and stop repeating the failure block
glasstiger Aug 19, 2026
67542a2
Bound the two lock waits on nanoTime, not the wall clock
glasstiger Aug 19, 2026
002228a
Make java.desktop optional so the module resolves without it
glasstiger Aug 19, 2026
a4da03b
Make the desktop-free child structurally unable to open a browser
glasstiger Aug 19, 2026
65f0733
Stripe the in-process token store locks, unblock the lock stamp
glasstiger Aug 19, 2026
5c11757
Pin what five regression tests only appeared to test
glasstiger Aug 19, 2026
94adf95
State the token store's real single-login and integrity limits
glasstiger Aug 20, 2026
90e49e1
Drop the credentials on close instead of keeping them
glasstiger Aug 20, 2026
ce9c142
Validate a row once per row, not twice
glasstiger Aug 20, 2026
a230dfc
Tidy four things the auth tests were getting away with
glasstiger Aug 20, 2026
0e4c762
Arm the refresh back-off only on a real attempt
glasstiger Aug 20, 2026
c19e037
End the settle budget when the wire delivers
glasstiger Aug 20, 2026
5533480
Create the store fixture dir owner-only, not by umask
glasstiger Aug 20, 2026
9ceb005
Give each identity its own in-process lock again
glasstiger Aug 20, 2026
42fd547
Refuse a served token that reads as a bare JSON null
glasstiger Aug 20, 2026
ca7074a
Decline a sign-in on a cancelled thread, store or not
glasstiger Aug 20, 2026
be0b821
Restore what the review-pr skill sync overwrote
glasstiger Aug 20, 2026
72c5828
Say elapsed time where the comments claimed the wall clock
glasstiger Aug 20, 2026
dc6b1a6
Assert the leak test actually injected a failure
glasstiger Aug 20, 2026
be6ce1c
Pin the rotating-401 dwell clamp at its call site
glasstiger Aug 20, 2026
d23e952
Discard the whole store directory, not one entry, when it was exposed
glasstiger Aug 20, 2026
3052914
Pin the exported signatures this branch had to put back
glasstiger Aug 20, 2026
6204a0e
Snapshot a pulled token before validating it
glasstiger Aug 20, 2026
49e0c9b
Collapse two mutually masking SIGSEGV guards into one that is pinned
glasstiger Aug 20, 2026
90df372
Zero the buffer StringSink abandons when it grows
glasstiger Aug 20, 2026
975cdaf
Reunite eight doc comments with the members they document
glasstiger Aug 20, 2026
675b786
Say why the error response body could not be read
glasstiger Aug 20, 2026
5ebdaf0
Keep the two other things the ILP sender was handed and dropped
glasstiger Aug 20, 2026
807f984
Tidy three hygiene slips in the branch's own files
glasstiger Aug 20, 2026
110775b
Keep the rotated refresh token a refresh returns
glasstiger Aug 20, 2026
d228c96
Discard only files the token store itself wrote
glasstiger Aug 20, 2026
967a729
Namespace the in-process store lock by directory
glasstiger Aug 20, 2026
56eecc7
Stop signIn() ignoring and erasing a cancellation
glasstiger Aug 20, 2026
79e31ca
Assert the retryable half of isRetryable(), not just the other one
glasstiger Aug 20, 2026
7a66cb0
Bound the response head read on elapsed time
glasstiger Aug 20, 2026
2720bbf
Derive the OIDC transport budgets from its timeout
glasstiger Aug 20, 2026
930e568
Keep the interrupt a cancelled lock wait consumed
glasstiger Aug 20, 2026
71eadd7
Break a recovery delegate stuck in a credential pull
glasstiger Aug 20, 2026
26744e7
Retry a store whose directory was transiently unusable
glasstiger Aug 20, 2026
43db382
Cover lazy_connect crossed with a token provider
glasstiger Aug 20, 2026
d780810
Bound the chunk size where it is read, not in Numbers
glasstiger Aug 21, 2026
1fc842a
Document the bound the no-arg recv() now carries
glasstiger Aug 21, 2026
939c7ee
Write the JsonParser contract the lexer now relies on
glasstiger Aug 21, 2026
da85158
Restore the Windows FDSet leak injection
glasstiger Aug 21, 2026
7b3e207
Stop a carried interrupt skipping client teardown
glasstiger Aug 21, 2026
16180b9
Pin the capability-gap counter across recycles
glasstiger Aug 21, 2026
e03a512
Bound the rotating-401 ride-out under alternation
glasstiger Aug 21, 2026
7554b84
Wipe the JSON lexer's decode buffers too
glasstiger Aug 21, 2026
1cd272c
Qualify when a WebSocket build() pulls a token
glasstiger Aug 21, 2026
76b1b61
Make TokenStoreKey a value type
glasstiger Aug 21, 2026
9d5d053
Pin the OIDC body read's elapsed deadline
glasstiger Aug 21, 2026
7dd9358
Copy printable text in one go, not per character
glasstiger Aug 21, 2026
7faad66
Document the credential exception as the API it is
glasstiger Aug 21, 2026
89f5622
Pin the two uncovered escaping callsites
glasstiger Aug 21, 2026
5ecb431
Pin snapshot-before-validate on all three callsites
glasstiger Aug 21, 2026
b8ce60c
Stop a carried interrupt failing a delegate close
glasstiger Aug 21, 2026
929b626
Stop retrying a response head that cannot change
glasstiger Aug 21, 2026
5a6c431
Stop a recovered store read undoing a sign-in
glasstiger Aug 21, 2026
0a662f8
Stop writing a store entry adopt() refuses to read
glasstiger Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
738 changes: 599 additions & 139 deletions .claude/skills/review-pr/SKILL.md

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,106 @@ try (QuestDB db = QuestDB.connect("wss::addr=localhost:9000;tls_verify=unsafe_of
}
```

### OIDC Sign-In (Device Flow)

For QuestDB Enterprise instances secured with OIDC, `OidcDeviceAuth` signs a user in interactively using the [OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628). It works from environments that have no local browser — a remote notebook kernel, a container, a headless job — because the user authorizes on any device (laptop or phone) while the process only makes outbound calls to the identity provider.

On first use it prints a verification URL and a short code, and opens the URL in your default browser when one is available; authorize there (or open the URL on any device, such as your phone), enter the code, and the token is cached in memory and refreshed silently on later calls.

```java
import io.questdb.client.QuestDB;
import io.questdb.client.Sender;
import io.questdb.client.cutlass.auth.OidcDeviceAuth;

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

// The provider is shared by the ingest and query pools. It is queried for
// every initial WebSocket upgrade and reconnect, so both pools follow token
// rotation without putting a credential in the configuration string.
try (QuestDB db = QuestDB.connect(
"wss::addr=questdb.example.com:9000;",
auth::getToken)) {
try (Sender sender = db.borrowSender()) {
sender.table("trades")
.symbol("symbol", "ETH-USD")
.doubleColumn("price", 2615.54)
.atNow();
}
// db.borrowQuery() uses the same provider for query connections.
}
}
```

For a standalone sender, use `httpTokenProvider(auth::getToken)` for the same rotating-token behavior. A fixed `httpToken(token)` or `token=` connect-string value captures the token once, so a client that reconnects after that token expires starts failing authentication. Hand rotating credentials to the provider API, not a `Sender.fromConfig(...)` string or the `QDB_CLIENT_CONF` environment variable, which are easily logged, persisted, or left in shell history.

`getToken()` sits on a hot path — it is called once per ILP flush and once per WebSocket upgrade or reconnect — so a credential failure is rate-limited rather than retried on every call. When a silent refresh fails, `getToken()` does not attempt another one for 5 seconds: calls inside that window fail immediately, asking for an interactive `signIn()`, instead of hitting the identity provider again. Without that guard a producer retrying its rows would drive one token-endpoint round trip per flush, blocking the producer thread for each one and hitting the provider hard enough to trip its rate limits and lengthen the very outage being retried. Only a real refresh attempt arms the guard, and an explicit `signIn()` or `clearCache()` clears it outright. It is deliberately short — a stampede guard, not a circuit breaker — so a credential that comes back within seconds is picked up on the first call after the window rather than on the first call after it recovers.

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 client declares `requires static java.desktop`, so the module is optional at run time and its absence can never break module resolution; a modular application therefore gets the browser launch only when `java.desktop` is in its own module graph) — 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`:

```java
// print only, do not open a browser:
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
"https://questdb.example.com:9000",
new OidcDeviceAuth.DiscoveryOptions().prompt(DeviceCodePrompt.SYSTEM_OUT))) {
auth.signIn();
}
```

The same token can be presented to QuestDB over any auth path the server already validates:

- **REST API:** send it as an `Authorization: Bearer <token>` header (`auth.getAuthorizationHeaderValue()` returns the full value).
- **PG-wire:** connect as user `_sso` with the token as the password (requires `acl.oidc.pg.token.as.password.enabled=true` on the server).

To configure the identity provider explicitly instead of discovering it from the server:

```java
OidcDeviceAuth auth = OidcDeviceAuth.builder()
.clientId("questdb")
.deviceAuthorizationEndpoint("https://idp.example.com/as/device_authz.oauth2")
.tokenEndpoint("https://idp.example.com/as/token.oauth2")
.scope("openid groups")
.groupsInToken(true) // matches acl.oidc.groups.encoded.in.token on the server
.build();
```

Discovery via `fromQuestDB(...)` reads the OIDC client id, scope, audience and endpoints from the server's `/settings`, and the identity provider's client must have the device authorization grant enabled. When the server does not advertise its device authorization endpoint (today's servers), pin the identity provider by its issuer so the client can discover the endpoint from the issuer's `.well-known/openid-configuration` document:

```java
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
"https://questdb.example.com:9000",
new OidcDeviceAuth.DiscoveryOptions().issuer("https://idp.example.com"))) {
auth.signIn();
}
```

The identity provider's device authorization and token endpoints must use `https` — a loopback endpoint (`localhost` or `127.0.0.0/8`) may use `http`, since the request never leaves the host — so the device code and refresh token are never sent in cleartext. `allowInsecureTransport(true)` relaxes only the QuestDB `/settings` link (for local development against an `http` QuestDB server), e.g. `OidcDeviceAuth.fromQuestDB(url, new OidcDeviceAuth.DiscoveryOptions().allowInsecureTransport(true))`; it never relaxes the identity provider endpoints, matching the Python client.

`fromQuestDB(...)` takes the identity provider endpoints from the server's unauthenticated `/settings`, so it trusts that server to designate where you sign in: a spoofed, compromised, or man-in-the-middled server could redirect the sign-in to an attacker-controlled identity provider. Only use it against a server you trust, reached over `https`. Passing an issuer hardens this: the token and device authorization endpoints are then pinned to the issuer's origin (and, when the issuer has a path, an endpoint advertised by `/settings` must also be under that path — so a tampered `/settings` cannot redirect to a different tenant on a path-based provider such as Keycloak `…/realms/{realm}`), and an endpoint outside it is rejected; the issuer itself comes from you out of band, so a tampered `/settings` cannot move it. When the server is not trusted, configure the identity provider explicitly with `OidcDeviceAuth.builder()` (optionally with `.issuer(...)`) instead of discovering it.

#### Persisting the Token Across Restarts

By default the token lives in memory only, so a process that restarts has to run the device flow again. Pass a `TokenStore` to persist it; the restarted process then resumes from the saved refresh token — a silent call to the token endpoint — instead of prompting the user again:

```java
import io.questdb.client.cutlass.auth.FileTokenStore;

try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
"https://questdb.example.com:9000",
new OidcDeviceAuth.DiscoveryOptions().tokenStore(FileTokenStore.atDefaultLocation()))) {
auth.signIn(); // prompts the first time; after a restart it refreshes silently from the saved token
}
```

`FileTokenStore.atDefaultLocation()` writes one file per OIDC configuration under `${user.home}/.questdb/oidc-tokens/` (override the directory with the `questdb.client.oidc.token.store.dir` system property). The file name is a hash of the endpoints, client id, scope, audience and groups-in-token mode — the *configuration*, not the person who signed in through it, since none of those fields names a subject. Entries for different servers, providers or client configurations therefore stay separate, but **two people signing in through the same configuration share one entry**: whoever signs in last overwrites the previous token, so a store represents a single active login. If more than one application user has to be signed in at the same time, give each their own store — `FileTokenStore.at(dir)` on a per-user directory, or a per-user `questdb.client.oidc.token.store.dir` — rather than relying on the file name to separate them. The default location is already per OS user, so this only arises when one OS user (a shared service account, a multi-tenant process) signs in as several people. After a restart, `getToken()` also works as the first call — no explicit `signIn()` needed — which suits a long-lived `Sender` built with `httpTokenProvider(auth::getToken)`. `clearCache()` removes the persisted entry and forces a fresh sign-in next time.

A store read that *throws* — an unreadable file after a `chmod` or a uid change in a container, `EIO`/`ESTALE` on an NFS home — is not fatal and does not disable persistence for the life of the process, but it is not retried on every call either, since `getToken()` would otherwise pay a blocking file open and a `WARN` line per ILP flush, forever. The first failure is retried immediately, so a one-shot fault (notably a carried interrupt flag, which makes the channel underneath `FileTokenStore` throw on a thread that merely carries it) recovers on the next call; each consecutive failure after that backs off 5 seconds, doubling to a 60 second cap. A store that simply has nothing to return is unaffected — `load` reports that by returning `null` rather than by throwing, and the client stops asking.

The token is stored as **plaintext JSON protected by file permissions** — `0600` file, `0700` directory on POSIX systems (Linux, macOS), the same approach `gcloud`, `aws` and `gh` take. On Windows these POSIX permissions cannot be enforced, so the file currently relies on the user-profile directory's default ACL (owner-only ACL hardening is a follow-up); the client logs a one-line warning through SLF4J at `WARN` the first time it cannot enforce them (the library ships `slf4j-api` only, so this - and every other client warning - is discarded unless your application supplies an SLF4J binding). Enabling persistence therefore writes a long-lived refresh token to disk: anyone who can read the file holds a credential until it expires or is revoked. To encrypt it at rest, supply your own `TokenStore` (backed by an OS keychain or a secrets manager) instead of `FileTokenStore`. A persisted file is treated as untrusted input on load, but it is **not cryptographically authenticated** — there is no MAC or signature over its contents. Anyone who can write the file can therefore substitute a well-formed entry of their own, and the client will adopt it and present those tokens: the file permissions, not the file format, are what protect it. What the load path rejects is corruption and mix-ups rather than forgery — an oversized, malformed or unparseable file; an entry whose recorded client id, endpoints, scope, audience or groups-in-token mode does not match the identity being loaded; an entry carrying no usable token; and a token with control or non-ASCII characters, which is never placed on the wire — with the recorded expiry and lifetime clamped rather than trusted. In each case the client falls back to a refresh or an interactive sign-in. On POSIX the container is checked too: if the directory was writable by other local users, it is tightened back to `0700` and **every** entry in it is discarded — not just the one being read, since the tightening is what destroys the evidence, so anything left behind would look protected to the next load. Each identity then signs in again. Inside those permissions, though, the client cannot tell a planted credential from its own.

`FileTokenStore` is safe to share between processes that sign in as the same identity: each update is written atomically (so a concurrent reader never sees a half-written credential), and when the identity provider rotates the refresh token on each refresh, the read-refresh-write is serialized across processes with a lock file so they do not race each other into an unnecessary re-prompt. The lock file's staleness is judged by its modification time, so this coordination assumes the processes share a clock — a single machine, or machines with synchronized clocks; under significant clock skew (for example a store directory on NFS shared across hosts) a live lock can be mis-judged stale or a dead one never expire. `clearCache()` removes the persisted entry under the same lock, but across processes it is best-effort: a peer that still holds a live in-memory token may legitimately re-persist afterwards (it always forces a fresh sign-in for the calling process).

### Explicit Timestamps

```java
Expand Down
109 changes: 109 additions & 0 deletions core/src/main/java/io/questdb/client/HttpTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*+*****************************************************************************
* ___ _ ____ ____
* / _ \ _ _ ___ ___| |_| _ \| __ )
* | | | | | | |/ _ \/ __| __| | | | _ \
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
* \__\_\\__,_|\___||___/\__|____/|____/
*
* Copyright (c) 2014-2019 Appsicle
* Copyright (c) 2019-2026 QuestDB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
******************************************************************************/

package io.questdb.client;

import io.questdb.client.cutlass.line.LineSenderException;
import io.questdb.client.std.Chars;

/**
* Supplies an HTTP authentication token to a {@link Sender} or pooled {@link QuestDB} connection on
* demand, so a provider returning a freshly refreshed token - e.g. {@code OidcDeviceAuth::getToken}
* - keeps long-lived ingest and query connections authenticated as the token rotates, without
* rebuilding them. An HTTP sender calls {@link #getToken()} as it builds each request; WebSocket
* ingest and query clients call it once per connection handshake, on the initial connect and again
* on every reconnect.
* <p>
* {@link #getToken()} runs on HTTP flush and pooled connection/reconnection paths. Different pooled
* connections may call it concurrently, so implementations must be thread-safe. It must return
* promptly and must not block on interactive input. A quick silent token refresh is fine, but it must
* not start an interactive sign-in; a provider that coordinates a shared token store across processes
* (for example {@code OidcDeviceAuth} with a {@code FileTokenStore}) may add a brief, bounded wait to
* acquire that store's cross-process lock before such a refresh, which still counts as a quick silent
* refresh. Note that "quick" bounds the interactive wait, not the network: the silent refresh is a
* synchronous HTTP round-trip to the token endpoint, and its connection phase (DNS, TCP connect, TLS)
* is bounded by the client timeout as well for the bundled {@code OidcDeviceAuth}, leaving only DNS
* resolution to the OS. A provider that builds its own HTTP client should bound its connect and TLS
* handshake likewise, or a black-holed token endpoint stalls a flush for the OS connect timeout. An exception from {@link #getToken()} fails the
* in-flight flush (HTTP) or the connection attempt (WebSocket).
*
* @see QuestDB#connect(CharSequence, HttpTokenProvider)
* @see QuestDBBuilder#httpTokenProvider(HttpTokenProvider)
* @see Sender.LineSenderBuilder#httpTokenProvider(HttpTokenProvider)
*/
@FunctionalInterface
public interface HttpTokenProvider {
/**
* Validates a token returned by {@link #getToken()} before the client writes it into an
* {@code Authorization: Bearer} header.
* <p>
* Callers must pass a value that cannot change between this check and the write that follows it.
* {@code getToken()} may return a reused buffer, so validating the provider's sequence and then
* re-reading it to build the header reads it twice: a mutation in between passes the check and
* splices the mutated bytes - a CR/LF among them - into the header. Snapshot with
* {@link Object#toString()} first, then validate and send the snapshot. Every call site in this
* library does.
* <p>
* Rejects a null, empty or blank token, and any token
* carrying a control or non-ASCII character (outside {@code 0x20}-{@code 0x7e}): a real bearer
* token is printable ASCII, so a stray CR/LF (which would inject into the HTTP request line) or a
* non-ASCII byte (silently truncated to one byte by the ASCII header writer, yielding a corrupt
* credential the server only answers with 401) is refused rather than sent. The token itself is
* never placed in the exception message - it is the secret this guards.
*
* @param token the token returned by a provider
* @throws LineSenderException if the token is null, empty, blank, or carries a control or
* non-ASCII character
*/
static void validateToken(CharSequence token) {
if (Chars.isBlank(token)) {
throw new LineSenderException("token provider returned a null or empty token");
}
for (int i = 0, n = token.length(); i < n; i++) {
char c = token.charAt(i);
if (c < 0x20 || c > 0x7e) {
throw new LineSenderException("token provider returned a token containing a control or non-ASCII character; refusing to send it as a credential");
}
}
}

/**
* Returns the current HTTP authentication token, without the {@code "Bearer "} prefix (the client
* adds it). Must not return null or empty, and must contain only printable ASCII (no control or
* non-ASCII characters) - the client splices the value verbatim into an {@code Authorization:
* Bearer} header and rejects a token that violates this (see {@link #validateToken(CharSequence)}).
* <p>
* Returning a reused, mutable {@link CharSequence} - the idiomatic zero-allocation style - is
* supported and expected: the client re-validates every pulled token rather than trusting instance
* identity, so a buffer whose contents changed since the last call is checked again. What an
* implementation must not do is mutate a sequence it has already returned <i>while the client is
* still reading it</i>. The client snapshots each returned value before validating it, so a
* concurrent mutation cannot slip past the check into the header; an implementation that mutates
* mid-call is nonetheless racing with a reader and may see its own token dropped for the one the
* snapshot captured. Mutate between calls, not during one.
*
* @return the current HTTP authentication token
*/
CharSequence getToken();
}
Loading
Loading