go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/encryptedcookies/module.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 encryptedcookies
    16  
    17  import (
    18  	"context"
    19  	"flag"
    20  	"fmt"
    21  	"strings"
    22  	"sync/atomic"
    23  
    24  	"github.com/google/tink/go/tink"
    25  
    26  	"go.chromium.org/luci/common/errors"
    27  	"go.chromium.org/luci/common/flag/stringlistflag"
    28  	"go.chromium.org/luci/common/logging"
    29  
    30  	"go.chromium.org/luci/server/auth"
    31  	"go.chromium.org/luci/server/auth/openid"
    32  	"go.chromium.org/luci/server/encryptedcookies/internal"
    33  	"go.chromium.org/luci/server/encryptedcookies/internal/fakecookies"
    34  	"go.chromium.org/luci/server/encryptedcookies/session"
    35  	"go.chromium.org/luci/server/module"
    36  	"go.chromium.org/luci/server/router"
    37  	"go.chromium.org/luci/server/secrets"
    38  	"go.chromium.org/luci/server/warmup"
    39  )
    40  
    41  // ModuleName can be used to refer to this module when declaring dependencies.
    42  var ModuleName = module.RegisterName("go.chromium.org/luci/server/encryptedcookies")
    43  
    44  // ModuleOptions contain configuration of the encryptedcookies server module.
    45  type ModuleOptions struct {
    46  	// TinkAEADKey is a "sm://..." reference to a Tink AEAD keyset to use.
    47  	//
    48  	// If empty, will use the primary keyset via secrets.PrimaryTinkAEAD().
    49  	TinkAEADKey string
    50  
    51  	// DiscoveryURL is an URL of the discovery document with provider's config.
    52  	DiscoveryURL string
    53  
    54  	// ClientID identifies OAuth2 Web client representing the application.
    55  	ClientID string
    56  
    57  	// ClientSecret is a "sm://..." reference to OAuth2 client secret.
    58  	ClientSecret string
    59  
    60  	// RedirectURL must be `https://<host>/auth/openid/callback`.
    61  	RedirectURL string
    62  
    63  	// SessionStoreKind can be used to pick a concrete implementation of a store.
    64  	SessionStoreKind string
    65  
    66  	// SessionStoreNamespace can be used to namespace sessions in the store.
    67  	SessionStoreNamespace string
    68  
    69  	// RequiredScopes is a list of required OAuth scopes that will be requested
    70  	// when making the OAuth authorization request, in addition to the default
    71  	// scopes (openid email profile) and the OptionalScopes.
    72  	//
    73  	// Existing sessions that don't have the required scopes will be closed. All
    74  	// scopes in the RequiredScopes must be in the RequiredScopes or
    75  	// OptionalScopes of other running instances of the app. Otherwise a session
    76  	// opened by other running instances could be closed immediately.
    77  	RequiredScopes stringlistflag.Flag
    78  
    79  	// OptionalScopes is a list of optional OAuth scopes that will be requested
    80  	// when making the OAuth authorization request, in addition to the default
    81  	// scopes (openid email profile) and the RequiredScopes.
    82  	//
    83  	// Existing sessions that don't have the optional scopes will not be closed.
    84  	// This is useful for rolling out changes incrementally. Once the new version
    85  	// takes over all the traffic, promote the optional scopes to RequiredScopes.
    86  	OptionalScopes stringlistflag.Flag
    87  
    88  	// ExposeStateEndpoint controls whether "/auth/openid/state" endpoint should
    89  	// be exposed.
    90  	//
    91  	// See auth.StateEndpointResponse struct for details.
    92  	//
    93  	// It is off by default since it can potentially make XSS vulnerabilities more
    94  	// severe by exposing OAuth and ID tokens to malicious injected code. It
    95  	// should be enabled only if the frontend code needs it and it is aware of
    96  	// XSS risks.
    97  	ExposeStateEndpoint bool
    98  
    99  	// LimitCookieExposure, if set, limits the cookie to be set only on
   100  	// "/auth/openid/" HTTP path and makes it `SameSite: strict`.
   101  	//
   102  	// This is useful for SPAs that exchange cookies for authentication tokens via
   103  	// fetch(...) requests to "/auth/openid/state". In this case the cookie is
   104  	// not normally used by any other HTTP handler and it makes no sense to send
   105  	// it in every request.
   106  	LimitCookieExposure bool
   107  }
   108  
   109  // Register registers the command line flags.
   110  func (o *ModuleOptions) Register(f *flag.FlagSet) {
   111  	f.StringVar(
   112  		&o.TinkAEADKey,
   113  		"encrypted-cookies-tink-aead-key",
   114  		o.TinkAEADKey,
   115  		`An optional reference (e.g. "sm://...") to a secret with Tink AEAD keyset `+
   116  			`to use to encrypt cookies instead of the -primary-tink-aead-key.`,
   117  	)
   118  	f.StringVar(
   119  		&o.DiscoveryURL,
   120  		"encrypted-cookies-discovery-url",
   121  		o.DiscoveryURL,
   122  		`URL of the discovery document with OpenID provider's config.`,
   123  	)
   124  	f.StringVar(
   125  		&o.ClientID,
   126  		"encrypted-cookies-client-id",
   127  		o.ClientID,
   128  		`OAuth2 web client ID representing the application.`,
   129  	)
   130  	f.StringVar(
   131  		&o.ClientSecret,
   132  		"encrypted-cookies-client-secret",
   133  		o.ClientSecret,
   134  		`Reference (e.g. "sm://...") to a secret with OAuth2 client secret.`,
   135  	)
   136  	f.StringVar(
   137  		&o.RedirectURL,
   138  		"encrypted-cookies-redirect-url",
   139  		o.RedirectURL,
   140  		fmt.Sprintf(`A redirect URL registered with the OpenID provider, must end with %q.`, callbackURL),
   141  	)
   142  	f.StringVar(
   143  		&o.SessionStoreKind,
   144  		"encrypted-cookies-session-store-kind",
   145  		o.SessionStoreKind,
   146  		`Defines what sort of a session store to use if there's more than one available.`,
   147  	)
   148  	f.StringVar(
   149  		&o.SessionStoreNamespace,
   150  		"encrypted-cookies-session-store-namespace",
   151  		o.SessionStoreNamespace,
   152  		`Namespace for the sessions in the store.`,
   153  	)
   154  	f.Var(
   155  		&o.RequiredScopes,
   156  		`encrypted-cookies-required-scopes`, `Required OAuth scopes that will be requested when `+
   157  			`making the OAuth authorization request, in addition to the default `+
   158  			`scopes (openid email profile) and the optional-scopes. Existing `+
   159  			`sessions without the required scopes will be closed.`,
   160  	)
   161  	f.Var(
   162  		&o.OptionalScopes,
   163  		`encrypted-cookies-optional-scopes`, `Optional OAuth scopes that will be requested when `+
   164  			`making the OAuth authorization request, in addition to the default `+
   165  			`scopes (openid email profile) and the required-scopes. Existing `+
   166  			`sessions without the optional scopes will NOT be closed.`,
   167  	)
   168  	f.BoolVar(&o.ExposeStateEndpoint,
   169  		"encrypted-cookies-expose-state-endpoint",
   170  		o.ExposeStateEndpoint,
   171  		`Controls whether to expose "/auth/openid/state" endpoint.`,
   172  	)
   173  	f.BoolVar(&o.LimitCookieExposure,
   174  		"encrypted-cookies-limit-cookie-exposure",
   175  		o.LimitCookieExposure,
   176  		`If set, assign the cookie only to "/auth/openid/" HTTP path and make it "SameSite: strict".`,
   177  	)
   178  }
   179  
   180  // NewModule returns a server module that configures an authentication method
   181  // based on encrypted cookies.
   182  func NewModule(opts *ModuleOptions) module.Module {
   183  	if opts == nil {
   184  		opts = &ModuleOptions{}
   185  	}
   186  	return &serverModule{opts: opts}
   187  }
   188  
   189  // NewModuleFromFlags is a variant of NewModule that initializes options through
   190  // command line flags.
   191  //
   192  // Calling this function registers flags in flag.CommandLine. They are usually
   193  // parsed in server.Main(...).
   194  func NewModuleFromFlags() module.Module {
   195  	opts := &ModuleOptions{}
   196  	opts.Register(flag.CommandLine)
   197  	return NewModule(opts)
   198  }
   199  
   200  // serverModule implements module.Module.
   201  type serverModule struct {
   202  	opts *ModuleOptions
   203  }
   204  
   205  // Name is part of module.Module interface.
   206  func (*serverModule) Name() module.Name {
   207  	return ModuleName
   208  }
   209  
   210  // Dependencies is part of module.Module interface.
   211  func (*serverModule) Dependencies() []module.Dependency {
   212  	deps := []module.Dependency{
   213  		module.RequiredDependency(secrets.ModuleName),
   214  	}
   215  	for _, impl := range internal.StoreImpls() {
   216  		deps = append(deps, impl.Deps...)
   217  	}
   218  	return deps
   219  }
   220  
   221  // Initialize is part of module.Module interface.
   222  func (m *serverModule) Initialize(ctx context.Context, host module.Host, opts module.HostOptions) (context.Context, error) {
   223  	// If in the dev mode and have no configuration, use a fake implementation.
   224  	if !opts.Prod && m.opts.ClientID == "" {
   225  		return ctx, m.initInDevMode(ctx, host)
   226  	}
   227  
   228  	// Fill in defaults.
   229  	if m.opts.DiscoveryURL == "" {
   230  		m.opts.DiscoveryURL = openid.GoogleDiscoveryURL
   231  	}
   232  
   233  	// Check required flags.
   234  	if m.opts.ClientID == "" {
   235  		return nil, errors.Reason("client ID is required").Err()
   236  	}
   237  	if m.opts.ClientSecret == "" {
   238  		return nil, errors.Reason("client secret is required").Err()
   239  	}
   240  	if m.opts.RedirectURL == "" {
   241  		return nil, errors.Reason("redirect URL is required").Err()
   242  	}
   243  	if !strings.HasSuffix(m.opts.RedirectURL, callbackURL) {
   244  		return nil, errors.Reason("redirect URL should end with %q", callbackURL).Err()
   245  	}
   246  
   247  	// Figure out what AEAD key to use.
   248  	var aead *secrets.AEADHandle
   249  	if m.opts.TinkAEADKey != "" {
   250  		var err error
   251  		if aead, err = secrets.LoadTinkAEAD(ctx, m.opts.TinkAEADKey); err != nil {
   252  			return nil, err
   253  		}
   254  	} else {
   255  		aead = secrets.PrimaryTinkAEAD(ctx)
   256  		if aead == nil {
   257  			return nil, errors.Reason("no AEAD key is configured, use either -primary-tink-aead-key or -encrypted-cookies-tink-aead-key").Err()
   258  		}
   259  	}
   260  
   261  	// Construct the session store based on a link time config and CLI flags.
   262  	sessions, err := m.initSessionStore(ctx)
   263  	if err != nil {
   264  		return nil, errors.Annotate(err, "failed to initialize the session store").Err()
   265  	}
   266  
   267  	// Load initial values of secrets to verify they are correct. This also
   268  	// subscribes to their rotations.
   269  	cfg, err := m.loadOpenIDConfig(ctx)
   270  	if err != nil {
   271  		return nil, err
   272  	}
   273  
   274  	// Have enough configuration to create the AuthMethod.
   275  	method := &AuthMethod{
   276  		OpenIDConfig:        func(context.Context) (*OpenIDConfig, error) { return cfg.Load().(*OpenIDConfig), nil },
   277  		AEADProvider:        func(context.Context) tink.AEAD { return aead.Unwrap() },
   278  		Sessions:            sessions,
   279  		Insecure:            !opts.Prod,
   280  		OptionalScopes:      m.opts.OptionalScopes,
   281  		RequiredScopes:      m.opts.RequiredScopes,
   282  		ExposeStateEndpoint: m.opts.ExposeStateEndpoint,
   283  		LimitCookieExposure: m.opts.LimitCookieExposure,
   284  	}
   285  
   286  	// Register it with the server guts.
   287  	host.RegisterCookieAuth(method)
   288  	warmup.Register("server/encryptedcookies", method.Warmup)
   289  	method.InstallHandlers(host.Routes(), nil)
   290  
   291  	return ctx, nil
   292  }
   293  
   294  // initSessionStore makes a store based on a link time configuration and flags.
   295  func (m *serverModule) initSessionStore(ctx context.Context) (session.Store, error) {
   296  	impls := internal.StoreImpls()
   297  
   298  	var ids []string
   299  	for _, impl := range impls {
   300  		ids = append(ids, impl.ID)
   301  	}
   302  	idsStr := strings.Join(ids, ", ")
   303  
   304  	var impl internal.StoreImpl
   305  	switch {
   306  	case len(impls) == 0:
   307  		return nil, errors.Reason("no session store implementations are linked into the binary, " +
   308  			"use nameless imports to link to some").Err()
   309  	case len(impls) == 1 && m.opts.SessionStoreKind == "":
   310  		impl = impls[0] // have only one and can use it by default
   311  	case len(impls) > 1 && m.opts.SessionStoreKind == "":
   312  		return nil, errors.Reason(
   313  			"multiple session store implementations are linked into the binary, "+
   314  				"pick one explicitly: %s", idsStr).Err()
   315  	default:
   316  		found := false
   317  		for _, impl = range impls {
   318  			if impl.ID == m.opts.SessionStoreKind {
   319  				found = true
   320  				break
   321  			}
   322  		}
   323  		if !found {
   324  			return nil, errors.Reason("session store implementation %q is not linked into the binary, "+
   325  				"linked implementations: %s", m.opts.SessionStoreKind, idsStr).Err()
   326  		}
   327  	}
   328  
   329  	return impl.Factory(ctx, m.opts.SessionStoreNamespace)
   330  }
   331  
   332  // loadOpenIDConfig loads the client secret and constructs OpenIDConfig with it.
   333  //
   334  // Subscribes to its rotation. Returns an atomic with the current value of
   335  // the OpenID config (as *OpenIDConfig). It will be updated when the secret is
   336  // rotated.
   337  func (m *serverModule) loadOpenIDConfig(ctx context.Context) (*atomic.Value, error) {
   338  	secret, err := secrets.StoredSecret(ctx, m.opts.ClientSecret)
   339  	if err != nil {
   340  		return nil, errors.Annotate(err, "failed to load OAuth2 client secret").Err()
   341  	}
   342  
   343  	openIDConfig := func(s *secrets.Secret) *OpenIDConfig {
   344  		return &OpenIDConfig{
   345  			DiscoveryURL: m.opts.DiscoveryURL,
   346  			ClientID:     m.opts.ClientID,
   347  			ClientSecret: string(s.Active),
   348  			RedirectURI:  m.opts.RedirectURL,
   349  		}
   350  	}
   351  
   352  	val := &atomic.Value{}
   353  	val.Store(openIDConfig(&secret))
   354  
   355  	secrets.AddRotationHandler(ctx, m.opts.ClientSecret, func(ctx context.Context, secret secrets.Secret) {
   356  		logging.Infof(ctx, "OAuth2 client secret was rotated")
   357  		val.Store(openIDConfig(&secret))
   358  	})
   359  
   360  	return val, nil
   361  }
   362  
   363  // initInDevMode initializes a primitive fake cookie-based auth method.
   364  //
   365  // Can be used on the localhost during the development as a replacement for the
   366  // real thing.
   367  func (m *serverModule) initInDevMode(ctx context.Context, host module.Host) error {
   368  	method := &fakecookies.AuthMethod{LimitCookieExposure: m.opts.LimitCookieExposure}
   369  	host.RegisterCookieAuth(method)
   370  	method.InstallHandlers(host.Routes(), nil)
   371  
   372  	// fakecookies.AuthMethod can't register the state handler itself since it
   373  	// introduces module import cycle, so do it here instead. fakecookies is
   374  	// internal API of this package.
   375  	if m.opts.ExposeStateEndpoint {
   376  		authenticator := auth.Authenticator{Methods: []auth.Method{method}}
   377  		host.Routes().GET(stateURL, []router.Middleware{authenticator.GetMiddleware()}, func(ctx *router.Context) {
   378  			stateHandlerImpl(ctx, fakecookies.IsFakeCookiesSession)
   379  		})
   380  		method.ExposedStateEndpoint = stateURL
   381  	}
   382  
   383  	return nil
   384  }