diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 94d6f408..2f8670e5 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -14,6 +14,7 @@ - [Workload Identity](./concepts/login-modes/workloadidentity.md) - [Resource Owner Password Credential](./concepts/login-modes/ropc.md) - [Using kubelogin with AKS](./concepts/aks.md) + - [Using kubelogin to get Proof-of-Possession (PoP) tokens for Azure Arc](./concepts/azure-arc.md) - [Command-Line Tool](./cli-reference.md) - [convert-kubeconfig](./cli/convert-kubeconfig.md) - [get-token](./cli/get-token.md) diff --git a/docs/book/src/cli/convert-kubeconfig.md b/docs/book/src/cli/convert-kubeconfig.md index 816974f3..22e61b17 100644 --- a/docs/book/src/cli/convert-kubeconfig.md +++ b/docs/book/src/cli/convert-kubeconfig.md @@ -29,6 +29,8 @@ Flags: --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") --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 --server-id string AAD server application ID -t, --tenant-id string AAD tenant ID. It may be specified in AZURE_TENANT_ID environment variable --token-cache-dir string directory to cache token (default "${HOME}/.kube/cache/kubelogin/") diff --git a/docs/book/src/cli/get-token.md b/docs/book/src/cli/get-token.md index 7217488c..33e6dbe0 100644 --- a/docs/book/src/cli/get-token.md +++ b/docs/book/src/cli/get-token.md @@ -28,6 +28,8 @@ ECRET environment variable -l, --login string Login method. Supported methods: devicecode, interactive, spn, ropc, msi, azurecli, 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 + --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 --server-id string AAD server application ID -t, --tenant-id string AAD tenant ID. It may be specified in AZURE_TENANT_ID environment variable --token-cache-dir string directory to cache token (default "${HOME}/.kube/cache/kubelogin/") diff --git a/docs/book/src/concepts/azure-arc.md b/docs/book/src/concepts/azure-arc.md new file mode 100644 index 00000000..512d136f --- /dev/null +++ b/docs/book/src/concepts/azure-arc.md @@ -0,0 +1,10 @@ +# Using kubelogin with Azure Arc + +kubelogin can be used to authenticate with Azure Arc-enabled clusters by requesting a [proof-of-possession (PoP) token](https://learn.microsoft.com/en-us/entra/msal/dotnet/advanced/proof-of-possession-tokens). This can be done by providing both of the following flags together: + +1. `--pop-enabled`: indicates that `kubelogin` should request a PoP token instead of a regular bearer token +2. `--pop-claims`: is a comma-separated list of `key=value` claims to include in the PoP token. At minimum, this must include the u-claim as `u=ARM_ID_OF_CLUSTER`, which specifies the host that the requested token should allow access on. + +These flags can be provided to either `kubelogin get-token` directly to get a PoP token, or to `kubelogin convert-kubeconfig` for `kubectl` to request the token internally. + +PoP token requests only work with `interactive` and `spn` login modes; these flags will be ignored if provided for other login modes. diff --git a/docs/book/src/concepts/login-modes/interactive.md b/docs/book/src/concepts/login-modes/interactive.md index faa29c5e..6158888a 100644 --- a/docs/book/src/concepts/login-modes/interactive.md +++ b/docs/book/src/concepts/login-modes/interactive.md @@ -8,6 +8,7 @@ In this login mode, the access token will be cached at `${HOME}/.kube/cache/kube ## Usage Examples +### Bearer token with interactive flow ```sh export KUBECONFIG=/path/to/kubeconfig @@ -16,6 +17,15 @@ kubelogin convert-kubeconfig -l interactive kubectl get nodes ``` +### Proof-of-possession (PoP) token with interactive flow +```sh +export KUBECONFIG=/path/to/kubeconfig + +kubelogin convert-kubeconfig -l interactive --pop-enabled --pop-claims "u=/ARM/ID/OF/CLUSTER" + +kubectl get nodes +``` + ## References - https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.interactivebrowsercredential?view=azure-python diff --git a/docs/book/src/concepts/login-modes/sp.md b/docs/book/src/concepts/login-modes/sp.md index 3b56e142..bd4abf8f 100644 --- a/docs/book/src/concepts/login-modes/sp.md +++ b/docs/book/src/concepts/login-modes/sp.md @@ -74,6 +74,18 @@ export AZURE_CLIENT_CERTIFICATE_PASSWORD= kubectl get nodes ``` +### Proof-of-possession (PoP) token with client secret from environment variables +```sh +export KUBECONFIG=/path/to/kubeconfig + +kubelogin convert-kubeconfig -l spn --pop-enabled --pop-claims "u=/ARM/ID/OF/CLUSTER" + +export AAD_SERVICE_PRINCIPAL_CLIENT_ID= +export AAD_SERVICE_PRINCIPAL_CLIENT_SECRET= + +kubectl get nodes +``` + ## Restrictions - on AKS, it will only work with managed AAD diff --git a/go.mod b/go.mod index 5cf487db..4ea32430 100644 --- a/go.mod +++ b/go.mod @@ -7,8 +7,11 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 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.0.0 + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 + github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/golang-jwt/jwt/v5 v5.0.0 github.com/golang/mock v1.6.0 + github.com/google/go-cmp v0.5.9 github.com/google/uuid v1.3.0 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 @@ -36,11 +39,9 @@ require ( github.com/go-openapi/jsonreference v0.20.1 // indirect github.com/go-openapi/swag v0.22.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/btree v1.1.2 // indirect github.com/google/gnostic v0.6.9 // indirect - github.com/google/go-cmp v0.5.9 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect diff --git a/go.sum b/go.sum index 3c703910..f2acad81 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 h1:OBhqkivkhkMqLPymWEppkm7vgPQY2XsHoEkaMQ0AdZY= -github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 h1:hVeq+yCyUi+MsoO/CU95yqCIcdzra5ovzk8Q2BBpV2M= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -74,6 +74,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= diff --git a/pkg/converter/convert.go b/pkg/converter/convert.go index b59b9eab..6202658e 100644 --- a/pkg/converter/convert.go +++ b/pkg/converter/convert.go @@ -33,6 +33,8 @@ const ( argAuthorityHost = "--authority-host" argFederatedTokenFile = "--federated-token-file" argTokenCacheDir = "--token-cache-dir" + argIsPoPTokenEnabled = "--pop-enabled" + argPoPTokenClaims = "--pop-claims" flagAzureConfigDir = "azure-config-dir" flagClientID = "client-id" @@ -51,6 +53,8 @@ const ( flagAuthorityHost = "authority-host" flagFederatedTokenFile = "federated-token-file" flagTokenCacheDir = "token-cache-dir" + flagIsPoPTokenEnabled = "pop-enabled" + flagPoPTokenClaims = "pop-claims" execName = "kubelogin" getTokenCommand = "get-token" @@ -64,7 +68,16 @@ To learn more, please go to https://azure.github.io/kubelogin/ azureConfigDir = "AZURE_CONFIG_DIR" ) -func getArgValues(o Options, authInfo *api.AuthInfo) (argServerIDVal, argClientIDVal, argEnvironmentVal, argTenantIDVal, argTokenCacheDirVal string, argIsLegacyConfigModeVal bool) { +func getArgValues(o Options, authInfo *api.AuthInfo) ( + argServerIDVal, + argClientIDVal, + argEnvironmentVal, + argTenantIDVal, + argTokenCacheDirVal, + argPoPTokenClaimsVal string, + argIsLegacyConfigModeVal, + argIsPoPTokenEnabledVal bool, +) { if authInfo == nil { return } @@ -129,6 +142,20 @@ func getArgValues(o Options, authInfo *api.AuthInfo) (argServerIDVal, argClientI argTokenCacheDirVal = getExecArg(authInfo, argTokenCacheDir) } + if o.isSet(flagIsPoPTokenEnabled) { + argIsPoPTokenEnabledVal = o.TokenOptions.IsPoPTokenEnabled + } else { + if found := getExecBoolArg(authInfo, argIsPoPTokenEnabled); found { + argIsPoPTokenEnabledVal = true + } + } + + if o.isSet(flagPoPTokenClaims) { + argPoPTokenClaimsVal = o.TokenOptions.PoPTokenClaims + } else { + argPoPTokenClaimsVal = getExecArg(authInfo, argPoPTokenClaims) + } + return } @@ -198,7 +225,7 @@ func Convert(o Options, pathOptions *clientcmd.PathOptions) error { klog.V(5).Info("converting...") - argServerIDVal, argClientIDVal, argEnvironmentVal, argTenantIDVal, argTokenCacheDirVal, isLegacyConfigMode := getArgValues(o, authInfo) + argServerIDVal, argClientIDVal, argEnvironmentVal, argTenantIDVal, argTokenCacheDirVal, argPoPTokenClaimsVal, isLegacyConfigMode, isPoPTokenEnabled := getArgValues(o, authInfo) exec := &api.ExecConfig{ Command: execName, Args: []string{ @@ -282,6 +309,12 @@ func Convert(o Options, pathOptions *clientcmd.PathOptions) error { exec.Args = append(exec.Args, argEnvironment, argEnvironmentVal) } + // PoP token flags are optional but must be provided together + exec.Args, err = validatePoPClaims(exec.Args, isPoPTokenEnabled, argPoPTokenClaims, argPoPTokenClaimsVal) + if err != nil { + return err + } + case token.ServicePrincipalLogin: if argClientIDVal == "" { @@ -317,6 +350,12 @@ func Convert(o Options, pathOptions *clientcmd.PathOptions) error { exec.Args = append(exec.Args, argIsLegacy) } + // PoP token flags are optional but must be provided together + exec.Args, err = validatePoPClaims(exec.Args, isPoPTokenEnabled, argPoPTokenClaims, argPoPTokenClaimsVal) + if err != nil { + return err + } + case token.MSILogin: if o.isSet(flagClientID) { @@ -420,3 +459,25 @@ func getExecBoolArg(authInfoPtr *api.AuthInfo, someArg string) bool { } return false } + +// If enabling PoP token support, users must provide both "--pop-enabled" and "--pop-claims" flags together. +// If either is provided without the other, validation should throw an error, otherwise the get-token command +// will fail under the hood. +func validatePoPClaims(args []string, isPopTokenEnabled bool, popTokenClaimsFlag, popTokenClaimsVal string) ([]string, error) { + if isPopTokenEnabled && popTokenClaimsVal == "" { + // pop-enabled and pop-claims must be provided together + return args, fmt.Errorf("%s is required when specifying %s", argPoPTokenClaims, argIsPoPTokenEnabled) + } + + if popTokenClaimsVal != "" && !isPopTokenEnabled { + // pop-enabled and pop-claims must be provided together + return args, fmt.Errorf("%s is required when specifying %s", argIsPoPTokenEnabled, argPoPTokenClaims) + } + + if isPopTokenEnabled && popTokenClaimsVal != "" { + args = append(args, argIsPoPTokenEnabled) + args = append(args, popTokenClaimsFlag, popTokenClaimsVal) + } + + return args, nil +} diff --git a/pkg/converter/convert_test.go b/pkg/converter/convert_test.go index 0d9e6847..ccff5580 100644 --- a/pkg/converter/convert_test.go +++ b/pkg/converter/convert_test.go @@ -1186,6 +1186,117 @@ func TestConvert(t *testing.T) { }, }, }, + { + name: "with exec format kubeconfig, convert from devicecode to interactive with only pop-enabled specified, Convert should return error", + execArgItems: []string{ + getTokenCommand, + argServerID, serverID, + argClientID, clientID, + argTenantID, tenantID, + argEnvironment, envName, + argLoginMethod, token.DeviceCodeLogin, + argIsPoPTokenEnabled, + }, + overrideFlags: map[string]string{ + flagLoginMethod: token.InteractiveLogin, + }, + command: execName, + expectedError: "--pop-claims is required when specifying --pop-enabled", + }, + { + name: "with exec format kubeconfig, convert from devicecode to interactive with only pop-claims specified, Convert should return error", + execArgItems: []string{ + getTokenCommand, + argServerID, serverID, + argClientID, clientID, + argTenantID, tenantID, + argEnvironment, envName, + argLoginMethod, token.DeviceCodeLogin, + argPoPTokenClaims, "u=testhost", + }, + overrideFlags: map[string]string{ + flagLoginMethod: token.InteractiveLogin, + }, + command: execName, + expectedError: "--pop-enabled is required when specifying --pop-claims", + }, + { + name: "with exec format kubeconfig, convert from devicecode to interactive with pop-enabled and pop-claims", + execArgItems: []string{ + getTokenCommand, + argServerID, serverID, + argClientID, clientID, + argTenantID, tenantID, + argEnvironment, envName, + argLoginMethod, token.DeviceCodeLogin, + argIsPoPTokenEnabled, + argPoPTokenClaims, "u=testhost, 1=2", + }, + overrideFlags: map[string]string{ + flagLoginMethod: token.InteractiveLogin, + }, + expectedArgs: []string{ + getTokenCommand, + argServerID, serverID, + argClientID, clientID, + argTenantID, tenantID, + argEnvironment, envName, + argLoginMethod, token.InteractiveLogin, + argIsPoPTokenEnabled, + argPoPTokenClaims, "u=testhost, 1=2", + }, + command: execName, + }, + { + name: "with exec format kubeconfig, convert from devicecode to spn with pop-enabled and pop-claims as flags", + execArgItems: []string{ + getTokenCommand, + argServerID, serverID, + argClientID, clientID, + argTenantID, tenantID, + argEnvironment, envName, + argLoginMethod, token.DeviceCodeLogin, + }, + overrideFlags: map[string]string{ + flagLoginMethod: token.ServicePrincipalLogin, + flagIsPoPTokenEnabled: "true", + flagPoPTokenClaims: "u=testhost, 1=2", + }, + expectedArgs: []string{ + getTokenCommand, + argEnvironment, envName, + argServerID, serverID, + argTenantID, tenantID, + argClientID, clientID, + argLoginMethod, token.ServicePrincipalLogin, + argIsPoPTokenEnabled, + argPoPTokenClaims, "u=testhost, 1=2", + }, + command: execName, + }, + { + name: "with exec format kubeconfig, convert from azurecli to devicecode with pop-enabled and pop-claims, expect pop args to be ignored", + execArgItems: []string{ + getTokenCommand, + argServerID, serverID, + argLoginMethod, token.AzureCLILogin, + argIsPoPTokenEnabled, + argPoPTokenClaims, "u=testhost, 1=2", + }, + overrideFlags: map[string]string{ + flagClientID: clientID, + flagTenantID: tenantID, + flagLoginMethod: token.DeviceCodeLogin, + }, + expectedArgs: []string{ + getTokenCommand, + argServerID, serverID, + argClientID, clientID, + argTenantID, tenantID, + argLoginMethod, token.DeviceCodeLogin, + }, + command: execName, + }, } rootTmpDir, err := os.MkdirTemp("", "kubelogin-test") if err != nil { @@ -1236,20 +1347,23 @@ func TestConvert(t *testing.T) { err = Convert(o, &pathOptions) if data.expectedError == "" && err != nil { t.Fatalf("Unexpected error from Convert: %v", err) - } else if data.expectedError != "" && (err == nil || err.Error() != data.expectedError) { - t.Fatalf("Expected error: %q, but got: %q", data.expectedError, err) - } - - if o.context != "" { - // when --context is specified, convert-kubeconfig will convert only the targeted context - // hence, we expect the second auth info not to change - validate(t, clusterName1, config.AuthInfos[clusterName1], data.authProviderConfig, data.expectedArgs, data.expectedExecName, data.expectedInstallHint, data.expectedEnv) - validateAuthInfoThatShouldNotChange(t, clusterName2, config.AuthInfos[clusterName2], data.authProviderConfig) + } else if data.expectedError != "" { + if err == nil || err.Error() != data.expectedError { + t.Fatalf("Expected error: %q, but got: %q", data.expectedError, err) + } } else { - // when --context is not specified, convert-kubeconfig will convert every auth info in the kubeconfig - // hence, we expect the second auth info to be converted in the same way as the first one - validate(t, clusterName1, config.AuthInfos[clusterName1], data.authProviderConfig, data.expectedArgs, data.expectedExecName, data.expectedInstallHint, data.expectedEnv) - validate(t, clusterName2, config.AuthInfos[clusterName2], data.authProviderConfig, data.expectedArgs, data.expectedExecName, data.expectedInstallHint, data.expectedEnv) + // only need to validate fields if we're not expecting an error + if o.context != "" { + // when --context is specified, convert-kubeconfig will convert only the targeted context + // hence, we expect the second auth info not to change + validate(t, clusterName1, config.AuthInfos[clusterName1], data.authProviderConfig, data.expectedArgs, data.expectedExecName, data.expectedInstallHint, data.expectedEnv) + validateAuthInfoThatShouldNotChange(t, clusterName2, config.AuthInfos[clusterName2], data.authProviderConfig) + } else { + // when --context is not specified, convert-kubeconfig will convert every auth info in the kubeconfig + // hence, we expect the second auth info to be converted in the same way as the first one + validate(t, clusterName1, config.AuthInfos[clusterName1], data.authProviderConfig, data.expectedArgs, data.expectedExecName, data.expectedInstallHint, data.expectedEnv) + validate(t, clusterName2, config.AuthInfos[clusterName2], data.authProviderConfig, data.expectedArgs, data.expectedExecName, data.expectedInstallHint, data.expectedEnv) + } } }) } @@ -1333,7 +1447,7 @@ func validate( t.Fatalf("[context:%s]: expected exec command: %s, actual: %s", clusterName, expectedExecName, exec.Command) } - // defautl to the kubelogin install hint + // default to the kubelogin install hint if expectedInstallHint == "" { expectedInstallHint = execInstallHint } diff --git a/pkg/pop/authnscheme.go b/pkg/pop/authnscheme.go new file mode 100644 index 00000000..d1e3ba78 --- /dev/null +++ b/pkg/pop/authnscheme.go @@ -0,0 +1,155 @@ +// Disclaimer: The PoPAuthenticationScheme implementation of the MSAL AuthenticationScheme +// interface is intended for the usage of Azure Arc. + +package pop + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +// type of a PoP token, as opposed to "JWT" for a regular bearer token +const popTokenType = "pop" + +// PoPAuthenticationScheme is a PoP token implementation of the MSAL AuthenticationScheme interface +// used by the Azure Arc Platform team. +// This implementation will only use the passed-in u-claim (representing the ARM ID of the +// cluster/host); other claims passed in during a PoP token request will be disregarded +type PoPAuthenticationScheme struct { + // host is the u claim we will add on the pop token + Host string + PoPKey PoPKey +} + +// TokenRequestParams returns the params to use when sending a request for a PoP token +func (as *PoPAuthenticationScheme) TokenRequestParams() map[string]string { + return map[string]string{ + "token_type": popTokenType, + "req_cnf": as.PoPKey.ReqCnf(), + } +} + +// KeyID returns the key used to sign the PoP token +func (as *PoPAuthenticationScheme) KeyID() string { + return as.PoPKey.KeyID() +} + +// FormatAccessToken takes an access token, formats it as a PoP token, +// and returns it as a base-64 encoded string +func (as *PoPAuthenticationScheme) FormatAccessToken(accessToken string) (string, error) { + timestamp := time.Now().Unix() + nonce := uuid.NewString() + nonce = strings.ReplaceAll(nonce, "-", "") + + return as.FormatAccessTokenWithOptions(accessToken, nonce, timestamp) +} + +// FormatAccessTokenWithOptions takes an access token, nonce, and timestamp, formats +// the token as a PoP token containing the given fields, and returns it as a +// base-64 encoded string +func (as *PoPAuthenticationScheme) FormatAccessTokenWithOptions(accessToken, nonce string, timestamp int64) (string, error) { + header := header{ + typ: popTokenType, + alg: as.PoPKey.Alg(), + kid: as.PoPKey.KeyID(), + } + payload := payload{ + at: accessToken, + ts: timestamp, + host: as.Host, + jwk: as.PoPKey.JWK(), + nonce: nonce, + } + + popAccessToken, err := createPoPAccessToken(header, payload, as.PoPKey) + if err != nil { + return "", fmt.Errorf("error formatting PoP token: %w", err) + } + return popAccessToken.ToBase64(), nil +} + +// AccessTokenType returns the PoP access token type +func (as *PoPAuthenticationScheme) AccessTokenType() string { + return popTokenType +} + +// type representing the header of a PoP access token +type header struct { + typ string + alg string + kid string +} + +// ToString returns a string representation of a header object +func (h *header) ToString() string { + return fmt.Sprintf(`{"typ":"%s","alg":"%s","kid":"%s"}`, h.typ, h.alg, h.kid) +} + +// ToBase64 returns a base-64 encoded string representation of a header object +func (h *header) ToBase64() string { + return base64.RawURLEncoding.EncodeToString([]byte(h.ToString())) +} + +// type representing the payload of a PoP token +type payload struct { + at string + ts int64 + host string + jwk string + nonce string +} + +// ToString returns a string representation of a payload object +func (p *payload) ToString() string { + return fmt.Sprintf(`{"at":"%s","ts":%d,"u":"%s","cnf":{"jwk":%s},"nonce":"%s"}`, p.at, p.ts, p.host, p.jwk, p.nonce) +} + +// ToBase64 returns a base-64 encoded representation of a payload object +func (p *payload) ToBase64() string { + return base64.RawURLEncoding.EncodeToString([]byte(p.ToString())) +} + +// type representing the signature of a PoP token +type signature struct { + sig []byte +} + +// ToBase64 returns a base-64 encoded representation of a signature object +func (s *signature) ToBase64() string { + return base64.RawURLEncoding.EncodeToString(s.sig) +} + +// type representing a PoP access token +type popAccessToken struct { + Header header + Payload payload + Signature signature +} + +// given a header, payload, and PoP key, creates the signature for the token and returns +// a PoPAccessToken object representing the signed token +func createPoPAccessToken(h header, p payload, popKey PoPKey) (*popAccessToken, error) { + token := &popAccessToken{ + Header: h, + Payload: p, + } + h256 := sha256.Sum256([]byte(h.ToBase64() + "." + p.ToBase64())) + sig, err := popKey.Sign(h256[:]) + if err != nil { + return nil, err + } + token.Signature = signature{ + sig: sig, + } + return token, nil +} + +// ToBase64 returns a base-64 encoded representation of a PoP access token +func (p *popAccessToken) ToBase64() string { + return fmt.Sprintf("%s.%s.%s", p.Header.ToBase64(), p.Payload.ToBase64(), p.Signature.ToBase64()) +} diff --git a/pkg/pop/authnscheme_test.go b/pkg/pop/authnscheme_test.go new file mode 100644 index 00000000..983803df --- /dev/null +++ b/pkg/pop/authnscheme_test.go @@ -0,0 +1,128 @@ +package pop + +import ( + "crypto/rand" + "crypto/rsa" + "math" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" +) + +func TestAuthnScheme(t *testing.T) { + t.Run("FormatAccessTokenWithOptions should return a correctly formatted PoP token", func(t *testing.T) { + accessToken := uuid.NewString() + timestamp := time.Now().Unix() + nonce := uuid.NewString() + nonce = strings.ReplaceAll(nonce, "-", "") + host := "testresource" + popKey, err := GetSwPoPKey() + if err != nil { + t.Errorf("expected no error but got: %s", err) + } + authnScheme := &PoPAuthenticationScheme{ + Host: host, + PoPKey: popKey, + } + + formatted, err := authnScheme.FormatAccessTokenWithOptions(accessToken, nonce, timestamp) + if err != nil { + t.Errorf("expected no error but got: %s", err) + } + claims := jwt.MapClaims{} + parsed, _ := jwt.ParseWithClaims(formatted, &claims, func(token *jwt.Token) (interface{}, error) { + return authnScheme.PoPKey.KeyID(), nil + }) + if claims["at"] != accessToken { + t.Errorf("expected access token: %s but got: %s", accessToken, claims["at"]) + } + if claims["u"] != host { + t.Errorf("expected u-claim value: %s but got: %s", host, claims["u"]) + } + ts := int64(math.Round(claims["ts"].(float64))) + if ts != timestamp { + t.Errorf("expected timestamp value: %d but got: %d", timestamp, ts) + } + if claims["nonce"] != nonce { + t.Errorf("expected nonce value: %s but got: %s", nonce, claims["nonce"]) + } + if parsed.Header["typ"] != popTokenType { + t.Errorf("expected token type: %s but got: %s", popTokenType, parsed.Header["typ"]) + } + if parsed.Header["alg"] != authnScheme.PoPKey.Alg() { + t.Errorf("expected token alg: %s but got: %s", authnScheme.PoPKey.Alg(), parsed.Header["alg"]) + } + if parsed.Header["kid"] != authnScheme.KeyID() { + t.Errorf("expected token kid: %s but got: %s", authnScheme.PoPKey.KeyID(), parsed.Header["kid"]) + } + + header := header{ + typ: popTokenType, + alg: authnScheme.PoPKey.Alg(), + kid: authnScheme.PoPKey.KeyID(), + } + payload := payload{ + at: accessToken, + ts: timestamp, + host: host, + jwk: authnScheme.PoPKey.JWK(), + nonce: nonce, + } + popAccessToken, err := createPoPAccessToken(header, payload, authnScheme.PoPKey) + if err != nil { + t.Errorf("expected no error but got: %s", err) + } + if parsed.Signature != popAccessToken.Signature.ToBase64() { + t.Errorf("expected token signature: %s but got: %s", popAccessToken.Signature.ToBase64(), parsed.Signature) + } + }) + + t.Run("TokenRequestParams should return correct token_type and req_cnf claims", func(t *testing.T) { + host := "testresource" + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Errorf("expected no error generating RSA key but got: %s", err) + } + popKey, err := GetSwPoPKeyWithRSAKey(rsaKey) + if err != nil { + t.Errorf("expected no error but got: %s", err) + } + authnScheme := &PoPAuthenticationScheme{ + Host: host, + PoPKey: popKey, + } + tokenRequestParams := authnScheme.TokenRequestParams() + + // validate token type + if tokenRequestParams["token_type"] != "pop" { + t.Errorf("expected req_cnf: %s but got: %s", "pop", tokenRequestParams["token_type"]) + } + + // validate req_cnf + eB64, nB64 := getRSAKeyExponentAndModulus(popKey.key) + jwktp := computeJWKThumbprint(eB64, nB64) + expectedReqCnf := getReqCnf(jwktp) + if tokenRequestParams["req_cnf"] != expectedReqCnf { + t.Errorf("expected req_cnf: %s but got: %s", expectedReqCnf, tokenRequestParams["req_cnf"]) + } + }) + + t.Run("AccessTokenType should return correct type", func(t *testing.T) { + host := "testresource" + popKey, err := GetSwPoPKey() + if err != nil { + t.Errorf("expected no error but got: %s", err) + } + authnScheme := &PoPAuthenticationScheme{ + Host: host, + PoPKey: popKey, + } + + if authnScheme.AccessTokenType() != "pop" { + t.Errorf("expected req_cnf: %s but got: %s", "pop", authnScheme.AccessTokenType()) + } + }) +} diff --git a/pkg/pop/msal.go b/pkg/pop/msal.go new file mode 100644 index 00000000..706ea7a6 --- /dev/null +++ b/pkg/pop/msal.go @@ -0,0 +1,120 @@ +package pop + +import ( + "context" + "fmt" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential" + "github.com/AzureAD/microsoft-authentication-library-for-go/apps/public" +) + +// AcquirePoPTokenInteractive acquires a PoP token using MSAL's interactive login flow. +// Requires user to authenticate via browser +func AcquirePoPTokenInteractive( + context context.Context, + popClaims map[string]string, + scopes []string, + authority, + clientID string, + options *azcore.ClientOptions, +) (string, int64, error) { + var client public.Client + var err error + if options != nil && options.Transport != nil { + client, err = public.New( + clientID, + public.WithAuthority(authority), + public.WithHTTPClient(options.Transport.(*http.Client)), + ) + } else { + client, err = public.New( + clientID, + public.WithAuthority(authority), + ) + } + if err != nil { + return "", -1, fmt.Errorf("unable to create public client: %w", err) + } + + popKey, err := GetSwPoPKey() + if err != nil { + return "", -1, err + } + result, err := client.AcquireTokenInteractive( + context, + scopes, + public.WithAuthenticationScheme( + &PoPAuthenticationScheme{ + Host: popClaims["u"], + PoPKey: popKey, + }, + ), + ) + if err != nil { + return "", -1, fmt.Errorf("failed to create PoP token with interactive flow: %w", err) + } + + return result.AccessToken, result.ExpiresOn.Unix(), nil +} + +// AcquirePoPTokenConfidential acquires a PoP token using MSAL's confidential login flow. +// This flow does not require user interaction as the credentials for the request have +// already been provided +func AcquirePoPTokenConfidential( + context context.Context, + popClaims map[string]string, + scopes []string, + cred confidential.Credential, + authority, + clientID, + tenantID string, + options *azcore.ClientOptions, +) (string, int64, error) { + popKey, err := GetSwPoPKey() + if err != nil { + return "", -1, err + } + authnScheme := &PoPAuthenticationScheme{ + Host: popClaims["u"], + PoPKey: popKey, + } + var client confidential.Client + if options != nil && options.Transport != nil { + client, err = confidential.New( + authority, + clientID, + cred, + confidential.WithHTTPClient(options.Transport.(*http.Client)), + ) + } else { + client, err = confidential.New( + authority, + clientID, + cred, + ) + } + if err != nil { + return "", -1, fmt.Errorf("unable to create confidential client: %w", err) + } + result, err := client.AcquireTokenSilent( + context, + scopes, + confidential.WithAuthenticationScheme(authnScheme), + confidential.WithTenantID(tenantID), + ) + if err != nil { + result, err = client.AcquireTokenByCredential( + context, + scopes, + confidential.WithAuthenticationScheme(authnScheme), + confidential.WithTenantID(tenantID), + ) + if err != nil { + return "", -1, fmt.Errorf("failed to create service principal PoP token using secret: %w", err) + } + } + + return result.AccessToken, result.ExpiresOn.Unix(), nil +} diff --git a/pkg/pop/msal_test.go b/pkg/pop/msal_test.go new file mode 100644 index 00000000..4523e27c --- /dev/null +++ b/pkg/pop/msal_test.go @@ -0,0 +1,144 @@ +package pop + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/kubelogin/pkg/testutils" + "github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "gopkg.in/dnaeon/go-vcr.v3/recorder" +) + +type confidentialTokenVars struct { + clientID string + clientSecret string + resourceID string + tenantID string + cloud cloud.Configuration + popClaims map[string]string +} + +func TestAcquirePoPTokenConfidential(t *testing.T) { + pEnv := &confidentialTokenVars{ + clientID: os.Getenv(testutils.ClientID), + clientSecret: os.Getenv(testutils.ClientSecret), + resourceID: os.Getenv(testutils.ResourceID), + tenantID: os.Getenv(testutils.TenantID), + } + // Use defaults if environmental variables are empty + if pEnv.clientID == "" { + pEnv.clientID = testutils.ClientID + } + if pEnv.clientSecret == "" { + pEnv.clientSecret = testutils.ClientSecret + } + if pEnv.resourceID == "" { + pEnv.resourceID = testutils.ResourceID + } + if pEnv.tenantID == "" { + pEnv.tenantID = "00000000-0000-0000-0000-000000000000" + } + ctx := context.Background() + scopes := []string{pEnv.resourceID + "/.default"} + authority := "https://login.microsoftonline.com/" + pEnv.tenantID + var expectedToken string + var token string + expectedTokenType := "pop" + testCase := []struct { + cassetteName string + p *confidentialTokenVars + expectedError error + useSecret bool + }{ + { + // Test using bad client secret + cassetteName: "AcquirePoPTokenConfidentialFromBadSecretVCR", + p: &confidentialTokenVars{ + clientID: pEnv.clientID, + clientSecret: testutils.BadSecret, + resourceID: pEnv.resourceID, + tenantID: pEnv.tenantID, + popClaims: map[string]string{"u": "testhost"}, + cloud: cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/AZURE_TENANT_ID", + }, + }, + expectedError: fmt.Errorf("failed to create service principal PoP token using secret"), + useSecret: true, + }, + { + // Test using service principal secret value to get PoP token + cassetteName: "AcquirePoPTokenConfidentialWithSecretVCR", + p: &confidentialTokenVars{ + clientID: pEnv.clientID, + clientSecret: pEnv.clientSecret, + resourceID: pEnv.resourceID, + tenantID: pEnv.tenantID, + popClaims: map[string]string{"u": "testhost"}, + cloud: cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/AZURE_TENANT_ID", + }, + }, + expectedError: nil, + useSecret: true, + }, + } + + for _, tc := range testCase { + t.Run(tc.cassetteName, func(t *testing.T) { + if tc.expectedError == nil { + expectedToken = uuid.New().String() + } + vcrRecorder, httpClient := testutils.GetVCRHttpClient(fmt.Sprintf("testdata/%s", tc.cassetteName), expectedToken) + + clientOpts := azcore.ClientOptions{ + Cloud: cloud.AzurePublic, + Transport: httpClient, + } + + cred, err := confidential.NewCredFromSecret(tc.p.clientSecret) + if err != nil { + t.Errorf("expected no error creating credential but got: %s", err) + } + + token, _, err = AcquirePoPTokenConfidential( + ctx, + tc.p.popClaims, + scopes, + cred, + authority, + tc.p.clientID, + tc.p.tenantID, + &clientOpts, + ) + defer vcrRecorder.Stop() + if tc.expectedError != nil { + if !testutils.ErrorContains(err, tc.expectedError.Error()) { + t.Errorf("expected error %s, but got %s", tc.expectedError.Error(), err) + } + } else if err != nil { + t.Errorf("expected no error, but got: %s", err) + } else { + if token == "" { + t.Error("expected valid token, but received empty token.") + } + claims := jwt.MapClaims{} + parsed, _ := jwt.ParseWithClaims(token, &claims, nil) + if vcrRecorder.Mode() == recorder.ModeReplayOnly { + if claims["at"] != expectedToken { + t.Errorf("unexpected token returned (expected %s, but got %s)", expectedToken, claims["at"]) + } + if parsed.Header["typ"] != expectedTokenType { + t.Errorf("unexpected token returned (expected %s, but got %s)", expectedTokenType, parsed.Header["typ"]) + } + } + } + }) + } +} diff --git a/pkg/pop/poptoken.go b/pkg/pop/poptoken.go new file mode 100644 index 00000000..61447bec --- /dev/null +++ b/pkg/pop/poptoken.go @@ -0,0 +1,144 @@ +package pop + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "fmt" + "math/big" +) + +// PoPKey is a generic interface for PoP key properties and methods +type PoPKey interface { + // encryption/signature algo + Alg() string + // kid + KeyID() string + // jwk that can be embedded in JWT w/ PoP token's cnf claim + JWK() string + // https://tools.ietf.org/html/rfc7638 compliant jwk thumbprint + JWKThumbprint() string + // req_cnf claim that can be included in access token request to AAD + ReqCnf() string + // sign payload using private key + Sign([]byte) ([]byte, error) +} + +// software based pop key implementation of PoPKey +type swKey struct { + key *rsa.PrivateKey + keyID string + jwk string + jwkTP string + reqCnf string +} + +// Alg returns the algorithm used to encrypt/sign the swKey +func (swk *swKey) Alg() string { + return "RS256" +} + +// KeyID returns the keyID of the swKey, representing the key used to sign the swKey +func (swk *swKey) KeyID() string { + return swk.keyID +} + +// JWK returns the JSON Web Key of the given swKey +func (swk *swKey) JWK() string { + return swk.jwk +} + +// JWKThumbprint returns the JWK thumbprint of the given swKey +func (swk *swKey) JWKThumbprint() string { + return swk.jwkTP +} + +// ReqCnf returns the req_cnf claim to send to AAD for the given swKey +func (swk *swKey) ReqCnf() string { + return swk.reqCnf +} + +// Sign uses the given swKey to sign the given payload and returns the signed payload +func (swk *swKey) Sign(payload []byte) ([]byte, error) { + return swk.key.Sign(rand.Reader, payload, crypto.SHA256) +} + +// init initializes the given swKey using the given private key +func (swk *swKey) init(key *rsa.PrivateKey) { + swk.key = key + + eB64, nB64 := getRSAKeyExponentAndModulus(key) + swk.jwkTP = computeJWKThumbprint(eB64, nB64) + swk.reqCnf = getReqCnf(swk.jwkTP) + + // set keyID to jwkTP + swk.keyID = swk.jwkTP + + // compute JWK to be included in JWT w/ PoP token's cnf claim + // - https://tools.ietf.org/html/rfc7800#section-3.2 + swk.jwk = getJWK(eB64, nB64, swk.keyID) +} + +// generateSwKey generates a new swkey and initializes it with required fields before returning it +func generateSwKey(key *rsa.PrivateKey) (*swKey, error) { + swk := &swKey{} + swk.init(key) + return swk, nil +} + +// GetSwPoPKey generates a new PoP key that rotates every 8 hours and returns it +func GetSwPoPKey() (*swKey, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("error generating RSA private key: %w", err) + } + return GetSwPoPKeyWithRSAKey(key) +} + +func GetSwPoPKeyWithRSAKey(rsaKey *rsa.PrivateKey) (*swKey, error) { + key, err := generateSwKey(rsaKey) + if err != nil { + return nil, fmt.Errorf("unable to generate PoP key. err: %w", err) + } + return key, nil +} + +// getRSAKeyExponentAndModulus returns the exponent and modulus from the given RSA key +// as base-64 encoded strings +func getRSAKeyExponentAndModulus(rsaKey *rsa.PrivateKey) (string, string) { + pubKey := rsaKey.PublicKey + e := big.NewInt(int64(pubKey.E)) + eB64 := base64.RawURLEncoding.EncodeToString(e.Bytes()) + n := pubKey.N + nB64 := base64.RawURLEncoding.EncodeToString(n.Bytes()) + return eB64, nB64 +} + +// computeJWKThumbprint returns a computed JWK thumbprint using the given base-64 encoded +// exponent and modulus +func computeJWKThumbprint(eB64 string, nB64 string) string { + // compute JWK thumbprint + // jwk format - e, kty, n - in lexicographic order + // - https://tools.ietf.org/html/rfc7638#section-3.3 + // - https://tools.ietf.org/html/rfc7638#section-3.1 + jwk := fmt.Sprintf(`{"e":"%s","kty":"RSA","n":"%s"}`, eB64, nB64) + jwkS256 := sha256.Sum256([]byte(jwk)) + return base64.RawURLEncoding.EncodeToString(jwkS256[:]) +} + +// getReqCnf computes and returns the value for the req_cnf claim to include when sending +// a request for the token +func getReqCnf(jwkTP string) string { + // req_cnf - base64URL("{"kid":"jwkTP","xms_ksl":"sw"}") + reqCnfJSON := fmt.Sprintf(`{"kid":"%s","xms_ksl":"sw"}`, jwkTP) + return base64.RawURLEncoding.EncodeToString([]byte(reqCnfJSON)) +} + +// getJWK computes the JWK to be included in the PoP token's enclosed cnf claim and returns it +func getJWK(eB64 string, nB64 string, keyID string) string { + // compute JWK to be included in JWT w/ PoP token's cnf claim + // - https://tools.ietf.org/html/rfc7800#section-3.2 + return fmt.Sprintf(`{"e":"%s","kty":"RSA","n":"%s","alg":"RS256","kid":"%s"}`, eB64, nB64, keyID) +} diff --git a/pkg/pop/poptoken_test.go b/pkg/pop/poptoken_test.go new file mode 100644 index 00000000..fa8550fd --- /dev/null +++ b/pkg/pop/poptoken_test.go @@ -0,0 +1,71 @@ +package pop + +import ( + "crypto/rand" + "crypto/rsa" + "testing" +) + +func TestSwPoPKey(t *testing.T) { + t.Run("GetSwPoPKeyWithRSAKey should return a key with all the expected fields", func(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Errorf("expected no error generating RSA key but got: %s", err) + } + key, err := GetSwPoPKeyWithRSAKey(rsaKey) + if err != nil { + t.Errorf("expected no error but got: %s", err) + } + + // validate key alg + if key.Alg() != "RS256" { + t.Errorf("expected key alg: %s but got: %s", "RS256", key.Alg()) + } + + // validate key jwk thumbprint + eB64, nB64 := getRSAKeyExponentAndModulus(key.key) + expectedJWKThumbprint := computeJWKThumbprint(eB64, nB64) + if key.JWKThumbprint() != expectedJWKThumbprint { + t.Errorf("expected key jwt thumbprint: %s but got: %s", expectedJWKThumbprint, key.JWKThumbprint()) + } + + // validate req_cnf + expectedReqCnf := getReqCnf(expectedJWKThumbprint) + if key.ReqCnf() != expectedReqCnf { + t.Errorf("expected key req_cnf: %s but got: %s", expectedReqCnf, key.ReqCnf()) + } + + // validate key ID + if key.KeyID() != expectedJWKThumbprint { + t.Errorf("expected key ID: %s but got: %s", expectedJWKThumbprint, key.KeyID()) + } + + // validate jwk + expectedJWK := getJWK(eB64, nB64, expectedJWKThumbprint) + if key.JWK() != expectedJWK { + t.Errorf("expected key JWK: %s but got: %s", expectedJWK, key.JWK()) + } + }) + + t.Run("GetSwPoPKeyWithRSAKey should return a key with all the expected fields", func(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Errorf("expected no error generating RSA key but got: %s", err) + } + + e, n := getRSAKeyExponentAndModulus(rsaKey) + e2, n2 := getRSAKeyExponentAndModulus(rsaKey) + if e2 != e { + t.Errorf("%s but got: %s", e, e2) + } + if n2 != n { + t.Errorf("%s but got: %s", n, n2) + } + + tp1 := computeJWKThumbprint(e, n) + tp2 := computeJWKThumbprint(e2, n2) + if tp1 != tp2 { + t.Errorf("%s but got: %s", tp1, tp2) + } + }) +} diff --git a/pkg/pop/testdata/AcquirePoPTokenConfidentialFromBadSecretVCR.yaml b/pkg/pop/testdata/AcquirePoPTokenConfidentialFromBadSecretVCR.yaml new file mode 100644 index 00000000..81e179ce --- /dev/null +++ b/pkg/pop/testdata/AcquirePoPTokenConfidentialFromBadSecretVCR.yaml @@ -0,0 +1,220 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - be8c850b-0a27-4a34-bb09-9c47caa68378 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https%3A%2F%2Flogin.microsoftonline.com%2FAZURE_TENANT_ID%2Foauth2%2Fv2.0%2Fauthorize + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 980 + uncompressed: false + body: '{"tenant_discovery_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration","api-version":"1.1","metadata":[{"preferred_network":"login.microsoftonline.com","preferred_cache":"login.windows.net","aliases":["login.microsoftonline.com","login.windows.net","login.microsoft.com","sts.windows.net"]},{"preferred_network":"login.partner.microsoftonline.cn","preferred_cache":"login.partner.microsoftonline.cn","aliases":["login.partner.microsoftonline.cn","login.chinacloudapi.cn"]},{"preferred_network":"login.microsoftonline.de","preferred_cache":"login.microsoftonline.de","aliases":["login.microsoftonline.de"]},{"preferred_network":"login.microsoftonline.us","preferred_cache":"login.microsoftonline.us","aliases":["login.microsoftonline.us","login.usgovcloudapi.net"]},{"preferred_network":"login-us.microsoftonline.com","preferred_cache":"login-us.microsoftonline.com","aliases":["login-us.microsoftonline.com"]}]}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - be8c850b-0a27-4a34-bb09-9c47caa68378 + Content-Length: + - "980" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 01 Sep 2023 00:04:12 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16150.3 - SCUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 251.892272ms + - id: 1 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 179f7bc3-d530-4b6c-a573-a60bded5a3cd + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 1753 + uncompressed: false + body: '{"token_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token","token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],"jwks_uri":"https://login.microsoftonline.com/AZURE_TENANT_ID/discovery/v2.0/keys","response_modes_supported":["query","fragment","form_post"],"subject_types_supported":["pairwise"],"id_token_signing_alg_values_supported":["RS256"],"response_types_supported":["code","id_token","code id_token","id_token token"],"scopes_supported":["openid","profile","email","offline_access"],"issuer":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0","request_uri_parameter_supported":false,"userinfo_endpoint":"https://graph.microsoft.com/oidc/userinfo","authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/authorize","device_authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/devicecode","http_logout_supported":true,"frontchannel_logout_supported":true,"end_session_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/logout","claims_supported":["sub","iss","cloud_instance_name","cloud_instance_host_name","cloud_graph_host_name","msgraph_host","aud","exp","iat","auth_time","acr","nonce","preferred_username","name","tid","ver","at_hash","c_hash","email"],"kerberos_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/kerberos","tenant_region_scope":"WW","cloud_instance_name":"microsoftonline.com","cloud_graph_host_name":"graph.windows.net","msgraph_host":"graph.microsoft.com","rbac_url":"https://pas.windows.net"}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - 179f7bc3-d530-4b6c-a573-a60bded5a3cd + Content-Length: + - "1753" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 01 Sep 2023 00:04:13 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16209.3 - EUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 89.482682ms + - id: 2 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 300 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: client_id=[REDACTED]&client_secret=Bad_Secret&grant_type=client_credentials&req_cnf=[REDACTED]&scope=6256c85f-0aad-4d50-b960-e6e9b21efe35%2F.default+openid+offline_access+profile&token_type=pop + form: + client_id: + - '[REDACTED]' + client_secret: + - Bad_Secret + grant_type: + - client_credentials + req_cnf: + - '[REDACTED]' + scope: + - '[REDACTED]/.default openid offline_access profile' + token_type: + - pop + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 4a139f54-91e6-483a-ab79-25451bd3479f + Content-Type: + - application/x-www-form-urlencoded; charset=utf-8 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 632 + uncompressed: false + body: '{"error":"invalid_client","error_description":"AADSTS7000215: Invalid client secret provided. Ensure the secret being sent in the request is the client secret value, not the client secret ID, for a secret added to app ''''[REDACTED]''''.\r\nTrace ID: [REDACTED]\r\nCorrelation ID: [REDACTED]\r\nTimestamp: 2023-06-02 21:00:26Z","error_codes":[7000215],"timestamp":"2023-06-02 21:00:26Z","trace_id":"[REDACTED]","correlation_id":"[REDACTED]","error_uri":"https://login.microsoftonline.com/error?code=7000215"}' + headers: + Cache-Control: + - no-store, no-cache + Client-Request-Id: + - 4a139f54-91e6-483a-ab79-25451bd3479f + Content-Length: + - "632" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 01 Sep 2023 00:04:13 GMT + Expires: + - "-1" + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Clitelem: + - 1,7000215,0,, + X-Ms-Ests-Server: + - 2.1.16209.3 - EUS ProdSlices + X-Xss-Protection: + - "0" + status: 401 Unauthorized + code: 401 + duration: 175.229239ms diff --git a/pkg/pop/testdata/AcquirePoPTokenConfidentialWithSecretVCR.yaml b/pkg/pop/testdata/AcquirePoPTokenConfidentialWithSecretVCR.yaml new file mode 100644 index 00000000..b7af203b --- /dev/null +++ b/pkg/pop/testdata/AcquirePoPTokenConfidentialWithSecretVCR.yaml @@ -0,0 +1,220 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 065e28b0-f783-4c75-8e2c-e4dc665742d2 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https%3A%2F%2Flogin.microsoftonline.com%2FAZURE_TENANT_ID%2Foauth2%2Fv2.0%2Fauthorize + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 980 + uncompressed: false + body: '{"tenant_discovery_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration","api-version":"1.1","metadata":[{"preferred_network":"login.microsoftonline.com","preferred_cache":"login.windows.net","aliases":["login.microsoftonline.com","login.windows.net","login.microsoft.com","sts.windows.net"]},{"preferred_network":"login.partner.microsoftonline.cn","preferred_cache":"login.partner.microsoftonline.cn","aliases":["login.partner.microsoftonline.cn","login.chinacloudapi.cn"]},{"preferred_network":"login.microsoftonline.de","preferred_cache":"login.microsoftonline.de","aliases":["login.microsoftonline.de"]},{"preferred_network":"login.microsoftonline.us","preferred_cache":"login.microsoftonline.us","aliases":["login.microsoftonline.us","login.usgovcloudapi.net"]},{"preferred_network":"login-us.microsoftonline.com","preferred_cache":"login-us.microsoftonline.com","aliases":["login-us.microsoftonline.com"]}]}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - 065e28b0-f783-4c75-8e2c-e4dc665742d2 + Content-Length: + - "980" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 01 Sep 2023 00:07:59 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16150.3 - EUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 262.893349ms + - id: 1 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 4e0b4541-609a-48ee-91ec-e77d961ef56d + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 1753 + uncompressed: false + body: '{"token_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token","token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],"jwks_uri":"https://login.microsoftonline.com/AZURE_TENANT_ID/discovery/v2.0/keys","response_modes_supported":["query","fragment","form_post"],"subject_types_supported":["pairwise"],"id_token_signing_alg_values_supported":["RS256"],"response_types_supported":["code","id_token","code id_token","id_token token"],"scopes_supported":["openid","profile","email","offline_access"],"issuer":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0","request_uri_parameter_supported":false,"userinfo_endpoint":"https://graph.microsoft.com/oidc/userinfo","authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/authorize","device_authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/devicecode","http_logout_supported":true,"frontchannel_logout_supported":true,"end_session_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/logout","claims_supported":["sub","iss","cloud_instance_name","cloud_instance_host_name","cloud_graph_host_name","msgraph_host","aud","exp","iat","auth_time","acr","nonce","preferred_username","name","tid","ver","at_hash","c_hash","email"],"kerberos_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/kerberos","tenant_region_scope":"WW","cloud_instance_name":"microsoftonline.com","cloud_graph_host_name":"graph.windows.net","msgraph_host":"graph.microsoft.com","rbac_url":"https://pas.windows.net"}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - 4e0b4541-609a-48ee-91ec-e77d961ef56d + Content-Length: + - "1753" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 01 Sep 2023 00:07:59 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16209.3 - EUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 91.482757ms + - id: 2 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 330 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: client_id=[REDACTED]&client_secret=[REDACTED]&grant_type=client_credentials&req_cnf=[REDACTED]&scope=6256c85f-0aad-4d50-b960-e6e9b21efe35%2F.default+openid+offline_access+profile&token_type=pop + form: + client_id: + - '[REDACTED]' + client_secret: + - '[REDACTED]' + grant_type: + - client_credentials + req_cnf: + - '[REDACTED]' + scope: + - '[REDACTED]/.default openid offline_access profile' + token_type: + - pop + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 0e0789fc-d6fd-4157-a622-d5da8347b009 + Content-Type: + - application/x-www-form-urlencoded; charset=utf-8 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 1475 + uncompressed: false + body: '{"token_type":"Bearer","expires_in":86399,"ext_expires_in":86399,"access_token":"TEST_ACCESS_TOKEN"}' + headers: + Cache-Control: + - no-store, no-cache + Client-Request-Id: + - 0e0789fc-d6fd-4157-a622-d5da8347b009 + Content-Length: + - "1475" + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 01 Sep 2023 00:07:59 GMT + Expires: + - "-1" + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Clitelem: + - 1,0,0,, + X-Ms-Ests-Server: + - 2.1.16209.3 - SCUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 157.469883ms diff --git a/pkg/token/govcrutils.go b/pkg/testutils/govcrutils.go similarity index 87% rename from pkg/token/govcrutils.go rename to pkg/testutils/govcrutils.go index e0d6a347..7807a6cc 100644 --- a/pkg/token/govcrutils.go +++ b/pkg/testutils/govcrutils.go @@ -1,4 +1,4 @@ -package token +package testutils import ( "net/http" @@ -13,7 +13,6 @@ const ( tenantUUID = "AZURE_TENANT_ID" vcrMode = "VCR_MODE" vcrModeRecordOnly = "RecordOnly" - badSecret = "Bad_Secret" redactionToken = "[REDACTED]" testToken = "TEST_ACCESS_TOKEN" ) @@ -31,7 +30,7 @@ func GetVCRHttpClient(path string, token string) (*recorder.Recorder, *http.Clie rec, _ := recorder.NewWithOptions(opts) hook := func(i *cassette.Interaction) error { - var detectedClientID, detectedClientSecret, detectedClientAssertion, detectedScope string + var detectedClientID, detectedClientSecret, detectedClientAssertion, detectedScope, detectedReqCnf string // Delete sensitive content delete(i.Response.Headers, "Set-Cookie") delete(i.Response.Headers, "X-Ms-Request-Id") @@ -39,7 +38,7 @@ func GetVCRHttpClient(path string, token string) (*recorder.Recorder, *http.Clie detectedClientID = i.Request.Form["client_id"][0] i.Request.Form["client_id"] = []string{redactionToken} } - if i.Request.Form["client_secret"] != nil && i.Request.Form["client_secret"][0] != badSecret { + if i.Request.Form["client_secret"] != nil && i.Request.Form["client_secret"][0] != BadSecret { detectedClientSecret = i.Request.Form["client_secret"][0] i.Request.Form["client_secret"] = []string{redactionToken} } @@ -51,24 +50,31 @@ func GetVCRHttpClient(path string, token string) (*recorder.Recorder, *http.Clie detectedScope = i.Request.Form["scope"][0][:strings.IndexByte(i.Request.Form["scope"][0], '/')] i.Request.Form["scope"] = []string{redactionToken + "/.default openid offline_access profile"} } - i.Request.URL = strings.ReplaceAll(i.Request.URL, os.Getenv(tenantUUID), tenantUUID) - i.Response.Body = strings.ReplaceAll(i.Response.Body, os.Getenv(tenantUUID), tenantUUID) + if i.Request.Form["req_cnf"] != nil { + detectedScope = i.Request.Form["req_cnf"][0] + i.Request.Form["req_cnf"] = []string{redactionToken} + } + + if os.Getenv(tenantUUID) != "" { + i.Request.URL = strings.ReplaceAll(i.Request.URL, os.Getenv(tenantUUID), tenantUUID) + i.Response.Body = strings.ReplaceAll(i.Response.Body, os.Getenv(tenantUUID), tenantUUID) + } if detectedClientID != "" { i.Request.Body = strings.ReplaceAll(i.Request.Body, detectedClientID, redactionToken) } - if detectedClientSecret != "" { i.Request.Body = strings.ReplaceAll(i.Request.Body, detectedClientSecret, redactionToken) } - if detectedClientAssertion != "" { i.Request.Body = strings.ReplaceAll(i.Request.Body, detectedClientAssertion, redactionToken) } - if detectedScope != "" { i.Request.Body = strings.ReplaceAll(i.Request.Body, detectedScope, redactionToken) } + if detectedReqCnf != "" { + i.Request.Body = strings.ReplaceAll(i.Request.Body, detectedReqCnf, redactionToken) + } if strings.Contains(i.Response.Body, "access_token") { i.Response.Body = `{"token_type":"Bearer","expires_in":86399,"ext_expires_in":86399,"access_token":"` + testToken + `"}` diff --git a/pkg/testutils/testutils.go b/pkg/testutils/testutils.go new file mode 100644 index 00000000..dca27fb2 --- /dev/null +++ b/pkg/testutils/testutils.go @@ -0,0 +1,26 @@ +package testutils + +import "strings" + +const ( + ClientID = "AZURE_CLIENT_ID" + ClientSecret = "AAD_SERVICE_PRINCIPAL_CLIENT_SECRET" + ClientCert = "AZURE_CLIENT_CER" + ClientCertPass = "AZURE_CLIENT_CERTIFICATE_PASSWORD" + ResourceID = "AZURE_RESOURCE_ID" + TenantID = "AZURE_TENANT_ID" + BadSecret = "Bad_Secret" +) + +// ErrorContains takes an input error and a desired substring, checks if the string is present +// in the error message, and returns the boolean result +func ErrorContains(out error, want string) bool { + substring := strings.TrimSpace(want) + if out == nil { + return substring == "" + } + if substring == "" { + return false + } + return strings.Contains(out.Error(), substring) +} diff --git a/pkg/testutils/testutils_test.go b/pkg/testutils/testutils_test.go new file mode 100644 index 00000000..3be35dba --- /dev/null +++ b/pkg/testutils/testutils_test.go @@ -0,0 +1,79 @@ +package testutils + +import ( + "fmt" + "testing" +) + +func TestErrorContains(t *testing.T) { + testCase := []struct { + name string + err error + desiredSubstring string + expectedResult bool + }{ + { + name: "should return true if error is nil and desired substring is empty string", + desiredSubstring: "", + err: nil, + expectedResult: true, + }, + { + name: "should return true if error is nil and desired substring is whitespace", + desiredSubstring: " ", + err: nil, + expectedResult: true, + }, + { + name: "should return false if error is not nil and desired substring is empty string", + desiredSubstring: "", + err: fmt.Errorf("test error"), + expectedResult: false, + }, + { + name: "should return false if error is not nil and desired substring is whitespace", + desiredSubstring: " ", + err: fmt.Errorf("test error"), + expectedResult: false, + }, + { + name: "should return false if error is not nil and desired substring is not contained in error", + desiredSubstring: "not a test error", + err: fmt.Errorf("test error"), + expectedResult: false, + }, + { + name: "should return true if error is not nil and desired substring is smaller than but contained in error", + desiredSubstring: "error", + err: fmt.Errorf("test error"), + expectedResult: true, + }, + { + name: "should return true if error is not nil and desired substring is the same as error string", + desiredSubstring: "test error", + err: fmt.Errorf("test error"), + expectedResult: true, + }, + { + name: "should return false if error is not nil and desired substring is the same as error string but has different casing", + desiredSubstring: "Test Error", + err: fmt.Errorf("test error"), + expectedResult: false, + }, + } + + for _, tc := range testCase { + t.Run(tc.name, func(t *testing.T) { + result := ErrorContains(tc.err, tc.desiredSubstring) + if result != tc.expectedResult { + t.Errorf( + "comparing error: %s and desired substring: %s, expected %t but got %t", + tc.err, + tc.desiredSubstring, + tc.expectedResult, + result, + ) + } + }) + } +} diff --git a/pkg/token/azurecli_test.go b/pkg/token/azurecli_test.go index a8da2e12..2bb9b8b5 100644 --- a/pkg/token/azurecli_test.go +++ b/pkg/token/azurecli_test.go @@ -1,14 +1,15 @@ package token import ( - "strings" "testing" + + "github.com/Azure/kubelogin/pkg/testutils" ) func TestNewAzureCLITokenEmpty(t *testing.T) { _, err := newAzureCLIToken("", "") - if !ErrorContains(err, "resourceID cannot be empty") { + if !testutils.ErrorContains(err, "resourceID cannot be empty") { t.Errorf("unexpected error: %v", err) } } @@ -17,17 +18,7 @@ func TestNewAzureCLIToken(t *testing.T) { azcli := AzureCLIToken{} _, err := azcli.Token() - if !ErrorContains(err, "expected an empty error but received:") { + if !testutils.ErrorContains(err, "expected an empty error but received:") { t.Errorf("unexpected error: %v", err) } } - -func ErrorContains(out error, want string) bool { - if out == nil { - return want == "" - } - if want == "" { - return false - } - return strings.Contains(out.Error(), want) -} diff --git a/pkg/token/devicecode_test.go b/pkg/token/devicecode_test.go index da46903e..2cbe7f92 100644 --- a/pkg/token/devicecode_test.go +++ b/pkg/token/devicecode_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/kubelogin/pkg/testutils" ) func TestNewDeviceCodeTokenProviderEmpty(t *testing.T) { @@ -40,7 +41,7 @@ func TestNewDeviceCodeTokenProviderEmpty(t *testing.T) { fmt.Println(false) } - if !ErrorContains(err, data.name) { + if !testutils.ErrorContains(err, data.name) { t.Errorf("unexpected error: %v", err) } }) @@ -51,7 +52,7 @@ func TestNewDeviceCodeToken(t *testing.T) { deviceCode := deviceCodeTokenProvider{} _, err := deviceCode.Token() - if !ErrorContains(err, "initialing the device code authentication:") { + if !testutils.ErrorContains(err, "initialing the device code authentication:") { t.Errorf("unexpected error: %v", err) } } diff --git a/pkg/token/federatedIdentity_test.go b/pkg/token/federatedIdentity_test.go index 0b6df54b..16ac2b58 100644 --- a/pkg/token/federatedIdentity_test.go +++ b/pkg/token/federatedIdentity_test.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" "testing" + + "github.com/Azure/kubelogin/pkg/testutils" ) func TestNewWorkloadIdentityTokenProviderEmpty(t *testing.T) { @@ -48,7 +50,7 @@ func TestNewWorkloadIdentityTokenProviderEmpty(t *testing.T) { fmt.Println(false) } - if !ErrorContains(err, data.name) { + if !testutils.ErrorContains(err, data.name) { t.Errorf("unexpected error: %v", err) } }) @@ -59,21 +61,21 @@ func TestNewWorkloadIdentityToken(t *testing.T) { workloadIdentityToken := workloadIdentityToken{} _, err := workloadIdentityToken.Token() - if !ErrorContains(err, "failed to read signed assertion from token file:") { + if !testutils.ErrorContains(err, "failed to read signed assertion from token file:") { t.Errorf("unexpected error: %v", err) } } func TestNewCredentialEmptyString(t *testing.T) { _, err := newCredential("") - if !ErrorContains(err, "failed to read signed assertion from token file:") { + if !testutils.ErrorContains(err, "failed to read signed assertion from token file:") { t.Errorf("unexpected error: %v", err) } } func TestReadJWTFromFSEmptyString(t *testing.T) { _, err := readJWTFromFS("") - if !ErrorContains(err, "no such file or directory") { + if !testutils.ErrorContains(err, "no such file or directory") { t.Errorf("unexpected error: %v", err) } } diff --git a/pkg/token/interactive.go b/pkg/token/interactive.go index a9c09c91..b3f4dcac 100644 --- a/pkg/token/interactive.go +++ b/pkg/token/interactive.go @@ -12,6 +12,7 @@ import ( "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" + "github.com/Azure/kubelogin/pkg/pop" ) type InteractiveToken struct { @@ -19,11 +20,12 @@ type InteractiveToken struct { resourceID string tenantID string oAuthConfig adal.OAuthConfig + popClaims map[string]string } // newInteractiveTokenProvider returns a TokenProvider that will fetch a token for the user currently logged into the Interactive. // Required arguments include an oAuthConfiguration object and the resourceID (which is used as the scope) -func newInteractiveTokenProvider(oAuthConfig adal.OAuthConfig, clientID, resourceID, tenantID string) (TokenProvider, error) { +func newInteractiveTokenProvider(oAuthConfig adal.OAuthConfig, clientID, resourceID, tenantID string, popClaims map[string]string) (TokenProvider, error) { if clientID == "" { return nil, errors.New("clientID cannot be empty") } @@ -39,44 +41,76 @@ func newInteractiveTokenProvider(oAuthConfig adal.OAuthConfig, clientID, resourc resourceID: resourceID, tenantID: tenantID, oAuthConfig: oAuthConfig, + popClaims: popClaims, }, nil } // Token fetches an azcore.AccessToken from the interactive browser SDK and converts it to an adal.Token for use with kubelogin. func (p *InteractiveToken) Token() (adal.Token, error) { + return p.TokenWithOptions(nil) +} + +func (p *InteractiveToken) TokenWithOptions(options *azcore.ClientOptions) (adal.Token, error) { + ctx := context.Background() emptyToken := adal.Token{} // Request a new Interactive token provider authorityFromConfig := p.oAuthConfig.AuthorityEndpoint + scopes := []string{p.resourceID + "/.default"} clientOpts := azcore.ClientOptions{Cloud: cloud.Configuration{ ActiveDirectoryAuthorityHost: authorityFromConfig.String(), }} - cred, err := azidentity.NewInteractiveBrowserCredential(&azidentity.InteractiveBrowserCredentialOptions{ - ClientOptions: clientOpts, - TenantID: p.tenantID, - ClientID: p.clientID, - }) - if err != nil { - return emptyToken, fmt.Errorf("unable to create credential. Received: %w", err) + if options != nil { + clientOpts = *options } + var token string + var expirationTimeUnix int64 + var err error - // Use the token provider to get a new token - interactiveToken, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{p.resourceID + "/.default"}}) - if err != nil { - return emptyToken, fmt.Errorf("expected an empty error but received: %w", err) - } - if interactiveToken.Token == "" { - return emptyToken, errors.New("did not receive a token") + if len(p.popClaims) > 0 { + // If PoP token support is enabled and the correct u-claim is provided, use the MSAL + // token provider to acquire a new token + token, expirationTimeUnix, err = pop.AcquirePoPTokenInteractive( + ctx, + p.popClaims, + scopes, + authorityFromConfig.String(), + p.clientID, + &clientOpts, + ) + if err != nil { + return emptyToken, fmt.Errorf("failed to create PoP token using interactive login: %w", err) + } + } else { + cred, err := azidentity.NewInteractiveBrowserCredential(&azidentity.InteractiveBrowserCredentialOptions{ + ClientOptions: clientOpts, + TenantID: p.tenantID, + ClientID: p.clientID, + }) + if err != nil { + return emptyToken, fmt.Errorf("unable to create credential. Received: %w", err) + } + + // Use the token provider to get a new token + interactiveToken, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: scopes}) + if err != nil { + return emptyToken, fmt.Errorf("expected an empty error but received: %w", err) + } + token = interactiveToken.Token + if token == "" { + return emptyToken, errors.New("did not receive a token") + } + expirationTimeUnix = interactiveToken.ExpiresOn.Unix() } // 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(interactiveToken.ExpiresOn.Unix(), 10)) + // JSON.Number via formatting as a string using a base-10 int64 conversion + expiresOn := json.Number(strconv.FormatInt(expirationTimeUnix, 10)) - // Re-wrap the azurecore.AccessToken into an adal.Token + // re-wrap the azurecore.AccessToken into an adal.Token return adal.Token{ - AccessToken: interactiveToken.Token, + AccessToken: token, ExpiresOn: expiresOn, Resource: p.resourceID, }, nil diff --git a/pkg/token/interactive_test.go b/pkg/token/interactive_test.go new file mode 100644 index 00000000..cb6b3bba --- /dev/null +++ b/pkg/token/interactive_test.go @@ -0,0 +1,88 @@ +package token + +import ( + "testing" + + "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/kubelogin/pkg/testutils" + "github.com/google/go-cmp/cmp" +) + +func TestNewInteractiveToken(t *testing.T) { + testCases := []struct { + name string + clientID string + resourceID string + tenantID string + popClaims map[string]string + expectedError string + }{ + { + name: "test new interactive token provider with empty client ID should return error", + expectedError: "clientID cannot be empty", + }, + { + name: "test new interactive token provider with empty resource ID should return error", + clientID: "testclientid", + expectedError: "resourceID cannot be empty", + }, + { + name: "test new interactive token provider with empty tenant ID should return error", + clientID: "testclientid", + resourceID: "testresource", + expectedError: "tenantID cannot be empty", + }, + { + name: "test new interactive token provider with no pop claims should not return error", + clientID: "testclientid", + resourceID: "testresource", + tenantID: "testtenant", + expectedError: "", + }, + { + name: "test new interactive token provider with pop claims should not return error", + clientID: "testclientid", + resourceID: "testresource", + tenantID: "testtenant", + popClaims: map[string]string{"u": "testhost"}, + expectedError: "", + }, + } + var tokenProvider TokenProvider + var err error + oauthConfig := adal.OAuthConfig{} + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tokenProvider, err = newInteractiveTokenProvider( + oauthConfig, + tc.clientID, + tc.resourceID, + tc.tenantID, + tc.popClaims, + ) + + if tc.expectedError != "" { + if !testutils.ErrorContains(err, tc.expectedError) { + t.Errorf("expected error %s, but got %s", tc.expectedError, err) + } + } else { + if tokenProvider == nil { + t.Errorf("expected token provider creation to succeed, but got error: %s", err) + } + itp := tokenProvider.(*InteractiveToken) + if itp.clientID != tc.clientID { + t.Errorf("expected client ID: %s but got: %s", tc.clientID, itp.clientID) + } + if itp.resourceID != tc.resourceID { + t.Errorf("expected resource ID: %s but got: %s", tc.resourceID, itp.resourceID) + } + if itp.tenantID != tc.tenantID { + t.Errorf("expected tenant ID: %s but got: %s", tc.tenantID, itp.tenantID) + } + if !cmp.Equal(itp.popClaims, tc.popClaims) { + t.Errorf("expected PoP claims: %s but got: %s", tc.popClaims, itp.popClaims) + } + } + }) + } +} diff --git a/pkg/token/options.go b/pkg/token/options.go index ca0c9ed2..b3f20b53 100644 --- a/pkg/token/options.go +++ b/pkg/token/options.go @@ -28,6 +28,8 @@ type Options struct { FederatedTokenFile string AuthorityHost string UseAzureRMTerraformEnv bool + IsPoPTokenEnabled bool + PoPTokenClaims string } const ( @@ -118,6 +120,8 @@ func (o *Options) AddFlags(fs *pflag.FlagSet) { fs.BoolVar(&o.IsLegacy, "legacy", o.IsLegacy, "set to true to get token with 'spn:' prefix in audience claim") fs.BoolVar(&o.UseAzureRMTerraformEnv, "use-azurerm-env-vars", o.UseAzureRMTerraformEnv, "Use environment variable names of Terraform Azure Provider (ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_CLIENT_CERTIFICATE_PATH, ARM_CLIENT_CERTIFICATE_PASSWORD, ARM_TENANT_ID)") + fs.BoolVar(&o.IsPoPTokenEnabled, "pop-enabled", o.IsPoPTokenEnabled, "set to true to use a PoP token for authentication or false to use a regular bearer token") + fs.StringVar(&o.PoPTokenClaims, "pop-claims", o.PoPTokenClaims, "contains a comma-separated list of claims to attach to the pop token in the format `key=val,key2=val2`. At minimum, specify the ARM ID of the cluster as `u=ARM_ID`") } func (o *Options) Validate() error { @@ -131,6 +135,16 @@ func (o *Options) Validate() error { if !foundValidLoginMethod { return fmt.Errorf("'%s' is not a supported login method. Supported method is one of %s", o.LoginMethod, GetSupportedLogins()) } + + // both of the following checks ensure that --pop-enabled and --pop-claims flags are provided together + if o.IsPoPTokenEnabled && o.PoPTokenClaims == "" { + return fmt.Errorf("if enabling pop token mode, please provide the pop-claims flag containing the PoP token claims as a comma-separated string: `u=popClaimHost,key1=val1`") + } + + if o.PoPTokenClaims != "" && !o.IsPoPTokenEnabled { + return fmt.Errorf("pop-enabled flag is required to use the PoP token feature. Please provide both pop-enabled and pop-claims flags") + } + return nil } @@ -236,3 +250,29 @@ func getCacheFileName(o *Options) string { } return filepath.Join(o.TokenCacheDir, fmt.Sprintf(cacheFileNameFormat, o.Environment, o.ServerID, o.ClientID, o.TenantID)) } + +// parsePoPClaims parses the pop token claims. Pop token claims are passed in as a +// comma-separated string in the format "key1=val1,key2=val2" +func parsePoPClaims(popClaims string) (map[string]string, error) { + if strings.TrimSpace(popClaims) == "" { + return nil, fmt.Errorf("failed to parse PoP token claims: no claims provided") + } + claimsArray := strings.Split(popClaims, ",") + claimsMap := make(map[string]string) + for _, claim := range claimsArray { + claimPair := strings.Split(claim, "=") + if len(claimPair) < 2 { + return nil, fmt.Errorf("failed to parse PoP token claims. Ensure the claims are formatted as `key=value` with no extra whitespace") + } + key := strings.TrimSpace(claimPair[0]) + val := strings.TrimSpace(claimPair[1]) + if key == "" || val == "" { + return nil, fmt.Errorf("failed to parse PoP token claims. Ensure the claims are formatted as `key=value` with no extra whitespace") + } + claimsMap[key] = val + } + if claimsMap["u"] == "" { + return nil, fmt.Errorf("required u-claim not provided for PoP token flow. Please provide the ARM ID of the cluster in the format `u=`") + } + return claimsMap, nil +} diff --git a/pkg/token/options_test.go b/pkg/token/options_test.go index d3f38556..981b3d43 100644 --- a/pkg/token/options_test.go +++ b/pkg/token/options_test.go @@ -1,10 +1,13 @@ package token import ( + "fmt" "path/filepath" "strings" "testing" + "github.com/Azure/kubelogin/pkg/testutils" + "github.com/google/go-cmp/cmp" "github.com/spf13/pflag" ) @@ -43,6 +46,22 @@ func TestOptions(t *testing.T) { t.Fatalf("unsupported login method should return unsupported error. got: %s", err) } }) + + t.Run("pop-enabled flag should return error if pop-claims are not provided", func(t *testing.T) { + o := NewOptions() + o.IsPoPTokenEnabled = true + if err := o.Validate(); err == nil || !strings.Contains(err.Error(), "please provide the pop-claims flag") { + t.Fatalf("pop-enabled with no pop claims should return missing pop-claims error. got: %s", err) + } + }) + + t.Run("pop-claims flag should return error if pop-enabled is not provided", func(t *testing.T) { + o := NewOptions() + o.PoPTokenClaims = "u=testhost" + if err := o.Validate(); err == nil || !strings.Contains(err.Error(), "pop-enabled flag is required to use the PoP token feature") { + t.Fatalf("pop-claims provided with no pop-enabled flag should return missing pop-enabled error. got: %s", err) + } + }) } func TestOptionsWithEnvVars(t *testing.T) { @@ -149,9 +168,83 @@ func TestOptionsWithEnvVars(t *testing.T) { } o.AddFlags(&pflag.FlagSet{}) o.UpdateFromEnv() - if o != tc.expected { + if !cmp.Equal(o, tc.expected, cmp.AllowUnexported(Options{})) { t.Fatalf("expected option: %+v, got %+v", tc.expected, o) } }) } } + +func TestParsePoPClaims(t *testing.T) { + testCases := []struct { + name string + popClaims string + expectedError error + expectedClaims map[string]string + }{ + { + name: "pop-claim parsing should fail on empty string", + popClaims: "", + expectedError: fmt.Errorf("failed to parse PoP token claims: no claims provided"), + expectedClaims: nil, + }, + { + name: "pop-claim parsing should fail on whitespace-only string", + popClaims: " ", + expectedError: fmt.Errorf("failed to parse PoP token claims: no claims provided"), + expectedClaims: nil, + }, + { + name: "pop-claim parsing should fail if claims are not provided in key=value format", + popClaims: "claim1=val1,claim2", + expectedError: fmt.Errorf("failed to parse PoP token claims. Ensure the claims are formatted as `key=value` with no extra whitespace"), + expectedClaims: nil, + }, + { + name: "pop-claim parsing should fail if claims are malformed", + popClaims: "claim1= ", + expectedError: fmt.Errorf("failed to parse PoP token claims. Ensure the claims are formatted as `key=value` with no extra whitespace"), + expectedClaims: nil, + }, + { + name: "pop-claim parsing should fail if claims are malformed/commas only", + popClaims: ",,,,,,,,", + expectedError: fmt.Errorf("failed to parse PoP token claims. Ensure the claims are formatted as `key=value` with no extra whitespace"), + expectedClaims: nil, + }, + { + name: "pop-claim parsing should fail if u-claim is not provided", + popClaims: "1=2,3=4", + expectedError: fmt.Errorf("required u-claim not provided for PoP token flow. Please provide the ARM ID of the cluster in the format `u=`"), + expectedClaims: nil, + }, + { + name: "pop-claim parsing should succeed with u-claim and additional claims", + popClaims: "u=val1, claim2=val2, claim3=val3", + expectedError: nil, + expectedClaims: map[string]string{ + "u": "val1", + "claim2": "val2", + "claim3": "val3", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + claimsMap, err := parsePoPClaims(tc.popClaims) + if err != nil { + if !testutils.ErrorContains(err, tc.expectedError.Error()) { + t.Fatalf("expected error: %+v, got error: %+v", tc.expectedError, err) + } + } else { + if err != tc.expectedError { + t.Fatalf("expected error: %+v, got error: %+v", tc.expectedError, err) + } + } + if !cmp.Equal(claimsMap, tc.expectedClaims) { + t.Fatalf("expected claims map to be %s, got map: %s", tc.expectedClaims, claimsMap) + } + }) + } +} diff --git a/pkg/token/provider.go b/pkg/token/provider.go index f985d088..c7da532e 100644 --- a/pkg/token/provider.go +++ b/pkg/token/provider.go @@ -24,13 +24,17 @@ func newTokenProvider(o *Options) (TokenProvider, error) { if err != nil { return nil, fmt.Errorf("failed to get cloud.Configuration. err: %s", err) } + popClaimsMap, err := parsePoPClaims(o.PoPTokenClaims) + if o.IsPoPTokenEnabled && err != nil { + return nil, err + } switch o.LoginMethod { case DeviceCodeLogin: return newDeviceCodeTokenProvider(*oAuthConfig, o.ClientID, o.ServerID, o.TenantID) case InteractiveLogin: - return newInteractiveTokenProvider(*oAuthConfig, o.ClientID, o.ServerID, o.TenantID) + return newInteractiveTokenProvider(*oAuthConfig, o.ClientID, o.ServerID, o.TenantID, popClaimsMap) case ServicePrincipalLogin: - return newServicePrincipalToken(cloudConfiguration, o.ClientID, o.ClientSecret, o.ClientCert, o.ClientCertPassword, o.ServerID, o.TenantID) + return newServicePrincipalTokenProvider(cloudConfiguration, o.ClientID, o.ClientSecret, o.ClientCert, o.ClientCertPassword, o.ServerID, o.TenantID, popClaimsMap) case ROPCLogin: return newResourceOwnerToken(*oAuthConfig, o.ClientID, o.Username, o.Password, o.ServerID, o.TenantID) case MSILogin: diff --git a/pkg/token/provider_test.go b/pkg/token/provider_test.go new file mode 100644 index 00000000..56543668 --- /dev/null +++ b/pkg/token/provider_test.go @@ -0,0 +1,254 @@ +package token + +import ( + "testing" + + "github.com/Azure/kubelogin/pkg/testutils" + "github.com/google/go-cmp/cmp" +) + +func TestNewTokenProvider(t *testing.T) { + t.Run("newTokenProvider should return error on failure to get oAuthConfig", func(t *testing.T) { + options := &Options{ + Environment: "badenvironment", + TenantID: "testtenant", + IsLegacy: false, + } + provider, err := newTokenProvider(options) + if err == nil || provider != nil { + t.Errorf("expected error but got nil") + } + if !testutils.ErrorContains(err, "autorest/azure: There is no cloud environment matching the name") { + t.Errorf("expected error getting environment but got: %s", err) + } + }) + + t.Run("newTokenProvider should return error on failure to parse PoP claims", func(t *testing.T) { + options := &Options{ + TenantID: "testtenant", + IsLegacy: false, + IsPoPTokenEnabled: true, + PoPTokenClaims: "1=2", + } + provider, err := newTokenProvider(options) + if err == nil || provider != nil { + t.Errorf("expected error but got nil") + } + if !testutils.ErrorContains(err, "required u-claim not provided for PoP token flow") { + t.Errorf("expected error parsing PoP claims but got: %s", err) + } + }) + + t.Run("newTokenProvider should return error on invalid login method", func(t *testing.T) { + options := &Options{ + LoginMethod: "unsupported", + } + provider, err := newTokenProvider(options) + if err == nil || provider != nil { + t.Errorf("expected error but got nil") + } + if !testutils.ErrorContains(err, "unsupported token provider") { + t.Errorf("expected unsupported token provider error but got: %s", err) + } + }) + + t.Run("newTokenProvider should return interactive token provider with correct fields", func(t *testing.T) { + options := &Options{ + TenantID: "testtenant", + ClientID: "testclient", + ServerID: "testserver", + IsPoPTokenEnabled: true, + PoPTokenClaims: "u=testhost", + LoginMethod: "interactive", + } + provider, err := newTokenProvider(options) + if err != nil || provider == nil { + t.Errorf("expected no error but got: %s", err) + } + interactive := provider.(*InteractiveToken) + if interactive.clientID != options.ClientID { + t.Errorf("expected provider client ID to be: %s but got: %s", options.ClientID, interactive.clientID) + } + if interactive.resourceID != options.ServerID { + t.Errorf("expected provider resource ID to be: %s but got: %s", options.ServerID, interactive.resourceID) + } + if interactive.tenantID != options.TenantID { + t.Errorf("expected provider tenant ID to be: %s but got: %s", options.TenantID, interactive.tenantID) + } + expectedPoPClaims := map[string]string{"u": "testhost"} + if !cmp.Equal(interactive.popClaims, expectedPoPClaims) { + t.Errorf("expected provider PoP claims to be: %v but got: %v", expectedPoPClaims, interactive.popClaims) + } + }) + + t.Run("newTokenProvider should return SPN token provider using client secret with correct fields", func(t *testing.T) { + options := &Options{ + TenantID: "testtenant", + ClientID: "testclient", + ServerID: "testserver", + ClientSecret: "testsecret", + IsPoPTokenEnabled: true, + PoPTokenClaims: "u=testhost, 1=2", + LoginMethod: "spn", + } + provider, err := newTokenProvider(options) + if err != nil || provider == nil { + t.Errorf("expected no error but got: %s", err) + } + spn := provider.(*servicePrincipalToken) + if spn.clientID != options.ClientID { + t.Errorf("expected provider client ID to be: %s but got: %s", options.ClientID, spn.clientID) + } + if spn.resourceID != options.ServerID { + t.Errorf("expected provider resource ID to be: %s but got: %s", options.ServerID, spn.resourceID) + } + if spn.tenantID != options.TenantID { + t.Errorf("expected provider tenant ID to be: %s but got: %s", options.TenantID, spn.tenantID) + } + if spn.clientSecret != options.ClientSecret { + t.Errorf("expected provider client secret to be: %s but got: %s", options.ClientSecret, spn.clientSecret) + } + expectedPoPClaims := map[string]string{"u": "testhost", "1": "2"} + if !cmp.Equal(spn.popClaims, expectedPoPClaims) { + t.Errorf("expected provider PoP claims to be: %v but got: %v", expectedPoPClaims, spn.popClaims) + } + }) + + t.Run("newTokenProvider should return SPN token provider using client cert with correct fields", func(t *testing.T) { + options := &Options{ + TenantID: "testtenant", + ClientID: "testclient", + ServerID: "testserver", + ClientCert: "testcert", + ClientCertPassword: "testcertpass", + LoginMethod: "spn", + } + provider, err := newTokenProvider(options) + if err != nil || provider == nil { + t.Errorf("expected no error but got: %s", err) + } + spn := provider.(*servicePrincipalToken) + if spn.clientID != options.ClientID { + t.Errorf("expected provider client ID to be: %s but got: %s", options.ClientID, spn.clientID) + } + if spn.resourceID != options.ServerID { + t.Errorf("expected provider resource ID to be: %s but got: %s", options.ServerID, spn.resourceID) + } + if spn.tenantID != options.TenantID { + t.Errorf("expected provider tenant ID to be: %s but got: %s", options.TenantID, spn.tenantID) + } + if spn.clientCert != options.ClientCert { + t.Errorf("expected provider client cert to be: %s but got: %s", options.ClientCert, spn.clientCert) + } + if spn.clientCertPassword != options.ClientCertPassword { + t.Errorf("expected provider client cert password to be: %s but got: %s", options.ClientCertPassword, spn.clientCertPassword) + } + if spn.popClaims != nil { + t.Errorf("expected provider PoP claims to be nil but got: %v", spn.popClaims) + } + }) + + t.Run("newTokenProvider should return resource owner token provider with correct fields", func(t *testing.T) { + options := &Options{ + TenantID: "testtenant", + ClientID: "testclient", + ServerID: "testserver", + Username: "testuser", + Password: "testpass", + LoginMethod: "ropc", + } + provider, err := newTokenProvider(options) + if err != nil || provider == nil { + t.Errorf("expected no error but got: %s", err) + } + ropc := provider.(*resourceOwnerToken) + if ropc.clientID != options.ClientID { + t.Errorf("expected provider client ID to be: %s but got: %s", options.ClientID, ropc.clientID) + } + if ropc.resourceID != options.ServerID { + t.Errorf("expected provider resource ID to be: %s but got: %s", options.ServerID, ropc.resourceID) + } + if ropc.tenantID != options.TenantID { + t.Errorf("expected provider tenant ID to be: %s but got: %s", options.TenantID, ropc.tenantID) + } + if ropc.username != options.Username { + t.Errorf("expected provider username to be: %s but got: %s", options.Username, ropc.username) + } + if ropc.password != options.Password { + t.Errorf("expected provider password to be: %s but got: %s", options.Password, ropc.password) + } + }) + + t.Run("newTokenProvider should return resource owner token provider with correct fields", func(t *testing.T) { + options := &Options{ + ClientID: "testclient", + ServerID: "testserver", + IdentityResourceID: "testidentity", + LoginMethod: "msi", + } + provider, err := newTokenProvider(options) + if err != nil || provider == nil { + t.Errorf("expected no error but got: %s", err) + } + msi := provider.(*managedIdentityToken) + if msi.clientID != options.ClientID { + t.Errorf("expected provider client ID to be: %s but got: %s", options.ClientID, msi.clientID) + } + if msi.resourceID != options.ServerID { + t.Errorf("expected provider resource ID to be: %s but got: %s", options.ServerID, msi.resourceID) + } + if msi.identityResourceID != options.IdentityResourceID { + t.Errorf("expected provider identity resource ID to be: %s but got: %s", options.IdentityResourceID, msi.identityResourceID) + } + }) + + t.Run("newTokenProvider should return azure CLI token provider with correct fields", func(t *testing.T) { + options := &Options{ + ServerID: "testserver", + TenantID: "testtenant", + LoginMethod: "azurecli", + } + provider, err := newTokenProvider(options) + if err != nil || provider == nil { + t.Errorf("expected no error but got: %s", err) + } + msi := provider.(*AzureCLIToken) + if msi.tenantID != options.TenantID { + t.Errorf("expected provider tenant ID to be: %s but got: %s", options.TenantID, msi.tenantID) + } + if msi.resourceID != options.ServerID { + t.Errorf("expected provider resource ID to be: %s but got: %s", options.ServerID, msi.resourceID) + } + }) + + t.Run("newTokenProvider should return workload identity token provider with correct fields", func(t *testing.T) { + options := &Options{ + TenantID: "testtenant", + ClientID: "testclient", + ServerID: "testserver", + FederatedTokenFile: "testfile", + AuthorityHost: "testauthority", + LoginMethod: "workloadidentity", + } + provider, err := newTokenProvider(options) + if err != nil || provider == nil { + t.Errorf("expected no error but got: %s", err) + } + workloadId := provider.(*workloadIdentityToken) + if workloadId.clientID != options.ClientID { + t.Errorf("expected provider client ID to be: %s but got: %s", options.ClientID, workloadId.clientID) + } + if workloadId.serverID != options.ServerID { + t.Errorf("expected provider server ID to be: %s but got: %s", options.ServerID, workloadId.serverID) + } + if workloadId.tenantID != options.TenantID { + t.Errorf("expected provider tenant ID to be: %s but got: %s", options.TenantID, workloadId.tenantID) + } + if workloadId.federatedTokenFile != options.FederatedTokenFile { + t.Errorf("expected provider federated token file to be: %s but got: %s", options.FederatedTokenFile, workloadId.federatedTokenFile) + } + if workloadId.authorityHost != options.AuthorityHost { + t.Errorf("expected provider authority host to be: %s but got: %s", options.AuthorityHost, workloadId.authorityHost) + } + }) +} diff --git a/pkg/token/serviceprincipaltoken.go b/pkg/token/serviceprincipaltoken.go index 7ce2034e..73da9c09 100644 --- a/pkg/token/serviceprincipaltoken.go +++ b/pkg/token/serviceprincipaltoken.go @@ -2,21 +2,14 @@ package token import ( "context" - "crypto/rsa" - "crypto/x509" "encoding/json" - "encoding/pem" "errors" "fmt" - "os" "strconv" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" - "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" - "golang.org/x/crypto/pkcs12" ) const ( @@ -32,17 +25,27 @@ type servicePrincipalToken struct { resourceID string tenantID string cloud cloud.Configuration + popClaims map[string]string } -func newServicePrincipalToken(cloud cloud.Configuration, clientID, clientSecret, clientCert, clientCertPassword, resourceID, tenantID string) (TokenProvider, error) { +func newServicePrincipalTokenProvider( + cloud cloud.Configuration, + clientID, + clientSecret, + clientCert, + clientCertPassword, + resourceID, + tenantID string, + popClaims map[string]string, +) (TokenProvider, error) { if clientID == "" { return nil, errors.New("clientID cannot be empty") } if clientSecret == "" && clientCert == "" { - return nil, errors.New("both clientSecret and clientcert cannot be empty") + return nil, errors.New("both clientSecret and clientcert cannot be empty. One must be specified") } if clientSecret != "" && clientCert != "" { - return nil, errors.New("client secret and client certificate cannot be set at the same time. Only one has to be specified") + return nil, errors.New("client secret and client certificate cannot be set at the same time. Only one can be specified") } if resourceID == "" { return nil, errors.New("resourceID cannot be empty") @@ -59,6 +62,7 @@ func newServicePrincipalToken(cloud cloud.Configuration, clientID, clientSecret, resourceID: resourceID, tenantID: tenantID, cloud: cloud, + popClaims: popClaims, }, nil } @@ -68,191 +72,41 @@ func (p *servicePrincipalToken) Token() (adal.Token, error) { } func (p *servicePrincipalToken) TokenWithOptions(options *azcore.ClientOptions) (adal.Token, error) { + ctx := context.Background() emptyToken := adal.Token{} - var spnAccessToken azcore.AccessToken + var accessToken string + var expirationTimeUnix int64 + var err error + scopes := []string{p.resourceID + "/.default"} // Request a new Azure token provider for service principal if p.clientSecret != "" { - clientOptions := &azidentity.ClientSecretCredentialOptions{ - ClientOptions: azcore.ClientOptions{ - Cloud: p.cloud, - }, - } - if options != nil { - clientOptions.ClientOptions = *options - } - cred, err := azidentity.NewClientSecretCredential( - p.tenantID, - p.clientID, - p.clientSecret, - clientOptions, - ) - if err != nil { - return emptyToken, fmt.Errorf("unable to create credential. Received: %w", err) - } - - // Use the token provider to get a new token - spnAccessToken, err = cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{p.resourceID + "/.default"}}) + accessToken, expirationTimeUnix, err = p.getTokenWithClientSecret(ctx, scopes, options) if err != nil { return emptyToken, fmt.Errorf("failed to create service principal token using secret: %w", err) } - } else if p.clientCert != "" { - clientOptions := &azidentity.ClientCertificateCredentialOptions{ - ClientOptions: azcore.ClientOptions{ - Cloud: p.cloud, - }, - SendCertificateChain: true, - } - if options != nil { - clientOptions.ClientOptions = *options - } - certData, err := os.ReadFile(p.clientCert) + accessToken, expirationTimeUnix, err = p.getTokenWithClientCert(ctx, scopes, options) if err != nil { - return emptyToken, fmt.Errorf("failed to read the certificate file (%s): %w", p.clientCert, err) + return emptyToken, fmt.Errorf("failed to create service principal token using certificate: %w", err) } - - // Get the certificate and private key from pfx file - cert, rsaPrivateKey, err := decodePkcs12(certData, p.clientCertPassword) - if err != nil { - return emptyToken, fmt.Errorf("failed to decode pkcs12 certificate while creating spt: %w", err) - } - - cred, err := azidentity.NewClientCertificateCredential( - p.tenantID, - p.clientID, - []*x509.Certificate{cert}, - rsaPrivateKey, - clientOptions, - ) - if err != nil { - return emptyToken, fmt.Errorf("unable to create credential. Received: %v", err) - } - spnAccessToken, err = cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{p.resourceID + "/.default"}}) - if err != nil { - return emptyToken, fmt.Errorf("failed to create service principal token using cert: %s", err) - } - } else { return emptyToken, errors.New("service principal token requires either client secret or certificate") } - if spnAccessToken.Token == "" { + if accessToken == "" { return emptyToken, errors.New("unexpectedly got empty access 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(spnAccessToken.ExpiresOn.Unix(), 10)) + expiresOn := json.Number(strconv.FormatInt(expirationTimeUnix, 10)) // Re-wrap the azurecore.AccessToken into an adal.Token return adal.Token{ - AccessToken: spnAccessToken.Token, + AccessToken: accessToken, ExpiresOn: expiresOn, Resource: p.resourceID, }, nil } - -func isPublicKeyEqual(key1, key2 *rsa.PublicKey) bool { - if key1.N == nil || key2.N == nil { - return false - } - return key1.E == key2.E && key1.N.Cmp(key2.N) == 0 -} - -func splitPEMBlock(pemBlock []byte) (certPEM []byte, keyPEM []byte) { - for { - var derBlock *pem.Block - derBlock, pemBlock = pem.Decode(pemBlock) - if derBlock == nil { - break - } - if derBlock.Type == certificate { - certPEM = append(certPEM, pem.EncodeToMemory(derBlock)...) - } else if derBlock.Type == privateKey { - keyPEM = append(keyPEM, pem.EncodeToMemory(derBlock)...) - } - } - - return certPEM, keyPEM -} - -func parseRsaPrivateKey(privateKeyPEM []byte) (*rsa.PrivateKey, error) { - block, _ := pem.Decode(privateKeyPEM) - if block == nil { - return nil, fmt.Errorf("failed to decode a pem block from private key") - } - - privatePkcs1Key, errPkcs1 := x509.ParsePKCS1PrivateKey(block.Bytes) - if errPkcs1 == nil { - return privatePkcs1Key, nil - } - - privatePkcs8Key, errPkcs8 := x509.ParsePKCS8PrivateKey(block.Bytes) - if errPkcs8 == nil { - privatePkcs8RsaKey, ok := privatePkcs8Key.(*rsa.PrivateKey) - if !ok { - return nil, fmt.Errorf("pkcs8 contained non-RSA key. Expected RSA key") - } - return privatePkcs8RsaKey, nil - } - - return nil, fmt.Errorf("failed to parse private key as Pkcs#1 or Pkcs#8. (%s). (%s)", errPkcs1, errPkcs8) -} - -func parseKeyPairFromPEMBlock(pemBlock []byte) (*x509.Certificate, *rsa.PrivateKey, error) { - certPEM, keyPEM := splitPEMBlock(pemBlock) - - privateKey, err := parseRsaPrivateKey(keyPEM) - if err != nil { - return nil, nil, err - } - - found := false - var cert *x509.Certificate - for { - var certBlock *pem.Block - var err error - certBlock, certPEM = pem.Decode(certPEM) - if certBlock == nil { - break - } - - cert, err = x509.ParseCertificate(certBlock.Bytes) - if err != nil { - return nil, nil, fmt.Errorf("unable to parse certificate. %w", err) - } - - certPublicKey, ok := cert.PublicKey.(*rsa.PublicKey) - if ok { - if isPublicKeyEqual(certPublicKey, &privateKey.PublicKey) { - found = true - break - } - } - } - - if !found { - return nil, nil, fmt.Errorf("unable to find a matching public certificate") - } - - return cert, privateKey, nil -} - -func decodePkcs12(pkcs []byte, password string) (*x509.Certificate, *rsa.PrivateKey, error) { - blocks, err := pkcs12.ToPEM(pkcs, password) - if err != nil { - return nil, nil, err - } - - var ( - pemData []byte - ) - - for _, b := range blocks { - pemData = append(pemData, pem.EncodeToMemory(b)...) - } - - return parseKeyPairFromPEMBlock(pemData) -} diff --git a/pkg/token/serviceprincipaltoken_test.go b/pkg/token/serviceprincipaltoken_test.go index fb5f8c92..866a7a9f 100644 --- a/pkg/token/serviceprincipaltoken_test.go +++ b/pkg/token/serviceprincipaltoken_test.go @@ -7,69 +7,155 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/kubelogin/pkg/testutils" + "github.com/golang-jwt/jwt/v5" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" "gopkg.in/dnaeon/go-vcr.v3/recorder" ) -const ( - clientID = "AZURE_CLIENT_ID" - clientSecret = "AAD_SERVICE_PRINCIPAL_CLIENT_SECRET" - clientCert = "AZURE_CLIENT_CER" - clientCertPass = "AZURE_CLIENT_CERTIFICATE_PASSWORD" - resourceID = "AZURE_RESOURCE_ID" - tenantID = "AZURE_TENANT_ID" -) - -func TestMissingLoginMethods(t *testing.T) { - p := &servicePrincipalToken{} - expectedErr := "service principal token requires either client secret or certificate" - - _, err := p.Token() - if !ErrorContains(err, expectedErr) { - t.Errorf("expected error %s, but got %s", expectedErr, err) +func TestNewServicePrincipalTokenProvider(t *testing.T) { + testCases := []struct { + name string + clientID string + clientSecret string + clientCert string + clientCertPassword string + resourceID string + tenantID string + popClaims map[string]string + expectedError string + }{ + { + name: "test new service principal token provider with empty client ID should return error", + expectedError: "clientID cannot be empty", + }, + { + name: "test new service principal token provider with empty client secret and cert should return error", + clientID: "testclientid", + expectedError: "both clientSecret and clientcert cannot be empty. One must be specified", + }, + { + name: "test new service principal token provider with both client secret and cert provided should return error", + clientID: "testclientid", + clientSecret: "testsecret", + clientCert: "testcert", + expectedError: "client secret and client certificate cannot be set at the same time. Only one can be specified", + }, + { + name: "test new service principal token provider with empty resource ID should return error", + clientID: "testclientid", + clientSecret: "testsecret", + expectedError: "resourceID cannot be empty", + }, + { + name: "test new service principal token provider with empty tenant ID should return error", + clientID: "testclientid", + clientCert: "testcert", + resourceID: "testresource", + expectedError: "tenantID cannot be empty", + }, + { + name: "test new service principal token provider with cert fields should not return error", + clientID: "testclientid", + clientCert: "testcert", + clientCertPassword: "testpass", + resourceID: "testresource", + tenantID: "testtenant", + expectedError: "", + }, + { + name: "test new service principal token provider with secret and pop claims should not return error", + clientID: "testclientid", + clientSecret: "testsecret", + resourceID: "testresource", + tenantID: "testtenant", + popClaims: map[string]string{"u": "testhost"}, + expectedError: "", + }, } -} -func TestMissingCertFile(t *testing.T) { - p := &servicePrincipalToken{ - clientCert: "testdata/noCertHere.pfx", + cloudConfig := cloud.Configuration{ + ActiveDirectoryAuthorityHost: "testendpoint", } - expectedErr := "failed to read the certificate file" + var tokenProvider TokenProvider + var err error + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tokenProvider, err = newServicePrincipalTokenProvider( + cloudConfig, + tc.clientID, + tc.clientSecret, + tc.clientCert, + tc.clientCertPassword, + tc.resourceID, + tc.tenantID, + tc.popClaims, + ) - _, err := p.Token() - if !ErrorContains(err, expectedErr) { - t.Errorf("expected error %s, but got %s", expectedErr, err) + if tc.expectedError != "" { + if !testutils.ErrorContains(err, tc.expectedError) { + t.Errorf("expected error %s, but got %s", tc.expectedError, err) + } + } else { + if tokenProvider == nil { + t.Errorf("expected token provider creation to succeed, but got error: %s", err) + } + spnTokenProvider := tokenProvider.(*servicePrincipalToken) + if spnTokenProvider.clientID != tc.clientID { + t.Errorf("expected client ID: %s but got: %s", tc.clientID, spnTokenProvider.clientID) + } + if spnTokenProvider.clientSecret != tc.clientSecret { + t.Errorf("expected client secret: %s but got: %s", tc.clientSecret, spnTokenProvider.clientSecret) + } + if spnTokenProvider.clientCert != tc.clientCert { + t.Errorf("expected client cert: %s but got: %s", tc.clientCert, spnTokenProvider.clientCert) + } + if spnTokenProvider.clientCertPassword != tc.clientCertPassword { + t.Errorf("expected client cert password: %s but got: %s", tc.clientCertPassword, spnTokenProvider.clientCertPassword) + } + if spnTokenProvider.resourceID != tc.resourceID { + t.Errorf("expected resource ID: %s but got: %s", tc.resourceID, spnTokenProvider.resourceID) + } + if spnTokenProvider.tenantID != tc.tenantID { + t.Errorf("expected tenant ID: %s but got: %s", tc.tenantID, spnTokenProvider.tenantID) + } + if !cmp.Equal(spnTokenProvider.cloud, cloudConfig) { + t.Errorf("expected cloud config: %s but got: %s", tc.clientCertPassword, spnTokenProvider.clientCertPassword) + } + if !cmp.Equal(spnTokenProvider.popClaims, tc.popClaims) { + t.Errorf("expected PoP claims: %s but got: %s", tc.popClaims, spnTokenProvider.popClaims) + } + } + }) } } -func TestBadCertPassword(t *testing.T) { - p := &servicePrincipalToken{ - clientCert: "testdata/testCert.pfx", - clientCertPassword: badSecret, - } - expectedErr := "failed to decode pkcs12 certificate while creating spt: pkcs12: decryption password incorrect" - +func TestMissingLoginMethods(t *testing.T) { + p := &servicePrincipalToken{} + expectedErr := "service principal token requires either client secret or certificate" _, err := p.Token() - if !ErrorContains(err, expectedErr) { + if !testutils.ErrorContains(err, expectedErr) { t.Errorf("expected error %s, but got %s", expectedErr, err) } } func TestServicePrincipalTokenVCR(t *testing.T) { pEnv := &servicePrincipalToken{ - clientID: os.Getenv(clientID), - clientSecret: os.Getenv(clientSecret), - clientCert: os.Getenv(clientCert), - clientCertPassword: os.Getenv(clientCertPass), - resourceID: os.Getenv(resourceID), - tenantID: os.Getenv(tenantID), + clientID: os.Getenv(testutils.ClientID), + clientSecret: os.Getenv(testutils.ClientSecret), + clientCert: os.Getenv(testutils.ClientCert), + clientCertPassword: os.Getenv(testutils.ClientCertPass), + resourceID: os.Getenv(testutils.ResourceID), + tenantID: os.Getenv(testutils.TenantID), } // Use defaults if environmental variables are empty if pEnv.clientID == "" { - pEnv.clientID = clientID + pEnv.clientID = testutils.ClientID } if pEnv.clientSecret == "" { - pEnv.clientSecret = clientSecret + pEnv.clientSecret = testutils.ClientSecret } if pEnv.clientCert == "" { pEnv.clientCert = "testdata/testCert.pfx" @@ -78,25 +164,25 @@ func TestServicePrincipalTokenVCR(t *testing.T) { pEnv.clientCertPassword = "TestPassword" } if pEnv.resourceID == "" { - pEnv.resourceID = resourceID + pEnv.resourceID = testutils.ResourceID } if pEnv.tenantID == "" { pEnv.tenantID = "00000000-0000-0000-0000-000000000000" } var expectedToken string - testCase := []struct { - cassetteName string - p *servicePrincipalToken - expectedError error - useSecret bool + cassetteName string + p *servicePrincipalToken + expectedError error + useSecret bool + expectedTokenType string }{ { // Test using incorrect secret value cassetteName: "ServicePrincipalTokenFromBadSecretVCR", p: &servicePrincipalToken{ clientID: pEnv.clientID, - clientSecret: badSecret, + clientSecret: testutils.BadSecret, resourceID: pEnv.resourceID, tenantID: pEnv.tenantID, }, @@ -135,7 +221,7 @@ func TestServicePrincipalTokenVCR(t *testing.T) { if tc.expectedError == nil { expectedToken = uuid.New().String() } - vcrRecorder, httpClient := GetVCRHttpClient(fmt.Sprintf("testdata/%s", tc.cassetteName), expectedToken) + vcrRecorder, httpClient := testutils.GetVCRHttpClient(fmt.Sprintf("testdata/%s", tc.cassetteName), expectedToken) clientOpts := azcore.ClientOptions{ Cloud: cloud.AzurePublic, @@ -145,7 +231,7 @@ func TestServicePrincipalTokenVCR(t *testing.T) { token, err := tc.p.TokenWithOptions(&clientOpts) defer vcrRecorder.Stop() if err != nil { - if !ErrorContains(err, tc.expectedError.Error()) { + if !testutils.ErrorContains(err, tc.expectedError.Error()) { t.Errorf("expected error %s, but got %s", tc.expectedError.Error(), err) } } else { @@ -161,3 +247,129 @@ func TestServicePrincipalTokenVCR(t *testing.T) { }) } } + +func TestServicePrincipalPoPTokenVCR(t *testing.T) { + pEnv := &servicePrincipalToken{ + clientID: os.Getenv(testutils.ClientID), + clientSecret: os.Getenv(testutils.ClientSecret), + clientCert: os.Getenv(testutils.ClientCert), + clientCertPassword: os.Getenv(testutils.ClientCertPass), + resourceID: os.Getenv(testutils.ResourceID), + tenantID: os.Getenv(testutils.TenantID), + } + // Use defaults if environmental variables are empty + if pEnv.clientID == "" { + pEnv.clientID = testutils.ClientID + } + if pEnv.clientSecret == "" { + pEnv.clientSecret = testutils.ClientSecret + } + if pEnv.clientCert == "" { + pEnv.clientCert = "testdata/testCert.pfx" + } + if pEnv.clientCertPassword == "" { + pEnv.clientCertPassword = "TestPassword" + } + if pEnv.resourceID == "" { + pEnv.resourceID = testutils.ResourceID + } + if pEnv.tenantID == "" { + pEnv.tenantID = "00000000-0000-0000-0000-000000000000" + } + var expectedToken string + var err error + var token adal.Token + expectedTokenType := "pop" + testCase := []struct { + cassetteName string + p *servicePrincipalToken + expectedError error + useSecret bool + }{ + { + // Test using bad client secret + cassetteName: "ServicePrincipalPoPTokenFromBadSecretVCR", + p: &servicePrincipalToken{ + clientID: pEnv.clientID, + clientSecret: testutils.BadSecret, + resourceID: pEnv.resourceID, + tenantID: pEnv.tenantID, + popClaims: map[string]string{"u": "testhost"}, + cloud: cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/AZURE_TENANT_ID", + }, + }, + expectedError: fmt.Errorf("failed to create service principal PoP token using secret"), + useSecret: true, + }, + { + // Test using service principal secret value to get PoP token + cassetteName: "ServicePrincipalPoPTokenFromSecretVCR", + p: &servicePrincipalToken{ + clientID: pEnv.clientID, + clientSecret: pEnv.clientSecret, + resourceID: pEnv.resourceID, + tenantID: pEnv.tenantID, + popClaims: map[string]string{"u": "testhost"}, + cloud: cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/AZURE_TENANT_ID", + }, + }, + expectedError: nil, + useSecret: true, + }, + { + // Test using service principal certificate to get PoP token + cassetteName: "ServicePrincipalPoPTokenFromCertVCR", + p: &servicePrincipalToken{ + clientID: pEnv.clientID, + clientCert: pEnv.clientCert, + clientCertPassword: pEnv.clientCertPassword, + resourceID: pEnv.resourceID, + tenantID: pEnv.tenantID, + popClaims: map[string]string{"u": "testhost"}, + cloud: cloud.Configuration{ + ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/AZURE_TENANT_ID", + }, + }, + expectedError: nil, + useSecret: false, + }, + } + + for _, tc := range testCase { + t.Run(tc.cassetteName, func(t *testing.T) { + if tc.expectedError == nil { + expectedToken = uuid.New().String() + } + vcrRecorder, httpClient := testutils.GetVCRHttpClient(fmt.Sprintf("testdata/%s", tc.cassetteName), expectedToken) + + clientOpts := azcore.ClientOptions{ + Cloud: cloud.AzurePublic, + Transport: httpClient, + } + + token, err = tc.p.TokenWithOptions(&clientOpts) + defer vcrRecorder.Stop() + if err != nil { + if !testutils.ErrorContains(err, tc.expectedError.Error()) { + t.Errorf("expected error %s, but got %s", tc.expectedError.Error(), err) + } + } else { + if token.AccessToken == "" { + t.Error("expected valid token, but received empty token.") + } + claims := jwt.MapClaims{} + parsed, _ := jwt.ParseWithClaims(token.AccessToken, &claims, nil) + if vcrRecorder.Mode() == recorder.ModeReplayOnly { + if claims["at"] != expectedToken { + t.Errorf("unexpected token returned (expected %s, but got %s)", expectedToken, claims["at"]) + } + if parsed.Header["typ"] != expectedTokenType { + t.Errorf("unexpected token returned (expected %s, but got %s)", expectedTokenType, parsed.Header["typ"]) + } + } + } + }) + } +} diff --git a/pkg/token/serviceprincipaltokencertificate.go b/pkg/token/serviceprincipaltokencertificate.go new file mode 100644 index 00000000..219f702f --- /dev/null +++ b/pkg/token/serviceprincipaltokencertificate.go @@ -0,0 +1,203 @@ +package token + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/kubelogin/pkg/pop" + "github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential" + "golang.org/x/crypto/pkcs12" +) + +// getTokenWithClientCert requests a token using the configured client ID/certificate +// and returns a PoP token if PoP claims are provided, otherwise returns a regular +// bearer token +func (p *servicePrincipalToken) getTokenWithClientCert( + context context.Context, + scopes []string, + options *azcore.ClientOptions, +) (string, int64, error) { + clientOptions := &azidentity.ClientCertificateCredentialOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: p.cloud, + }, + SendCertificateChain: true, + } + if options != nil { + clientOptions.ClientOptions = *options + } + certData, err := os.ReadFile(p.clientCert) + if err != nil { + return "", -1, fmt.Errorf("failed to read the certificate file (%s): %w", p.clientCert, err) + } + + // Get the certificate and private key from pfx file + cert, rsaPrivateKey, err := decodePkcs12(certData, p.clientCertPassword) + if err != nil { + return "", -1, fmt.Errorf("failed to decode pkcs12 certificate while creating spt: %w", err) + } + + certArray := []*x509.Certificate{cert} + if len(p.popClaims) > 0 { + // if PoP token support is enabled, use the PoP token flow to request the token + return p.getPoPTokenWithClientCert(context, scopes, certArray, rsaPrivateKey, options) + } + + cred, err := azidentity.NewClientCertificateCredential( + p.tenantID, + p.clientID, + certArray, + rsaPrivateKey, + clientOptions, + ) + if err != nil { + return "", -1, fmt.Errorf("unable to create credential. Received: %v", err) + } + spnAccessToken, err := cred.GetToken(context, policy.TokenRequestOptions{Scopes: scopes}) + if err != nil { + return "", -1, fmt.Errorf("failed to create service principal token using cert: %s", err) + } + + return spnAccessToken.Token, spnAccessToken.ExpiresOn.Unix(), nil +} + +// getPoPTokenWithClientCert requests a PoP token using the given client ID/certificate +// and returns it +func (p *servicePrincipalToken) getPoPTokenWithClientCert( + context context.Context, + scopes []string, + certArray []*x509.Certificate, + rsaPrivateKey *rsa.PrivateKey, + options *azcore.ClientOptions, +) (string, int64, error) { + cred, err := confidential.NewCredFromCert(certArray, rsaPrivateKey) + if err != nil { + return "", -1, fmt.Errorf("unable to create credential from certificate. Received: %w", err) + } + + accessToken, expiresOn, err := pop.AcquirePoPTokenConfidential( + context, + p.popClaims, + scopes, + cred, + p.cloud.ActiveDirectoryAuthorityHost, + p.clientID, + p.tenantID, + options, + ) + if err != nil { + return "", -1, fmt.Errorf("failed to create service principal PoP token using certificate: %w", err) + } + + return accessToken, expiresOn, nil +} + +func isPublicKeyEqual(key1, key2 *rsa.PublicKey) bool { + if key1.N == nil || key2.N == nil { + return false + } + return key1.E == key2.E && key1.N.Cmp(key2.N) == 0 +} + +func splitPEMBlock(pemBlock []byte) (certPEM []byte, keyPEM []byte) { + for { + var derBlock *pem.Block + derBlock, pemBlock = pem.Decode(pemBlock) + if derBlock == nil { + break + } + if derBlock.Type == certificate { + certPEM = append(certPEM, pem.EncodeToMemory(derBlock)...) + } else if derBlock.Type == privateKey { + keyPEM = append(keyPEM, pem.EncodeToMemory(derBlock)...) + } + } + + return certPEM, keyPEM +} + +func parseRsaPrivateKey(privateKeyPEM []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(privateKeyPEM) + if block == nil { + return nil, fmt.Errorf("failed to decode a pem block from private key") + } + + privatePkcs1Key, errPkcs1 := x509.ParsePKCS1PrivateKey(block.Bytes) + if errPkcs1 == nil { + return privatePkcs1Key, nil + } + + privatePkcs8Key, errPkcs8 := x509.ParsePKCS8PrivateKey(block.Bytes) + if errPkcs8 == nil { + privatePkcs8RsaKey, ok := privatePkcs8Key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("pkcs8 contained non-RSA key. Expected RSA key") + } + return privatePkcs8RsaKey, nil + } + + return nil, fmt.Errorf("failed to parse private key as Pkcs#1 or Pkcs#8. (%s). (%s)", errPkcs1, errPkcs8) +} + +func parseKeyPairFromPEMBlock(pemBlock []byte) (*x509.Certificate, *rsa.PrivateKey, error) { + certPEM, keyPEM := splitPEMBlock(pemBlock) + + privateKey, err := parseRsaPrivateKey(keyPEM) + if err != nil { + return nil, nil, err + } + + found := false + var cert *x509.Certificate + for { + var certBlock *pem.Block + var err error + certBlock, certPEM = pem.Decode(certPEM) + if certBlock == nil { + break + } + + cert, err = x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, nil, fmt.Errorf("unable to parse certificate. %w", err) + } + + certPublicKey, ok := cert.PublicKey.(*rsa.PublicKey) + if ok { + if isPublicKeyEqual(certPublicKey, &privateKey.PublicKey) { + found = true + break + } + } + } + + if !found { + return nil, nil, fmt.Errorf("unable to find a matching public certificate") + } + + return cert, privateKey, nil +} + +func decodePkcs12(pkcs []byte, password string) (*x509.Certificate, *rsa.PrivateKey, error) { + blocks, err := pkcs12.ToPEM(pkcs, password) + if err != nil { + return nil, nil, err + } + + var ( + pemData []byte + ) + + for _, b := range blocks { + pemData = append(pemData, pem.EncodeToMemory(b)...) + } + + return parseKeyPairFromPEMBlock(pemData) +} diff --git a/pkg/token/serviceprincipaltokencertificate_test.go b/pkg/token/serviceprincipaltokencertificate_test.go new file mode 100644 index 00000000..b5f04ccf --- /dev/null +++ b/pkg/token/serviceprincipaltokencertificate_test.go @@ -0,0 +1,32 @@ +package token + +import ( + "testing" + + "github.com/Azure/kubelogin/pkg/testutils" +) + +func TestMissingCertFile(t *testing.T) { + p := &servicePrincipalToken{ + clientCert: "testdata/noCertHere.pfx", + } + expectedErr := "failed to read the certificate file" + + _, err := p.Token() + if !testutils.ErrorContains(err, expectedErr) { + t.Errorf("expected error %s, but got %s", expectedErr, err) + } +} + +func TestBadCertPassword(t *testing.T) { + p := &servicePrincipalToken{ + clientCert: "testdata/testCert.pfx", + clientCertPassword: testutils.BadSecret, + } + expectedErr := "failed to decode pkcs12 certificate while creating spt: pkcs12: decryption password incorrect" + + _, err := p.Token() + if !testutils.ErrorContains(err, expectedErr) { + t.Errorf("expected error %s, but got %s", expectedErr, err) + } +} diff --git a/pkg/token/serviceprincipaltokensecret.go b/pkg/token/serviceprincipaltokensecret.go new file mode 100644 index 00000000..398a88e0 --- /dev/null +++ b/pkg/token/serviceprincipaltokensecret.go @@ -0,0 +1,79 @@ +package token + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/kubelogin/pkg/pop" + "github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential" +) + +// getTokenWithClientSecret requests a token using the configured client ID/secret +// and returns a PoP token if PoP claims are provided, otherwise returns a regular +// bearer token +func (p *servicePrincipalToken) getTokenWithClientSecret( + context context.Context, + scopes []string, + options *azcore.ClientOptions, +) (string, int64, error) { + clientOptions := &azidentity.ClientSecretCredentialOptions{ + ClientOptions: azcore.ClientOptions{ + Cloud: p.cloud, + }, + } + if options != nil { + clientOptions.ClientOptions = *options + } + if len(p.popClaims) > 0 { + // if PoP token support is enabled, use the PoP token flow to request the token + return p.getPoPTokenWithClientSecret(context, scopes, options) + } + cred, err := azidentity.NewClientSecretCredential( + p.tenantID, + p.clientID, + p.clientSecret, + clientOptions, + ) + if err != nil { + return "", -1, fmt.Errorf("unable to create credential. Received: %w", err) + } + + // Use the token provider to get a new token + spnAccessToken, err := cred.GetToken(context, policy.TokenRequestOptions{Scopes: scopes}) + if err != nil { + return "", -1, fmt.Errorf("failed to create service principal bearer token using secret: %w", err) + } + + return spnAccessToken.Token, spnAccessToken.ExpiresOn.Unix(), nil +} + +// getPoPTokenWithClientSecret requests a PoP token using the given client +// ID/secret and returns it +func (p *servicePrincipalToken) getPoPTokenWithClientSecret( + context context.Context, + scopes []string, + options *azcore.ClientOptions, +) (string, int64, error) { + cred, err := confidential.NewCredFromSecret(p.clientSecret) + if err != nil { + return "", -1, fmt.Errorf("unable to create credential. Received: %w", err) + } + accessToken, expiresOn, err := pop.AcquirePoPTokenConfidential( + context, + p.popClaims, + scopes, + cred, + p.cloud.ActiveDirectoryAuthorityHost, + p.clientID, + p.tenantID, + options, + ) + if err != nil { + return "", -1, fmt.Errorf("failed to create service principal PoP token using secret: %w", err) + } + + return accessToken, expiresOn, nil +} diff --git a/pkg/token/testdata/ServicePrincipalPoPTokenFromBadSecretVCR.yaml b/pkg/token/testdata/ServicePrincipalPoPTokenFromBadSecretVCR.yaml new file mode 100644 index 00000000..56007420 --- /dev/null +++ b/pkg/token/testdata/ServicePrincipalPoPTokenFromBadSecretVCR.yaml @@ -0,0 +1,220 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 7284108a-16f9-4481-9594-e5c464b6507f + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https%3A%2F%2Flogin.microsoftonline.com%2FAZURE_TENANT_ID%2Foauth2%2Fv2.0%2Fauthorize + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 980 + uncompressed: false + body: '{"tenant_discovery_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration","api-version":"1.1","metadata":[{"preferred_network":"login.microsoftonline.com","preferred_cache":"login.windows.net","aliases":["login.microsoftonline.com","login.windows.net","login.microsoft.com","sts.windows.net"]},{"preferred_network":"login.partner.microsoftonline.cn","preferred_cache":"login.partner.microsoftonline.cn","aliases":["login.partner.microsoftonline.cn","login.chinacloudapi.cn"]},{"preferred_network":"login.microsoftonline.de","preferred_cache":"login.microsoftonline.de","aliases":["login.microsoftonline.de"]},{"preferred_network":"login.microsoftonline.us","preferred_cache":"login.microsoftonline.us","aliases":["login.microsoftonline.us","login.usgovcloudapi.net"]},{"preferred_network":"login-us.microsoftonline.com","preferred_cache":"login-us.microsoftonline.com","aliases":["login-us.microsoftonline.com"]}]}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - 7284108a-16f9-4481-9594-e5c464b6507f + Content-Length: + - "980" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 31 Aug 2023 17:16:28 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16150.3 - EUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 290.911842ms + - id: 1 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - e3b8e802-0639-4b81-9f0b-a6e88a4dda3f + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 1753 + uncompressed: false + body: '{"token_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token","token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],"jwks_uri":"https://login.microsoftonline.com/AZURE_TENANT_ID/discovery/v2.0/keys","response_modes_supported":["query","fragment","form_post"],"subject_types_supported":["pairwise"],"id_token_signing_alg_values_supported":["RS256"],"response_types_supported":["code","id_token","code id_token","id_token token"],"scopes_supported":["openid","profile","email","offline_access"],"issuer":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0","request_uri_parameter_supported":false,"userinfo_endpoint":"https://graph.microsoft.com/oidc/userinfo","authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/authorize","device_authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/devicecode","http_logout_supported":true,"frontchannel_logout_supported":true,"end_session_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/logout","claims_supported":["sub","iss","cloud_instance_name","cloud_instance_host_name","cloud_graph_host_name","msgraph_host","aud","exp","iat","auth_time","acr","nonce","preferred_username","name","tid","ver","at_hash","c_hash","email"],"kerberos_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/kerberos","tenant_region_scope":"WW","cloud_instance_name":"microsoftonline.com","cloud_graph_host_name":"graph.windows.net","msgraph_host":"graph.microsoft.com","rbac_url":"https://pas.windows.net"}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - e3b8e802-0639-4b81-9f0b-a6e88a4dda3f + Content-Length: + - "1753" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 31 Aug 2023 17:16:29 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16209.3 - EUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 319.257624ms + - id: 2 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 330 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: client_id=[REDACTED]&client_secret=Bad_Secret&grant_type=client_credentials&req_cnf=[REDACTED]&scope=[REDACTED]%2F.default+openid+offline_access+profile+openid+offline_access+profile&token_type=pop + form: + client_id: + - '[REDACTED]' + client_secret: + - Bad_Secret + grant_type: + - client_credentials + req_cnf: + - '[REDACTED]' + scope: + - '[REDACTED]/.default openid offline_access profile' + token_type: + - pop + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 9e99d829-f6b6-45e3-b2f7-7ee552cdaa06 + Content-Type: + - application/x-www-form-urlencoded; charset=utf-8 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 632 + uncompressed: false + body: '{"error":"invalid_client","error_description":"AADSTS7000215: Invalid client secret provided. Ensure the secret being sent in the request is the client secret value, not the client secret ID, for a secret added to app ''''[REDACTED]''''.\r\nTrace ID: [REDACTED]\r\nCorrelation ID: [REDACTED]\r\nTimestamp: 2023-06-02 21:00:26Z","error_codes":[7000215],"timestamp":"2023-06-02 21:00:26Z","trace_id":"[REDACTED]","correlation_id":"[REDACTED]","error_uri":"https://login.microsoftonline.com/error?code=7000215"}' + headers: + Cache-Control: + - no-store, no-cache + Client-Request-Id: + - 9e99d829-f6b6-45e3-b2f7-7ee552cdaa06 + Content-Length: + - "632" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 31 Aug 2023 17:16:29 GMT + Expires: + - "-1" + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Clitelem: + - 1,7000215,0,, + X-Ms-Ests-Server: + - 2.1.16209.3 - SCUS ProdSlices + X-Xss-Protection: + - "0" + status: 401 Unauthorized + code: 401 + duration: 128.330915ms diff --git a/pkg/token/testdata/ServicePrincipalPoPTokenFromCertVCR.yaml b/pkg/token/testdata/ServicePrincipalPoPTokenFromCertVCR.yaml new file mode 100644 index 00000000..3ce6c5be --- /dev/null +++ b/pkg/token/testdata/ServicePrincipalPoPTokenFromCertVCR.yaml @@ -0,0 +1,224 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 603ee4ad-772a-4888-82a3-51f7cbe8b9f3 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https%3A%2F%2Flogin.microsoftonline.com%2FAZURE_TENANT_ID%2Foauth2%2Fv2.0%2Fauthorize + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 980 + uncompressed: false + body: '{"tenant_discovery_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration","api-version":"1.1","metadata":[{"preferred_network":"login.microsoftonline.com","preferred_cache":"login.windows.net","aliases":["login.microsoftonline.com","login.windows.net","login.microsoft.com","sts.windows.net"]},{"preferred_network":"login.partner.microsoftonline.cn","preferred_cache":"login.partner.microsoftonline.cn","aliases":["login.partner.microsoftonline.cn","login.chinacloudapi.cn"]},{"preferred_network":"login.microsoftonline.de","preferred_cache":"login.microsoftonline.de","aliases":["login.microsoftonline.de"]},{"preferred_network":"login.microsoftonline.us","preferred_cache":"login.microsoftonline.us","aliases":["login.microsoftonline.us","login.usgovcloudapi.net"]},{"preferred_network":"login-us.microsoftonline.com","preferred_cache":"login-us.microsoftonline.com","aliases":["login-us.microsoftonline.com"]}]}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - 603ee4ad-772a-4888-82a3-51f7cbe8b9f3 + Content-Length: + - "980" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 31 Aug 2023 17:07:22 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16150.3 - SCUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 230.063698ms + - id: 1 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 0d698da6-bf09-4c87-887e-bddeb898b952 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 1753 + uncompressed: false + body: '{"token_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token","token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],"jwks_uri":"https://login.microsoftonline.com/AZURE_TENANT_ID/discovery/v2.0/keys","response_modes_supported":["query","fragment","form_post"],"subject_types_supported":["pairwise"],"id_token_signing_alg_values_supported":["RS256"],"response_types_supported":["code","id_token","code id_token","id_token token"],"scopes_supported":["openid","profile","email","offline_access"],"issuer":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0","request_uri_parameter_supported":false,"userinfo_endpoint":"https://graph.microsoft.com/oidc/userinfo","authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/authorize","device_authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/devicecode","http_logout_supported":true,"frontchannel_logout_supported":true,"end_session_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/logout","claims_supported":["sub","iss","cloud_instance_name","cloud_instance_host_name","cloud_graph_host_name","msgraph_host","aud","exp","iat","auth_time","acr","nonce","preferred_username","name","tid","ver","at_hash","c_hash","email"],"kerberos_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/kerberos","tenant_region_scope":"WW","cloud_instance_name":"microsoftonline.com","cloud_graph_host_name":"graph.windows.net","msgraph_host":"graph.microsoft.com","rbac_url":"https://pas.windows.net"}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - 0d698da6-bf09-4c87-887e-bddeb898b952 + Content-Length: + - "1753" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 31 Aug 2023 17:07:22 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16209.3 - SCUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 61.158756ms + - id: 2 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 1210 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: client_assertion=[REDACTED]&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_id=[REDACTED]&client_info=1&grant_type=client_credentials&req_cnf=[REDACTED]&scope=[REDACTED]%2F.default+openid+offline_access+profile+openid+offline_access+profile&token_type=pop + form: + client_assertion: + - '[REDACTED]' + client_assertion_type: + - urn:ietf:params:oauth:client-assertion-type:jwt-bearer + client_id: + - '[REDACTED]' + client_info: + - "1" + grant_type: + - client_credentials + req_cnf: + - '[REDACTED]' + scope: + - '[REDACTED]/.default openid offline_access profile' + token_type: + - pop + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - c8476324-8b14-47f1-88c9-3d129a847b4b + Content-Type: + - application/x-www-form-urlencoded; charset=utf-8 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 1475 + uncompressed: false + body: '{"token_type":"Bearer","expires_in":86399,"ext_expires_in":86399,"access_token":"TEST_ACCESS_TOKEN"}' + headers: + Cache-Control: + - no-store, no-cache + Client-Request-Id: + - c8476324-8b14-47f1-88c9-3d129a847b4b + Content-Length: + - "1475" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 31 Aug 2023 17:07:23 GMT + Expires: + - "-1" + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Clitelem: + - 1,0,0,, + X-Ms-Ests-Server: + - 2.1.16209.3 - WUS2 ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 614.589692ms diff --git a/pkg/token/testdata/ServicePrincipalPoPTokenFromSecretVCR.yaml b/pkg/token/testdata/ServicePrincipalPoPTokenFromSecretVCR.yaml new file mode 100644 index 00000000..786e9b4f --- /dev/null +++ b/pkg/token/testdata/ServicePrincipalPoPTokenFromSecretVCR.yaml @@ -0,0 +1,220 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - f74ebab8-6d7b-48c8-b69e-3c74e66e7369 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https%3A%2F%2Flogin.microsoftonline.com%2FAZURE_TENANT_ID%2Foauth2%2Fv2.0%2Fauthorize + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 980 + uncompressed: false + body: '{"tenant_discovery_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration","api-version":"1.1","metadata":[{"preferred_network":"login.microsoftonline.com","preferred_cache":"login.windows.net","aliases":["login.microsoftonline.com","login.windows.net","login.microsoft.com","sts.windows.net"]},{"preferred_network":"login.partner.microsoftonline.cn","preferred_cache":"login.partner.microsoftonline.cn","aliases":["login.partner.microsoftonline.cn","login.chinacloudapi.cn"]},{"preferred_network":"login.microsoftonline.de","preferred_cache":"login.microsoftonline.de","aliases":["login.microsoftonline.de"]},{"preferred_network":"login.microsoftonline.us","preferred_cache":"login.microsoftonline.us","aliases":["login.microsoftonline.us","login.usgovcloudapi.net"]},{"preferred_network":"login-us.microsoftonline.com","preferred_cache":"login-us.microsoftonline.com","aliases":["login-us.microsoftonline.com"]}]}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - f74ebab8-6d7b-48c8-b69e-3c74e66e7369 + Content-Length: + - "980" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 31 Aug 2023 17:18:50 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16150.3 - NCUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 285.223583ms + - id: 1 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 0 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: "" + form: {} + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - 81b6bbee-e624-4862-8f2f-f4fc14901fda + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration + method: GET + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 1753 + uncompressed: false + body: '{"token_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token","token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"],"jwks_uri":"https://login.microsoftonline.com/AZURE_TENANT_ID/discovery/v2.0/keys","response_modes_supported":["query","fragment","form_post"],"subject_types_supported":["pairwise"],"id_token_signing_alg_values_supported":["RS256"],"response_types_supported":["code","id_token","code id_token","id_token token"],"scopes_supported":["openid","profile","email","offline_access"],"issuer":"https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0","request_uri_parameter_supported":false,"userinfo_endpoint":"https://graph.microsoft.com/oidc/userinfo","authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/authorize","device_authorization_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/devicecode","http_logout_supported":true,"frontchannel_logout_supported":true,"end_session_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/logout","claims_supported":["sub","iss","cloud_instance_name","cloud_instance_host_name","cloud_graph_host_name","msgraph_host","aud","exp","iat","auth_time","acr","nonce","preferred_username","name","tid","ver","at_hash","c_hash","email"],"kerberos_endpoint":"https://login.microsoftonline.com/AZURE_TENANT_ID/kerberos","tenant_region_scope":"WW","cloud_instance_name":"microsoftonline.com","cloud_graph_host_name":"graph.windows.net","msgraph_host":"graph.microsoft.com","rbac_url":"https://pas.windows.net"}' + headers: + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - max-age=86400, private + Client-Request-Id: + - 81b6bbee-e624-4862-8f2f-f4fc14901fda + Content-Length: + - "1753" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 31 Aug 2023 17:18:50 GMT + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Ests-Server: + - 2.1.16209.3 - NCUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 94.112563ms + - id: 2 + request: + proto: "" + proto_major: 0 + proto_minor: 0 + content_length: 360 + transfer_encoding: [] + trailer: {} + host: "" + remote_addr: "" + request_uri: "" + body: client_id=[REDACTED]&client_secret=[REDACTED]&grant_type=client_credentials&req_cnf=[REDACTED]&scope=[REDACTED]%2F.default+openid+offline_access+profile+openid+offline_access+profile&token_type=pop + form: + client_id: + - '[REDACTED]' + client_secret: + - '[REDACTED]' + grant_type: + - client_credentials + req_cnf: + - '[REDACTED]' + scope: + - '[REDACTED]/.default openid offline_access profile' + token_type: + - pop + headers: + Accept-Encoding: + - gzip + Client-Request-Id: + - ae4179f8-f3ad-4011-a394-73469155d956 + Content-Type: + - application/x-www-form-urlencoded; charset=utf-8 + Return-Client-Request-Id: + - "false" + X-Client-Cpu: + - amd64 + X-Client-Os: + - linux + X-Client-Sku: + - MSAL.Go + X-Client-Ver: + - 1.2.0 + url: https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token + method: POST + response: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + transfer_encoding: [] + trailer: {} + content_length: 1475 + uncompressed: false + body: '{"token_type":"Bearer","expires_in":86399,"ext_expires_in":86399,"access_token":"TEST_ACCESS_TOKEN"}' + headers: + Cache-Control: + - no-store, no-cache + Client-Request-Id: + - ae4179f8-f3ad-4011-a394-73469155d956 + Content-Length: + - "1475" + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 31 Aug 2023 17:18:50 GMT + Expires: + - "-1" + P3p: + - CP="DSP CUR OTPi IND OTRi ONL FIN" + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Ms-Clitelem: + - 1,0,0,, + X-Ms-Ests-Server: + - 2.1.16209.3 - EUS ProdSlices + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 200.972924ms diff --git a/pkg/token/testdata/ServicePrincipalTokenFromBadSecretVCR.yaml b/pkg/token/testdata/ServicePrincipalTokenFromBadSecretVCR.yaml index 255dad21..27639417 100644 --- a/pkg/token/testdata/ServicePrincipalTokenFromBadSecretVCR.yaml +++ b/pkg/token/testdata/ServicePrincipalTokenFromBadSecretVCR.yaml @@ -16,7 +16,7 @@ interactions: form: {} headers: User-Agent: - - azsdk-go-azidentity/v1.2.2 (go1.19.6; linux) + - azsdk-go-azidentity/v1.3.0 (go1.19.10; linux) url: https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https%3A%2F%2Flogin.microsoftonline.com%2FAZURE_TENANT_ID%2Foauth2%2Fv2.0%2Fauthorize method: GET response: @@ -40,7 +40,7 @@ interactions: Content-Type: - application/json; charset=utf-8 Date: - - Fri, 02 Jun 2023 22:18:27 GMT + - Thu, 31 Aug 2023 16:47:48 GMT P3p: - CP="DSP CUR OTPi IND OTRi ONL FIN" Strict-Transport-Security: @@ -48,12 +48,12 @@ interactions: X-Content-Type-Options: - nosniff X-Ms-Ests-Server: - - 2.1.15427.11 - NCUS ProdSlices + - 2.1.16150.3 - SCUS ProdSlices X-Xss-Protection: - "0" status: 200 OK code: 200 - duration: 76.830574ms + duration: 525.29276ms - id: 1 request: proto: HTTP/1.1 @@ -69,7 +69,7 @@ interactions: form: {} headers: User-Agent: - - azsdk-go-azidentity/v1.2.2 (go1.19.6; linux) + - azsdk-go-azidentity/v1.3.0 (go1.19.10; linux) url: https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration method: GET response: @@ -93,7 +93,7 @@ interactions: Content-Type: - application/json; charset=utf-8 Date: - - Fri, 02 Jun 2023 22:18:27 GMT + - Thu, 31 Aug 2023 16:47:49 GMT P3p: - CP="DSP CUR OTPi IND OTRi ONL FIN" Strict-Transport-Security: @@ -101,24 +101,24 @@ interactions: X-Content-Type-Options: - nosniff X-Ms-Ests-Server: - - 2.1.15482.15 - SCUS ProdSlices + - 2.1.16209.3 - EUS ProdSlices X-Xss-Protection: - "0" status: 200 OK code: 200 - duration: 40.271291ms + duration: 208.446417ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 185 + content_length: 215 transfer_encoding: [] trailer: {} host: login.microsoftonline.com remote_addr: "" request_uri: "" - body: client_id=[REDACTED]&client_secret=Bad_Secret&grant_type=client_credentials&scope=[REDACTED]%2F.default+openid+offline_access+profile + body: client_id=[REDACTED]&client_secret=Bad_Secret&grant_type=client_credentials&scope=[REDACTED]%2F.default+openid+offline_access+profile+openid+offline_access+profile form: client_id: - '[REDACTED]' @@ -130,11 +130,11 @@ interactions: - '[REDACTED]/.default openid offline_access profile' headers: Content-Length: - - "185" + - "215" Content-Type: - application/x-www-form-urlencoded; charset=utf-8 User-Agent: - - azsdk-go-azidentity/v1.2.2 (go1.19.6; linux) + - azsdk-go-azidentity/v1.3.0 (go1.19.10; linux) url: https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token method: POST response: @@ -154,7 +154,7 @@ interactions: Content-Type: - application/json; charset=utf-8 Date: - - Fri, 02 Jun 2023 22:18:27 GMT + - Thu, 31 Aug 2023 16:47:49 GMT Expires: - "-1" P3p: @@ -166,9 +166,9 @@ interactions: X-Content-Type-Options: - nosniff X-Ms-Ests-Server: - - 2.1.15482.15 - SCUS ProdSlices + - 2.1.16209.3 - NCUS ProdSlices X-Xss-Protection: - "0" status: 401 Unauthorized code: 401 - duration: 93.545411ms + duration: 173.336451ms diff --git a/pkg/token/testdata/ServicePrincipalTokenFromCertVCR.yaml b/pkg/token/testdata/ServicePrincipalTokenFromCertVCR.yaml index 2018dd3e..916fab88 100644 --- a/pkg/token/testdata/ServicePrincipalTokenFromCertVCR.yaml +++ b/pkg/token/testdata/ServicePrincipalTokenFromCertVCR.yaml @@ -16,7 +16,7 @@ interactions: form: {} headers: User-Agent: - - azsdk-go-azidentity/v1.2.2 (go1.19.6; linux) + - azsdk-go-azidentity/v1.3.0 (go1.19.10; linux) url: https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https%3A%2F%2Flogin.microsoftonline.com%2FAZURE_TENANT_ID%2Foauth2%2Fv2.0%2Fauthorize method: GET response: @@ -40,7 +40,7 @@ interactions: Content-Type: - application/json; charset=utf-8 Date: - - Fri, 02 Jun 2023 22:18:28 GMT + - Thu, 31 Aug 2023 17:05:09 GMT P3p: - CP="DSP CUR OTPi IND OTRi ONL FIN" Strict-Transport-Security: @@ -48,12 +48,12 @@ interactions: X-Content-Type-Options: - nosniff X-Ms-Ests-Server: - - 2.1.15427.11 - SCUS ProdSlices + - 2.1.16150.3 - EUS ProdSlices X-Xss-Protection: - "0" status: 200 OK code: 200 - duration: 40.421101ms + duration: 260.148832ms - id: 1 request: proto: HTTP/1.1 @@ -69,7 +69,7 @@ interactions: form: {} headers: User-Agent: - - azsdk-go-azidentity/v1.2.2 (go1.19.6; linux) + - azsdk-go-azidentity/v1.3.0 (go1.19.10; linux) url: https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration method: GET response: @@ -93,7 +93,7 @@ interactions: Content-Type: - application/json; charset=utf-8 Date: - - Fri, 02 Jun 2023 22:18:28 GMT + - Thu, 31 Aug 2023 17:05:09 GMT P3p: - CP="DSP CUR OTPi IND OTRi ONL FIN" Strict-Transport-Security: @@ -101,24 +101,24 @@ interactions: X-Content-Type-Options: - nosniff X-Ms-Ests-Server: - - 2.1.15482.15 - NCUS ProdSlices + - 2.1.16209.3 - WUS2 ProdSlices X-Xss-Protection: - "0" status: 200 OK code: 200 - duration: 25.865464ms + duration: 79.226611ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 2445 + content_length: 2517 transfer_encoding: [] trailer: {} host: login.microsoftonline.com remote_addr: "" request_uri: "" - body: client_assertion=[REDACTED]&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_id=[REDACTED]&client_info=1&grant_type=client_credentials&scope=[REDACTED]%2F.default+openid+offline_access+profile + body: client_assertion=[REDACTED]&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_id=[REDACTED]&client_info=1&grant_type=client_credentials&scope=[REDACTED]%2F.default+openid+offline_access+profile+openid+offline_access+profile form: client_assertion: - '[REDACTED]' @@ -134,11 +134,11 @@ interactions: - '[REDACTED]/.default openid offline_access profile' headers: Content-Length: - - "2445" + - "2517" Content-Type: - application/x-www-form-urlencoded; charset=utf-8 User-Agent: - - azsdk-go-azidentity/v1.2.2 (go1.19.6; linux) + - azsdk-go-azidentity/v1.3.0 (go1.19.10; linux) url: https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token method: POST response: @@ -147,18 +147,18 @@ interactions: proto_minor: 1 transfer_encoding: [] trailer: {} - content_length: 1359 + content_length: 1383 uncompressed: false body: '{"token_type":"Bearer","expires_in":86399,"ext_expires_in":86399,"access_token":"TEST_ACCESS_TOKEN"}' headers: Cache-Control: - no-store, no-cache Content-Length: - - "1359" + - "1383" Content-Type: - application/json; charset=utf-8 Date: - - Fri, 02 Jun 2023 22:18:28 GMT + - Thu, 31 Aug 2023 17:05:09 GMT Expires: - "-1" P3p: @@ -170,9 +170,9 @@ interactions: X-Content-Type-Options: - nosniff X-Ms-Ests-Server: - - 2.1.15482.15 - WUS2 ProdSlices + - 2.1.16209.3 - WUS2 ProdSlices X-Xss-Protection: - "0" status: 200 OK code: 200 - duration: 144.867461ms + duration: 241.190477ms diff --git a/pkg/token/testdata/ServicePrincipalTokenFromSecretVCR.yaml b/pkg/token/testdata/ServicePrincipalTokenFromSecretVCR.yaml index 8937136c..1716aa76 100644 --- a/pkg/token/testdata/ServicePrincipalTokenFromSecretVCR.yaml +++ b/pkg/token/testdata/ServicePrincipalTokenFromSecretVCR.yaml @@ -16,7 +16,7 @@ interactions: form: {} headers: User-Agent: - - azsdk-go-azidentity/v1.2.2 (go1.19.6; linux) + - azsdk-go-azidentity/v1.3.0 (go1.19.10; linux) url: https://login.microsoftonline.com/common/discovery/instance?api-version=1.1&authorization_endpoint=https%3A%2F%2Flogin.microsoftonline.com%2FAZURE_TENANT_ID%2Foauth2%2Fv2.0%2Fauthorize method: GET response: @@ -40,7 +40,7 @@ interactions: Content-Type: - application/json; charset=utf-8 Date: - - Fri, 02 Jun 2023 22:18:28 GMT + - Thu, 31 Aug 2023 16:46:26 GMT P3p: - CP="DSP CUR OTPi IND OTRi ONL FIN" Strict-Transport-Security: @@ -48,12 +48,12 @@ interactions: X-Content-Type-Options: - nosniff X-Ms-Ests-Server: - - 2.1.15427.11 - NCUS ProdSlices + - 2.1.16150.3 - EUS ProdSlices X-Xss-Protection: - "0" status: 200 OK code: 200 - duration: 27.748362ms + duration: 439.978264ms - id: 1 request: proto: HTTP/1.1 @@ -69,7 +69,7 @@ interactions: form: {} headers: User-Agent: - - azsdk-go-azidentity/v1.2.2 (go1.19.6; linux) + - azsdk-go-azidentity/v1.3.0 (go1.19.10; linux) url: https://login.microsoftonline.com/AZURE_TENANT_ID/v2.0/.well-known/openid-configuration method: GET response: @@ -93,7 +93,7 @@ interactions: Content-Type: - application/json; charset=utf-8 Date: - - Fri, 02 Jun 2023 22:18:28 GMT + - Thu, 31 Aug 2023 16:46:26 GMT P3p: - CP="DSP CUR OTPi IND OTRi ONL FIN" Strict-Transport-Security: @@ -101,24 +101,24 @@ interactions: X-Content-Type-Options: - nosniff X-Ms-Ests-Server: - - 2.1.15482.15 - SCUS ProdSlices + - 2.1.16209.3 - NCUS ProdSlices X-Xss-Protection: - "0" status: 200 OK code: 200 - duration: 40.765501ms + duration: 191.903645ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 215 + content_length: 245 transfer_encoding: [] trailer: {} host: login.microsoftonline.com remote_addr: "" request_uri: "" - body: client_id=[REDACTED]&client_secret=[REDACTED]&grant_type=client_credentials&scope=[REDACTED]%2F.default+openid+offline_access+profile + body: client_id=[REDACTED]&client_secret=[REDACTED]&grant_type=client_credentials&scope=[REDACTED]%2F.default+openid+offline_access+profile+openid+offline_access+profile form: client_id: - '[REDACTED]' @@ -130,11 +130,11 @@ interactions: - '[REDACTED]/.default openid offline_access profile' headers: Content-Length: - - "215" + - "245" Content-Type: - application/x-www-form-urlencoded; charset=utf-8 User-Agent: - - azsdk-go-azidentity/v1.2.2 (go1.19.6; linux) + - azsdk-go-azidentity/v1.3.0 (go1.19.10; linux) url: https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/v2.0/token method: POST response: @@ -143,18 +143,18 @@ interactions: proto_minor: 1 transfer_encoding: [] trailer: {} - content_length: 1359 + content_length: 1378 uncompressed: false body: '{"token_type":"Bearer","expires_in":86399,"ext_expires_in":86399,"access_token":"TEST_ACCESS_TOKEN"}' headers: Cache-Control: - no-store, no-cache Content-Length: - - "1359" + - "1378" Content-Type: - application/json; charset=utf-8 Date: - - Fri, 02 Jun 2023 22:18:28 GMT + - Thu, 31 Aug 2023 16:46:27 GMT Expires: - "-1" P3p: @@ -166,9 +166,9 @@ interactions: X-Content-Type-Options: - nosniff X-Ms-Ests-Server: - - 2.1.15482.15 - SCUS ProdSlices + - 2.1.16209.3 - EUS ProdSlices X-Xss-Protection: - "0" status: 200 OK code: 200 - duration: 104.210059ms + duration: 319.17706ms