1 // Copyright (c) Microsoft Corporation. All rights reserved.
2 // Licensed under the MIT License.
3 4 package azidentity
5 6 import (
7 "context"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "net/http"
12 "os"
13 14 "github.com/Azure/azure-sdk-for-go/sdk/azcore"
15 "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
16 "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
17 )
18 19 const (
20 credNameAzurePipelines = "AzurePipelinesCredential"
21 oidcAPIVersion = "7.1"
22 systemOIDCRequestURI = "SYSTEM_OIDCREQUESTURI"
23 xMsEdgeRef = "x-msedge-ref"
24 xVssE2eId = "x-vss-e2eid"
25 )
26 27 // AzurePipelinesCredential authenticates with workload identity federation in an Azure Pipeline. See
28 // [Azure Pipelines documentation] for more information.
29 //
30 // [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
31 type AzurePipelinesCredential struct {
32 connectionID, oidcURI, systemAccessToken string
33 cred *ClientAssertionCredential
34 }
35 36 // AzurePipelinesCredentialOptions contains optional parameters for AzurePipelinesCredential.
37 type AzurePipelinesCredentialOptions struct {
38 azcore.ClientOptions
39 40 // AdditionallyAllowedTenants specifies additional tenants for which the credential may acquire tokens.
41 // Add the wildcard value "*" to allow the credential to acquire tokens for any tenant in which the
42 // application is registered.
43 AdditionallyAllowedTenants []string
44 45 // Cache is a persistent cache the credential will use to store the tokens it acquires, making
46 // them available to other processes and credential instances. The default, zero value means the
47 // credential will store tokens in memory and not share them with any other credential instance.
48 Cache Cache
49 50 // DisableInstanceDiscovery should be set true only by applications authenticating in disconnected clouds, or
51 // private clouds such as Azure Stack. It determines whether the credential requests Microsoft Entra instance metadata
52 // from https://login.microsoft.com before authenticating. Setting this to true will skip this request, making
53 // the application responsible for ensuring the configured authority is valid and trustworthy.
54 DisableInstanceDiscovery bool
55 }
56 57 // NewAzurePipelinesCredential is the constructor for AzurePipelinesCredential.
58 //
59 // - tenantID: tenant ID of the service principal federated with the service connection
60 // - clientID: client ID of that service principal
61 // - serviceConnectionID: ID of the service connection to authenticate
62 // - systemAccessToken: security token for the running build. See [Azure Pipelines documentation] for
63 // an example showing how to get this value.
64 //
65 // [Azure Pipelines documentation]: https://learn.microsoft.com/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#systemaccesstoken
66 func NewAzurePipelinesCredential(tenantID, clientID, serviceConnectionID, systemAccessToken string, options *AzurePipelinesCredentialOptions) (*AzurePipelinesCredential, error) {
67 if !validTenantID(tenantID) {
68 return nil, errInvalidTenantID
69 }
70 if clientID == "" {
71 return nil, errors.New("no client ID specified")
72 }
73 if serviceConnectionID == "" {
74 return nil, errors.New("no service connection ID specified")
75 }
76 if systemAccessToken == "" {
77 return nil, errors.New("no system access token specified")
78 }
79 u := os.Getenv(systemOIDCRequestURI)
80 if u == "" {
81 return nil, fmt.Errorf("no value for environment variable %s. This should be set by Azure Pipelines", systemOIDCRequestURI)
82 }
83 a := AzurePipelinesCredential{
84 connectionID: serviceConnectionID,
85 oidcURI: u,
86 systemAccessToken: systemAccessToken,
87 }
88 if options == nil {
89 options = &AzurePipelinesCredentialOptions{}
90 }
91 // these headers are useful to the DevOps team when debugging OIDC error responses
92 options.ClientOptions.Logging.AllowedHeaders = append(options.ClientOptions.Logging.AllowedHeaders, xMsEdgeRef, xVssE2eId)
93 caco := ClientAssertionCredentialOptions{
94 AdditionallyAllowedTenants: options.AdditionallyAllowedTenants,
95 Cache: options.Cache,
96 ClientOptions: options.ClientOptions,
97 DisableInstanceDiscovery: options.DisableInstanceDiscovery,
98 }
99 cred, err := NewClientAssertionCredential(tenantID, clientID, a.getAssertion, &caco)
100 if err != nil {
101 return nil, err
102 }
103 cred.client.name = credNameAzurePipelines
104 a.cred = cred
105 return &a, nil
106 }
107 108 // GetToken requests an access token from Microsoft Entra ID. Azure SDK clients call this method automatically.
109 func (a *AzurePipelinesCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
110 var err error
111 ctx, endSpan := runtime.StartSpan(ctx, credNameAzurePipelines+"."+traceOpGetToken, a.cred.client.azClient.Tracer(), nil)
112 defer func() { endSpan(err) }()
113 tk, err := a.cred.GetToken(ctx, opts)
114 return tk, err
115 }
116 117 func (a *AzurePipelinesCredential) getAssertion(ctx context.Context) (string, error) {
118 url := a.oidcURI + "?api-version=" + oidcAPIVersion + "&serviceConnectionId=" + a.connectionID
119 url, err := runtime.EncodeQueryParams(url)
120 if err != nil {
121 return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't encode OIDC URL: "+err.Error(), nil)
122 }
123 req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
124 if err != nil {
125 return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't create OIDC token request: "+err.Error(), nil)
126 }
127 req.Header.Set("Authorization", "Bearer "+a.systemAccessToken)
128 // instruct endpoint to return 401 instead of 302, if the system access token is invalid
129 req.Header.Set("X-TFS-FedAuthRedirect", "Suppress")
130 res, err := doForClient(a.cred.client.azClient, req)
131 if err != nil {
132 return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't send OIDC token request: "+err.Error(), nil)
133 }
134 if res.StatusCode != http.StatusOK {
135 msg := res.Status + " response from the OIDC endpoint. Check service connection ID and Pipeline configuration."
136 for _, h := range []string{xMsEdgeRef, xVssE2eId} {
137 if v := res.Header.Get(h); v != "" {
138 msg += fmt.Sprintf("\n%s: %s", h, v)
139 }
140 }
141 // include the response because its body, if any, probably contains an error message.
142 // OK responses aren't included with errors because they probably contain secrets
143 return "", newAuthenticationFailedError(credNameAzurePipelines, msg, res)
144 }
145 b, err := runtime.Payload(res)
146 if err != nil {
147 return "", newAuthenticationFailedError(credNameAzurePipelines, "couldn't read OIDC response content: "+err.Error(), nil)
148 }
149 var r struct {
150 OIDCToken string `json:"oidcToken"`
151 }
152 err = json.Unmarshal(b, &r)
153 if err != nil {
154 return "", newAuthenticationFailedError(credNameAzurePipelines, "unexpected response from OIDC endpoint", nil)
155 }
156 return r.OIDCToken, nil
157 }
158