@@ -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 (
2532const (
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
3042var (
@@ -230,7 +242,142 @@ func (key *MasterKey) TypeToIdentifier() string {
230242// azidentity.NewDefaultAzureCredential.
231243func (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+ }
0 commit comments