Skip to content

Commit

Permalink
Adds Azure Developer CLI (azd) as a new login method (#398)
Browse files Browse the repository at this point in the history
* WIP: Adding azd credential

* Updates scopes for AAD v2.0 that azd supports

* Adds docs for azd login mode.

* Updated internal comments

* Stores underlying azd credential in token wrapper

* Adds unit tests to validate converter for azd
  • Loading branch information
wbreza authored Feb 6, 2024
1 parent 4c8a6b2 commit 1dc7e82
Show file tree
Hide file tree
Showing 14 changed files with 210 additions and 22 deletions.
1 change: 1 addition & 0 deletions docs/book/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
- [non-interactive user principal login](./concepts/login-modes/ropc.md) using [Resource owner login flow](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth-ropc)
- [non-interactive managed service identity login](./concepts/login-modes/msi.md)
- [non-interactive Azure CLI token login (AKS only)](./concepts/login-modes/azurecli.md)
- [non-interactive Azure Developer CLI token login (AKS only)](./concepts/login-modes/azd.md)
- [non-interactive workload identity login](./concepts/login-modes/workloadidentity.md)
1 change: 1 addition & 0 deletions docs/book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- [Login Modes](./concepts/login-modes.md)
- [Device Code](./concepts/login-modes/devicecode.md)
- [Azure CLI](./concepts/login-modes/azurecli.md)
- [Azure Developer CLI](./concepts/login-modes/azd.md)
- [Web Browser Interactive](./concepts/login-modes/interactive.md)
- [Service Principal](./concepts/login-modes/sp.md)
- [Managed Service Identity](./concepts/login-modes/msi.md)
Expand Down
2 changes: 1 addition & 1 deletion docs/book/src/cli/convert-kubeconfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Flags:
--identity-resource-id string Managed Identity resource id.
--kubeconfig string Path to the kubeconfig file to use for CLI requests.
--legacy set to true to get token with 'spn:' prefix in audience claim
-l, --login string Login method. Supported methods: devicecode, interactive, spn, ropc, msi, azurecli, workloadidentity. It may be specified in AAD_LOGIN_METHOD environment variable (default "devicecode")
-l, --login string Login method. Supported methods: devicecode, interactive, spn, ropc, msi, azurecli, azd, workloadidentity. It may be specified in AAD_LOGIN_METHOD environment variable (default "devicecode")
--password string password for ropc login flow. It may be specified in AAD_USER_PRINCIPAL_PASSWORD or AZURE_PASSWORD environment variable
--pop-enabled set to true to request a proof-of-possession/PoP token, or false to request a regular bearer token. Only works with interactive and spn login modes. --pop-claims must be provided if --pop-enabled is true
--pop-claims claims to include when requesting a PoP token, formatted as a comma-separated string of key=value pairs. Must include the u-claim, `u=ARM_ID` containing the ARM ID of the cluster (host). --pop-enabled must be set to true if --pop-claims are provided
Expand Down
2 changes: 1 addition & 1 deletion docs/book/src/cli/get-token.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ ECRET environment variable
-h, --help help for get-token
--identity-resource-id string Managed Identity resource id.
--legacy set to true to get token with 'spn:' prefix in audience claim
-l, --login string Login method. Supported methods: devicecode, interactive, spn, ropc, msi, azurecli, workloadidentity. It may be specified in A
-l, --login string Login method. Supported methods: devicecode, interactive, spn, ropc, msi, azurecli, azd, workloadidentity. It may be specified in A
AD_LOGIN_METHOD environment variable (default "devicecode")
--password string password for ropc login flow. It may be specified in AAD_USER_PRINCIPAL_PASSWORD or AZURE_PASSWORD environment variable
--pop-enabled set to true to request a proof-of-possession/PoP token, or false to request a regular bearer token. Only works with interactive and spn login modes. --pop-claims must be provided if --pop-enabled is true
Expand Down
27 changes: 27 additions & 0 deletions docs/book/src/concepts/login-modes/azd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Azure Developer CLI (azd)

This login mode uses the already logged-in context performed by Azure Developer CLI to get the access token.
The token will be issued in the same Azure AD tenant as in `azd auth login`.

`kubelogin` will not cache any token since it's already managed by Azure Developer CLI.

> ### NOTE
>
> This login mode only works with managed AAD in AKS.
## Usage Examples

```sh
azd auth login

export KUBECONFIG=/path/to/kubeconfig

kubelogin convert-kubeconfig -l azd

kubectl get nodes
```

## References

- https://learn.microsoft.com/azure/developer/azure-developer-cli/overview
- https://github.com/azure/azure-dev
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.20

require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1
github.com/Azure/go-autorest/autorest v0.11.29
github.com/Azure/go-autorest/autorest/adal v0.9.23
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.1
Expand Down Expand Up @@ -59,12 +59,12 @@ require (
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/oauth2 v0.8.0 // indirect
golang.org/x/sync v0.2.0 // indirect
golang.org/x/sys v0.16.0 // indirect
Expand Down
14 changes: 7 additions & 7 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0 h1:BMAjVKJM0U/CYF27gA0ZMmXGkOcvfFtD0oHVZ1TIPRI=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0/go.mod h1:1fXstnBMas5kzG+S3q8UoJcmyU6nUeunJcMDHcRYHhs=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
Expand Down Expand Up @@ -135,8 +135,8 @@ github.com/onsi/ginkgo/v2 v2.9.4 h1:xR7vG4IXt5RWx6FfIjyAtsoMAtnc3C/rFXBBd2AjZwE=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down Expand Up @@ -200,8 +200,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
Expand All @@ -220,10 +220,10 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
Expand Down
5 changes: 5 additions & 0 deletions pkg/internal/converter/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ func Convert(o Options, pathOptions *clientcmd.PathOptions) error {
}

switch o.TokenOptions.LoginMethod {
case token.AzureDeveloperCLILogin:
if o.isSet(flagTenantID) {
exec.Args = append(exec.Args, argTenantID, o.TokenOptions.TenantID)
}

case token.AzureCLILogin:

if o.azureConfigDir != "" {
Expand Down
38 changes: 38 additions & 0 deletions pkg/internal/converter/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,24 @@ func TestConvert(t *testing.T) {
argLoginMethod, token.AzureCLILogin,
},
},
{
name: "using legacy azure auth to convert to azd",
authProviderConfig: map[string]string{
cfgEnvironment: envName,
cfgApiserverID: serverID,
cfgClientID: clientID,
cfgTenantID: tenantID,
cfgConfigMode: "1",
},
overrideFlags: map[string]string{
flagLoginMethod: token.AzureDeveloperCLILogin,
},
expectedArgs: []string{
getTokenCommand,
argServerID, serverID,
argLoginMethod, token.AzureDeveloperCLILogin,
},
},
{
name: "using legacy azure auth to convert to azurecli with --tenant-id override",
authProviderConfig: map[string]string{
Expand All @@ -341,6 +359,26 @@ func TestConvert(t *testing.T) {
argTenantID, tenantID,
},
},
{
name: "using legacy azure auth to convert to azd with --tenant-id override",
authProviderConfig: map[string]string{
cfgEnvironment: envName,
cfgApiserverID: serverID,
cfgClientID: clientID,
cfgTenantID: tenantID,
cfgConfigMode: "1",
},
overrideFlags: map[string]string{
flagLoginMethod: token.AzureDeveloperCLILogin,
flagTenantID: tenantID,
},
expectedArgs: []string{
getTokenCommand,
argServerID, serverID,
argLoginMethod, token.AzureDeveloperCLILogin,
argTenantID, tenantID,
},
},
{
name: "using legacy azure auth to convert to azurecli with --token-cache-dir override",
authProviderConfig: map[string]string{
Expand Down
87 changes: 87 additions & 0 deletions pkg/internal/token/azuredevelopercli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package token

import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"

"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/go-autorest/autorest/adal"
)

type AzureDeveloperCLIToken struct {
resourceID string
tenantID string
cred *azidentity.AzureDeveloperCLICredential
timeout time.Duration
}

// newAzureDeveloperCLIToken returns a TokenProvider that will fetch a token for the user currently logged into the Azure Developer CLI.
// Required arguments the resourceID (which is used as the scope) and an optional tenantID.
func newAzureDeveloperCLIToken(resourceID string, tenantID string, timeout time.Duration) (TokenProvider, error) {
if resourceID == "" {
return nil, errors.New("resourceID cannot be empty")
}

if timeout <= 0 {
timeout = defaultTimeout
}

// Request a new Azure Developer CLI token provider
cred, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{
TenantID: tenantID,
})
if err != nil {
return nil, fmt.Errorf("unable to create credential. Received: %v", err)
}

return &AzureDeveloperCLIToken{
resourceID: resourceID,
tenantID: tenantID,
cred: cred,
timeout: timeout,
}, nil
}

// Token fetches an azcore.AccessToken from the Azure Developer CLI SDK and converts it to an adal.Token for use with kubelogin.
func (p *AzureDeveloperCLIToken) Token(ctx context.Context) (adal.Token, error) {
emptyToken := adal.Token{}

if p.cred == nil {
return emptyToken, errors.New("credential is nil. Create new instance with newAzureDeveloperCLIToken function")
}

ctx, cancel := context.WithTimeout(ctx, p.timeout)
defer cancel()

policyOptions := policy.TokenRequestOptions{
TenantID: p.tenantID,
Scopes: []string{fmt.Sprintf("%s/.default", p.resourceID)},
}

// Use the token provider to get a new token with the new context
azdAccessToken, err := p.cred.GetToken(ctx, policyOptions)
if err != nil {
return emptyToken, fmt.Errorf("expected an empty error but received: %v", err)
}

if azdAccessToken.Token == "" {
return emptyToken, errors.New("did not receive a token")
}

// azurecore.AccessTokens have ExpiresOn as Time.Time. We need to convert it to JSON.Number
// by fetching the time in seconds since the Unix epoch via Unix() and then converting to a
// JSON.Number via formatting as a string using a base-10 int64 conversion.
expiresOn := json.Number(strconv.FormatInt(azdAccessToken.ExpiresOn.Unix(), 10))

// Re-wrap the azurecore.AccessToken into an adal.Token
return adal.Token{
AccessToken: azdAccessToken.Token,
ExpiresOn: expiresOn,
Resource: p.resourceID,
}, nil
}
26 changes: 26 additions & 0 deletions pkg/internal/token/azuredevelopercli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package token

import (
"context"
"testing"

"github.com/Azure/kubelogin/pkg/internal/testutils"
)

func TestNewAzureDeveloperCLITokenEmpty(t *testing.T) {
// Using default timeout for testing
_, err := newAzureDeveloperCLIToken("", "", defaultTimeout)

if !testutils.ErrorContains(err, "resourceID cannot be empty") {
t.Errorf("unexpected error: %v", err)
}
}

func TestNewAzureDeveloperCLIToken(t *testing.T) {
azd := AzureDeveloperCLIToken{}
_, err := azd.Token(context.TODO())

if !testutils.ErrorContains(err, "credential is nil") {
t.Errorf("unexpected error: %v", err)
}
}
2 changes: 1 addition & 1 deletion pkg/internal/token/execCredentialPlugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func New(o *Options) (ExecCredentialPlugin, error) {
return nil, err
}
disableTokenCache := false
if o.LoginMethod == ServicePrincipalLogin || o.LoginMethod == MSILogin || o.LoginMethod == WorkloadIdentityLogin || o.LoginMethod == AzureCLILogin {
if o.LoginMethod == ServicePrincipalLogin || o.LoginMethod == MSILogin || o.LoginMethod == WorkloadIdentityLogin || o.LoginMethod == AzureCLILogin || o.LoginMethod == AzureDeveloperCLILogin {
disableTokenCache = true
}
return &execCredentialPlugin{
Expand Down
19 changes: 10 additions & 9 deletions pkg/internal/token/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,15 @@ type Options struct {
const (
defaultEnvironmentName = "AzurePublicCloud"

DeviceCodeLogin = "devicecode"
InteractiveLogin = "interactive"
ServicePrincipalLogin = "spn"
ROPCLogin = "ropc"
MSILogin = "msi"
AzureCLILogin = "azurecli"
WorkloadIdentityLogin = "workloadidentity"
manualTokenLogin = "manual_token"
DeviceCodeLogin = "devicecode"
InteractiveLogin = "interactive"
ServicePrincipalLogin = "spn"
ROPCLogin = "ropc"
MSILogin = "msi"
AzureCLILogin = "azurecli"
AzureDeveloperCLILogin = "azd"
WorkloadIdentityLogin = "workloadidentity"
manualTokenLogin = "manual_token"
)

var (
Expand All @@ -54,7 +55,7 @@ var (
)

func init() {
supportedLogin = []string{DeviceCodeLogin, InteractiveLogin, ServicePrincipalLogin, ROPCLogin, MSILogin, AzureCLILogin, WorkloadIdentityLogin}
supportedLogin = []string{DeviceCodeLogin, InteractiveLogin, ServicePrincipalLogin, ROPCLogin, MSILogin, AzureCLILogin, AzureDeveloperCLILogin, WorkloadIdentityLogin}
}

func GetSupportedLogins() string {
Expand Down
2 changes: 2 additions & 0 deletions pkg/internal/token/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func NewTokenProvider(o *Options) (TokenProvider, error) {
return newAzureCLIToken(o.ServerID, o.TenantID, o.Timeout)
case WorkloadIdentityLogin:
return newWorkloadIdentityToken(o.ClientID, o.FederatedTokenFile, o.AuthorityHost, o.ServerID, o.TenantID)
case AzureDeveloperCLILogin:
return newAzureDeveloperCLIToken(o.ServerID, o.TenantID, o.Timeout)
}

return nil, errors.New("unsupported token provider")
Expand Down

0 comments on commit 1dc7e82

Please sign in to comment.