Skip to content

Commit 081c0f1

Browse files
azkv: enable specifying some auth methods directly, and add cachable user authentication methods
1 parent 7d4395f commit 081c0f1

File tree

3 files changed

+159
-1
lines changed

3 files changed

+159
-1
lines changed

README.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,14 @@ which tries several authentication methods, in this order:
336336
3. `Managed Identity credentials <https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity#ManagedIdentityCredential>`_
337337
4. `Azure CLI credentials <https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity#AzureCLICredential>`_
338338
339+
340+
If you want to force a specific method you can override this with the enviornment variable ``SOPS_AZURE_AUTH_METHOD``
341+
- ``default`` (same as not setting this variable)
342+
- ``msi``
343+
- ``azure-cli``
344+
- ``cached-device-code`` (device code authentication which caches the token in the os keyring)
345+
- ``cached-browser`` (interactive browser authentication which caches the token in the os keyring)
346+
339347
For example, you can use a Service Principal with the following environment variables:
340348
341349
.. code:: bash

azkv/keysource.go

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,21 @@ import (
99
"context"
1010
"encoding/base64"
1111
"fmt"
12+
"path/filepath"
1213
"regexp"
1314
"strings"
1415
"time"
1516

17+
"encoding/json"
18+
"os"
19+
1620
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
21+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
1722
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
1823
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
24+
azidentitycache "github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache"
1925
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
26+
"github.com/pkg/browser"
2027
"github.com/sirupsen/logrus"
2128

2229
"github.com/getsops/sops/v3/logging"
@@ -25,6 +32,11 @@ import (
2532
const (
2633
// KeyTypeIdentifier is the string used to identify an Azure Key Vault MasterKey.
2734
KeyTypeIdentifier = "azure_kv"
35+
36+
SopsAzureAuthMethodEnv = "SOPS_AZURE_AUTH_METHOD"
37+
38+
cachedBrowserAuthRecordFileName = "azure-auth-record-browser.json"
39+
cachedDeviceCodeAuthRecordFileName = "azure-auth-record-device-code.json"
2840
)
2941

3042
var (
@@ -230,7 +242,142 @@ func (key *MasterKey) TypeToIdentifier() string {
230242
// azidentity.NewDefaultAzureCredential.
231243
func (key *MasterKey) getTokenCredential() (azcore.TokenCredential, error) {
232244
if key.tokenCredential == nil {
233-
return azidentity.NewDefaultAzureCredential(nil)
245+
246+
authMethod := strings.ToLower(os.Getenv(SopsAzureAuthMethodEnv))
247+
switch authMethod {
248+
case "cached-browser":
249+
return cachedInteractiveBrowserCredentials()
250+
case "cached-device-code":
251+
return cachedDeviceCodeCredentials()
252+
case "azure-cli":
253+
return azidentity.NewAzureCLICredential(nil)
254+
case "msi":
255+
return azidentity.NewManagedIdentityCredential(nil)
256+
// If "DEFAULT" or not explicitly specified then use the default authentication chain.
257+
case "", "default":
258+
return azidentity.NewDefaultAzureCredential(nil)
259+
default:
260+
return nil, fmt.Errorf("Value `%s` is unsupported for environment variable `%s`, to resolve this either leave it unset or use one of `default`/`msi`/`azure-cli`/`cached-browser`/`cached-device-code`", authMethod, SopsAzureAuthMethodEnv)
261+
}
234262
}
235263
return key.tokenCredential, nil
236264
}
265+
266+
func sopsCacheDir() (string, error) {
267+
userCacheDir, err := os.UserCacheDir()
268+
if err != nil {
269+
return "", err
270+
}
271+
272+
cacheDir := filepath.Join(userCacheDir, "/sops")
273+
274+
if err = os.MkdirAll(cacheDir, 0o700); err != nil {
275+
return "", err
276+
}
277+
278+
return cacheDir, nil
279+
}
280+
281+
type CachableTokenCredential interface {
282+
Authenticate(ctx context.Context, opts *policy.TokenRequestOptions) (azidentity.AuthenticationRecord, error)
283+
GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error)
284+
}
285+
286+
func cacheStoreRecord(cachePath string, record azidentity.AuthenticationRecord) error {
287+
b, err := json.Marshal(record)
288+
if err != nil {
289+
return err
290+
}
291+
292+
return os.WriteFile(cachePath, b, 0600)
293+
}
294+
295+
func cacheLoadRecord(cachePath string) (azidentity.AuthenticationRecord, error) {
296+
var record azidentity.AuthenticationRecord
297+
298+
b, err := os.ReadFile(cachePath)
299+
if err != nil {
300+
return record, err
301+
}
302+
303+
err = json.Unmarshal(b, &record)
304+
if err != nil {
305+
return record, err
306+
}
307+
308+
return record, nil
309+
}
310+
311+
func cacheTokenCredential(cachePath string, tokenCredentialFn func(cache azidentity.Cache, record azidentity.AuthenticationRecord) (CachableTokenCredential, error)) (azcore.TokenCredential, error) {
312+
cache, err := azidentitycache.New(nil)
313+
// Errors if persistent caching is not supported by the current runtime
314+
if err != nil {
315+
return nil, err
316+
}
317+
318+
cachedRecord, cacheLoadErr := cacheLoadRecord(cachePath)
319+
320+
credential, err := tokenCredentialFn(cache, cachedRecord)
321+
if err != nil {
322+
return nil, err
323+
}
324+
325+
// If loading the authenticationRecord from the cachePath failed for any reason (validation, file doesn't exist, not encoded using json, etc.)
326+
if cacheLoadErr != nil {
327+
record, err := credential.Authenticate(context.Background(), nil)
328+
if err != nil {
329+
return nil, err
330+
}
331+
332+
if err = cacheStoreRecord(cachePath, record); err != nil {
333+
return nil, err
334+
}
335+
}
336+
337+
return credential, nil
338+
}
339+
340+
func cachedInteractiveBrowserCredentials() (azcore.TokenCredential, error) {
341+
// The default behaviour of `browser` which `azidentity` is using for the interactive browser authentication method is to write anything the browser prints to stdout to the stdout of the program running it.
342+
// This is not desired since on the initial authentication or when refreshing the cache it would pollute the output of sops.
343+
// To fix this behaviour we redirect the browser stdout -> stderr so any pertinent information written by the browser is not completely hidden from the user but it doesn't mess up the sops output.
344+
browser.Stdout = os.Stderr
345+
346+
cacheDir, err := sopsCacheDir()
347+
if err != nil {
348+
return nil, err
349+
}
350+
351+
return cacheTokenCredential(
352+
filepath.Join(cacheDir, cachedBrowserAuthRecordFileName),
353+
func(cache azidentity.Cache, record azidentity.AuthenticationRecord) (CachableTokenCredential, error) {
354+
return azidentity.NewInteractiveBrowserCredential(&azidentity.InteractiveBrowserCredentialOptions{
355+
AuthenticationRecord: record,
356+
Cache: cache,
357+
})
358+
},
359+
)
360+
}
361+
362+
func cachedDeviceCodeCredentials() (azcore.TokenCredential, error) {
363+
cacheDir, err := sopsCacheDir()
364+
if err != nil {
365+
return nil, err
366+
}
367+
368+
return cacheTokenCredential(
369+
filepath.Join(cacheDir, cachedDeviceCodeAuthRecordFileName),
370+
func(cache azidentity.Cache, record azidentity.AuthenticationRecord) (CachableTokenCredential, error) {
371+
return azidentity.NewDeviceCodeCredential(&azidentity.DeviceCodeCredentialOptions{
372+
AuthenticationRecord: record,
373+
Cache: cache,
374+
// Print the device code authentication information to stderr so we don't pollute the output of sops.
375+
UserPrompt: func(ctx context.Context, dc azidentity.DeviceCodeMessage) error {
376+
_, err := fmt.Fprintln(os.Stderr, dc.Message)
377+
return err
378+
379+
},
380+
})
381+
},
382+
)
383+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
filippo.io/age v1.2.1
99
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0
1010
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2
11+
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2
1112
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1
1213
github.com/ProtonMail/go-crypto v1.1.6
1314
github.com/aws/aws-sdk-go-v2 v1.36.3
@@ -60,6 +61,7 @@ require (
6061
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0 // indirect
6162
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 // indirect
6263
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
64+
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 // indirect
6365
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
6466
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
6567
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
@@ -111,6 +113,7 @@ require (
111113
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
112114
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
113115
github.com/hashicorp/hcl v1.0.0 // indirect
116+
github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 // indirect
114117
github.com/kylelemons/godebug v1.1.0 // indirect
115118
github.com/mattn/go-colorable v0.1.14 // indirect
116119
github.com/mattn/go-isatty v0.0.20 // indirect

0 commit comments

Comments
 (0)