From a88306be980a4866edae676f8c976ab94f23eec6 Mon Sep 17 00:00:00 2001 From: Conrad Hoffmann <1226676+bitfehler@users.noreply.github.com> Date: Tue, 22 Jul 2025 08:16:32 +0200 Subject: [PATCH 1/6] feat: add SourceHut (sr.ht) provider (#2359) * Add SourceHut (sr.ht) provider * fix changelog entry Signed-off-by: Jan Larwig --------- Signed-off-by: Jan Larwig Co-authored-by: Jan Larwig --- CHANGELOG.md | 1 + .../docs/configuration/providers/sourcehut.md | 25 ++++ pkg/apis/options/providers.go | 3 + providers/providers.go | 5 +- providers/srht.go | 108 ++++++++++++++++++ providers/srht_test.go | 77 +++++++++++++ 6 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 docs/docs/configuration/providers/sourcehut.md create mode 100644 providers/srht.go create mode 100644 providers/srht_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa456e2..b2ffc50d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - [#2615](https://github.com/oauth2-proxy/oauth2-proxy/pull/2615) feat(cookies): add option to set a limit on the number of per-request CSRF cookies oauth2-proxy sets (@bh-tt) - [#2605](https://github.com/oauth2-proxy/oauth2-proxy/pull/2605) fix: show login page on broken cookie (@Primexz) - [#2743](https://github.com/oauth2-proxy/oauth2-proxy/pull/2743) feat: allow use more possible google admin-sdk api scopes (@BobDu) +- [#2359](https://github.com/oauth2-proxy/oauth2-proxy/pull/2359) feat: add SourceHut (sr.ht) provider(@bitfehler) # V7.10.0 diff --git a/docs/docs/configuration/providers/sourcehut.md b/docs/docs/configuration/providers/sourcehut.md new file mode 100644 index 00000000..88d14622 --- /dev/null +++ b/docs/docs/configuration/providers/sourcehut.md @@ -0,0 +1,25 @@ +--- +id: sourcehut +title: SourceHut +--- + +1. Create a new OAuth client: https://meta.sr.ht/oauth2 +2. Under `Redirection URI` enter the correct URL, i.e. + `https://internal.yourcompany.com/oauth2/callback` + +To use the provider, start with `--provider=sourcehut`. + +If you are hosting your own SourceHut instance, make sure you set the following +to the appropriate URLs: + +```shell + --login-url="https:///oauth2/authorize" + --redeem-url="https:///oauth2/access-token" + --profile-url="https:///query" + --validate-url="https:///profile" +``` + +The default configuration allows everyone with an account to authenticate. +Restricting access is currently only supported by +[email](#email-authentication). + diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 280b1ce0..ac5652ca 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -147,6 +147,9 @@ const ( // OIDCProvider is the provider type for OIDC OIDCProvider ProviderType = "oidc" + + // SourceHutProvider is the provider type for SourceHut + SourceHutProvider ProviderType = "sourcehut" ) type KeycloakOptions struct { diff --git a/providers/providers.go b/providers/providers.go index 3a125a24..8bc5ff88 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -67,6 +67,8 @@ func NewProvider(providerConfig options.Provider) (Provider, error) { return NewNextcloudProvider(providerData), nil case options.OIDCProvider: return NewOIDCProvider(providerData, providerConfig.OIDCConfig), nil + case options.SourceHutProvider: + return NewSourceHutProvider(providerData), nil default: return nil, fmt.Errorf("unknown provider type %q", providerConfig.Type) } @@ -183,7 +185,8 @@ func parseCodeChallengeMethod(providerConfig options.Provider) string { func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bool, error) { switch providerType { case options.BitbucketProvider, options.DigitalOceanProvider, options.FacebookProvider, options.GitHubProvider, - options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider, options.NextCloudProvider: + options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider, + options.NextCloudProvider, options.SourceHutProvider: return false, nil case options.ADFSProvider, options.AzureProvider, options.GitLabProvider, options.KeycloakOIDCProvider, options.OIDCProvider, options.MicrosoftEntraIDProvider: return true, nil diff --git a/providers/srht.go b/providers/srht.go new file mode 100644 index 00000000..aa72229c --- /dev/null +++ b/providers/srht.go @@ -0,0 +1,108 @@ +package providers + +import ( + "bytes" + "context" + "fmt" + "net/url" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" +) + +type SourceHutProvider struct { + *ProviderData +} + +var _ Provider = (*SourceHutProvider)(nil) + +const ( + SourceHutProviderName = "SourceHut" + SourceHutDefaultScope = "meta.sr.ht/PROFILE:RO" +) + +var ( + // Default Login URL for SourceHut. + // Pre-parsed URL of https://meta.sr.ht/oauth2/authorize. + SourceHutDefaultLoginURL = &url.URL{ + Scheme: "https", + Host: "meta.sr.ht", + Path: "/oauth2/authorize", + } + + // Default Redeem URL for SourceHut. + // Pre-parsed URL of https://meta.sr.ht/oauth2/access-token. + SourceHutDefaultRedeemURL = &url.URL{ + Scheme: "https", + Host: "meta.sr.ht", + Path: "/oauth2/access-token", + } + + // Default Profile URL for SourceHut. + // Pre-parsed URL of https://meta.sr.ht/query. + SourceHutDefaultProfileURL = &url.URL{ + Scheme: "https", + Host: "meta.sr.ht", + Path: "/query", + } + + // Default Validation URL for SourceHut. + // Pre-parsed URL of https://meta.sr.ht/profile. + SourceHutDefaultValidateURL = &url.URL{ + Scheme: "https", + Host: "meta.sr.ht", + Path: "/profile", + } +) + +// NewSourceHutProvider creates a SourceHutProvider using the passed ProviderData +func NewSourceHutProvider(p *ProviderData) *SourceHutProvider { + p.setProviderDefaults(providerDefaults{ + name: SourceHutProviderName, + loginURL: SourceHutDefaultLoginURL, + redeemURL: SourceHutDefaultRedeemURL, + profileURL: SourceHutDefaultProfileURL, + validateURL: SourceHutDefaultValidateURL, + scope: SourceHutDefaultScope, + }) + + return &SourceHutProvider{ProviderData: p} +} + +// EnrichSession uses the SourceHut userinfo endpoint to populate the session's +// email and username. +func (p *SourceHutProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { + json, err := requests.New(p.ProfileURL.String()). + WithContext(ctx). + WithMethod("POST"). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", "Bearer "+s.AccessToken). + WithBody(bytes.NewBufferString(`{"query": "{ me { username, email } }"}`)). + Do(). + UnmarshalSimpleJSON() + if err != nil { + logger.Errorf("failed making request %v", err) + return err + } + + email, err := json.GetPath("data", "me", "email").String() + if err != nil { + return fmt.Errorf("unable to extract email from userinfo endpoint: %v", err) + } + s.Email = email + + username, err := json.GetPath("data", "me", "username").String() + if err != nil { + return fmt.Errorf("unable to extract username from userinfo endpoint: %v", err) + } + s.PreferredUsername = username + s.User = username + + return nil +} + +// ValidateSession validates the AccessToken +func (p *SourceHutProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool { + return validateToken(ctx, p, s.AccessToken, makeOIDCHeader(s.AccessToken)) +} diff --git a/providers/srht_test.go b/providers/srht_test.go new file mode 100644 index 00000000..fd51bf7f --- /dev/null +++ b/providers/srht_test.go @@ -0,0 +1,77 @@ +package providers + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func testSourceHutProvider(hostname string) *SourceHutProvider { + p := NewSourceHutProvider( + &ProviderData{ + ProviderName: "SourceHut", + LoginURL: &url.URL{}, + RedeemURL: &url.URL{}, + ProfileURL: &url.URL{}, + ValidateURL: &url.URL{}, + Scope: ""}, + ) + p.ProviderName = "SourceHut" + + if hostname != "" { + updateURL(p.Data().LoginURL, hostname) + updateURL(p.Data().RedeemURL, hostname) + updateURL(p.Data().ProfileURL, hostname) + updateURL(p.Data().ValidateURL, hostname) + } + return p +} + +func testSourceHutBackend(payloads map[string][]string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + index := 0 + payload, ok := payloads[r.URL.Path] + if !ok { + w.WriteHeader(404) + } else if payload[index] == "" { + w.WriteHeader(204) + } else { + w.WriteHeader(200) + w.Write([]byte(payload[index])) + } + })) +} + +func TestSourceHutProvider_ValidateSessionWithBaseUrl(t *testing.T) { + b := testSourceHutBackend(map[string][]string{}) + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testSourceHutProvider(bURL.Host) + + session := CreateAuthorizedSession() + + valid := p.ValidateSession(context.Background(), session) + assert.False(t, valid) +} + +func TestSourceHutProvider_ValidateSessionWithUserEmails(t *testing.T) { + b := testSourceHutBackend(map[string][]string{ + "/query": {`{"data":{"me":{"username":"bitfehler","email":"ch@bitfehler.net"}}}`}, + "/profile": {`ok`}, + }) + defer b.Close() + + bURL, _ := url.Parse(b.URL) + p := testSourceHutProvider(bURL.Host) + + session := CreateAuthorizedSession() + + valid := p.ValidateSession(context.Background(), session) + assert.True(t, valid) +} From 137e59d52668e3fc5d670dfbb6c3d667739e22e3 Mon Sep 17 00:00:00 2001 From: Ashkan Daie <1415513+dashkan@users.noreply.github.com> Date: Mon, 21 Jul 2025 23:52:23 -0700 Subject: [PATCH 2/6] fix: regex substitution for $ signs in upstream path handling before running envsubst (#2524) * Perform a regex replace of $NUM to $$NUM before running envsubst * Perform a regex replace of $NUM to $$NUM before running envsubst * add test case; fix linter warnings; add method documentation Signed-off-by: Jan Larwig * add changelog entry Signed-off-by: Jan Larwig --------- Signed-off-by: Jan Larwig Co-authored-by: Jan Larwig --- CHANGELOG.md | 1 + pkg/apis/options/load.go | 24 ++++++++++++++++++++---- pkg/apis/options/load_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ffc50d..50dbc9b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - [#2605](https://github.com/oauth2-proxy/oauth2-proxy/pull/2605) fix: show login page on broken cookie (@Primexz) - [#2743](https://github.com/oauth2-proxy/oauth2-proxy/pull/2743) feat: allow use more possible google admin-sdk api scopes (@BobDu) - [#2359](https://github.com/oauth2-proxy/oauth2-proxy/pull/2359) feat: add SourceHut (sr.ht) provider(@bitfehler) +-[#2524](https://github.com/oauth2-proxy/oauth2-proxy/pull/2524) fix: regex substitution for $ signs in upstream path handling before running envsubst (@dashkan / @tuunit) # V7.10.0 diff --git a/pkg/apis/options/load.go b/pkg/apis/options/load.go index b198c4ff..c302e8e7 100644 --- a/pkg/apis/options/load.go +++ b/pkg/apis/options/load.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "reflect" + "regexp" "strings" "github.com/a8m/envsubst" @@ -155,7 +156,8 @@ func LoadYAML(configFileName string, into interface{}) error { return nil } -// Performs the heavy lifting of the LoadYaml function +// loadAndParseYaml reads the config from the filesystem and +// execute the environment variable substitution func loadAndParseYaml(configFileName string) ([]byte, error) { if configFileName == "" { return nil, errors.New("no configuration file provided") @@ -166,12 +168,26 @@ func loadAndParseYaml(configFileName string) ([]byte, error) { return nil, fmt.Errorf("unable to load config file: %w", err) } - // We now parse over the yaml with env substring, and fill in the ENV's - buffer, err := envsubst.Bytes(unparsedBuffer) + modifiedBuffer, err := normalizeSubstitution(unparsedBuffer) + if err != nil { + return nil, fmt.Errorf("error normalizing substitution string : %w", err) + } + + buffer, err := envsubst.Bytes(modifiedBuffer) if err != nil { return nil, fmt.Errorf("error in substituting env variables : %w", err) } return buffer, nil - +} + +// normalizeSubstitution normalizes dollar signs ($) with numerals like +// $1 or $2 properly by correctly escaping them +func normalizeSubstitution(unparsedBuffer []byte) ([]byte, error) { + unparsedString := string(unparsedBuffer) + + regexPattern := regexp.MustCompile(`\$(\d+)`) + + substitutedString := regexPattern.ReplaceAllString(unparsedString, `$$$$1`) + return []byte(substitutedString), nil } diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go index fefbc2e7..06123c37 100644 --- a/pkg/apis/options/load_test.go +++ b/pkg/apis/options/load_test.go @@ -487,6 +487,31 @@ sub: StringOption: "Bob", }, }), + Entry("with a config file containing $ signs for things other than environment variables", loadYAMLTableInput{ + configFile: []byte(` +stringOption: /$1 +stringSliceOption: +- /$1 +- ^/(.*)$ +- api/$1 +- api/(.*)$ +- ^/api/(.*)$ +- /api/$1`), + input: &TestOptions{}, + expectedOutput: &TestOptions{ + StringOption: "/$1", + TestOptionSubStruct: TestOptionSubStruct{ + StringSliceOption: []string{ + "/$1", + "^/(.*)$", + "api/$1", + "api/(.*)$", + "^/api/(.*)$", + "/api/$1", + }, + }, + }, + }), ) }) From dc8b1623a26a2537a8d0119e087f2048234c9843 Mon Sep 17 00:00:00 2001 From: Sandy Chen Date: Wed, 23 Jul 2025 01:59:55 +0900 Subject: [PATCH 3/6] feat(cookie): add feature support for cookie-secret-file (#3104) * feat: add feature support for cookie-secret-file --------- Signed-off-by: Jan Larwig Co-Authored-By: Sandy Chen Co-authored-by: Jan Larwig --- CHANGELOG.md | 3 +- docs/docs/configuration/overview.md | 1 + pkg/apis/options/cookie.go | 46 +++++++++++++----- pkg/apis/options/cookie_test.go | 70 ++++++++++++++++++++++++++++ pkg/cookies/csrf.go | 32 +++++++++---- pkg/sessions/cookie/session_store.go | 26 +++++++---- pkg/sessions/persistence/ticket.go | 6 ++- pkg/validation/cookie.go | 27 +++++++++-- pkg/validation/cookie_test.go | 49 ++++++++++++++++++- pkg/validation/options_test.go | 2 +- 10 files changed, 226 insertions(+), 36 deletions(-) create mode 100644 pkg/apis/options/cookie_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 50dbc9b9..ea7b0b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ - [#2605](https://github.com/oauth2-proxy/oauth2-proxy/pull/2605) fix: show login page on broken cookie (@Primexz) - [#2743](https://github.com/oauth2-proxy/oauth2-proxy/pull/2743) feat: allow use more possible google admin-sdk api scopes (@BobDu) - [#2359](https://github.com/oauth2-proxy/oauth2-proxy/pull/2359) feat: add SourceHut (sr.ht) provider(@bitfehler) --[#2524](https://github.com/oauth2-proxy/oauth2-proxy/pull/2524) fix: regex substitution for $ signs in upstream path handling before running envsubst (@dashkan / @tuunit) +- [#2524](https://github.com/oauth2-proxy/oauth2-proxy/pull/2524) fix: regex substitution for $ signs in upstream path handling before running envsubst (@dashkan / @tuunit) +- [#3104](https://github.com/oauth2-proxy/oauth2-proxy/pull/3104) feat(cookie): add feature support for cookie-secret-file (@sandy2008) # V7.10.0 diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md index 6a8f52e5..7c216dfb 100644 --- a/docs/docs/configuration/overview.md +++ b/docs/docs/configuration/overview.md @@ -128,6 +128,7 @@ Provider specific options can be found on their respective subpages. | flag: `--cookie-refresh`
toml: `cookie_refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers [^1] | | | flag: `--cookie-samesite`
toml: `cookie_samesite` | string | set SameSite cookie attribute (`"lax"`, `"strict"`, `"none"`, or `""`). | `""` | | flag: `--cookie-secret`
toml: `cookie_secret` | string | the seed string for secure cookies (optionally base64 encoded) | | +| flag: `--cookie-secret-file`
toml: `cookie_secret_file` | string | For defining a separate cookie secret file to read the encryption key from | | | flag: `--cookie-secure`
toml: `cookie_secure` | bool | set [secure (HTTPS only) cookie flag](https://owasp.org/www-community/controls/SecureFlag) | true | [^1]: The following providers support `--cookie-refresh`: ADFS, Azure, GitLab, Google, Keycloak and all other Identity Providers which support the full [OIDC specification](https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens) diff --git a/pkg/apis/options/cookie.go b/pkg/apis/options/cookie.go index 22b74a6c..3653b7d0 100644 --- a/pkg/apis/options/cookie.go +++ b/pkg/apis/options/cookie.go @@ -1,8 +1,11 @@ package options import ( + "errors" + "os" "time" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" "github.com/spf13/pflag" ) @@ -10,6 +13,7 @@ import ( type Cookie struct { Name string `flag:"cookie-name" cfg:"cookie_name"` Secret string `flag:"cookie-secret" cfg:"cookie_secret"` + SecretFile string `flag:"cookie-secret-file" cfg:"cookie_secret_file"` Domains []string `flag:"cookie-domain" cfg:"cookie_domains"` Path string `flag:"cookie-path" cfg:"cookie_path"` Expire time.Duration `flag:"cookie-expire" cfg:"cookie_expire"` @@ -18,8 +22,8 @@ type Cookie struct { HTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"` SameSite string `flag:"cookie-samesite" cfg:"cookie_samesite"` CSRFPerRequest bool `flag:"cookie-csrf-per-request" cfg:"cookie_csrf_per_request"` - CSRFExpire time.Duration `flag:"cookie-csrf-expire" cfg:"cookie_csrf_expire"` CSRFPerRequestLimit int `flag:"cookie-csrf-per-request-limit" cfg:"cookie_csrf_per_request_limit"` + CSRFExpire time.Duration `flag:"cookie-csrf-expire" cfg:"cookie_csrf_expire"` } func cookieFlagSet() *pflag.FlagSet { @@ -27,6 +31,7 @@ func cookieFlagSet() *pflag.FlagSet { flagSet.String("cookie-name", "_oauth2_proxy", "the name of the cookie that the oauth_proxy creates") flagSet.String("cookie-secret", "", "the seed string for secure cookies (optionally base64 encoded)") + flagSet.String("cookie-secret-file", "", "For defining a separate cookie secret file to read the encryption key from") flagSet.StringSlice("cookie-domain", []string{}, "Optional cookie domains to force cookies to (ie: `.yourcompany.com`). The longest domain matching the request's host will be used (or the shortest cookie domain if there is no match).") flagSet.String("cookie-path", "/", "an optional cookie path to force cookies to (ie: /poc/)*") flagSet.Duration("cookie-expire", time.Duration(168)*time.Hour, "expire timeframe for cookie") @@ -43,16 +48,33 @@ func cookieFlagSet() *pflag.FlagSet { // cookieDefaults creates a Cookie populating each field with its default value func cookieDefaults() Cookie { return Cookie{ - Name: "_oauth2_proxy", - Secret: "", - Domains: nil, - Path: "/", - Expire: time.Duration(168) * time.Hour, - Refresh: time.Duration(0), - Secure: true, - HTTPOnly: true, - SameSite: "", - CSRFPerRequest: false, - CSRFExpire: time.Duration(15) * time.Minute, + Name: "_oauth2_proxy", + Secret: "", + SecretFile: "", + Domains: nil, + Path: "/", + Expire: time.Duration(168) * time.Hour, + Refresh: time.Duration(0), + Secure: true, + HTTPOnly: true, + SameSite: "", + CSRFPerRequest: false, + CSRFPerRequestLimit: 0, + CSRFExpire: time.Duration(15) * time.Minute, } } + +// GetSecret returns the cookie secret, reading from file if SecretFile is set +func (c *Cookie) GetSecret() (secret string, err error) { + if c.Secret != "" || c.SecretFile == "" { + return c.Secret, nil + } + + fileSecret, err := os.ReadFile(c.SecretFile) + if err != nil { + logger.Errorf("error reading cookie secret file %s: %s", c.SecretFile, err) + return "", errors.New("could not read cookie secret file") + } + + return string(fileSecret), nil +} diff --git a/pkg/apis/options/cookie_test.go b/pkg/apis/options/cookie_test.go new file mode 100644 index 00000000..a1486fed --- /dev/null +++ b/pkg/apis/options/cookie_test.go @@ -0,0 +1,70 @@ +package options + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCookieGetSecret(t *testing.T) { + t.Run("returns secret when Secret is set", func(t *testing.T) { + c := &Cookie{ + Secret: "my-secret", + SecretFile: "", + } + secret, err := c.GetSecret() + assert.NoError(t, err) + assert.Equal(t, "my-secret", secret) + }) + + t.Run("returns secret when both Secret and SecretFile are set", func(t *testing.T) { + c := &Cookie{ + Secret: "my-secret", + SecretFile: "/some/file", + } + secret, err := c.GetSecret() + assert.NoError(t, err) + assert.Equal(t, "my-secret", secret) + }) + + t.Run("reads from file when only SecretFile is set", func(t *testing.T) { + // Create a temporary file + tmpfile, err := os.CreateTemp("", "cookie-secret-test") + assert.NoError(t, err) + defer os.Remove(tmpfile.Name()) + + _, err = tmpfile.Write([]byte("file-secret")) + assert.NoError(t, err) + tmpfile.Close() + + c := &Cookie{ + Secret: "", + SecretFile: tmpfile.Name(), + } + secret, err := c.GetSecret() + assert.NoError(t, err) + assert.Equal(t, "file-secret", secret) + }) + + t.Run("returns error when file does not exist", func(t *testing.T) { + c := &Cookie{ + Secret: "", + SecretFile: "/nonexistent/file", + } + secret, err := c.GetSecret() + assert.Error(t, err) + assert.Equal(t, "", secret) + assert.Contains(t, err.Error(), "could not read cookie secret file") + }) + + t.Run("returns empty when both Secret and SecretFile are empty", func(t *testing.T) { + c := &Cookie{ + Secret: "", + SecretFile: "", + } + secret, err := c.GetSecret() + assert.NoError(t, err) + assert.Equal(t, "", secret) + }) +} diff --git a/pkg/cookies/csrf.go b/pkg/cookies/csrf.go index eab87869..3b8efaf3 100644 --- a/pkg/cookies/csrf.go +++ b/pkg/cookies/csrf.go @@ -219,13 +219,22 @@ func (c *csrf) encodeCookie() (string, error) { return "", err } - return encryption.SignedValue(c.cookieOpts.Secret, c.cookieName(), encrypted, c.time.Now()) + secret, err := c.cookieOpts.GetSecret() + if err != nil { + return "", fmt.Errorf("error getting cookie secret: %v", err) + } + return encryption.SignedValue(secret, c.cookieName(), encrypted, c.time.Now()) } // decodeCSRFCookie validates the signature then decrypts and decodes a CSRF // cookie into a CSRF struct func decodeCSRFCookie(cookie *http.Cookie, opts *options.Cookie) (*csrf, error) { - val, t, ok := encryption.Validate(cookie, opts.Secret, opts.Expire) + secret, err := opts.GetSecret() + if err != nil { + return nil, fmt.Errorf("error getting cookie secret: %v", err) + } + + val, t, ok := encryption.Validate(cookie, secret, opts.Expire) if !ok { return nil, errors.New("CSRF cookie failed validation") } @@ -235,15 +244,18 @@ func decodeCSRFCookie(cookie *http.Cookie, opts *options.Cookie) (*csrf, error) return nil, err } - // Valid cookie, Unmarshal the CSRF + return unmarshalCSRF(decrypted, opts, t) +} + +// unmarshalCSRF unmarshals decrypted data into a CSRF struct +func unmarshalCSRF(decrypted []byte, opts *options.Cookie, csrfTime time.Time) (*csrf, error) { clock := clock.Clock{} - clock.Set(t) + clock.Set(csrfTime) + csrf := &csrf{cookieOpts: opts, time: clock} - err = msgpack.Unmarshal(decrypted, csrf) - if err != nil { + if err := msgpack.Unmarshal(decrypted, csrf); err != nil { return nil, fmt.Errorf("error unmarshalling data to CSRF: %v", err) } - return csrf, nil } @@ -290,5 +302,9 @@ func decrypt(data []byte, opts *options.Cookie) ([]byte, error) { } func makeCipher(opts *options.Cookie) (encryption.Cipher, error) { - return encryption.NewCFBCipher(encryption.SecretBytes(opts.Secret)) + secret, err := opts.GetSecret() + if err != nil { + return nil, fmt.Errorf("error getting cookie secret: %v", err) + } + return encryption.NewCFBCipher(encryption.SecretBytes(secret)) } diff --git a/pkg/sessions/cookie/session_store.go b/pkg/sessions/cookie/session_store.go index 3947177f..095bc0e7 100644 --- a/pkg/sessions/cookie/session_store.go +++ b/pkg/sessions/cookie/session_store.go @@ -54,16 +54,18 @@ func (s *SessionStore) Load(req *http.Request) (*sessions.SessionState, error) { // always http.ErrNoCookie return nil, err } - val, _, ok := encryption.Validate(c, s.Cookie.Secret, s.Cookie.Expire) + + secret, err := s.Cookie.GetSecret() + if err != nil { + return nil, fmt.Errorf("error getting cookie secret: %v", err) + } + + val, _, ok := encryption.Validate(c, secret, s.Cookie.Expire) if !ok { return nil, errors.New("cookie signature not valid") } - session, err := sessions.DecodeSessionState(val, s.CookieCipher, true) - if err != nil { - return nil, err - } - return session, nil + return sessions.DecodeSessionState(val, s.CookieCipher, true) } // Clear clears any saved session information by writing a cookie to @@ -121,7 +123,11 @@ func (s *SessionStore) makeSessionCookie(req *http.Request, value []byte, now ti strValue := string(value) if strValue != "" { var err error - strValue, err = encryption.SignedValue(s.Cookie.Secret, s.Cookie.Name, value, now) + secret, err := s.Cookie.GetSecret() + if err != nil { + return nil, fmt.Errorf("error getting cookie secret: %v", err) + } + strValue, err = encryption.SignedValue(secret, s.Cookie.Name, value, now) if err != nil { return nil, err } @@ -146,7 +152,11 @@ func (s *SessionStore) makeCookie(req *http.Request, name string, value string, // NewCookieSessionStore initialises a new instance of the SessionStore from // the configuration given func NewCookieSessionStore(opts *options.SessionOptions, cookieOpts *options.Cookie) (sessions.SessionStore, error) { - cipher, err := encryption.NewCFBCipher(encryption.SecretBytes(cookieOpts.Secret)) + secret, err := cookieOpts.GetSecret() + if err != nil { + return nil, fmt.Errorf("error getting cookie secret: %v", err) + } + cipher, err := encryption.NewCFBCipher(encryption.SecretBytes(secret)) if err != nil { return nil, fmt.Errorf("error initialising cipher: %v", err) } diff --git a/pkg/sessions/persistence/ticket.go b/pkg/sessions/persistence/ticket.go index 581a7f45..7855db45 100644 --- a/pkg/sessions/persistence/ticket.go +++ b/pkg/sessions/persistence/ticket.go @@ -146,7 +146,11 @@ func decodeTicketFromRequest(req *http.Request, cookieOpts *options.Cookie) (*ti } // An existing cookie exists, try to retrieve the ticket - val, _, ok := encryption.Validate(requestCookie, cookieOpts.Secret, cookieOpts.Expire) + secret, err := cookieOpts.GetSecret() + if err != nil { + return nil, fmt.Errorf("error getting cookie secret: %v", err) + } + val, _, ok := encryption.Validate(requestCookie, secret, cookieOpts.Expire) if !ok { return nil, fmt.Errorf("session ticket cookie failed validation: %v", err) } diff --git a/pkg/validation/cookie.go b/pkg/validation/cookie.go index b515809d..5f2dd8ac 100644 --- a/pkg/validation/cookie.go +++ b/pkg/validation/cookie.go @@ -3,6 +3,7 @@ package validation import ( "fmt" "net/http" + "os" "sort" "time" @@ -11,7 +12,7 @@ import ( ) func validateCookie(o options.Cookie) []string { - msgs := validateCookieSecret(o.Secret) + msgs := validateCookieSecret(o.Secret, o.SecretFile) if o.Expire != time.Duration(0) && o.Refresh >= o.Expire { msgs = append(msgs, fmt.Sprintf( @@ -49,9 +50,27 @@ func validateCookieName(name string) []string { return msgs } -func validateCookieSecret(secret string) []string { - if secret == "" { - return []string{"missing setting: cookie-secret"} +func validateCookieSecret(secret string, secretFile string) []string { + if secret == "" && secretFile == "" { + return []string{"missing setting: cookie-secret or cookie-secret-file"} + } + if secret == "" && secretFile != "" { + fileData, err := os.ReadFile(secretFile) + if err != nil { + return []string{"could not read cookie secret file: " + secretFile} + } + // Validate the file content as a secret + secretBytes := encryption.SecretBytes(string(fileData)) + switch len(secretBytes) { + case 16, 24, 32: + // Valid secret size found + return []string{} + } + // Invalid secret size found, return a message + return []string{fmt.Sprintf( + "cookie_secret from file must be 16, 24, or 32 bytes to create an AES cipher, but is %d bytes", + len(secretBytes)), + } } secretBytes := encryption.SecretBytes(secret) diff --git a/pkg/validation/cookie_test.go b/pkg/validation/cookie_test.go index 1f0dc5cd..d11134da 100644 --- a/pkg/validation/cookie_test.go +++ b/pkg/validation/cookie_test.go @@ -1,6 +1,7 @@ package validation import ( + "os" "strings" "testing" "time" @@ -29,9 +30,23 @@ func TestValidateCookie(t *testing.T) { "a.cba.localhost", } + // Create a temporary file for the valid secret file test + tmpfile, err := os.CreateTemp("", "cookie-secret-test") + if err != nil { + t.Fatalf("Failed to create temporary file: %v", err) + } + defer os.Remove(tmpfile.Name()) + + // Write a valid 32-byte secret to the file + _, err = tmpfile.Write([]byte(validSecret)) + if err != nil { + t.Fatalf("Failed to write to temporary file: %v", err) + } + tmpfile.Close() + invalidNameMsg := "invalid cookie name: \"_oauth2;proxy\"" longNameMsg := "cookie name should be under 256 characters: cookie name is 260 characters" - missingSecretMsg := "missing setting: cookie-secret" + missingSecretMsg := "missing setting: cookie-secret or cookie-secret-file" invalidSecretMsg := "cookie_secret must be 16, 24, or 32 bytes to create an AES cipher, but is 6 bytes" invalidBase64SecretMsg := "cookie_secret must be 16, 24, or 32 bytes to create an AES cipher, but is 10 bytes" refreshLongerThanExpireMsg := "cookie_refresh (\"1h0m0s\") must be less than cookie_expire (\"15m0s\")" @@ -271,6 +286,38 @@ func TestValidateCookie(t *testing.T) { }, errStrings: []string{}, }, + { + name: "with valid secret file", + cookie: options.Cookie{ + Name: validName, + Secret: "", + SecretFile: tmpfile.Name(), + Domains: domains, + Path: "", + Expire: 24 * time.Hour, + Refresh: 0, + Secure: true, + HTTPOnly: true, + SameSite: "", + }, + errStrings: []string{}, + }, + { + name: "with nonexistent secret file", + cookie: options.Cookie{ + Name: validName, + Secret: "", + SecretFile: "/nonexistent/file.txt", + Domains: domains, + Path: "", + Expire: 24 * time.Hour, + Refresh: 0, + Secure: true, + HTTPOnly: true, + SameSite: "", + }, + errStrings: []string{"could not read cookie secret file: /nonexistent/file.txt"}, + }, } for _, tc := range testCases { diff --git a/pkg/validation/options_test.go b/pkg/validation/options_test.go index 2d5e9560..5c242e02 100644 --- a/pkg/validation/options_test.go +++ b/pkg/validation/options_test.go @@ -48,7 +48,7 @@ func TestNewOptions(t *testing.T) { assert.NotEqual(t, nil, err) expected := errorMsg([]string{ - "missing setting: cookie-secret", + "missing setting: cookie-secret or cookie-secret-file", "provider has empty id: ids are required for all providers", "provider missing setting: client-id", "missing setting: client-secret or client-secret-file"}) From b905f2cd934315100dadc5c64203533fa4c9aa70 Mon Sep 17 00:00:00 2001 From: Michael Cornel Date: Wed, 23 Jul 2025 22:40:12 +0200 Subject: [PATCH 4/6] feat: use non-default authorization request response mode in OIDC providers (#3055) * fix: OIDC sets response mode * Update CHANGELOG --- CHANGELOG.md | 1 + providers/oidc.go | 5 +++++ providers/oidc_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7b0b09..78718cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - [#2359](https://github.com/oauth2-proxy/oauth2-proxy/pull/2359) feat: add SourceHut (sr.ht) provider(@bitfehler) - [#2524](https://github.com/oauth2-proxy/oauth2-proxy/pull/2524) fix: regex substitution for $ signs in upstream path handling before running envsubst (@dashkan / @tuunit) - [#3104](https://github.com/oauth2-proxy/oauth2-proxy/pull/3104) feat(cookie): add feature support for cookie-secret-file (@sandy2008) +- [#3055](https://github.com/oauth2-proxy/oauth2-proxy/pull/3055) feat: support non-default authorization request response mode also for OIDC providers (@stieler-it) # V7.10.0 diff --git a/providers/oidc.go b/providers/oidc.go index 43b5227e..15598aba 100644 --- a/providers/oidc.go +++ b/providers/oidc.go @@ -61,6 +61,11 @@ func (p *OIDCProvider) GetLoginURL(redirectURI, state, nonce string, extraParams if !p.SkipNonce { extraParams.Add("nonce", nonce) } + // Response mode should only be set if a non default mode is requested + if p.AuthRequestResponseMode != "" { + extraParams.Add("response_mode", p.AuthRequestResponseMode) + } + loginURL := makeLoginURL(p.Data(), redirectURI, state, extraParams) return loginURL.String() } diff --git a/providers/oidc_test.go b/providers/oidc_test.go index 6a49f8ff..81a70eb4 100644 --- a/providers/oidc_test.go +++ b/providers/oidc_test.go @@ -275,3 +275,32 @@ func TestOIDCProviderCreateSessionFromToken(t *testing.T) { }) } } + +func TestOIDCProviderResponseModeConfigured(t *testing.T) { + providerData := &ProviderData{ + LoginURL: &url.URL{ + Scheme: "http", + Host: "my.test.idp", + Path: "/oauth/authorize", + }, + AuthRequestResponseMode: "form_post", + } + p := NewOIDCProvider(providerData, options.OIDCOptions{}) + + result := p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{}) + assert.Contains(t, result, "response_mode=form_post") +} + +func TestOIDCProviderResponseModeNotConfigured(t *testing.T) { + providerData := &ProviderData{ + LoginURL: &url.URL{ + Scheme: "http", + Host: "my.test.idp", + Path: "/oauth/authorize", + }, + } + p := NewOIDCProvider(providerData, options.OIDCOptions{}) + + result := p.GetLoginURL("https://my.test.app/oauth", "", "", url.Values{}) + assert.NotContains(t, result, "response_mode") +} From e75a258299ec3db633450dd48a6df54b38988916 Mon Sep 17 00:00:00 2001 From: Sourav Agrawal <146818014+sourava01@users.noreply.github.com> Date: Thu, 24 Jul 2025 11:25:54 +0530 Subject: [PATCH 5/6] feat: make google-groups argument optional (#3138) add test cases update documentation refactor code and some cleanup update changelog Signed-off-by: Jan Larwig --- CHANGELOG.md | 1 + docs/docs/configuration/providers/google.md | 2 +- pkg/validation/options_test.go | 26 +++++- pkg/validation/providers.go | 6 +- providers/google.go | 89 +++++++++++++++++---- providers/google_test.go | 36 +++++++++ 6 files changed, 135 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78718cc3..0c117129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - [#2524](https://github.com/oauth2-proxy/oauth2-proxy/pull/2524) fix: regex substitution for $ signs in upstream path handling before running envsubst (@dashkan / @tuunit) - [#3104](https://github.com/oauth2-proxy/oauth2-proxy/pull/3104) feat(cookie): add feature support for cookie-secret-file (@sandy2008) - [#3055](https://github.com/oauth2-proxy/oauth2-proxy/pull/3055) feat: support non-default authorization request response mode also for OIDC providers (@stieler-it) +- [#3138](https://github.com/oauth2-proxy/oauth2-proxy/pull/3138) feat: make google_groups argument optional when using google provider (@sourava01) # V7.10.0 diff --git a/docs/docs/configuration/providers/google.md b/docs/docs/configuration/providers/google.md index 26af87ab..ac2a7dfa 100644 --- a/docs/docs/configuration/providers/google.md +++ b/docs/docs/configuration/providers/google.md @@ -8,7 +8,7 @@ title: Google (default) | Flag | Toml Field | Type | Description | Default | | ---------------------------------------------- | -------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | -------------------------------------------------- | | `--google-admin-email` | `google_admin_email` | string | the google admin to impersonate for api calls | | -| `--google-group` | `google_groups` | string | restrict logins to members of this google group (may be given multiple times). | | +| `--google-group` | `google_groups` | string | restrict logins to members of this google group (may be given multiple times). If not specified and service account or default credentials are configured, all user groups will be allowed. | | | `--google-service-account-json` | `google_service_account_json` | string | the path to the service account json credentials | | | `--google-use-application-default-credentials` | `google_use_application_default_credentials` | bool | use application default credentials instead of service account json (i.e. GKE Workload Identity) | | | `--google-target-principal` | `google_target_principal` | bool | the target principal to impersonate when using ADC | defaults to the service account configured for ADC | diff --git a/pkg/validation/options_test.go b/pkg/validation/options_test.go index 5c242e02..5c283545 100644 --- a/pkg/validation/options_test.go +++ b/pkg/validation/options_test.go @@ -55,18 +55,38 @@ func TestNewOptions(t *testing.T) { assert.Equal(t, expected, err.Error()) } -func TestGoogleGroupOptions(t *testing.T) { +func TestGoogleGroupOptionsWithoutServiceAccountJSON(t *testing.T) { o := testOptions() - o.Providers[0].GoogleConfig.Groups = []string{"googlegroup"} + o.Providers[0].GoogleConfig.AdminEmail = "admin@example.com" err := Validate(o) assert.NotEqual(t, nil, err) expected := errorMsg([]string{ - "missing setting: google-admin-email", "missing setting: google-service-account-json or google-use-application-default-credentials"}) assert.Equal(t, expected, err.Error()) } +func TestGoogleGroupOptionsWithoutAdminEmail(t *testing.T) { + o := testOptions() + o.Providers[0].GoogleConfig.UseApplicationDefaultCredentials = true + err := Validate(o) + assert.NotEqual(t, nil, err) + + expected := errorMsg([]string{ + "missing setting: google-admin-email"}) + assert.Equal(t, expected, err.Error()) +} + +func TestGoogleGroupOptionsWithoutGroups(t *testing.T) { + o := testOptions() + // Set admin email and application default credentials but no groups - should still require them + o.Providers[0].GoogleConfig.AdminEmail = "admin@example.com" + o.Providers[0].GoogleConfig.UseApplicationDefaultCredentials = true + err := Validate(o) + // Should pass validation since google-group is now optional + assert.Equal(t, nil, err) +} + func TestGoogleGroupInvalidFile(t *testing.T) { o := testOptions() o.Providers[0].GoogleConfig.Groups = []string{"test_group"} diff --git a/pkg/validation/providers.go b/pkg/validation/providers.go index b1106b35..4527b841 100644 --- a/pkg/validation/providers.go +++ b/pkg/validation/providers.go @@ -94,18 +94,14 @@ func validateClientSecret(provider options.Provider) []string { func validateGoogleConfig(provider options.Provider) []string { msgs := []string{} - hasGoogleGroups := len(provider.GoogleConfig.Groups) >= 1 hasAdminEmail := provider.GoogleConfig.AdminEmail != "" hasSAJSON := provider.GoogleConfig.ServiceAccountJSON != "" useADC := provider.GoogleConfig.UseApplicationDefaultCredentials - if !hasGoogleGroups && !hasAdminEmail && !hasSAJSON && !useADC { + if !hasAdminEmail && !hasSAJSON && !useADC { return msgs } - if !hasGoogleGroups { - msgs = append(msgs, "missing setting: google-group") - } if !hasAdminEmail { msgs = append(msgs, "missing setting: google-admin-email") } diff --git a/providers/google.go b/providers/google.go index 0e1e2156..097e3567 100644 --- a/providers/google.go +++ b/providers/google.go @@ -103,17 +103,24 @@ func NewGoogleProvider(p *ProviderData, opts options.GoogleOptions) (*GoogleProv } if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials { - // Backwards compatibility with `--google-group` option - if len(opts.Groups) > 0 { - provider.setAllowedGroups(opts.Groups) - } - - provider.setGroupRestriction(opts) + provider.configureGroups(opts) } return provider, nil } +func (p *GoogleProvider) configureGroups(opts options.GoogleOptions) { + adminService := getAdminService(opts) + // Backwards compatibility with `--google-group` option + if len(opts.Groups) > 0 { + p.setAllowedGroups(opts.Groups) + p.groupValidator = p.setGroupRestriction(opts.Groups, adminService) + return + } + + p.groupValidator = p.populateAllGroups(adminService) +} + func claimsFromIDToken(idToken string) (*claims, error) { // id_token is a base64 encode ID token payload @@ -209,18 +216,13 @@ func (p *GoogleProvider) EnrichSession(_ context.Context, s *sessions.SessionSta } // SetGroupRestriction configures the GoogleProvider to restrict access to the -// specified group(s). AdminEmail has to be an administrative email on the domain that is -// checked. CredentialsFile is the path to a json file containing a Google service -// account credentials. -// -// TODO (@NickMeves) - Unit Test this OR refactor away from groupValidator func -func (p *GoogleProvider) setGroupRestriction(opts options.GoogleOptions) { - adminService := getAdminService(opts) - p.groupValidator = func(s *sessions.SessionState) bool { +// specified group(s). +func (p *GoogleProvider) setGroupRestriction(groups []string, adminService *admin.Service) func(*sessions.SessionState) bool { + return func(s *sessions.SessionState) bool { // Reset our saved Groups in case membership changed // This is used by `Authorize` on every request - s.Groups = make([]string, 0, len(opts.Groups)) - for _, group := range opts.Groups { + s.Groups = make([]string, 0, len(groups)) + for _, group := range groups { if userInGroup(adminService, group, s.Email) { s.Groups = append(s.Groups, group) } @@ -229,6 +231,25 @@ func (p *GoogleProvider) setGroupRestriction(opts options.GoogleOptions) { } } +// populateAllGroups configures the GoogleProvider to allow access with all +// groups and populate session with all groups of the user when no specific +// groups are configured. +func (p *GoogleProvider) populateAllGroups(adminService *admin.Service) func(s *sessions.SessionState) bool { + return func(s *sessions.SessionState) bool { + // Get all groups of the user + groups, err := getUserGroups(adminService, s.Email) + if err != nil { + logger.Errorf("Failed to get user groups for %s: %v", s.Email, err) + s.Groups = []string{} + return true // Allow access even if we can't get groups + } + + // Populate session with all user groups + s.Groups = groups + return true // Always allow access when no specific groups are configured + } +} + // https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/hasMember#authorization-scopes var possibleScopesList = [...]string{ admin.AdminDirectoryGroupMemberReadonlyScope, @@ -269,6 +290,10 @@ func getOauth2TokenSource(ctx context.Context, opts options.GoogleOptions, scope return conf.TokenSource(ctx) } +// getAdminService retrieves an oauth token for the admin api of Google +// AdminEmail has to be an administrative email on the domain that is +// checked. CredentialsFile is the path to a json file containing a Google service +// account credentials. func getAdminService(opts options.GoogleOptions) *admin.Service { ctx := context.Background() var client *http.Client @@ -339,6 +364,38 @@ func getTargetPrincipal(ctx context.Context, opts options.GoogleOptions) (target return targetPrincipal } +// getUserGroups retrieves all groups that a user is a member of using the Google Admin Directory API +func getUserGroups(service *admin.Service, email string) ([]string, error) { + var allGroups []string + var pageToken string + + for { + req := service.Groups.List().UserKey(email).MaxResults(200) + if pageToken != "" { + req = req.PageToken(pageToken) + } + + groupsResp, err := req.Do() + if err != nil { + return nil, fmt.Errorf("failed to list groups for user %s: %v", email, err) + } + + for _, group := range groupsResp.Groups { + if group.Email != "" { + allGroups = append(allGroups, group.Email) + } + } + + // Check if there are more pages + if groupsResp.NextPageToken == "" { + break + } + pageToken = groupsResp.NextPageToken + } + + return allGroups, nil +} + func userInGroup(service *admin.Service, group string, email string) bool { // Use the HasMember API to checking for the user's presence in each group or nested subgroups req := service.Members.HasMember(group, email) diff --git a/providers/google_test.go b/providers/google_test.go index 23bca7ea..dc061203 100644 --- a/providers/google_test.go +++ b/providers/google_test.go @@ -289,3 +289,39 @@ func TestGoogleProvider_userInGroup(t *testing.T) { result = userInGroup(service, "group@example.com", "non-member-out-of-domain@otherexample.com") assert.False(t, result) } + +func TestGoogleProvider_getUserGroups(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/admin/directory/v1/groups" && r.URL.Query().Get("userKey") == "test@example.com" { + response := `{ + "kind": "admin#directory#groups", + "groups": [ + { + "kind": "admin#directory#group", + "id": "1", + "email": "group1@example.com", + "name": "Group 1" + }, + { + "kind": "admin#directory#group", + "id": "2", + "email": "group2@example.com", + "name": "Group 2" + } + ] + }` + fmt.Fprintln(w, response) + } else { + http.NotFound(w, r) + } + })) + defer ts.Close() + + client := &http.Client{} + adminService, err := admin.NewService(context.Background(), option.WithHTTPClient(client), option.WithEndpoint(ts.URL)) + assert.NoError(t, err) + + groups, err := getUserGroups(adminService, "test@example.com") + assert.NoError(t, err) + assert.Equal(t, []string{"group1@example.com", "group2@example.com"}, groups) +} From f4b33b64bd66ad28e9b0d63bea51837b83c00ca1 Mon Sep 17 00:00:00 2001 From: nobletrout Date: Thu, 24 Jul 2025 02:33:06 -0400 Subject: [PATCH 6/6] feat: differentiate between "no available key" and error for redis sessions (#3093) * add some better error handling * add changelog entry Signed-off-by: Jan Larwig --------- Signed-off-by: Jan Larwig Co-authored-by: Jan Larwig --- CHANGELOG.md | 2 ++ pkg/sessions/redis/redis_store.go | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c117129..ed30ea57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - [#3104](https://github.com/oauth2-proxy/oauth2-proxy/pull/3104) feat(cookie): add feature support for cookie-secret-file (@sandy2008) - [#3055](https://github.com/oauth2-proxy/oauth2-proxy/pull/3055) feat: support non-default authorization request response mode also for OIDC providers (@stieler-it) - [#3138](https://github.com/oauth2-proxy/oauth2-proxy/pull/3138) feat: make google_groups argument optional when using google provider (@sourava01) +- [#3093](https://github.com/oauth2-proxy/oauth2-proxy/pull/3093) feat: differentiate between "no available key" and error for redis sessions (@nobletrout) + # V7.10.0 diff --git a/pkg/sessions/redis/redis_store.go b/pkg/sessions/redis/redis_store.go index 18d79b80..4e846e9b 100644 --- a/pkg/sessions/redis/redis_store.go +++ b/pkg/sessions/redis/redis_store.go @@ -49,9 +49,12 @@ func (store *SessionStore) Save(ctx context.Context, key string, value []byte, e // cookie within the HTTP request object func (store *SessionStore) Load(ctx context.Context, key string) ([]byte, error) { value, err := store.Client.Get(ctx, key) - if err != nil { + if err == redis.Nil { + return nil, fmt.Errorf("session does not exist") + } else if err != nil { return nil, fmt.Errorf("error loading redis session: %v", err) } + return value, nil }