Merge branch 'master' into add-cidaas-provider
This commit is contained in:
commit
203f42e660
|
|
@ -11,6 +11,13 @@
|
||||||
- [#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)
|
- [#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)
|
- [#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)
|
- [#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)
|
||||||
|
- [#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
|
# V7.10.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ Provider specific options can be found on their respective subpages.
|
||||||
| flag: `--cookie-refresh`<br/>toml: `cookie_refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers [^1] | |
|
| flag: `--cookie-refresh`<br/>toml: `cookie_refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers [^1] | |
|
||||||
| flag: `--cookie-samesite`<br/>toml: `cookie_samesite` | string | set SameSite cookie attribute (`"lax"`, `"strict"`, `"none"`, or `""`). | `""` |
|
| flag: `--cookie-samesite`<br/>toml: `cookie_samesite` | string | set SameSite cookie attribute (`"lax"`, `"strict"`, `"none"`, or `""`). | `""` |
|
||||||
| flag: `--cookie-secret`<br/>toml: `cookie_secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
|
| flag: `--cookie-secret`<br/>toml: `cookie_secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
|
||||||
|
| flag: `--cookie-secret-file`<br/>toml: `cookie_secret_file` | string | For defining a separate cookie secret file to read the encryption key from | |
|
||||||
| flag: `--cookie-secure`<br/>toml: `cookie_secure` | bool | set [secure (HTTPS only) cookie flag](https://owasp.org/www-community/controls/SecureFlag) | true |
|
| flag: `--cookie-secure`<br/>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)
|
[^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)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ title: Google (default)
|
||||||
| Flag | Toml Field | Type | Description | Default |
|
| Flag | Toml Field | Type | Description | Default |
|
||||||
| ---------------------------------------------- | -------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
|
| ---------------------------------------------- | -------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
|
||||||
| `--google-admin-email` | `google_admin_email` | string | the google admin to impersonate for api calls | |
|
| `--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-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-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 |
|
| `--google-target-principal` | `google_target_principal` | bool | the target principal to impersonate when using ADC | defaults to the service account configured for ADC |
|
||||||
|
|
|
||||||
|
|
@ -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://<meta.your.instance>/oauth2/authorize"
|
||||||
|
--redeem-url="https://<meta.your.instance>/oauth2/access-token"
|
||||||
|
--profile-url="https://<meta.your.instance>/query"
|
||||||
|
--validate-url="https://<meta.your.instance>/profile"
|
||||||
|
```
|
||||||
|
|
||||||
|
The default configuration allows everyone with an account to authenticate.
|
||||||
|
Restricting access is currently only supported by
|
||||||
|
[email](#email-authentication).
|
||||||
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package options
|
package options
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -10,6 +13,7 @@ import (
|
||||||
type Cookie struct {
|
type Cookie struct {
|
||||||
Name string `flag:"cookie-name" cfg:"cookie_name"`
|
Name string `flag:"cookie-name" cfg:"cookie_name"`
|
||||||
Secret string `flag:"cookie-secret" cfg:"cookie_secret"`
|
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"`
|
Domains []string `flag:"cookie-domain" cfg:"cookie_domains"`
|
||||||
Path string `flag:"cookie-path" cfg:"cookie_path"`
|
Path string `flag:"cookie-path" cfg:"cookie_path"`
|
||||||
Expire time.Duration `flag:"cookie-expire" cfg:"cookie_expire"`
|
Expire time.Duration `flag:"cookie-expire" cfg:"cookie_expire"`
|
||||||
|
|
@ -18,8 +22,8 @@ type Cookie struct {
|
||||||
HTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"`
|
HTTPOnly bool `flag:"cookie-httponly" cfg:"cookie_httponly"`
|
||||||
SameSite string `flag:"cookie-samesite" cfg:"cookie_samesite"`
|
SameSite string `flag:"cookie-samesite" cfg:"cookie_samesite"`
|
||||||
CSRFPerRequest bool `flag:"cookie-csrf-per-request" cfg:"cookie_csrf_per_request"`
|
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"`
|
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 {
|
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-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", "", "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.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.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")
|
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
|
// cookieDefaults creates a Cookie populating each field with its default value
|
||||||
func cookieDefaults() Cookie {
|
func cookieDefaults() Cookie {
|
||||||
return Cookie{
|
return Cookie{
|
||||||
Name: "_oauth2_proxy",
|
Name: "_oauth2_proxy",
|
||||||
Secret: "",
|
Secret: "",
|
||||||
Domains: nil,
|
SecretFile: "",
|
||||||
Path: "/",
|
Domains: nil,
|
||||||
Expire: time.Duration(168) * time.Hour,
|
Path: "/",
|
||||||
Refresh: time.Duration(0),
|
Expire: time.Duration(168) * time.Hour,
|
||||||
Secure: true,
|
Refresh: time.Duration(0),
|
||||||
HTTPOnly: true,
|
Secure: true,
|
||||||
SameSite: "",
|
HTTPOnly: true,
|
||||||
CSRFPerRequest: false,
|
SameSite: "",
|
||||||
CSRFExpire: time.Duration(15) * time.Minute,
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/a8m/envsubst"
|
"github.com/a8m/envsubst"
|
||||||
|
|
@ -155,7 +156,8 @@ func LoadYAML(configFileName string, into interface{}) error {
|
||||||
return nil
|
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) {
|
func loadAndParseYaml(configFileName string) ([]byte, error) {
|
||||||
if configFileName == "" {
|
if configFileName == "" {
|
||||||
return nil, errors.New("no configuration file provided")
|
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)
|
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
|
modifiedBuffer, err := normalizeSubstitution(unparsedBuffer)
|
||||||
buffer, err := envsubst.Bytes(unparsedBuffer)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error normalizing substitution string : %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer, err := envsubst.Bytes(modifiedBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error in substituting env variables : %w", err)
|
return nil, fmt.Errorf("error in substituting env variables : %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer, nil
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,31 @@ sub:
|
||||||
StringOption: "Bob",
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,9 @@ const (
|
||||||
|
|
||||||
// OIDCProvider is the provider type for OIDC
|
// OIDCProvider is the provider type for OIDC
|
||||||
OIDCProvider ProviderType = "oidc"
|
OIDCProvider ProviderType = "oidc"
|
||||||
|
|
||||||
|
// SourceHutProvider is the provider type for SourceHut
|
||||||
|
SourceHutProvider ProviderType = "sourcehut"
|
||||||
)
|
)
|
||||||
|
|
||||||
type KeycloakOptions struct {
|
type KeycloakOptions struct {
|
||||||
|
|
|
||||||
|
|
@ -219,13 +219,22 @@ func (c *csrf) encodeCookie() (string, error) {
|
||||||
return "", err
|
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
|
// decodeCSRFCookie validates the signature then decrypts and decodes a CSRF
|
||||||
// cookie into a CSRF struct
|
// cookie into a CSRF struct
|
||||||
func decodeCSRFCookie(cookie *http.Cookie, opts *options.Cookie) (*csrf, error) {
|
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 {
|
if !ok {
|
||||||
return nil, errors.New("CSRF cookie failed validation")
|
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
|
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 := clock.Clock{}
|
||||||
clock.Set(t)
|
clock.Set(csrfTime)
|
||||||
|
|
||||||
csrf := &csrf{cookieOpts: opts, time: clock}
|
csrf := &csrf{cookieOpts: opts, time: clock}
|
||||||
err = msgpack.Unmarshal(decrypted, csrf)
|
if err := msgpack.Unmarshal(decrypted, csrf); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error unmarshalling data to CSRF: %v", err)
|
return nil, fmt.Errorf("error unmarshalling data to CSRF: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return csrf, nil
|
return csrf, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,5 +302,9 @@ func decrypt(data []byte, opts *options.Cookie) ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCipher(opts *options.Cookie) (encryption.Cipher, 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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,16 +54,18 @@ func (s *SessionStore) Load(req *http.Request) (*sessions.SessionState, error) {
|
||||||
// always http.ErrNoCookie
|
// always http.ErrNoCookie
|
||||||
return nil, err
|
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 {
|
if !ok {
|
||||||
return nil, errors.New("cookie signature not valid")
|
return nil, errors.New("cookie signature not valid")
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := sessions.DecodeSessionState(val, s.CookieCipher, true)
|
return sessions.DecodeSessionState(val, s.CookieCipher, true)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return session, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear clears any saved session information by writing a cookie to
|
// 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)
|
strValue := string(value)
|
||||||
if strValue != "" {
|
if strValue != "" {
|
||||||
var err error
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// NewCookieSessionStore initialises a new instance of the SessionStore from
|
||||||
// the configuration given
|
// the configuration given
|
||||||
func NewCookieSessionStore(opts *options.SessionOptions, cookieOpts *options.Cookie) (sessions.SessionStore, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error initialising cipher: %v", err)
|
return nil, fmt.Errorf("error initialising cipher: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,11 @@ func decodeTicketFromRequest(req *http.Request, cookieOpts *options.Cookie) (*ti
|
||||||
}
|
}
|
||||||
|
|
||||||
// An existing cookie exists, try to retrieve the ticket
|
// 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 {
|
if !ok {
|
||||||
return nil, fmt.Errorf("session ticket cookie failed validation: %v", err)
|
return nil, fmt.Errorf("session ticket cookie failed validation: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,12 @@ func (store *SessionStore) Save(ctx context.Context, key string, value []byte, e
|
||||||
// cookie within the HTTP request object
|
// cookie within the HTTP request object
|
||||||
func (store *SessionStore) Load(ctx context.Context, key string) ([]byte, error) {
|
func (store *SessionStore) Load(ctx context.Context, key string) ([]byte, error) {
|
||||||
value, err := store.Client.Get(ctx, key)
|
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 nil, fmt.Errorf("error loading redis session: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package validation
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -11,7 +12,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func validateCookie(o options.Cookie) []string {
|
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 {
|
if o.Expire != time.Duration(0) && o.Refresh >= o.Expire {
|
||||||
msgs = append(msgs, fmt.Sprintf(
|
msgs = append(msgs, fmt.Sprintf(
|
||||||
|
|
@ -49,9 +50,27 @@ func validateCookieName(name string) []string {
|
||||||
return msgs
|
return msgs
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateCookieSecret(secret string) []string {
|
func validateCookieSecret(secret string, secretFile string) []string {
|
||||||
if secret == "" {
|
if secret == "" && secretFile == "" {
|
||||||
return []string{"missing setting: cookie-secret"}
|
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)
|
secretBytes := encryption.SecretBytes(secret)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package validation
|
package validation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -29,9 +30,23 @@ func TestValidateCookie(t *testing.T) {
|
||||||
"a.cba.localhost",
|
"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\""
|
invalidNameMsg := "invalid cookie name: \"_oauth2;proxy\""
|
||||||
longNameMsg := "cookie name should be under 256 characters: cookie name is 260 characters"
|
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"
|
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"
|
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\")"
|
refreshLongerThanExpireMsg := "cookie_refresh (\"1h0m0s\") must be less than cookie_expire (\"15m0s\")"
|
||||||
|
|
@ -271,6 +286,38 @@ func TestValidateCookie(t *testing.T) {
|
||||||
},
|
},
|
||||||
errStrings: []string{},
|
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 {
|
for _, tc := range testCases {
|
||||||
|
|
|
||||||
|
|
@ -48,25 +48,45 @@ func TestNewOptions(t *testing.T) {
|
||||||
assert.NotEqual(t, nil, err)
|
assert.NotEqual(t, nil, err)
|
||||||
|
|
||||||
expected := errorMsg([]string{
|
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 has empty id: ids are required for all providers",
|
||||||
"provider missing setting: client-id",
|
"provider missing setting: client-id",
|
||||||
"missing setting: client-secret or client-secret-file"})
|
"missing setting: client-secret or client-secret-file"})
|
||||||
assert.Equal(t, expected, err.Error())
|
assert.Equal(t, expected, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGoogleGroupOptions(t *testing.T) {
|
func TestGoogleGroupOptionsWithoutServiceAccountJSON(t *testing.T) {
|
||||||
o := testOptions()
|
o := testOptions()
|
||||||
o.Providers[0].GoogleConfig.Groups = []string{"googlegroup"}
|
o.Providers[0].GoogleConfig.AdminEmail = "admin@example.com"
|
||||||
err := Validate(o)
|
err := Validate(o)
|
||||||
assert.NotEqual(t, nil, err)
|
assert.NotEqual(t, nil, err)
|
||||||
|
|
||||||
expected := errorMsg([]string{
|
expected := errorMsg([]string{
|
||||||
"missing setting: google-admin-email",
|
|
||||||
"missing setting: google-service-account-json or google-use-application-default-credentials"})
|
"missing setting: google-service-account-json or google-use-application-default-credentials"})
|
||||||
assert.Equal(t, expected, err.Error())
|
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) {
|
func TestGoogleGroupInvalidFile(t *testing.T) {
|
||||||
o := testOptions()
|
o := testOptions()
|
||||||
o.Providers[0].GoogleConfig.Groups = []string{"test_group"}
|
o.Providers[0].GoogleConfig.Groups = []string{"test_group"}
|
||||||
|
|
|
||||||
|
|
@ -94,18 +94,14 @@ func validateClientSecret(provider options.Provider) []string {
|
||||||
func validateGoogleConfig(provider options.Provider) []string {
|
func validateGoogleConfig(provider options.Provider) []string {
|
||||||
msgs := []string{}
|
msgs := []string{}
|
||||||
|
|
||||||
hasGoogleGroups := len(provider.GoogleConfig.Groups) >= 1
|
|
||||||
hasAdminEmail := provider.GoogleConfig.AdminEmail != ""
|
hasAdminEmail := provider.GoogleConfig.AdminEmail != ""
|
||||||
hasSAJSON := provider.GoogleConfig.ServiceAccountJSON != ""
|
hasSAJSON := provider.GoogleConfig.ServiceAccountJSON != ""
|
||||||
useADC := provider.GoogleConfig.UseApplicationDefaultCredentials
|
useADC := provider.GoogleConfig.UseApplicationDefaultCredentials
|
||||||
|
|
||||||
if !hasGoogleGroups && !hasAdminEmail && !hasSAJSON && !useADC {
|
if !hasAdminEmail && !hasSAJSON && !useADC {
|
||||||
return msgs
|
return msgs
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasGoogleGroups {
|
|
||||||
msgs = append(msgs, "missing setting: google-group")
|
|
||||||
}
|
|
||||||
if !hasAdminEmail {
|
if !hasAdminEmail {
|
||||||
msgs = append(msgs, "missing setting: google-admin-email")
|
msgs = append(msgs, "missing setting: google-admin-email")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,17 +103,24 @@ func NewGoogleProvider(p *ProviderData, opts options.GoogleOptions) (*GoogleProv
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials {
|
if opts.ServiceAccountJSON != "" || opts.UseApplicationDefaultCredentials {
|
||||||
// Backwards compatibility with `--google-group` option
|
provider.configureGroups(opts)
|
||||||
if len(opts.Groups) > 0 {
|
|
||||||
provider.setAllowedGroups(opts.Groups)
|
|
||||||
}
|
|
||||||
|
|
||||||
provider.setGroupRestriction(opts)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return provider, nil
|
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) {
|
func claimsFromIDToken(idToken string) (*claims, error) {
|
||||||
|
|
||||||
// id_token is a base64 encode ID token payload
|
// 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
|
// SetGroupRestriction configures the GoogleProvider to restrict access to the
|
||||||
// specified group(s). AdminEmail has to be an administrative email on the domain that is
|
// specified group(s).
|
||||||
// checked. CredentialsFile is the path to a json file containing a Google service
|
func (p *GoogleProvider) setGroupRestriction(groups []string, adminService *admin.Service) func(*sessions.SessionState) bool {
|
||||||
// account credentials.
|
return func(s *sessions.SessionState) bool {
|
||||||
//
|
|
||||||
// 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 {
|
|
||||||
// Reset our saved Groups in case membership changed
|
// Reset our saved Groups in case membership changed
|
||||||
// This is used by `Authorize` on every request
|
// This is used by `Authorize` on every request
|
||||||
s.Groups = make([]string, 0, len(opts.Groups))
|
s.Groups = make([]string, 0, len(groups))
|
||||||
for _, group := range opts.Groups {
|
for _, group := range groups {
|
||||||
if userInGroup(adminService, group, s.Email) {
|
if userInGroup(adminService, group, s.Email) {
|
||||||
s.Groups = append(s.Groups, group)
|
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
|
// https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/hasMember#authorization-scopes
|
||||||
var possibleScopesList = [...]string{
|
var possibleScopesList = [...]string{
|
||||||
admin.AdminDirectoryGroupMemberReadonlyScope,
|
admin.AdminDirectoryGroupMemberReadonlyScope,
|
||||||
|
|
@ -269,6 +290,10 @@ func getOauth2TokenSource(ctx context.Context, opts options.GoogleOptions, scope
|
||||||
return conf.TokenSource(ctx)
|
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 {
|
func getAdminService(opts options.GoogleOptions) *admin.Service {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
var client *http.Client
|
var client *http.Client
|
||||||
|
|
@ -339,6 +364,38 @@ func getTargetPrincipal(ctx context.Context, opts options.GoogleOptions) (target
|
||||||
return targetPrincipal
|
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 {
|
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
|
// Use the HasMember API to checking for the user's presence in each group or nested subgroups
|
||||||
req := service.Members.HasMember(group, email)
|
req := service.Members.HasMember(group, email)
|
||||||
|
|
|
||||||
|
|
@ -289,3 +289,39 @@ func TestGoogleProvider_userInGroup(t *testing.T) {
|
||||||
result = userInGroup(service, "group@example.com", "non-member-out-of-domain@otherexample.com")
|
result = userInGroup(service, "group@example.com", "non-member-out-of-domain@otherexample.com")
|
||||||
assert.False(t, result)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,11 @@ func (p *OIDCProvider) GetLoginURL(redirectURI, state, nonce string, extraParams
|
||||||
if !p.SkipNonce {
|
if !p.SkipNonce {
|
||||||
extraParams.Add("nonce", nonce)
|
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)
|
loginURL := makeLoginURL(p.Data(), redirectURI, state, extraParams)
|
||||||
return loginURL.String()
|
return loginURL.String()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ func NewProvider(providerConfig options.Provider) (Provider, error) {
|
||||||
return NewNextcloudProvider(providerData), nil
|
return NewNextcloudProvider(providerData), nil
|
||||||
case options.OIDCProvider:
|
case options.OIDCProvider:
|
||||||
return NewOIDCProvider(providerData, providerConfig.OIDCConfig), nil
|
return NewOIDCProvider(providerData, providerConfig.OIDCConfig), nil
|
||||||
|
case options.SourceHutProvider:
|
||||||
|
return NewSourceHutProvider(providerData), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown provider type %q", providerConfig.Type)
|
return nil, fmt.Errorf("unknown provider type %q", providerConfig.Type)
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +187,8 @@ func parseCodeChallengeMethod(providerConfig options.Provider) string {
|
||||||
func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bool, error) {
|
func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bool, error) {
|
||||||
switch providerType {
|
switch providerType {
|
||||||
case options.BitbucketProvider, options.DigitalOceanProvider, options.FacebookProvider, options.GitHubProvider,
|
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
|
return false, nil
|
||||||
case options.OIDCProvider, options.ADFSProvider, options.AzureProvider, options.CidaasProvider,
|
case options.OIDCProvider, options.ADFSProvider, options.AzureProvider, options.CidaasProvider,
|
||||||
options.GitLabProvider, options.KeycloakOIDCProvider, options.MicrosoftEntraIDProvider:
|
options.GitLabProvider, options.KeycloakOIDCProvider, options.MicrosoftEntraIDProvider:
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue