diff --git a/cli/server.go b/cli/server.go index 8f636cec357..33796d3a4cf 100644 --- a/cli/server.go +++ b/cli/server.go @@ -200,7 +200,16 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De return nil, xerrors.Errorf("parse oidc redirect url %q", err) } logger.Warn(ctx, "custom OIDC redirect URL used instead of 'access_url', ensure this matches the value configured in your OIDC provider") + if len(vals.OIDC.RedirectAllowedHosts.Value()) > 0 { + // Static override takes precedence; keep the behavior explicit and + // loud rather than silently mixing the two modes. + logger.Warn(ctx, "ignoring CODER_OIDC_REDIRECT_ALLOWED_HOSTS because CODER_OIDC_REDIRECT_URL is set") + } } + // Capture the configured scheme for the dynamic-host code path so that + // the dynamic redirect_uri uses the same scheme as the static one even + // when upstream proxies report a misleading X-Forwarded-Proto. + redirectDefaultScheme := redirectURL.Scheme // If the scopes contain 'groups', we enable group support. // Do not override any custom value set by the user. @@ -259,6 +268,17 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De return nil, xerrors.Errorf("pkce detect in claims: %w", err) } + // CODER_OIDC_REDIRECT_URL is a strict override: when set, the redirect_uri + // is fixed at startup and dynamic-host selection is disabled. Otherwise, + // surface the allowlist to the middleware. + var redirectAllowedHosts []string + if vals.OIDC.RedirectURL.String() == "" { + redirectAllowedHosts = vals.OIDC.RedirectAllowedHosts.Value() + } else { + // Static-override mode does not need the dynamic default scheme. + redirectDefaultScheme = "" + } + return &coderd.OIDCConfig{ OAuth2Config: useCfg, Provider: oidcProvider, @@ -268,19 +288,21 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De // matches the issuer URL. This is not recommended. SkipIssuerCheck: vals.OIDC.SkipIssuerChecks.Value(), }), - EmailDomain: vals.OIDC.EmailDomain, - AllowSignups: vals.OIDC.AllowSignups.Value(), - UsernameField: vals.OIDC.UsernameField.String(), - NameField: vals.OIDC.NameField.String(), - EmailField: vals.OIDC.EmailField.String(), - AuthURLParams: vals.OIDC.AuthURLParams.Value, - SecondaryClaims: secondaryClaimsSrc, - SignInText: vals.OIDC.SignInText.String(), - SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(), - IconURL: vals.OIDC.IconURL.String(), - IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(), - PKCEMethods: pkceSupport.CodeChallengeMethodsSupported, - EmailFallback: vals.OIDC.EmailFallback.Value(), + EmailDomain: vals.OIDC.EmailDomain, + AllowSignups: vals.OIDC.AllowSignups.Value(), + UsernameField: vals.OIDC.UsernameField.String(), + NameField: vals.OIDC.NameField.String(), + EmailField: vals.OIDC.EmailField.String(), + AuthURLParams: vals.OIDC.AuthURLParams.Value, + SecondaryClaims: secondaryClaimsSrc, + SignInText: vals.OIDC.SignInText.String(), + SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(), + IconURL: vals.OIDC.IconURL.String(), + IgnoreEmailVerified: vals.OIDC.IgnoreEmailVerified.Value(), + PKCEMethods: pkceSupport.CodeChallengeMethodsSupported, + EmailFallback: vals.OIDC.EmailFallback.Value(), + RedirectAllowedHosts: redirectAllowedHosts, + RedirectDefaultScheme: redirectDefaultScheme, }, nil } diff --git a/cli/server_test.go b/cli/server_test.go index 3a7d8be4c8c..6124a1b0c00 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -1591,6 +1591,65 @@ func TestServer(t *testing.T) { } } }) + + t.Run("RedirectAllowedHosts", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + // Same fake-issuer setup as the other OIDC subtests. + oidcServer := httptest.NewServer(nil) + fakeWellKnownHandler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + payload := fmt.Sprintf("{\"issuer\": %q}", oidcServer.URL) + _, _ = w.Write([]byte(payload)) + } + oidcServer.Config.Handler = http.HandlerFunc(fakeWellKnownHandler) + t.Cleanup(oidcServer.Close) + + inv, cfg := clitest.New(t, + "server", + dbArg(t), + "--http-address", ":0", + "--access-url", "http://example.com", + "--oidc-client-id", "fake", + "--oidc-client-secret", "fake", + "--oidc-issuer-url", oidcServer.URL, + "--oidc-redirect-allowed-hosts", "coder.example.com,coder-walle.example.com", + ) + + clitest.Start(t, inv) + accessURL := waitAccessURL(t, cfg) + client := codersdk.New(accessURL) + + randPassword, err := cryptorand.String(24) + require.NoError(t, err) + + _, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{ + Email: "admin@coder.com", + Password: randPassword, + Username: "admin", + Trial: true, + }) + require.NoError(t, err) + + loginResp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{ + Email: "admin@coder.com", + Password: randPassword, + }) + require.NoError(t, err) + client.SetSessionToken(loginResp.SessionToken) + + deploymentConfig, err := client.DeploymentConfig(ctx) + require.NoError(t, err) + + // The CLI flag should have populated the runtime config. + require.Equal(t, + []string{"coder.example.com", "coder-walle.example.com"}, + deploymentConfig.Values.OIDC.RedirectAllowedHosts.Value(), + ) + }) }) t.Run("RateLimit", func(t *testing.T) { diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 8d7b441354e..baff9b75dd0 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -448,6 +448,13 @@ oidc: # enable if you understand and accept the risk. # (default: , type: bool) dangerousOidcEmailFallback: false + # An allowlist of hostnames that may be used as the host of the OIDC redirect_uri. + # When set, the redirect_uri sent to the OIDC provider is built from the incoming + # request's Host header (validated against this list) instead of from access-url. + # Every listed host must also be registered as a valid redirect URI in the OIDC + # provider. Ignored when oidc-redirect-url is set. + # (default: , type: string-array) + oidcRedirectAllowedHosts: [] # Telemetry is critical to our ability to improve Coder. We strip all personal # information before sending data to our servers. Please only disable telemetry # when required by your organization's security policy. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fdbdb9f46dd..36bbacb0471 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -21354,6 +21354,13 @@ const docTemplate = `{ "organization_mapping": { "type": "object" }, + "redirect_allowed_hosts": { + "description": "RedirectAllowedHosts is an allowlist of hostnames that may be used as\nthe host of the OIDC redirect_uri. When non-empty, the redirect_uri is\nconstructed from the incoming request's Host header (validated against\nthis list) instead of from AccessURL. Every listed host must also be\nregistered as a valid redirect URI in the OIDC provider. This setting\nis mutually exclusive with RedirectURL: if RedirectURL is set, this\nallowlist is ignored.", + "type": "array", + "items": { + "type": "string" + } + }, "redirect_url": { "description": "RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche\nsituations where the OIDC callback domain is different from the ACCESS_URL\ndomain.", "allOf": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 93bfcc07bdf..faf1c9f0489 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -19482,6 +19482,13 @@ "organization_mapping": { "type": "object" }, + "redirect_allowed_hosts": { + "description": "RedirectAllowedHosts is an allowlist of hostnames that may be used as\nthe host of the OIDC redirect_uri. When non-empty, the redirect_uri is\nconstructed from the incoming request's Host header (validated against\nthis list) instead of from AccessURL. Every listed host must also be\nregistered as a valid redirect URI in the OIDC provider. This setting\nis mutually exclusive with RedirectURL: if RedirectURL is set, this\nallowlist is ignored.", + "type": "array", + "items": { + "type": "string" + } + }, "redirect_url": { "description": "RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche\nsituations where the OIDC callback domain is different from the ACCESS_URL\ndomain.", "allOf": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index ae0df0eb88b..c1649832299 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -776,8 +776,12 @@ func New(options *Options) *API { } var oidcAuthURLParams map[string]string + var oidcRedirectAllowedHosts []string + var oidcRedirectDefaultScheme string if options.OIDCConfig != nil { oidcAuthURLParams = options.OIDCConfig.AuthURLParams + oidcRedirectAllowedHosts = options.OIDCConfig.RedirectAllowedHosts + oidcRedirectDefaultScheme = options.OIDCConfig.RedirectDefaultScheme } api.Auditor.Store(&options.Auditor) @@ -1111,7 +1115,7 @@ func New(options *Options) *API { r.Route(fmt.Sprintf("/%s/callback", externalAuthConfig.ID), func(r chi.Router) { r.Use( apiKeyMiddlewareRedirect, - httpmw.ExtractOAuth2(externalAuthConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil, externalAuthConfig.CodeChallengeMethodsSupported), + httpmw.ExtractOAuth2(externalAuthConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil, externalAuthConfig.CodeChallengeMethodsSupported, nil, ""), ) r.Get("/", api.externalAuthCallback(externalAuthConfig)) }) @@ -1657,14 +1661,14 @@ func New(options *Options) *API { r.Route("/github", func(r chi.Router) { r.Use( // Github supports PKCE S256 - httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil, options.GithubOAuth2Config.PKCESupported()), + httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil, options.GithubOAuth2Config.PKCESupported(), nil, ""), ) r.Get("/callback", api.userOAuth2Github) }) }) r.Route("/oidc/callback", func(r chi.Router) { r.Use( - httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, oidcAuthURLParams, options.OIDCConfig.PKCESupported()), + httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, oidcAuthURLParams, options.OIDCConfig.PKCESupported(), oidcRedirectAllowedHosts, oidcRedirectDefaultScheme), ) r.Get("/", api.userOIDC) }) diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 5f12543887a..71b20a2f28e 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -3,17 +3,21 @@ package httpmw import ( "context" "fmt" + "net" "net/http" "net/url" "reflect" "slices" + "strings" "github.com/go-chi/chi/v5" "github.com/google/uuid" "golang.org/x/oauth2" + "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/httpmw/loggermw" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" @@ -45,13 +49,42 @@ func OAuth2(r *http.Request) OAuth2State { // pkceMethods should be a list like ['S256', 'plain'] indicating // which PKCE methods are supported by the OAuth2 provider. If empty, // PKCE will not be used. -func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg codersdk.HTTPCookieConfig, authURLOpts map[string]string, pkceMethods []promoauth.Oauth2PKCEChallengeMethod) func(http.Handler) http.Handler { +// +// redirectAllowedHosts, when non-empty, enables dynamic redirect_uri +// construction from the request Host header. The request Host must match +// (case-insensitive, ignoring port) one of the listed hostnames. The +// dynamic redirect_uri is cached in a cookie so the same value is reused +// for the token exchange, as required by RFC 6749 section 4.1.3. Pass nil +// to preserve the legacy behavior of using the redirect_uri baked into +// config at startup. +// +// redirectDefaultScheme is the scheme used when constructing the dynamic +// redirect_uri. It is populated from the configured AccessURL and takes +// precedence over r.TLS / X-Forwarded-Proto because some reverse proxies +// report the inner-hop scheme (e.g. "http") rather than the original +// client-facing scheme, which would produce a redirect_uri the IdP +// rejects. Callers must always supply this when redirectAllowedHosts is +// non-empty; an empty value would yield an invalid redirect_uri without +// a scheme. +func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg codersdk.HTTPCookieConfig, authURLOpts map[string]string, pkceMethods []promoauth.Oauth2PKCEChallengeMethod, redirectAllowedHosts []string, redirectDefaultScheme string) func(http.Handler) http.Handler { opts := make([]oauth2.AuthCodeOption, 0, len(authURLOpts)+1) opts = append(opts, oauth2.AccessTypeOffline) for k, v := range authURLOpts { opts = append(opts, oauth2.SetAuthURLParam(k, v)) } + // Pre-normalize the allowlist once so the per-request check is a plain + // case-insensitive compare and we do not re-allocate on every login. + normalizedAllowedHosts := make([]string, 0, len(redirectAllowedHosts)) + for _, h := range redirectAllowedHosts { + h = strings.TrimSpace(h) + if h == "" { + continue + } + normalizedAllowedHosts = append(normalizedAllowedHosts, strings.ToLower(h)) + } + dynamicRedirectEnabled := len(normalizedAllowedHosts) > 0 + // Only S256 PKCE is currently supported. sha256PKCESupported := slices.Contains(pkceMethods, promoauth.PKCEChallengeMethodSha256) return func(next http.Handler) http.Handler { @@ -103,6 +136,32 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg redirect = uriFromURL(redirect) } + // When dynamic redirect URIs are enabled, validate the request Host + // against the allowlist regardless of whether we are initiating the + // flow or handling the callback. Doing this upfront avoids burning + // state and lets us reject obviously-bad requests with a clear error. + var dynamicRedirectURI string + if dynamicRedirectEnabled { + hostname := r.Host + if h, _, splitErr := net.SplitHostPort(r.Host); splitErr == nil { + hostname = h + } + if !slices.Contains(normalizedAllowedHosts, strings.ToLower(hostname)) { + if rlogger := loggermw.RequestLoggerFromContext(ctx); rlogger != nil { + rlogger.WithFields( + slog.F("oidc_rejected_reason", "host_not_in_allowlist"), + slog.F("oidc_rejected_host", hostname), + ) + } + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "OIDC login is not permitted from this host.", + Detail: fmt.Sprintf("Host %q is not in the OIDC redirect allowlist. Configure CODER_OIDC_REDIRECT_ALLOWED_HOSTS to include it.", hostname), + }) + return + } + dynamicRedirectURI = buildDynamicRedirectURI(r, redirectDefaultScheme) + } + if code == "" { // If the code isn't provided, we'll redirect! var state string @@ -153,6 +212,19 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg })) } + // Persist and inject the dynamic redirect_uri so the IdP + // sends the user back to the same domain they started on, + // and so the token exchange below uses the matching value. + if dynamicRedirectURI != "" { + http.SetCookie(rw, cookieCfg.Apply(&http.Cookie{ + Name: codersdk.OAuth2RedirectURICookie, + Value: dynamicRedirectURI, + Path: "/", + HttpOnly: true, + })) + authOpts = append(authOpts, oauth2.SetAuthURLParam("redirect_uri", dynamicRedirectURI)) + } + http.Redirect(rw, r, config.AuthCodeURL(state, authOpts...), http.StatusTemporaryRedirect) return } @@ -195,6 +267,50 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg exchangeOpts = append(exchangeOpts, oauth2.VerifierOption(pkceVerifier.Value)) } + // RFC 6749 section 4.1.3: the redirect_uri included in the token + // exchange must match the one sent in the authorization request. + // When the dynamic-redirect path is in use, the original value was + // stashed in a cookie; replay it here. + // + // Defense in depth: we do not blindly forward the cookie value to + // the IdP. We recompute the expected redirect_uri from the (already + // allowlist-validated) request Host, then require the cookie to + // match. This guards against: + // - The cookie going missing (e.g. third-party cookie blocking) + // and silently falling back to the static redirect_uri, which + // would mismatch the authorization request and produce a + // confusing IdP rejection. Fail loudly here instead. + // - A tampered cookie pointing at a host the user did not + // authenticate on. The IdP allowlist would normally catch this, + // but we should not depend on it. + if dynamicRedirectEnabled { + redirectCookie, err := r.Cookie(codersdk.OAuth2RedirectURICookie) + if err != nil || redirectCookie.Value == "" { + if rlogger := loggermw.RequestLoggerFromContext(ctx); rlogger != nil { + rlogger.WithFields(slog.F("oidc_rejected_reason", "missing_redirect_uri_cookie")) + } + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Cookie %q must be provided for the OIDC callback when CODER_OIDC_REDIRECT_ALLOWED_HOSTS is configured.", codersdk.OAuth2RedirectURICookie), + }) + return + } + expectedRedirectURI := buildDynamicRedirectURI(r, redirectDefaultScheme) + if redirectCookie.Value != expectedRedirectURI { + if rlogger := loggermw.RequestLoggerFromContext(ctx); rlogger != nil { + rlogger.WithFields( + slog.F("oidc_rejected_reason", "redirect_uri_cookie_mismatch"), + slog.F("oidc_cookie_redirect_uri", redirectCookie.Value), + slog.F("oidc_expected_redirect_uri", expectedRedirectURI), + ) + } + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "OIDC redirect_uri cookie does not match the current request host.", + }) + return + } + exchangeOpts = append(exchangeOpts, oauth2.SetAuthURLParam("redirect_uri", redirectCookie.Value)) + } + oauthToken, err := config.Exchange(ctx, code, exchangeOpts...) if err != nil { errorCode := http.StatusInternalServerError @@ -424,3 +540,27 @@ func uriFromURL(u string) string { return uri.RequestURI() } + +// buildDynamicRedirectURI constructs the OIDC redirect_uri from the incoming +// request, used when CODER_OIDC_REDIRECT_ALLOWED_HOSTS is configured. +// +// The scheme is taken from the configured AccessURL (passed in as +// defaultScheme by the caller) rather than from the request itself. Real +// deployments that use this feature always sit behind a TLS-terminating +// proxy, and some such proxies set X-Forwarded-Proto to the inner-hop +// scheme (e.g. "http" between proxy and coderd) instead of the original +// client-facing scheme. Trusting the request for scheme would produce a +// redirect_uri the IdP rejects. AccessURL is the operator-defined source +// of truth and is the same value the static OIDC path uses, so reusing +// it keeps the dynamic and static paths byte-for-byte consistent. +// +// The callback path is whatever path the middleware is mounted at, which +// today is /api/v2/users/oidc/callback for OIDC. +func buildDynamicRedirectURI(r *http.Request, defaultScheme string) string { + u := url.URL{ + Scheme: defaultScheme, + Host: r.Host, + Path: r.URL.Path, + } + return u.String() +} diff --git a/coderd/httpmw/oauth2_test.go b/coderd/httpmw/oauth2_test.go index baedd2cc2fe..6638194ab3a 100644 --- a/coderd/httpmw/oauth2_test.go +++ b/coderd/httpmw/oauth2_test.go @@ -50,7 +50,7 @@ func TestOAuth2(t *testing.T) { t.Parallel() req := httptest.NewRequest("GET", "/", nil) res := httptest.NewRecorder() - httpmw.ExtractOAuth2(nil, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req) + httpmw.ExtractOAuth2(nil, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req) require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) }) t.Run("RedirectWithoutCode", func(t *testing.T) { @@ -58,7 +58,7 @@ func TestOAuth2(t *testing.T) { req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape("/dashboard"), nil) res := httptest.NewRecorder() tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) - httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req) location := res.Header().Get("Location") if !assert.NotEmpty(t, location) { return @@ -82,7 +82,7 @@ func TestOAuth2(t *testing.T) { req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape(uri.String()), nil) res := httptest.NewRecorder() tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) - httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req) location := res.Header().Get("Location") if !assert.NotEmpty(t, location) { return @@ -97,7 +97,7 @@ func TestOAuth2(t *testing.T) { req := httptest.NewRequest("GET", "/?code=something", nil) res := httptest.NewRecorder() tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) - httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req) require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) }) t.Run("NoStateCookie", func(t *testing.T) { @@ -105,7 +105,7 @@ func TestOAuth2(t *testing.T) { req := httptest.NewRequest("GET", "/?code=something&state=test", nil) res := httptest.NewRecorder() tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) - httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req) require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode) }) t.Run("MismatchedState", func(t *testing.T) { @@ -117,7 +117,7 @@ func TestOAuth2(t *testing.T) { }) res := httptest.NewRecorder() tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) - httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(nil).ServeHTTP(res, req) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req) require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode) }) t.Run("ExchangeCodeAndState", func(t *testing.T) { @@ -133,7 +133,7 @@ func TestOAuth2(t *testing.T) { }) res := httptest.NewRecorder() tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) - httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { state := httpmw.OAuth2(r) require.Equal(t, "/dashboard", state.Redirect) })).ServeHTTP(res, req) @@ -144,7 +144,7 @@ func TestOAuth2(t *testing.T) { res := httptest.NewRecorder() tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("foo", "bar")) authOpts := map[string]string{"foo": "bar"} - httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, authOpts, nil)(nil).ServeHTTP(res, req) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, authOpts, nil, nil, "")(nil).ServeHTTP(res, req) location := res.Header().Get("Location") // Ideally we would also assert that the location contains the query params // we set in the auth URL but this would essentially be testing the oauth2 package. @@ -160,7 +160,7 @@ func TestOAuth2(t *testing.T) { httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{ Secure: true, SameSite: "none", - }, nil, nil)(nil).ServeHTTP(res, req) + }, nil, nil, nil, "")(nil).ServeHTTP(res, req) found := false for _, cookie := range res.Result().Cookies() { @@ -174,3 +174,198 @@ func TestOAuth2(t *testing.T) { require.True(t, found, "expected state cookie") }) } + +// nolint:bodyclose +func TestOAuth2DynamicRedirect(t *testing.T) { + t.Parallel() + + const callbackPath = "/api/v2/users/oidc/callback" + const primaryHost = "coder.test.netflix.net" + const altHost = "dev-workspaces.test.netflix.net" + const wantPrimaryURI = "https://" + primaryHost + callbackPath + const wantAltURI = "https://" + altHost + callbackPath + + t.Run("InitOnAllowedHostSetsCookieAndOverridesRedirect", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", callbackPath+"?redirect="+url.QueryEscape("/dashboard"), nil) + req.Host = altHost + res := httptest.NewRecorder() + + tp := newTestOAuth2Provider(t, + oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("redirect_uri", wantAltURI), + ) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, + []string{primaryHost, altHost}, "https")(nil).ServeHTTP(res, req) + + require.Equal(t, http.StatusTemporaryRedirect, res.Result().StatusCode) + + var redirectCookie *http.Cookie + for _, c := range res.Result().Cookies() { + if c.Name == codersdk.OAuth2RedirectURICookie { + redirectCookie = c + break + } + } + require.NotNil(t, redirectCookie, "expected %s cookie", codersdk.OAuth2RedirectURICookie) + require.Equal(t, wantAltURI, redirectCookie.Value) + }) + + t.Run("InitOnDisallowedHostReturnsBadRequest", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", callbackPath, nil) + req.Host = "evil.example.com" + res := httptest.NewRecorder() + + // authOpts must not be asserted: the request should be rejected + // before AuthCodeURL is called. + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, + []string{primaryHost, altHost}, "")(nil).ServeHTTP(res, req) + + require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + for _, c := range res.Result().Cookies() { + require.NotEqual(t, codersdk.OAuth2RedirectURICookie, c.Name, "must not set redirect_uri cookie when host is rejected") + } + }) + + t.Run("AllowlistHostMatchIsCaseInsensitiveAndIgnoresPort", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", callbackPath, nil) + req.Host = "DEV-WORKSPACES.test.netflix.net:8443" + res := httptest.NewRecorder() + + // Host is preserved verbatim in the constructed redirect_uri so the + // IdP sees exactly what the user typed (case is preserved but the + // allowlist match is insensitive). The scheme comes from the caller- + // supplied defaultScheme; real callers populate this from AccessURL. + expectedURI := "https://DEV-WORKSPACES.test.netflix.net:8443" + callbackPath + tp := newTestOAuth2Provider(t, + oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("redirect_uri", expectedURI), + ) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, + []string{altHost}, "https")(nil).ServeHTTP(res, req) + + require.Equal(t, http.StatusTemporaryRedirect, res.Result().StatusCode) + }) + + t.Run("ExchangeReusesRedirectURIFromCookie", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", callbackPath+"?code=test&state=something", nil) + req.Host = altHost + req.AddCookie(&http.Cookie{Name: codersdk.OAuth2StateCookie, Value: "something"}) + req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectCookie, Value: "/dashboard"}) + req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectURICookie, Value: wantAltURI}) + res := httptest.NewRecorder() + + exchangeCalled := false + tp := &exchangeAssertingProvider{ + t: t, + onExchange: func(opts []oauth2.AuthCodeOption) { + exchangeCalled = true + require.Contains(t, opts, oauth2.SetAuthURLParam("redirect_uri", wantAltURI)) + }, + } + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, + []string{primaryHost, altHost}, "https")(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + state := httpmw.OAuth2(r) + require.Equal(t, "/dashboard", state.Redirect) + })).ServeHTTP(res, req) + require.True(t, exchangeCalled, "expected Exchange to be invoked") + }) + + t.Run("CallbackWithMissingRedirectURICookieReturnsBadRequest", func(t *testing.T) { + t.Parallel() + // Same shape as ExchangeReusesRedirectURIFromCookie but without the + // redirect_uri cookie. Must fail loudly rather than silently sending + // the static config redirect_uri (which would mismatch what was used + // in the original authorization request). + req := httptest.NewRequest("GET", callbackPath+"?code=test&state=something", nil) + req.Host = altHost + req.AddCookie(&http.Cookie{Name: codersdk.OAuth2StateCookie, Value: "something"}) + req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectCookie, Value: "/dashboard"}) + // Intentionally NO OAuth2RedirectURICookie. + res := httptest.NewRecorder() + + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, + []string{primaryHost, altHost}, "https")(nil).ServeHTTP(res, req) + + require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + }) + + t.Run("CallbackWithMismatchedRedirectURICookieReturnsBadRequest", func(t *testing.T) { + t.Parallel() + // Cookie was set when the user initiated on altHost, but the callback + // is somehow arriving from primaryHost (or the cookie was tampered). + // Defense in depth: reject the exchange instead of forwarding a + // stale/mismatched value to the IdP. + req := httptest.NewRequest("GET", callbackPath+"?code=test&state=something", nil) + req.Host = primaryHost + req.AddCookie(&http.Cookie{Name: codersdk.OAuth2StateCookie, Value: "something"}) + req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectURICookie, Value: wantAltURI}) + res := httptest.NewRecorder() + + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, + []string{primaryHost, altHost}, "https")(nil).ServeHTTP(res, req) + + require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + }) + + t.Run("CallbackOnDisallowedHostReturnsBadRequest", func(t *testing.T) { + t.Parallel() + // Even with a valid state cookie and code, the host must be allowed. + req := httptest.NewRequest("GET", callbackPath+"?code=test&state=something", nil) + req.Host = "evil.example.com" + req.AddCookie(&http.Cookie{Name: codersdk.OAuth2StateCookie, Value: "something"}) + req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectURICookie, Value: wantPrimaryURI}) + res := httptest.NewRecorder() + + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, + []string{primaryHost}, "")(nil).ServeHTTP(res, req) + + require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + }) + + t.Run("AllowlistDisabledLeavesBehaviorUnchanged", func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest("GET", callbackPath+"?redirect="+url.QueryEscape("/dashboard"), nil) + req.Host = "anything.example.com" + res := httptest.NewRecorder() + + // With no allowlist, AuthCodeURL must be invoked with only the base + // AccessTypeOffline option; no redirect_uri override should be added. + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req) + + require.Equal(t, http.StatusTemporaryRedirect, res.Result().StatusCode) + for _, c := range res.Result().Cookies() { + require.NotEqual(t, codersdk.OAuth2RedirectURICookie, c.Name) + } + }) +} + +// exchangeAssertingProvider is a test OAuth2 provider that captures the +// options passed to Exchange so the test can assert on them. +type exchangeAssertingProvider struct { + t testing.TB + onExchange func(opts []oauth2.AuthCodeOption) +} + +func (*exchangeAssertingProvider) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { + return "?state=" + url.QueryEscape(state) +} + +func (p *exchangeAssertingProvider) Exchange(_ context.Context, _ string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + if p.onExchange != nil { + p.onExchange(opts) + } + return &oauth2.Token{AccessToken: "hello"}, nil +} + +func (*exchangeAssertingProvider) TokenSource(_ context.Context, _ *oauth2.Token) oauth2.TokenSource { + return nil +} diff --git a/coderd/userauth.go b/coderd/userauth.go index 91e0d0f5e58..3e85536d210 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1193,6 +1193,19 @@ type OIDCConfig struct { // check. Used for IdP brokers that do not issue a stable `sub` for the // same user across connections. EmailFallback bool + // RedirectAllowedHosts, when non-empty, enables dynamic redirect_uri + // construction from the request Host header. The request Host must match + // (case-insensitive, ignoring port) one of the hostnames in this list, + // otherwise the OIDC flow is rejected. + RedirectAllowedHosts []string + // RedirectDefaultScheme is the scheme to use in the dynamically built + // redirect_uri. It is populated from the configured AccessURL (or + // OIDC.RedirectURL if explicitly overridden) so that the dynamic path + // uses the same scheme as the static path. It takes precedence over + // X-Forwarded-Proto because some reverse proxies report the inner-hop + // scheme (e.g. "http") rather than the original client-facing scheme, + // which would produce a redirect_uri the IdP rejects. + RedirectDefaultScheme string } // PKCESupported is to prevent nil pointer dereference. diff --git a/codersdk/client.go b/codersdk/client.go index 834dfa465e2..56e43c3514d 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -44,6 +44,11 @@ const ( OAuth2PKCEVerifier = "oauth_pkce_verifier" // OAuth2RedirectCookie is the name of the cookie that stores the oauth2 redirect. OAuth2RedirectCookie = "oauth_redirect" + // OAuth2RedirectURICookie stores the dynamically computed OIDC redirect_uri + // when CODER_OIDC_REDIRECT_ALLOWED_HOSTS is enabled. The same value must be + // used for both the authorization request and the token exchange (RFC 6749 + // section 4.1.3). + OAuth2RedirectURICookie = "oauth_redirect_uri" // PathAppSessionTokenCookie is the name of the cookie that stores an // application-scoped API token on workspace proxy path app domains. diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 2b4d92006a9..9664c145397 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -979,6 +979,15 @@ type OIDCConfig struct { // brokers that do not issue a stable `sub` for the same user across // connections. EmailFallback serpent.Bool `json:"email_fallback" typescript:",notnull"` + + // RedirectAllowedHosts is an allowlist of hostnames that may be used as + // the host of the OIDC redirect_uri. When non-empty, the redirect_uri is + // constructed from the incoming request's Host header (validated against + // this list) instead of from AccessURL. Every listed host must also be + // registered as a valid redirect URI in the OIDC provider. This setting + // is mutually exclusive with RedirectURL: if RedirectURL is set, this + // allowlist is ignored. + RedirectAllowedHosts serpent.StringArray `json:"redirect_allowed_hosts" typescript:",notnull"` } type TelemetryConfig struct { @@ -3055,6 +3064,21 @@ communicating directly.`, Group: &deploymentGroupOIDC, Hidden: true, }, + { + Name: "OIDC Redirect Allowed Hosts", + Description: "An allowlist of hostnames that may be used as the host of the OIDC redirect_uri. " + + "When set, the redirect_uri sent to the OIDC provider is built from the incoming request's Host header " + + "(validated against this list) instead of from access-url. Every listed host must also be registered " + + "as a valid redirect URI in the OIDC provider. Ignored when oidc-redirect-url is set.", + Flag: "oidc-redirect-allowed-hosts", + Env: "CODER_OIDC_REDIRECT_ALLOWED_HOSTS", + YAML: "oidcRedirectAllowedHosts", + Default: "", + Value: &c.OIDC.RedirectAllowedHosts, + Group: &deploymentGroupOIDC, + // Niche feature for multi-domain deployments. Surface only to operators who need it. + Hidden: true, + }, // Telemetry settings telemetryEnable, { diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index fa3c245d809..32a968febe1 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -464,6 +464,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "organization_assign_default": true, "organization_field": "string", "organization_mapping": {}, + "redirect_allowed_hosts": [ + "string" + ], "redirect_url": { "forceQuery": true, "fragment": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4e4a49745c9..f68451e9dd9 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5823,6 +5823,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "organization_assign_default": true, "organization_field": "string", "organization_mapping": {}, + "redirect_allowed_hosts": [ + "string" + ], "redirect_url": { "forceQuery": true, "fragment": "string", @@ -6429,6 +6432,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "organization_assign_default": true, "organization_field": "string", "organization_mapping": {}, + "redirect_allowed_hosts": [ + "string" + ], "redirect_url": { "forceQuery": true, "fragment": "string", @@ -8912,6 +8918,9 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "organization_assign_default": true, "organization_field": "string", "organization_mapping": {}, + "redirect_allowed_hosts": [ + "string" + ], "redirect_url": { "forceQuery": true, "fragment": "string", @@ -8943,41 +8952,42 @@ Only certain features set these fields: - FeatureManagedAgentLimit| ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------------------------|----------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `allow_signups` | boolean | false | | | -| `auth_url_params` | object | false | | | -| `auto_repair_links` | boolean | false | | | -| `client_cert_file` | string | false | | | -| `client_id` | string | false | | | -| `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | -| `client_secret` | string | false | | | -| `email_domain` | array of string | false | | | -| `email_fallback` | boolean | false | | Email fallback allows OIDC logins to fall back to email-based matching when the `linked_id` (issuer+subject) does not match an existing user link. INSECURE: weakens the linked_id check. It exists for IdP brokers that do not issue a stable `sub` for the same user across connections. | -| `email_field` | string | false | | | -| `group_allow_list` | array of string | false | | | -| `group_auto_create` | boolean | false | | | -| `group_mapping` | object | false | | | -| `group_regex_filter` | [serpent.Regexp](#serpentregexp) | false | | | -| `groups_field` | string | false | | | -| `icon_url` | [serpent.URL](#serpenturl) | false | | | -| `ignore_email_verified` | boolean | false | | | -| `ignore_user_info` | boolean | false | | Ignore user info & UserInfoFromAccessToken are mutually exclusive. Only 1 can be set to true. Ideally this would be an enum with 3 states, ['none', 'userinfo', 'access_token']. However, for backward compatibility, `ignore_user_info` must remain. And `access_token` is a niche, non-spec compliant edge case. So it's use is rare, and should not be advised. | -| `issuer_url` | string | false | | | -| `name_field` | string | false | | | -| `organization_assign_default` | boolean | false | | | -| `organization_field` | string | false | | | -| `organization_mapping` | object | false | | | -| `redirect_url` | [serpent.URL](#serpenturl) | false | | Redirect URL is optional, defaulting to 'ACCESS_URL'. Only useful in niche situations where the OIDC callback domain is different from the ACCESS_URL domain. | -| `scopes` | array of string | false | | | -| `sign_in_text` | string | false | | | -| `signups_disabled_text` | string | false | | | -| `skip_issuer_checks` | boolean | false | | | -| `source_user_info_from_access_token` | boolean | false | | Source user info from access token as mentioned above is an edge case. This allows sourcing the user_info from the access token itself instead of a user_info endpoint. This assumes the access token is a valid JWT with a set of claims to be merged with the id_token. | -| `user_role_field` | string | false | | | -| `user_role_mapping` | object | false | | | -| `user_roles_default` | array of string | false | | | -| `username_field` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------------------------|----------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `allow_signups` | boolean | false | | | +| `auth_url_params` | object | false | | | +| `auto_repair_links` | boolean | false | | | +| `client_cert_file` | string | false | | | +| `client_id` | string | false | | | +| `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. | +| `client_secret` | string | false | | | +| `email_domain` | array of string | false | | | +| `email_fallback` | boolean | false | | Email fallback allows OIDC logins to fall back to email-based matching when the `linked_id` (issuer+subject) does not match an existing user link. INSECURE: weakens the linked_id check. It exists for IdP brokers that do not issue a stable `sub` for the same user across connections. | +| `email_field` | string | false | | | +| `group_allow_list` | array of string | false | | | +| `group_auto_create` | boolean | false | | | +| `group_mapping` | object | false | | | +| `group_regex_filter` | [serpent.Regexp](#serpentregexp) | false | | | +| `groups_field` | string | false | | | +| `icon_url` | [serpent.URL](#serpenturl) | false | | | +| `ignore_email_verified` | boolean | false | | | +| `ignore_user_info` | boolean | false | | Ignore user info & UserInfoFromAccessToken are mutually exclusive. Only 1 can be set to true. Ideally this would be an enum with 3 states, ['none', 'userinfo', 'access_token']. However, for backward compatibility, `ignore_user_info` must remain. And `access_token` is a niche, non-spec compliant edge case. So it's use is rare, and should not be advised. | +| `issuer_url` | string | false | | | +| `name_field` | string | false | | | +| `organization_assign_default` | boolean | false | | | +| `organization_field` | string | false | | | +| `organization_mapping` | object | false | | | +| `redirect_allowed_hosts` | array of string | false | | Redirect allowed hosts is an allowlist of hostnames that may be used as the host of the OIDC redirect_uri. When non-empty, the redirect_uri is constructed from the incoming request's Host header (validated against this list) instead of from AccessURL. Every listed host must also be registered as a valid redirect URI in the OIDC provider. This setting is mutually exclusive with RedirectURL: if RedirectURL is set, this allowlist is ignored. | +| `redirect_url` | [serpent.URL](#serpenturl) | false | | Redirect URL is optional, defaulting to 'ACCESS_URL'. Only useful in niche situations where the OIDC callback domain is different from the ACCESS_URL domain. | +| `scopes` | array of string | false | | | +| `sign_in_text` | string | false | | | +| `signups_disabled_text` | string | false | | | +| `skip_issuer_checks` | boolean | false | | | +| `source_user_info_from_access_token` | boolean | false | | Source user info from access token as mentioned above is an edge case. This allows sourcing the user_info from the access token itself instead of a user_info endpoint. This assumes the access token is a valid JWT with a set of claims to be merged with the id_token. | +| `user_role_field` | string | false | | | +| `user_role_mapping` | object | false | | | +| `user_roles_default` | array of string | false | | | +| `username_field` | string | false | | | ## codersdk.OptionType diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cd7ca431860..634cd4fb0a6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6111,6 +6111,15 @@ export const OAuth2ProviderResponseTypes: OAuth2ProviderResponseType[] = [ */ export const OAuth2RedirectCookie = "oauth_redirect"; +// From codersdk/client.go +/** + * OAuth2RedirectURICookie stores the dynamically computed OIDC redirect_uri + * when CODER_OIDC_REDIRECT_ALLOWED_HOSTS is enabled. The same value must be + * used for both the authorization request and the token exchange (RFC 6749 + * section 4.1.3). + */ +export const OAuth2RedirectURICookie = "oauth_redirect_uri"; + // From codersdk/oauth2.go export type OAuth2RevocationTokenTypeHint = "access_token" | "refresh_token"; @@ -6278,6 +6287,16 @@ export interface OIDCConfig { * connections. */ readonly email_fallback: boolean; + /** + * RedirectAllowedHosts is an allowlist of hostnames that may be used as + * the host of the OIDC redirect_uri. When non-empty, the redirect_uri is + * constructed from the incoming request's Host header (validated against + * this list) instead of from AccessURL. Every listed host must also be + * registered as a valid redirect URI in the OIDC provider. This setting + * is mutually exclusive with RedirectURL: if RedirectURL is set, this + * allowlist is ignored. + */ + readonly redirect_allowed_hosts: string; } // From codersdk/parameters.go