From d7ad85f7f64e587a08aaf1dd104ecaffaf86b872 Mon Sep 17 00:00:00 2001 From: Itay Dafna Date: Sat, 4 Jul 2026 21:36:33 -0700 Subject: [PATCH] feat: support multiple OIDC redirect URIs (#25408) This PR adds a new opt-in setting, `CODER_OIDC_REDIRECT_ALLOWED_HOSTS`, that lets a single Coder deployment complete OIDC login on more than one hostname. When the allowlist is non-empty, Coder picks the OIDC `redirect_uri` based on the incoming request's Host header (validated against the list) instead of always using the static URL derived from `CODER_ACCESS_URL`. When unset, the (default) behavior is identical to today. The motivation is that a single Coder deployment is frequently reachable via multiple hostnames - for example, an internal hostname for users on a corporate VPN and a different hostname routed through a zero-trust gateway for users off-VPN - but OIDC login today only works on whichever single hostname `CODER_ACCESS_URL` points to, because the `redirect_uri` sent to the IdP is fixed at server startup. Users who reach the deployment on any other valid hostname can see the login page but fail the OIDC callback, since the IdP redirects them back to a hostname they can't reach (or whose cookies they don't have). --- cli/server.go | 48 ++++-- cli/server_test.go | 59 +++++++ cli/testdata/server-config.yaml.golden | 7 + coderd/apidoc/docs.go | 7 + coderd/apidoc/swagger.json | 7 + coderd/coderd.go | 10 +- coderd/httpmw/oauth2.go | 142 ++++++++++++++++- coderd/httpmw/oauth2_test.go | 213 +++++++++++++++++++++++-- coderd/userauth.go | 13 ++ codersdk/client.go | 5 + codersdk/deployment.go | 24 +++ docs/reference/api/general.md | 3 + docs/reference/api/schemas.md | 80 ++++++---- site/src/api/typesGenerated.ts | 19 +++ 14 files changed, 576 insertions(+), 61 deletions(-) diff --git a/cli/server.go b/cli/server.go index 5b754b88c7..21f26c1dc0 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 3a7d8be4c8..6124a1b0c0 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 dfbed021a9..05aa4738a8 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 86eea40d2c..b423dca3c7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -21377,6 +21377,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 d30e6894da..811633d02b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -19505,6 +19505,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 1d7a18212b..f2d5e6f5df 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -777,8 +777,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) @@ -1115,7 +1119,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)) }) @@ -1665,14 +1669,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 5f12543887..71b20a2f28 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 baedd2cc2f..6638194ab3 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 91e0d0f5e5..3e85536d21 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 834dfa465e..56e43c3514 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 877eb4fa6b..2edd295dbc 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 fa3c245d80..32a968febe 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 879951910b..4d9124782b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -5846,6 +5846,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", @@ -6452,6 +6455,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", @@ -8935,6 +8941,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", @@ -8966,41 +8975,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 09e6ac16cb..84a8a8c3ae 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -6140,6 +6140,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"; @@ -6307,6 +6316,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