Skip to content

Commit a114599

Browse files
[Feature] Add credential provider for Azure Github OIDC (#307)
## Changes This PR adds a `CredentialsProvider` to authenticate with Azure from a Github action using OIDC. The implementation echoes the one in the Go and Python SDKs. ## Tests Most of the new code paths are covered by the test. Note that I'm adding the `org.json` library to help building JSON object in a concise way. The library is fairly mature and part of the Android SDK.
1 parent e7f05b9 commit a114599

File tree

7 files changed

+421
-3
lines changed

7 files changed

+421
-3
lines changed

databricks-sdk-java/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@
8686
<version>${mockito.version}</version>
8787
<scope>test</scope>
8888
</dependency>
89+
<dependency>
90+
<groupId>org.json</groupId>
91+
<artifactId>json</artifactId>
92+
<version>20240303</version>
93+
</dependency>
8994
<!-- Google Auth Library -->
9095
<dependency>
9196
<groupId>com.google.auth</groupId>

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DatabricksConfig.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ public class DatabricksConfig {
8686
@ConfigAttribute(env = "ARM_ENVIRONMENT")
8787
private String azureEnvironment;
8888

89+
@ConfigAttribute(env = "ACTIONS_ID_TOKEN_REQUEST_URL")
90+
private String actionsIdTokenRequestUrl;
91+
92+
@ConfigAttribute(env = "ACTIONS_ID_TOKEN_REQUEST_TOKEN")
93+
private String actionsIdTokenRequestToken;
94+
8995
@ConfigAttribute(env = "DATABRICKS_CLI_PATH")
9096
private String databricksCliPath;
9197

@@ -421,6 +427,24 @@ public DatabricksConfig setAzureEnvironment(String azureEnvironment) {
421427
return this;
422428
}
423429

430+
public String getActionsIdTokenRequestUrl() {
431+
return actionsIdTokenRequestUrl;
432+
}
433+
434+
public DatabricksConfig setActionsIdTokenRequestUrl(String url) {
435+
this.actionsIdTokenRequestUrl = url;
436+
return this;
437+
}
438+
439+
public String getActionsIdTokenRequestToken() {
440+
return actionsIdTokenRequestToken;
441+
}
442+
443+
public DatabricksConfig setActionsIdTokenRequestToken(String token) {
444+
this.actionsIdTokenRequestToken = token;
445+
return this;
446+
}
447+
424448
public String getEffectiveAzureLoginAppId() {
425449
return getDatabricksEnvironment().getAzureApplicationId();
426450
}

databricks-sdk-java/src/main/java/com/databricks/sdk/core/DefaultCredentialsProvider.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.databricks.sdk.core;
22

3+
import com.databricks.sdk.core.oauth.AzureGithubOidcCredentialsProvider;
34
import com.databricks.sdk.core.oauth.AzureServicePrincipalCredentialsProvider;
45
import com.databricks.sdk.core.oauth.ExternalBrowserCredentialsProvider;
56
import com.databricks.sdk.core.oauth.OAuthM2MServicePrincipalCredentialsProvider;
@@ -17,6 +18,7 @@ public class DefaultCredentialsProvider implements CredentialsProvider {
1718
PatCredentialsProvider.class,
1819
BasicCredentialsProvider.class,
1920
OAuthM2MServicePrincipalCredentialsProvider.class,
21+
AzureGithubOidcCredentialsProvider.class,
2022
AzureServicePrincipalCredentialsProvider.class,
2123
AzureCliCredentialsProvider.class,
2224
ExternalBrowserCredentialsProvider.class,

databricks-sdk-java/src/main/java/com/databricks/sdk/core/http/Response.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,21 @@ public Response(
8181
this(request, url, statusCode, status, headers, body, "\"<InputStream>\"");
8282
}
8383

84-
public Response(String body, URL url) {
84+
public Response(String body, int statusCode, String status, URL url) {
8585
this(
8686
new Request("GET", "/"),
8787
url,
88-
200,
89-
"OK",
88+
statusCode,
89+
status,
9090
Collections.emptyMap(),
9191
new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)),
9292
body);
9393
}
9494

95+
public Response(String body, URL url) {
96+
this(body, 200, "OK", url);
97+
}
98+
9599
private Response(
96100
Request request,
97101
URL url,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.databricks.sdk.core.oauth;
2+
3+
import com.databricks.sdk.core.*;
4+
import com.databricks.sdk.core.http.Request;
5+
import com.databricks.sdk.core.http.Response;
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import com.fasterxml.jackson.databind.node.ObjectNode;
8+
import java.io.IOException;
9+
import java.util.HashMap;
10+
import java.util.Map;
11+
import java.util.Optional;
12+
13+
/**
14+
* {@code AzureGithubOidcCredentialsProvider} is a credentials provider for GitHub Actions that use
15+
* an Azure Active Directory Federated Identity. It authenticates with Azure by exchanging GitHub's
16+
* OIDC token for an Azure Active Directory (AAD) Service Principal OAuth token. This class handles
17+
* the process of obtaining, refreshing, and attaching the necessary tokens to each HTTP request.
18+
*/
19+
public class AzureGithubOidcCredentialsProvider implements CredentialsProvider {
20+
private final ObjectMapper mapper = new ObjectMapper();
21+
22+
@Override
23+
public String authType() {
24+
return "github-oidc-azure";
25+
}
26+
27+
@Override
28+
public HeaderFactory configure(DatabricksConfig config) {
29+
if (!config.isAzure()
30+
|| config.getAzureClientId() == null
31+
|| config.getAzureTenantId() == null
32+
|| config.getHost() == null) {
33+
return null;
34+
}
35+
36+
Optional<String> idToken = requestIdToken(config);
37+
if (!idToken.isPresent()) {
38+
return null;
39+
}
40+
41+
TokenSource tokenSource =
42+
new OidcTokenSource(
43+
config.getHttpClient(),
44+
"",
45+
config.getClientId(),
46+
config.getEffectiveAzureLoginAppId(),
47+
idToken.get(),
48+
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
49+
50+
return () -> {
51+
Map<String, String> headers = new HashMap<>();
52+
headers.put("Authorization", "Bearer " + tokenSource.getToken().getAccessToken());
53+
return headers;
54+
};
55+
}
56+
57+
/**
58+
* Requests an Azure access token using GitHub's OIDC token.
59+
*
60+
* @param config The DatabricksConfig instance containing the required authentication parameters.
61+
* @return An optional Azure access token.
62+
*/
63+
private Optional<String> requestIdToken(DatabricksConfig config) {
64+
if (config.getActionsIdTokenRequestUrl() == null
65+
|| config.getActionsIdTokenRequestToken() == null) {
66+
return Optional.empty();
67+
}
68+
69+
String requestUrl =
70+
config.getActionsIdTokenRequestUrl() + "&audience=api://AzureADTokenExchange";
71+
Request req =
72+
new Request("GET", requestUrl)
73+
.withHeader("Authorization", "Bearer " + config.getActionsIdTokenRequestToken());
74+
75+
Response resp;
76+
try {
77+
resp = config.getHttpClient().execute(req);
78+
} catch (IOException e) {
79+
throw new DatabricksException(
80+
"Failed to request ID token from " + requestUrl + ":" + e.getMessage(), e);
81+
}
82+
83+
if (resp.getStatusCode() != 200) {
84+
throw new DatabricksException(
85+
"Failed to request ID token: status code "
86+
+ resp.getStatusCode()
87+
+ ", response body: "
88+
+ resp.getBody());
89+
}
90+
91+
ObjectNode jsonResp;
92+
try {
93+
jsonResp = mapper.readValue(resp.getBody(), ObjectNode.class);
94+
} catch (IOException e) {
95+
throw new DatabricksException(
96+
"Failed to request ID token: corrupted token: " + e.getMessage());
97+
}
98+
99+
return Optional.ofNullable(jsonResp.get("value").textValue());
100+
}
101+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.databricks.sdk.core.oauth;
2+
3+
import com.databricks.sdk.core.DatabricksException;
4+
import com.databricks.sdk.core.http.FormRequest;
5+
import com.databricks.sdk.core.http.HttpClient;
6+
import com.databricks.sdk.core.http.Response;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.google.common.base.Strings;
9+
import com.google.common.collect.ImmutableMap;
10+
import java.io.IOException;
11+
import java.time.LocalDateTime;
12+
13+
/**
14+
* {@code OidcTokenSource} is responsible for obtaining OAuth tokens using the OpenID Connect (OIDC)
15+
* protocol. It communicates with an OAuth server to request access tokens using the client
16+
* credentials grant type instead of a client secret.
17+
*/
18+
class OidcTokenSource extends RefreshableTokenSource {
19+
20+
private final HttpClient httpClient;
21+
private final String tokenUrl;
22+
private final ImmutableMap<String, String> params;
23+
24+
/**
25+
* Constructs an {@code OidcTokenSource} with the specified parameters.
26+
*
27+
* @param httpClient The HttpClient used to make HTTP requests.
28+
* @param tokenUrl The URL of the token endpoint.
29+
* @param clientId The client ID for the OAuth application.
30+
* @param resource The resource for which the token is requested.
31+
* @param clientAssertion The client assertion used for authentication.
32+
* @param clientAssertionType The type of the client assertion.
33+
*/
34+
public OidcTokenSource(
35+
HttpClient httpClient,
36+
String tokenUrl,
37+
String clientId,
38+
String resource,
39+
String clientAssertion,
40+
String clientAssertionType) {
41+
this.httpClient = httpClient;
42+
this.tokenUrl = tokenUrl;
43+
44+
ImmutableMap.Builder<String, String> builder = new ImmutableMap.Builder<>();
45+
putIfDefined(builder, "grant_type", "client_credentials");
46+
putIfDefined(builder, "resource", resource);
47+
putIfDefined(builder, "client_id", clientId);
48+
putIfDefined(builder, "client_assertion_type", clientAssertionType);
49+
putIfDefined(builder, "client_assertion", clientAssertion);
50+
this.params = builder.build();
51+
}
52+
53+
// Add the key-value pair to the builder iff the value is a non-empty string.
54+
private static void putIfDefined(
55+
ImmutableMap.Builder<String, String> builder, String key, String value) {
56+
if (!Strings.isNullOrEmpty(value)) {
57+
builder.put(key, value);
58+
}
59+
}
60+
61+
protected Token refresh() {
62+
Response rawResp;
63+
try {
64+
rawResp = httpClient.execute(new FormRequest(tokenUrl, params));
65+
} catch (IOException e) {
66+
throw new DatabricksException("Failed to request auth token: " + e.getMessage(), e);
67+
}
68+
69+
OAuthResponse resp;
70+
try {
71+
resp = new ObjectMapper().readValue(rawResp.getBody(), OAuthResponse.class);
72+
} catch (IOException e) {
73+
throw new DatabricksException(
74+
"Failed to request auth token: corrupted token: " + e.getMessage());
75+
}
76+
77+
if (resp.getErrorCode() != null) {
78+
throw new IllegalArgumentException(resp.getErrorCode() + ": " + resp.getErrorSummary());
79+
}
80+
LocalDateTime expiry = LocalDateTime.now().plusSeconds(resp.getExpiresIn());
81+
return new Token(resp.getAccessToken(), resp.getTokenType(), resp.getRefreshToken(), expiry);
82+
}
83+
}

0 commit comments

Comments
 (0)