diff --git a/authentik/policies/views.py b/authentik/policies/views.py index 4d5d9adb2f85..ece35ef63a09 100644 --- a/authentik/policies/views.py +++ b/authentik/policies/views.py @@ -68,13 +68,20 @@ def resolve_provider_application(self): is not caught, and will return directly""" raise NotImplementedError + def supports_cors_preflight_requests(self) -> bool: + """If true, OPTIONS requests will be answered without authentication or permission checks. + This is useful for CORS preflight requests.""" + return hasattr(self, "options") and callable(self.options) + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + # Validate request before doing anything else try: self.pre_permission_check() except RequestValidationError as exc: if exc.response: return exc.response return self.handle_no_permission() + try: self.resolve_provider_application() except (Application.DoesNotExist, Provider.DoesNotExist) as exc: @@ -82,14 +89,19 @@ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespo return self.handle_no_permission_authenticated( PolicyResult(False, _("Failed to resolve application")) ) - # Check if user is unauthenticated, so we pass the application - # for the identification stage - if not request.user.is_authenticated: - return self.handle_no_permission() - # Check permissions - result = self.user_has_access() - if not result.passing: - return self.handle_no_permission_authenticated(result) + + # CORS preflight requests should not require authentication + if request.method != "OPTIONS" or not self.supports_cors_preflight_requests(): + # Check if user is unauthenticated, so we pass the application + # for the identification stage + if not request.user.is_authenticated: + return self.handle_no_permission() + + # Check permissions + result = self.user_has_access() + if not result.passing: + return self.handle_no_permission_authenticated(result) + return super().dispatch(request, *args, **kwargs) def handle_no_permission(self) -> HttpResponse: diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index 433d3175187d..bede1a6853c2 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -62,7 +62,7 @@ ResponseTypes, ScopeMapping, ) -from authentik.providers.oauth2.utils import HttpResponseRedirectScheme +from authentik.providers.oauth2.utils import HttpResponseRedirectScheme, TokenResponse, cors_allow from authentik.providers.oauth2.views.userinfo import UserInfoView from authentik.stages.consent.models import ConsentMode, ConsentStage from authentik.stages.consent.stage import ( @@ -347,6 +347,10 @@ class AuthorizationFlowInitView(BufferedPolicyAccessView): def pre_permission_check(self): """Check prompt parameter before checking permission/authentication, see https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.2.6""" + # Allow unauthenticated CORS preflight requests + if self.request.method == "OPTIONS": + return + # Quick sanity check at the beginning to prevent event spamming if len(self.request.GET) < 1: raise Http404 @@ -422,14 +426,31 @@ def dispatch_with_language(self, request: HttpRequest, *args, **kwargs) -> HttpR response["Location"] = urlunparse( parsed_url._replace(query=urlencode(args, quote_via=quote, doseq=True)) ) + + # Add CORS headers based on the provider's redirect URIs, if a provider exists and has + # redirect URIs configured + allowed_origins = [] + if hasattr(self, "provider") and self.provider and hasattr(self.provider, "redirect_uris"): + allowed_origins = [x.url for x in self.provider.redirect_uris] + cors_allow(self.request, response, *allowed_origins) + + # Override Access-Control-Allow-Methods to only allow GET and OPTIONS + # POST is not defined for this endpoint + if "Access-Control-Allow-Methods" in response: + response["Access-Control-Allow-Methods"] = "GET, OPTIONS" + return response def dispatch(self, request: HttpRequest, *args, **kwargs): # Activate language before parsing params (error messages should be localised) return self.dispatch_with_language(request, *args, **kwargs) + def options(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + # Return an empty response. The dispatch method will add the CORS headers. + return TokenResponse({}) + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: - """Start FlowPLanner, return to flow executor shell""" + """Start FlowPlanner, return to flow executor shell""" # Require a login event to be set, otherwise make the user re-login login_event = get_login_event(request) if not login_event: diff --git a/internal/outpost/proxyv2/application/application.go b/internal/outpost/proxyv2/application/application.go index 49239fbefd25..36aa83141f3a 100644 --- a/internal/outpost/proxyv2/application/application.go +++ b/internal/outpost/proxyv2/application/application.go @@ -45,6 +45,8 @@ type Application struct { outpostName string sessionName string + corsOrigin string + sessions sessions.Store proxyConfig api.ProxyOutpostConfig httpClient *http.Client @@ -79,6 +81,18 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old return nil, fmt.Errorf("failed to parse URL, skipping provider") } + // Build a URL with just the scheme, host, and port for CORS use + corsURL := &url.URL{ + Scheme: externalHost.Scheme, + Host: externalHost.Host, + } + + if err := setUrlPort(corsURL); err != nil { + return nil, fmt.Errorf("failed to set CORS origin URL port: %w", err) + } + + corsOrigin := strings.ToLower(corsURL.String()) + var ks oidc.KeySet if contains(p.OidcConfiguration.IdTokenSigningAlgValuesSupported, "HS256") { ks = hs256.NewKeySet(*p.ClientSecret) @@ -139,6 +153,7 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old endpoint: endpoint, oauthConfig: oauth2Config, tokenVerifier: verifier, + corsOrigin: corsOrigin, proxyConfig: p, httpClient: c, publicHostHTTPClient: publicHTTPClient, @@ -317,3 +332,90 @@ func (a *Application) handleSignOut(rw http.ResponseWriter, r *http.Request) { } http.Redirect(rw, r, redirect, http.StatusFound) } + +func (a *Application) sendCORSPreflightResponse(rw http.ResponseWriter, r *http.Request, options ...string) { + a.addCORSHeaders(rw, r) + + // Allow all headers that the client requested + requestedHeaders := r.Header.Get("Access-Control-Request-Headers") + if requestedHeaders != "" { + rw.Header().Set("Access-Control-Allow-Headers", requestedHeaders) + } + + // Allow whatever method the client requested + requestedMethod := r.Header.Get("Access-Control-Request-Method") + if requestedMethod != "" { + rw.Header().Set("Access-Control-Allow-Methods", requestedMethod) + } +} + +// addCORSHeaders adds headers to the response to allow CORS requests to work properly. Without this, redirect responses +// to CORS requests will fail in the browser. +// These should be set both on preflight (OPTIONS) requests and actual authenticated requests. +func (a *Application) addCORSHeaders(rw http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin != "" { + rw.Header().Set("Access-Control-Allow-Origin", origin) + } + + rw.Header().Set("Vary", "Origin") + rw.Header().Set("Access-Control-Allow-Credentials", "true") + + a.log.WithField("origin", origin).Trace("added CORS headers to response") +} + +// isCORSPreflightRequest returns true if the request is a CORS preflight request, false otherwise. +// CORS preflight requests should be allowed to pass through unauthenticated. +func (a *Application) isCORSPreflightRequest(r *http.Request) bool { + if r.Method != http.MethodOptions { + a.log.WithField("method", r.Method).Trace("not an OPTIONS request, skipping CORS preflight check") + return false + } + + if !a.isOriginAllowed(r) { + a.log.Trace("request origin is not allowed, skipping CORS preflight check") + return false + } + + return true +} + +// isOriginAllowed checks if the request's Origin header matches the application's configured CORS origin. +// If the Origin header is not present or is malformed, it returns false. +func (a *Application) isOriginAllowed(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + a.log.Trace("no origin header present, skipping CORS preflight check") + return false + } + + // The origin header can be a literal "null" when the request is coming from the same origin, e.g. from + // https://app.example.com to https://app.example.com/outpost.goauthentik.io/callback. Because the forward + // auth setup requires that the '/outpost.goauthentik.io' path is under the same origin as the application, + // and because endpoints under that path are called in a CORS "redirect" chain, same origin requests will + // occur. In this case, allow "null" origins to pass through. + originString := "null" + if origin != "null" { + parsedOrigin, err := url.Parse(origin) + if err != nil { + a.log.WithError(err).WithField("origin", origin).Trace("failed to parse origin header") + return false + } + + // Ensure that the port is set for comparison + if err := setUrlPort(parsedOrigin); err != nil { + // This really shouldn't ever happen unless the client uses an unknown protocol + a.log.WithError(err).WithField("origin", origin).Trace("failed to set port for origin header") + return false + } + + originString = strings.ToLower(parsedOrigin.String()) + if a.corsOrigin != originString { + a.log.WithField("origin", origin).WithField("external_host", originString).Trace("origin does not match external host") + return false + } + } + + a.log.WithField("origin", origin).WithField("external_host", originString).Trace("origin matches external host") + return true +} diff --git a/internal/outpost/proxyv2/application/mode_forward.go b/internal/outpost/proxyv2/application/mode_forward.go index 4a2c987d9e5e..5d9755877c67 100644 --- a/internal/outpost/proxyv2/application/mode_forward.go +++ b/internal/outpost/proxyv2/application/mode_forward.go @@ -48,6 +48,14 @@ func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Reque a.handleSignOut(rw, r) return } + + // Check for CORS preflight request and allow it to pass through unauthenticated + if a.isCORSPreflightRequest(r) { + rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) + a.log.Trace("allowing CORS preflight request to pass through unauthenticated") + return + } + // Check if we're authenticated, or the request path is on the allowlist claims, err := a.checkAuth(rw, r) if claims != nil && err == nil { @@ -55,7 +63,9 @@ func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Reque rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") return - } else if claims == nil && a.IsAllowlisted(fwd) { + } + + if claims == nil && a.IsAllowlisted(fwd) { a.log.Trace("path can be accessed without authentication") return } @@ -91,6 +101,14 @@ func (a *Application) forwardHandleCaddy(rw http.ResponseWriter, r *http.Request a.handleSignOut(rw, r) return } + + // Check for CORS preflight request and allow it to pass through unauthenticated + if a.isCORSPreflightRequest(r) { + rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) + a.log.Trace("allowing CORS preflight request to pass through unauthenticated") + return + } + // Check if we're authenticated, or the request path is on the allowlist claims, err := a.checkAuth(rw, r) if claims != nil && err == nil { @@ -98,7 +116,9 @@ func (a *Application) forwardHandleCaddy(rw http.ResponseWriter, r *http.Request rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") return - } else if claims == nil && a.IsAllowlisted(fwd) { + } + + if claims == nil && a.IsAllowlisted(fwd) { a.log.Trace("path can be accessed without authentication") return } @@ -123,6 +143,13 @@ func (a *Application) forwardHandleNginx(rw http.ResponseWriter, r *http.Request return } + // Check for CORS preflight request and allow it to pass through unauthenticated + if a.isCORSPreflightRequest(r) { + rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) + a.log.Trace("allowing CORS preflight request to pass through unauthenticated") + return + } + claims, err := a.checkAuth(rw, r) if claims != nil && err == nil { a.addHeaders(rw.Header(), claims) @@ -130,7 +157,9 @@ func (a *Application) forwardHandleNginx(rw http.ResponseWriter, r *http.Request rw.WriteHeader(200) a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") return - } else if claims == nil && a.IsAllowlisted(fwd) { + } + + if claims == nil && a.IsAllowlisted(fwd) { a.log.Trace("path can be accessed without authentication") return } @@ -158,6 +187,14 @@ func (a *Application) forwardHandleEnvoy(rw http.ResponseWriter, r *http.Request r.URL.Path = strings.TrimPrefix(r.URL.Path, envoyPrefix) r.URL.Host = r.Host fwd := r.URL + + // Check for CORS preflight request and allow it to pass through unauthenticated + if a.isCORSPreflightRequest(r) { + rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) + a.log.Trace("allowing CORS preflight request to pass through unauthenticated") + return + } + // Check if we're authenticated, or the request path is on the allowlist claims, err := a.checkAuth(rw, r) if claims != nil && err == nil { @@ -165,10 +202,13 @@ func (a *Application) forwardHandleEnvoy(rw http.ResponseWriter, r *http.Request rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") return - } else if claims == nil && a.IsAllowlisted(fwd) { + } + + if claims == nil && a.IsAllowlisted(fwd) { a.log.Trace("path can be accessed without authentication") return } + // set the redirect flag to the current URL we have, since we redirect // to a (possibly) different domain, but we want to be redirected back // to the application diff --git a/internal/outpost/proxyv2/application/oauth.go b/internal/outpost/proxyv2/application/oauth.go index 774bf0625a83..61cb467a8e4d 100644 --- a/internal/outpost/proxyv2/application/oauth.go +++ b/internal/outpost/proxyv2/application/oauth.go @@ -26,6 +26,13 @@ func (a *Application) handleAuthStart(rw http.ResponseWriter, r *http.Request, f if err != nil { a.log.WithError(err).Warning("failed to save session") } + + // Add CORS headers if origin is allowed + if a.isOriginAllowed(r) { + a.addCORSHeaders(rw, r) + return + } + http.Redirect(rw, r, a.oauthConfig.AuthCodeURL(state), http.StatusFound) } diff --git a/internal/outpost/proxyv2/application/oauth_callback.go b/internal/outpost/proxyv2/application/oauth_callback.go index ac6462637816..0c589fc8d1dd 100644 --- a/internal/outpost/proxyv2/application/oauth_callback.go +++ b/internal/outpost/proxyv2/application/oauth_callback.go @@ -12,6 +12,13 @@ import ( ) func (a *Application) handleAuthCallback(rw http.ResponseWriter, r *http.Request) { + // Check for CORS preflight request and allow it to pass through unauthenticated + if a.isCORSPreflightRequest(r) { + a.sendCORSPreflightResponse(rw, r) + a.log.Trace("responding to CORS preflight request on callback endpoint") + return + } + state := a.stateFromRequest(r) if state == nil { a.log.Warning("invalid state") diff --git a/internal/outpost/proxyv2/application/utils.go b/internal/outpost/proxyv2/application/utils.go index 248d7ddf0920..8f2f3e3d880a 100644 --- a/internal/outpost/proxyv2/application/utils.go +++ b/internal/outpost/proxyv2/application/utils.go @@ -1,9 +1,12 @@ package application import ( + "fmt" + "net" "net/http" "net/url" "strconv" + "strings" ) func urlJoin(originalUrl string, newPath string) string { @@ -25,6 +28,7 @@ func (a *Application) redirect(rw http.ResponseWriter, r *http.Request) { state.Redirect = fallbackRedirect } a.log.WithField("redirect", state.Redirect).Trace("final redirect") + a.addCORSHeaders(rw, r) http.Redirect(rw, r, state.Redirect, http.StatusFound) } @@ -59,3 +63,33 @@ func cleanseHeaders(headers http.Header) map[string]string { } return h } + +// setUrlPort sets the port of a URL if it is missing, based on the scheme. This is +// useful for URL comparisons, specifically when comparing against the Origin header. +// If the port is already set, or the scheme is unknown, no changes are made. +// If the scheme is unknown, an error is returned. +func setUrlPort(u *url.URL) error { + if u.Port() != "" { + return nil + } + + // This is designed to support the schemes listed here specifically: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Origin#description + switch strings.ToLower(u.Scheme) { + case "http": + u.Host = net.JoinHostPort(u.Host, "80") + case "https": + u.Host = net.JoinHostPort(u.Host, "443") + case "ftp": + u.Host = net.JoinHostPort(u.Host, "21") + case "ws": + u.Host = net.JoinHostPort(u.Host, "80") + case "wss": + u.Host = net.JoinHostPort(u.Host, "443") + case "gopher": + u.Host = net.JoinHostPort(u.Host, "70") + default: + return &url.Error{Op: "setUrlPort", URL: u.String(), Err: fmt.Errorf("unknown scheme: %s", u.Scheme)} + } + + return nil +} diff --git a/website/docs/add-secure-apps/providers/proxy/_envoy_istio.md b/website/docs/add-secure-apps/providers/proxy/_envoy_istio.md index 4aaf21a036a3..b92df7721433 100644 --- a/website/docs/add-secure-apps/providers/proxy/_envoy_istio.md +++ b/website/docs/add-secure-apps/providers/proxy/_envoy_istio.md @@ -16,15 +16,17 @@ spec: port: "9000" pathPrefix: "/outpost.goauthentik.io/auth/envoy" headersToDownstreamOnAllow: - - cookie - headersToUpstreamOnAllow: - set-cookie + headersToUpstreamOnAllow: + - cookie - x-authentik-* # Add authorization headers to the allow list if you need proxy providers which # send a custom HTTP-Basic Authentication header based on values from authentik # - authorization includeRequestHeadersInCheck: - cookie + # Needed for CORS requests to work properly when renewing a proxy ticket + - origin ``` Afterwards, you can create _AuthorizationPolicy_ resources to protect your applications like this: