From 0e5334218ddb019d104c0376e92ac8f7921927b6 Mon Sep 17 00:00:00 2001 From: gavin mcdonough Date: Wed, 18 Feb 2026 18:02:15 -0500 Subject: [PATCH] feat: add /oauth2/auth/sign_in endpoint for auth-check with sign-in redirect Add a new endpoint that returns 202 when authenticated (like /oauth2/auth) but redirects to the sign-in flow when unauthenticated (like /). This simplifies reverse proxy configurations (e.g., Traefik ForwardAuth) by combining auth checking and sign-in redirect in a single endpoint, eliminating the need for a separate errors middleware. Signed-off-by: gavin mcdonough --- .../configuration/integrations/traefik.md | 65 ++++++ docs/docs/features/endpoints.md | 14 ++ oauthproxy.go | 51 ++++- oauthproxy_test.go | 206 ++++++++++++++++++ 4 files changed, 332 insertions(+), 4 deletions(-) diff --git a/docs/docs/configuration/integrations/traefik.md b/docs/docs/configuration/integrations/traefik.md index e4b64b94..9cbdcefe 100644 --- a/docs/docs/configuration/integrations/traefik.md +++ b/docs/docs/configuration/integrations/traefik.md @@ -179,6 +179,71 @@ http: - Authorization ``` +### ForwardAuth with `/oauth2/auth/sign_in` endpoint + +The `/oauth2/auth/sign_in` endpoint combines auth checking and sign-in redirect in a single endpoint, eliminating the need for the `errors` middleware. It returns 202 Accepted when authenticated and authorized, 403 Forbidden when authenticated but not authorized, or redirects to the sign-in flow when unauthenticated. + +**Following options need to be set on `oauth2-proxy`:** +- `--skip-provider-button=true`: Recommended so that unauthenticated requests redirect directly to the OAuth provider instead of showing the sign-in page +- `--reverse-proxy=true`: Enables the use of `X-Forwarded-*` headers to determine redirects correctly + +```yaml +http: + routers: + a-service: + rule: "Host(`a-service.example.com`)" + service: a-service-backend + middlewares: + - oauth-auth-signin + tls: + certResolver: default + domains: + - main: "example.com" + sans: + - "*.example.com" + oauth: + rule: "Host(`a-service.example.com`, `oauth.example.com`) && PathPrefix(`/oauth2/`)" + middlewares: + - auth-headers + service: oauth-backend + tls: + certResolver: default + domains: + - main: "example.com" + sans: + - "*.example.com" + + services: + a-service-backend: + loadBalancer: + servers: + - url: http://172.16.0.2:7555 + oauth-backend: + loadBalancer: + servers: + - url: http://172.16.0.1:4180 + + middlewares: + auth-headers: + headers: + sslRedirect: true + stsSeconds: 315360000 + browserXssFilter: true + contentTypeNosniff: true + forceSTSHeader: true + sslHost: example.com + stsIncludeSubdomains: true + stsPreload: true + frameDeny: true + oauth-auth-signin: + forwardAuth: + address: https://oauth.example.com/oauth2/auth/sign_in + trustForwardHeader: true + authResponseHeaders: + - X-Auth-Request-Access-Token + - Authorization +``` + :::note If you set up your OAuth2 provider to rotate your client secret, you can use the `client-secret-file` option to reload the secret when it is updated. ::: diff --git a/docs/docs/features/endpoints.md b/docs/docs/features/endpoints.md index 5befce18..ae75c3cc 100644 --- a/docs/docs/features/endpoints.md +++ b/docs/docs/features/endpoints.md @@ -16,6 +16,7 @@ OAuth2 Proxy responds directly to the following endpoints. All other endpoints w - /oauth2/callback - the URL used at the end of the OAuth cycle. The oauth app will be configured with this as the callback url. - /oauth2/userinfo - the URL is used to return user's email from the session in JSON format. - /oauth2/auth - only returns a 202 Accepted response or a 401 Unauthorized response; for use with the [Nginx `auth_request` directive](../configuration/integrations/nginx) +- /oauth2/auth/sign_in - returns a 202 Accepted response when authenticated, or redirects to the sign-in flow when unauthenticated; for use with the [Traefik `ForwardAuth` middleware](../configuration/integrations/traefik) - /oauth2/static/\* - stylesheets and other dependencies used in the sign_in and error pages ### Sign out @@ -47,6 +48,19 @@ It can be configured using the following query parameters: - `allowed_email_domains`: comma separated list of allowed email domains - `allowed_emails`: comma separated list of allowed emails +### Auth Sign In + +This endpoint returns a 202 Accepted response when authenticated and authorized, or redirects to the sign-in flow when there is no valid session. If the user is authenticated but fails authorization checks (e.g., not in an allowed group), it returns 403 Forbidden to prevent infinite redirect loops. + +When `--skip-provider-button` is set, unauthenticated requests are redirected directly to the OAuth provider. Otherwise, the oauth2-proxy sign-in page is shown. + +This endpoint is useful with reverse proxies like Traefik that use `ForwardAuth`, as it combines auth checking with sign-in redirect in a single endpoint — no separate error middleware is needed. + +It can be configured using the following query parameters: +- `allowed_groups`: comma separated list of allowed groups +- `allowed_email_domains`: comma separated list of allowed email domains +- `allowed_emails`: comma separated list of allowed emails + ### Proxy (/) This endpoint returns the upstream response if authenticated. diff --git a/oauthproxy.go b/oauthproxy.go index 508084c8..e388f549 100644 --- a/oauthproxy.go +++ b/oauthproxy.go @@ -52,6 +52,7 @@ const ( oauthStartPath = "/start" oauthCallbackPath = "/callback" authOnlyPath = "/auth" + authSignInPath = "/auth/sign_in" userInfoPath = "/userinfo" staticPathPrefix = "/static/" ) @@ -320,13 +321,15 @@ func (p *OAuthProxy) buildServeMux(proxyPrefix string) { // Register the robots path writer r.Path(robotsPath).HandlerFunc(p.pageWriter.WriteRobotsTxt) - // The authonly path should be registered separately to prevent it from getting no-cache headers. - // We do this to allow users to have a short cache (via nginx) of the response to reduce the + // The auth-only paths should be registered separately to prevent them from getting no-cache headers. + // For /auth, this allows users to have a short cache (via nginx) of the response to reduce the // likelihood of multiple requests trying to refresh sessions simultaneously. + // The /auth/sign_in endpoint sets its own no-cache headers internally since its responses are user-specific. r.Path(proxyPrefix + authOnlyPath).Handler(p.sessionChain.ThenFunc(p.AuthOnly)) + r.Path(proxyPrefix + authSignInPath).Handler(p.sessionChain.ThenFunc(p.AuthOnlyWithSignIn)) - // This will register all of the paths under the proxy prefix, except the auth only path so that no cache headers - // are not applied. + // This will register all of the paths under the proxy prefix, except the auth-only paths so that + // no-cache headers are not applied to them. p.buildProxySubrouter(r.PathPrefix(proxyPrefix).Subrouter()) // Register serveHTTP last so it catches anything that isn't already caught earlier. @@ -1007,6 +1010,46 @@ func (p *OAuthProxy) AuthOnly(rw http.ResponseWriter, req *http.Request) { })).ServeHTTP(rw, req) } +// AuthOnlyWithSignIn checks whether the user is currently logged in and +// redirects to sign-in if not. +// +// Returns: +// - 202 Accepted when authenticated and authorized +// - 403 Forbidden when authenticated but not authorized (provider-level or query-param checks) +// - Redirect to sign-in flow (or OAuth provider if SkipProviderButton is set) when no valid session exists +// - 500 Internal Server Error on unexpected errors +func (p *OAuthProxy) AuthOnlyWithSignIn(rw http.ResponseWriter, req *http.Request) { + prepareNoCache(rw) + session, err := p.getAuthenticatedSession(rw, req) + switch { + case err == nil: + if !authOnlyAuthorize(req, session) { + http.Error(rw, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + p.addHeadersForProxying(rw, session) + p.headersChain.Then(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + rw.WriteHeader(http.StatusAccepted) + })).ServeHTTP(rw, req) + case errors.Is(err, ErrNeedsLogin): + logger.Printf("No valid authentication in request. Initiating login.") + if p.SkipProviderButton { + p.doOAuthStart(rw, req, nil) + } else { + p.SignInPage(rw, req, http.StatusForbidden) + } + case errors.Is(err, ErrAccessDenied): + if p.forceJSONErrors { + p.errorJSON(rw, http.StatusForbidden) + } else { + p.ErrorPage(rw, req, http.StatusForbidden, "The session failed authorization checks") + } + default: + logger.Errorf("Unexpected internal error: %v", err) + p.ErrorPage(rw, req, http.StatusInternalServerError, err.Error()) + } +} + // Proxy proxies the user request if the user is authenticated else it prompts // them to authenticate func (p *OAuthProxy) Proxy(rw http.ResponseWriter, req *http.Request) { diff --git a/oauthproxy_test.go b/oauthproxy_test.go index ccabdbbd..3f5e7eb3 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -1404,6 +1404,212 @@ func TestAuthOnlyEndpointSetBasicAuthFalseRequestHeaders(t *testing.T) { assert.Equal(t, 0, len(pcTest.rw.Header().Values("Authorization")), "should not have Authorization header entries") } +func NewAuthOnlyWithSignInEndpointTest(querystring string, modifiers ...OptionsModifier) (*ProcessCookieTest, error) { + pcTest, err := NewProcessCookieTestWithOptionsModifiers(modifiers...) + if err != nil { + return nil, err + } + pcTest.req, _ = http.NewRequest( + "GET", + fmt.Sprintf("%s/auth/sign_in%s", pcTest.opts.ProxyPrefix, querystring), + nil) + return pcTest, nil +} + +func TestAuthOnlyWithSignInEndpointAccepted(t *testing.T) { + test, err := NewAuthOnlyWithSignInEndpointTest("") + if err != nil { + t.Fatal(err) + } + + created := time.Now() + startSession := &sessions.SessionState{ + Email: "michael.bland@gsa.gov", AccessToken: "my_access_token", CreatedAt: &created} + err = test.SaveSession(startSession) + assert.NoError(t, err) + + test.proxy.ServeHTTP(test.rw, test.req) + assert.Equal(t, http.StatusAccepted, test.rw.Code) + bodyBytes, _ := io.ReadAll(test.rw.Body) + assert.Equal(t, "", string(bodyBytes)) + assert.Equal(t, "no-cache, no-store, must-revalidate, max-age=0", test.rw.Header().Get("Cache-Control")) + assert.Equal(t, "0", test.rw.Header().Get("X-Accel-Expires")) +} + +func TestAuthOnlyWithSignInEndpointRedirectOnNoCookie(t *testing.T) { + test, err := NewAuthOnlyWithSignInEndpointTest("", func(opts *options.Options) { + opts.SkipProviderButton = true + }) + if err != nil { + t.Fatal(err) + } + + loginURL, _ := url.Parse("https://accounts.google.com/o/oauth2/v2/auth") + test.proxy.provider = &TestProvider{ + ProviderData: &providers.ProviderData{ + LoginURL: loginURL, + }, + ValidToken: true, + } + + test.proxy.ServeHTTP(test.rw, test.req) + assert.Equal(t, http.StatusFound, test.rw.Code) + location := test.rw.Header().Get("Location") + assert.Contains(t, location, "accounts.google.com") +} + +func TestAuthOnlyWithSignInEndpointSignInPageOnNoCookie(t *testing.T) { + test, err := NewAuthOnlyWithSignInEndpointTest("") + if err != nil { + t.Fatal(err) + } + + test.proxy.ServeHTTP(test.rw, test.req) + assert.Equal(t, http.StatusForbidden, test.rw.Code) + bodyBytes, _ := io.ReadAll(test.rw.Body) + assert.Contains(t, string(bodyBytes), "Sign in") +} + +func TestAuthOnlyWithSignInEndpointRedirectOnExpiration(t *testing.T) { + test, err := NewAuthOnlyWithSignInEndpointTest("", func(opts *options.Options) { + opts.Cookie.Expire = time.Duration(24) * time.Hour + opts.SkipProviderButton = true + }) + if err != nil { + t.Fatal(err) + } + + loginURL, _ := url.Parse("https://accounts.google.com/o/oauth2/v2/auth") + test.proxy.provider = &TestProvider{ + ProviderData: &providers.ProviderData{ + LoginURL: loginURL, + }, + ValidToken: true, + } + + reference := time.Now().Add(time.Duration(25) * time.Hour * -1) + startSession := &sessions.SessionState{ + Email: "michael.bland@gsa.gov", AccessToken: "my_access_token", CreatedAt: &reference} + err = test.SaveSession(startSession) + assert.NoError(t, err) + + test.proxy.ServeHTTP(test.rw, test.req) + assert.Equal(t, http.StatusFound, test.rw.Code) + location := test.rw.Header().Get("Location") + assert.Contains(t, location, "accounts.google.com") +} + +func TestAuthOnlyWithSignInEndpointForbiddenOnEmailValidation(t *testing.T) { + test, err := NewAuthOnlyWithSignInEndpointTest("") + if err != nil { + t.Fatal(err) + } + + created := time.Now() + startSession := &sessions.SessionState{ + Email: "michael.bland@gsa.gov", AccessToken: "my_access_token", CreatedAt: &created} + err = test.SaveSession(startSession) + assert.NoError(t, err) + test.validateUser = false + + test.proxy.ServeHTTP(test.rw, test.req) + assert.Equal(t, http.StatusForbidden, test.rw.Code) +} + +func TestAuthOnlyWithSignInEndpointForbiddenOnGroupAuthorization(t *testing.T) { + test, err := NewAuthOnlyWithSignInEndpointTest("?allowed_groups=a,b", func(opts *options.Options) { + opts.Providers[0].AllowedGroups = []string{"a", "b", "c"} + }) + if err != nil { + t.Fatal(err) + } + + created := time.Now() + startSession := &sessions.SessionState{ + Groups: []string{"c"}, + Email: "michael.bland@gsa.gov", + AccessToken: "my_access_token", + CreatedAt: &created, + } + err = test.SaveSession(startSession) + assert.NoError(t, err) + + test.proxy.ServeHTTP(test.rw, test.req) + assert.Equal(t, http.StatusForbidden, test.rw.Code) + bodyBytes, _ := io.ReadAll(test.rw.Body) + assert.Equal(t, "Forbidden\n", string(bodyBytes)) +} + +func TestAuthOnlyWithSignInEndpointSetXAuthRequestHeaders(t *testing.T) { + var pcTest ProcessCookieTest + + pcTest.opts = baseTestOptions() + pcTest.opts.InjectResponseHeaders = []options.Header{ + { + Name: "X-Auth-Request-User", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "user", + }, + }, + }, + }, + { + Name: "X-Auth-Request-Email", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "email", + }, + }, + }, + }, + { + Name: "X-Auth-Request-Groups", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "groups", + }, + }, + }, + }, + } + pcTest.opts.Providers[0].AllowedGroups = []string{"oauth_groups"} + err := validation.Validate(pcTest.opts) + assert.NoError(t, err) + + pcTest.proxy, err = NewOAuthProxy(pcTest.opts, func(email string) bool { + return pcTest.validateUser + }) + if err != nil { + t.Fatal(err) + } + pcTest.proxy.provider = &TestProvider{ + ProviderData: &providers.ProviderData{}, + ValidToken: true, + } + + pcTest.validateUser = true + + pcTest.rw = httptest.NewRecorder() + pcTest.req, _ = http.NewRequest("GET", + pcTest.opts.ProxyPrefix+authSignInPath, nil) + + created := time.Now() + startSession := &sessions.SessionState{ + User: "oauth_user", Groups: []string{"oauth_groups"}, Email: "oauth_user@example.com", AccessToken: "oauth_token", CreatedAt: &created} + err = pcTest.SaveSession(startSession) + assert.NoError(t, err) + + pcTest.proxy.ServeHTTP(pcTest.rw, pcTest.req) + assert.Equal(t, http.StatusAccepted, pcTest.rw.Code) + assert.Equal(t, "oauth_user", pcTest.rw.Header().Get("X-Auth-Request-User")) + assert.Equal(t, startSession.Groups, pcTest.rw.Header().Values("X-Auth-Request-Groups")) + assert.Equal(t, "oauth_user@example.com", pcTest.rw.Header().Get("X-Auth-Request-Email")) +} + func TestAuthSkippedForPreflightRequests(t *testing.T) { upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200)