Skip to content

Commit d4c160f

Browse files
authored
Add support for tokenless authentication for GitHub Actions configured with OpenID Connect with Azure User Managed Identity (or Service Principal) (#385)
## Changes See https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers and https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-azure Technically, it should also work with Azure DevOps Workload Identity Federation, once we figure out the environment variables: https://techcommunity.microsoft.com/t5/azure-devops-blog/introduction-to-azure-devops-workload-identity-federation-oidc/ba-p/3908687 ## Tests setup: ``` resource "github_actions_environment_secret" "scope" { for_each = github_repository_environment.default repository = each.value.repository environment = each.value.environment secret_name = "ARM_CLIENT_ID" # this value is not a secret as well plaintext_value = data.azurerm_user_assigned_identity.scopes[local.project_scopes[each.key]].client_id } ... resource "azurerm_federated_identity_credential" "oidc" { for_each = github_repository_environment.default name = "${local.org}-${each.value.repository}-${each.value.environment}-oidc" resource_group_name = local.resource_group_name audience = ["api://AzureADTokenExchange"] issuer = "https://token.actions.githubusercontent.com" parent_id = data.azurerm_user_assigned_identity.scopes[local.project_scopes[each.key]].id subject = "repo:${local.org}/${each.value.repository}:environment:${each.value.environment}" } ... resource "azurerm_user_assigned_identity" "scopes" { for_each = local.scopes name = "labs-${each.key}-identity" resource_group_name = azurerm_resource_group.this.name location = azurerm_resource_group.this.location // ... } ... resource "databricks_service_principal" "scopes" { provider = databricks.account for_each = local.scopes application_id = azurerm_user_assigned_identity.scopes[each.key].client_id display_name = azurerm_user_assigned_identity.scopes[each.key].name external_id = azurerm_user_assigned_identity.scopes[each.key].principal_id } ``` result <img width="603" alt="_Experiment__Call_integration_tests_via_OIDC_·_databrickslabs_ucx_5a94b24" src="https://github.com/databricks/databricks-sdk-py/assets/259697/33b08224-ceed-4c15-bfcd-32e0b58f8483"> - [ ] `make test` run locally - [x] `make fmt` applied - [ ] relevant integration tests applied
1 parent 8ee6ff3 commit d4c160f

File tree

2 files changed

+85
-1
lines changed

2 files changed

+85
-1
lines changed

databricks/sdk/core.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,53 @@ def refreshed_headers() -> Dict[str, str]:
217217
return refreshed_headers
218218

219219

220+
@credentials_provider('github-oidc-azure', ['host', 'azure_client_id'])
221+
def github_oidc_azure(cfg: 'Config') -> Optional[HeaderFactory]:
222+
if 'ACTIONS_ID_TOKEN_REQUEST_TOKEN' not in os.environ:
223+
# not in GitHub actions
224+
return None
225+
226+
# Client ID is the minimal thing we need, as otherwise we get AADSTS700016: Application with
227+
# identifier 'https://token.actions.githubusercontent.com' was not found in the directory '...'.
228+
if not cfg.is_azure:
229+
return None
230+
231+
# See https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers
232+
headers = {'Authorization': f"Bearer {os.environ['ACTIONS_ID_TOKEN_REQUEST_TOKEN']}"}
233+
endpoint = f"{os.environ['ACTIONS_ID_TOKEN_REQUEST_URL']}&audience=api://AzureADTokenExchange"
234+
response = requests.get(endpoint, headers=headers)
235+
if not response.ok:
236+
return None
237+
238+
# get the ID Token with aud=api://AzureADTokenExchange sub=repo:org/repo:environment:name
239+
response_json = response.json()
240+
if 'value' not in response_json:
241+
return None
242+
243+
logger.info("Configured AAD token for GitHub Actions OIDC (%s)", cfg.azure_client_id)
244+
params = {
245+
'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
246+
'resource': cfg.effective_azure_login_app_id,
247+
'client_assertion': response_json['value'],
248+
}
249+
aad_endpoint = cfg.arm_environment.active_directory_endpoint
250+
if not cfg.azure_tenant_id:
251+
# detect Azure AD Tenant ID if it's not specified directly
252+
token_endpoint = cfg.oidc_endpoints.token_endpoint
253+
cfg.azure_tenant_id = token_endpoint.replace(aad_endpoint, '').split('/')[0]
254+
inner = ClientCredentials(client_id=cfg.azure_client_id,
255+
client_secret="", # we have no (rotatable) secrets in OIDC flow
256+
token_url=f"{aad_endpoint}{cfg.azure_tenant_id}/oauth2/token",
257+
endpoint_params=params,
258+
use_params=True)
259+
260+
def refreshed_headers() -> Dict[str, str]:
261+
token = inner.token()
262+
return {'Authorization': f'{token.token_type} {token.access_token}'}
263+
264+
return refreshed_headers
265+
266+
220267
class CliTokenSource(Refreshable):
221268

222269
def __init__(self, cmd: List[str], token_type_field: str, access_token_field: str, expiry_field: str):
@@ -462,7 +509,7 @@ def auth_type(self) -> str:
462509
def __call__(self, cfg: 'Config') -> HeaderFactory:
463510
auth_providers = [
464511
pat_auth, basic_auth, metadata_service, oauth_service_principal, azure_service_principal,
465-
azure_cli, external_browser, databricks_cli, runtime_native_auth
512+
github_oidc_azure, azure_cli, external_browser, databricks_cli, runtime_native_auth
466513
]
467514
for provider in auth_providers:
468515
auth_type = provider.auth_type()

tests/test_core.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import requests
1414

1515
from databricks.sdk import WorkspaceClient
16+
from databricks.sdk.azure import ENVIRONMENTS, AzureEnvironment
1617
from databricks.sdk.core import (ApiClient, Config, CredentialsProvider,
1718
DatabricksCliTokenSource, DatabricksError,
1819
HeaderFactory, StreamingResponse,
@@ -510,3 +511,39 @@ def inner(h: BaseHTTPRequestHandler):
510511
assert 'foo' in res
511512

512513
assert len(requests) == 2
514+
515+
516+
def test_github_oidc_flow_works_with_azure(monkeypatch):
517+
518+
def inner(h: BaseHTTPRequestHandler):
519+
if 'audience=api://AzureADTokenExchange' in h.path:
520+
auth = h.headers['Authorization']
521+
assert 'Bearer gh-actions-token' == auth
522+
h.send_response(200)
523+
h.end_headers()
524+
h.wfile.write(b'{"value": "this_is_jwt_token"}')
525+
return
526+
if '/oidc/oauth2/v2.0/authorize' == h.path:
527+
h.send_response(301)
528+
h.send_header('Location', f'http://{h.headers["Host"]}/mocked-tenant-id/irrelevant/part')
529+
h.end_headers()
530+
return
531+
if '/mocked-tenant-id/oauth2/token' == h.path:
532+
h.send_response(200)
533+
h.end_headers()
534+
h.wfile.write(b'{"expires_in": 100, "access_token": "this-is-it", "token_type": "Taker"}')
535+
536+
with http_fixture_server(inner) as host:
537+
monkeypatch.setenv('ACTIONS_ID_TOKEN_REQUEST_URL', f'{host}/oidc')
538+
monkeypatch.setenv('ACTIONS_ID_TOKEN_REQUEST_TOKEN', 'gh-actions-token')
539+
ENVIRONMENTS[host] = AzureEnvironment(name=host,
540+
service_management_endpoint=host + '/',
541+
resource_manager_endpoint=host + '/',
542+
active_directory_endpoint=host + '/')
543+
cfg = Config(host=host,
544+
azure_workspace_resource_id=...,
545+
azure_client_id='test',
546+
azure_environment=host)
547+
headers = cfg.authenticate()
548+
549+
assert {'Authorization': 'Taker this-is-it'} == headers

0 commit comments

Comments
 (0)