Skip to content

Commit b876c0d

Browse files
committed
[RFC-0010] Add azure auth library
Signed-off-by: Matheus Pimenta <[email protected]>
1 parent 45fbfee commit b876c0d

File tree

14 files changed

+939
-7
lines changed

14 files changed

+939
-7
lines changed

auth/azure/client.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ type OptFunc func(*Client)
5151
// https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity#NewDefaultAzureCredential
5252
// The default scope is to ARM endpoint in Azure Cloud. The scope is overridden
5353
// using OptFunc.
54+
//
55+
// Deprecated: Use auth.GetToken() with azure.Provider{} or NewTokenCredential().
5456
func New(opts ...OptFunc) (*Client, error) {
5557
p := &Client{}
5658
for _, opt := range opts {
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package azure
18+
19+
import (
20+
"errors"
21+
"fmt"
22+
"os"
23+
"strings"
24+
25+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
26+
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
27+
)
28+
29+
// newDefaultAzureCredential is like azidentity.newDefaultAzureCredential, but
30+
// does not call azidentity.NewAzureCLICredential().
31+
func newDefaultAzureCredential(options azidentity.DefaultAzureCredentialOptions) (azcore.TokenCredential, error) {
32+
var (
33+
azureClientID = "AZURE_CLIENT_ID"
34+
azureFederatedTokenFile = "AZURE_FEDERATED_TOKEN_FILE"
35+
azureAuthorityHost = "AZURE_AUTHORITY_HOST"
36+
azureTenantID = "AZURE_TENANT_ID"
37+
)
38+
39+
var errorMessages []string
40+
41+
envCred, err := azidentity.NewEnvironmentCredential(&azidentity.EnvironmentCredentialOptions{
42+
ClientOptions: options.ClientOptions, DisableInstanceDiscovery: options.DisableInstanceDiscovery},
43+
)
44+
if err == nil {
45+
return envCred, nil
46+
} else {
47+
errorMessages = append(errorMessages, "EnvironmentCredential: "+err.Error())
48+
}
49+
50+
// workload identity requires values for AZURE_AUTHORITY_HOST, AZURE_CLIENT_ID, AZURE_FEDERATED_TOKEN_FILE, AZURE_TENANT_ID
51+
haveWorkloadConfig := false
52+
clientID, haveClientID := os.LookupEnv(azureClientID)
53+
if haveClientID {
54+
if file, ok := os.LookupEnv(azureFederatedTokenFile); ok {
55+
if _, ok := os.LookupEnv(azureAuthorityHost); ok {
56+
if tenantID, ok := os.LookupEnv(azureTenantID); ok {
57+
haveWorkloadConfig = true
58+
workloadCred, err := azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{
59+
ClientID: clientID,
60+
TenantID: tenantID,
61+
TokenFilePath: file,
62+
ClientOptions: options.ClientOptions,
63+
DisableInstanceDiscovery: options.DisableInstanceDiscovery,
64+
})
65+
if err == nil {
66+
return workloadCred, nil
67+
} else {
68+
errorMessages = append(errorMessages, "Workload Identity"+": "+err.Error())
69+
}
70+
}
71+
}
72+
}
73+
}
74+
if !haveWorkloadConfig {
75+
err := errors.New("missing environment variables for workload identity. Check webhook and pod configuration")
76+
errorMessages = append(errorMessages, fmt.Sprintf("Workload Identity: %s", err))
77+
}
78+
79+
o := &azidentity.ManagedIdentityCredentialOptions{ClientOptions: options.ClientOptions}
80+
if haveClientID {
81+
o.ID = azidentity.ClientID(clientID)
82+
}
83+
miCred, err := azidentity.NewManagedIdentityCredential(o)
84+
if err == nil {
85+
return miCred, nil
86+
} else {
87+
errorMessages = append(errorMessages, "ManagedIdentity"+": "+err.Error())
88+
}
89+
90+
return nil, errors.New(strings.Join(errorMessages, "\n"))
91+
}

auth/azure/options.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package azure
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"strings"
23+
24+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
25+
corev1 "k8s.io/api/core/v1"
26+
27+
"github.com/fluxcd/pkg/auth"
28+
)
29+
30+
func getIdentity(serviceAccount corev1.ServiceAccount) (string, error) {
31+
tenantID, err := getTenantID(serviceAccount)
32+
if err != nil {
33+
return "", err
34+
}
35+
clientID, err := getClientID(serviceAccount)
36+
if err != nil {
37+
return "", err
38+
}
39+
return fmt.Sprintf("%s/%s", tenantID, clientID), nil
40+
}
41+
42+
func getTenantID(serviceAccount corev1.ServiceAccount) (string, error) {
43+
if tenantID, ok := serviceAccount.Annotations["azure.workload.identity/tenant-id"]; ok {
44+
return tenantID, nil
45+
}
46+
if tenantID := os.Getenv("AZURE_TENANT_ID"); tenantID != "" {
47+
return tenantID, nil
48+
}
49+
return "", fmt.Errorf("azure tenant ID not found in the service account annotations nor in the environment variable AZURE_TENANT_ID")
50+
}
51+
52+
func getClientID(serviceAccount corev1.ServiceAccount) (string, error) {
53+
if clientID, ok := serviceAccount.Annotations["azure.workload.identity/client-id"]; ok {
54+
return clientID, nil
55+
}
56+
return "", fmt.Errorf("azure client ID not found in the service account annotations")
57+
}
58+
59+
func getScopes(o *auth.Options) []string {
60+
if o.ArtifactRepository == "" {
61+
return o.Scopes
62+
}
63+
64+
var conf *cloud.Configuration
65+
switch {
66+
case strings.HasSuffix(o.ArtifactRepository, ".azurecr.cn"):
67+
conf = &cloud.AzureChina
68+
case strings.HasSuffix(o.ArtifactRepository, ".azurecr.us"):
69+
conf = &cloud.AzureGovernment
70+
default:
71+
conf = &cloud.AzurePublic
72+
}
73+
74+
return []string{conf.Services[cloud.ResourceManager].Endpoint + "/" + ".default"}
75+
}
76+
77+
func getACRHost(artifactRepository string) string {
78+
return strings.SplitN(artifactRepository, "/", 2)[0]
79+
}

auth/azure/provider.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package azure
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"fmt"
23+
"net/http"
24+
"net/url"
25+
"path"
26+
"strings"
27+
28+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
29+
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
30+
"github.com/golang-jwt/jwt/v5"
31+
corev1 "k8s.io/api/core/v1"
32+
33+
"github.com/fluxcd/pkg/auth"
34+
)
35+
36+
// ProviderName is the name of the Azure authentication provider.
37+
const ProviderName = "azure"
38+
39+
// Provider implements the auth.Provider interface for Azure authentication.
40+
type Provider struct{}
41+
42+
// GetName implements auth.Provider.
43+
func (Provider) GetName() string {
44+
return ProviderName
45+
}
46+
47+
// NewDefaultToken implements auth.Provider.
48+
func (Provider) NewDefaultToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) {
49+
var o auth.Options
50+
o.Apply(opts...)
51+
52+
var azOpts azidentity.DefaultAzureCredentialOptions
53+
54+
if hc := o.GetHTTPClient(); hc != nil {
55+
azOpts.Transport = hc
56+
}
57+
58+
cred, err := newDefaultAzureCredential(azOpts)
59+
if err != nil {
60+
return nil, err
61+
}
62+
token, err := cred.GetToken(ctx, policy.TokenRequestOptions{
63+
Scopes: getScopes(&o),
64+
})
65+
if err != nil {
66+
return nil, err
67+
}
68+
69+
return &Token{token}, nil
70+
}
71+
72+
// GetAudience implements auth.Provider.
73+
func (Provider) GetAudience(context.Context) (string, error) {
74+
return "api://AzureADTokenExchange", nil
75+
}
76+
77+
// GetIdentity implements auth.Provider.
78+
func (Provider) GetIdentity(serviceAccount corev1.ServiceAccount) (string, error) {
79+
return getIdentity(serviceAccount)
80+
}
81+
82+
// NewTokenForServiceAccount implements auth.Provider.
83+
func (Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string,
84+
serviceAccount corev1.ServiceAccount, opts ...auth.Option) (auth.Token, error) {
85+
86+
var o auth.Options
87+
o.Apply(opts...)
88+
89+
identity, err := getIdentity(serviceAccount)
90+
if err != nil {
91+
return nil, err
92+
}
93+
s := strings.Split(identity, "/")
94+
tenantID, clientID := s[0], s[1]
95+
96+
azOpts := &azidentity.ClientAssertionCredentialOptions{}
97+
98+
if hc := o.GetHTTPClient(); hc != nil {
99+
azOpts.Transport = hc
100+
}
101+
102+
cred, err := azidentity.NewClientAssertionCredential(tenantID, clientID, func(context.Context) (string, error) {
103+
return oidcToken, nil
104+
}, azOpts)
105+
if err != nil {
106+
return nil, err
107+
}
108+
token, err := cred.GetToken(ctx, policy.TokenRequestOptions{
109+
Scopes: getScopes(&o),
110+
})
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
return &Token{token}, nil
116+
}
117+
118+
// GetArtifactCacheKey implements auth.Provider.
119+
func (Provider) GetArtifactCacheKey(artifactRepository string) string {
120+
return getACRHost(artifactRepository)
121+
}
122+
123+
// NewArtifactRegistryToken implements auth.Provider.
124+
func (Provider) NewArtifactRegistryToken(ctx context.Context, artifactRepository string,
125+
accessToken auth.Token, opts ...auth.Option) (auth.Token, error) {
126+
127+
t := accessToken.(*Token)
128+
129+
var o auth.Options
130+
o.Apply(opts...)
131+
132+
// Build request.
133+
registryURL := artifactRepository
134+
if !strings.HasPrefix(artifactRepository, "http") {
135+
registryURL = fmt.Sprintf("https://%s", getACRHost(artifactRepository))
136+
}
137+
exchangeURL, err := url.Parse(registryURL)
138+
if err != nil {
139+
return nil, err
140+
}
141+
exchangeURL.Path = path.Join(exchangeURL.Path, "oauth2/exchange")
142+
parameters := url.Values{}
143+
parameters.Add("grant_type", "access_token")
144+
parameters.Add("service", exchangeURL.Hostname())
145+
parameters.Add("access_token", t.Token)
146+
body := strings.NewReader(parameters.Encode())
147+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL.String(), body)
148+
if err != nil {
149+
return nil, err
150+
}
151+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
152+
153+
// Send request.
154+
httpClient := http.DefaultClient
155+
if hc := o.GetHTTPClient(); hc != nil {
156+
httpClient = hc
157+
}
158+
resp, err := httpClient.Do(req)
159+
if err != nil {
160+
return nil, err
161+
}
162+
defer resp.Body.Close()
163+
164+
// Parse response.
165+
if resp.StatusCode != http.StatusOK {
166+
return nil, fmt.Errorf("unexpected status from ACR exchange request: %d", resp.StatusCode)
167+
}
168+
var tokenResp struct {
169+
RefreshToken string `json:"refresh_token"`
170+
}
171+
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
172+
return nil, err
173+
}
174+
var claims jwt.MapClaims
175+
if _, _, err := jwt.NewParser().ParseUnverified(tokenResp.RefreshToken, &claims); err != nil {
176+
return nil, err
177+
}
178+
expiry, err := claims.GetExpirationTime()
179+
if err != nil {
180+
return nil, err
181+
}
182+
183+
return &auth.ArtifactRegistryCredentials{
184+
// https://docs.microsoft.com/en-us/azure/container-registry/container-registry-authentication?tabs=azure-cli#az-acr-login-with---expose-token
185+
Username: "00000000-0000-0000-0000-000000000000",
186+
Password: tokenResp.RefreshToken,
187+
ExpiresAt: expiry.Time,
188+
}, nil
189+
}

auth/azure/scopes.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
Copyright 2025 The Flux authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package azure
18+
19+
const (
20+
// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity?view=azure-devops#q-can-i-use-a-service-principal-or-managed-identity-with-azure-cli
21+
ScopeDevOps = "499b84ac-1321-427f-aa17-267ca6975798/.default"
22+
23+
// https://github.com/Azure/azure-sdk-for-go/blob/f5dfe3b53fe63aacd3aeba948bbe21d961edf376/sdk/storage/azqueue/internal/shared/shared.go#L18
24+
ScopeBlobStorage = "https://storage.azure.com/.default"
25+
26+
// https://github.com/Azure/azure-sdk-for-go/blob/f5dfe3b53fe63aacd3aeba948bbe21d961edf376/sdk/messaging/azeventhubs/internal/sbauth/token_provider.go#L99
27+
ScopeEventHubs = "https://eventhubs.azure.net//.default"
28+
)

0 commit comments

Comments
 (0)