forked from Azure/azure-sdk-for-go
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add AzurePipelinesCredential for service connection authentication (A…
- Loading branch information
Showing
5 changed files
with
200 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
// Copyright (c) Microsoft Corporation. All rights reserved. | ||
// Licensed under the MIT License. | ||
|
||
package azidentity | ||
|
||
import ( | ||
"context" | ||
"encoding/json" | ||
"errors" | ||
"fmt" | ||
"net/http" | ||
"os" | ||
|
||
"github.com/Azure/azure-sdk-for-go/sdk/azcore" | ||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" | ||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" | ||
) | ||
|
||
const ( | ||
credNameAzurePipelines = "AzurePipelinesCredential" | ||
oidcAPIVersion = "7.1" | ||
systemAccessToken = "SYSTEM_ACCESSTOKEN" | ||
systemOIDCRequestURI = "SYSTEM_OIDCREQUESTURI" | ||
) | ||
|
||
// AzurePipelinesCredential authenticates with workload identity federation in an Azure Pipeline. See | ||
// [Azure Pipelines documentation] for more information. | ||
// | ||
// [Azure Pipelines documentation]: https://learn.microsoft.com/azure/devops/pipelines/library/connect-to-azure?view=azure-devops#create-an-azure-resource-manager-service-connection-that-uses-workload-identity-federation | ||
type AzurePipelinesCredential struct { | ||
connectionID, oidcURI, systemAccessToken string | ||
cred *ClientAssertionCredential | ||
} | ||
|
||
// AzurePipelinesServiceConnectionCredentialOptions contains optional parameters for AzurePipelinesServiceConnectionCredential. | ||
type AzurePipelinesServiceConnectionCredentialOptions struct { | ||
azcore.ClientOptions | ||
|
||
// AdditionallyAllowedTenants specifies additional tenants for which the credential may acquire tokens. | ||
// Add the wildcard value "*" to allow the credential to acquire tokens for any tenant in which the | ||
// application is registered. | ||
AdditionallyAllowedTenants []string | ||
|
||
// DisableInstanceDiscovery should be set true only by applications authenticating in disconnected clouds, or | ||
// private clouds such as Azure Stack. It determines whether the credential requests Microsoft Entra instance metadata | ||
// from https://login.microsoft.com before authenticating. Setting this to true will skip this request, making | ||
// the application responsible for ensuring the configured authority is valid and trustworthy. | ||
DisableInstanceDiscovery bool | ||
} | ||
|
||
// NewAzurePipelinesCredential is the constructor for AzurePipelinesCredential. In addition to its required arguments, | ||
// it reads a security token for the running build, which is required to authenticate the service connection, from the | ||
// environment variable SYSTEM_ACCESSTOKEN. See the [Azure Pipelines documentation] for an example showing how to set | ||
// this variable in build job YAML. | ||
// | ||
// [Azure Pipelines documentation]: https://learn.microsoft.com/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#systemaccesstoken | ||
func NewAzurePipelinesCredential(tenantID, clientID, serviceConnectionID string, options *AzurePipelinesServiceConnectionCredentialOptions) (*AzurePipelinesCredential, error) { | ||
if options == nil { | ||
options = &AzurePipelinesServiceConnectionCredentialOptions{} | ||
} | ||
u := os.Getenv(systemOIDCRequestURI) | ||
if u == "" { | ||
return nil, fmt.Errorf("no value for environment variable %s. This should be set by Azure Pipelines", systemOIDCRequestURI) | ||
} | ||
sat := os.Getenv(systemAccessToken) | ||
if sat == "" { | ||
return nil, errors.New("no value for environment variable " + systemAccessToken) | ||
} | ||
a := AzurePipelinesCredential{ | ||
connectionID: serviceConnectionID, | ||
oidcURI: u, | ||
systemAccessToken: sat, | ||
} | ||
caco := ClientAssertionCredentialOptions{ | ||
AdditionallyAllowedTenants: options.AdditionallyAllowedTenants, | ||
ClientOptions: options.ClientOptions, | ||
DisableInstanceDiscovery: options.DisableInstanceDiscovery, | ||
} | ||
cred, err := NewClientAssertionCredential(tenantID, clientID, a.getAssertion, &caco) | ||
if err != nil { | ||
return nil, err | ||
} | ||
cred.client.name = credNameAzurePipelines | ||
a.cred = cred | ||
return &a, nil | ||
} | ||
|
||
// GetToken requests an access token from Microsoft Entra ID. Azure SDK clients call this method automatically. | ||
func (a *AzurePipelinesCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) { | ||
var err error | ||
ctx, endSpan := runtime.StartSpan(ctx, credNameAzurePipelines+"."+traceOpGetToken, a.cred.client.azClient.Tracer(), nil) | ||
defer func() { endSpan(err) }() | ||
tk, err := a.cred.GetToken(ctx, opts) | ||
return tk, err | ||
} | ||
|
||
func (a *AzurePipelinesCredential) getAssertion(ctx context.Context) (string, error) { | ||
url := a.oidcURI + "?api-version=" + oidcAPIVersion + "&serviceConnectionId=" + a.connectionID | ||
url, err := runtime.EncodeQueryParams(url) | ||
if err != nil { | ||
return "", err | ||
} | ||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) | ||
if err != nil { | ||
return "", err | ||
} | ||
req.Header.Set("Authorization", "Bearer "+a.systemAccessToken) | ||
res, err := doForClient(a.cred.client.azClient, req) | ||
if err != nil { | ||
return "", err | ||
} | ||
b, err := runtime.Payload(res) | ||
if err != nil { | ||
return "", err | ||
} | ||
var r struct { | ||
OIDCToken string `json:"oidcToken"` | ||
} | ||
err = json.Unmarshal(b, &r) | ||
if err != nil { | ||
return "", err | ||
} | ||
return r.OIDCToken, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
// Copyright (c) Microsoft Corporation. All rights reserved. | ||
// Licensed under the MIT License. | ||
|
||
package azidentity | ||
|
||
import ( | ||
"fmt" | ||
"net/http" | ||
"net/url" | ||
"os" | ||
"testing" | ||
|
||
"github.com/Azure/azure-sdk-for-go/sdk/azcore" | ||
"github.com/Azure/azure-sdk-for-go/sdk/internal/mock" | ||
"github.com/Azure/azure-sdk-for-go/sdk/internal/recording" | ||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestAzurePipelinesCredential(t *testing.T) { | ||
t.Run("getAssertion", func(t *testing.T) { | ||
srv, close := mock.NewServer() | ||
defer close() | ||
t.Setenv(systemOIDCRequestURI, srv.URL()) | ||
oidcAccessToken := "token" | ||
t.Setenv(systemAccessToken, oidcAccessToken) | ||
connectionID := "connection" | ||
expected, err := url.Parse(fmt.Sprintf( | ||
"%s/?api-version=%s&serviceConnectionId=%s", | ||
srv.URL(), oidcAPIVersion, connectionID, | ||
)) | ||
require.NoError(t, err, "test bug: expected URL should parse") | ||
srv.AppendResponse( | ||
mock.WithBody([]byte(fmt.Sprintf(`{"oidcToken":%q}`, oidcAccessToken))), | ||
mock.WithPredicate(func(r *http.Request) bool { | ||
require.Equal(t, http.MethodPost, r.Method) | ||
require.Equal(t, expected.Host, r.Host) | ||
require.Equal(t, expected.Path, r.URL.Path) | ||
require.Equal(t, expected.RawQuery, r.URL.RawQuery) | ||
return true | ||
}), | ||
) | ||
srv.AppendResponse() | ||
o := AzurePipelinesServiceConnectionCredentialOptions{ | ||
ClientOptions: azcore.ClientOptions{ | ||
Transport: srv, | ||
}, | ||
} | ||
cred, err := NewAzurePipelinesCredential(fakeTenantID, fakeClientID, connectionID, &o) | ||
require.NoError(t, err) | ||
actual, err := cred.getAssertion(ctx) | ||
require.NoError(t, err) | ||
require.Equal(t, oidcAccessToken, actual) | ||
}) | ||
t.Run("Live", func(t *testing.T) { | ||
if recording.GetRecordMode() != recording.LiveMode { | ||
t.Skip("this test runs only live in an Azure Pipeline with a configured service connection") | ||
} | ||
clientID := os.Getenv("AZURE_SERVICE_CONNECTION_CLIENT_ID") | ||
connectionID := os.Getenv("AZURE_SERVICE_CONNECTION_ID") | ||
tenantID := os.Getenv("AZURE_SERVICE_CONNECTION_TENANT_ID") | ||
for _, s := range []string{clientID, connectionID, tenantID} { | ||
if s == "" { | ||
t.Skip("set AZURE_SERVICE_CONNECTION_CLIENT_ID, AZURE_SERVICE_CONNECTION_ID and AZURE_SERVICE_CONNECTION_TENANT_ID to run this test") | ||
} | ||
} | ||
cred, err := NewAzurePipelinesCredential(tenantID, clientID, connectionID, nil) | ||
require.NoError(t, err) | ||
testGetTokenSuccess(t, cred, "https://vault.azure.net/.default") | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters