go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/auth/deprecated/cookie_method_test.go (about)

     1  // Copyright 2015 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package deprecated
    16  
    17  import (
    18  	"context"
    19  	"encoding/json"
    20  	"fmt"
    21  	"net/http"
    22  	"net/http/httptest"
    23  	"net/url"
    24  	"testing"
    25  	"time"
    26  
    27  	"go.chromium.org/luci/common/clock"
    28  	"go.chromium.org/luci/common/clock/testclock"
    29  
    30  	"go.chromium.org/luci/server/auth"
    31  	"go.chromium.org/luci/server/auth/authtest"
    32  	"go.chromium.org/luci/server/auth/openid"
    33  	"go.chromium.org/luci/server/auth/signing/signingtest"
    34  	"go.chromium.org/luci/server/caching"
    35  	"go.chromium.org/luci/server/router"
    36  	"go.chromium.org/luci/server/secrets"
    37  	"go.chromium.org/luci/server/secrets/testsecrets"
    38  	"go.chromium.org/luci/server/settings"
    39  
    40  	. "github.com/smartystreets/goconvey/convey"
    41  	. "go.chromium.org/luci/common/testing/assertions"
    42  )
    43  
    44  func TestFullFlow(t *testing.T) {
    45  	t.Parallel()
    46  
    47  	Convey("with test context", t, func(c C) {
    48  		ctx := context.Background()
    49  		ctx = caching.WithEmptyProcessCache(ctx)
    50  		ctx = authtest.MockAuthConfig(ctx)
    51  		ctx = settings.Use(ctx, settings.New(&settings.MemoryStorage{}))
    52  		ctx, _ = testclock.UseTime(ctx, time.Unix(1442540000, 0))
    53  		ctx = secrets.Use(ctx, &testsecrets.Store{})
    54  
    55  		// Prepare the signing keys and the ID token.
    56  		const signingKeyID = "signing-key"
    57  		const clientID = "client_id"
    58  		signer := signingtest.NewSigner(nil)
    59  		idToken := idTokenForTest(ctx, &openid.IDToken{
    60  			Iss:           "https://issuer.example.com",
    61  			EmailVerified: true,
    62  			Sub:           "user_id_sub",
    63  			Email:         "user@example.com",
    64  			Name:          "Some Dude",
    65  			Picture:       "https://picture/url/s64/photo.jpg",
    66  			Aud:           clientID,
    67  			Iat:           clock.Now(ctx).Unix(),
    68  			Exp:           clock.Now(ctx).Add(time.Hour).Unix(),
    69  		}, signingKeyID, signer)
    70  		jwks := jwksForTest(signingKeyID, &signer.KeyForTest().PublicKey)
    71  
    72  		var ts *httptest.Server
    73  		ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    74  			switch r.URL.Path {
    75  
    76  			case "/discovery":
    77  				w.Write([]byte(fmt.Sprintf(`{
    78  					"issuer": "https://issuer.example.com",
    79  					"authorization_endpoint": "%s/authorization",
    80  					"token_endpoint": "%s/token",
    81  					"jwks_uri": "%s/jwks"
    82  				}`, ts.URL, ts.URL, ts.URL)))
    83  
    84  			case "/jwks":
    85  				json.NewEncoder(w).Encode(jwks)
    86  
    87  			case "/token":
    88  				c.So(r.ParseForm(), ShouldBeNil)
    89  				c.So(r.Form, ShouldResemble, url.Values{
    90  					"redirect_uri":  {"http://fake/redirect"},
    91  					"client_id":     {"client_id"},
    92  					"client_secret": {"client_secret"},
    93  					"code":          {"omg_auth_code"},
    94  					"grant_type":    {"authorization_code"},
    95  				})
    96  				w.Write([]byte(fmt.Sprintf(`{"id_token": "%s"}`, idToken)))
    97  
    98  			default:
    99  				http.Error(w, "Not found", http.StatusNotFound)
   100  			}
   101  		}))
   102  		defer ts.Close()
   103  
   104  		cfg := Settings{
   105  			DiscoveryURL: ts.URL + "/discovery",
   106  			ClientID:     clientID,
   107  			ClientSecret: "client_secret",
   108  			RedirectURI:  "http://fake/redirect",
   109  		}
   110  		So(settings.Set(ctx, SettingsKey, &cfg), ShouldBeNil)
   111  
   112  		method := CookieAuthMethod{
   113  			SessionStore:        &MemorySessionStore{},
   114  			Insecure:            true,
   115  			IncompatibleCookies: []string{"wrong_cookie"},
   116  		}
   117  
   118  		Convey("Full flow", func() {
   119  			So(method.Warmup(ctx), ShouldBeNil)
   120  
   121  			// Generate login URL.
   122  			loginURL, err := method.LoginURL(ctx, "/destination")
   123  			So(err, ShouldBeNil)
   124  			So(loginURL, ShouldEqual, "/auth/openid/login?r=%2Fdestination")
   125  
   126  			// "Visit" login URL.
   127  			req, err := http.NewRequestWithContext(ctx, "GET", "http://fake"+loginURL, nil)
   128  			So(err, ShouldBeNil)
   129  			rec := httptest.NewRecorder()
   130  			method.loginHandler(&router.Context{
   131  				Writer:  rec,
   132  				Request: req,
   133  			})
   134  
   135  			// It asks us to visit authorizarion endpoint.
   136  			So(rec.Code, ShouldEqual, http.StatusFound)
   137  			parsed, err := url.Parse(rec.Header().Get("Location"))
   138  			So(err, ShouldBeNil)
   139  			So(parsed.Host, ShouldEqual, ts.URL[len("http://"):])
   140  			So(parsed.Path, ShouldEqual, "/authorization")
   141  			So(parsed.Query(), ShouldResemble, url.Values{
   142  				"client_id":     {"client_id"},
   143  				"redirect_uri":  {"http://fake/redirect"},
   144  				"response_type": {"code"},
   145  				"scope":         {"openid email profile"},
   146  				"prompt":        {"select_account"},
   147  				"state": {
   148  					"AXsiX2kiOiIxNDQyNTQwMDAwMDAwIiwiZGVzdF91cmwiOiIvZGVzdGluYXRpb24iLC" +
   149  						"Job3N0X3VybCI6ImZha2UifUFtzG6wPbuvHG2mY_Wf6eQ_Eiu7n3_Tf6GmRcse1g" +
   150  						"YE",
   151  				},
   152  			})
   153  
   154  			// Pretend we've done it. OpenID redirects user's browser to callback URI.
   155  			// `callbackHandler` will call /token and /jwks fake endpoints exposed
   156  			// by testserver.
   157  			callbackParams := url.Values{}
   158  			callbackParams.Set("code", "omg_auth_code")
   159  			callbackParams.Set("state", parsed.Query().Get("state"))
   160  			req, err = http.NewRequestWithContext(ctx, "GET", "http://fake/redirect?"+callbackParams.Encode(), nil)
   161  			So(err, ShouldBeNil)
   162  			rec = httptest.NewRecorder()
   163  			method.callbackHandler(&router.Context{
   164  				Writer:  rec,
   165  				Request: req,
   166  			})
   167  
   168  			// We should be redirected to the login page, with session cookie set.
   169  			expectedCookie := "oid_session=AXsiX2kiOiIxNDQyNTQwMDAwMDAwIiwic2lkIjoi" +
   170  				"dXNlcl9pZF9zdWIvMSJ9PmRzaOv-mS0PMHkve897iiELNmpiLi_j3ICG1VKuNCs"
   171  			So(rec.Code, ShouldEqual, http.StatusFound)
   172  			So(rec.Header().Get("Location"), ShouldEqual, "/destination")
   173  			So(rec.Header().Get("Set-Cookie"), ShouldEqual,
   174  				expectedCookie+"; Path=/; Expires=Sun, 18 Oct 2015 01:18:20 GMT; Max-Age=2591100; HttpOnly")
   175  
   176  			// Use the cookie to authenticate some call.
   177  			req, err = http.NewRequest("GET", "http://fake/something", nil)
   178  			So(err, ShouldBeNil)
   179  			req.Header.Add("Cookie", expectedCookie)
   180  			user, session, err := method.Authenticate(ctx, auth.RequestMetadataForHTTP(req))
   181  			So(err, ShouldBeNil)
   182  			So(user, ShouldResemble, &auth.User{
   183  				Identity: "user:user@example.com",
   184  				Email:    "user@example.com",
   185  				Name:     "Some Dude",
   186  				Picture:  "https://picture/url/s64/photo.jpg",
   187  			})
   188  			So(session, ShouldBeNil)
   189  
   190  			// Now generate URL to and visit logout page.
   191  			logoutURL, err := method.LogoutURL(ctx, "/another_destination")
   192  			So(err, ShouldBeNil)
   193  			So(logoutURL, ShouldEqual, "/auth/openid/logout?r=%2Fanother_destination")
   194  			req, err = http.NewRequestWithContext(ctx, "GET", "http://fake"+logoutURL, nil)
   195  			So(err, ShouldBeNil)
   196  			req.Header.Add("Cookie", expectedCookie)
   197  			rec = httptest.NewRecorder()
   198  			method.logoutHandler(&router.Context{
   199  				Writer:  rec,
   200  				Request: req,
   201  			})
   202  
   203  			// Should be redirected to destination with the cookie killed.
   204  			So(rec.Code, ShouldEqual, http.StatusFound)
   205  			So(rec.Header().Get("Location"), ShouldEqual, "/another_destination")
   206  			So(rec.Header().Get("Set-Cookie"), ShouldEqual,
   207  				"oid_session=deleted; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Max-Age=0")
   208  		})
   209  	})
   210  }
   211  
   212  func TestCallbackHandleEdgeCases(t *testing.T) {
   213  	Convey("with test context", t, func(c C) {
   214  		ctx := context.Background()
   215  		ctx = settings.Use(ctx, settings.New(&settings.MemoryStorage{}))
   216  		ctx, _ = testclock.UseTime(ctx, time.Unix(1442540000, 0))
   217  		ctx = secrets.Use(ctx, &testsecrets.Store{})
   218  
   219  		method := CookieAuthMethod{SessionStore: &MemorySessionStore{}}
   220  
   221  		call := func(query map[string]string) *httptest.ResponseRecorder {
   222  			q := url.Values{}
   223  			for k, v := range query {
   224  				q.Add(k, v)
   225  			}
   226  			req, err := http.NewRequestWithContext(ctx, "GET", "/auth/openid/callback?"+q.Encode(), nil)
   227  			c.So(err, ShouldBeNil)
   228  			req.Host = "fake.com"
   229  			rec := httptest.NewRecorder()
   230  			method.callbackHandler(&router.Context{
   231  				Writer:  rec,
   232  				Request: req,
   233  			})
   234  			return rec
   235  		}
   236  
   237  		Convey("handles 'error'", func() {
   238  			rec := call(map[string]string{"error": "Omg, error"})
   239  			So(rec.Code, ShouldEqual, 400)
   240  			So(rec.Body.String(), ShouldEqual, "OpenID login error: Omg, error\n")
   241  		})
   242  
   243  		Convey("handles no 'code'", func() {
   244  			rec := call(map[string]string{})
   245  			So(rec.Code, ShouldEqual, 400)
   246  			So(rec.Body.String(), ShouldEqual, "Missing 'code' parameter\n")
   247  		})
   248  
   249  		Convey("handles no 'state'", func() {
   250  			rec := call(map[string]string{"code": "123"})
   251  			So(rec.Code, ShouldEqual, 400)
   252  			So(rec.Body.String(), ShouldEqual, "Missing 'state' parameter\n")
   253  		})
   254  
   255  		Convey("handles bad 'state'", func() {
   256  			rec := call(map[string]string{"code": "123", "state": "garbage"})
   257  			So(rec.Code, ShouldEqual, 400)
   258  			So(rec.Body.String(), ShouldEqual, "Failed to validate 'state' token\n")
   259  		})
   260  
   261  		Convey("handles redirect to another host", func() {
   262  			state := map[string]string{
   263  				"dest_url": "/",
   264  				"host_url": "non-default.fake.com",
   265  			}
   266  			stateTok, err := openIDStateToken.Generate(ctx, nil, state, 0)
   267  			So(err, ShouldBeNil)
   268  
   269  			rec := call(map[string]string{"code": "123", "state": stateTok})
   270  			So(rec.Code, ShouldEqual, 302)
   271  			So(rec.Header().Get("Location"), ShouldEqual,
   272  				"https://non-default.fake.com/auth/openid/callback?"+
   273  					"code=123&state=AXsiX2kiOiIxNDQyNTQwMDAwMDAwIiwiZGVzdF91cmwiOiIvIiw"+
   274  					"iaG9zdF91cmwiOiJub24tZGVmYXVsdC5mYWtlLmNvbSJ92y0UJtCrN2qGYbcbCiZsV"+
   275  					"9OdFEa3zAauzz4lmwPJLwI")
   276  		})
   277  	})
   278  }
   279  
   280  func TestNotConfigured(t *testing.T) {
   281  	Convey("Returns ErrNotConfigured is on SessionStore", t, func() {
   282  		ctx := context.Background()
   283  		method := CookieAuthMethod{}
   284  
   285  		_, err := method.LoginURL(ctx, "/")
   286  		So(err, ShouldEqual, ErrNotConfigured)
   287  
   288  		_, err = method.LogoutURL(ctx, "/")
   289  		So(err, ShouldEqual, ErrNotConfigured)
   290  
   291  		_, _, err = method.Authenticate(ctx, authtest.NewFakeRequestMetadata())
   292  		So(err, ShouldEqual, ErrNotConfigured)
   293  	})
   294  }
   295  
   296  func TestNormalizeURL(t *testing.T) {
   297  	Convey("Normalizes good URLs", t, func(ctx C) {
   298  		cases := []struct {
   299  			in  string
   300  			out string
   301  		}{
   302  			{"/", "/"},
   303  			{"/?asd=def#blah", "/?asd=def#blah"},
   304  			{"/abc/def", "/abc/def"},
   305  			{"/blah//abc///def/", "/blah/abc/def/"},
   306  			{"/blah/..//./abc/", "/abc/"},
   307  			{"/abc/%2F/def", "/abc/def"},
   308  		}
   309  		for _, c := range cases {
   310  			out, err := normalizeURL(c.in)
   311  			if err != nil {
   312  				ctx.Printf("Failed while checking %q\n", c.in)
   313  				So(err, ShouldBeNil)
   314  			}
   315  			So(out, ShouldEqual, c.out)
   316  		}
   317  	})
   318  
   319  	Convey("Rejects bad URLs", t, func(ctx C) {
   320  		cases := []string{
   321  			"",
   322  			"//",
   323  			"///",
   324  			"://",
   325  			":",
   326  			"http://another/abc/def",
   327  			"abc/def",
   328  			"//host.example.com",
   329  		}
   330  		for _, c := range cases {
   331  			_, err := normalizeURL(c)
   332  			if err == nil {
   333  				ctx.Printf("Didn't fail while testing %q\n", c)
   334  			}
   335  			So(err, ShouldNotBeNil)
   336  		}
   337  	})
   338  }
   339  
   340  func TestBadDestinationURLs(t *testing.T) {
   341  	Convey("Rejects bad destination URLs", t, func() {
   342  		ctx := context.Background()
   343  		method := CookieAuthMethod{SessionStore: &MemorySessionStore{}}
   344  
   345  		_, err := method.LoginURL(ctx, "http://somesite")
   346  		So(err, ShouldErrLike, "openid: dest URL in LoginURL or LogoutURL must be relative")
   347  
   348  		_, err = method.LogoutURL(ctx, "http://somesite")
   349  		So(err, ShouldErrLike, "openid: dest URL in LoginURL or LogoutURL must be relative")
   350  	})
   351  }