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  }