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 }