Skip to content

Commit a4e928f

Browse files
committed
opt-in token persistence
1 parent 1b7fecd commit a4e928f

10 files changed

Lines changed: 2682 additions & 12 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,26 @@ The identity provider's device authorization and token endpoints must use `https
230230

231231
`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.
232232

233+
#### Persisting the Token Across Restarts
234+
235+
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:
236+
237+
```java
238+
import io.questdb.client.cutlass.auth.FileTokenStore;
239+
240+
try (OidcDeviceAuth auth = OidcDeviceAuth.fromQuestDB(
241+
"https://questdb.example.com:9000",
242+
new OidcDeviceAuth.DiscoveryOptions().tokenStore(FileTokenStore.atDefaultLocation()))) {
243+
auth.signIn(); // prompts the first time; after a restart it refreshes silently from the saved token
244+
}
245+
```
246+
247+
`FileTokenStore.atDefaultLocation()` writes one file per identity 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, so tokens for different servers or identities never collide. 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.
248+
249+
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 prints a one-line warning to `System.err` the first time it cannot enforce them. 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 — a tampered, corrupt, oversized, or identity-mismatched entry is ignored (the client falls back to a refresh or an interactive sign-in), and a token carrying control or non-ASCII characters is never placed on the wire.
250+
251+
`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.
252+
233253
### Explicit Timestamps
234254

235255
```java

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

Lines changed: 667 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 211 additions & 12 deletions
Large diffs are not rendered by default.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*+*****************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.cutlass.auth;
26+
27+
/**
28+
* An immutable snapshot of the token state an {@link OidcDeviceAuth} holds, passed to and from a
29+
* {@link TokenStore} so the device flow does not have to be re-run after a process restart. Mirrors
30+
* the in-memory fields: the access token, the id token, the refresh token, the absolute wall-clock
31+
* expiry of the access/id token, and the (clamped) lifetime that expiry was derived from.
32+
* <p>
33+
* Any of the three token strings may be {@code null}. {@link #getExpiresAtMillis()} is an absolute
34+
* {@code System.currentTimeMillis()} value, so it remains meaningful across a restart (unlike a
35+
* monotonic clock reading).
36+
*/
37+
public final class PersistedToken {
38+
private final String accessToken;
39+
private final long expiresAtMillis;
40+
private final String idToken;
41+
private final String refreshToken;
42+
private final long tokenTtlMillis;
43+
44+
public PersistedToken(String accessToken, String idToken, String refreshToken, long expiresAtMillis, long tokenTtlMillis) {
45+
this.accessToken = accessToken;
46+
this.idToken = idToken;
47+
this.refreshToken = refreshToken;
48+
this.expiresAtMillis = expiresAtMillis;
49+
this.tokenTtlMillis = tokenTtlMillis;
50+
}
51+
52+
public String getAccessToken() {
53+
return accessToken;
54+
}
55+
56+
public long getExpiresAtMillis() {
57+
return expiresAtMillis;
58+
}
59+
60+
public String getIdToken() {
61+
return idToken;
62+
}
63+
64+
public String getRefreshToken() {
65+
return refreshToken;
66+
}
67+
68+
public long getTokenTtlMillis() {
69+
return tokenTtlMillis;
70+
}
71+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/*+*****************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.cutlass.auth;
26+
27+
/**
28+
* Persists the token state of an {@link OidcDeviceAuth} so a restarted process can resume from a saved
29+
* refresh token instead of running the interactive device flow again. Persistence is opt-in: an
30+
* {@code OidcDeviceAuth} with no store keeps its tokens in memory only (the previous behaviour).
31+
* <p>
32+
* The default implementation is {@link FileTokenStore} (a strict-permissions file under the user's home
33+
* directory). Supply your own to back persistence with an OS keychain, a secrets manager, or a vault -
34+
* for example to encrypt the refresh token at rest, which the file store does not do.
35+
* <p>
36+
* Entries are keyed by {@link TokenStoreKey} (the non-secret identity: endpoints, client id, scope,
37+
* audience, groups-in-token mode), so a token minted for one identity is never returned for another.
38+
* Calls are made while {@code OidcDeviceAuth} holds its own instance lock, so an implementation does not
39+
* need to be thread-safe against concurrent calls from one {@code OidcDeviceAuth} instance; it does,
40+
* however, share its backing storage with other processes (and other language clients), so it must keep a
41+
* concurrent reader from observing a half-written entry - see {@link FileTokenStore} for how the file
42+
* store does that and coordinates a rotating refresh token across processes.
43+
* <p>
44+
* A store reports a failure by throwing; {@code OidcDeviceAuth} treats persistence as best-effort and a
45+
* thrown failure as non-fatal - it logs a warning to {@code System.err} and continues with the in-memory
46+
* token, which is valid regardless of whether it could be saved.
47+
*/
48+
public interface TokenStore {
49+
/**
50+
* Removes any persisted entry for this identity. Called from {@link OidcDeviceAuth#clearCache()}.
51+
* A no-op when nothing is stored.
52+
*
53+
* @param key the identity whose entry to remove
54+
*/
55+
void clear(TokenStoreKey key);
56+
57+
/**
58+
* Runs {@code action} while holding a cross-process lock scoped to {@code key}, so a refresh by
59+
* another process sharing this identity is observed rather than raced. The action re-reads the store
60+
* inside the lock and refreshes only if still needed, which keeps a rotating refresh token consistent
61+
* across processes.
62+
* <p>
63+
* The default runs {@code action} with no locking, which is correct for a single process or a
64+
* non-rotating refresh token; {@link FileTokenStore} overrides it with a lock-file protocol. An
65+
* implementation that cannot acquire the lock should run {@code action} anyway (degrade) rather than
66+
* fail a sign-in.
67+
*
68+
* @param key the identity to lock
69+
* @param action the critical section; its boolean result is returned unchanged
70+
* @return whatever {@code action} returned
71+
*/
72+
default boolean inLock(TokenStoreKey key, CriticalSection action) {
73+
return action.run();
74+
}
75+
76+
/**
77+
* Loads the persisted token for this identity, or returns {@code null} if there is none usable (no
78+
* entry, or an entry that does not match {@code key}, or one that cannot be read as a valid token).
79+
* A {@code null} return makes {@code OidcDeviceAuth} fall back to a refresh or an interactive sign-in,
80+
* so an unreadable or stale entry is recoverable rather than fatal.
81+
*
82+
* @param key the identity to load
83+
* @return the persisted token, or {@code null}
84+
*/
85+
PersistedToken load(TokenStoreKey key);
86+
87+
/**
88+
* Persists (atomically replaces) the token for this identity.
89+
*
90+
* @param key the identity to store under
91+
* @param token the token state to persist
92+
*/
93+
void save(TokenStoreKey key, PersistedToken token);
94+
95+
/**
96+
* A unit of work {@link #inLock(TokenStoreKey, CriticalSection)} runs while holding the per-identity
97+
* lock. Returns whether a valid token resulted.
98+
*/
99+
@FunctionalInterface
100+
interface CriticalSection {
101+
boolean run();
102+
}
103+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*+*****************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.cutlass.auth;
26+
27+
import java.nio.charset.StandardCharsets;
28+
import java.security.MessageDigest;
29+
import java.security.NoSuchAlgorithmException;
30+
31+
/**
32+
* The non-secret identity a persisted token belongs to: the client id, the (canonicalised) token and
33+
* device-authorization endpoints, the scope, the optional audience, and whether the server expects
34+
* groups encoded in the token. A {@link TokenStore} keys its entries by this so a token minted for one
35+
* server / identity provider / scope / audience is never served to a process configured for another.
36+
* <p>
37+
* {@link #hash()} is a stable, lowercase-hex SHA-256 over a canonical, NUL-separated rendering of the
38+
* fields - intended as a file name (or any opaque key) that is identical across client implementations
39+
* (the Python client mirrors this), so several processes - and languages - sharing one identity address
40+
* the same persisted entry. The fields themselves are exposed (they are not secret) so a store can also
41+
* record them and re-check them on load as a defence against a hash collision or a copied file.
42+
*/
43+
public final class TokenStoreKey {
44+
// the canonical-string prefix doubles as a domain tag and a schema version, so a future format change
45+
// produces a different hash (and hence a different file) rather than silently colliding with v1 entries
46+
private static final String CANONICAL_PREFIX = "questdb-oidc-token-v1";
47+
private static final char[] HEX = "0123456789abcdef".toCharArray();
48+
private final String audience;
49+
private final String clientId;
50+
private final String deviceAuthorizationEndpoint;
51+
private final boolean groupsInToken;
52+
private final String hash;
53+
private final String scope;
54+
private final String tokenEndpoint;
55+
56+
/**
57+
* @param clientId the OIDC client id
58+
* @param tokenEndpoint the canonical token endpoint ({@code scheme://host:port/path},
59+
* scheme and host lower-cased, port explicit)
60+
* @param deviceAuthorizationEndpoint the canonical device-authorization endpoint, same form
61+
* @param scope the requested scope
62+
* @param audience the audience, or {@code null} if none
63+
* @param groupsInToken whether the id token (rather than the access token) is served
64+
*/
65+
public TokenStoreKey(
66+
String clientId,
67+
String tokenEndpoint,
68+
String deviceAuthorizationEndpoint,
69+
String scope,
70+
String audience,
71+
boolean groupsInToken
72+
) {
73+
this.clientId = clientId;
74+
this.tokenEndpoint = tokenEndpoint;
75+
this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint;
76+
this.scope = scope;
77+
this.audience = audience;
78+
this.groupsInToken = groupsInToken;
79+
this.hash = computeHash(clientId, tokenEndpoint, deviceAuthorizationEndpoint, scope, audience, groupsInToken);
80+
}
81+
82+
public String getAudience() {
83+
return audience;
84+
}
85+
86+
public String getClientId() {
87+
return clientId;
88+
}
89+
90+
public String getDeviceAuthorizationEndpoint() {
91+
return deviceAuthorizationEndpoint;
92+
}
93+
94+
public String getScope() {
95+
return scope;
96+
}
97+
98+
public String getTokenEndpoint() {
99+
return tokenEndpoint;
100+
}
101+
102+
/**
103+
* @return a stable lowercase-hex SHA-256 of the canonical identity string; suitable as an opaque file
104+
* name. Identical inputs (across processes and language implementations) yield an identical hash.
105+
*/
106+
public String hash() {
107+
return hash;
108+
}
109+
110+
public boolean isGroupsInToken() {
111+
return groupsInToken;
112+
}
113+
114+
private static String computeHash(
115+
String clientId,
116+
String tokenEndpoint,
117+
String deviceAuthorizationEndpoint,
118+
String scope,
119+
String audience,
120+
boolean groupsInToken
121+
) {
122+
// NUL-separate the fields so no field value can be confused with a separator; an OAuth client id,
123+
// url, scope or audience never contains a NUL. The prefix tags the domain and schema version.
124+
StringBuilder canonical = new StringBuilder();
125+
canonical.append(CANONICAL_PREFIX).append('\0')
126+
.append(nullToEmpty(clientId)).append('\0')
127+
.append(nullToEmpty(tokenEndpoint)).append('\0')
128+
.append(nullToEmpty(deviceAuthorizationEndpoint)).append('\0')
129+
.append(nullToEmpty(scope)).append('\0')
130+
.append(nullToEmpty(audience)).append('\0')
131+
.append(groupsInToken ? '1' : '0');
132+
try {
133+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
134+
byte[] bytes = digest.digest(canonical.toString().getBytes(StandardCharsets.UTF_8));
135+
return toHex(bytes);
136+
} catch (NoSuchAlgorithmException e) {
137+
// SHA-256 is mandated on every JVM, so this is unreachable; rethrow defensively rather than
138+
// declare a checked exception across the whole construction path
139+
throw new OidcAuthException(e).put("SHA-256 is not available to key the OIDC token store");
140+
}
141+
}
142+
143+
private static String nullToEmpty(String s) {
144+
return s != null ? s : "";
145+
}
146+
147+
private static String toHex(byte[] bytes) {
148+
char[] out = new char[bytes.length * 2];
149+
for (int i = 0; i < bytes.length; i++) {
150+
int v = bytes[i] & 0xff;
151+
out[i * 2] = HEX[v >>> 4];
152+
out[i * 2 + 1] = HEX[v & 0x0f];
153+
}
154+
return new String(out);
155+
}
156+
}

0 commit comments

Comments
 (0)