Repository: int128/kubelogin

Issue:
Azure AD Single Page Application should include `Origin` header for CORS support
## Purpose of the feature (why)
We would like to use Azure AD Single Page Application with PKCE and no client secret. However, after configuring `kubelogin` for such a setup, we receive an error:

```
I0212 11:53:00.490607    8300 cmd.go:66] stacktrace: get-token: authentication error: authcode-browser error: authentication error: authorization code flow error: oauth2 error: could not exchange the code and token: oauth2: "invalid_request" "AADSTS9002327: Tokens issued for the 'Single-Page Application' client-type may only be redeemed via cross-origin requests. ..."
```

After digging a bit more, it seems that Azure requires the request to populate the `Origin` header for this flow to succeed. See:
* https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/2482
* https://community.postman.com/t/add-origin-header-to-oauth-2-0-flow/39402/5
* https://stackoverflow.com/questions/61231144/getting-access-tokens-from-postman-tokens-issued-for-the-single-page-applicati

For a related github issue, see https://github.com/int128/kubelogin/issues/858

## Your idea (how)
I'm hoping that we can include a `Origin` header in these requests to resolve this error. A couple options to consider for this are:
1) Add CORS support in the HTTP client. I'm unsure how difficult this is. We can put this behind a feature flag if we would like.
2) Populate the `Origin` header manually in the HTTP client. We can either put this behind a feature flag, or just choose to add this header to all requests.
3) Support adding arbitrary HTTP headers as a CLI parameter (ie `--extra-header="Origin: localhost:8000"`)

If we decide on a solution, I'm happy to take an initial pass at making the PR.

## Workaround
This issue can be worked around by instead configuring the Azure Application to be a "Mobile and desktop applications". However, this requires us to include the "Client Secret" in all client's kubeconfig files, which is not desirable.


Hints from issue comments:
@adkafka: Hmm, it appears I found a better workaround. If I remove the "Single Page Application" completely in Azure, and instead use a "Mobile and desktop applications", the Client Secret is not required. Simply adding the "Mobile and desktop applications" was not sufficient, I had to also remove the "Single Page Application". We can succeed simply with:
```
$ kubelogin get-token \
  --oidc-issuer-url "https://login.microsoftonline.com/<REDACTED>/v2.0" \
  --oidc-client-id <REDACTED> \
  --oidc-use-pkce \
  --force-refresh \
  --listen-address 127.0.0.1:8000
```

So, perhaps the guidance should be to use `Mobile and desktop applications` in Azure, and avoid `Single Page Application` completely. This is confusing, because the Azure docs only mention PKCE with `Single Page Application`.@adkafka: Revisiting this now... I learned recently that Azure AD applications of type `Mobile and desktop applications` are not in scope for Conditional Access Policies (see documentation [here](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps#other-applications)), meaning in my case they do not trigger an MFA enforcement. This has be coming back to this issue, because using `Single Page Application` DOES trigger an MFA enforcement, if we can make it work with kubelogin.

I confirmed that simply adding a header in the request solves this issue. Here is an example:

This command fails:
```
$ curl "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
  -X POST \
  -d 'client_id=<client-id>&code=<authorization-code>&code_verifier=<verification-code>=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8000'
...
{"error":"invalid_request","error_description":"AADSTS9002327: Tokens issued for the 'Single-Page Application' client-type may only be redeemed via cross-origin requests. Trace ID: <omitted> Correlation ID: <omitted> Timestamp: 2024-04-22 19:39:04Z","error_codes":[9002327],"timestamp":"2024-04-22 19:39:04Z","trace_id":"<omitted>","correlation_id":"<omitted>"}
```

This command succeeds (simply adding `Origin: localhost:1800` as a header):
```
curl "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
  -H 'Origin: localhost:1800' \
  -X POST \
  -d 'client_id=<client-id>&code=<authorization-code>&code_verifier=<verification-code>=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A8000'
...
{"token_type":"Bearer","scope":"email openid profile User.Read","expires_in":4422,"ext_expires_in":4422,"access_token"...
```

Therefore, I would like to bring this issue back up for consideration. I'm happy to make a PR after receiving guidance for how best to proceed. @adkafka: In order to implement this code, I think we need to implement the change 2 libraries upstream. This library uses https://github.com/int128/oauth2cli/tree/master which in turn uses https://github.com/golang/oauth2/tree/master. The logic to actually request the token is here: https://github.com/golang/oauth2/blob/master/internal/token.go#L183-L202. Plumbing through the call back URL as a parameter seems like a relatively large lift in this case. We do have a `context.Context` object that gets passed through though. Maybe we could use that to set this header?@MisterMX: I've implemented a change that sets the header without making changes to the libraries being used: https://github.com/int128/kubelogin/pull/1323

Solution patch:
diff --git a/docs/usage.md b/docs/usage.md
index d6eb311..fcf9ce2 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -13,6 +13,7 @@ Flags:
       --oidc-redirect-url string                        [authcode, authcode-keyboard] Redirect URL
       --oidc-extra-scope strings                        Scopes to request to the provider
       --oidc-use-access-token                           Instead of using the id_token, use the access_token to authenticate to Kubernetes
+      --oidc-request-header stringToString              HTTP headers to send with an authentication request (default [])
       --force-refresh                                   If set, refresh the ID token regardless of its expiration time
       --token-cache-dir string                          Path to a directory of the token cache (default "~/.kube/cache/oidc-login")
       --token-cache-storage string                      Storage for the token cache. One of (disk|keyring|none) (default "disk")
@@ -88,6 +89,16 @@ you can enforce the code challenge method by `--oidc-pkce-method`.
 
 For the most providers, you don't need to set this option explicitly.
 
+### HTTP headers
+
+If your provider requires extra HTTP headers, you can set them by `--oidc-request-header`.
+
+For Azure AD Single Page Application with PKCE, you can set `Origin` header as follows:
+
+```yaml
+- --oidc-request-header=Origin=localhost
+```
+
 ### CA certificate
 
 You can use your self-signed certificate for the provider.
diff --git a/pkg/cmd/get_token.go b/pkg/cmd/get_token.go
index c6ba2ed..504efda 100644
--- a/pkg/cmd/get_token.go
+++ b/pkg/cmd/get_token.go
@@ -19,6 +19,7 @@ type getTokenOptions struct {
 	RedirectURL           string
 	ExtraScopes           []string
 	UseAccessToken        bool
+	RequestHeaders        map[string]string
 	tokenCacheOptions     tokenCacheOptions
 	tlsOptions            tlsOptions
 	pkceOptions           pkceOptions
@@ -33,6 +34,7 @@ func (o *getTokenOptions) addFlags(f *pflag.FlagSet) {
 	f.StringVar(&o.RedirectURL, "oidc-redirect-url", "", "[authcode, authcode-keyboard] Redirect URL")
 	f.StringSliceVar(&o.ExtraScopes, "oidc-extra-scope", nil, "Scopes to request to the provider")
 	f.BoolVar(&o.UseAccessToken, "oidc-use-access-token", false, "Instead of using the id_token, use the access_token to authenticate to Kubernetes")
+	f.StringToStringVar(&o.RequestHeaders, "oidc-request-header", nil, "HTTP headers to send with an authentication request")
 	f.BoolVar(&o.ForceRefresh, "force-refresh", false, "If set, refresh the ID token regardless of its expiration time")
 	o.tokenCacheOptions.addFlags(f)
 	o.tlsOptions.addFlags(f)
@@ -95,6 +97,7 @@ func (cmd *GetToken) New() *cobra.Command {
 					PKCEMethod:     pkceMethod,
 					UseAccessToken: o.UseAccessToken,
 					ExtraScopes:    o.ExtraScopes,
+					RequestHeaders: o.RequestHeaders,
 				},
 				ForceRefresh:     o.ForceRefresh,
 				TokenCacheConfig: tokenCacheConfig,
diff --git a/pkg/cmd/setup.go b/pkg/cmd/setup.go
index e00bae6..8e3bec2 100644
--- a/pkg/cmd/setup.go
+++ b/pkg/cmd/setup.go
@@ -18,6 +18,7 @@ type setupOptions struct {
 	RedirectURL           string
 	ExtraScopes           []string
 	UseAccessToken        bool
+	RequestHeaders        map[string]string
 	tlsOptions            tlsOptions
 	pkceOptions           pkceOptions
 	authenticationOptions authenticationOptions
@@ -30,6 +31,7 @@ func (o *setupOptions) addFlags(f *pflag.FlagSet) {
 	f.StringVar(&o.RedirectURL, "oidc-redirect-url", "", "[authcode, authcode-keyboard] Redirect URL")
 	f.StringSliceVar(&o.ExtraScopes, "oidc-extra-scope", nil, "Scopes to request to the provider")
 	f.BoolVar(&o.UseAccessToken, "oidc-use-access-token", false, "Instead of using the id_token, use the access_token to authenticate to Kubernetes")
+	f.StringToStringVar(&o.RequestHeaders, "oidc-request-header", nil, "HTTP headers to send with an authentication request")
 	o.tlsOptions.addFlags(f)
 	o.pkceOptions.addFlags(f)
 	o.authenticationOptions.addFlags(f)
@@ -79,6 +81,7 @@ func (cmd *Setup) New() *cobra.Command {
 				RedirectURL:     o.RedirectURL,
 				ExtraScopes:     o.ExtraScopes,
 				UseAccessToken:  o.UseAccessToken,
+				RequestHeaders:  o.RequestHeaders,
 				PKCEMethod:      pkceMethod,
 				GrantOptionSet:  grantOptionSet,
 				TLSClientConfig: o.tlsOptions.tlsClientConfig(),
diff --git a/pkg/oidc/client/factory.go b/pkg/oidc/client/factory.go
index dfdf318..b662368 100644
--- a/pkg/oidc/client/factory.go
+++ b/pkg/oidc/client/factory.go
@@ -40,16 +40,17 @@ func (f *Factory) New(ctx context.Context, prov oidc.Provider, tlsClientConfig t
 	if err != nil {
 		return nil, fmt.Errorf("could not load the TLS client config: %w", err)
 	}
-	baseTransport := &http.Transport{
-		TLSClientConfig: rawTLSClientConfig,
-		Proxy:           http.ProxyFromEnvironment,
-	}
-	loggingTransport := &transport.WithLogging{
-		Base:   baseTransport,
-		Logger: f.Logger,
-	}
 	httpClient := &http.Client{
-		Transport: loggingTransport,
+		Transport: &transport.WithHeader{
+			Base: &transport.WithLogging{
+				Base: &http.Transport{
+					TLSClientConfig: rawTLSClientConfig,
+					Proxy:           http.ProxyFromEnvironment,
+				},
+				Logger: f.Logger,
+			},
+			RequestHeaders: prov.RequestHeaders,
+		},
 	}
 
 	ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
diff --git a/pkg/oidc/client/transport/header.go b/pkg/oidc/client/transport/header.go
new file mode 100644
index 0000000..e195b7b
--- /dev/null
+++ b/pkg/oidc/client/transport/header.go
@@ -0,0 +1,21 @@
+package transport
+
+import "net/http"
+
+// WithHeader is a RoundTripper that adds custom headers to each request.
+//
+// Token retrievel fails when an auth code has been retrieved using Azure AD
+// Single Page Application due to the missing "Origin" header for CORS
+// validation.
+// https://github.com/int128/kubelogin/issues/1048
+type WithHeader struct {
+	Base           http.RoundTripper
+	RequestHeaders map[string]string
+}
+
+func (t *WithHeader) RoundTrip(req *http.Request) (*http.Response, error) {
+	for key, value := range t.RequestHeaders {
+		req.Header.Set(key, value)
+	}
+	return t.Base.RoundTrip(req)
+}
diff --git a/pkg/oidc/oidc.go b/pkg/oidc/oidc.go
index 421c9dc..1f61048 100644
--- a/pkg/oidc/oidc.go
+++ b/pkg/oidc/oidc.go
@@ -18,6 +18,7 @@ type Provider struct {
 	RedirectURL    string   // optional
 	PKCEMethod     PKCEMethod
 	UseAccessToken bool
+	RequestHeaders map[string]string
 }
 
 // PKCEMethod represents a preferred method of PKCE.
diff --git a/pkg/usecases/setup/setup.go b/pkg/usecases/setup/setup.go
index 9c25835..29a208b 100644
--- a/pkg/usecases/setup/setup.go
+++ b/pkg/usecases/setup/setup.go
@@ -45,6 +45,7 @@ type Input struct {
 	RedirectURL     string
 	ExtraScopes     []string
 	UseAccessToken  bool
+	RequestHeaders  map[string]string
 	PKCEMethod      oidc.PKCEMethod
 	GrantOptionSet  authentication.GrantOptionSet
 	TLSClientConfig tlsclientconfig.Config
@@ -62,6 +63,7 @@ func (u Setup) Do(ctx context.Context, in Input) error {
 			ExtraScopes:    in.ExtraScopes,
 			PKCEMethod:     in.PKCEMethod,
 			UseAccessToken: in.UseAccessToken,
+			RequestHeaders: in.RequestHeaders,
 		},
 		GrantOptionSet:  in.GrantOptionSet,
 		TLSClientConfig: in.TLSClientConfig,



Test patch:
diff --git a/pkg/cmd/cmd_test.go b/pkg/cmd/cmd_test.go
index 4d65206..9b9f348 100644
--- a/pkg/cmd/cmd_test.go
+++ b/pkg/cmd/cmd_test.go
@@ -129,6 +129,7 @@ func TestCmd_Run(t *testing.T) {
 					"--oidc-client-secret", "YOUR_CLIENT_SECRET",
 					"--oidc-extra-scope", "email",
 					"--oidc-extra-scope", "profile",
+					"--oidc-request-header", "Origin=localhost:8080",
 					"--token-cache-storage", "keyring",
 					"-v1",
 				},
@@ -138,6 +139,9 @@ func TestCmd_Run(t *testing.T) {
 						ClientID:     "YOUR_CLIENT_ID",
 						ClientSecret: "YOUR_CLIENT_SECRET",
 						ExtraScopes:  []string{"email", "profile"},
+						RequestHeaders: map[string]string{
+							"Origin": "localhost:8080",
+						},
 					},
 					TokenCacheConfig: tokencache.Config{
 						Directory: filepath.Join(userHomeDir, ".kube/cache/oidc-login"),