Browse code

Allow RequestHeaderIdentityProvider to redirect to UI login or challenging URL

Jordan Liggitt authored on 2015/08/20 04:18:47
Showing 15 changed files
1 1
new file mode 100644
... ...
@@ -0,0 +1,76 @@
0
+package redirector
1
+
2
+import (
3
+	"net/http"
4
+	"net/url"
5
+	"strings"
6
+
7
+	oauthhandlers "github.com/openshift/origin/pkg/auth/oauth/handlers"
8
+)
9
+
10
+const (
11
+	// URLToken in the query of the redirectURL gets replaced with the original request URL, escaped as a query parameter.
12
+	// Example use: https://www.example.com/login?then=${url}
13
+	URLToken = "${url}"
14
+
15
+	// QueryToken in the query of the redirectURL gets replaced with the original request URL, unescaped.
16
+	// Example use: https://www.example.com/sso/oauth/authorize?${query}
17
+	QueryToken = "${query}"
18
+)
19
+
20
+// NewRedirector returns an oauthhandlers.AuthenticationRedirector that redirects to the specified redirectURL.
21
+// Request URLs missing scheme/host, or with relative paths are resolved relative to the baseRequestURL, if specified.
22
+// The following tokens are replaceable in the query of the redirectURL:
23
+//   ${url} is replaced with the current request URL, escaped as a query parameter. Example: https://www.example.com/login?then=${url}
24
+//   ${query} is replaced with the current request query, unescaped. Example: https://www.example.com/sso/oauth/authorize?${query}
25
+func NewRedirector(baseRequestURL *url.URL, redirectURL string) oauthhandlers.AuthenticationRedirector {
26
+	return &redirector{BaseRequestURL: baseRequestURL, RedirectURL: redirectURL}
27
+}
28
+
29
+// NewChallenger returns an oauthhandlers.AuthenticationChallenger that returns a Location header to the specified redirectURL.
30
+// Request URLs missing scheme/host, or with relative paths are resolved relative to the baseRequestURL, if specified.
31
+// The following tokens are replaceable in the query of the redirectURL:
32
+//   ${url} is replaced with the current request URL, escaped as a query parameter. Example: https://www.example.com/login?then=${url}
33
+//   ${query} is replaced with the current request query, unescaped. Example: https://www.example.com/sso/oauth/authorize?${query}
34
+func NewChallenger(baseRequestURL *url.URL, redirectURL string) oauthhandlers.AuthenticationChallenger {
35
+	return &redirector{BaseRequestURL: baseRequestURL, RedirectURL: redirectURL}
36
+}
37
+
38
+type redirector struct {
39
+	BaseRequestURL *url.URL
40
+	RedirectURL    string
41
+}
42
+
43
+// AuthenticationChallenge returns a Location header to the configured RedirectURL (which should return a challenge)
44
+func (r *redirector) AuthenticationChallenge(req *http.Request) (http.Header, error) {
45
+	redirectURL, err := buildRedirectURL(r.RedirectURL, r.BaseRequestURL, req.URL)
46
+	if err != nil {
47
+		return nil, err
48
+	}
49
+	headers := http.Header{}
50
+	headers.Add("Location", redirectURL.String())
51
+	return headers, nil
52
+}
53
+
54
+// AuthenticationRedirect redirects to the configured RedirectURL
55
+func (r *redirector) AuthenticationRedirect(w http.ResponseWriter, req *http.Request) error {
56
+	redirectURL, err := buildRedirectURL(r.RedirectURL, r.BaseRequestURL, req.URL)
57
+	if err != nil {
58
+		return nil
59
+	}
60
+	http.Redirect(w, req, redirectURL.String(), http.StatusFound)
61
+	return nil
62
+}
63
+
64
+func buildRedirectURL(redirectTemplate string, baseRequestURL, requestURL *url.URL) (*url.URL, error) {
65
+	if baseRequestURL != nil {
66
+		requestURL = baseRequestURL.ResolveReference(requestURL)
67
+	}
68
+	redirectURL, err := url.Parse(redirectTemplate)
69
+	if err != nil {
70
+		return nil, err
71
+	}
72
+	redirectURL.RawQuery = strings.Replace(redirectURL.RawQuery, QueryToken, requestURL.RawQuery, -1)
73
+	redirectURL.RawQuery = strings.Replace(redirectURL.RawQuery, URLToken, url.QueryEscape(requestURL.String()), -1)
74
+	return redirectURL, nil
75
+}
... ...
@@ -95,7 +95,22 @@ func (authHandler *unionAuthenticationHandler) AuthenticationNeeded(apiClient au
95 95
 
96 96
 		if len(headers) > 0 {
97 97
 			mergeHeaders(w.Header(), headers)
98
-			w.WriteHeader(http.StatusUnauthorized)
98
+
99
+			redirectHeader := w.Header().Get("Location")
100
+			redirectHeaders := w.Header()[http.CanonicalHeaderKey("Location")]
101
+			challengeHeader := w.Header().Get("WWW-Authenticate")
102
+			switch {
103
+			case len(redirectHeader) > 0 && len(challengeHeader) > 0:
104
+				errors = append(errors, fmt.Errorf("redirect header (Location: %s) and challenge header (WWW-Authenticate: %s) cannot both be set", redirectHeader, challengeHeader))
105
+				return false, kerrors.NewAggregate(errors)
106
+			case len(redirectHeaders) > 1:
107
+				errors = append(errors, fmt.Errorf("cannot set multiple redirect headers: %s", strings.Join(redirectHeaders, ", ")))
108
+				return false, kerrors.NewAggregate(errors)
109
+			case len(redirectHeader) > 0:
110
+				w.WriteHeader(http.StatusFound)
111
+			default:
112
+				w.WriteHeader(http.StatusUnauthorized)
113
+			}
99 114
 
100 115
 			// Print Misc Warning headers (code 199) to the body
101 116
 			if warnings, hasWarnings := w.Header()[http.CanonicalHeaderKey("Warning")]; hasWarnings {
... ...
@@ -161,6 +161,64 @@ func TestWithChallengeErrorsAndMergedSuccess(t *testing.T) {
161 161
 	if !(reflect.DeepEqual(map[string][]string(responseRecorder.HeaderMap), expectedHeader1) || reflect.DeepEqual(map[string][]string(responseRecorder.HeaderMap), expectedHeader2)) {
162 162
 		t.Errorf("Expected %#v or %#v, got %#v.", expectedHeader1, expectedHeader2, responseRecorder.HeaderMap)
163 163
 	}
164
+	if responseRecorder.Code != 401 {
165
+		t.Errorf("Expected 401, got %d", responseRecorder.Code)
166
+	}
167
+}
168
+
169
+func TestWithChallengeAndRedirect(t *testing.T) {
170
+	expectedError := "Location"
171
+	workingChallengeHandler1 := &mockChallenger{headerName: "Location", headerValue: "https://example.com"}
172
+	workingChallengeHandler2 := &mockChallenger{headerName: "WWW-Authenticate", headerValue: "Basic"}
173
+
174
+	authHandler := NewUnionAuthenticationHandler(
175
+		map[string]AuthenticationChallenger{
176
+			"first":  workingChallengeHandler1,
177
+			"second": workingChallengeHandler2,
178
+		}, nil, nil)
179
+	client := &testClient{&oauthapi.OAuthClient{RespondWithChallenges: true}}
180
+	req, _ := http.NewRequest("GET", "http://example.org", nil)
181
+	responseRecorder := httptest.NewRecorder()
182
+	handled, err := authHandler.AuthenticationNeeded(client, responseRecorder, req)
183
+
184
+	if err == nil {
185
+		t.Errorf("Expected error, got none")
186
+	} else if !strings.Contains(err.Error(), expectedError) {
187
+		t.Errorf("Expected error containing %q, got %v", expectedError, err)
188
+	}
189
+	if handled {
190
+		t.Error("Unexpected handling.")
191
+	}
192
+}
193
+
194
+func TestWithRedirect(t *testing.T) {
195
+	workingChallengeHandler1 := &mockChallenger{headerName: "Location", headerValue: "https://example.com"}
196
+
197
+	// order of the array is not guaranteed
198
+	expectedHeader1 := map[string][]string{"Location": {"https://example.com"}}
199
+
200
+	authHandler := NewUnionAuthenticationHandler(
201
+		map[string]AuthenticationChallenger{
202
+			"first": workingChallengeHandler1,
203
+		},
204
+		nil, nil)
205
+	client := &testClient{&oauthapi.OAuthClient{RespondWithChallenges: true}}
206
+	req, _ := http.NewRequest("GET", "http://example.org", nil)
207
+	responseRecorder := httptest.NewRecorder()
208
+	handled, err := authHandler.AuthenticationNeeded(client, responseRecorder, req)
209
+
210
+	if err != nil {
211
+		t.Errorf("Unexpected error: %v", err)
212
+	}
213
+	if !handled {
214
+		t.Error("Expected handling.")
215
+	}
216
+	if !reflect.DeepEqual(map[string][]string(responseRecorder.HeaderMap), expectedHeader1) {
217
+		t.Errorf("Expected %#v, got %#v.", expectedHeader1, responseRecorder.HeaderMap)
218
+	}
219
+	if responseRecorder.Code != 302 {
220
+		t.Errorf("Expected 302, got %d", responseRecorder.Code)
221
+	}
164 222
 }
165 223
 
166 224
 type badTestClient struct {
... ...
@@ -89,7 +89,7 @@ func (o *LoginOptions) getClientConfig() (*kclient.Config, error) {
89 89
 				defaultServer := defaultClusterURL
90 90
 				promptMsg := fmt.Sprintf("Server [%s]: ", defaultServer)
91 91
 
92
-				o.Server = cmdutil.PromptForStringWithDefault(o.Reader, defaultServer, promptMsg)
92
+				o.Server = cmdutil.PromptForStringWithDefault(o.Reader, o.Out, defaultServer, promptMsg)
93 93
 			}
94 94
 		}
95 95
 	}
... ...
@@ -133,6 +133,9 @@ func (o *LoginOptions) getClientConfig() (*kclient.Config, error) {
133 133
 		switch {
134 134
 		case o.InsecureTLS:
135 135
 			clientConfig.Insecure = true
136
+			// insecure, clear CA info
137
+			clientConfig.CAFile = ""
138
+			clientConfig.CAData = nil
136 139
 
137 140
 		// certificate issue, prompt user for insecure connection
138 141
 		case clientcmd.IsCertificateAuthorityUnknown(result.Error()):
... ...
@@ -148,10 +151,13 @@ func (o *LoginOptions) getClientConfig() (*kclient.Config, error) {
148 148
 				fmt.Fprintln(o.Out, "The server uses a certificate signed by an unknown authority.")
149 149
 				fmt.Fprintln(o.Out, "You can bypass the certificate check, but any data you send to the server could be intercepted by others.")
150 150
 
151
-				clientConfig.Insecure = cmdutil.PromptForBool(os.Stdin, "Use insecure connections? (y/n): ")
151
+				clientConfig.Insecure = cmdutil.PromptForBool(os.Stdin, o.Out, "Use insecure connections? (y/n): ")
152 152
 				if !clientConfig.Insecure {
153 153
 					return nil, fmt.Errorf(clientcmd.GetPrettyMessageFor(result.Error()))
154 154
 				}
155
+				// insecure, clear CA info
156
+				clientConfig.CAFile = ""
157
+				clientConfig.CAData = nil
155 158
 				fmt.Fprintln(o.Out)
156 159
 			}
157 160
 
... ...
@@ -216,39 +222,32 @@ func (o *LoginOptions) gatherAuthInfo() error {
216 216
 		}
217 217
 	}
218 218
 
219
-	// if a token was provided try to make use of it
220
-	// make sure we have a username before continuing
221
-	if !o.usernameProvided() {
222
-		if cmdutil.IsTerminal(o.Reader) {
223
-			for !o.usernameProvided() {
224
-				o.Username = cmdutil.PromptForString(o.Reader, "Username: ")
225
-			}
226
-		}
227
-	}
219
+	// if a username was provided try to make use of it
220
+	if o.usernameProvided() {
221
+		// search all valid contexts with matching server stanzas to see if we have a matching user stanza
222
+		kubeconfig := *o.StartingKubeConfig
223
+		matchingClusters := getMatchingClusters(*clientConfig, kubeconfig)
228 224
 
229
-	// search all valid contexts with matching server stanzas to see if we have a matching user stanza
230
-	kubeconfig := *o.StartingKubeConfig
231
-	matchingClusters := getMatchingClusters(*clientConfig, kubeconfig)
225
+		for key, context := range o.StartingKubeConfig.Contexts {
226
+			if matchingClusters.Has(context.Cluster) {
227
+				clientcmdConfig := kclientcmd.NewDefaultClientConfig(kubeconfig, &kclientcmd.ConfigOverrides{CurrentContext: key})
228
+				if kubeconfigClientConfig, err := clientcmdConfig.ClientConfig(); err == nil {
229
+					if osClient, err := client.New(kubeconfigClientConfig); err == nil {
230
+						if me, err := whoAmI(osClient); err == nil && (o.Username == me.Name) {
231
+							clientConfig.BearerToken = kubeconfigClientConfig.BearerToken
232
+							clientConfig.CertFile = kubeconfigClientConfig.CertFile
233
+							clientConfig.CertData = kubeconfigClientConfig.CertData
234
+							clientConfig.KeyFile = kubeconfigClientConfig.KeyFile
235
+							clientConfig.KeyData = kubeconfigClientConfig.KeyData
232 236
 
233
-	for key, context := range o.StartingKubeConfig.Contexts {
234
-		if matchingClusters.Has(context.Cluster) {
235
-			clientcmdConfig := kclientcmd.NewDefaultClientConfig(kubeconfig, &kclientcmd.ConfigOverrides{CurrentContext: key})
236
-			if kubeconfigClientConfig, err := clientcmdConfig.ClientConfig(); err == nil {
237
-				if osClient, err := client.New(kubeconfigClientConfig); err == nil {
238
-					if me, err := whoAmI(osClient); err == nil && (o.Username == me.Name) {
239
-						clientConfig.BearerToken = kubeconfigClientConfig.BearerToken
240
-						clientConfig.CertFile = kubeconfigClientConfig.CertFile
241
-						clientConfig.CertData = kubeconfigClientConfig.CertData
242
-						clientConfig.KeyFile = kubeconfigClientConfig.KeyFile
243
-						clientConfig.KeyData = kubeconfigClientConfig.KeyData
237
+							o.Config = clientConfig
244 238
 
245
-						o.Config = clientConfig
239
+							if key == o.StartingKubeConfig.CurrentContext {
240
+								fmt.Fprintf(o.Out, "Logged into %q as %q using existing credentials.\n\n", o.Config.Host, o.Username)
241
+							}
246 242
 
247
-						if key == o.StartingKubeConfig.CurrentContext {
248
-							fmt.Fprintf(o.Out, "Already logged into %q as %q.\n\n", o.Config.Host, o.Username)
243
+							return nil
249 244
 						}
250
-
251
-						return nil
252 245
 					}
253 246
 				}
254 247
 			}
... ...
@@ -515,6 +515,22 @@ type LDAPAttributes struct {
515 515
 type RequestHeaderIdentityProvider struct {
516 516
 	api.TypeMeta
517 517
 
518
+	// LoginURL is a URL to redirect unauthenticated /authorize requests to
519
+	// Unauthenticated requests from OAuth clients which expect interactive logins will be redirected here
520
+	// ${url} is replaced with the current URL, escaped to be safe in a query parameter
521
+	//   https://www.example.com/sso-login?then=${url}
522
+	// ${query} is replaced with the current query string
523
+	//   https://www.example.com/auth-proxy/oauth/authorize?${query}
524
+	LoginURL string
525
+
526
+	// ChallengeURL is a URL to redirect unauthenticated /authorize requests to
527
+	// Unauthenticated requests from OAuth clients which expect WWW-Authenticate challenges will be redirected here
528
+	// ${url} is replaced with the current URL, escaped to be safe in a query parameter
529
+	//   https://www.example.com/sso-login?then=${url}
530
+	// ${query} is replaced with the current query string
531
+	//   https://www.example.com/auth-proxy/oauth/authorize?${query}
532
+	ChallengeURL string
533
+
518 534
 	// ClientCA is a file with the trusted signer certs.  If empty, no request verification is done, and any direct request to the OAuth server can impersonate any identity from this provider, merely by setting a request header.
519 535
 	ClientCA string
520 536
 	// Headers is the set of headers to check for identity information
... ...
@@ -501,6 +501,22 @@ type LDAPAttributes struct {
501 501
 type RequestHeaderIdentityProvider struct {
502 502
 	v1.TypeMeta `json:",inline"`
503 503
 
504
+	// LoginURL is a URL to redirect unauthenticated /authorize requests to
505
+	// Unauthenticated requests from OAuth clients which expect interactive logins will be redirected here
506
+	// ${url} is replaced with the current URL, escaped to be safe in a query parameter
507
+	//   https://www.example.com/sso-login?then=${url}
508
+	// ${query} is replaced with the current query string
509
+	//   https://www.example.com/auth-proxy/oauth/authorize?${query}
510
+	LoginURL string `json:"loginURL"`
511
+
512
+	// ChallengeURL is a URL to redirect unauthenticated /authorize requests to
513
+	// Unauthenticated requests from OAuth clients which expect WWW-Authenticate challenges will be redirected here
514
+	// ${url} is replaced with the current URL, escaped to be safe in a query parameter
515
+	//   https://www.example.com/sso-login?then=${url}
516
+	// ${query} is replaced with the current query string
517
+	//   https://www.example.com/auth-proxy/oauth/authorize?${query}
518
+	ChallengeURL string `json:"challengeURL"`
519
+
504 520
 	// ClientCA is a file with the trusted signer certs.  If empty, no request verification is done, and any direct request to the OAuth server can impersonate any identity from this provider, merely by setting a request header.
505 521
 	ClientCA string `json:"clientCA"`
506 522
 	// Headers is the set of headers to check for identity information
... ...
@@ -182,9 +182,11 @@ oauthConfig:
182 182
     name: ""
183 183
     provider:
184 184
       apiVersion: v1
185
+      challengeURL: ""
185 186
       clientCA: ""
186 187
       headers: null
187 188
       kind: RequestHeaderIdentityProvider
189
+      loginURL: ""
188 190
   - challenge: false
189 191
     login: false
190 192
     name: ""
... ...
@@ -5,6 +5,7 @@ import (
5 5
 	"strings"
6 6
 
7 7
 	"github.com/openshift/origin/pkg/auth/authenticator/password/ldappassword"
8
+	"github.com/openshift/origin/pkg/auth/authenticator/redirector"
8 9
 	"github.com/openshift/origin/pkg/cmd/server/api"
9 10
 	"github.com/openshift/origin/pkg/cmd/server/api/latest"
10 11
 	"github.com/openshift/origin/pkg/user/api/validation"
... ...
@@ -35,6 +36,10 @@ func ValidateOAuthConfig(config *api.OAuthConfig) ValidationResults {
35 35
 
36 36
 	providerNames := util.NewStringSet()
37 37
 	redirectingIdentityProviders := []string{}
38
+
39
+	challengeIssuingIdentityProviders := []string{}
40
+	challengeRedirectingIdentityProviders := []string{}
41
+
38 42
 	for i, identityProvider := range config.IdentityProviders {
39 43
 		if identityProvider.UseAsLogin {
40 44
 			redirectingIdentityProviders = append(redirectingIdentityProviders, identityProvider.Name)
... ...
@@ -46,6 +51,16 @@ func ValidateOAuthConfig(config *api.OAuthConfig) ValidationResults {
46 46
 			}
47 47
 		}
48 48
 
49
+		if identityProvider.UseAsChallenger {
50
+			// RequestHeaderIdentityProvider is special, it can only react to challenge clients by redirecting them
51
+			// Make sure we don't have more than a single redirector, and don't have a mix of challenge issuers and redirectors
52
+			if _, isRequestHeader := identityProvider.Provider.Object.(*api.RequestHeaderIdentityProvider); isRequestHeader {
53
+				challengeRedirectingIdentityProviders = append(challengeRedirectingIdentityProviders, identityProvider.Name)
54
+			} else {
55
+				challengeIssuingIdentityProviders = append(challengeIssuingIdentityProviders, identityProvider.Name)
56
+			}
57
+		}
58
+
49 59
 		validationResults.Append(ValidateIdentityProvider(identityProvider).Prefix(fmt.Sprintf("identityProvider[%d]", i)))
50 60
 
51 61
 		if len(identityProvider.Name) > 0 {
... ...
@@ -57,7 +72,18 @@ func ValidateOAuthConfig(config *api.OAuthConfig) ValidationResults {
57 57
 	}
58 58
 
59 59
 	if len(redirectingIdentityProviders) > 1 {
60
-		validationResults.AddErrors(fielderrors.NewFieldInvalid("identityProviders", config.IdentityProviders, fmt.Sprintf("only one identity provider can support login for a browser, found: %v", redirectingIdentityProviders)))
60
+		validationResults.AddErrors(fielderrors.NewFieldInvalid("identityProviders", "login", fmt.Sprintf("only one identity provider can support login for a browser, found: %v", strings.Join(redirectingIdentityProviders, ", "))))
61
+	}
62
+	if len(challengeRedirectingIdentityProviders) > 1 {
63
+		validationResults.AddErrors(fielderrors.NewFieldInvalid("identityProviders", "challenge", fmt.Sprintf("only one identity provider can redirect clients requesting an authentication challenge, found: %v", strings.Join(challengeRedirectingIdentityProviders, ", "))))
64
+	}
65
+	if len(challengeRedirectingIdentityProviders) > 0 && len(challengeIssuingIdentityProviders) > 0 {
66
+		validationResults.AddErrors(
67
+			fielderrors.NewFieldInvalid("identityProviders", "challenge", fmt.Sprintf(
68
+				"cannot mix providers that redirect clients requesting auth challenges (%s) with providers issuing challenges to those clients (%s)",
69
+				strings.Join(challengeRedirectingIdentityProviders, ", "),
70
+				strings.Join(challengeIssuingIdentityProviders, ", "),
71
+			)))
61 72
 	}
62 73
 
63 74
 	return validationResults
... ...
@@ -78,7 +104,7 @@ func ValidateIdentityProvider(identityProvider api.IdentityProvider) ValidationR
78 78
 	} else {
79 79
 		switch provider := identityProvider.Provider.Object.(type) {
80 80
 		case (*api.RequestHeaderIdentityProvider):
81
-			validationResults.AddErrors(ValidateRequestHeaderIdentityProvider(provider, identityProvider)...)
81
+			validationResults.Append(ValidateRequestHeaderIdentityProvider(provider, identityProvider))
82 82
 
83 83
 		case (*api.BasicAuthPasswordIdentityProvider):
84 84
 			validationResults.AddErrors(ValidateRemoteConnectionInfo(provider.RemoteConnectionInfo).Prefix("provider")...)
... ...
@@ -152,23 +178,59 @@ func ValidateLDAPIdentityProvider(provider *api.LDAPPasswordIdentityProvider, id
152 152
 	return validationResults
153 153
 }
154 154
 
155
-func ValidateRequestHeaderIdentityProvider(provider *api.RequestHeaderIdentityProvider, identityProvider api.IdentityProvider) fielderrors.ValidationErrorList {
156
-	allErrs := fielderrors.ValidationErrorList{}
155
+func ValidateRequestHeaderIdentityProvider(provider *api.RequestHeaderIdentityProvider, identityProvider api.IdentityProvider) ValidationResults {
156
+	validationResults := ValidationResults{}
157 157
 
158 158
 	if len(provider.ClientCA) > 0 {
159
-		allErrs = append(allErrs, ValidateFile(provider.ClientCA, "provider.clientCA")...)
159
+		validationResults.AddErrors(ValidateFile(provider.ClientCA, "provider.clientCA")...)
160 160
 	}
161 161
 	if len(provider.Headers) == 0 {
162
-		allErrs = append(allErrs, fielderrors.NewFieldRequired("provider.headers"))
162
+		validationResults.AddErrors(fielderrors.NewFieldRequired("provider.headers"))
163 163
 	}
164
-	if identityProvider.UseAsChallenger {
165
-		allErrs = append(allErrs, fielderrors.NewFieldInvalid("challenge", identityProvider.UseAsChallenger, "request header providers cannot be used for challenges"))
164
+	if identityProvider.UseAsChallenger && len(provider.ChallengeURL) == 0 {
165
+		err := fielderrors.NewFieldRequired("provider.challengeURL")
166
+		err.Detail = "challengeURL is required if challenge=true"
167
+		validationResults.AddErrors(err)
166 168
 	}
167
-	if identityProvider.UseAsLogin {
168
-		allErrs = append(allErrs, fielderrors.NewFieldInvalid("login", identityProvider.UseAsChallenger, "request header providers cannot be used for browser login"))
169
+	if identityProvider.UseAsLogin && len(provider.LoginURL) == 0 {
170
+		err := fielderrors.NewFieldRequired("provider.loginURL")
171
+		err.Detail = "loginURL is required if login=true"
172
+		validationResults.AddErrors(err)
169 173
 	}
170 174
 
171
-	return allErrs
175
+	if len(provider.ChallengeURL) > 0 {
176
+		url, urlErrs := ValidateURL(provider.ChallengeURL, "provider.challengeURL")
177
+		validationResults.AddErrors(urlErrs...)
178
+		if len(urlErrs) == 0 && !strings.Contains(url.RawQuery, redirector.URLToken) && !strings.Contains(url.RawQuery, redirector.QueryToken) {
179
+			validationResults.AddWarnings(
180
+				fielderrors.NewFieldInvalid(
181
+					"provider.challengeURL",
182
+					provider.ChallengeURL,
183
+					fmt.Sprintf("query does not include %q or %q, redirect will not preserve original authorize parameters", redirector.URLToken, redirector.QueryToken),
184
+				),
185
+			)
186
+		}
187
+	}
188
+	if len(provider.LoginURL) > 0 {
189
+		url, urlErrs := ValidateURL(provider.LoginURL, "provider.loginURL")
190
+		validationResults.AddErrors(urlErrs...)
191
+		if len(urlErrs) == 0 && !strings.Contains(url.RawQuery, redirector.URLToken) && !strings.Contains(url.RawQuery, redirector.QueryToken) {
192
+			validationResults.AddWarnings(
193
+				fielderrors.NewFieldInvalid(
194
+					"provider.loginURL",
195
+					provider.LoginURL,
196
+					fmt.Sprintf("query does not include %q or %q, redirect will not preserve original authorize parameters", redirector.URLToken, redirector.QueryToken),
197
+				),
198
+			)
199
+		}
200
+	}
201
+
202
+	// Warn if it looks like they expect direct requests to the OAuth endpoints, and have not secured the header checking with a client certificate check
203
+	if len(provider.ClientCA) == 0 && (len(provider.ChallengeURL) > 0 || len(provider.LoginURL) > 0) {
204
+		validationResults.AddWarnings(fielderrors.NewFieldInvalid("provider.clientCA", "", "if no clientCA is set, no request verification is done, and any request directly against the OAuth server can impersonate any identity from this provider"))
205
+	}
206
+
207
+	return validationResults
172 208
 }
173 209
 
174 210
 func ValidateOAuthIdentityProvider(clientID, clientSecret string, challenge bool) fielderrors.ValidationErrorList {
... ...
@@ -28,6 +28,7 @@ import (
28 28
 	"github.com/openshift/origin/pkg/auth/authenticator/password/denypassword"
29 29
 	"github.com/openshift/origin/pkg/auth/authenticator/password/htpasswd"
30 30
 	"github.com/openshift/origin/pkg/auth/authenticator/password/ldappassword"
31
+	"github.com/openshift/origin/pkg/auth/authenticator/redirector"
31 32
 	"github.com/openshift/origin/pkg/auth/authenticator/request/basicauthrequest"
32 33
 	"github.com/openshift/origin/pkg/auth/authenticator/request/headerrequest"
33 34
 	"github.com/openshift/origin/pkg/auth/authenticator/request/unionrequest"
... ...
@@ -323,6 +324,7 @@ func (c *AuthConfig) getAuthenticationHandler(mux cmdutil.Mux, errorHandler hand
323 323
 	for _, identityProvider := range c.Options.IdentityProviders {
324 324
 		identityMapper := identitymapper.NewAlwaysCreateUserIdentityToUserMapper(c.IdentityRegistry, c.UserRegistry)
325 325
 
326
+		// TODO: refactor handler building per type
326 327
 		if configapi.IsPasswordAuthenticator(identityProvider) {
327 328
 			passwordAuth, err := c.getPasswordAuthenticator(identityProvider)
328 329
 			if err != nil {
... ...
@@ -338,14 +340,15 @@ func (c *AuthConfig) getAuthenticationHandler(mux cmdutil.Mux, errorHandler hand
338 338
 				}
339 339
 				passwordSuccessHandler := handlers.AuthenticationSuccessHandlers{c.SessionAuth, redirectSuccessHandler{}}
340 340
 
341
-				redirectors["login"] = &redirector{RedirectURL: OpenShiftLoginPrefix, ThenParam: "then"}
341
+				// Since we're redirecting to a local login page, we don't need to force absolute URL resolution
342
+				redirectors["login-"+identityProvider.Name+"-redirect"] = redirector.NewRedirector(nil, OpenShiftLoginPrefix+"?then=${url}")
342 343
 				login := login.NewLogin(c.getCSRF(), &callbackPasswordAuthenticator{passwordAuth, passwordSuccessHandler}, login.DefaultLoginFormRenderer)
343 344
 				login.Install(mux, OpenShiftLoginPrefix)
344 345
 			}
345 346
 			if identityProvider.UseAsChallenger {
346
-				challengers["login"] = passwordchallenger.NewBasicAuthChallenger("openshift")
347
+				// For now, all password challenges share a single basic challenger, since they'll all respond to any basic credentials
348
+				challengers["basic-challenge"] = passwordchallenger.NewBasicAuthChallenger("openshift")
347 349
 			}
348
-
349 350
 		} else if configapi.IsOAuthIdentityProvider(identityProvider) {
350 351
 			oauthProvider, err := c.getOAuthProvider(identityProvider)
351 352
 			if err != nil {
... ...
@@ -374,11 +377,23 @@ func (c *AuthConfig) getAuthenticationHandler(mux cmdutil.Mux, errorHandler hand
374 374
 
375 375
 			mux.Handle(callbackPath, oauthHandler)
376 376
 			if identityProvider.UseAsLogin {
377
-				redirectors[identityProvider.Name] = oauthHandler
377
+				redirectors["oauth-"+identityProvider.Name+"-redirect"] = oauthHandler
378 378
 			}
379 379
 			if identityProvider.UseAsChallenger {
380 380
 				return nil, errors.New("oauth identity providers cannot issue challenges")
381 381
 			}
382
+		} else if requestHeaderProvider, isRequestHeader := identityProvider.Provider.Object.(*configapi.RequestHeaderIdentityProvider); isRequestHeader {
383
+			// We might be redirecting to an external site, we need to fully resolve the request URL to the public master
384
+			baseRequestURL, err := url.Parse(c.Options.MasterPublicURL + OpenShiftOAuthAPIPrefix + osinserver.AuthorizePath)
385
+			if err != nil {
386
+				return nil, err
387
+			}
388
+			if identityProvider.UseAsChallenger {
389
+				challengers["requestheader-"+identityProvider.Name+"-redirect"] = redirector.NewChallenger(baseRequestURL, requestHeaderProvider.ChallengeURL)
390
+			}
391
+			if identityProvider.UseAsLogin {
392
+				redirectors["requestheader-"+identityProvider.Name+"-redirect"] = redirector.NewRedirector(baseRequestURL, requestHeaderProvider.LoginURL)
393
+			}
382 394
 		}
383 395
 	}
384 396
 
... ...
@@ -554,27 +569,6 @@ func (c *AuthConfig) getAuthenticationRequestHandler() (authenticator.Request, e
554 554
 	return authRequestHandler, nil
555 555
 }
556 556
 
557
-// redirector captures the original request url as a "then" param in a redirect to a login flow
558
-type redirector struct {
559
-	RedirectURL string
560
-	ThenParam   string
561
-}
562
-
563
-// AuthenticationRedirect redirects HTTP request to authorization URL
564
-func (auth *redirector) AuthenticationRedirect(w http.ResponseWriter, req *http.Request) error {
565
-	redirectURL, err := url.Parse(auth.RedirectURL)
566
-	if err != nil {
567
-		return err
568
-	}
569
-	if len(auth.ThenParam) != 0 {
570
-		redirectURL.RawQuery = url.Values{
571
-			auth.ThenParam: {req.URL.String()},
572
-		}.Encode()
573
-	}
574
-	http.Redirect(w, req, redirectURL.String(), http.StatusFound)
575
-	return nil
576
-}
577
-
578 557
 // callbackPasswordAuthenticator combines password auth, successful login callback,
579 558
 // and "then" param redirection
580 559
 type callbackPasswordAuthenticator struct {
... ...
@@ -12,13 +12,21 @@ import (
12 12
 )
13 13
 
14 14
 // PromptForString takes an io.Reader and prompts for user input if it's a terminal, returning the result.
15
-func PromptForString(r io.Reader, format string, a ...interface{}) string {
16
-	fmt.Printf(format, a...)
15
+func PromptForString(r io.Reader, w io.Writer, format string, a ...interface{}) string {
16
+	if w == nil {
17
+		w = os.Stdout
18
+	}
19
+
20
+	fmt.Fprintf(w, format, a...)
17 21
 	return readInput(r)
18 22
 }
19 23
 
20 24
 // PromptForPasswordString prompts for user input by disabling echo in terminal, useful for password prompt.
21
-func PromptForPasswordString(r io.Reader, format string, a ...interface{}) string {
25
+func PromptForPasswordString(r io.Reader, w io.Writer, format string, a ...interface{}) string {
26
+	if w == nil {
27
+		w = os.Stdout
28
+	}
29
+
22 30
 	if file, ok := r.(*os.File); ok {
23 31
 		inFd := file.Fd()
24 32
 
... ...
@@ -26,10 +34,10 @@ func PromptForPasswordString(r io.Reader, format string, a ...interface{}) strin
26 26
 			oldState, err := term.SaveState(inFd)
27 27
 			if err != nil {
28 28
 				glog.V(3).Infof("Unable to save terminal state")
29
-				return PromptForString(r, format, a...)
29
+				return PromptForString(r, w, format, a...)
30 30
 			}
31 31
 
32
-			fmt.Printf(format, a...)
32
+			fmt.Fprintf(w, format, a...)
33 33
 
34 34
 			term.DisableEcho(inFd, oldState)
35 35
 
... ...
@@ -37,22 +45,26 @@ func PromptForPasswordString(r io.Reader, format string, a ...interface{}) strin
37 37
 
38 38
 			defer term.RestoreTerminal(inFd, oldState)
39 39
 
40
-			fmt.Printf("\n")
40
+			fmt.Fprintf(w, "\n")
41 41
 
42 42
 			return input
43 43
 		}
44 44
 		glog.V(3).Infof("Stdin is not a terminal")
45
-		return PromptForString(r, format, a...)
45
+		return PromptForString(r, w, format, a...)
46 46
 	}
47
-	return PromptForString(r, format, a...)
47
+	return PromptForString(r, w, format, a...)
48 48
 }
49 49
 
50 50
 // PromptForBool prompts for user input of a boolean value. The accepted values are:
51 51
 //   yes, y, true, 	t, 1 (not case sensitive)
52 52
 //   no, 	n, false, f, 0 (not case sensitive)
53 53
 // A valid answer is mandatory so it will keep asking until an answer is provided.
54
-func PromptForBool(r io.Reader, format string, a ...interface{}) bool {
55
-	str := PromptForString(r, format, a...)
54
+func PromptForBool(r io.Reader, w io.Writer, format string, a ...interface{}) bool {
55
+	if w == nil {
56
+		w = os.Stdout
57
+	}
58
+
59
+	str := PromptForString(r, w, format, a...)
56 60
 	switch strings.ToLower(str) {
57 61
 	case "1", "t", "true", "y", "yes":
58 62
 		return true
... ...
@@ -60,12 +72,16 @@ func PromptForBool(r io.Reader, format string, a ...interface{}) bool {
60 60
 		return false
61 61
 	}
62 62
 	fmt.Println("Please enter 'yes' or 'no'.")
63
-	return PromptForBool(r, format, a...)
63
+	return PromptForBool(r, w, format, a...)
64 64
 }
65 65
 
66 66
 // PromptForStringWithDefault prompts for user input but take a default in case nothing is provided.
67
-func PromptForStringWithDefault(r io.Reader, def string, format string, a ...interface{}) string {
68
-	s := PromptForString(r, format, a...)
67
+func PromptForStringWithDefault(r io.Reader, w io.Writer, def string, format string, a ...interface{}) string {
68
+	if w == nil {
69
+		w = os.Stdout
70
+	}
71
+
72
+	s := PromptForString(r, w, format, a...)
69 73
 	if len(s) == 0 {
70 74
 		return def
71 75
 	}
72 76
new file mode 100644
... ...
@@ -0,0 +1,119 @@
0
+package tokencmd
1
+
2
+import (
3
+	"encoding/base64"
4
+	"fmt"
5
+	"io"
6
+	"net/http"
7
+	"os"
8
+	"regexp"
9
+
10
+	"github.com/golang/glog"
11
+
12
+	"github.com/openshift/origin/pkg/cmd/util"
13
+)
14
+
15
+type BasicChallengeHandler struct {
16
+	// Host is the server being authenticated to. Used only for displaying messages when prompting for username/password
17
+	Host string
18
+
19
+	// Reader is used to prompt for username/password. If nil, no prompting is done
20
+	Reader io.Reader
21
+	// Writer is used to output prompts. If nil, stdout is used
22
+	Writer io.Writer
23
+
24
+	// Username is the username to use when challenged. If empty, a prompt is issued to a non-nil Reader
25
+	Username string
26
+	// Password is the password to use when challenged. If empty, a prompt is issued to a non-nil Reader
27
+	Password string
28
+
29
+	// handled tracks whether this handler has already handled a challenge.
30
+	handled bool
31
+	// prompted tracks whether this handler has already prompted for a username and/or password.
32
+	prompted bool
33
+}
34
+
35
+func (c *BasicChallengeHandler) CanHandle(headers http.Header) bool {
36
+	isBasic, _ := basicRealm(headers)
37
+	return isBasic
38
+}
39
+func (c *BasicChallengeHandler) HandleChallenge(headers http.Header) (http.Header, bool, error) {
40
+	if c.prompted {
41
+		glog.V(2).Info("already prompted for challenge, won't prompt again")
42
+		return nil, false, nil
43
+	}
44
+	if c.Reader == nil && c.handled {
45
+		glog.V(2).Info("already handled basic challenge, no reader to alter inputs")
46
+		return nil, false, nil
47
+	}
48
+
49
+	username := c.Username
50
+	password := c.Password
51
+
52
+	missingUsername := len(username) == 0
53
+	missingPassword := len(password) == 0
54
+
55
+	if (missingUsername || missingPassword) && c.Reader != nil {
56
+		w := c.Writer
57
+		if w == nil {
58
+			w = os.Stdout
59
+		}
60
+
61
+		if _, realm := basicRealm(headers); len(realm) > 0 {
62
+			fmt.Fprintf(w, "Authentication required for %s (%s)\n", c.Host, realm)
63
+		} else {
64
+			fmt.Fprintf(w, "Authentication required for %s\n", c.Host)
65
+		}
66
+		if missingUsername {
67
+			username = util.PromptForString(c.Reader, w, "Username: ")
68
+		} else {
69
+			fmt.Fprintf(w, "Username: %s\n", username)
70
+		}
71
+		if missingPassword {
72
+			password = util.PromptForPasswordString(c.Reader, w, "Password: ")
73
+		}
74
+		// remember so we don't re-prompt
75
+		c.prompted = true
76
+	}
77
+
78
+	if len(username) > 0 || len(password) > 0 {
79
+		responseHeaders := http.Header{}
80
+		responseHeaders.Set("Authorization", getBasicHeader(username, password))
81
+		// remember so we don't re-handle non-interactively
82
+		c.handled = true
83
+		return responseHeaders, true, nil
84
+	}
85
+
86
+	glog.V(2).Info("no username or password available")
87
+	return nil, false, nil
88
+}
89
+
90
+// if any of these match a WWW-Authenticate header, it is a basic challenge
91
+// capturing group 1 (if present) should contain the realm
92
+var basicRegexes = []*regexp.Regexp{
93
+	// quoted realm
94
+	regexp.MustCompile(`(?i)^\s*basic\s+realm\s*=\s*"(.*?)"\s*(,|$)`),
95
+	// token realm
96
+	regexp.MustCompile(`(?i)^\s*basic\s+realm\s*=\s*(.*?)\s*(,|$)`),
97
+	// no realm
98
+	regexp.MustCompile(`(?i)^\s*basic(?:\s+|$)`),
99
+}
100
+
101
+func basicRealm(headers http.Header) (bool, string) {
102
+	for _, challengeHeader := range headers[http.CanonicalHeaderKey("WWW-Authenticate")] {
103
+		for _, r := range basicRegexes {
104
+			if matches := r.FindStringSubmatch(challengeHeader); matches != nil {
105
+				if len(matches) > 1 {
106
+					// We got a realm as well
107
+					return true, matches[1]
108
+				}
109
+				// No realm, but still basic
110
+				return true, ""
111
+			}
112
+		}
113
+	}
114
+	return false, ""
115
+}
116
+func getBasicHeader(username, password string) string {
117
+	return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
118
+}
0 119
new file mode 100644
... ...
@@ -0,0 +1,296 @@
0
+package tokencmd
1
+
2
+import (
3
+	"bytes"
4
+	"net/http"
5
+	"reflect"
6
+	"testing"
7
+)
8
+
9
+var (
10
+	AUTHORIZATION    = http.CanonicalHeaderKey("Authorization")
11
+	WWW_AUTHENTICATE = http.CanonicalHeaderKey("WWW-Authenticate")
12
+)
13
+
14
+type Challenge struct {
15
+	Headers http.Header
16
+
17
+	ExpectedCanHandle bool
18
+	ExpectedHeaders   http.Header
19
+	ExpectedHandled   bool
20
+	ExpectedErr       error
21
+	ExpectedPrompt    string
22
+}
23
+
24
+func TestHandleChallenge(t *testing.T) {
25
+
26
+	basicChallenge := http.Header{WWW_AUTHENTICATE: []string{`Basic realm="myrealm"`}}
27
+
28
+	testCases := map[string]struct {
29
+		Handler    *BasicChallengeHandler
30
+		Challenges []Challenge
31
+	}{
32
+		"non-interactive with no defaults": {
33
+			Handler: &BasicChallengeHandler{
34
+				Host:     "myhost",
35
+				Reader:   nil,
36
+				Username: "",
37
+				Password: "",
38
+			},
39
+			Challenges: []Challenge{
40
+				{
41
+					Headers:           basicChallenge,
42
+					ExpectedCanHandle: true,
43
+					ExpectedHeaders:   nil,
44
+					ExpectedHandled:   false,
45
+					ExpectedErr:       nil,
46
+					ExpectedPrompt:    "",
47
+				},
48
+			},
49
+		},
50
+
51
+		"non-interactive challenge with defaults": {
52
+			Handler: &BasicChallengeHandler{
53
+				Host:     "myhost",
54
+				Reader:   nil,
55
+				Username: "myuser",
56
+				Password: "mypassword",
57
+			},
58
+			Challenges: []Challenge{
59
+				{
60
+					Headers:           basicChallenge,
61
+					ExpectedCanHandle: true,
62
+					ExpectedHeaders:   http.Header{AUTHORIZATION: []string{getBasicHeader("myuser", "mypassword")}},
63
+					ExpectedHandled:   true,
64
+					ExpectedErr:       nil,
65
+					ExpectedPrompt:    "",
66
+				},
67
+				{
68
+					Headers:           basicChallenge,
69
+					ExpectedCanHandle: true,
70
+					ExpectedHeaders:   nil,
71
+					ExpectedHandled:   false,
72
+					ExpectedErr:       nil,
73
+					ExpectedPrompt:    "",
74
+				},
75
+			},
76
+		},
77
+
78
+		"interactive challenge with default user": {
79
+			Handler: &BasicChallengeHandler{
80
+				Host:     "myhost",
81
+				Reader:   bytes.NewBufferString("mypassword\n"),
82
+				Username: "myuser",
83
+				Password: "",
84
+			},
85
+			Challenges: []Challenge{
86
+				{
87
+					Headers:           basicChallenge,
88
+					ExpectedCanHandle: true,
89
+					ExpectedHeaders:   http.Header{AUTHORIZATION: []string{getBasicHeader("myuser", "mypassword")}},
90
+					ExpectedHandled:   true,
91
+					ExpectedErr:       nil,
92
+					ExpectedPrompt: `Authentication required for myhost (myrealm)
93
+Username: myuser
94
+Password: `,
95
+				},
96
+			},
97
+		},
98
+
99
+		"interactive challenge": {
100
+			Handler: &BasicChallengeHandler{
101
+				Host:     "myhost",
102
+				Reader:   bytes.NewBufferString("myuser\nmypassword\n"),
103
+				Username: "",
104
+				Password: "",
105
+			},
106
+			Challenges: []Challenge{
107
+				{
108
+					Headers:           basicChallenge,
109
+					ExpectedCanHandle: true,
110
+					ExpectedHeaders:   http.Header{AUTHORIZATION: []string{getBasicHeader("myuser", "mypassword")}},
111
+					ExpectedHandled:   true,
112
+					ExpectedErr:       nil,
113
+					ExpectedPrompt: `Authentication required for myhost (myrealm)
114
+Username: Password: `,
115
+				},
116
+				{
117
+					Headers:           basicChallenge,
118
+					ExpectedCanHandle: true,
119
+					ExpectedHeaders:   nil,
120
+					ExpectedHandled:   false,
121
+					ExpectedErr:       nil,
122
+					ExpectedPrompt:    ``,
123
+				},
124
+			},
125
+		},
126
+	}
127
+
128
+	for k, tc := range testCases {
129
+		for i, challenge := range tc.Challenges {
130
+			out := &bytes.Buffer{}
131
+			tc.Handler.Writer = out
132
+
133
+			canHandle := tc.Handler.CanHandle(challenge.Headers)
134
+			if canHandle != challenge.ExpectedCanHandle {
135
+				t.Errorf("%s: %d: Expected CanHandle=%v, got %v", k, i, challenge.ExpectedCanHandle, canHandle)
136
+			}
137
+
138
+			if canHandle {
139
+				headers, handled, err := tc.Handler.HandleChallenge(challenge.Headers)
140
+				if !reflect.DeepEqual(headers, challenge.ExpectedHeaders) {
141
+					t.Errorf("%s: %d: Expected headers\n\t%#v\ngot\n\t%#v", k, i, challenge.ExpectedHeaders, headers)
142
+				}
143
+				if handled != challenge.ExpectedHandled {
144
+					t.Errorf("%s: %d: Expected handled=%v, got %v", k, i, challenge.ExpectedHandled, handled)
145
+				}
146
+				if err != challenge.ExpectedErr {
147
+					t.Errorf("%s: %d: Expected err=%v, got %v", k, i, challenge.ExpectedErr, err)
148
+				}
149
+				if out.String() != challenge.ExpectedPrompt {
150
+					t.Errorf("%s: %d: Expected prompt %q, got %q", k, i, challenge.ExpectedPrompt, out.String())
151
+				}
152
+			}
153
+		}
154
+	}
155
+}
156
+
157
+func TestBasicRealm(t *testing.T) {
158
+
159
+	testCases := map[string]struct {
160
+		Headers       http.Header
161
+		ExpectedBasic bool
162
+		ExpectedRealm string
163
+	}{
164
+		"empty": {
165
+			Headers:       http.Header{},
166
+			ExpectedBasic: false,
167
+			ExpectedRealm: ``,
168
+		},
169
+
170
+		"non-challenge": {
171
+			Headers: http.Header{
172
+				"test": []string{`value`},
173
+			},
174
+			ExpectedBasic: false,
175
+			ExpectedRealm: ``,
176
+		},
177
+
178
+		"non-basic": {
179
+			Headers: http.Header{
180
+				WWW_AUTHENTICATE: []string{
181
+					`basicrealm="myrealm"`,
182
+					`digest basic="realm"`,
183
+				},
184
+			},
185
+			ExpectedBasic: false,
186
+			ExpectedRealm: ``,
187
+		},
188
+
189
+		"basic multiple www-authenticate headers": {
190
+			Headers: http.Header{
191
+				WWW_AUTHENTICATE: []string{
192
+					`digest realm="digestrealm"`,
193
+					`basic realm="Foo"`,
194
+					`foo bar="baz"`,
195
+				},
196
+			},
197
+			ExpectedBasic: true,
198
+			ExpectedRealm: `Foo`,
199
+		},
200
+
201
+		"basic no realm": {
202
+			Headers: http.Header{
203
+				WWW_AUTHENTICATE: []string{`basic`},
204
+			},
205
+			ExpectedBasic: true,
206
+			ExpectedRealm: ``,
207
+		},
208
+
209
+		"basic other param": {
210
+			Headers: http.Header{
211
+				WWW_AUTHENTICATE: []string{`basic otherparam="othervalue"`},
212
+			},
213
+			ExpectedBasic: true,
214
+			ExpectedRealm: ``,
215
+		},
216
+
217
+		"basic token realm": {
218
+			Headers: http.Header{
219
+				WWW_AUTHENTICATE: []string{`basic realm=Foo Bar `},
220
+			},
221
+			ExpectedBasic: true,
222
+			ExpectedRealm: `Foo Bar`,
223
+		},
224
+
225
+		"basic quoted realm": {
226
+			Headers: http.Header{
227
+				WWW_AUTHENTICATE: []string{`basic realm="Foo Bar"`},
228
+			},
229
+			ExpectedBasic: true,
230
+			ExpectedRealm: `Foo Bar`,
231
+		},
232
+
233
+		"basic case-insensitive scheme": {
234
+			Headers: http.Header{
235
+				WWW_AUTHENTICATE: []string{`BASIC realm="Foo"`},
236
+			},
237
+			ExpectedBasic: true,
238
+			ExpectedRealm: `Foo`,
239
+		},
240
+
241
+		"basic case-insensitive realm": {
242
+			Headers: http.Header{
243
+				WWW_AUTHENTICATE: []string{`basic REALM="Foo"`},
244
+			},
245
+			ExpectedBasic: true,
246
+			ExpectedRealm: `Foo`,
247
+		},
248
+
249
+		"basic whitespace": {
250
+			Headers: http.Header{
251
+				WWW_AUTHENTICATE: []string{` 	basic 	realm 	= 	"Foo\" Bar" 	`},
252
+			},
253
+			ExpectedBasic: true,
254
+			ExpectedRealm: `Foo\" Bar`,
255
+		},
256
+
257
+		"basic trailing comma": {
258
+			Headers: http.Header{
259
+				WWW_AUTHENTICATE: []string{`basic realm="Foo", otherparam="value"`},
260
+			},
261
+			ExpectedBasic: true,
262
+			ExpectedRealm: `Foo`,
263
+		},
264
+
265
+		"realm containing quotes": {
266
+			Headers: http.Header{
267
+				WWW_AUTHENTICATE: []string{`basic realm="F\"oo", otherparam="value"`},
268
+			},
269
+			ExpectedBasic: true,
270
+			ExpectedRealm: `F\"oo`,
271
+		},
272
+
273
+		"realm containing comma": {
274
+			Headers: http.Header{
275
+				WWW_AUTHENTICATE: []string{`basic realm="Foo, bar", otherparam="value"`},
276
+			},
277
+			ExpectedBasic: true,
278
+			ExpectedRealm: `Foo, bar`,
279
+		},
280
+
281
+		// TODO: additional forms to support
282
+		//   Basic param="value", realm="myrealm"
283
+		//   Digest, Basic param="value", realm="myrealm"
284
+	}
285
+
286
+	for k, tc := range testCases {
287
+		isBasic, realm := basicRealm(tc.Headers)
288
+		if isBasic != tc.ExpectedBasic {
289
+			t.Errorf("%s: Expected isBasicChallenge=%v, got %v", k, tc.ExpectedBasic, isBasic)
290
+		}
291
+		if realm != tc.ExpectedRealm {
292
+			t.Errorf("%s: Expected realm=%q, got %q", k, tc.ExpectedRealm, realm)
293
+		}
294
+	}
295
+}
0 296
deleted file mode 100644
... ...
@@ -1,87 +0,0 @@
1
-package tokencmd
2
-
3
-import (
4
-	"fmt"
5
-	"io"
6
-	"net/http"
7
-	"regexp"
8
-	"strings"
9
-
10
-	kclient "k8s.io/kubernetes/pkg/client"
11
-
12
-	"github.com/openshift/origin/pkg/cmd/util"
13
-)
14
-
15
-// CSRFTokenHeader is a marker header that indicates we are not a browser that got tricked into requesting basic auth
16
-// Corresponds to the header expected by basic-auth challenging authenticators
17
-const CSRFTokenHeader = "X-CSRF-Token"
18
-
19
-// challengingClient conforms the kclient.HTTPClient interface.  It introspects responses for auth challenges and
20
-// tries to response to those challenges in order to get a token back.
21
-type challengingClient struct {
22
-	delegate        *http.Client
23
-	reader          io.Reader
24
-	defaultUsername string
25
-	defaultPassword string
26
-}
27
-
28
-const basicAuthPattern = `[\s]*Basic[\s]*realm="([\w]+)"`
29
-
30
-var basicAuthRegex = regexp.MustCompile(basicAuthPattern)
31
-
32
-// Do watches for unauthorized challenges.  If we know to respond, we respond to the challenge
33
-func (client *challengingClient) Do(req *http.Request) (*http.Response, error) {
34
-	// Set custom header required by server to avoid CSRF attacks on browsers using basic auth
35
-	if req.Header == nil {
36
-		req.Header = http.Header{}
37
-	}
38
-	req.Header.Set(CSRFTokenHeader, "1")
39
-
40
-	resp, err := client.delegate.Do(req)
41
-	if err != nil {
42
-		return nil, err
43
-	}
44
-
45
-	if resp.StatusCode == http.StatusUnauthorized {
46
-		if wantsBasicAuth, realm := isBasicAuthChallenge(resp); wantsBasicAuth {
47
-			username := client.defaultUsername
48
-			password := client.defaultPassword
49
-
50
-			missingUsername := len(username) == 0
51
-			missingPassword := len(password) == 0
52
-
53
-			url := *req.URL
54
-			url.Path, url.RawQuery, url.Fragment = "", "", ""
55
-
56
-			if (missingUsername || missingPassword) && client.reader != nil {
57
-				fmt.Printf("Authentication required for %s (%s)\n", &url, realm)
58
-				if missingUsername {
59
-					username = util.PromptForString(client.reader, "Username: ")
60
-				}
61
-				if missingPassword {
62
-					password = util.PromptForPasswordString(client.reader, "Password: ")
63
-				}
64
-			}
65
-
66
-			if len(username) > 0 || len(password) > 0 {
67
-				client.delegate.Transport = kclient.NewBasicAuthRoundTripper(username, password, client.delegate.Transport)
68
-				return client.delegate.Do(resp.Request)
69
-			}
70
-		}
71
-	}
72
-	return resp, err
73
-}
74
-
75
-func isBasicAuthChallenge(resp *http.Response) (bool, string) {
76
-	for currHeader, headerValue := range resp.Header {
77
-		if strings.EqualFold(currHeader, "WWW-Authenticate") {
78
-			for _, currAuthorizeHeader := range headerValue {
79
-				if matches := basicAuthRegex.FindAllStringSubmatch(currAuthorizeHeader, 1); matches != nil {
80
-					return true, matches[0][1]
81
-				}
82
-			}
83
-		}
84
-	}
85
-
86
-	return false, ""
87
-}
... ...
@@ -1,121 +1,154 @@
1 1
 package tokencmd
2 2
 
3 3
 import (
4
-	"bytes"
5
-	"encoding/json"
6 4
 	"errors"
7 5
 	"fmt"
8 6
 	"io"
7
+	"io/ioutil"
9 8
 	"net/http"
10 9
 	"net/url"
10
+	"strings"
11 11
 
12
-	"github.com/RangelReale/osincli"
13
-	"github.com/golang/glog"
14
-
12
+	"k8s.io/kubernetes/pkg/api"
13
+	apierrs "k8s.io/kubernetes/pkg/api/errors"
15 14
 	kclient "k8s.io/kubernetes/pkg/client"
16
-
17
-	"github.com/openshift/origin/pkg/client"
18
-	"github.com/openshift/origin/pkg/oauth/server/osinserver"
15
+	"k8s.io/kubernetes/pkg/util"
19 16
 )
20 17
 
18
+// CSRFTokenHeader is a marker header that indicates we are not a browser that got tricked into requesting basic auth
19
+// Corresponds to the header expected by basic-auth challenging authenticators
20
+const CSRFTokenHeader = "X-CSRF-Token"
21
+
21 22
 // RequestToken uses the cmd arguments to locate an openshift oauth server and attempts to authenticate
22 23
 // it returns the access token if it gets one.  An error if it does not
23 24
 func RequestToken(clientCfg *kclient.Config, reader io.Reader, defaultUsername string, defaultPassword string) (string, error) {
24
-	tokenGetter := &tokenGetterInfo{}
25
-
26
-	osClient, err := client.New(clientCfg)
27
-	if err != nil {
28
-		return "", err
25
+	challengeHandler := &BasicChallengeHandler{
26
+		Host:     clientCfg.Host,
27
+		Reader:   reader,
28
+		Username: defaultUsername,
29
+		Password: defaultPassword,
29 30
 	}
30 31
 
31
-	// get the transport, so that we can use it to build our own client that wraps it
32
-	// our client understands certain challenges and can respond to them
33
-	clientTransport, err := kclient.TransportFor(clientCfg)
32
+	rt, err := kclient.TransportFor(clientCfg)
34 33
 	if err != nil {
35 34
 		return "", err
36 35
 	}
37 36
 
38
-	httpClient := &http.Client{
39
-		Transport:     clientTransport,
40
-		CheckRedirect: tokenGetter.checkRedirect,
41
-	}
42
-
43
-	osClient.Client = &challengingClient{httpClient, reader, defaultUsername, defaultPassword}
37
+	// requestURL holds the current URL to make requests to. This can change if the server responds with a redirect
38
+	requestURL := clientCfg.Host + "/oauth/authorize?response_type=token&client_id=openshift-challenging-client"
39
+	// requestHeaders holds additional headers to add to the request. This can be changed by challengeHandlers
40
+	requestHeaders := http.Header{}
41
+	// requestedURLSet/requestedURLList hold the URLs we have requested, to prevent redirect loops. Gets reset when a challenge is handled.
42
+	requestedURLSet := util.NewStringSet()
43
+	requestedURLList := []string{}
44
+
45
+	for {
46
+		// Make the request
47
+		resp, err := request(rt, requestURL, requestHeaders)
48
+		if err != nil {
49
+			return "", err
50
+		}
51
+		defer resp.Body.Close()
52
+
53
+		if resp.StatusCode == http.StatusUnauthorized {
54
+			if resp.Header.Get("WWW-Authenticate") != "" {
55
+				if !challengeHandler.CanHandle(resp.Header) {
56
+					return "", apierrs.NewUnauthorized("unhandled challenge")
57
+				}
58
+				// Handle a challenge
59
+				newRequestHeaders, shouldRetry, err := challengeHandler.HandleChallenge(resp.Header)
60
+				if err != nil {
61
+					return "", err
62
+				}
63
+				if !shouldRetry {
64
+					return "", apierrs.NewUnauthorized("challenger chose not to retry the request")
65
+				}
66
+
67
+				// Reset request set/list. Since we're setting different headers, it is legitimate to request the same urls
68
+				requestedURLSet = util.NewStringSet()
69
+				requestedURLList = []string{}
70
+				// Use the response to the challenge as the new headers
71
+				requestHeaders = newRequestHeaders
72
+				continue
73
+			}
44 74
 
45
-	result := osClient.Get().AbsPath("/oauth", osinserver.AuthorizePath).
46
-		Param("response_type", "token").
47
-		Param("client_id", "openshift-challenging-client").
48
-		Do()
49
-	if err := result.Error(); err != nil && !isRedirectError(err) {
50
-		return "", err
51
-	}
75
+			// Unauthorized with no challenge
76
+			unauthorizedError := apierrs.NewUnauthorized("")
77
+			// Attempt to read body content and include as an error detail
78
+			if details, err := ioutil.ReadAll(resp.Body); err == nil && len(details) > 0 {
79
+				unauthorizedError.(*apierrs.StatusError).ErrStatus.Details = &api.StatusDetails{
80
+					Causes: []api.StatusCause{
81
+						{Message: string(details)},
82
+					},
83
+				}
84
+			}
52 85
 
53
-	if len(tokenGetter.accessToken) == 0 {
54
-		r, _ := result.Raw()
55
-		if description, ok := rawOAuthJSONErrorDescription(r); ok {
56
-			return "", fmt.Errorf("cannot retrieve a token: %s", description)
86
+			return "", unauthorizedError
57 87
 		}
58
-		glog.V(4).Infof("A request token could not be created, server returned: %s", string(r))
59
-		return "", fmt.Errorf("the server did not return a token (possible server error)")
60
-	}
61 88
 
62
-	return tokenGetter.accessToken, nil
63
-}
89
+		if resp.StatusCode == http.StatusFound {
90
+			redirectURL := resp.Header.Get("Location")
64 91
 
65
-func rawOAuthJSONErrorDescription(data []byte) (string, bool) {
66
-	output := osincli.ResponseData{}
67
-	decoder := json.NewDecoder(bytes.NewBuffer(data))
68
-	if err := decoder.Decode(&output); err != nil {
69
-		return "", false
70
-	}
71
-	if _, ok := output["error"]; !ok {
72
-		return "", false
73
-	}
74
-	desc, ok := output["error_description"]
75
-	if !ok {
76
-		return "", false
77
-	}
78
-	s, ok := desc.(string)
79
-	if !ok || len(s) == 0 {
80
-		return "", false
92
+			// OAuth response case (access_token or error parameter)
93
+			accessToken, err := oauthAuthorizeResult(redirectURL)
94
+			if err != nil {
95
+				return "", err
96
+			}
97
+			if len(accessToken) > 0 {
98
+				return accessToken, err
99
+			}
100
+
101
+			// Non-OAuth response, just follow the URL
102
+			// add to our list of redirects
103
+			requestedURLList = append(requestedURLList, redirectURL)
104
+			// detect loops
105
+			if !requestedURLSet.Has(redirectURL) {
106
+				requestedURLSet.Insert(redirectURL)
107
+				requestURL = redirectURL
108
+				continue
109
+			}
110
+			return "", apierrs.NewInternalError(fmt.Errorf("redirect loop: %s", strings.Join(requestedURLList, " -> ")))
111
+		}
112
+
113
+		// Unknown response
114
+		return "", apierrs.NewInternalError(fmt.Errorf("unexpected response: %d", resp.StatusCode))
81 115
 	}
82
-	return s, true
83 116
 }
84 117
 
85
-const accessTokenKey = "access_token"
86
-
87
-var errRedirectComplete = errors.New("found access token")
118
+func oauthAuthorizeResult(location string) (string, error) {
119
+	u, err := url.Parse(location)
120
+	if err != nil {
121
+		return "", err
122
+	}
88 123
 
89
-type tokenGetterInfo struct {
90
-	accessToken string
91
-}
124
+	if errorCode := u.Query().Get("error"); len(errorCode) > 0 {
125
+		errorDescription := u.Query().Get("error_description")
126
+		return "", errors.New(errorCode + " " + errorDescription)
127
+	}
92 128
 
93
-// checkRedirect watches the redirects to see if any contain the access_token anchor.  It then stores the value of the access token for later retrieval
94
-func (tokenGetter *tokenGetterInfo) checkRedirect(req *http.Request, via []*http.Request) error {
95
-	fragment := req.URL.Fragment
96
-	if values, err := url.ParseQuery(fragment); err == nil {
97
-		if v, ok := values[accessTokenKey]; ok {
98
-			if len(v) > 0 {
99
-				tokenGetter.accessToken = v[0]
100
-			}
101
-			return errRedirectComplete
102
-		}
129
+	fragmentValues, err := url.ParseQuery(u.Fragment)
130
+	if err != nil {
131
+		return "", err
103 132
 	}
104 133
 
105
-	if len(via) >= 10 {
106
-		return errors.New("stopped after 10 redirects")
134
+	if accessToken := fragmentValues.Get("access_token"); len(accessToken) > 0 {
135
+		return accessToken, nil
107 136
 	}
108 137
 
109
-	return nil
138
+	return "", nil
110 139
 }
111 140
 
112
-func isRedirectError(err error) bool {
113
-	if err == errRedirectComplete {
114
-		return true
141
+func request(rt http.RoundTripper, requestURL string, requestHeaders http.Header) (*http.Response, error) {
142
+	// Build the request
143
+	req, err := http.NewRequest("GET", requestURL, nil)
144
+	if err != nil {
145
+		return nil, err
115 146
 	}
116
-	switch t := err.(type) {
117
-	case *url.Error:
118
-		return t.Err == errRedirectComplete
147
+	for k, v := range requestHeaders {
148
+		req.Header[k] = v
119 149
 	}
120
-	return false
150
+	req.Header.Set(CSRFTokenHeader, "1")
151
+
152
+	// Make the request
153
+	return rt.RoundTrip(req)
121 154
 }
... ...
@@ -3,16 +3,22 @@
3 3
 package integration
4 4
 
5 5
 import (
6
+	"bytes"
7
+	"io"
6 8
 	"io/ioutil"
7 9
 	"net/http"
10
+	"net/http/httptest"
11
+	"net/url"
8 12
 	"os"
9 13
 	"regexp"
10 14
 	"testing"
11 15
 
12 16
 	kclient "k8s.io/kubernetes/pkg/client"
17
+	clientcmdapi "k8s.io/kubernetes/pkg/client/clientcmd/api"
13 18
 	"k8s.io/kubernetes/pkg/runtime"
14 19
 
15 20
 	"github.com/openshift/origin/pkg/client"
21
+	"github.com/openshift/origin/pkg/cmd/cli/cmd"
16 22
 	configapi "github.com/openshift/origin/pkg/cmd/server/api"
17 23
 	testutil "github.com/openshift/origin/test/util"
18 24
 )
... ...
@@ -88,7 +94,17 @@ qLwYJxjzwYTLvLYPU5vHmdg8v5wIXh0TaRTDTdKViISGD09aiXSYzw==
88 88
 `)
89 89
 )
90 90
 
91
+// TestOAuthRequestHeader checks the following scenarios:
92
+//  * request containing remote user header is ignored if it doesn't have client cert auth
93
+//  * request containing remote user header is honored if it has client cert auth
94
+//  * unauthenticated requests are redirected to an auth proxy
95
+//  * login command succeeds against a request-header identity provider via redirection to an auth proxy
91 96
 func TestOAuthRequestHeader(t *testing.T) {
97
+	// Test data used by auth proxy
98
+	users := map[string]string{
99
+		"myusername": "mypassword",
100
+	}
101
+
92 102
 	// Write cert we're going to use to verify OAuth requestheader requests
93 103
 	caFile, err := ioutil.TempFile("", "test.crt")
94 104
 	if err != nil {
... ...
@@ -99,19 +115,63 @@ func TestOAuthRequestHeader(t *testing.T) {
99 99
 		t.Fatalf("unexpected error: %v", err)
100 100
 	}
101 101
 
102
+	// Get master config
102 103
 	masterOptions, err := testutil.DefaultMasterOptions()
103 104
 	if err != nil {
104 105
 		t.Fatalf("unexpected error: %v", err)
105 106
 	}
107
+	masterURL, _ := url.Parse(masterOptions.OAuthConfig.MasterPublicURL)
108
+
109
+	// Set up an auth proxy
110
+	var proxyTransport http.RoundTripper
111
+	proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112
+		// Decide whether to challenge
113
+		username, password, hasBasicAuth := r.BasicAuth()
114
+		if correctPassword, hasUser := users[username]; !hasBasicAuth || !hasUser || password != correctPassword {
115
+			w.Header().Set("WWW-Authenticate", "Basic realm=Protected Area")
116
+			w.WriteHeader(401)
117
+			return
118
+		}
119
+
120
+		// Swap the scheme and host to the master, keeping path and params the same
121
+		proxyURL := r.URL
122
+		proxyURL.Scheme = masterURL.Scheme
123
+		proxyURL.Host = masterURL.Host
124
+
125
+		// Build a request, copying the original method, body, and headers, overriding the remote user headers
126
+		proxyRequest, _ := http.NewRequest(r.Method, proxyURL.String(), r.Body)
127
+		proxyRequest.Header = r.Header
128
+		proxyRequest.Header.Set("My-Remote-User", username)
129
+		proxyRequest.Header.Set("SSO-User", "")
130
+
131
+		// Round trip to the back end
132
+		response, err := proxyTransport.RoundTrip(r)
133
+		if err != nil {
134
+			t.Fatalf("Unexpected error: %v", err)
135
+		}
136
+		defer response.Body.Close()
137
+
138
+		// Copy response back to originator
139
+		for k, v := range response.Header {
140
+			w.Header()[k] = v
141
+		}
142
+		w.WriteHeader(response.StatusCode)
143
+		if _, err := io.Copy(w, response.Body); err != nil {
144
+			t.Fatalf("Unexpected error: %v", err)
145
+		}
146
+	}))
147
+	defer proxyServer.Close()
106 148
 
107 149
 	masterOptions.OAuthConfig.IdentityProviders[0] = configapi.IdentityProvider{
108 150
 		Name:            "requestheader",
109
-		UseAsChallenger: false,
110
-		UseAsLogin:      false,
151
+		UseAsChallenger: true,
152
+		UseAsLogin:      true,
111 153
 		Provider: runtime.EmbeddedObject{
112 154
 			Object: &configapi.RequestHeaderIdentityProvider{
113
-				ClientCA: caFile.Name(),
114
-				Headers:  []string{"My-Remote-User", "SSO-User"},
155
+				ChallengeURL: proxyServer.URL + "/oauth/authorize?${query}",
156
+				LoginURL:     "http://www.example.com/login?then=${url}",
157
+				ClientCA:     caFile.Name(),
158
+				Headers:      []string{"My-Remote-User", "SSO-User"},
115 159
 			},
116 160
 		},
117 161
 	}
... ...
@@ -132,49 +192,53 @@ func TestOAuthRequestHeader(t *testing.T) {
132 132
 	anonConfig.Host = clientConfig.Host
133 133
 	anonConfig.CAFile = clientConfig.CAFile
134 134
 	anonConfig.CAData = clientConfig.CAData
135
+	anonTransport, err := kclient.TransportFor(&anonConfig)
136
+	if err != nil {
137
+		t.Fatalf("unexpected error: %v", err)
138
+	}
139
+
140
+	// Use the server and CA info, with cert info
141
+	proxyConfig := anonConfig
142
+	proxyConfig.CertData = clientCert
143
+	proxyConfig.KeyData = clientKey
144
+	proxyTransport, err = kclient.TransportFor(&proxyConfig)
145
+	if err != nil {
146
+		t.Fatalf("unexpected error: %v", err)
147
+	}
135 148
 
136
-	// Build the authorize request with the My-Remote-User header
149
+	// Build the authorize request, spoofing a remote user header
137 150
 	authorizeURL := clientConfig.Host + "/oauth/authorize?client_id=openshift-challenging-client&response_type=token"
138 151
 	req, err := http.NewRequest("GET", authorizeURL, nil)
139 152
 	req.Header.Set("My-Remote-User", "myuser")
140 153
 
141 154
 	// Make the request without cert auth
142
-	transport, err := kclient.TransportFor(&anonConfig)
155
+	resp, err := anonTransport.RoundTrip(req)
143 156
 	if err != nil {
144 157
 		t.Fatalf("unexpected error: %v", err)
145 158
 	}
146
-	resp, err := transport.RoundTrip(req)
159
+	proxyRedirect, err := resp.Location()
147 160
 	if err != nil {
148
-		t.Fatalf("unexpected error: %v", err)
161
+		t.Fatalf("expected spoofed remote user header to get 302 redirect, got error: %v", err)
149 162
 	}
150
-	redirect, err := resp.Location()
151
-	if err != nil {
152
-		t.Fatalf("expected 302 redirect, got error: %v", err)
153
-	}
154
-	if redirect.Query().Get("error") == "" {
155
-		t.Fatalf("expected unsuccessful token request, got redirected to %v", redirect.String())
163
+	if proxyRedirect.String() != proxyServer.URL+"/oauth/authorize?client_id=openshift-challenging-client&response_type=token" {
164
+		t.Fatalf("expected redirect to proxy endpoint, got redirected to %v", proxyRedirect.String())
156 165
 	}
157 166
 
158
-	// Use the server and CA info, with cert info
159
-	authProxyConfig := anonConfig
160
-	authProxyConfig.CertData = clientCert
161
-	authProxyConfig.KeyData = clientKey
167
+	// Request the redirected URL, which should cause the proxy to make the same request with cert auth
168
+	req, err = http.NewRequest("GET", proxyRedirect.String(), nil)
169
+	req.Header.Set("My-Remote-User", "myuser")
170
+	req.SetBasicAuth("myusername", "mypassword")
162 171
 
163
-	// Make the request with cert info
164
-	transport, err = kclient.TransportFor(&authProxyConfig)
165
-	if err != nil {
166
-		t.Fatalf("unexpected error: %v", err)
167
-	}
168
-	resp, err = transport.RoundTrip(req)
172
+	resp, err = proxyTransport.RoundTrip(req)
169 173
 	if err != nil {
170 174
 		t.Fatalf("unexpected error: %v", err)
171 175
 	}
172
-	redirect, err = resp.Location()
176
+	tokenRedirect, err := resp.Location()
173 177
 	if err != nil {
174 178
 		t.Fatalf("expected 302 redirect, got error: %v", err)
175 179
 	}
176
-	if redirect.Query().Get("error") != "" {
177
-		t.Fatalf("expected successful token request, got error %v", redirect.String())
180
+	if tokenRedirect.Query().Get("error") != "" {
181
+		t.Fatalf("expected successful token request, got error %v", tokenRedirect.String())
178 182
 	}
179 183
 
180 184
 	// Extract the access_token
... ...
@@ -182,11 +246,11 @@ func TestOAuthRequestHeader(t *testing.T) {
182 182
 	// group #0 is everything.                      #1                #2     #3
183 183
 	accessTokenRedirectRegex := regexp.MustCompile(`(^|&)access_token=([^&]+)($|&)`)
184 184
 	accessToken := ""
185
-	if matches := accessTokenRedirectRegex.FindStringSubmatch(redirect.Fragment); matches != nil {
185
+	if matches := accessTokenRedirectRegex.FindStringSubmatch(tokenRedirect.Fragment); matches != nil {
186 186
 		accessToken = matches[2]
187 187
 	}
188 188
 	if accessToken == "" {
189
-		t.Fatalf("Expected access token, got %s", redirect.String())
189
+		t.Fatalf("Expected access token, got %s", tokenRedirect.String())
190 190
 	}
191 191
 
192 192
 	// Make sure we can use the token, and it represents who we expect
... ...
@@ -200,7 +264,41 @@ func TestOAuthRequestHeader(t *testing.T) {
200 200
 	if err != nil {
201 201
 		t.Fatalf("Unexpected error: %v", err)
202 202
 	}
203
-	if user.Name != "myuser" {
204
-		t.Fatalf("Expected myuser as the user, got %v", user)
203
+	if user.Name != "myusername" {
204
+		t.Fatalf("Expected myusername as the user, got %v", user)
205
+	}
206
+
207
+	// Get the master CA data for the login command
208
+	masterCAFile := userConfig.CAFile
209
+	if masterCAFile == "" {
210
+		// Write master ca data
211
+		tmpFile, err := ioutil.TempFile("", "ca.crt")
212
+		if err != nil {
213
+			t.Fatalf("unexpected error: %v", err)
214
+		}
215
+		defer os.Remove(tmpFile.Name())
216
+		if err := ioutil.WriteFile(tmpFile.Name(), userConfig.CAData, os.FileMode(0600)); err != nil {
217
+			t.Fatalf("unexpected error: %v", err)
218
+		}
219
+		masterCAFile = tmpFile.Name()
220
+	}
221
+
222
+	// Attempt a login using a redirecting auth proxy
223
+	loginOutput := &bytes.Buffer{}
224
+	loginOptions := &cmd.LoginOptions{
225
+		Server:             anonConfig.Host,
226
+		CAFile:             masterCAFile,
227
+		StartingKubeConfig: &clientcmdapi.Config{},
228
+		Reader:             bytes.NewBufferString("myusername\nmypassword\n"),
229
+		Out:                loginOutput,
230
+	}
231
+	if err := loginOptions.GatherInfo(); err != nil {
232
+		t.Fatalf("Error trying to determine server info: %v\n%v", err, loginOutput.String())
233
+	}
234
+	if loginOptions.Username != "myusername" {
235
+		t.Fatalf("Unexpected user after authentication: %#v", loginOptions)
236
+	}
237
+	if len(loginOptions.Config.BearerToken) == 0 {
238
+		t.Fatalf("Expected token after authentication: %#v", loginOptions.Config)
205 239
 	}
206 240
 }