1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
23 "git.arvados.org/arvados.git/lib/config"
24 "git.arvados.org/arvados.git/lib/controller/rpc"
25 "git.arvados.org/arvados.git/sdk/go/arvados"
26 "git.arvados.org/arvados.git/sdk/go/arvadostest"
27 "git.arvados.org/arvados.git/sdk/go/auth"
28 "git.arvados.org/arvados.git/sdk/go/ctxlog"
29 check "gopkg.in/check.v1"
30 jose "gopkg.in/square/go-jose.v2"
33 // Gocheck boilerplate
34 func Test(t *testing.T) {
38 var _ = check.Suite(&OIDCLoginSuite{})
40 type OIDCLoginSuite struct {
41 cluster *arvados.Cluster
44 railsSpy *arvadostest.Proxy
45 fakeIssuer *httptest.Server
46 fakePeopleAPI *httptest.Server
47 fakePeopleAPIResponse map[string]interface{}
48 issuerKey *rsa.PrivateKey
50 // expected token request
53 validClientSecret string
54 // desired response from token endpoint
56 authEmailVerified bool
60 func (s *OIDCLoginSuite) TearDownSuite(c *check.C) {
61 // Undo any changes/additions to the user database so they
62 // don't affect subsequent tests.
63 arvadostest.ResetEnv()
64 c.Check(arvados.NewClientFromEnv().RequestAndDecode(nil, "POST", "database/reset", nil, nil), check.IsNil)
67 func (s *OIDCLoginSuite) SetUpTest(c *check.C) {
69 s.issuerKey, err = rsa.GenerateKey(rand.Reader, 2048)
70 c.Assert(err, check.IsNil)
72 s.authEmail = "active-user@arvados.local"
73 s.authEmailVerified = true
74 s.authName = "Fake User Name"
75 s.fakeIssuer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
77 c.Logf("fakeIssuer: got req: %s %s %s", req.Method, req.URL, req.Form)
78 w.Header().Set("Content-Type", "application/json")
80 case "/.well-known/openid-configuration":
81 json.NewEncoder(w).Encode(map[string]interface{}{
82 "issuer": s.fakeIssuer.URL,
83 "authorization_endpoint": s.fakeIssuer.URL + "/auth",
84 "token_endpoint": s.fakeIssuer.URL + "/token",
85 "jwks_uri": s.fakeIssuer.URL + "/jwks",
86 "userinfo_endpoint": s.fakeIssuer.URL + "/userinfo",
89 var clientID, clientSecret string
90 auth, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(req.Header.Get("Authorization"), "Basic "))
91 authsplit := strings.Split(string(auth), ":")
92 if len(authsplit) == 2 {
93 clientID, _ = url.QueryUnescape(authsplit[0])
94 clientSecret, _ = url.QueryUnescape(authsplit[1])
96 if clientID != s.validClientID || clientSecret != s.validClientSecret {
97 c.Logf("fakeIssuer: expected (%q, %q) got (%q, %q)", s.validClientID, s.validClientSecret, clientID, clientSecret)
98 w.WriteHeader(http.StatusUnauthorized)
102 if req.Form.Get("code") != s.validCode || s.validCode == "" {
103 w.WriteHeader(http.StatusUnauthorized)
106 idToken, _ := json.Marshal(map[string]interface{}{
107 "iss": s.fakeIssuer.URL,
108 "aud": []string{clientID},
109 "sub": "fake-user-id",
110 "exp": time.Now().UTC().Add(time.Minute).Unix(),
111 "iat": time.Now().UTC().Unix(),
112 "nonce": "fake-nonce",
113 "email": s.authEmail,
114 "email_verified": s.authEmailVerified,
117 json.NewEncoder(w).Encode(struct {
118 AccessToken string `json:"access_token"`
119 TokenType string `json:"token_type"`
120 RefreshToken string `json:"refresh_token"`
121 ExpiresIn int32 `json:"expires_in"`
122 IDToken string `json:"id_token"`
124 AccessToken: s.fakeToken(c, []byte("fake access token")),
126 RefreshToken: "test-refresh-token",
128 IDToken: s.fakeToken(c, idToken),
131 json.NewEncoder(w).Encode(jose.JSONWebKeySet{
132 Keys: []jose.JSONWebKey{
133 {Key: s.issuerKey.Public(), Algorithm: string(jose.RS256), KeyID: ""},
137 w.WriteHeader(http.StatusInternalServerError)
139 w.WriteHeader(http.StatusInternalServerError)
141 w.WriteHeader(http.StatusNotFound)
144 s.validCode = fmt.Sprintf("abcdefgh-%d", time.Now().Unix())
146 s.fakePeopleAPI = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
148 c.Logf("fakePeopleAPI: got req: %s %s %s", req.Method, req.URL, req.Form)
149 w.Header().Set("Content-Type", "application/json")
150 switch req.URL.Path {
151 case "/v1/people/me":
152 if f := req.Form.Get("personFields"); f != "emailAddresses,names" {
153 w.WriteHeader(http.StatusBadRequest)
156 json.NewEncoder(w).Encode(s.fakePeopleAPIResponse)
158 w.WriteHeader(http.StatusNotFound)
161 s.fakePeopleAPIResponse = map[string]interface{}{}
163 cfg, err := config.NewLoader(nil, ctxlog.TestLogger(c)).Load()
164 c.Assert(err, check.IsNil)
165 s.cluster, err = cfg.GetCluster("")
166 c.Assert(err, check.IsNil)
167 s.cluster.Login.SSO.Enable = false
168 s.cluster.Login.Google.Enable = true
169 s.cluster.Login.Google.ClientID = "test%client$id"
170 s.cluster.Login.Google.ClientSecret = "test#client/secret"
171 s.cluster.Users.PreferDomainForUsername = "PreferDomainForUsername.example.com"
172 s.validClientID = "test%client$id"
173 s.validClientSecret = "test#client/secret"
175 s.localdb = NewConn(s.cluster)
176 c.Assert(s.localdb.loginController, check.FitsTypeOf, (*oidcLoginController)(nil))
177 s.localdb.loginController.(*oidcLoginController).Issuer = s.fakeIssuer.URL
178 s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
180 s.railsSpy = arvadostest.NewProxy(c, s.cluster.Services.RailsAPI)
181 *s.localdb.railsProxy = *rpc.NewConn(s.cluster.ClusterID, s.railsSpy.URL, true, rpc.PassthroughTokenProvider)
184 func (s *OIDCLoginSuite) TearDownTest(c *check.C) {
188 func (s *OIDCLoginSuite) TestGoogleLogout(c *check.C) {
189 resp, err := s.localdb.Logout(context.Background(), arvados.LogoutOptions{ReturnTo: "https://foo.example.com/bar"})
190 c.Check(err, check.IsNil)
191 c.Check(resp.RedirectLocation, check.Equals, "https://foo.example.com/bar")
194 func (s *OIDCLoginSuite) TestGoogleLogin_Start_Bogus(c *check.C) {
195 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{})
196 c.Check(err, check.IsNil)
197 c.Check(resp.RedirectLocation, check.Equals, "")
198 c.Check(resp.HTML.String(), check.Matches, `.*missing return_to parameter.*`)
201 func (s *OIDCLoginSuite) TestGoogleLogin_Start(c *check.C) {
202 for _, remote := range []string{"", "zzzzz"} {
203 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{Remote: remote, ReturnTo: "https://app.example.com/foo?bar"})
204 c.Check(err, check.IsNil)
205 target, err := url.Parse(resp.RedirectLocation)
206 c.Check(err, check.IsNil)
207 issuerURL, _ := url.Parse(s.fakeIssuer.URL)
208 c.Check(target.Host, check.Equals, issuerURL.Host)
210 c.Check(q.Get("client_id"), check.Equals, "test%client$id")
211 state := s.localdb.loginController.(*oidcLoginController).parseOAuth2State(q.Get("state"))
212 c.Check(state.verify([]byte(s.cluster.SystemRootToken)), check.Equals, true)
213 c.Check(state.Time, check.Not(check.Equals), 0)
214 c.Check(state.Remote, check.Equals, remote)
215 c.Check(state.ReturnTo, check.Equals, "https://app.example.com/foo?bar")
219 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidCode(c *check.C) {
220 state := s.startLogin(c)
221 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
222 Code: "first-try-a-bogus-code",
225 c.Check(err, check.IsNil)
226 c.Check(resp.RedirectLocation, check.Equals, "")
227 c.Check(resp.HTML.String(), check.Matches, `(?ms).*error in OAuth2 exchange.*cannot fetch token.*`)
230 func (s *OIDCLoginSuite) TestGoogleLogin_InvalidState(c *check.C) {
232 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
234 State: "bogus-state",
236 c.Check(err, check.IsNil)
237 c.Check(resp.RedirectLocation, check.Equals, "")
238 c.Check(resp.HTML.String(), check.Matches, `(?ms).*invalid OAuth2 state.*`)
241 func (s *OIDCLoginSuite) setupPeopleAPIError(c *check.C) {
242 s.fakePeopleAPI = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
243 w.WriteHeader(http.StatusForbidden)
244 fmt.Fprintln(w, `Error 403: accessNotConfigured`)
246 s.localdb.loginController.(*oidcLoginController).peopleAPIBasePath = s.fakePeopleAPI.URL
249 func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIDisabled(c *check.C) {
250 s.localdb.loginController.(*oidcLoginController).UseGooglePeopleAPI = false
251 s.authEmail = "joe.smith@primary.example.com"
252 s.setupPeopleAPIError(c)
253 state := s.startLogin(c)
254 _, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
258 c.Check(err, check.IsNil)
259 authinfo := getCallbackAuthInfo(c, s.railsSpy)
260 c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
263 func (s *OIDCLoginSuite) TestConfig(c *check.C) {
264 s.cluster.Login.Google.Enable = false
265 s.cluster.Login.OpenIDConnect.Enable = true
266 s.cluster.Login.OpenIDConnect.Issuer = arvados.URL{Scheme: "https", Host: "accounts.example.com", Path: "/"}
267 s.cluster.Login.OpenIDConnect.ClientID = "oidc-client-id"
268 s.cluster.Login.OpenIDConnect.ClientSecret = "oidc-client-secret"
269 localdb := NewConn(s.cluster)
270 ctrl := localdb.loginController.(*oidcLoginController)
271 c.Check(ctrl.Issuer, check.Equals, "https://accounts.example.com")
272 c.Check(ctrl.ClientID, check.Equals, "oidc-client-id")
273 c.Check(ctrl.ClientSecret, check.Equals, "oidc-client-secret")
274 c.Check(ctrl.UseGooglePeopleAPI, check.Equals, false)
276 for _, enableAltEmails := range []bool{false, true} {
277 s.cluster.Login.OpenIDConnect.Enable = false
278 s.cluster.Login.Google.Enable = true
279 s.cluster.Login.Google.ClientID = "google-client-id"
280 s.cluster.Login.Google.ClientSecret = "google-client-secret"
281 s.cluster.Login.Google.AlternateEmailAddresses = enableAltEmails
282 localdb = NewConn(s.cluster)
283 ctrl = localdb.loginController.(*oidcLoginController)
284 c.Check(ctrl.Issuer, check.Equals, "https://accounts.google.com")
285 c.Check(ctrl.ClientID, check.Equals, "google-client-id")
286 c.Check(ctrl.ClientSecret, check.Equals, "google-client-secret")
287 c.Check(ctrl.UseGooglePeopleAPI, check.Equals, enableAltEmails)
291 func (s *OIDCLoginSuite) TestGoogleLogin_PeopleAPIError(c *check.C) {
292 s.setupPeopleAPIError(c)
293 state := s.startLogin(c)
294 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
298 c.Check(err, check.IsNil)
299 c.Check(resp.RedirectLocation, check.Equals, "")
302 func (s *OIDCLoginSuite) TestOIDCLogin_Success(c *check.C) {
303 s.cluster.Login.Google.Enable = false
304 s.cluster.Login.OpenIDConnect.Enable = true
305 json.Unmarshal([]byte(fmt.Sprintf("%q", s.fakeIssuer.URL)), &s.cluster.Login.OpenIDConnect.Issuer)
306 s.cluster.Login.OpenIDConnect.ClientID = "oidc#client#id"
307 s.cluster.Login.OpenIDConnect.ClientSecret = "oidc#client#secret"
308 s.validClientID = "oidc#client#id"
309 s.validClientSecret = "oidc#client#secret"
310 s.localdb = NewConn(s.cluster)
311 state := s.startLogin(c)
312 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
316 c.Assert(err, check.IsNil)
317 c.Check(resp.HTML.String(), check.Equals, "")
318 target, err := url.Parse(resp.RedirectLocation)
319 c.Assert(err, check.IsNil)
320 token := target.Query().Get("api_token")
321 c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`)
324 func (s *OIDCLoginSuite) TestGoogleLogin_Success(c *check.C) {
325 state := s.startLogin(c)
326 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{
330 c.Check(err, check.IsNil)
331 c.Check(resp.HTML.String(), check.Equals, "")
332 target, err := url.Parse(resp.RedirectLocation)
333 c.Check(err, check.IsNil)
334 c.Check(target.Host, check.Equals, "app.example.com")
335 c.Check(target.Path, check.Equals, "/foo")
336 token := target.Query().Get("api_token")
337 c.Check(token, check.Matches, `v2/zzzzz-gj3su-.{15}/.{32,50}`)
339 authinfo := getCallbackAuthInfo(c, s.railsSpy)
340 c.Check(authinfo.FirstName, check.Equals, "Fake User")
341 c.Check(authinfo.LastName, check.Equals, "Name")
342 c.Check(authinfo.Email, check.Equals, "active-user@arvados.local")
343 c.Check(authinfo.AlternateEmails, check.HasLen, 0)
345 // Try using the returned Arvados token.
346 c.Logf("trying an API call with new token %q", token)
347 ctx := auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{token}})
348 cl, err := s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
349 c.Check(cl.ItemsAvailable, check.Not(check.Equals), 0)
350 c.Check(cl.Items, check.Not(check.HasLen), 0)
351 c.Check(err, check.IsNil)
353 // Might as well check that bogus tokens aren't accepted.
354 badtoken := token + "plussomeboguschars"
355 c.Logf("trying an API call with mangled token %q", badtoken)
356 ctx = auth.NewContext(context.Background(), &auth.Credentials{Tokens: []string{badtoken}})
357 cl, err = s.localdb.CollectionList(ctx, arvados.ListOptions{Limit: -1})
358 c.Check(cl.Items, check.HasLen, 0)
359 c.Check(err, check.NotNil)
360 c.Check(err, check.ErrorMatches, `.*401 Unauthorized: Not logged in.*`)
363 func (s *OIDCLoginSuite) TestGoogleLogin_RealName(c *check.C) {
364 s.authEmail = "joe.smith@primary.example.com"
365 s.fakePeopleAPIResponse = map[string]interface{}{
366 "names": []map[string]interface{}{
368 "metadata": map[string]interface{}{"primary": false},
370 "familyName": "Smith",
373 "metadata": map[string]interface{}{"primary": true},
374 "givenName": "Joseph",
375 "familyName": "Psmith",
379 state := s.startLogin(c)
380 s.localdb.Login(context.Background(), arvados.LoginOptions{
385 authinfo := getCallbackAuthInfo(c, s.railsSpy)
386 c.Check(authinfo.FirstName, check.Equals, "Joseph")
387 c.Check(authinfo.LastName, check.Equals, "Psmith")
390 func (s *OIDCLoginSuite) TestGoogleLogin_OIDCRealName(c *check.C) {
391 s.authName = "Joe P. Smith"
392 s.authEmail = "joe.smith@primary.example.com"
393 state := s.startLogin(c)
394 s.localdb.Login(context.Background(), arvados.LoginOptions{
399 authinfo := getCallbackAuthInfo(c, s.railsSpy)
400 c.Check(authinfo.FirstName, check.Equals, "Joe P.")
401 c.Check(authinfo.LastName, check.Equals, "Smith")
404 // People API returns some additional email addresses.
405 func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses(c *check.C) {
406 s.authEmail = "joe.smith@primary.example.com"
407 s.fakePeopleAPIResponse = map[string]interface{}{
408 "emailAddresses": []map[string]interface{}{
410 "metadata": map[string]interface{}{"verified": true},
411 "value": "joe.smith@work.example.com",
414 "value": "joe.smith@unverified.example.com", // unverified, so this one will be ignored
417 "metadata": map[string]interface{}{"verified": true},
418 "value": "joe.smith@home.example.com",
422 state := s.startLogin(c)
423 s.localdb.Login(context.Background(), arvados.LoginOptions{
428 authinfo := getCallbackAuthInfo(c, s.railsSpy)
429 c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
430 c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com", "joe.smith@work.example.com"})
433 // Primary address is not the one initially returned by oidc.
434 func (s *OIDCLoginSuite) TestGoogleLogin_AlternateEmailAddresses_Primary(c *check.C) {
435 s.authEmail = "joe.smith@alternate.example.com"
436 s.fakePeopleAPIResponse = map[string]interface{}{
437 "emailAddresses": []map[string]interface{}{
439 "metadata": map[string]interface{}{"verified": true, "primary": true},
440 "value": "joe.smith@primary.example.com",
443 "metadata": map[string]interface{}{"verified": true},
444 "value": "joe.smith@alternate.example.com",
447 "metadata": map[string]interface{}{"verified": true},
448 "value": "jsmith+123@preferdomainforusername.example.com",
452 state := s.startLogin(c)
453 s.localdb.Login(context.Background(), arvados.LoginOptions{
457 authinfo := getCallbackAuthInfo(c, s.railsSpy)
458 c.Check(authinfo.Email, check.Equals, "joe.smith@primary.example.com")
459 c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@alternate.example.com", "jsmith+123@preferdomainforusername.example.com"})
460 c.Check(authinfo.Username, check.Equals, "jsmith")
463 func (s *OIDCLoginSuite) TestGoogleLogin_NoPrimaryEmailAddress(c *check.C) {
464 s.authEmail = "joe.smith@unverified.example.com"
465 s.authEmailVerified = false
466 s.fakePeopleAPIResponse = map[string]interface{}{
467 "emailAddresses": []map[string]interface{}{
469 "metadata": map[string]interface{}{"verified": true},
470 "value": "joe.smith@work.example.com",
473 "metadata": map[string]interface{}{"verified": true},
474 "value": "joe.smith@home.example.com",
478 state := s.startLogin(c)
479 s.localdb.Login(context.Background(), arvados.LoginOptions{
484 authinfo := getCallbackAuthInfo(c, s.railsSpy)
485 c.Check(authinfo.Email, check.Equals, "joe.smith@work.example.com") // first verified email in People response
486 c.Check(authinfo.AlternateEmails, check.DeepEquals, []string{"joe.smith@home.example.com"})
487 c.Check(authinfo.Username, check.Equals, "")
490 func (s *OIDCLoginSuite) startLogin(c *check.C) (state string) {
491 // Initiate login, but instead of following the redirect to
492 // the provider, just grab state from the redirect URL.
493 resp, err := s.localdb.Login(context.Background(), arvados.LoginOptions{ReturnTo: "https://app.example.com/foo?bar"})
494 c.Check(err, check.IsNil)
495 target, err := url.Parse(resp.RedirectLocation)
496 c.Check(err, check.IsNil)
497 state = target.Query().Get("state")
498 c.Check(state, check.Not(check.Equals), "")
502 func (s *OIDCLoginSuite) fakeToken(c *check.C, payload []byte) string {
503 signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: s.issuerKey}, nil)
507 object, err := signer.Sign(payload)
511 t, err := object.CompactSerialize()
515 c.Logf("fakeToken(%q) == %q", payload, t)
519 func getCallbackAuthInfo(c *check.C, railsSpy *arvadostest.Proxy) (authinfo rpc.UserSessionAuthInfo) {
520 for _, dump := range railsSpy.RequestDumps {
521 c.Logf("spied request: %q", dump)
522 split := bytes.Split(dump, []byte("\r\n\r\n"))
523 c.Assert(split, check.HasLen, 2)
524 hdr, body := string(split[0]), string(split[1])
525 if strings.Contains(hdr, "POST /auth/controller/callback") {
526 vs, err := url.ParseQuery(body)
527 c.Check(json.Unmarshal([]byte(vs.Get("auth_info")), &authinfo), check.IsNil)
528 c.Check(err, check.IsNil)
529 sort.Strings(authinfo.AlternateEmails)
533 c.Error("callback not found")