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 }