Skip to content

Commit 78b367a

Browse files
Add a credentials provider for Github Azure OIDC (#965)
_Note: this PR is a copy of PR #950 which could not be merged because of some unverified commits. Please check PR #950 for the original review and comments._ ## Changes This PR adds a `CredentialsProvider` to authenticate with Azure from Github workflows. The code is inspired by a similar feature already implemented in the Python SDK. It works as follows: 1. Obtain an ID token from Azure leveraging the env variables `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` as [explained here](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers). 2. Exchange that ID token for an auth token. ## Tests Added a test suite which covers all the added code paths. I've also confirmed in my own Github Action that the code is properly able to authenticate. Note: I'm not super happy with how errors are compared (i.e. using a prefix) which is a little brittle. A better approach would be to leverage `errors.As` or `errors.Is`. However, it is difficult to do that at the moment without adding ad hoc new error types. A longer term solution would probably involve standardizing the package around a set of clearly defined error types shared by all implementations of `CredentialsProvider` in `config`. That is out of the scope of this PR though. - [x] `make test` passing - [x] `make fmt` applied - [x] relevant integration tests applied
1 parent b58dc70 commit 78b367a

File tree

6 files changed

+432
-16
lines changed

6 files changed

+432
-16
lines changed

config/auth_azure_github_oidc.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package config
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"time"
8+
9+
"github.com/databricks/databricks-sdk-go/credentials"
10+
"github.com/databricks/databricks-sdk-go/httpclient"
11+
"github.com/databricks/databricks-sdk-go/logger"
12+
"golang.org/x/oauth2"
13+
)
14+
15+
// AzureGithubOIDCCredentials provides credentials for GitHub Actions that use
16+
// an Azure Active Directory Federated Identity to authenticate with Azure.
17+
type AzureGithubOIDCCredentials struct{}
18+
19+
// Name implements [CredentialsStrategy.Name].
20+
func (c AzureGithubOIDCCredentials) Name() string {
21+
return "github-oidc-azure"
22+
}
23+
24+
// Configure implements [CredentialsStrategy.Configure].
25+
func (c AzureGithubOIDCCredentials) Configure(ctx context.Context, cfg *Config) (credentials.CredentialsProvider, error) {
26+
// Sanity check that the config is configured for Azure Databricks.
27+
if !cfg.IsAzure() || cfg.AzureClientID == "" || cfg.Host == "" || cfg.AzureTenantID == "" {
28+
return nil, nil
29+
}
30+
31+
idToken, err := requestIDToken(ctx, cfg)
32+
if err != nil {
33+
return nil, err
34+
}
35+
if idToken == "" {
36+
return nil, nil
37+
}
38+
39+
ts := &azureOIDCTokenSource{
40+
aadEndpoint: fmt.Sprintf("%s%s/oauth2/token", cfg.Environment().AzureActiveDirectoryEndpoint(), cfg.AzureTenantID),
41+
clientID: cfg.AzureClientID,
42+
applicationID: cfg.Environment().AzureApplicationID,
43+
idToken: idToken,
44+
httpClient: cfg.refreshClient,
45+
}
46+
47+
return credentials.NewOAuthCredentialsProvider(refreshableVisitor(ts), ts.Token), nil
48+
}
49+
50+
// requestIDToken requests an ID token from the Github Action.
51+
func requestIDToken(ctx context.Context, cfg *Config) (string, error) {
52+
if cfg.ActionsIDTokenRequestURL == "" {
53+
logger.Debugf(ctx, "Missing cfg.ActionsIDTokenRequestURL, likely not calling from a Github action")
54+
return "", nil
55+
}
56+
if cfg.ActionsIDTokenRequestToken == "" {
57+
logger.Debugf(ctx, "Missing cfg.ActionsIDTokenRequestToken, likely not calling from a Github action")
58+
return "", nil
59+
}
60+
61+
resp := struct { // anonymous struct to parse the response
62+
Value string `json:"value"`
63+
}{}
64+
err := cfg.refreshClient.Do(ctx, "GET", fmt.Sprintf("%s&audience=api://AzureADTokenExchange", cfg.ActionsIDTokenRequestURL),
65+
httpclient.WithRequestHeader("Authorization", fmt.Sprintf("Bearer %s", cfg.ActionsIDTokenRequestToken)),
66+
httpclient.WithResponseUnmarshal(&resp),
67+
)
68+
if err != nil {
69+
return "", fmt.Errorf("failed to request ID token from %s: %w", cfg.ActionsIDTokenRequestURL, err)
70+
}
71+
72+
return resp.Value, nil
73+
}
74+
75+
// azureOIDCTokenSource implements [oauth2.TokenSource] to obtain Azure auth
76+
// tokens from an ID token.
77+
type azureOIDCTokenSource struct {
78+
aadEndpoint string
79+
clientID string
80+
applicationID string
81+
idToken string
82+
83+
httpClient *httpclient.ApiClient
84+
}
85+
86+
const azureOICDTimeout = 10 * time.Second
87+
88+
func (ts *azureOIDCTokenSource) Token() (*oauth2.Token, error) {
89+
ctx, cancel := context.WithTimeout(context.Background(), azureOICDTimeout)
90+
defer cancel()
91+
92+
resp := struct { // anonymous struct to parse the response
93+
TokenType string `json:"token_type"`
94+
AccessToken string `json:"access_token"`
95+
RefreshToken string `json:"refresh_token"`
96+
ExpiresOn json.Number `json:"expires_on"`
97+
}{}
98+
err := ts.httpClient.Do(ctx, "POST", ts.aadEndpoint,
99+
httpclient.WithUrlEncodedData(map[string]string{
100+
"grant_type": "client_credentials",
101+
"resource": ts.applicationID,
102+
"client_id": ts.clientID,
103+
// Use the ID token instead of a client_secret.
104+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
105+
"client_assertion": ts.idToken,
106+
}),
107+
httpclient.WithResponseUnmarshal(&resp),
108+
)
109+
if err != nil {
110+
return nil, err
111+
}
112+
113+
epochInSec, err := resp.ExpiresOn.Int64()
114+
if err != nil {
115+
return nil, fmt.Errorf("invalid token: cannot parse token expiry: %w", err)
116+
}
117+
return &oauth2.Token{
118+
TokenType: resp.TokenType,
119+
AccessToken: resp.AccessToken,
120+
RefreshToken: resp.RefreshToken,
121+
Expiry: time.Unix(epochInSec, 0),
122+
}, nil
123+
}

0 commit comments

Comments
 (0)