go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/encryptedcookies/internal/fakecookies/fakecookies.go (about)

     1  // Copyright 2021 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 fakecookies implements a cookie-based fake authentication method.
    16  //
    17  // It is used during the development instead of real encrypted cookies. It is
    18  // absolutely insecure and must not be used in any real server.
    19  package fakecookies
    20  
    21  import (
    22  	"context"
    23  	"encoding/json"
    24  	"fmt"
    25  	"html/template"
    26  	"io"
    27  	"net/http"
    28  	"net/url"
    29  	"sync"
    30  
    31  	"golang.org/x/oauth2"
    32  
    33  	"go.chromium.org/luci/auth/identity"
    34  	"go.chromium.org/luci/common/errors"
    35  	"go.chromium.org/luci/common/logging"
    36  	"go.chromium.org/luci/common/retry/transient"
    37  
    38  	"go.chromium.org/luci/server/auth"
    39  	"go.chromium.org/luci/server/encryptedcookies/internal"
    40  	"go.chromium.org/luci/server/router"
    41  )
    42  
    43  // AuthMethod is an auth.Method implementation that uses fake cookies.
    44  type AuthMethod struct {
    45  	// LimitCookieExposure, if set, makes the fake cookie behave the same way as
    46  	// when this option is used with production cookies.
    47  	//
    48  	// See the module documentation.
    49  	LimitCookieExposure bool
    50  	// ExposedStateEndpoint is a URL path of the state endpoint, if any.
    51  	ExposedStateEndpoint string
    52  
    53  	m              sync.Mutex
    54  	serverUser     *auth.User // see serverUserInfo
    55  	serverUserInit bool       // true if already initialized (can still be nil)
    56  }
    57  
    58  var _ interface {
    59  	auth.Method
    60  	auth.UsersAPI
    61  	auth.HasHandlers
    62  	auth.HasStateEndpoint
    63  } = (*AuthMethod)(nil)
    64  
    65  const (
    66  	loginURL          = "/auth/openid/login"
    67  	logoutURL         = "/auth/openid/logout"
    68  	defaultPictureURL = "/auth/openid/profile.svg"
    69  
    70  	cookieName = "FAKE_LUCI_DEV_AUTH_COOKIE"
    71  )
    72  
    73  // InstallHandlers installs HTTP handlers used in the login protocol.
    74  //
    75  // Implements auth.HasHandlers.
    76  func (m *AuthMethod) InstallHandlers(r *router.Router, base router.MiddlewareChain) {
    77  	r.GET(loginURL, base, m.loginHandlerGET)
    78  	r.POST(loginURL, base, m.loginHandlerPOST)
    79  	r.GET(logoutURL, base, m.logoutHandler)
    80  	r.GET(defaultPictureURL, base, m.pictureHandler)
    81  }
    82  
    83  // Authenticate authenticates the request.
    84  //
    85  // Implements auth.Method.
    86  func (m *AuthMethod) Authenticate(ctx context.Context, r auth.RequestMetadata) (*auth.User, auth.Session, error) {
    87  	cookie, _ := r.Cookie(cookieName)
    88  	if cookie == nil {
    89  		return nil, nil, nil // the method is not applicable, skip it
    90  	}
    91  
    92  	email, err := decodeFakeCookie(cookie.Value)
    93  	if err != nil {
    94  		logging.Warningf(ctx, "Skipping %s: %s", cookieName, err)
    95  		return nil, nil, nil
    96  	}
    97  	ident, err := identity.MakeIdentity("user:" + email)
    98  	if err != nil {
    99  		logging.Warningf(ctx, "Skipping %s: %s", cookieName, err)
   100  		return nil, nil, nil
   101  	}
   102  
   103  	user := &auth.User{
   104  		Identity: ident,
   105  		Email:    email,
   106  		Name:     "Some User",
   107  		Picture:  defaultPictureURL,
   108  	}
   109  
   110  	// If the local developer logs in using their email, we can actually produce
   111  	// real auth tokens (since the server runs under this account too). We can
   112  	// also try to extract the real profile information. Not a big deal if it is
   113  	// not available. It is not essential, just adds more "realism" when it is
   114  	// present.
   115  	if email == serverEmail(ctx) {
   116  		switch serverUser, err := m.serverUserInfo(ctx); {
   117  		case err != nil:
   118  			return nil, nil, errors.Annotate(err, "transient error getting server's user info").Tag(transient.Tag).Err()
   119  		case serverUser != nil:
   120  			user = serverUser
   121  		}
   122  		return user, serverSelfSession{}, nil
   123  	}
   124  
   125  	// If the fake session user is not matching server's email, use a fake profile
   126  	// and install an erroring session that asks the caller to log in as
   127  	// the developer. We can't generate real tokens for fake users.
   128  	return user, erroringSession{
   129  		err: fmt.Errorf(
   130  			"session-bound auth tokens are available only when logging in "+
   131  				"with the account used by the local dev server itself: %s", email,
   132  		),
   133  	}, nil
   134  }
   135  
   136  // LoginURL returns a URL that, when visited, prompts the user to sign in,
   137  // then redirects the user to the URL specified by dest.
   138  //
   139  // Implements auth.UsersAPI.
   140  func (m *AuthMethod) LoginURL(ctx context.Context, dest string) (string, error) {
   141  	return internal.MakeRedirectURL(loginURL, dest)
   142  }
   143  
   144  // LogoutURL returns a URL that, when visited, signs the user out,
   145  // then redirects the user to the URL specified by dest.
   146  //
   147  // Implements auth.UsersAPI.
   148  func (m *AuthMethod) LogoutURL(ctx context.Context, dest string) (string, error) {
   149  	return internal.MakeRedirectURL(logoutURL, dest)
   150  }
   151  
   152  // StateEndpointURL returns an URL that serves the authentication state.
   153  //
   154  // Implements auth.HasStateEndpoint.
   155  func (m *AuthMethod) StateEndpointURL(ctx context.Context) (string, error) {
   156  	if m.ExposedStateEndpoint != "" {
   157  		return m.ExposedStateEndpoint, nil
   158  	}
   159  	return "", auth.ErrNoStateEndpoint
   160  }
   161  
   162  // IsFakeCookiesSession returns true if the given auth.Session was produced by
   163  // a fake cookies auth method.
   164  func IsFakeCookiesSession(s auth.Session) bool {
   165  	switch s.(type) {
   166  	case serverSelfSession, erroringSession:
   167  		return true
   168  	default:
   169  		return false
   170  	}
   171  }
   172  
   173  ////////////////////////////////////////////////////////////////////////////////
   174  
   175  var loginPageTmpl = template.Must(template.New("login").Parse(`<!DOCTYPE html>
   176  <html lang="en">
   177  <head>
   178  <title>Dev Mode Fake Login</title>
   179  <style>
   180  body {
   181  	font-family: "Roboto", sans-serif;
   182  }
   183  .container {
   184  	width: 440px;
   185  	padding-top: 50px;
   186  	margin: auto;
   187  }
   188  .form {
   189  	position: relative;
   190  	max-width: 440px;
   191  	padding: 45px;
   192  	margin: 0 auto 100px;
   193  	background: #ffffff;
   194  	text-align: center;
   195  	box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
   196  }
   197  .form input {
   198  	width: 100%;
   199  	padding: 15px;
   200  	margin: 0 0 15px;
   201  	background: #f2f2f2;
   202  	outline: 0;
   203  	border: 0;
   204  	box-sizing: border-box;
   205  	font-size: 14px;
   206  }
   207  .form button {
   208  	width: 100%;
   209  	padding: 15px;
   210  	outline: 0;
   211  	border: 0;
   212  	background: #404040;
   213  	color: #ffffff;
   214  	font-size: 14px;
   215  	cursor: pointer;
   216  }
   217  .form button:hover, .form button:active, .form button:focus {
   218  	background: #212121;
   219  }
   220  </style>
   221  </head>
   222  <body>
   223  <div class="container">
   224  	<div class="form">
   225  		<form method="POST">
   226  			<input type="text" placeholder="EMAIL" name="email" value="{{.Email}}"/>
   227  			<button>LOGIN</button>
   228  		</form>
   229  	</div>
   230  </div>
   231  </body>
   232  </html>`))
   233  
   234  const profilePictureSVG = `<svg xmlns="http://www.w3.org/2000/svg" height="96px" width="96px" viewBox="0 0 24 24" fill="#455A64">
   235  <path d="M0 0h24v24H0V0z" fill="none"/>
   236  <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm6.36 14.83c-1.43-1.74-4.9-2.33-6.36-2.33s-4.93.59-6.36 2.33C4.62 15.49 4 13.82 4 12c0-4.41 3.59-8 8-8s8 3.59 8 8c0 1.82-.62 3.49-1.64 4.83zM12 6c-1.94 0-3.5 1.56-3.5 3.5S10.06 13 12 13s3.5-1.56 3.5-3.5S13.94 6 12 6z"/>
   237  </svg>`
   238  
   239  // encodeFakeCookie prepares a cookie value that contains the given email.
   240  func encodeFakeCookie(email string) string {
   241  	return (url.Values{"email": {email}}).Encode()
   242  }
   243  
   244  // decodeFakeCookies is reverse of encodeFakeCookie.
   245  func decodeFakeCookie(val string) (email string, err error) {
   246  	v, err := url.ParseQuery(val)
   247  	if err != nil {
   248  		return "", err
   249  	}
   250  	return v.Get("email"), nil
   251  }
   252  
   253  // serverEmail returns the email the server runs as or "".
   254  //
   255  // In most cases the local dev server runs under the developer account.
   256  func serverEmail(ctx context.Context) string {
   257  	if s := auth.GetSigner(ctx); s != nil {
   258  		if info, _ := s.ServiceInfo(ctx); info != nil {
   259  			return info.ServiceAccountName
   260  		}
   261  	}
   262  	return ""
   263  }
   264  
   265  // handler adapts `cb(...)` to match router.Handler.
   266  func handler(ctx *router.Context, cb func(ctx context.Context, r *http.Request, rw http.ResponseWriter) error) {
   267  	if err := cb(ctx.Request.Context(), ctx.Request, ctx.Writer); err != nil {
   268  		http.Error(ctx.Writer, err.Error(), http.StatusInternalServerError)
   269  	}
   270  }
   271  
   272  // loginHandlerGET initiates the login flow.
   273  func (m *AuthMethod) loginHandlerGET(ctx *router.Context) {
   274  	handler(ctx, func(ctx context.Context, r *http.Request, rw http.ResponseWriter) error {
   275  		if _, err := internal.NormalizeURL(r.URL.Query().Get("r")); err != nil {
   276  			return errors.Annotate(err, "bad redirect URI").Err()
   277  		}
   278  		email := serverEmail(ctx)
   279  		if email == "" {
   280  			email = "someone@example.com"
   281  		}
   282  		return loginPageTmpl.Execute(rw, map[string]string{"Email": email})
   283  	})
   284  }
   285  
   286  // loginHandlerPOST completes the login flow.
   287  func (m *AuthMethod) loginHandlerPOST(ctx *router.Context) {
   288  	handler(ctx, func(ctx context.Context, r *http.Request, rw http.ResponseWriter) error {
   289  		dest, err := internal.NormalizeURL(r.URL.Query().Get("r"))
   290  		if err != nil {
   291  			return errors.Annotate(err, "bad redirect URI").Err()
   292  		}
   293  		email := r.FormValue("email")
   294  		if _, err := identity.MakeIdentity("user:" + email); err != nil {
   295  			return errors.Annotate(err, "bad email").Err()
   296  		}
   297  
   298  		var curPath, prevPath string
   299  		var sameSite http.SameSite
   300  		if m.LimitCookieExposure {
   301  			curPath = internal.LimitedCookiePath
   302  			prevPath = internal.UnlimitedCookiePath
   303  			sameSite = http.SameSiteStrictMode
   304  		} else {
   305  			curPath = internal.UnlimitedCookiePath
   306  			prevPath = internal.LimitedCookiePath
   307  			sameSite = 0 // use browser's default
   308  		}
   309  
   310  		http.SetCookie(rw, &http.Cookie{
   311  			Name:     cookieName,
   312  			Value:    encodeFakeCookie(email),
   313  			Path:     curPath,
   314  			SameSite: sameSite,
   315  			HttpOnly: true,
   316  			Secure:   false,
   317  			MaxAge:   60 * 60 * 24 * 14, // 2 weeks
   318  		})
   319  		internal.RemoveCookie(rw, r, cookieName, prevPath)
   320  
   321  		http.Redirect(rw, r, dest, http.StatusFound)
   322  		return nil
   323  	})
   324  }
   325  
   326  // logoutHandler closes the session.
   327  func (m *AuthMethod) logoutHandler(ctx *router.Context) {
   328  	handler(ctx, func(ctx context.Context, r *http.Request, rw http.ResponseWriter) error {
   329  		dest, err := internal.NormalizeURL(r.URL.Query().Get("r"))
   330  		if err != nil {
   331  			return errors.Annotate(err, "bad redirect URI").Err()
   332  		}
   333  		internal.RemoveCookie(rw, r, cookieName, internal.UnlimitedCookiePath)
   334  		internal.RemoveCookie(rw, r, cookieName, internal.LimitedCookiePath)
   335  		http.Redirect(rw, r, dest, http.StatusFound)
   336  		return nil
   337  	})
   338  }
   339  
   340  // pictureHandler returns hardcoded SVG user profile picture.
   341  func (m *AuthMethod) pictureHandler(ctx *router.Context) {
   342  	ctx.Writer.Header().Set("Content-Type", "image/svg+xml")
   343  	ctx.Writer.Header().Set("Cache-Control", "public, max-age=86400")
   344  	ctx.Writer.Write([]byte(profilePictureSVG))
   345  }
   346  
   347  // serverUserInfo grabs *auth.User info based on server's own credentials.
   348  //
   349  // We use Google ID provider's /userinfo endpoint and access tokens. Note that
   350  // we can't extract the profile information from the ID token since it may not
   351  // be there anymore (if the token was refreshed already).
   352  //
   353  // Returns (nil, nil) if the user info is not available for some reason (e.g.
   354  // when running the server under a service account). All errors should be
   355  // considered transient.
   356  func (m *AuthMethod) serverUserInfo(ctx context.Context) (*auth.User, error) {
   357  	m.m.Lock()
   358  	defer m.m.Unlock()
   359  	if m.serverUserInit {
   360  		return m.serverUser, nil
   361  	}
   362  
   363  	// See the comment in serverSelfSession.AccessToken regarding scopes.
   364  	tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(auth.CloudOAuthScopes...))
   365  	if err != nil {
   366  		return nil, err
   367  	}
   368  
   369  	req, _ := http.NewRequest("GET", "https://openidconnect.googleapis.com/v1/userinfo", nil)
   370  	resp, err := (&http.Client{Transport: tr}).Do(req.WithContext(ctx))
   371  	if err != nil {
   372  		return nil, err
   373  	}
   374  	defer resp.Body.Close()
   375  
   376  	body, err := io.ReadAll(resp.Body)
   377  	if err != nil {
   378  		return nil, err
   379  	}
   380  
   381  	if resp.StatusCode >= 500 {
   382  		return nil, errors.Reason("HTTP %d: %q", resp.StatusCode, body).Err()
   383  	}
   384  
   385  	if resp.StatusCode != 200 {
   386  		logging.Warningf(ctx, "When fetching server's own user info: HTTP %d, body %q", resp.StatusCode, body)
   387  		m.serverUserInit = true // we are done, no user info available
   388  		return nil, nil
   389  	}
   390  
   391  	var claims struct {
   392  		Email   string `json:"email"`
   393  		Name    string `json:"name"`
   394  		Picture string `json:"picture"`
   395  	}
   396  	if err := json.Unmarshal(body, &claims); err != nil {
   397  		return nil, errors.Annotate(err, "failed to deserialize userinfo endpoint response").Err()
   398  	}
   399  
   400  	m.serverUserInit = true
   401  	m.serverUser = &auth.User{
   402  		Identity: identity.Identity("user:" + claims.Email),
   403  		Email:    claims.Email,
   404  		Name:     claims.Name,
   405  		Picture:  claims.Picture,
   406  	}
   407  	return m.serverUser, nil
   408  }
   409  
   410  ////////////////////////////////////////////////////////////////////////////////
   411  
   412  // serverSelfSession implements auth.Session by using server's own credentials.
   413  //
   414  // This is useful only when the session user matches the account the server
   415  // is running as. This can happen only locally in the dev mode.
   416  type serverSelfSession struct{}
   417  
   418  func (serverSelfSession) AccessToken(ctx context.Context) (*oauth2.Token, error) {
   419  	// Strictly speaking we need only userinfo.email scope, but its refresh token
   420  	// might not be present locally. But a token with CloudOAuthScopes (which
   421  	// includes the userinfo.email scope) is guaranteed to be present, since
   422  	// the server checks for it when it starts.
   423  	ts, err := auth.GetTokenSource(
   424  		ctx,
   425  		auth.AsSelf,
   426  		auth.WithScopes(auth.CloudOAuthScopes...),
   427  	)
   428  	if err != nil {
   429  		return nil, err
   430  	}
   431  	return ts.Token()
   432  }
   433  
   434  func (serverSelfSession) IDToken(ctx context.Context) (*oauth2.Token, error) {
   435  	// In a real scenario ID token audience always matches the OAuth client ID
   436  	// used during the login. We use some similarly looking fake. Note that this
   437  	// fake is ignored when running locally using a token established with
   438  	// `luci-auth login` (there's no way to substitute audiences of such local
   439  	// tokens).
   440  	ts, err := auth.GetTokenSource(
   441  		ctx,
   442  		auth.AsSelf,
   443  		auth.WithIDTokenAudience("fake-client-id.apps.example.com"),
   444  	)
   445  	if err != nil {
   446  		return nil, err
   447  	}
   448  	return ts.Token()
   449  }
   450  
   451  // erroringSession returns the given error from all methods.
   452  type erroringSession struct {
   453  	err error
   454  }
   455  
   456  func (s erroringSession) AccessToken(ctx context.Context) (*oauth2.Token, error) {
   457  	return nil, s.err
   458  }
   459  
   460  func (s erroringSession) IDToken(ctx context.Context) (*oauth2.Token, error) {
   461  	return nil, s.err
   462  }