go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/loginsessions/module_test.go (about)

     1  // Copyright 2022 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 loginsessions
    16  
    17  import (
    18  	"context"
    19  	"encoding/base64"
    20  	"encoding/json"
    21  	"fmt"
    22  	"io"
    23  	"net/http"
    24  	"net/http/cookiejar"
    25  	"net/http/httptest"
    26  	"net/url"
    27  	"strings"
    28  	"testing"
    29  	"time"
    30  
    31  	"google.golang.org/grpc/metadata"
    32  	"google.golang.org/protobuf/types/known/durationpb"
    33  	"google.golang.org/protobuf/types/known/timestamppb"
    34  
    35  	"go.chromium.org/luci/auth/loginsessionspb"
    36  	"go.chromium.org/luci/common/clock/testclock"
    37  	"go.chromium.org/luci/common/logging/gologger"
    38  
    39  	"go.chromium.org/luci/server/loginsessions/internal"
    40  	"go.chromium.org/luci/server/loginsessions/internal/statepb"
    41  	"go.chromium.org/luci/server/router"
    42  	"go.chromium.org/luci/server/secrets"
    43  
    44  	. "github.com/smartystreets/goconvey/convey"
    45  	. "go.chromium.org/luci/common/testing/assertions"
    46  )
    47  
    48  const mockedAuthorizationEndpoint = "http://localhost/authorization"
    49  
    50  func TestModule(t *testing.T) {
    51  	t.Parallel()
    52  
    53  	Convey("With module", t, func() {
    54  		var now = testclock.TestRecentTimeUTC.Round(time.Millisecond)
    55  
    56  		timestampFromNow := func(d time.Duration) *timestamppb.Timestamp {
    57  			return timestamppb.New(now.Add(d))
    58  		}
    59  
    60  		ctx, tc := testclock.UseTime(context.Background(), now)
    61  		ctx = gologger.StdConfig.Use(ctx)
    62  		ctx = secrets.GeneratePrimaryTinkAEADForTest(ctx)
    63  
    64  		opts := &ModuleOptions{
    65  			RootURL: "", // set below after we get httptest server
    66  		}
    67  		mod := &loginSessionsModule{
    68  			opts: opts,
    69  			srv: &loginSessionsServer{
    70  				opts:  opts,
    71  				store: &internal.MemorySessionStore{},
    72  				provider: func(_ context.Context, id string) (*internal.OAuthClient, error) {
    73  					if id == "allowed-client-id" {
    74  						return &internal.OAuthClient{
    75  							AuthorizationEndpoint: mockedAuthorizationEndpoint,
    76  						}, nil
    77  					}
    78  					return nil, nil
    79  				},
    80  			},
    81  			insecureCookie: true,
    82  		}
    83  
    84  		handler := router.New()
    85  		handler.Use(router.MiddlewareChain{
    86  			func(rc *router.Context, next router.Handler) {
    87  				rc.Request = rc.Request.WithContext(ctx)
    88  				next(rc)
    89  			},
    90  		})
    91  		mod.installRoutes(handler)
    92  		srv := httptest.NewServer(handler)
    93  		defer srv.Close()
    94  
    95  		opts.RootURL = srv.URL
    96  
    97  		jar, err := cookiejar.New(nil)
    98  		So(err, ShouldBeNil)
    99  		web := &http.Client{Jar: jar}
   100  
   101  		// Union of all template args ever passed to templates.
   102  		type templateArgs struct {
   103  			Template            string
   104  			Session             *statepb.LoginSession
   105  			OAuthClient         *internal.OAuthClient
   106  			OAuthState          string
   107  			OAuthRedirectParams map[string]string
   108  			BadCode             bool
   109  			Error               string
   110  		}
   111  
   112  		parseWebResponse := func(resp *http.Response, err error) templateArgs {
   113  			So(err, ShouldBeNil)
   114  			defer resp.Body.Close()
   115  			body, err := io.ReadAll(resp.Body)
   116  			So(err, ShouldBeNil)
   117  			var args templateArgs
   118  			So(json.Unmarshal(body, &args), ShouldBeNil)
   119  			return args
   120  		}
   121  
   122  		webGET := func(url string) templateArgs {
   123  			return parseWebResponse(web.Get(url))
   124  		}
   125  
   126  		webPOST := func(url string, vals url.Values) templateArgs {
   127  			return parseWebResponse(web.PostForm(url, vals))
   128  		}
   129  
   130  		createSessionReq := func() *loginsessionspb.CreateLoginSessionRequest {
   131  			return &loginsessionspb.CreateLoginSessionRequest{
   132  				OauthClientId:          "allowed-client-id",
   133  				OauthScopes:            []string{"scope-0", "scope-1"},
   134  				OauthS256CodeChallenge: "code-challenge",
   135  				ExecutableName:         "executable",
   136  				ClientHostname:         "hostname",
   137  			}
   138  		}
   139  
   140  		Convey("CreateLoginSession + GetLoginSession", func() {
   141  			session, err := mod.srv.CreateLoginSession(ctx, createSessionReq())
   142  			So(err, ShouldBeNil)
   143  			So(session, ShouldResembleProto, &loginsessionspb.LoginSession{
   144  				Id:                      session.Id,
   145  				Password:                session.Password,
   146  				State:                   loginsessionspb.LoginSession_PENDING,
   147  				Created:                 timestampFromNow(0),
   148  				Expiry:                  timestampFromNow(sessionExpiry),
   149  				LoginFlowUrl:            fmt.Sprintf("%s/cli/login/%s", srv.URL, session.Id),
   150  				PollInterval:            durationpb.New(time.Second),
   151  				ConfirmationCode:        session.ConfirmationCode,
   152  				ConfirmationCodeExpiry:  durationpb.New(confirmationCodeExpiryMax),
   153  				ConfirmationCodeRefresh: durationpb.New(confirmationCodeExpiryRefresh),
   154  			})
   155  
   156  			pwd := session.Password
   157  			session.Password = nil
   158  
   159  			got, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   160  				LoginSessionId:       session.Id,
   161  				LoginSessionPassword: pwd,
   162  			})
   163  			So(err, ShouldBeNil)
   164  			So(got, ShouldResembleProto, session)
   165  
   166  			// Later the confirmation code gets stale and a new one is generated.
   167  			tc.Set(now.Add(confirmationCodeExpiryRefresh + time.Second))
   168  			got1, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   169  				LoginSessionId:       session.Id,
   170  				LoginSessionPassword: pwd,
   171  			})
   172  			So(err, ShouldBeNil)
   173  			So(got1.ConfirmationCode, ShouldNotEqual, session.ConfirmationCode)
   174  
   175  			// Have two codes in the store right now.
   176  			stored, err := mod.srv.store.Get(ctx, session.Id)
   177  			So(err, ShouldBeNil)
   178  			So(stored.ConfirmationCodes, ShouldHaveLength, 2)
   179  
   180  			// Later the expired code is kicked out of the storage.
   181  			tc.Set(now.Add(confirmationCodeExpiryMax + time.Second))
   182  			got2, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   183  				LoginSessionId:       session.Id,
   184  				LoginSessionPassword: pwd,
   185  			})
   186  			So(err, ShouldBeNil)
   187  			So(got2.ConfirmationCode, ShouldEqual, got1.ConfirmationCode)
   188  
   189  			// Have only one code now.
   190  			stored, err = mod.srv.store.Get(ctx, session.Id)
   191  			So(err, ShouldBeNil)
   192  			So(stored.ConfirmationCodes, ShouldHaveLength, 1)
   193  
   194  			// Later the session itself expires.
   195  			tc.Set(now.Add(sessionExpiry + time.Second))
   196  			exp, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   197  				LoginSessionId:       session.Id,
   198  				LoginSessionPassword: pwd,
   199  			})
   200  			So(err, ShouldBeNil)
   201  			So(exp, ShouldResembleProto, &loginsessionspb.LoginSession{
   202  				Id:           session.Id,
   203  				State:        loginsessionspb.LoginSession_EXPIRED,
   204  				Created:      timestampFromNow(0),
   205  				Expiry:       timestampFromNow(sessionExpiry),
   206  				Completed:    timestampFromNow(sessionExpiry + time.Second),
   207  				LoginFlowUrl: fmt.Sprintf("%s/cli/login/%s", srv.URL, session.Id),
   208  			})
   209  
   210  			// And this is the final stage.
   211  			tc.Set(now.Add(sessionExpiry + time.Hour))
   212  			exp2, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   213  				LoginSessionId:       session.Id,
   214  				LoginSessionPassword: pwd,
   215  			})
   216  			So(err, ShouldBeNil)
   217  			So(exp2, ShouldResembleProto, exp)
   218  		})
   219  
   220  		Convey("CreateLoginSession validation", func() {
   221  			Convey("Browser headers", func() {
   222  				ctx := metadata.NewIncomingContext(ctx, metadata.Pairs("sec-fetch-site", "none"))
   223  				_, err := mod.srv.CreateLoginSession(ctx, createSessionReq())
   224  				So(err, ShouldBeRPCPermissionDenied)
   225  			})
   226  			Convey("Missing OAuth client ID", func() {
   227  				req := createSessionReq()
   228  				req.OauthClientId = ""
   229  				_, err := mod.srv.CreateLoginSession(ctx, req)
   230  				So(err, ShouldBeRPCInvalidArgument)
   231  			})
   232  			Convey("Missing OAuth scopes", func() {
   233  				req := createSessionReq()
   234  				req.OauthScopes = nil
   235  				_, err := mod.srv.CreateLoginSession(ctx, req)
   236  				So(err, ShouldBeRPCInvalidArgument)
   237  			})
   238  			Convey("Missing OAuth challenge", func() {
   239  				req := createSessionReq()
   240  				req.OauthS256CodeChallenge = ""
   241  				_, err := mod.srv.CreateLoginSession(ctx, req)
   242  				So(err, ShouldBeRPCInvalidArgument)
   243  			})
   244  			Convey("Unknown OAuth client", func() {
   245  				req := createSessionReq()
   246  				req.OauthClientId = "unknown"
   247  				_, err := mod.srv.CreateLoginSession(ctx, req)
   248  				So(err, ShouldBeRPCPermissionDenied)
   249  			})
   250  		})
   251  
   252  		Convey("GetLoginSession validation", func() {
   253  			session, err := mod.srv.CreateLoginSession(ctx, createSessionReq())
   254  			So(err, ShouldBeNil)
   255  
   256  			Convey("Browser headers", func() {
   257  				ctx := metadata.NewIncomingContext(ctx, metadata.Pairs("sec-fetch-site", "none"))
   258  				_, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   259  					LoginSessionId:       session.Id,
   260  					LoginSessionPassword: session.Password,
   261  				})
   262  				So(err, ShouldBeRPCPermissionDenied)
   263  			})
   264  			Convey("Missing ID", func() {
   265  				_, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   266  					LoginSessionPassword: session.Password,
   267  				})
   268  				So(err, ShouldBeRPCInvalidArgument)
   269  			})
   270  			Convey("Missing password", func() {
   271  				_, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   272  					LoginSessionId: session.Id,
   273  				})
   274  				So(err, ShouldBeRPCInvalidArgument)
   275  			})
   276  			Convey("Missing session", func() {
   277  				_, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   278  					LoginSessionId:       "missing",
   279  					LoginSessionPassword: session.Password,
   280  				})
   281  				So(err, ShouldBeRPCNotFound)
   282  			})
   283  			Convey("Wrong password", func() {
   284  				_, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   285  					LoginSessionId:       session.Id,
   286  					LoginSessionPassword: []byte("wrong"),
   287  				})
   288  				So(err, ShouldBeRPCNotFound)
   289  			})
   290  		})
   291  
   292  		Convey("Full successful flow", func() {
   293  			// The CLI tool starts a new login session.
   294  			sessionReq := createSessionReq()
   295  			session, err := mod.srv.CreateLoginSession(ctx, sessionReq)
   296  			So(err, ShouldBeNil)
   297  
   298  			// The user opens the web page with the session and gets the cookie and
   299  			// the authorization endpoint redirect parameters.
   300  			tmpl := webGET(session.LoginFlowUrl)
   301  			So(tmpl.Error, ShouldBeEmpty)
   302  			So(tmpl.Template, ShouldEqual, "pages/start.html")
   303  			So(tmpl.OAuthState, ShouldNotBeEmpty)
   304  			So(tmpl.OAuthRedirectParams, ShouldResemble, map[string]string{
   305  				"access_type":           "offline",
   306  				"client_id":             sessionReq.OauthClientId,
   307  				"code_challenge":        sessionReq.OauthS256CodeChallenge,
   308  				"code_challenge_method": "S256",
   309  				"nonce":                 session.Id,
   310  				"prompt":                "consent",
   311  				"redirect_uri":          srv.URL + "/cli/confirm",
   312  				"response_type":         "code",
   313  				"scope":                 strings.Join(sessionReq.OauthScopes, " "),
   314  				"state":                 tmpl.OAuthState,
   315  			})
   316  
   317  			// The user goes through the login flow and ends up back with a code.
   318  			// This renders a page asking for the confirmation code.
   319  			confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   320  				"code":  {"authorization-code"},
   321  				"state": {tmpl.OAuthState},
   322  			}).Encode()
   323  			tmpl = webGET(confirmURL)
   324  			So(tmpl.Error, ShouldBeEmpty)
   325  			So(tmpl.Template, ShouldEqual, "pages/confirm.html")
   326  			So(tmpl.OAuthState, ShouldNotBeEmpty)
   327  
   328  			// A correct confirmation code is entered and accepted.
   329  			tmpl = webPOST(confirmURL, url.Values{
   330  				"confirmation_code": {session.ConfirmationCode},
   331  			})
   332  			So(tmpl.Error, ShouldBeEmpty)
   333  			So(tmpl.Template, ShouldEqual, "pages/success.html")
   334  
   335  			// The session is completed and the code is returned to the CLI.
   336  			session, err = mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   337  				LoginSessionId:       session.Id,
   338  				LoginSessionPassword: session.Password,
   339  			})
   340  			So(err, ShouldBeNil)
   341  			So(session, ShouldResembleProto, &loginsessionspb.LoginSession{
   342  				Id:                     session.Id,
   343  				State:                  loginsessionspb.LoginSession_SUCCEEDED,
   344  				Created:                timestampFromNow(0),
   345  				Expiry:                 timestampFromNow(sessionExpiry),
   346  				Completed:              timestampFromNow(0),
   347  				LoginFlowUrl:           fmt.Sprintf("%s/cli/login/%s", srv.URL, session.Id),
   348  				OauthAuthorizationCode: "authorization-code",
   349  				OauthRedirectUrl:       srv.URL + "/cli/confirm",
   350  			})
   351  
   352  			// Visiting the session page again shows it is gone.
   353  			tmpl = webGET(session.LoginFlowUrl)
   354  			So(tmpl.Error, ShouldContainSubstring, "No such login session")
   355  		})
   356  
   357  		Convey("Session page errors", func() {
   358  			session, err := mod.srv.CreateLoginSession(ctx, createSessionReq())
   359  			So(err, ShouldBeNil)
   360  
   361  			Convey("Wrong session ID", func() {
   362  				// Opening a non-existent session page is an error.
   363  				tmpl := webGET(session.LoginFlowUrl + "extra")
   364  				So(tmpl.Error, ShouldContainSubstring, "No such login session")
   365  			})
   366  
   367  			Convey("Expired session", func() {
   368  				// Opening an old session is an error.
   369  				tc.Add(sessionExpiry + time.Second)
   370  				tmpl := webGET(session.LoginFlowUrl)
   371  				So(tmpl.Error, ShouldContainSubstring, "No such login session")
   372  			})
   373  		})
   374  
   375  		Convey("Redirect page errors", func() {
   376  			// Create the session and get the session cookie.
   377  			session, err := mod.srv.CreateLoginSession(ctx, createSessionReq())
   378  			So(err, ShouldBeNil)
   379  			tmpl := webGET(session.LoginFlowUrl)
   380  			So(tmpl.OAuthState, ShouldNotBeEmpty)
   381  
   382  			checkSessionState := func(state loginsessionspb.LoginSession_State, msg string) {
   383  				session, err := mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   384  					LoginSessionId:       session.Id,
   385  					LoginSessionPassword: session.Password,
   386  				})
   387  				So(err, ShouldBeNil)
   388  				So(session.State, ShouldEqual, state)
   389  				So(session.OauthError, ShouldEqual, msg)
   390  			}
   391  
   392  			Convey("OK", func() {
   393  				// Just double check the test setup is correct.
   394  				confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   395  					"state": {tmpl.OAuthState},
   396  					"code":  {"authorization-code"},
   397  				}).Encode()
   398  				webPOST(confirmURL, url.Values{
   399  					"confirmation_code": {session.ConfirmationCode},
   400  				})
   401  				checkSessionState(loginsessionspb.LoginSession_SUCCEEDED, "")
   402  			})
   403  
   404  			Convey("No OAuth code or error", func() {
   405  				confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   406  					"state": {tmpl.OAuthState},
   407  				}).Encode()
   408  				tmpl = webGET(confirmURL)
   409  				So(tmpl.Error, ShouldContainSubstring, "The authorization provider returned error code: unknown")
   410  				checkSessionState(loginsessionspb.LoginSession_FAILED, "unknown")
   411  			})
   412  
   413  			Convey("OAuth error", func() {
   414  				confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   415  					"state": {tmpl.OAuthState},
   416  					"error": {"boom"},
   417  				}).Encode()
   418  				tmpl = webGET(confirmURL)
   419  				So(tmpl.Error, ShouldContainSubstring, "The authorization provider returned error code: boom")
   420  				checkSessionState(loginsessionspb.LoginSession_FAILED, "boom")
   421  			})
   422  
   423  			Convey("No state", func() {
   424  				confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   425  					"error": {"boom"},
   426  				}).Encode()
   427  				tmpl = webGET(confirmURL)
   428  				So(tmpl.Error, ShouldContainSubstring, "The authorization provider returned error code: boom")
   429  				checkSessionState(loginsessionspb.LoginSession_PENDING, "")
   430  			})
   431  
   432  			Convey("Bad state", func() {
   433  				confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   434  					"state": {tmpl.OAuthState[:20]},
   435  					"code":  {"authorization-code"},
   436  				}).Encode()
   437  				tmpl = webGET(confirmURL)
   438  				So(tmpl.Error, ShouldContainSubstring, "Internal server error")
   439  				checkSessionState(loginsessionspb.LoginSession_PENDING, "")
   440  			})
   441  
   442  			Convey("Expired session", func() {
   443  				tc.Add(sessionExpiry + time.Second)
   444  				confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   445  					"state": {tmpl.OAuthState},
   446  					"code":  {"authorization-code"},
   447  				}).Encode()
   448  				tmpl = webGET(confirmURL)
   449  				So(tmpl.Error, ShouldContainSubstring, "finished or expired")
   450  				checkSessionState(loginsessionspb.LoginSession_EXPIRED, "")
   451  			})
   452  
   453  			Convey("Missing login cookie", func() {
   454  				emptyJar, err := cookiejar.New(nil)
   455  				So(err, ShouldBeNil)
   456  				web.Jar = emptyJar
   457  				confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   458  					"state": {tmpl.OAuthState},
   459  					"code":  {"authorization-code"},
   460  				}).Encode()
   461  				tmpl = webGET(confirmURL)
   462  				So(tmpl.Error, ShouldContainSubstring, "finished or expired")
   463  				checkSessionState(loginsessionspb.LoginSession_PENDING, "")
   464  			})
   465  
   466  			Convey("Wrong login cookie", func() {
   467  				u, err := url.Parse(srv.URL + "/cli/session")
   468  				So(err, ShouldBeNil)
   469  				jar.SetCookies(u, []*http.Cookie{
   470  					{
   471  						Name:     mod.loginCookieName(session.Id),
   472  						Value:    base64.RawURLEncoding.EncodeToString([]byte("wrong")),
   473  						Path:     "/cli/",
   474  						MaxAge:   100000,
   475  						HttpOnly: true,
   476  					},
   477  				})
   478  				confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   479  					"state": {tmpl.OAuthState},
   480  					"code":  {"authorization-code"},
   481  				}).Encode()
   482  				tmpl = webGET(confirmURL)
   483  				So(tmpl.Error, ShouldContainSubstring, "finished or expired")
   484  				checkSessionState(loginsessionspb.LoginSession_PENDING, "")
   485  			})
   486  
   487  			Convey("Wrong confirmation code", func() {
   488  				confirmURL := srv.URL + "/cli/confirm?" + (url.Values{
   489  					"state": {tmpl.OAuthState},
   490  					"code":  {"authorization-code"},
   491  				}).Encode()
   492  
   493  				Convey("Empty", func() {
   494  					tmpl = webPOST(confirmURL, url.Values{
   495  						"confirmation_code": {""},
   496  					})
   497  					So(tmpl.BadCode, ShouldBeTrue)
   498  					checkSessionState(loginsessionspb.LoginSession_PENDING, "")
   499  				})
   500  
   501  				Convey("Wrong", func() {
   502  					tmpl = webPOST(confirmURL, url.Values{
   503  						"confirmation_code": {"wrong"},
   504  					})
   505  					So(tmpl.BadCode, ShouldBeTrue)
   506  					checkSessionState(loginsessionspb.LoginSession_PENDING, "")
   507  				})
   508  
   509  				Convey("Stale, but still valid", func() {
   510  					tc.Add(confirmationCodeExpiryRefresh + time.Second)
   511  					webPOST(confirmURL, url.Values{
   512  						"confirmation_code": {session.ConfirmationCode},
   513  					})
   514  					checkSessionState(loginsessionspb.LoginSession_SUCCEEDED, "")
   515  				})
   516  
   517  				Convey("Expired", func() {
   518  					tc.Add(confirmationCodeExpiryMax + time.Second)
   519  					tmpl = webPOST(confirmURL, url.Values{
   520  						"confirmation_code": {session.ConfirmationCode},
   521  					})
   522  					So(tmpl.BadCode, ShouldBeTrue)
   523  					checkSessionState(loginsessionspb.LoginSession_PENDING, "")
   524  				})
   525  			})
   526  		})
   527  
   528  		Convey("Session cancellation", func() {
   529  			// Create the session and get the session cookie.
   530  			session, err := mod.srv.CreateLoginSession(ctx, createSessionReq())
   531  			So(err, ShouldBeNil)
   532  			tmpl := webGET(session.LoginFlowUrl)
   533  			So(tmpl.OAuthState, ShouldNotBeEmpty)
   534  
   535  			// Cancel it.
   536  			tmpl = webPOST(srv.URL+"/cli/cancel", url.Values{
   537  				"state": {tmpl.OAuthState},
   538  			})
   539  			So(tmpl.Template, ShouldEqual, "pages/canceled.html")
   540  
   541  			// Verify it is indeed canceled.
   542  			session, err = mod.srv.GetLoginSession(ctx, &loginsessionspb.GetLoginSessionRequest{
   543  				LoginSessionId:       session.Id,
   544  				LoginSessionPassword: session.Password,
   545  			})
   546  			So(err, ShouldBeNil)
   547  			So(session.State, ShouldEqual, loginsessionspb.LoginSession_CANCELED)
   548  		})
   549  	})
   550  }