diff --git a/CHANGELOG.md b/CHANGELOG.md index f2c9b441..bb2ec187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Changes since v7.12.0 +- [#TBD](https://github.com/oauth2-proxy/oauth2-proxy/pull/xxx) feat: add --pass-refresh-token (@carillonator) + # V7.12.0 ## Release Highlights @@ -119,7 +121,7 @@ For detailed information, migration guidance, and security implications, see the - 🕵️‍♀️ Vulnerabilities have been addressed - [CVE-2025-22871](https://github.com/advisories/GHSA-g9pc-8g42-g6vq) - 🐛 Squashed some bugs - + ## Important Notes ## Breaking Changes diff --git a/contrib/oauth2-proxy.cfg.example b/contrib/oauth2-proxy.cfg.example index df16e4cc..5970d9df 100644 --- a/contrib/oauth2-proxy.cfg.example +++ b/contrib/oauth2-proxy.cfg.example @@ -59,6 +59,9 @@ ## Pass OAuth Access token to upstream via "X-Forwarded-Access-Token" # pass_access_token = false +## Pass OAuth Refresh token to upstream via "X-Forwarded-Refresh-Token" +# pass_refresh_token = false + ## Authenticated Email Addresses File (one email per line) # authenticated_emails_file = "" diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index b159df09..ca53ae21 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -143,6 +143,7 @@ Provider specific options can be found on their respective subpages. | flag: `--set-basic-auth`
toml: `set_basic_auth` | bool | set HTTP Basic Auth information in response (useful in Nginx auth_request mode) | false | | flag: `--skip-auth-strip-headers`
toml: `skip_auth_strip_headers` | bool | strips `X-Forwarded-*` style authentication headers & `Authorization` header if they would be set by oauth2-proxy | true | | flag: `--pass-access-token`
toml: `pass_access_token` | bool | pass OAuth access_token to upstream via X-Forwarded-Access-Token header. When used with `--set-xauthrequest` this adds the X-Auth-Request-Access-Token header to the response | false | +| flag: `--pass-refresh-token`
toml: `pass_refresh_token` | bool | pass OAuth refresh_token to upstream via X-Forwarded-Refresh-Token header | false | | flag: `--pass-authorization-header`
toml: `pass_authorization_header` | bool | pass OIDC IDToken to upstream via Authorization Bearer header | false | | flag: `--pass-basic-auth`
toml: `pass_basic_auth` | bool | pass HTTP Basic Auth, X-Forwarded-User, X-Forwarded-Email and X-Forwarded-Preferred-Username information to upstream | true | | flag: `--prefer-email-to-user`
toml: `prefer_email_to_user` | bool | Prefer to use the Email address as the Username when passing information to upstream. Will only use Username if Email is unavailable, e.g. htaccess authentication. Used in conjunction with `--pass-basic-auth` and `--pass-user-headers` | false | diff --git a/oauthproxy_test.go b/oauthproxy_test.go index 488b8cea..ebda2ab3 100644 --- a/oauthproxy_test.go +++ b/oauthproxy_test.go @@ -99,6 +99,19 @@ func (tp *TestProvider) GetEmailAddress(_ context.Context, _ *sessions.SessionSt return tp.EmailAddress, nil } +func (tp *TestProvider) Redeem(ctx context.Context, redirectURL, code, codeVerifier string) (*sessions.SessionState, error) { + // Call the parent Redeem to get the basic session with access_token + session, err := tp.ProviderData.Redeem(ctx, redirectURL, code, codeVerifier) + if err != nil { + return nil, err + } + + session.RefreshToken = "my_refresh_token" + session.IDToken = "my_id_token" + + return session, nil +} + func (tp *TestProvider) ValidateSession(_ context.Context, _ *sessions.SessionState) bool { return tp.ValidToken } @@ -313,20 +326,22 @@ func TestPassGroupsHeadersWithGroups(t *testing.T) { assert.Equal(t, []string{"a,b"}, req.Header["X-Forwarded-Groups"]) } -type PassAccessTokenTest struct { +type PassTokensTest struct { providerServer *httptest.Server proxy *OAuthProxy opts *options.Options } -type PassAccessTokenTestOptions struct { - PassAccessToken bool - ValidToken bool - ProxyUpstream options.Upstream +type PassTokensTestOptions struct { + PassAccessToken bool + PassRefreshToken bool + PassAuthorization bool + ValidToken bool + ProxyUpstream options.Upstream } -func NewPassAccessTokenTest(opts PassAccessTokenTestOptions) (*PassAccessTokenTest, error) { - patt := &PassAccessTokenTest{} +func NewPassTokensTest(opts PassTokensTestOptions) (*PassTokensTest, error) { + patt := &PassTokensTest{} patt.providerServer = httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -334,6 +349,16 @@ func NewPassAccessTokenTest(opts PassAccessTokenTestOptions) (*PassAccessTokenTe switch r.URL.Path { case "/oauth/token": payload = `{"access_token": "my_auth_token"}` + case "/refresh": + payload = r.Header.Get("X-Forwarded-Refresh-Token") + if payload == "" { + payload = "No refresh token found." + } + case "/authorization": + payload = r.Header.Get("Authorization") + if payload == "" { + payload = "No ID token found." + } default: payload = r.Header.Get("X-Forwarded-Access-Token") if payload == "" { @@ -362,21 +387,49 @@ func NewPassAccessTokenTest(opts PassAccessTokenTestOptions) (*PassAccessTokenTe } patt.opts.Cookie.Secure = false + headers := []options.Header{} if opts.PassAccessToken { - patt.opts.InjectRequestHeaders = []options.Header{ - { - Name: "X-Forwarded-Access-Token", - Values: []options.HeaderValue{ - { - ClaimSource: &options.ClaimSource{ - Claim: "access_token", - }, + headers = append(headers, options.Header{ + Name: "X-Forwarded-Access-Token", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "access_token", }, }, }, - } + }) } + if opts.PassRefreshToken { + headers = append(headers, options.Header{ + Name: "X-Forwarded-Refresh-Token", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "refresh_token", + }, + }, + }, + }) + } + + if opts.PassAuthorization { + headers = append(headers, options.Header{ + Name: "Authorization", + Values: []options.HeaderValue{ + { + ClaimSource: &options.ClaimSource{ + Claim: "id_token", + Prefix: "Bearer ", + }, + }, + }, + }) + } + + patt.opts.InjectRequestHeaders = headers + err := validation.Validate(patt.opts) if err != nil { return nil, err @@ -397,11 +450,11 @@ func NewPassAccessTokenTest(opts PassAccessTokenTestOptions) (*PassAccessTokenTe return patt, nil } -func (patTest *PassAccessTokenTest) Close() { +func (patTest *PassTokensTest) Close() { patTest.providerServer.Close() } -func (patTest *PassAccessTokenTest) getCallbackEndpoint() (httpCode int, cookie string) { +func (patTest *PassTokensTest) getCallbackEndpoint() (httpCode int, cookie string) { rw := httptest.NewRecorder() csrf, err := cookies.NewCSRF(patTest.proxy.CookieOptions, "") @@ -439,7 +492,7 @@ func (patTest *PassAccessTokenTest) getCallbackEndpoint() (httpCode int, cookie // getEndpointWithCookie makes a requests againt the oauthproxy with passed requestPath // and cookie and returns body and status code. -func (patTest *PassAccessTokenTest) getEndpointWithCookie(cookie string, endpoint string) (httpCode int, accessToken string) { +func (patTest *PassTokensTest) getEndpointWithCookie(cookie string, endpoint string) (httpCode int, accessToken string) { cookieName := patTest.proxy.CookieOptions.Name var value string keyPrefix := cookieName + "=" @@ -473,7 +526,7 @@ func (patTest *PassAccessTokenTest) getEndpointWithCookie(cookie string, endpoin } func TestForwardAccessTokenUpstream(t *testing.T) { - patTest, err := NewPassAccessTokenTest(PassAccessTokenTestOptions{ + patTest, err := NewPassTokensTest(PassTokensTestOptions{ PassAccessToken: true, ValidToken: true, }) @@ -499,8 +552,64 @@ func TestForwardAccessTokenUpstream(t *testing.T) { assert.Equal(t, "my_auth_token", payload) } +func TestForwardRefreshTokenUpstream(t *testing.T) { + patTest, err := NewPassTokensTest(PassTokensTestOptions{ + PassRefreshToken: true, + ValidToken: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(patTest.Close) + + // A successful validation will redirect and set the auth cookie. + code, cookie := patTest.getCallbackEndpoint() + if code != 302 { + t.Fatalf("expected 302; got %d", code) + } + assert.NotNil(t, cookie) + + // Now we make a regular request; the refresh_token from the cookie is + // forwarded as the "X-Forwarded-Refresh-Token" header. The token is + // read by the test provider server and written in the response body. + code, payload := patTest.getEndpointWithCookie(cookie, "/refresh") + if code != 200 { + t.Fatalf("expected 200; got %d", code) + } + assert.Equal(t, "my_refresh_token", payload) +} + +func TestForwardIDTokenUpstream(t *testing.T) { + patTest, err := NewPassTokensTest(PassTokensTestOptions{ + PassAuthorization: true, + PassAccessToken: true, + PassRefreshToken: true, + ValidToken: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(patTest.Close) + + // A successful validation will redirect and set the auth cookie. + code, cookie := patTest.getCallbackEndpoint() + if code != 302 { + t.Fatalf("expected 302; got %d", code) + } + assert.NotNil(t, cookie) + + // Now we make a regular request; the id_token from the cookie is + // forwarded as the "Authorization" header with Bearer prefix. The token is + // read by the test provider server and written in the response body. + code, payload := patTest.getEndpointWithCookie(cookie, "/authorization") + if code != 200 { + t.Fatalf("expected 200; got %d", code) + } + assert.Equal(t, "Bearer my_id_token", payload) +} + func TestStaticProxyUpstream(t *testing.T) { - patTest, err := NewPassAccessTokenTest(PassAccessTokenTestOptions{ + patTest, err := NewPassTokensTest(PassTokensTestOptions{ PassAccessToken: true, ValidToken: true, ProxyUpstream: options.Upstream{ @@ -531,7 +640,7 @@ func TestStaticProxyUpstream(t *testing.T) { } func TestDoNotForwardAccessTokenUpstream(t *testing.T) { - patTest, err := NewPassAccessTokenTest(PassAccessTokenTestOptions{ + patTest, err := NewPassTokensTest(PassTokensTestOptions{ PassAccessToken: false, ValidToken: true, }) @@ -557,7 +666,7 @@ func TestDoNotForwardAccessTokenUpstream(t *testing.T) { } func TestSessionValidationFailure(t *testing.T) { - patTest, err := NewPassAccessTokenTest(PassAccessTokenTestOptions{ + patTest, err := NewPassTokensTest(PassTokensTestOptions{ ValidToken: false, }) require.NoError(t, err) diff --git a/pkg/apis/options/legacy_options.go b/pkg/apis/options/legacy_options.go index 12975225..f082ce6c 100644 --- a/pkg/apis/options/legacy_options.go +++ b/pkg/apis/options/legacy_options.go @@ -194,6 +194,7 @@ func (l *LegacyUpstreams) convert() (UpstreamConfig, error) { type LegacyHeaders struct { PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"` PassAccessToken bool `flag:"pass-access-token" cfg:"pass_access_token"` + PassRefreshToken bool `flag:"pass-refresh-token" cfg:"pass_refresh_token"` PassUserHeaders bool `flag:"pass-user-headers" cfg:"pass_user_headers"` PassAuthorization bool `flag:"pass-authorization-header" cfg:"pass_authorization_header"` @@ -211,6 +212,7 @@ func legacyHeadersFlagSet() *pflag.FlagSet { flagSet.Bool("pass-basic-auth", true, "pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream") flagSet.Bool("pass-access-token", false, "pass OAuth access_token to upstream via X-Forwarded-Access-Token header") + flagSet.Bool("pass-refresh-token", false, "pass OAuth refresh_token to upstream via X-Forwarded-Refresh-Token header") flagSet.Bool("pass-user-headers", true, "pass X-Forwarded-User and X-Forwarded-Email information to upstream") flagSet.Bool("pass-authorization-header", false, "pass the Authorization Header to upstream") @@ -248,6 +250,10 @@ func (l *LegacyHeaders) getRequestHeaders() []Header { requestHeaders = append(requestHeaders, getPassAccessTokenHeader()) } + if l.PassRefreshToken { + requestHeaders = append(requestHeaders, getPassRefreshTokenHeader()) + } + if l.PassAuthorization { requestHeaders = append(requestHeaders, getAuthorizationHeader()) } @@ -368,6 +374,19 @@ func getPassAccessTokenHeader() Header { } } +func getPassRefreshTokenHeader() Header { + return Header{ + Name: "X-Forwarded-Refresh-Token", + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "refresh_token", + }, + }, + }, + } +} + func getAuthorizationHeader() Header { return Header{ Name: "Authorization", diff --git a/pkg/apis/options/legacy_options_test.go b/pkg/apis/options/legacy_options_test.go index 9481cf95..b73907b9 100644 --- a/pkg/apis/options/legacy_options_test.go +++ b/pkg/apis/options/legacy_options_test.go @@ -400,6 +400,18 @@ var _ = Describe("Legacy Options", func() { }, } + xForwardedRefreshToken := Header{ + Name: "X-Forwarded-Refresh-Token", + PreserveRequestValue: false, + Values: []HeaderValue{ + { + ClaimSource: &ClaimSource{ + Claim: "refresh_token", + }, + }, + }, + } + basicAuthHeaderWithEmail := Header{ Name: "Authorization", PreserveRequestValue: false, @@ -499,6 +511,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: false, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: false, @@ -517,6 +530,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: true, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: false, @@ -543,6 +557,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: true, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: false, @@ -569,6 +584,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: true, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: false, @@ -594,6 +610,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: true, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: true, PassAuthorization: false, @@ -620,6 +637,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: false, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: true, PassAuthorization: false, @@ -643,6 +661,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: false, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: true, PassAuthorization: false, @@ -666,6 +685,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: false, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: false, @@ -689,6 +709,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: false, PassAccessToken: true, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: false, @@ -709,6 +730,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: false, PassAccessToken: true, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: false, @@ -735,6 +757,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: false, PassAccessToken: true, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: false, @@ -751,10 +774,32 @@ var _ = Describe("Legacy Options", func() { }, expectedResponseHeaders: []Header{}, }), + Entry("with passRefreshToken", legacyHeadersTableInput{ + legacyHeaders: &LegacyHeaders{ + PassBasicAuth: false, + PassAccessToken: false, + PassRefreshToken: true, + PassUserHeaders: false, + PassAuthorization: false, + + SetBasicAuth: false, + SetXAuthRequest: false, + SetAuthorization: false, + + PreferEmailToUser: false, + BasicAuthPassword: "", + SkipAuthStripHeaders: true, + }, + expectedRequestHeaders: []Header{ + xForwardedRefreshToken, + }, + expectedResponseHeaders: []Header{}, + }), Entry("with authorization headers", legacyHeadersTableInput{ legacyHeaders: &LegacyHeaders{ PassBasicAuth: false, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: true, @@ -777,6 +822,7 @@ var _ = Describe("Legacy Options", func() { legacyHeaders: &LegacyHeaders{ PassBasicAuth: false, PassAccessToken: false, + PassRefreshToken: false, PassUserHeaders: false, PassAuthorization: true,