diff --git a/CHANGELOG.md b/CHANGELOG.md index 788e82c2..724eb653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Changes since v7.15.3 +- [#3487](https://github.com/oauth2-proxy/oauth2-proxy/issues/3487) Add opt-in `--oidc-lazy-discovery` (default off): when the OIDC issuer is unreachable at startup, oauth2-proxy starts anyway and performs OIDC discovery in the background (retrying with backoff), so Basic Auth via `--htpasswd-file` and other provider-independent features keep working while discovery is pending. The readiness endpoint stays healthy (keeping the pod in load-balancer rotation) while OAuth2 login returns 503 until discovery succeeds. The background backoff is configurable via `--oidc-lazy-discovery-initial-interval` (default `3s`) and `--oidc-lazy-discovery-max-interval` (default `30s`). + # V7.15.3 ## Release Highlights diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index 680741ba..4160f26a 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -544,6 +544,9 @@ character. | `insecureSkipIssuerVerification` | _bool_ | InsecureSkipIssuerVerification skips verification of ID token issuers. When false, ID Token Issuers must match the OIDC discovery URL
default set to 'false' | | `insecureSkipNonce` | _bool_ | InsecureSkipNonce skips verifying the ID Token's nonce claim that must match
the random nonce sent in the initial OAuth flow. Otherwise, the nonce is checked
after the initial OAuth redeem & subsequent token refreshes.
default set to 'true'
Warning: In a future release, this will change to 'false' by default for enhanced security. | | `skipDiscovery` | _bool_ | SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints
default set to 'false' | +| `lazyDiscovery` | _bool_ | LazyDiscovery lets oauth2-proxy start when the OIDC issuer is unreachable,
retrying discovery in the background instead of failing startup. OAuth2
login returns 503 until discovery succeeds; other features (e.g. Basic Auth)
keep working. default set to 'false' | +| `lazyDiscoveryInitialInterval` | _duration_ | LazyDiscoveryInitialInterval is the backoff before the first background
discovery retry, doubling up to LazyDiscoveryMaxInterval. default set to '3s' | +| `lazyDiscoveryMaxInterval` | _duration_ | LazyDiscoveryMaxInterval is the maximum backoff between background
discovery retries. default set to '30s' | | `jwksURL` | _string_ | JwksURL is the OpenID Connect JWKS URL
eg: https://www.googleapis.com/oauth2/v3/certs | | `publicKeyFiles` | _[]string_ | PublicKeyFiles is a list of paths pointing to public key files in PEM format to use
for verifying JWT tokens | | `emailClaim` | _string_ | EmailClaim indicates which claim contains the user email,
default set to 'email' | diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index 965953fa..a6252f13 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -98,6 +98,9 @@ Provider specific options can be found on their respective subpages. | flag: `--oidc-extra-audience`
toml: `oidc_extra_audiences` | string \| list | additional audiences which are allowed to pass verification | `"[]"` | | flag: `--oidc-groups-claim`
toml: `oidc_groups_claim` | string | which OIDC claim contains the user groups | `"groups"` | | flag: `--oidc-issuer-url`
toml: `oidc_issuer_url` | string | the OpenID Connect issuer URL, e.g. `"https://accounts.google.com"` | | +| flag: `--oidc-lazy-discovery`
toml: `oidc_lazy_discovery` | bool | start oauth2-proxy even when the OIDC issuer is unreachable, retrying discovery in the background instead of failing startup. OAuth2 login returns `503` until discovery succeeds; other features (e.g. Basic Auth) keep working | false | +| flag: `--oidc-lazy-discovery-initial-interval`
toml: `oidc_lazy_discovery_initial_interval` | duration | backoff before the first background discovery retry, doubling up to `--oidc-lazy-discovery-max-interval` | `3s` | +| flag: `--oidc-lazy-discovery-max-interval`
toml: `oidc_lazy_discovery_max_interval` | duration | maximum backoff between background discovery retries | `30s` | | flag: `--oidc-jwks-url`
toml: `oidc_jwks_url` | string | OIDC JWKS URI for token verification; required if OIDC discovery is disabled and public key files are not provided | | | flag: `--oidc-public-key-file`
toml: `oidc_public_key_files` | string | Path to public key file in PEM format to use for verifying JWT tokens (may be given multiple times). Required if OIDC discovery is disabled na JWKS URL isn't provided | | | flag: `--oidc-enabled-signing-alg`
toml: `oidc_enabled_signing_algs` | string \| list | List of allowed JWT signing algorithms. When oidc discovery is enabled, the effective set is the intersection between this list and the provider's discovered supported algorithms. | | diff --git a/main_test.go b/main_test.go index 58b8ae7e..8218437f 100644 --- a/main_test.go +++ b/main_test.go @@ -187,6 +187,9 @@ redirect_url="http://localhost:4180/oauth2/callback" InsecureAllowUnverifiedEmail: ptr.To(false), InsecureSkipIssuerVerification: ptr.To(false), SkipDiscovery: ptr.To(false), + LazyDiscovery: ptr.To(false), + LazyDiscoveryInitialInterval: options.DefaultOIDCLazyDiscoveryInitialInterval, + LazyDiscoveryMaxInterval: options.DefaultOIDCLazyDiscoveryMaxInterval, EnabledSigningAlgs: []string{}, }, MicrosoftEntraIDConfig: options.MicrosoftEntraIDOptions{ diff --git a/oauthproxy.go b/oauthproxy.go index f8dc5471..8558e2ba 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -30,6 +30,7 @@ import ( "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/proxyhttp" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/version" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/ip" @@ -89,12 +90,16 @@ type OAuthProxy struct { SignInPath string - allowedRoutes []allowedRoute - apiRoutes []apiRoute - redirectURL *url.URL // the url to receive requests at - relativeRedirectURL bool - whitelistDomains []string - provider providers.Provider + allowedRoutes []allowedRoute + apiRoutes []apiRoute + redirectURL *url.URL // the url to receive requests at + relativeRedirectURL bool + whitelistDomains []string + provider providers.Provider + // lazyProvider is non-nil only when the provider is being initialised in the + // background (see --oidc-lazy-discovery). Its background discovery loop is + // started in Start so it can be tied to the proxy's shutdown context. + lazyProvider *providers.LazyProvider sessionStore sessionsapi.SessionStore ProxyPrefix string basicAuthValidator basic.Validator @@ -137,10 +142,13 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr } } - provider, err := providers.NewProvider(opts.Providers[0]) + provider, err := setupProvider(opts.Providers[0]) if err != nil { return nil, fmt.Errorf("error initialising provider: %v", err) } + // A LazyProvider needs its background discovery loop started; it is launched + // in Start so it can be cancelled on shutdown (see p.lazyProvider usage). + lazyProvider, _ := provider.(*providers.LazyProvider) pageWriter, err := pagewriter.NewWriter(pagewriter.Opts{ TemplatesPath: opts.Templates.Path, @@ -229,6 +237,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr ProxyPrefix: opts.ProxyPrefix, provider: provider, + lazyProvider: lazyProvider, sessionStore: sessionStore, redirectURL: redirectURL, relativeRedirectURL: opts.RelativeRedirectURL, @@ -280,6 +289,12 @@ func (p *OAuthProxy) Start() error { cancel() // cancel the context }() + // When lazy OIDC discovery is enabled, perform discovery in the background + // and tie its lifetime to the proxy's shutdown context so it stops cleanly. + if p.lazyProvider != nil { + go p.lazyProvider.InitWithRetry(ctx) + } + return p.server.Start(ctx) } @@ -478,6 +493,60 @@ func buildProviderName(p providers.Provider, override string) string { return p.Data().ProviderName } +// setupProvider constructs the identity provider. When OIDC discovery fails and +// lazy discovery is enabled for an OIDC-based provider, it returns a +// providers.LazyProvider that serves OIDC flows as "not ready" while background +// discovery is performed (started by Start) - so Basic Auth and other +// provider-independent features keep working. Otherwise a discovery failure is +// returned to the caller and aborts startup, preserving the historical +// behaviour. +func setupProvider(providerConfig options.Provider) (providers.Provider, error) { + provider, err := providers.NewProvider(providerConfig) + if err == nil { + return provider, nil + } + + lazyEnabled := ptr.Deref(providerConfig.OIDCConfig.LazyDiscovery, options.DefaultOIDCLazyDiscovery) + skipDiscovery := ptr.Deref(providerConfig.OIDCConfig.SkipDiscovery, options.DefaultSkipDiscovery) + needsVerifier, verifierErr := providers.ProviderRequiresOIDCProviderVerifier(providerConfig.Type) + if verifierErr != nil { + return nil, verifierErr + } + + // Only defer to lazy initialisation when OIDC discovery is the failing step: + // the user must have opted in, discovery must be enabled, and the provider + // must actually use OIDC discovery. + if !lazyEnabled || skipDiscovery || !needsVerifier { + return nil, err + } + + lazy, lazyErr := providers.NewLazyProvider(providerConfig) + if lazyErr != nil { + // If even the discovery-independent placeholder cannot be built, the + // failure is a genuine configuration error rather than an unreachable + // issuer, so surface the original error and abort startup. + return nil, err + } + + logger.Errorf("OIDC discovery failed at startup; continuing with lazy discovery: %v", err) + logger.Printf("WARNING: with --oidc-lazy-discovery, discovery is retried indefinitely in the background. " + + "If the failure above is a configuration error (e.g. issuer mismatch or no common signing algorithms) " + + "rather than an unreachable issuer, the provider will never become ready - watch the readiness endpoint and logs.") + return lazy, nil +} + +// providerReady reports whether the identity provider can serve OAuth2 flows. +// A LazyProvider is only ready once background OIDC discovery has completed; all +// other providers are always ready. Note this does not gate the /ready endpoint +// (which stays healthy under lazy discovery so the pod remains in load-balancer +// rotation); it only gates the OAuth2 login flow. +func (p *OAuthProxy) providerReady() bool { + if p.lazyProvider != nil { + return p.lazyProvider.Ready() + } + return true +} + // buildRoutesAllowlist builds an []allowedRoute list from either the legacy // SkipAuthRegex option (paths only support) or newer SkipAuthRoutes option // (method=path support) @@ -823,6 +892,15 @@ func (p *OAuthProxy) OAuthStart(rw http.ResponseWriter, req *http.Request) { } func (p *OAuthProxy) doOAuthStart(rw http.ResponseWriter, req *http.Request, overrides url.Values) { + // With lazy OIDC discovery the provider may not be ready yet. Returning an + // error here avoids a silent self-redirect loop (GetLoginURL would be empty) + // and keeps provider-independent auth (e.g. Basic Auth) usable meanwhile. + if !p.providerReady() { + logger.Errorf("cannot start OAuth2 login flow: identity provider is not ready (OIDC discovery is still pending)") + p.ErrorPage(rw, req, http.StatusServiceUnavailable, "The identity provider is not ready yet. Please try again shortly.") + return + } + extraParams := p.provider.Data().LoginURLParams(overrides) prepareNoCache(rw) diff --git a/oauthproxy_lazy_test.go b/oauthproxy_lazy_test.go new file mode 100644 index 00000000..c83cb9ac --- /dev/null +++ b/oauthproxy_lazy_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/validation" + "github.com/oauth2-proxy/oauth2-proxy/v7/providers" + . "github.com/onsi/gomega" +) + +// newLazyPendingProxy builds a proxy whose OIDC issuer is unreachable with lazy +// discovery enabled. Start is not called, so background discovery never runs and +// the provider stays not-ready for the duration of the test. +func newLazyPendingProxy(g *WithT) *OAuthProxy { + opts := baseTestOptions() + opts.Providers[0].Type = "oidc" + opts.Providers[0].OIDCConfig = options.OIDCOptions{ + IssuerURL: "http://127.0.0.1:1/realms/test", + SkipDiscovery: ptr.To(false), + LazyDiscovery: ptr.To(true), + EmailClaim: options.OIDCEmailClaim, + AudienceClaims: []string{"aud"}, + } + g.Expect(validation.Validate(opts)).To(Succeed()) + + proxy, err := NewOAuthProxy(opts, func(string) bool { return true }) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(proxy.providerReady()).To(BeFalse()) + return proxy +} + +func unreachableOIDCProvider(lazy bool) options.Provider { + return options.Provider{ + ID: "test-provider", + Type: "oidc", + ClientID: "client-id", + OIDCConfig: options.OIDCOptions{ + IssuerURL: "http://127.0.0.1:1/realms/test", + SkipDiscovery: ptr.To(false), + LazyDiscovery: ptr.To(lazy), + }, + } +} + +func TestSetupProviderLazyFallback(t *testing.T) { + g := NewWithT(t) + + // setupProvider does not start the background loop (Start does), so there is + // no goroutine to cancel here. + provider, err := setupProvider(unreachableOIDCProvider(true)) + g.Expect(err).ToNot(HaveOccurred()) + + lazy, ok := provider.(*providers.LazyProvider) + g.Expect(ok).To(BeTrue(), "expected a LazyProvider when discovery fails and lazy discovery is enabled") + g.Expect(lazy.Ready()).To(BeFalse()) +} + +func TestSetupProviderWithoutLazyFailsFast(t *testing.T) { + g := NewWithT(t) + + _, err := setupProvider(unreachableOIDCProvider(false)) + g.Expect(err).To(HaveOccurred()) +} + +// TestReadyEndpointOKWhenProviderNotReady ensures /ready stays healthy under +// lazy discovery so the pod remains in load-balancer rotation while discovery +// is still pending. +func TestReadyEndpointOKWhenProviderNotReady(t *testing.T) { + g := NewWithT(t) + + proxy := newLazyPendingProxy(g) + + rw := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/ready", nil) + proxy.ServeHTTP(rw, req) + + g.Expect(rw.Code).To(Equal(http.StatusOK)) +} + +// TestOAuthStartUnavailableWhenProviderNotReady ensures the OAuth2 login flow +// returns 503 (rather than a silent self-redirect loop) while lazy discovery is +// still pending. +func TestOAuthStartUnavailableWhenProviderNotReady(t *testing.T) { + g := NewWithT(t) + + proxy := newLazyPendingProxy(g) + + rw := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/oauth2/start", nil) + proxy.ServeHTTP(rw, req) + + g.Expect(rw.Code).To(Equal(http.StatusServiceUnavailable)) +} diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index e53fd480..452a3084 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -531,36 +531,39 @@ type LegacyProvider struct { // These options allow for other providers besides Google, with // potential overrides. - ProviderType string `flag:"provider" cfg:"provider"` - ProviderName string `flag:"provider-display-name" cfg:"provider_display_name"` - ProviderCAFiles []string `flag:"provider-ca-file" cfg:"provider_ca_files"` - UseSystemTrustStore bool `flag:"use-system-trust-store" cfg:"use_system_trust_store"` - OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url"` - InsecureOIDCAllowUnverifiedEmail bool `flag:"insecure-oidc-allow-unverified-email" cfg:"insecure_oidc_allow_unverified_email"` - InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"` - InsecureOIDCSkipNonce bool `flag:"insecure-oidc-skip-nonce" cfg:"insecure_oidc_skip_nonce"` - SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"` - OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"` - OIDCEmailClaim string `flag:"oidc-email-claim" cfg:"oidc_email_claim"` - OIDCGroupsClaim string `flag:"oidc-groups-claim" cfg:"oidc_groups_claim"` - OIDCAudienceClaims []string `flag:"oidc-audience-claim" cfg:"oidc_audience_claims"` - OIDCExtraAudiences []string `flag:"oidc-extra-audience" cfg:"oidc_extra_audiences"` - OIDCPublicKeyFiles []string `flag:"oidc-public-key-file" cfg:"oidc_public_key_files"` - OIDCEnabledSigningAlgs []string `flag:"oidc-enabled-signing-alg" cfg:"oidc_enabled_signing_algs"` - LoginURL string `flag:"login-url" cfg:"login_url"` - AuthRequestResponseMode string `flag:"auth-request-response-mode" cfg:"auth_request_response_mode"` - RedeemURL string `flag:"redeem-url" cfg:"redeem_url"` - ProfileURL string `flag:"profile-url" cfg:"profile_url"` - SkipClaimsFromProfileURL bool `flag:"skip-claims-from-profile-url" cfg:"skip_claims_from_profile_url"` - ProtectedResource string `flag:"resource" cfg:"resource"` - ValidateURL string `flag:"validate-url" cfg:"validate_url"` - Scope string `flag:"scope" cfg:"scope"` - Prompt string `flag:"prompt" cfg:"prompt"` - ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0 - UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"` - AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"` - AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"` - BackendLogoutURL string `flag:"backend-logout-url" cfg:"backend_logout_url"` + ProviderType string `flag:"provider" cfg:"provider"` + ProviderName string `flag:"provider-display-name" cfg:"provider_display_name"` + ProviderCAFiles []string `flag:"provider-ca-file" cfg:"provider_ca_files"` + UseSystemTrustStore bool `flag:"use-system-trust-store" cfg:"use_system_trust_store"` + OIDCIssuerURL string `flag:"oidc-issuer-url" cfg:"oidc_issuer_url"` + InsecureOIDCAllowUnverifiedEmail bool `flag:"insecure-oidc-allow-unverified-email" cfg:"insecure_oidc_allow_unverified_email"` + InsecureOIDCSkipIssuerVerification bool `flag:"insecure-oidc-skip-issuer-verification" cfg:"insecure_oidc_skip_issuer_verification"` + InsecureOIDCSkipNonce bool `flag:"insecure-oidc-skip-nonce" cfg:"insecure_oidc_skip_nonce"` + SkipOIDCDiscovery bool `flag:"skip-oidc-discovery" cfg:"skip_oidc_discovery"` + OIDCLazyDiscovery bool `flag:"oidc-lazy-discovery" cfg:"oidc_lazy_discovery"` + OIDCLazyDiscoveryInitialInterval time.Duration `flag:"oidc-lazy-discovery-initial-interval" cfg:"oidc_lazy_discovery_initial_interval"` + OIDCLazyDiscoveryMaxInterval time.Duration `flag:"oidc-lazy-discovery-max-interval" cfg:"oidc_lazy_discovery_max_interval"` + OIDCJwksURL string `flag:"oidc-jwks-url" cfg:"oidc_jwks_url"` + OIDCEmailClaim string `flag:"oidc-email-claim" cfg:"oidc_email_claim"` + OIDCGroupsClaim string `flag:"oidc-groups-claim" cfg:"oidc_groups_claim"` + OIDCAudienceClaims []string `flag:"oidc-audience-claim" cfg:"oidc_audience_claims"` + OIDCExtraAudiences []string `flag:"oidc-extra-audience" cfg:"oidc_extra_audiences"` + OIDCPublicKeyFiles []string `flag:"oidc-public-key-file" cfg:"oidc_public_key_files"` + OIDCEnabledSigningAlgs []string `flag:"oidc-enabled-signing-alg" cfg:"oidc_enabled_signing_algs"` + LoginURL string `flag:"login-url" cfg:"login_url"` + AuthRequestResponseMode string `flag:"auth-request-response-mode" cfg:"auth_request_response_mode"` + RedeemURL string `flag:"redeem-url" cfg:"redeem_url"` + ProfileURL string `flag:"profile-url" cfg:"profile_url"` + SkipClaimsFromProfileURL bool `flag:"skip-claims-from-profile-url" cfg:"skip_claims_from_profile_url"` + ProtectedResource string `flag:"resource" cfg:"resource"` + ValidateURL string `flag:"validate-url" cfg:"validate_url"` + Scope string `flag:"scope" cfg:"scope"` + Prompt string `flag:"prompt" cfg:"prompt"` + ApprovalPrompt string `flag:"approval-prompt" cfg:"approval_prompt"` // Deprecated by OIDC 1.0 + UserIDClaim string `flag:"user-id-claim" cfg:"user_id_claim"` + AllowedGroups []string `flag:"allowed-group" cfg:"allowed_groups"` + AllowedRoles []string `flag:"allowed-role" cfg:"allowed_roles"` + BackendLogoutURL string `flag:"backend-logout-url" cfg:"backend_logout_url"` AcrValues string `flag:"acr-values" cfg:"acr_values"` JWTKey string `flag:"jwt-key" cfg:"jwt_key"` @@ -602,6 +605,9 @@ func legacyProviderFlagSet() *pflag.FlagSet { flagSet.Bool("insecure-oidc-skip-issuer-verification", false, "Do not verify if issuer matches OIDC discovery URL") flagSet.Bool("insecure-oidc-skip-nonce", true, "skip verifying the OIDC ID Token's nonce claim") flagSet.Bool("skip-oidc-discovery", false, "Skip OIDC discovery and use manually supplied Endpoints") + flagSet.Bool("oidc-lazy-discovery", false, "Start oauth2-proxy even if the OIDC issuer is unreachable and perform discovery in the background, retrying with backoff. Features that do not depend on the provider (e.g. Basic Auth via htpasswd-file) remain available while discovery is pending") + flagSet.Duration("oidc-lazy-discovery-initial-interval", DefaultOIDCLazyDiscoveryInitialInterval, "Initial backoff before the first background OIDC discovery retry when oidc-lazy-discovery is enabled; doubles after each attempt up to oidc-lazy-discovery-max-interval") + flagSet.Duration("oidc-lazy-discovery-max-interval", DefaultOIDCLazyDiscoveryMaxInterval, "Maximum backoff between background OIDC discovery retries when oidc-lazy-discovery is enabled") flagSet.String("oidc-jwks-url", "", "OpenID Connect JWKS URL (ie: https://www.googleapis.com/oauth2/v3/certs)") flagSet.String("oidc-groups-claim", OIDCGroupsClaim, "which OIDC claim contains the user groups") flagSet.String("oidc-email-claim", OIDCEmailClaim, "which OIDC claim contains the user's email") @@ -723,6 +729,9 @@ func (l *LegacyProvider) convert() (Providers, error) { InsecureSkipIssuerVerification: &l.InsecureOIDCSkipIssuerVerification, InsecureSkipNonce: &l.InsecureOIDCSkipNonce, SkipDiscovery: &l.SkipOIDCDiscovery, + LazyDiscovery: &l.OIDCLazyDiscovery, + LazyDiscoveryInitialInterval: l.OIDCLazyDiscoveryInitialInterval, + LazyDiscoveryMaxInterval: l.OIDCLazyDiscoveryMaxInterval, JwksURL: l.OIDCJwksURL, UserIDClaim: l.UserIDClaim, EmailClaim: l.OIDCEmailClaim, diff --git a/pkg/apis/options/legacy_options_test.go b/pkg/apis/options/legacy_options_test.go index f6cbfb7c..29457776 100644 --- a/pkg/apis/options/legacy_options_test.go +++ b/pkg/apis/options/legacy_options_test.go @@ -951,6 +951,7 @@ var _ = Describe("Legacy Options", func() { defaultOIDCOptions := OIDCOptions{ SkipDiscovery: ptr.To(false), + LazyDiscovery: ptr.To(false), InsecureSkipNonce: ptr.To(false), InsecureAllowUnverifiedEmail: ptr.To(false), InsecureSkipIssuerVerification: ptr.To(false), diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go index 40f9a725..4d581e2b 100644 --- a/pkg/apis/options/load_test.go +++ b/pkg/apis/options/load_test.go @@ -44,6 +44,9 @@ var _ = Describe("Load", func() { OIDCGroupsClaim: "groups", OIDCAudienceClaims: []string{"aud"}, InsecureOIDCSkipNonce: true, + + OIDCLazyDiscoveryInitialInterval: DefaultOIDCLazyDiscoveryInitialInterval, + OIDCLazyDiscoveryMaxInterval: DefaultOIDCLazyDiscoveryMaxInterval, }, Options: Options{ diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 6f115f8a..d0ab0685 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -1,6 +1,10 @@ package options -import "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" +import ( + "time" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" +) const ( // OIDCEmailClaim is the generic email claim used by the OIDC provider. @@ -13,6 +17,18 @@ const ( // for OIDCOptions.SkipDiscovery DefaultSkipDiscovery bool = false + // DefaultOIDCLazyDiscovery is the default value for OIDCOptions.LazyDiscovery. + // When false, a failed OIDC discovery aborts startup (historical behaviour). + DefaultOIDCLazyDiscovery bool = false + + // DefaultOIDCLazyDiscoveryInitialInterval is the default value for + // OIDCOptions.LazyDiscoveryInitialInterval. + DefaultOIDCLazyDiscoveryInitialInterval time.Duration = 3 * time.Second + + // DefaultOIDCLazyDiscoveryMaxInterval is the default value for + // OIDCOptions.LazyDiscoveryMaxInterval. + DefaultOIDCLazyDiscoveryMaxInterval time.Duration = 30 * time.Second + // DefaultInsecureSkipNonce is the default value // for OIDCOptions.InsecureSkipNonce DefaultInsecureSkipNonce bool = true @@ -300,6 +316,17 @@ type OIDCOptions struct { // SkipDiscovery allows to skip OIDC discovery and use manually supplied Endpoints // default set to 'false' SkipDiscovery *bool `yaml:"skipDiscovery,omitempty"` + // LazyDiscovery lets oauth2-proxy start when the OIDC issuer is unreachable, + // retrying discovery in the background instead of failing startup. OAuth2 + // login returns 503 until discovery succeeds; other features (e.g. Basic Auth) + // keep working. default set to 'false' + LazyDiscovery *bool `yaml:"lazyDiscovery,omitempty"` + // LazyDiscoveryInitialInterval is the backoff before the first background + // discovery retry, doubling up to LazyDiscoveryMaxInterval. default set to '3s' + LazyDiscoveryInitialInterval time.Duration `yaml:"lazyDiscoveryInitialInterval,omitempty"` + // LazyDiscoveryMaxInterval is the maximum backoff between background + // discovery retries. default set to '30s' + LazyDiscoveryMaxInterval time.Duration `yaml:"lazyDiscoveryMaxInterval,omitempty"` // JwksURL is the OpenID Connect JWKS URL // eg: https://www.googleapis.com/oauth2/v3/certs JwksURL string `yaml:"jwksURL,omitempty"` @@ -349,6 +376,7 @@ func providerDefaults() Providers { InsecureAllowUnverifiedEmail: ptr.To(DefaultInsecureAllowUnverifiedEmail), InsecureSkipNonce: ptr.To(DefaultInsecureSkipNonce), SkipDiscovery: ptr.To(DefaultSkipDiscovery), + LazyDiscovery: ptr.To(DefaultOIDCLazyDiscovery), UserIDClaim: OIDCEmailClaim, // Deprecated: Use OIDCEmailClaim EmailClaim: OIDCEmailClaim, GroupsClaim: OIDCGroupsClaim, @@ -394,6 +422,18 @@ func (o *OIDCOptions) EnsureDefaults() { if o.SkipDiscovery == nil { o.SkipDiscovery = ptr.To(DefaultSkipDiscovery) } + if o.LazyDiscovery == nil { + o.LazyDiscovery = ptr.To(DefaultOIDCLazyDiscovery) + } + if o.LazyDiscoveryInitialInterval <= 0 { + o.LazyDiscoveryInitialInterval = DefaultOIDCLazyDiscoveryInitialInterval + } + if o.LazyDiscoveryMaxInterval <= 0 { + o.LazyDiscoveryMaxInterval = DefaultOIDCLazyDiscoveryMaxInterval + } + if o.LazyDiscoveryMaxInterval < o.LazyDiscoveryInitialInterval { + o.LazyDiscoveryMaxInterval = o.LazyDiscoveryInitialInterval + } if o.UserIDClaim == "" { o.UserIDClaim = OIDCEmailClaim } diff --git a/pkg/apis/options/providers_test.go b/pkg/apis/options/providers_test.go new file mode 100644 index 00000000..494e50da --- /dev/null +++ b/pkg/apis/options/providers_test.go @@ -0,0 +1,16 @@ +package options + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("OIDCOptions EnsureDefaults", func() { + It("defaults LazyDiscovery to false when unset", func() { + o := &OIDCOptions{} + o.EnsureDefaults() + + Expect(o.LazyDiscovery).ToNot(BeNil()) + Expect(*o.LazyDiscovery).To(Equal(DefaultOIDCLazyDiscovery)) + }) +}) diff --git a/providers/lazy_provider.go b/providers/lazy_provider.go new file mode 100644 index 00000000..4d20d92a --- /dev/null +++ b/providers/lazy_provider.go @@ -0,0 +1,176 @@ +package providers + +import ( + "context" + "errors" + "net/url" + "sync" + "time" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" +) + +// ErrProviderNotReady is returned by a LazyProvider's methods while background +// OIDC discovery has not yet completed. Provider-independent features (such as +// Basic Auth via htpasswd-file) do not go through these methods and therefore +// keep working while discovery is pending. +var ErrProviderNotReady = errors.New("provider not ready: OIDC discovery has not completed yet") + +// LazyProvider wraps a Provider whose construction depends on OIDC discovery. +// It starts out not-ready, delegating only Data() to a discovery-independent +// placeholder, and returns ErrProviderNotReady from OAuth flow methods. Once +// background discovery succeeds, the real provider is swapped in atomically and +// all methods delegate to it. +type LazyProvider struct { + providerConfig options.Provider + placeholder Provider + + mu sync.RWMutex + inner Provider +} + +var _ Provider = (*LazyProvider)(nil) + +// NewLazyProvider builds a LazyProvider for the given configuration. The +// placeholder provider is constructed without performing OIDC discovery so it +// cannot fail on an unreachable issuer. Call InitWithRetry (typically in a +// goroutine) to perform discovery in the background. +func NewLazyProvider(providerConfig options.Provider) (*LazyProvider, error) { + placeholder, err := newPlaceholderProvider(providerConfig) + if err != nil { + return nil, err + } + return &LazyProvider{ + providerConfig: providerConfig, + placeholder: placeholder, + }, nil +} + +// current returns the real provider if discovery has completed, otherwise nil. +func (l *LazyProvider) current() Provider { + l.mu.RLock() + defer l.mu.RUnlock() + return l.inner +} + +// Ready reports whether background discovery has completed and the real +// provider is available. +func (l *LazyProvider) Ready() bool { + return l.current() != nil +} + +func (l *LazyProvider) setInner(p Provider) { + l.mu.Lock() + l.inner = p + l.mu.Unlock() +} + +// InitWithRetry repeatedly attempts to construct the real provider (performing +// OIDC discovery) until it succeeds or ctx is cancelled. Once construction +// succeeds the real provider is swapped in and the LazyProvider becomes ready. +// It is intended to be run in a goroutine. +func (l *LazyProvider) InitWithRetry(ctx context.Context) { + initialInterval := l.providerConfig.OIDCConfig.LazyDiscoveryInitialInterval + if initialInterval <= 0 { + initialInterval = options.DefaultOIDCLazyDiscoveryInitialInterval + } + maxInterval := l.providerConfig.OIDCConfig.LazyDiscoveryMaxInterval + if maxInterval < initialInterval { + maxInterval = initialInterval + } + + interval := initialInterval + attempt := 0 + for { + attempt++ + provider, err := NewProvider(l.providerConfig) + if err == nil { + l.setInner(provider) + logger.Printf("OIDC discovery succeeded after %d attempt(s); provider is now ready", attempt) + return + } + logger.Errorf("lazy OIDC discovery attempt %d failed, will retry in %s: %v", attempt, interval, err) + + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + logger.Errorf("stopping lazy OIDC discovery: %v", ctx.Err()) + return + case <-timer.C: + } + + interval *= 2 + if interval > maxInterval { + interval = maxInterval + } + } +} + +// Data returns the real provider's data once ready, otherwise the placeholder's. +func (l *LazyProvider) Data() *ProviderData { + if p := l.current(); p != nil { + return p.Data() + } + return l.placeholder.Data() +} + +// GetLoginURL returns an empty string until the provider is ready. +func (l *LazyProvider) GetLoginURL(redirectURI, finalRedirect, nonce string, extraParams url.Values) string { + if p := l.current(); p != nil { + return p.GetLoginURL(redirectURI, finalRedirect, nonce, extraParams) + } + return "" +} + +func (l *LazyProvider) Redeem(ctx context.Context, redirectURI, code, codeVerifier string) (*sessions.SessionState, error) { + if p := l.current(); p != nil { + return p.Redeem(ctx, redirectURI, code, codeVerifier) + } + return nil, ErrProviderNotReady +} + +func (l *LazyProvider) GetEmailAddress(ctx context.Context, s *sessions.SessionState) (string, error) { + if p := l.current(); p != nil { + return p.GetEmailAddress(ctx, s) + } + return "", ErrProviderNotReady +} + +func (l *LazyProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { + if p := l.current(); p != nil { + return p.EnrichSession(ctx, s) + } + return ErrProviderNotReady +} + +func (l *LazyProvider) Authorize(ctx context.Context, s *sessions.SessionState) (bool, error) { + if p := l.current(); p != nil { + return p.Authorize(ctx, s) + } + return false, ErrProviderNotReady +} + +// ValidateSession returns false until the provider is ready. +func (l *LazyProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool { + if p := l.current(); p != nil { + return p.ValidateSession(ctx, s) + } + return false +} + +func (l *LazyProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) { + if p := l.current(); p != nil { + return p.RefreshSession(ctx, s) + } + return false, ErrProviderNotReady +} + +func (l *LazyProvider) CreateSessionFromToken(ctx context.Context, token string) (*sessions.SessionState, error) { + if p := l.current(); p != nil { + return p.CreateSessionFromToken(ctx, token) + } + return nil, ErrProviderNotReady +} diff --git a/providers/lazy_provider_test.go b/providers/lazy_provider_test.go new file mode 100644 index 00000000..7aa00feb --- /dev/null +++ b/providers/lazy_provider_test.go @@ -0,0 +1,119 @@ +package providers + +import ( + "context" + "net/url" + "testing" + + "github.com/oauth2-proxy/mockoidc" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr" + . "github.com/onsi/gomega" +) + +// unreachableLazyConfig builds an OIDC provider config pointing at an +// unreachable issuer, with lazy discovery enabled. +func unreachableLazyConfig() options.Provider { + return options.Provider{ + ID: providerID, + Type: "oidc", + ClientID: clientID, + OIDCConfig: options.OIDCOptions{ + // Port 1 is not listenable, so discovery fails fast. + IssuerURL: "http://127.0.0.1:1/realms/test", + SkipDiscovery: ptr.To(false), + LazyDiscovery: ptr.To(true), + AudienceClaims: []string{"aud"}, + }, + } +} + +func TestLazyProviderNotReadyGating(t *testing.T) { + g := NewWithT(t) + + lazy, err := NewLazyProvider(unreachableLazyConfig()) + g.Expect(err).ToNot(HaveOccurred()) + + // Not ready until background discovery completes. + g.Expect(lazy.Ready()).To(BeFalse()) + + // Data() serves the discovery-independent placeholder. + g.Expect(lazy.Data()).ToNot(BeNil()) + g.Expect(lazy.Data().ProviderName).To(Equal("OpenID Connect")) + + // OAuth-flow methods report not-ready rather than panicking. + g.Expect(lazy.GetLoginURL("https://rd", "", "", url.Values{})).To(BeEmpty()) + g.Expect(lazy.ValidateSession(context.Background(), nil)).To(BeFalse()) + + _, err = lazy.Redeem(context.Background(), "https://rd", "code", "") + g.Expect(err).To(MatchError(ErrProviderNotReady)) + + _, err = lazy.GetEmailAddress(context.Background(), nil) + g.Expect(err).To(MatchError(ErrProviderNotReady)) + + err = lazy.EnrichSession(context.Background(), nil) + g.Expect(err).To(MatchError(ErrProviderNotReady)) + + _, err = lazy.Authorize(context.Background(), nil) + g.Expect(err).To(MatchError(ErrProviderNotReady)) + + _, err = lazy.RefreshSession(context.Background(), nil) + g.Expect(err).To(MatchError(ErrProviderNotReady)) + + _, err = lazy.CreateSessionFromToken(context.Background(), "token") + g.Expect(err).To(MatchError(ErrProviderNotReady)) +} + +func TestLazyProviderBecomesReady(t *testing.T) { + g := NewWithT(t) + + m, err := mockoidc.Run() + g.Expect(err).ToNot(HaveOccurred()) + defer func() { + g.Expect(m.Shutdown()).To(Succeed()) + }() + + providerConfig := options.Provider{ + ID: providerID, + Type: "oidc", + ClientID: m.Config().ClientID, + OIDCConfig: options.OIDCOptions{ + IssuerURL: m.Issuer(), + SkipDiscovery: ptr.To(false), + LazyDiscovery: ptr.To(true), + AudienceClaims: []string{"aud"}, + }, + } + + lazy, err := NewLazyProvider(providerConfig) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(lazy.Ready()).To(BeFalse()) + + // The issuer is reachable, so discovery succeeds on the first attempt and + // InitWithRetry returns promptly. + lazy.InitWithRetry(context.Background()) + + g.Expect(lazy.Ready()).To(BeTrue()) + // Once ready, delegation to the real provider works. + g.Expect(lazy.GetLoginURL("https://rd", "", "nonce", url.Values{})).ToNot(BeEmpty()) +} + +func TestLazyProviderInitWithRetryStopsOnContextCancel(t *testing.T) { + g := NewWithT(t) + + lazy, err := NewLazyProvider(unreachableLazyConfig()) + g.Expect(err).ToNot(HaveOccurred()) + + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan struct{}) + go func() { + lazy.InitWithRetry(ctx) + close(done) + }() + + cancel() + + g.Eventually(done, "2s").Should(BeClosed()) + g.Expect(lazy.Ready()).To(BeFalse()) +} diff --git a/providers/providers.go b/providers/providers.go index f87d26a2..2e8c3c39 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -37,6 +37,25 @@ func NewProvider(providerConfig options.Provider) (Provider, error) { if err != nil { return nil, fmt.Errorf("could not create provider data: %v", err) } + return providerFromData(providerConfig, providerData) +} + +// newPlaceholderProvider constructs a Provider without performing OIDC +// discovery. The resulting provider has the correct display name and defaults +// but no verifier or discovered endpoints, so it must not be used to serve OIDC +// flows. It is used as the initial value of a LazyProvider until background +// discovery completes. +func newPlaceholderProvider(providerConfig options.Provider) (Provider, error) { + providerData, err := buildProviderData(providerConfig, true) + if err != nil { + return nil, fmt.Errorf("could not create provider data: %v", err) + } + return providerFromData(providerConfig, providerData) +} + +// providerFromData constructs the concrete Provider implementation for the +// configured provider type from an already-built ProviderData. +func providerFromData(providerConfig options.Provider, providerData *ProviderData) (Provider, error) { switch providerConfig.Type { case options.ADFSProvider: return NewADFSProvider(providerData, providerConfig), nil @@ -78,6 +97,15 @@ func NewProvider(providerConfig options.Provider) (Provider, error) { } func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData, error) { + return buildProviderData(providerConfig, false) +} + +// buildProviderData builds the ProviderData for the given configuration. When +// skipDiscovery is true, the OIDC discovery step (which reaches out to the +// issuer over the network) is skipped, leaving the Verifier and discovered +// endpoints unset. This is used to build a placeholder provider for lazy +// initialisation, so oauth2-proxy can start before the issuer is reachable. +func buildProviderData(providerConfig options.Provider, skipDiscovery bool) (*ProviderData, error) { p := &ProviderData{ Scope: providerConfig.Scope, ClientID: providerConfig.ClientID, @@ -87,12 +115,12 @@ func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData, AdditionalClaims: providerConfig.AdditionalClaims, } - needsVerifier, err := providerRequiresOIDCProviderVerifier(providerConfig.Type) + needsVerifier, err := ProviderRequiresOIDCProviderVerifier(providerConfig.Type) if err != nil { return nil, err } - if needsVerifier { + if needsVerifier && !skipDiscovery { pv, err := internaloidc.NewProviderVerifier(context.TODO(), internaloidc.ProviderVerifierOptions{ AudienceClaims: providerConfig.OIDCConfig.AudienceClaims, ClientID: providerConfig.ClientID, @@ -187,7 +215,10 @@ func parseCodeChallengeMethod(providerConfig options.Provider) string { } } -func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bool, error) { +// ProviderRequiresOIDCProviderVerifier reports whether the given provider type +// relies on the OIDC ProviderVerifier (and therefore on OIDC discovery when it +// is enabled). +func ProviderRequiresOIDCProviderVerifier(providerType options.ProviderType) (bool, error) { switch providerType { case options.BitbucketProvider, options.DigitalOceanProvider, options.FacebookProvider, options.GitHubProvider, options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider,