Skip to content

Commit 31bc220

Browse files
authored
feat(token-exchange): read subject/actor tokens from local files (#207)
Fixes #206.
1 parent c3dc417 commit 31bc220

9 files changed

Lines changed: 211 additions & 15 deletions

File tree

docs/configuration.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,15 @@ See the [Token Exchange](./token-exchange.md) section for more details.
309309

310310
The subject token to exchange.
311311

312-
If this value is present, the subject token will be used as-is. If this value is not present, the subject token will be dynamically fetched using the configuration provided under the `rest.auth.oauth2.token-exchange.subject-token` prefix.
312+
If this value is present, the subject token will be used as-is. If this value is not present, the subject token may be read from the file specified by `rest.auth.oauth2.token-exchange.subject-token-file`, or dynamically fetched using the configuration provided under the `rest.auth.oauth2.token-exchange.subject-token` prefix.
313+
314+
### `rest.auth.oauth2.token-exchange.subject-token-file`
315+
316+
Path to a file containing the subject token. The file content is read and trimmed to obtain the token value. Ignored if `rest.auth.oauth2.token-exchange.subject-token` is set. If this is the only static source and neither inline token nor dynamic config is provided, the file must exist and be readable at configuration load time.
317+
318+
### `rest.auth.oauth2.token-exchange.actor-token-file`
319+
320+
Path to a file containing the actor token. The file content is read and trimmed to obtain the token value. Ignored if `rest.auth.oauth2.token-exchange.actor-token` is set. If this is the only static source and neither inline token nor dynamic config is provided, the file must exist and be readable at configuration load time.
313321

314322
### `rest.auth.oauth2.token-exchange.subject-token-type`
315323

docs/token-exchange.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,17 @@ Specifically, it enables subject and actor tokens to be provided in two methods:
3636
Static subject and actor tokens are provided in the configuration using the following properties,
3737
respectively:
3838

39-
* `rest.auth.oauth2.token-exchange.subject-token`
40-
* `rest.auth.oauth2.token-exchange.actor-token`
39+
* `rest.auth.oauth2.token-exchange.subject-token` — the inline token value
40+
* `rest.auth.oauth2.token-exchange.subject-token-file` — path to a file whose content (read and
41+
trimmed) is used as the subject token; ignored if `subject-token` is set
42+
* `rest.auth.oauth2.token-exchange.actor-token` — the inline token value
43+
* `rest.auth.oauth2.token-exchange.actor-token-file` — path to a file whose content (read and
44+
trimmed) is used as the actor token; ignored if `actor-token` is set
45+
46+
The subject token is taken from the inline `subject-token` if set, otherwise from the file at
47+
`subject-token-file` if set, otherwise from dynamic configuration under `subject-token.*`. \
48+
Similarly, the actor token is taken from the inline `actor-token` if set, otherwise from the file at
49+
`actor-token-file` if set, otherwise from dynamic configuration under `actor-token.*`.
4150

4251
Additionally, the type of the subject and actor tokens can be specified using the following
4352
properties, respectively:
@@ -59,10 +68,16 @@ rest.auth.oauth2.scope=catalog1
5968

6069
# Subject token settings
6170
rest.auth.oauth2.token-exchange.subject-token=$SUBJECT_TOKEN
71+
# Alternatively, the subject token can be read from a local file
72+
# (instead of subject-token=...) If both are set, subject-token takes precedence.
73+
rest.auth.oauth2.token-exchange.subject-token-file=/path/to/subject-token
6274
rest.auth.oauth2.token-exchange.subject-token-type=urn:ietf:params:oauth:token-type:jwt
6375

6476
# Actor token settings
6577
rest.auth.oauth2.token-exchange.actor-token=$ACTOR_TOKEN
78+
# Alternatively, the actor token can be read from a local file
79+
# (instead of actor-token=...) If both are set, actor-token takes precedence.
80+
rest.auth.oauth2.token-exchange.actor-token-file=/path/to/actor-token
6681
rest.auth.oauth2.token-exchange.actor-token-type=urn:ietf:params:oauth:token-type:jwt
6782
```
6883

@@ -73,7 +88,9 @@ token exchange is performed using the primary IDP, which is configured using the
7388
### Using Dynamic Tokens
7489

7590
To enable dynamic fetching of tokens, the `rest.auth.oauth2.token-exchange.subject-token` and
76-
`rest.auth.oauth2.token-exchange.actor-token` properties must _not_ be set.
91+
`rest.auth.oauth2.token-exchange.subject-token-file` properties must _not_ be set for the subject
92+
token, and `rest.auth.oauth2.token-exchange.actor-token` and
93+
`rest.auth.oauth2.token-exchange.actor-token-file` must _not_ be set for the actor token.
7794

7895
Then, details for the subject and actor token fetch must be provided under the following prefixes,
7996
respectively:

oauth2/core/src/main/java/com/dremio/iceberg/authmgr/oauth2/config/TokenExchangeConfig.java

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
package com.dremio.iceberg.authmgr.oauth2.config;
1717

1818
import com.dremio.iceberg.authmgr.oauth2.OAuth2Config;
19+
import com.dremio.iceberg.authmgr.oauth2.config.validator.ConfigValidator;
1920
import com.nimbusds.oauth2.sdk.id.Audience;
2021
import com.nimbusds.oauth2.sdk.token.TokenTypeURI;
2122
import com.nimbusds.oauth2.sdk.token.TypelessAccessToken;
2223
import io.smallrye.config.WithDefault;
2324
import io.smallrye.config.WithName;
2425
import java.net.URI;
26+
import java.nio.file.Files;
27+
import java.nio.file.Path;
2528
import java.util.List;
2629
import java.util.Map;
2730
import java.util.Optional;
@@ -41,8 +44,10 @@ public interface TokenExchangeConfig {
4144
String PREFIX = OAuth2Config.PREFIX + '.' + GROUP_NAME;
4245

4346
String SUBJECT_TOKEN = "subject-token";
47+
String SUBJECT_TOKEN_FILE = "subject-token-file";
4448
String SUBJECT_TOKEN_TYPE = "subject-token-type";
4549
String ACTOR_TOKEN = "actor-token";
50+
String ACTOR_TOKEN_FILE = "actor-token-file";
4651
String ACTOR_TOKEN_TYPE = "actor-token-type";
4752
String REQUESTED_TOKEN_TYPE = "requested-token-type";
4853
String RESOURCE = "resource";
@@ -54,12 +59,31 @@ public interface TokenExchangeConfig {
5459
* The subject token to exchange.
5560
*
5661
* <p>If this value is present, the subject token will be used as-is. If this value is not
57-
* present, the subject token will be dynamically fetched using the configuration provided under
58-
* the {@value #SUBJECT_TOKEN} prefix.
62+
* present, the subject token may be read from the file specified by {@value #SUBJECT_TOKEN_FILE},
63+
* or dynamically fetched using the configuration provided under the {@value #SUBJECT_TOKEN}
64+
* prefix.
5965
*/
6066
@WithName(SUBJECT_TOKEN)
6167
Optional<TypelessAccessToken> getSubjectToken();
6268

69+
/**
70+
* Path to a file containing the subject token. The file content is read and trimmed to obtain the
71+
* token value. Ignored if {@value #SUBJECT_TOKEN} is set. If this is the only static source and
72+
* neither inline token nor dynamic config is provided, the file must exist and be readable at
73+
* configuration load time.
74+
*/
75+
@WithName(SUBJECT_TOKEN_FILE)
76+
Optional<Path> getSubjectTokenFile();
77+
78+
/**
79+
* Path to a file containing the actor token. The file content is read and trimmed to obtain the
80+
* token value. Ignored if {@value #ACTOR_TOKEN} is set. If this is the only static source and
81+
* neither inline token nor dynamic config is provided, the file must exist and be readable at
82+
* configuration load time.
83+
*/
84+
@WithName(ACTOR_TOKEN_FILE)
85+
Optional<Path> getActorTokenFile();
86+
6387
/**
6488
* The type of the subject token. Must be a valid URN. The default is {@code
6589
* urn:ietf:params:oauth:token-type:access_token}.
@@ -174,6 +198,25 @@ public interface TokenExchangeConfig {
174198
Optional<List<Audience>> getAudience();
175199

176200
default void validate() {
177-
// No validation needed
201+
ConfigValidator validator = new ConfigValidator();
202+
validateTokenFile(
203+
validator,
204+
getSubjectToken().isEmpty(),
205+
getSubjectTokenFile().orElse(null),
206+
SUBJECT_TOKEN_FILE);
207+
validateTokenFile(
208+
validator, getActorToken().isEmpty(), getActorTokenFile().orElse(null), ACTOR_TOKEN_FILE);
209+
validator.validate();
210+
}
211+
212+
private static void validateTokenFile(
213+
ConfigValidator validator, boolean inlineTokenAbsent, Path tokenFilePath, String fileKey) {
214+
if (inlineTokenAbsent && tokenFilePath != null) {
215+
validator.check(
216+
Files.isReadable(tokenFilePath),
217+
PREFIX + '.' + fileKey,
218+
"token-exchange: '%s' is not a file or is not readable",
219+
tokenFilePath);
220+
}
178221
}
179222
}

oauth2/core/src/main/java/com/dremio/iceberg/authmgr/oauth2/tokenexchange/AbstractTokenSupplier.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
import com.nimbusds.oauth2.sdk.token.TokenTypeURI;
2828
import com.nimbusds.oauth2.sdk.token.TypelessAccessToken;
2929
import jakarta.annotation.Nullable;
30+
import java.io.IOException;
31+
import java.io.UncheckedIOException;
32+
import java.nio.file.Files;
33+
import java.nio.file.Path;
3034
import java.util.HashMap;
3135
import java.util.Map;
3236
import java.util.Optional;
@@ -91,6 +95,23 @@ protected OAuth2Agent getTokenAgent() {
9195
return new OAuth2Agent(tokenAgentConfig, getRuntime());
9296
}
9397

98+
/**
99+
* Reads a token from a file. The file content is read as UTF-8 and trimmed of leading and
100+
* trailing whitespace; the result is returned as a {@link TypelessAccessToken}.
101+
*
102+
* @param path path to the file containing the token
103+
* @return the token parsed from the file
104+
* @throws UncheckedIOException if the file cannot be read
105+
*/
106+
protected static TypelessAccessToken readTokenFromFile(Path path) {
107+
try {
108+
String value = Files.readString(path).strip();
109+
return new TypelessAccessToken(value);
110+
} catch (IOException e) {
111+
throw new UncheckedIOException("Failed to read token from file: " + path, e);
112+
}
113+
}
114+
94115
@Override
95116
public void close() {
96117
if (getTokenAgent() != null) {

oauth2/core/src/main/java/com/dremio/iceberg/authmgr/oauth2/tokenexchange/ActorTokenSupplier.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import com.dremio.iceberg.authmgr.oauth2.OAuth2Config;
1919
import com.dremio.iceberg.authmgr.oauth2.agent.OAuth2AgentRuntime;
20+
import com.dremio.iceberg.authmgr.oauth2.config.TokenExchangeConfig;
2021
import com.dremio.iceberg.authmgr.tools.immutables.AuthManagerImmutable;
2122
import com.nimbusds.oauth2.sdk.token.TokenTypeURI;
2223
import com.nimbusds.oauth2.sdk.token.TypelessAccessToken;
@@ -41,7 +42,12 @@ public ActorTokenSupplier copy() {
4142

4243
@Override
4344
protected Optional<TypelessAccessToken> getStaticToken() {
44-
return getMainConfig().getTokenExchangeConfig().getActorToken();
45+
TokenExchangeConfig tokenExchangeConfig = getMainConfig().getTokenExchangeConfig();
46+
return tokenExchangeConfig
47+
.getActorToken()
48+
.or(
49+
() ->
50+
tokenExchangeConfig.getActorTokenFile().map(ActorTokenSupplier::readTokenFromFile));
4551
}
4652

4753
@Override

oauth2/core/src/main/java/com/dremio/iceberg/authmgr/oauth2/tokenexchange/SubjectTokenSupplier.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import com.dremio.iceberg.authmgr.oauth2.OAuth2Config;
1919
import com.dremio.iceberg.authmgr.oauth2.agent.OAuth2AgentRuntime;
20+
import com.dremio.iceberg.authmgr.oauth2.config.TokenExchangeConfig;
2021
import com.dremio.iceberg.authmgr.tools.immutables.AuthManagerImmutable;
2122
import com.nimbusds.oauth2.sdk.token.TokenTypeURI;
2223
import com.nimbusds.oauth2.sdk.token.TypelessAccessToken;
@@ -50,7 +51,14 @@ protected void validate() {
5051

5152
@Override
5253
protected Optional<TypelessAccessToken> getStaticToken() {
53-
return getMainConfig().getTokenExchangeConfig().getSubjectToken();
54+
TokenExchangeConfig tokenExchangeConfig = getMainConfig().getTokenExchangeConfig();
55+
return tokenExchangeConfig
56+
.getSubjectToken()
57+
.or(
58+
() ->
59+
tokenExchangeConfig
60+
.getSubjectTokenFile()
61+
.map(SubjectTokenSupplier::readTokenFromFile));
5462
}
5563

5664
@Override

oauth2/core/src/test/java/com/dremio/iceberg/authmgr/oauth2/config/TokenExchangeConfigTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,65 @@
1515
*/
1616
package com.dremio.iceberg.authmgr.oauth2.config;
1717

18+
import static com.dremio.iceberg.authmgr.oauth2.config.TokenExchangeConfig.ACTOR_TOKEN_FILE;
1819
import static com.dremio.iceberg.authmgr.oauth2.config.TokenExchangeConfig.AUDIENCE;
1920
import static com.dremio.iceberg.authmgr.oauth2.config.TokenExchangeConfig.PREFIX;
2021
import static com.dremio.iceberg.authmgr.oauth2.config.TokenExchangeConfig.SUBJECT_TOKEN;
22+
import static com.dremio.iceberg.authmgr.oauth2.config.TokenExchangeConfig.SUBJECT_TOKEN_FILE;
23+
import static java.util.Collections.singletonList;
2124
import static org.assertj.core.api.Assertions.assertThat;
25+
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
2226

27+
import com.dremio.iceberg.authmgr.oauth2.config.validator.ConfigValidator;
2328
import com.google.common.collect.ImmutableMap;
2429
import com.nimbusds.oauth2.sdk.id.Audience;
2530
import io.smallrye.config.SmallRyeConfig;
2631
import io.smallrye.config.SmallRyeConfigBuilder;
2732
import io.smallrye.config.common.MapBackedConfigSource;
2833
import java.util.List;
2934
import java.util.Map;
35+
import java.util.stream.Stream;
3036
import org.junit.jupiter.api.Test;
37+
import org.junit.jupiter.params.ParameterizedTest;
38+
import org.junit.jupiter.params.provider.Arguments;
39+
import org.junit.jupiter.params.provider.MethodSource;
3140

3241
class TokenExchangeConfigTest {
3342

43+
@ParameterizedTest
44+
@MethodSource
45+
void testValidate(Map<String, String> properties, List<String> expected) {
46+
SmallRyeConfig smallRyeConfig =
47+
new SmallRyeConfigBuilder()
48+
.withMapping(TokenExchangeConfig.class, PREFIX)
49+
.withSources(new MapBackedConfigSource("catalog-properties", properties, 1000) {})
50+
.build();
51+
TokenExchangeConfig config = smallRyeConfig.getConfigMapping(TokenExchangeConfig.class, PREFIX);
52+
assertThatIllegalArgumentException()
53+
.isThrownBy(config::validate)
54+
.withMessage(ConfigValidator.buildDescription(expected.stream()));
55+
}
56+
57+
static Stream<Arguments> testValidate() {
58+
return Stream.of(
59+
Arguments.of(
60+
Map.of(PREFIX + '.' + SUBJECT_TOKEN_FILE, "/invalid/subject-token-file"),
61+
singletonList(
62+
"token-exchange: '/invalid/subject-token-file' is not a file or is not readable ("
63+
+ PREFIX
64+
+ '.'
65+
+ SUBJECT_TOKEN_FILE
66+
+ ")")),
67+
Arguments.of(
68+
Map.of(PREFIX + '.' + ACTOR_TOKEN_FILE, "/invalid/actor-token-file"),
69+
singletonList(
70+
"token-exchange: '/invalid/actor-token-file' is not a file or is not readable ("
71+
+ PREFIX
72+
+ '.'
73+
+ ACTOR_TOKEN_FILE
74+
+ ")")));
75+
}
76+
3477
@Test
3578
void testAudienceEmpty() {
3679
Map<String, String> properties =

oauth2/core/src/test/java/com/dremio/iceberg/authmgr/oauth2/tokenexchange/ActorTokenSupplierTest.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,22 @@
2828
import com.nimbusds.oauth2.sdk.token.AccessToken;
2929
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
3030
import com.nimbusds.oauth2.sdk.token.TokenTypeURI;
31+
import java.nio.file.Files;
32+
import java.nio.file.Path;
3133
import java.util.Map;
3234
import java.util.concurrent.CompletionStage;
3335
import java.util.concurrent.ScheduledExecutorService;
3436
import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
3537
import org.junit.jupiter.api.Test;
3638
import org.junit.jupiter.api.extension.ExtendWith;
39+
import org.junit.jupiter.api.io.TempDir;
3740

3841
@ExtendWith(SoftAssertionsExtension.class)
3942
class ActorTokenSupplierTest {
4043

4144
@Test
4245
void testSupplyActorTokenAsyncStatic() {
43-
OAuth2Config config = createMainConfig("actor-token", TokenTypeURI.ID_TOKEN, Map.of());
46+
OAuth2Config config = createMainConfig("actor-token", null, TokenTypeURI.ID_TOKEN, Map.of());
4447
try (ActorTokenSupplier supplier = createSupplier(config)) {
4548
CompletionStage<AccessToken> stage = supplier.supplyTokenAsync();
4649
assertThat(stage)
@@ -53,6 +56,7 @@ void testSupplyActorTokenAsyncStatic() {
5356
void testSupplyActorTokenAsyncDynamic() {
5457
OAuth2Config config =
5558
createMainConfig(
59+
null,
5660
null,
5761
TokenTypeURI.ACCESS_TOKEN,
5862
Map.of(
@@ -72,15 +76,31 @@ void testSupplyActorTokenAsyncDynamic() {
7276

7377
@Test
7478
void testSupplyActorTokenAsyncNull() {
75-
OAuth2Config config = createMainConfig(null, TokenTypeURI.ACCESS_TOKEN, Map.of());
79+
OAuth2Config config = createMainConfig(null, null, TokenTypeURI.ACCESS_TOKEN, Map.of());
7680
try (ActorTokenSupplier supplier = createSupplier(config)) {
7781
CompletionStage<AccessToken> stage = supplier.supplyTokenAsync();
7882
assertThat(stage).isCompletedWithValue(null);
7983
}
8084
}
8185

86+
@Test
87+
void testSupplyActorTokenAsyncFromFile(@TempDir Path tempDir) throws Exception {
88+
Path tokenFile = tempDir.resolve("actor-token.txt");
89+
Files.writeString(tokenFile, " actor-token-from-file ");
90+
OAuth2Config config = createMainConfig(null, tokenFile, TokenTypeURI.JWT, Map.of());
91+
try (ActorTokenSupplier supplier = createSupplier(config)) {
92+
CompletionStage<AccessToken> stage = supplier.supplyTokenAsync();
93+
assertThat(stage)
94+
.isCompletedWithValue(
95+
new BearerAccessToken("actor-token-from-file", 0, null, TokenTypeURI.JWT));
96+
}
97+
}
98+
8299
private static OAuth2Config createMainConfig(
83-
String actorToken, TokenTypeURI actorTokenType, Map<String, String> actorTokenConfig) {
100+
String actorToken,
101+
Path actorTokenFile,
102+
TokenTypeURI actorTokenType,
103+
Map<String, String> actorTokenConfig) {
84104

85105
ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
86106

@@ -103,6 +123,11 @@ private static OAuth2Config createMainConfig(
103123
if (actorToken != null) {
104124
builder.put(TokenExchangeConfig.PREFIX + '.' + TokenExchangeConfig.ACTOR_TOKEN, actorToken);
105125
}
126+
if (actorTokenFile != null) {
127+
builder.put(
128+
TokenExchangeConfig.PREFIX + '.' + TokenExchangeConfig.ACTOR_TOKEN_FILE,
129+
actorTokenFile.toString());
130+
}
106131

107132
return OAuth2Config.from(builder.build());
108133
}

0 commit comments

Comments
 (0)