go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/loginsessions/module.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 implements Login Sessions backend that is used to 16 // perform interactive logins in LUCI CLI tools. 17 package loginsessions 18 19 import ( 20 "context" 21 "crypto/subtle" 22 "encoding/json" 23 "flag" 24 "fmt" 25 "html/template" 26 "net/http" 27 "strings" 28 "time" 29 "unicode" 30 31 "google.golang.org/grpc/codes" 32 "google.golang.org/grpc/metadata" 33 "google.golang.org/grpc/status" 34 "google.golang.org/protobuf/types/known/durationpb" 35 "google.golang.org/protobuf/types/known/timestamppb" 36 37 "go.chromium.org/luci/auth/loginsessionspb" 38 "go.chromium.org/luci/common/clock" 39 "go.chromium.org/luci/common/errors" 40 "go.chromium.org/luci/common/logging" 41 42 "go.chromium.org/luci/server/cron" 43 "go.chromium.org/luci/server/gaeemulation" 44 "go.chromium.org/luci/server/loginsessions/internal" 45 "go.chromium.org/luci/server/loginsessions/internal/assets" 46 "go.chromium.org/luci/server/loginsessions/internal/statepb" 47 "go.chromium.org/luci/server/module" 48 "go.chromium.org/luci/server/router" 49 "go.chromium.org/luci/server/secrets" 50 "go.chromium.org/luci/server/templates" 51 ) 52 53 // ModuleName can be used to refer to this module when declaring dependencies. 54 var ModuleName = module.RegisterName("go.chromium.org/luci/server/loginsessions") 55 56 // ModuleOptions contain configuration of the login sessions server module. 57 type ModuleOptions struct { 58 // RootURL is the root URL of the login session server to use in links. 59 // 60 // E.g. "https://<publicly routable domain name>". 61 // 62 // Required for production mode. 63 RootURL string 64 } 65 66 // Register registers the command line flags. 67 func (o *ModuleOptions) Register(f *flag.FlagSet) { 68 f.StringVar( 69 &o.RootURL, 70 "login-sessions-root-url", 71 o.RootURL, 72 "The root URL of the login session server to use in links e.g. "+ 73 "`https://<publicly routable domain name>`. Required.", 74 ) 75 } 76 77 // NewModule returns a server module that implements login sessions backend. 78 func NewModule(opts *ModuleOptions) module.Module { 79 if opts == nil { 80 opts = &ModuleOptions{} 81 } 82 return &loginSessionsModule{opts: opts} 83 } 84 85 // NewModuleFromFlags is a variant of NewModule that initializes options through 86 // command line flags. 87 // 88 // Calling this function registers flags in flag.CommandLine. They are usually 89 // parsed in server.Main(...). 90 func NewModuleFromFlags() module.Module { 91 opts := &ModuleOptions{} 92 opts.Register(flag.CommandLine) 93 return NewModule(opts) 94 } 95 96 // loginSessionsModule implements module.Module. 97 type loginSessionsModule struct { 98 opts *ModuleOptions 99 srv *loginSessionsServer 100 tmpl *templates.Bundle // if nil render template args as JSON (for tests) 101 insecureCookie bool // if true allow non-HTTPS cookie (for tests) 102 } 103 104 // Name is part of module.Module interface. 105 func (*loginSessionsModule) Name() module.Name { 106 return ModuleName 107 } 108 109 // Dependencies is part of module.Module interface. 110 func (*loginSessionsModule) Dependencies() []module.Dependency { 111 return []module.Dependency{ 112 // For encryption of OpenIDState. 113 module.RequiredDependency(secrets.ModuleName), 114 // For prod session store based on Datastore. 115 module.RequiredDependency(gaeemulation.ModuleName), 116 // For cleaning up old sessions. 117 module.RequiredDependency(cron.ModuleName), 118 } 119 } 120 121 // Initialize is part of module.Module interface. 122 func (m *loginSessionsModule) Initialize(ctx context.Context, host module.Host, opts module.HostOptions) (context.Context, error) { 123 var store internal.SessionStore 124 var provider internal.OAuthClientProvider 125 126 if opts.Prod { 127 if m.opts.RootURL == "" { 128 return nil, errors.Reason("-login-sessions-root-url is required").Err() 129 } 130 m.opts.RootURL = strings.TrimSuffix(m.opts.RootURL, "/") 131 if !strings.HasPrefix(m.opts.RootURL, "https://") { 132 return nil, errors.Reason("-login-sessions-root-url should start with https://, got %q", m.opts.RootURL).Err() 133 } 134 store = &internal.DatastoreSessionStore{} 135 provider = internal.AuthDBClientProvider 136 } else { 137 // Fakes for local development mode. 138 m.opts.RootURL = fmt.Sprintf("http://%s", host.HTTPAddr()) 139 store = &internal.MemorySessionStore{} 140 provider = func(context.Context, string) (*internal.OAuthClient, error) { 141 return &internal.OAuthClient{ 142 ProviderName: "Google Accounts", 143 AuthorizationEndpoint: internal.GoogleAuthorizationEndpoint, 144 }, nil 145 } 146 } 147 148 // Install the RPC server called by the CLI tools. 149 m.srv = &loginSessionsServer{ 150 opts: m.opts, 151 store: store, 152 provider: provider, 153 } 154 loginsessionspb.RegisterLoginSessionsServer(host, m.srv) 155 156 // Load templates for the browser flow. 157 m.tmpl = &templates.Bundle{ 158 Loader: templates.AssetsLoader(assets.Assets()), 159 DebugMode: func(context.Context) bool { return !opts.Prod }, 160 DefaultTemplate: "base", 161 FuncMap: template.FuncMap{ 162 "includeCSS": func(name string) template.CSS { return template.CSS(assets.GetAsset(name)) }, 163 }, 164 } 165 if err := m.tmpl.EnsureLoaded(ctx); err != nil { 166 return nil, err 167 } 168 169 // Install web routes for the browser flow. 170 m.installRoutes(host.Routes()) 171 172 // Install the cron handler that cleans old sessions. 173 cron.RegisterHandler("loginsessions-cleanup", func(ctx context.Context) error { 174 return m.srv.store.Cleanup(ctx) 175 }) 176 177 return ctx, nil 178 } 179 180 // installRoutes installs web routes into the router. 181 // 182 // Extracted into a separate function to be called from tests. 183 func (m *loginSessionsModule) installRoutes(r *router.Router) { 184 r.GET("/cli/login/:SessionID", nil, m.loginSessionPage) 185 r.POST("/cli/cancel", nil, m.loginCancelPage) 186 r.GET("/cli/confirm", nil, m.loginConfirmPageGET) 187 r.POST("/cli/confirm", nil, m.loginConfirmPagePOST) 188 } 189 190 // loginCookieName is a login cookie name matching the given session ID. 191 func (m *loginSessionsModule) loginCookieName(sessionID string) string { 192 return "_LUCI_CLI_LOGIN_" + sessionID 193 } 194 195 // redirectURI is a OAuth redirect URI, it must match the client configuration. 196 func (m *loginSessionsModule) redirectURI() string { 197 return fmt.Sprintf("%s/cli/confirm", m.opts.RootURL) 198 } 199 200 // loginSessionPage renders the starting login page and sets the login cookie. 201 func (m *loginSessionsModule) loginSessionPage(ctx *router.Context) { 202 // Check the session exists and is still in PENDING state. 203 session := m.pendingSessionOrRenderErr(ctx, ctx.Params.ByName("SessionID")) 204 if session == nil { 205 return 206 } 207 208 // Load OAuth client details to show on the page. Bail if this client is no 209 // longer known (this should be rare). 210 oauthClient, err := m.srv.provider(ctx.Request.Context(), session.OauthClientId) 211 switch { 212 case err != nil: 213 m.renderInternalError(ctx, "error fetching OAuth client %s: %s", session.OauthClientId, err) 214 return 215 case oauthClient == nil: 216 m.renderBadRequestError(ctx, "OAuth client %s is no longer allowed", session.OauthClientId) 217 return 218 } 219 220 // Generate a random cookie that we'll double check in the OAuth redirect to 221 // make sure the user actually visited our page with the scary phishing 222 // warnings before launching the OAuth flow through the authorization server. 223 loginCookieValue := internal.RandomAlphaNum(20) 224 225 // Generate `state` blob that would come back to us in the redirect URL from 226 // the authorization endpoint. 227 state, err := internal.EncryptState(ctx.Request.Context(), &statepb.OpenIDState{ 228 LoginSessionId: session.Id, 229 LoginCookieValue: loginCookieValue, 230 }) 231 if err != nil { 232 m.renderInternalError(ctx, "error preparing encrypted state: %s", err) 233 return 234 } 235 236 // Set the login cookie and render the initial page. 237 http.SetCookie(ctx.Writer, &http.Cookie{ 238 Name: m.loginCookieName(session.Id), 239 Value: loginCookieValue, 240 Path: "/cli/", 241 MaxAge: int(session.Expiry.AsTime().Sub(clock.Now(ctx.Request.Context())).Seconds()), 242 Secure: !m.insecureCookie, 243 HttpOnly: true, 244 }) 245 m.renderTemplate(ctx, http.StatusOK, "pages/start.html", templates.Args{ 246 "Session": session, 247 "OAuthClient": oauthClient, 248 "OAuthState": state, 249 "OAuthRedirectParams": map[string]string{ 250 "response_type": "code", 251 "scope": strings.Join(session.OauthScopes, " "), 252 "access_type": "offline", // want a refresh token 253 "prompt": "consent", // want a NEW refresh token 254 "client_id": session.OauthClientId, 255 "redirect_uri": m.redirectURI(), 256 "nonce": session.Id, // per Login Sessions protocol 257 "code_challenge": session.OauthS256CodeChallenge, // PKCE 258 "code_challenge_method": "S256", // PKCE 259 "state": state, 260 }, 261 }) 262 } 263 264 // sessionFromState decrypts OpenIDState, checks the login cookie, and loads and 265 // checks the login session is still in PENDING state. 266 // 267 // It renders errors directly into the response. It returns the LoginSession on 268 // success or nil on errors. 269 func (m *loginSessionsModule) sessionFromState(ctx *router.Context, stateB64 string) *statepb.LoginSession { 270 // Decrypt the state to get the session ID. 271 state, err := internal.DecryptState(ctx.Request.Context(), stateB64) 272 if err != nil { 273 m.renderInternalError(ctx, "failed to decrypt state: %s", err) 274 return nil 275 } 276 277 // Verify the state passed to us matches the login cookie. This ensures that 278 // the user saw our login page (with phishing warnings) before going through 279 // the authorization server redirect flow. 280 cookie, err := ctx.Request.Cookie(m.loginCookieName(state.LoginSessionId)) 281 if err != nil || subtle.ConstantTimeCompare([]byte(state.LoginCookieValue), []byte(cookie.Value)) == 0 { 282 m.renderExpiredError(ctx, "login cookie is missing or invalid") 283 return nil 284 } 285 286 // Check if the session is still in PENDING state. 287 return m.pendingSessionOrRenderErr(ctx, state.LoginSessionId) 288 } 289 290 // pendingSessionOrRenderErr fetches a session and checks it is still pending. 291 // 292 // It renders errors directly into the response. It returns the LoginSession on 293 // success or nil on errors. 294 func (m *loginSessionsModule) pendingSessionOrRenderErr(ctx *router.Context, sessionID string) *statepb.LoginSession { 295 switch session, err := m.srv.store.Get(ctx.Request.Context(), sessionID); { 296 case err == internal.ErrNoSession: 297 m.renderExpiredError(ctx, "no such session") 298 return nil 299 case err != nil: 300 m.renderInternalError(ctx, "error fetching session: %s", err) 301 return nil 302 case session.State != loginsessionspb.LoginSession_PENDING: 303 m.renderExpiredError(ctx, "the session is not pending, it is %s", session.State) 304 return nil 305 case clock.Now(ctx.Request.Context()).After(session.Expiry.AsTime()): 306 m.renderExpiredError(ctx, "the session is expired") 307 return nil 308 default: 309 return session 310 } 311 } 312 313 // handleRedirectURL is called by loginConfirmPageGET and loginConfirmPagePOST 314 // to handle parameters passed from the authorization server via the redirect 315 // URL. 316 // 317 // It decrypts OpenIDState, checks the login cookie, loads and checks the 318 // login session is still in PENDING state, and check the OAuth error. 319 // 320 // It renders errors directly into the response. It returns the pending 321 // LoginSession and the authorization code on success or (nil, "") on errors. 322 func (m *loginSessionsModule) handleRedirectURL(ctx *router.Context) (*statepb.LoginSession, string) { 323 q := ctx.Request.URL.Query() 324 325 // We must have either `code` or `error` by now (but not both). 326 oauthCode := q.Get("code") 327 oauthError := q.Get("error") 328 switch { 329 case oauthCode == "" && oauthError == "": 330 oauthError = "unknown" 331 case oauthError != "": 332 oauthCode = "" 333 } 334 335 // If state is not available, we can at most show the error page. We don't 336 // know an associated session that should be updated. We can't use a login 337 // cookie for discovering the session since there may be multiple login 338 // cookies for different sessions (if there are concurrent flows or some 339 // cookies from previous workflows didn't expire yet). 340 oauthState := q.Get("state") 341 if oauthState == "" { 342 m.renderBadRequestError(ctx, "the authorization provider returned error code: %s", oauthError) 343 return nil, "" 344 } 345 346 // Load the session if it is still pending. 347 session := m.sessionFromState(ctx, oauthState) 348 if session == nil { 349 return nil, "" 350 } 351 352 // If the login failed, flip the session into FAILED state right away. There's 353 // no security risk in skipping checking the confirmation code in this case. 354 // In fact, it would be weird to ask for a confirmation code just to fail 355 // right after it is checked. 356 if oauthError != "" { 357 m.finalizeSession(ctx, session.Id, func(session *statepb.LoginSession) { 358 session.State = loginsessionspb.LoginSession_FAILED 359 session.OauthError = oauthError 360 }) 361 return nil, "" 362 } 363 364 return session, oauthCode 365 } 366 367 // loginCancelPage cancels the login session and renders the corresponding page. 368 func (m *loginSessionsModule) loginCancelPage(ctx *router.Context) { 369 session := m.sessionFromState(ctx, ctx.Request.PostFormValue("state")) 370 if session != nil { 371 m.finalizeSession(ctx, session.Id, func(session *statepb.LoginSession) { 372 session.State = loginsessionspb.LoginSession_CANCELED 373 }) 374 } 375 } 376 377 // loginConfirmPageGET renders the form that asks for the confirmation code. 378 // 379 // This page is the target of the redirect from the authorization server and 380 // it receives the OAuth authorization code as an URL parameter. 381 func (m *loginSessionsModule) loginConfirmPageGET(ctx *router.Context) { 382 // Verify the state and the cookie and check the session is in PENDING state. 383 if session, authorizationCode := m.handleRedirectURL(ctx); authorizationCode != "" { 384 // The session is still good and we got the authorization code. Ask the 385 // user to provide the up-to-date confirmation code before storing the 386 // authorization code in the session. 387 m.renderTemplate(ctx, http.StatusOK, "pages/confirm.html", templates.Args{ 388 "Session": session, 389 "OAuthState": ctx.Request.URL.Query().Get("state"), 390 "BadCode": false, 391 }) 392 } 393 } 394 395 // loginConfirmPagePOST handles the confirmation code entered by the user. 396 // 397 // All OAuth state received from the authorization server is still in URL 398 // parameters of this page. 399 func (m *loginSessionsModule) loginConfirmPagePOST(ctx *router.Context) { 400 // Verify the state and the cookie and check the session is in PENDING state. 401 session, authorizationCode := m.handleRedirectURL(ctx) 402 if session == nil { 403 return 404 } 405 406 // Check the provided confirmation code is a known non-expired code. 407 confirmationCode := ctx.Request.PostFormValue("confirmation_code") 408 now := clock.Now(ctx.Request.Context()) 409 good := false 410 for _, code := range session.ConfirmationCodes { 411 if code.Expiry.AsTime().After(now) && 412 subtle.ConstantTimeCompare([]byte(confirmationCode), []byte(code.Code)) == 1 { 413 good = true 414 break 415 } 416 } 417 if !good { 418 // Ask the user to enter another code. 419 m.renderTemplate(ctx, http.StatusOK, "pages/confirm.html", templates.Args{ 420 "Session": session, 421 "OAuthState": ctx.Request.URL.Query().Get("state"), 422 "BadCode": true, 423 }) 424 return 425 } 426 427 // The confirmation code is correct! Flip the session into SUCCEEDED state and 428 // store the authorization code in it. The polling native program will pick it 429 // up and finish the OAuth flow. 430 m.finalizeSession(ctx, session.Id, func(session *statepb.LoginSession) { 431 session.State = loginsessionspb.LoginSession_SUCCEEDED 432 session.OauthRedirectUrl = m.redirectURI() 433 session.OauthAuthorizationCode = authorizationCode 434 }) 435 } 436 437 // finalizeSession flips the session into a final state and renders the result. 438 func (m *loginSessionsModule) finalizeSession(ctx *router.Context, sessionID string, cb func(*statepb.LoginSession)) { 439 session, err := m.srv.store.Update(ctx.Request.Context(), sessionID, func(session *statepb.LoginSession) { 440 if session.State == loginsessionspb.LoginSession_PENDING { 441 updateExpiry(ctx.Request.Context(), session) 442 if session.State == loginsessionspb.LoginSession_PENDING { 443 cb(session) 444 if session.State == loginsessionspb.LoginSession_PENDING { 445 panic("the callback didn't change the state") 446 } 447 session.Completed = timestamppb.New(clock.Now(ctx.Request.Context())) 448 } 449 } 450 }) 451 if err != nil { 452 m.renderInternalError(ctx, "failed to update the session: %s", err) 453 return 454 } 455 switch session.State { 456 case loginsessionspb.LoginSession_SUCCEEDED: 457 m.renderTemplate(ctx, http.StatusOK, "pages/success.html", templates.Args{"Session": session}) 458 case loginsessionspb.LoginSession_CANCELED: 459 m.renderTemplate(ctx, http.StatusOK, "pages/canceled.html", templates.Args{"Session": session}) 460 case loginsessionspb.LoginSession_FAILED: 461 m.renderTemplate(ctx, http.StatusOK, "pages/error.html", templates.Args{ 462 "Error": fmt.Sprintf("The authorization provider returned error code: %s.", session.OauthError), 463 }) 464 case loginsessionspb.LoginSession_EXPIRED: 465 m.renderExpiredError(ctx, "the session is in EXPIRED state") 466 default: 467 m.renderInternalError(ctx, "unexpected session state: %s", session.State) 468 } 469 } 470 471 // renderTemplate renders an HTML template into the response. 472 // 473 // `args` will be mutated by adding `Template` key to it. 474 func (m *loginSessionsModule) renderTemplate(ctx *router.Context, status int, name string, args templates.Args) { 475 args["Template"] = name 476 if m.tmpl != nil { 477 // This code path is used when running for real. 478 ctx.Writer.Header().Add("Content-Type", "text/html; charset=utf-8") 479 ctx.Writer.WriteHeader(status) 480 m.tmpl.MustRender(ctx.Request.Context(), nil, ctx.Writer, name, args) 481 } else { 482 // This code path is used in tests. 483 ctx.Writer.Header().Add("Content-Type", "application/json; charset=utf-8") 484 ctx.Writer.WriteHeader(status) 485 blob, err := json.Marshal(args) 486 if err != nil { 487 panic(err) 488 } 489 _, err = ctx.Writer.Write(blob) 490 if err != nil { 491 panic(err) 492 } 493 } 494 } 495 496 // renderInternalError logs the error and renders generic "Internal error" page. 497 func (m *loginSessionsModule) renderInternalError(ctx *router.Context, msg string, args ...any) { 498 logging.Errorf(ctx.Request.Context(), "Internal error: "+msg, args...) 499 m.renderTemplate(ctx, http.StatusInternalServerError, "pages/error.html", templates.Args{ 500 "Error": "Internal server error.", 501 }) 502 } 503 504 // renderExpiredError logs the error and renders generic "Session expired" page. 505 func (m *loginSessionsModule) renderExpiredError(ctx *router.Context, msg string, args ...any) { 506 logging.Warningf(ctx.Request.Context(), "Expiry error: "+msg, args...) 507 m.renderTemplate(ctx, http.StatusNotFound, "pages/error.html", templates.Args{ 508 "Error": "No such login session or it has finished or expired. Please restart the login flow from scratch.", 509 }) 510 } 511 512 // renderBadRequestError logs and renders "bad argument" error page. 513 func (m *loginSessionsModule) renderBadRequestError(ctx *router.Context, msg string, args ...any) { 514 logging.Warningf(ctx.Request.Context(), "Bad request: "+msg, args...) 515 516 // Make it title case, add final '.'. 517 pretty := []rune(fmt.Sprintf(msg, args...)) 518 if len(pretty) == 0 || pretty[len(pretty)-1] != '.' { 519 pretty = append(pretty, '.') 520 } 521 pretty[0] = unicode.ToTitle(pretty[0]) 522 523 m.renderTemplate(ctx, http.StatusBadRequest, "pages/error.html", templates.Args{ 524 "Error": string(pretty), 525 }) 526 } 527 528 //////////////////////////////////////////////////////////////////////////////// 529 530 const ( 531 // Overall limit on lifetime of a session. 532 sessionExpiry = 5 * time.Minute 533 // Lifetime of a new confirmation code. 534 confirmationCodeExpiryMax = 30 * time.Second 535 // Minimal confirmation code expiry returned by the API. 536 confirmationCodeExpiryMin = 5 * time.Second 537 // If all codes are older than this, make a new code. 538 confirmationCodeExpiryRefresh = 20 * time.Second 539 ) 540 541 type loginSessionsServer struct { 542 loginsessionspb.UnimplementedLoginSessionsServer 543 544 opts *ModuleOptions 545 store internal.SessionStore 546 provider internal.OAuthClientProvider 547 } 548 549 func (srv *loginSessionsServer) CreateLoginSession(ctx context.Context, req *loginsessionspb.CreateLoginSessionRequest) (resp *loginsessionspb.LoginSession, err error) { 550 // Rejects attempts to use the API from a browser. 551 if err := checkBrowserHeaders(ctx); err != nil { 552 return nil, err 553 } 554 555 // Do some basic validation. No need to be super thorough, the login flow will 556 // fail anyway if some parameters are not recognized by the authorization 557 // server. 558 if req.OauthClientId == "" { 559 return nil, status.Error(codes.InvalidArgument, "OAuth client ID is required") 560 } 561 if len(req.OauthScopes) == 0 { 562 return nil, status.Error(codes.InvalidArgument, "OAuth scopes are required") 563 } 564 if req.OauthS256CodeChallenge == "" { 565 return nil, status.Errorf(codes.InvalidArgument, "OAuth code challenge is required") 566 } 567 568 // Check if this OAuth client is known to us. 569 switch oauthClient, err := srv.provider(ctx, req.OauthClientId); { 570 case err != nil: 571 logging.Errorf(ctx, "Internal error fetching OAuth client %s: %s", req.OauthClientId, err) 572 return nil, status.Errorf(codes.Internal, "internal error fetching OAuth client") 573 case oauthClient == nil: 574 return nil, status.Errorf(codes.PermissionDenied, "OAuth client %s is not allowed", req.OauthClientId) 575 } 576 577 now := clock.Now(ctx) 578 579 // Create the session in PENDING state. 580 session := &statepb.LoginSession{ 581 Id: internal.RandomAlphaNum(40), 582 Password: internal.RandomBlob(40), 583 State: loginsessionspb.LoginSession_PENDING, 584 Created: timestamppb.New(now), 585 Expiry: timestamppb.New(now.Add(sessionExpiry)), 586 OauthClientId: req.OauthClientId, 587 OauthScopes: req.OauthScopes, 588 OauthS256CodeChallenge: req.OauthS256CodeChallenge, 589 ExecutableName: req.ExecutableName, 590 ClientHostname: req.ClientHostname, 591 ConfirmationCodes: []*statepb.LoginSession_ConfirmationCode{ 592 { 593 Code: internal.RandomAlphaNum(40), 594 Expiry: timestamppb.New(now.Add(confirmationCodeExpiryMax)), 595 Refresh: timestamppb.New(now.Add(confirmationCodeExpiryRefresh)), 596 }, 597 }, 598 } 599 if err := srv.store.Create(ctx, session); err != nil { 600 logging.Errorf(ctx, "Internal error creating the session: %s", err) 601 return nil, status.Errorf(codes.Internal, "internal error creating the session") 602 } 603 604 // Return the session with the password. 605 return srv.sessionResponse(ctx, session, session.Password) 606 } 607 608 func (srv *loginSessionsServer) GetLoginSession(ctx context.Context, req *loginsessionspb.GetLoginSessionRequest) (resp *loginsessionspb.LoginSession, err error) { 609 // Rejects attempts to use the API from a browser. 610 if err := checkBrowserHeaders(ctx); err != nil { 611 return nil, err 612 } 613 614 if req.LoginSessionId == "" { 615 return nil, status.Error(codes.InvalidArgument, "session ID is required") 616 } 617 if len(req.LoginSessionPassword) == 0 { 618 return nil, status.Error(codes.InvalidArgument, "session password is required") 619 } 620 621 // Get the session or `nil` if missing. 622 session, err := srv.store.Get(ctx, req.LoginSessionId) 623 switch { 624 case err == internal.ErrNoSession: 625 session = nil 626 case err != nil: 627 logging.Errorf(ctx, "Internal error fetching session %s: %s", req.LoginSessionId, err) 628 return nil, status.Errorf(codes.Internal, "internal error fetching session") 629 } 630 631 // Treat invalid password exactly as a missing session. 632 badPassword := session != nil && subtle.ConstantTimeCompare(req.LoginSessionPassword, session.Password) == 0 633 if badPassword { 634 logging.Errorf(ctx, "Bad password given when fetching session %s", req.LoginSessionId) 635 } 636 if session == nil || badPassword { 637 return nil, status.Errorf(codes.NotFound, "no such session or the password is invalid") 638 } 639 640 // Perform "lazy" session updates, like moving it to EXPIRED state or updating 641 // confirmation codes. GetLoginSession RPC is the only way to "observe" 642 // a session, all time-related updates can be done lazily here, no need to do 643 // them proactively in crons (we still need a cron to delete old sessions, but 644 // that's it). 645 if needExpiry(ctx, session) || needUpdateCodes(ctx, session) { 646 session, err = srv.store.Update(ctx, session.Id, func(session *statepb.LoginSession) { 647 updateExpiry(ctx, session) 648 updateCodes(ctx, session) 649 }) 650 if err != nil { 651 logging.Errorf(ctx, "Failed to update the session: %s", err) 652 return nil, status.Errorf(codes.Internal, "internal error getting the session") 653 } 654 } 655 656 // Return the session without the password. 657 return srv.sessionResponse(ctx, session, nil) 658 } 659 660 func (srv *loginSessionsServer) sessionResponse(ctx context.Context, s *statepb.LoginSession, pwd []byte) (*loginsessionspb.LoginSession, error) { 661 out := &loginsessionspb.LoginSession{ 662 Id: s.Id, 663 Password: pwd, 664 State: s.State, 665 Created: s.Created, 666 Expiry: s.Expiry, 667 Completed: s.Completed, 668 LoginFlowUrl: fmt.Sprintf("%s/cli/login/%s", srv.opts.RootURL, s.Id), 669 OauthAuthorizationCode: s.OauthAuthorizationCode, 670 OauthRedirectUrl: s.OauthRedirectUrl, 671 OauthError: s.OauthError, 672 } 673 674 if s.State == loginsessionspb.LoginSession_PENDING { 675 // If the session is "old", poll less frequently. Likely the user is away, 676 // no need to hammer the server. This calculation is done on the server side 677 // so we can change it without redeploying all clients. 678 var pollInterval time.Duration 679 if clock.Since(ctx, s.Created.AsTime()) > 2*time.Minute { 680 pollInterval = 5 * time.Second 681 } else { 682 pollInterval = time.Second 683 } 684 out.PollInterval = durationpb.New(pollInterval) 685 686 // Report only the freshest confirmation code. There's no need for the 687 // client to ever use an older one if there's a newer available (but we 688 // still store it until it really expires). 689 var freshest *statepb.LoginSession_ConfirmationCode 690 for _, code := range s.ConfirmationCodes { 691 if freshest == nil || code.Refresh.AsTime().After(freshest.Refresh.AsTime()) { 692 freshest = code 693 } 694 } 695 if freshest == nil { 696 panic("no confirmation codes available, should not be possible") 697 } 698 699 // It is possible (but unlikely) that our process was stuck for a while 700 // after we checked the expiry and the confirmation code is already stale. 701 // Return an internal error to trigger a retry. The API promises to return 702 // a code with lifetime at least confirmationCodeExpiryMin. Note that we 703 // use durations (instead of absolute timestamps) to avoid relying on global 704 // clock synchronization. 705 expiryDuration := clock.Until(ctx, freshest.Expiry.AsTime()) 706 if expiryDuration < confirmationCodeExpiryMin { 707 logging.Errorf(ctx, "Internal error: the confirmation code expiry %s is too small", expiryDuration) 708 return nil, status.Errorf(codes.Internal, "internal error generating confirmation code") 709 } 710 out.ConfirmationCode = freshest.Code 711 out.ConfirmationCodeExpiry = durationpb.New(expiryDuration) 712 out.ConfirmationCodeRefresh = durationpb.New(clock.Until(ctx, freshest.Refresh.AsTime())) 713 } 714 715 return out, nil 716 } 717 718 // checkBrowserHeaders returns an error if there's a suspicion the pRPC request 719 // was made by a browser. 720 func checkBrowserHeaders(ctx context.Context) error { 721 md, _ := metadata.FromIncomingContext(ctx) 722 // Almost all browsers send "Sec-Fetch-Site" header, but pRPC client doesn't. 723 if len(md["sec-fetch-site"]) != 0 { 724 return status.Errorf(codes.PermissionDenied, "not allowed to be called from a browser") 725 } 726 // "Sec-Fetch-Site" is not supported at least on Safari (as of Sep 2022), 727 // check the "User-Agent" instead. pRPC native client is very unlikely to use 728 // Mozilla user agent (but most browsers, including Safari, do). 729 for _, ua := range md["user-agent"] { 730 if strings.Contains(ua, "Mozilla") { 731 return status.Errorf(codes.PermissionDenied, "not allowed to be called from a browser") 732 } 733 } 734 return nil 735 } 736 737 // needExpiry is true if the session should be flipped into EXPIRED state. 738 func needExpiry(ctx context.Context, session *statepb.LoginSession) bool { 739 return session.State == loginsessionspb.LoginSession_PENDING && 740 clock.Now(ctx).After(session.Expiry.AsTime()) 741 } 742 743 // updateExpiry flips the session into EXPIRED state if necessary. 744 func updateExpiry(ctx context.Context, session *statepb.LoginSession) { 745 if needExpiry(ctx, session) { 746 session.State = loginsessionspb.LoginSession_EXPIRED 747 session.Completed = timestamppb.New(clock.Now(ctx)) 748 } 749 } 750 751 // needUpdateCodes is true if we need to expire or generate confirmation codes. 752 func needUpdateCodes(ctx context.Context, session *statepb.LoginSession) bool { 753 if session.State != loginsessionspb.LoginSession_PENDING { 754 return false 755 } 756 757 now := clock.Now(ctx) 758 759 stale := true 760 for _, code := range session.ConfirmationCodes { 761 if now.After(code.Expiry.AsTime()) { 762 return true // this code has expired and needs to be deleted 763 } 764 if now.Before(code.Refresh.AsTime()) { 765 stale = false 766 } 767 } 768 769 // If all codes are stale need to generate a new one. 770 return stale 771 } 772 773 // updateCodes expires or generates confirmation codes. 774 func updateCodes(ctx context.Context, session *statepb.LoginSession) { 775 if session.State != loginsessionspb.LoginSession_PENDING { 776 return 777 } 778 779 now := clock.Now(ctx) 780 781 // Drop expired confirmation codes. 782 var codes []*statepb.LoginSession_ConfirmationCode 783 for _, code := range session.ConfirmationCodes { 784 if now.Before(code.Expiry.AsTime()) { 785 codes = append(codes, code) 786 } else { 787 logging.Infof(ctx, "Expiring old confirmation code") 788 } 789 } 790 791 // Add a new confirmation code if all codes are stale. 792 stale := true 793 for _, code := range codes { 794 if now.Before(code.Refresh.AsTime()) { 795 stale = false 796 break 797 } 798 } 799 if stale { 800 logging.Infof(ctx, "Generating new confirmation code") 801 codes = append(codes, &statepb.LoginSession_ConfirmationCode{ 802 Code: internal.RandomAlphaNum(40), 803 Expiry: timestamppb.New(now.Add(confirmationCodeExpiryMax)), 804 Refresh: timestamppb.New(now.Add(confirmationCodeExpiryRefresh)), 805 }) 806 } 807 808 session.ConfirmationCodes = codes 809 }