-
Notifications
You must be signed in to change notification settings - Fork 178
Add native support for Azure DevOps OIDC authentication #1027
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 19 commits
c1c95cb
8e4bb86
751c1a4
d4175ae
1bbc3c3
fd24dc4
553fd36
5bbb16b
649cc5d
ef7a058
eabfc73
8c95cd9
44c78c1
3304bbf
3f6505a
14aa770
3b5ab96
3393d78
4b4ca3e
9f02727
ac5db4f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -89,7 +89,6 @@ def inner( | |
| @functools.wraps(func) | ||
| def wrapper(cfg: "Config") -> Optional[CredentialsProvider]: | ||
| for attr in require: | ||
| getattr(cfg, attr) | ||
| if not getattr(cfg, attr): | ||
| return None | ||
| return func(cfg) | ||
|
|
@@ -103,7 +102,12 @@ def wrapper(cfg: "Config") -> Optional[CredentialsProvider]: | |
| def oauth_credentials_strategy(name: str, require: List[str]): | ||
| """Given the function that receives a Config and returns an OauthHeaderFactory, | ||
| create an OauthCredentialsProvider with a given name and required configuration | ||
| attribute names to be present for this function to be called.""" | ||
| attribute names to be present for this function to be called. | ||
|
|
||
| Args: | ||
| name: The name of the authentication strategy | ||
| require: List of config attributes that must be present | ||
| """ | ||
|
|
||
| def inner( | ||
| func: Callable[["Config"], OAuthCredentialsProvider], | ||
|
|
@@ -408,6 +412,64 @@ def token() -> oauth.Token: | |
| return OAuthCredentialsProvider(refreshed_headers, token) | ||
|
|
||
|
|
||
| @oauth_credentials_strategy("azure-devops-oidc", ["host", "client_id"]) | ||
| def azure_devops_oidc(cfg: "Config") -> Optional[CredentialsProvider]: | ||
| """ | ||
| Azure DevOps OIDC authentication uses a Token Supplier to get a JWT Token | ||
| and exchanges it for a Databricks Token. | ||
|
|
||
| Supported in Azure DevOps pipelines with OIDC service connections. | ||
| """ | ||
| try: | ||
| supplier = oidc_token_supplier.AzureDevOpsOIDCTokenSupplier() | ||
|
||
| except ValueError as e: | ||
| logger.debug(str(e)) | ||
| return None | ||
|
|
||
| audience = cfg.token_audience | ||
| if audience is None and cfg.is_account_client: | ||
| audience = cfg.account_id | ||
| if audience is None and not cfg.is_account_client: | ||
| audience = cfg.oidc_endpoints.token_endpoint | ||
|
|
||
| # Try to get an idToken. If no supplier returns a token, we cannot use this authentication mode. | ||
| id_token = supplier.get_oidc_token(audience) | ||
|
||
| if not id_token: | ||
| logger.debug("Azure DevOps OIDC: no token available, skipping authentication method") | ||
| return None | ||
|
|
||
| logger.info("Configured Azure DevOps OIDC authentication") | ||
|
|
||
| def token_source_for(audience: str) -> oauth.TokenSource: | ||
| id_token = supplier.get_oidc_token(audience) | ||
| if not id_token: | ||
| # Should not happen, since we checked it above. | ||
| raise Exception("Cannot get Azure DevOps OIDC token") | ||
|
|
||
| return oauth.ClientCredentials( | ||
| client_id=cfg.client_id, | ||
| client_secret="", # we have no (rotatable) secrets in OIDC flow | ||
| token_url=cfg.oidc_endpoints.token_endpoint, | ||
| endpoint_params={ | ||
| "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", | ||
| "subject_token": id_token, | ||
| "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", | ||
| }, | ||
| scopes=["all-apis"], | ||
| use_params=True, | ||
| disable_async=cfg.disable_async_token_refresh, | ||
| ) | ||
|
|
||
| def refreshed_headers() -> Dict[str, str]: | ||
| token = token_source_for(audience).token() | ||
| return {"Authorization": f"{token.token_type} {token.access_token}"} | ||
|
|
||
| def token() -> oauth.Token: | ||
| return token_source_for(audience).token() | ||
|
|
||
| return OAuthCredentialsProvider(refreshed_headers, token) | ||
|
|
||
|
|
||
| @oauth_credentials_strategy("github-oidc-azure", ["host", "azure_client_id"]) | ||
| def github_oidc_azure(cfg: "Config") -> Optional[CredentialsProvider]: | ||
| if "ACTIONS_ID_TOKEN_REQUEST_TOKEN" not in os.environ: | ||
|
|
@@ -1016,6 +1078,7 @@ def __init__(self) -> None: | |
| env_oidc, | ||
| file_oidc, | ||
| github_oidc, | ||
| azure_devops_oidc, | ||
hectorcast-db marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| azure_service_principal, | ||
| github_oidc_azure, | ||
| azure_cli, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,11 @@ | ||
| import logging | ||
| import os | ||
| from typing import Optional | ||
|
|
||
| import requests | ||
|
|
||
| logger = logging.getLogger("databricks.sdk") | ||
|
|
||
|
|
||
| class GitHubOIDCTokenSupplier: | ||
| """ | ||
|
|
@@ -26,3 +29,79 @@ def get_oidc_token(self, audience: str) -> Optional[str]: | |
| return None | ||
|
|
||
| return response_json["value"] | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same: GitHubOIDC does not validate on create. Is there a reasons to change this?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is done to ensure Early exit. This is similar to what is done in Go SDK. If the environment variables are not set then we are sure that we are not in Azure DevOps Environment so we should exit at the earliest and try other providers.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we add a TODO on the GitHub OIDC implementation to make it clear that it is the one with tech debt? |
||
|
|
||
| class AzureDevOpsOIDCTokenSupplier: | ||
| """ | ||
| Supplies OIDC tokens from Azure DevOps pipelines. | ||
|
|
||
| Constructs the OIDC token request URL using official Azure DevOps predefined variables. | ||
| See: https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables | ||
| """ | ||
|
|
||
| def __init__(self): | ||
| """Initialize and validate Azure DevOps environment variables.""" | ||
| # Get Azure DevOps environment variables. | ||
| self.access_token = os.environ.get("SYSTEM_ACCESSTOKEN") | ||
| self.collection_uri = os.environ.get("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI") | ||
| self.project_id = os.environ.get("SYSTEM_TEAMPROJECTID") | ||
| self.plan_id = os.environ.get("SYSTEM_PLANID") | ||
| self.job_id = os.environ.get("SYSTEM_JOBID") | ||
| self.hub_name = os.environ.get("SYSTEM_HOSTTYPE") | ||
|
|
||
| # Check for required variables with specific error messages. | ||
| missing_vars = [] | ||
| if not self.access_token: | ||
| missing_vars.append("SYSTEM_ACCESSTOKEN") | ||
| if not self.collection_uri: | ||
| missing_vars.append("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI") | ||
| if not self.project_id: | ||
| missing_vars.append("SYSTEM_TEAMPROJECTID") | ||
| if not self.plan_id: | ||
| missing_vars.append("SYSTEM_PLANID") | ||
| if not self.job_id: | ||
| missing_vars.append("SYSTEM_JOBID") | ||
| if not self.hub_name: | ||
| missing_vars.append("SYSTEM_HOSTTYPE") | ||
|
|
||
| if missing_vars: | ||
| if "SYSTEM_ACCESSTOKEN" in missing_vars: | ||
| error_msg = "Azure DevOps OIDC: SYSTEM_ACCESSTOKEN env var not found. If calling from Azure DevOps Pipeline, please set this env var following https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#systemaccesstoken" | ||
| else: | ||
| error_msg = f"Azure DevOps OIDC: missing required environment variables: {', '.join(missing_vars)}" | ||
| raise ValueError(error_msg) | ||
|
|
||
| def get_oidc_token(self, audience: str) -> Optional[str]: | ||
| # Note: Azure DevOps OIDC tokens have a fixed audience of "api://AzureADTokenExchange". | ||
| # The audience parameter is ignored but kept for interface compatibility with other OIDC suppliers. | ||
|
|
||
| try: | ||
| # Construct the OIDC token request URL. | ||
| # Format: {collection_uri}{project_id}/_apis/distributedtask/hubs/{hubName}/plans/{planId}/jobs/{jobId}/oidctoken. | ||
| request_url = f"{self.collection_uri}{self.project_id}/_apis/distributedtask/hubs/{self.hub_name}/plans/{self.plan_id}/jobs/{self.job_id}/oidctoken" | ||
|
|
||
| # Add API version (audience is fixed to "api://AzureADTokenExchange" by Azure DevOps). | ||
| endpoint = f"{request_url}?api-version=7.2-preview.1" | ||
| headers = { | ||
| "Authorization": f"Bearer {self.access_token}", | ||
| "Content-Type": "application/json", | ||
| "Content-Length": "0", | ||
| } | ||
|
|
||
| # Azure DevOps OIDC endpoint requires POST request with empty body. | ||
| response = requests.post(endpoint, headers=headers) | ||
| if not response.ok: | ||
| logger.debug(f"Azure DevOps OIDC: token request failed with status {response.status_code}") | ||
| return None | ||
|
|
||
| # Azure DevOps returns the token in 'oidcToken' field. | ||
| response_json = response.json() | ||
| if "oidcToken" not in response_json: | ||
| logger.debug("Azure DevOps OIDC: response missing 'oidcToken' field") | ||
| return None | ||
|
|
||
| logger.debug("Azure DevOps OIDC: successfully obtained token") | ||
| return response_json["oidcToken"] | ||
| except Exception as e: | ||
| logger.debug(f"Azure DevOps OIDC: failed to get token: {e}") | ||
| return None | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seemed like unnecessary Double check
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you sure? what does this function do?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function checks if the attribute exists in the config or not. This check is redundant, as it is done in the next line as well