This commit is contained in:
Gavin McDonough 2026-03-14 10:28:17 +08:00 committed by GitHub
commit cf0f03dc0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 332 additions and 4 deletions

View File

@ -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.
:::

View File

@ -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.

View File

@ -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.
@ -1009,6 +1012,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) {

View File

@ -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)