Compare commits

...

6 Commits

Author SHA1 Message Date
wpetit 132bf1e642 feat(authn-oidc): allow for dynamic post-logout redirection
Cadoles/bouncer/pipeline/head This commit looks good Details
2024-05-24 17:01:06 +02:00
wpetit 26a9ad0e2e feat(authn-oidc): match login callback/logout urls with query string by default
Cadoles/bouncer/pipeline/head This commit looks good Details
2024-05-24 15:28:21 +02:00
wpetit 3e5dd446cb feat(authn-oidc): use relative redirection to prevent internal/public host mixing 2024-05-24 15:27:43 +02:00
wpetit d5c846a9ce fix(docker): format default config durations
Cadoles/bouncer/pipeline/head This commit looks good Details
2024-05-24 14:52:31 +02:00
wpetit 82c93d3f1e feat(config): interpolate recursively in interpolated map
Cadoles/bouncer/pipeline/head This commit looks good Details
2024-05-24 12:49:03 +02:00
wpetit 544326a4b7 feat(authn-oidc): use full urls for login callback/logout options 2024-05-23 17:41:36 +02:00
18 changed files with 484 additions and 103 deletions

View File

@ -28,7 +28,9 @@ RUN /src/dist/bouncer_linux_amd64_v1/bouncer -c '' config dump > /src/dist/bounc
&& yq -i '.redis.adresses = ["redis:6379"]' /src/dist/bouncer_linux_amd64_v1/config.yml \ && yq -i '.redis.adresses = ["redis:6379"]' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.redis.writeTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \ && yq -i '.redis.writeTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.redis.readTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \ && yq -i '.redis.readTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.redis.dialTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml && yq -i '.redis.dialTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.bootstrap.lockTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.integrations.kubernetes.lockTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml
FROM reg.cadoles.com/proxy_cache/library/alpine:3.19.1 AS RUNTIME FROM reg.cadoles.com/proxy_cache/library/alpine:3.19.1 AS RUNTIME
@ -46,6 +48,7 @@ RUN ln -s /usr/share/bouncer/bin/bouncer /usr/local/bin/bouncer
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
EXPOSE 8082
RUN adduser -D -H bouncer RUN adduser -D -H bouncer

View File

@ -65,7 +65,7 @@ func (s *Server) bootstrapProxies(ctx context.Context) error {
for layerName, layerConfig := range proxyConfig.Layers { for layerName, layerConfig := range proxyConfig.Layers {
layerType := store.LayerType(layerConfig.Type) layerType := store.LayerType(layerConfig.Type)
layerOptions := store.LayerOptions(layerConfig.Options) layerOptions := store.LayerOptions(layerConfig.Options.Data)
if _, err := layerRepo.CreateLayer(ctx, proxyName, layerName, layerType, layerOptions); err != nil { if _, err := layerRepo.CreateLayer(ctx, proxyName, layerName, layerType, layerOptions); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
@ -109,7 +109,7 @@ func (s *Server) validateBootstrap(ctx context.Context) error {
} }
rawOptions := func(opts config.InterpolatedMap) map[string]any { rawOptions := func(opts config.InterpolatedMap) map[string]any {
return opts return opts.Data
}(layerConf.Options) }(layerConf.Options)
if err := schema.Validate(ctx, layerOptionsSchema, rawOptions); err != nil { if err := schema.Validate(ctx, layerOptionsSchema, rawOptions); err != nil {

View File

@ -101,33 +101,66 @@ func (ib *InterpolatedBool) UnmarshalYAML(value *yaml.Node) error {
return nil return nil
} }
type InterpolatedMap map[string]interface{} type InterpolatedMap struct {
Data map[string]any
getEnv func(string) string
}
func (im *InterpolatedMap) UnmarshalYAML(value *yaml.Node) error { func (im *InterpolatedMap) UnmarshalYAML(value *yaml.Node) error {
var data map[string]interface{} var data map[string]any
if err := value.Decode(&data); err != nil { if err := value.Decode(&data); err != nil {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into map", value.Value, value.Line) return errors.Wrapf(err, "could not decode value '%v' (line '%d') into map", value.Value, value.Line)
} }
for key, value := range data { if im.getEnv == nil {
strVal, ok := value.(string) im.getEnv = os.Getenv
if !ok {
continue
}
if match := reVar.FindStringSubmatch(strVal); len(match) > 0 {
strVal = os.Getenv(match[1])
}
data[key] = strVal
} }
*im = data interpolated, err := im.interpolateRecursive(data)
if err != nil {
return errors.WithStack(err)
}
im.Data = interpolated.(map[string]any)
return nil return nil
} }
func (im *InterpolatedMap) interpolateRecursive(data any) (any, error) {
switch typ := data.(type) {
case map[string]any:
for key, value := range typ {
value, err := im.interpolateRecursive(value)
if err != nil {
return nil, errors.WithStack(err)
}
typ[key] = value
}
case string:
value, err := envsubst.Eval(typ, im.getEnv)
if err != nil {
return nil, errors.WithStack(err)
}
data = value
case []any:
for idx := range typ {
value, err := im.interpolateRecursive(typ[idx])
if err != nil {
return nil, errors.WithStack(err)
}
typ[idx] = value
}
}
return data, nil
}
type InterpolatedStringSlice []string type InterpolatedStringSlice []string
func (iss *InterpolatedStringSlice) UnmarshalYAML(value *yaml.Node) error { func (iss *InterpolatedStringSlice) UnmarshalYAML(value *yaml.Node) error {

View File

@ -0,0 +1,82 @@
package config
import (
"fmt"
"os"
"testing"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
func TestInterpolatedMap(t *testing.T) {
type testCase struct {
Path string
Env map[string]string
Assert func(t *testing.T, parsed InterpolatedMap)
}
testCases := []testCase{
{
Path: "testdata/environment/interpolated-map-1.yml",
Env: map[string]string{
"TEST_PROP1": "foo",
"TEST_SUB_PROP1": "bar",
"TEST_SUB2_PROP1": "baz",
},
Assert: func(t *testing.T, parsed InterpolatedMap) {
if e, g := "foo", parsed.Data["prop1"]; e != g {
t.Errorf("parsed.Data[\"prop1\"]: expected '%v', got '%v'", e, g)
}
if e, g := "bar", parsed.Data["sub"].(map[string]any)["subProp1"]; e != g {
t.Errorf("parsed.Data[\"sub\"][\"subProp1\"]: expected '%v', got '%v'", e, g)
}
if e, g := "baz", parsed.Data["sub2"].(map[string]any)["sub2Prop1"].([]any)[0]; e != g {
t.Errorf("parsed.Data[\"sub2\"][\"sub2Prop1\"][0]: expected '%v', got '%v'", e, g)
}
if e, g := "test", parsed.Data["sub2"].(map[string]any)["sub2Prop1"].([]any)[1]; e != g {
t.Errorf("parsed.Data[\"sub2\"][\"sub2Prop1\"][1]: expected '%v', got '%v'", e, g)
}
},
},
{
Path: "testdata/environment/interpolated-map-2.yml",
Env: map[string]string{
"BAR": "bar",
},
Assert: func(t *testing.T, parsed InterpolatedMap) {
if e, g := "http://bar", parsed.Data["foo"]; e != g {
t.Errorf("parsed.Data[\"foo\"]: expected '%v', got '%v'", e, g)
}
},
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
data, err := os.ReadFile(tc.Path)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
var interpolatedMap InterpolatedMap
if tc.Env != nil {
interpolatedMap.getEnv = func(key string) string {
return tc.Env[key]
}
}
if err := yaml.Unmarshal(data, &interpolatedMap); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if tc.Assert != nil {
tc.Assert(t, interpolatedMap)
}
})
}
}

View File

@ -15,6 +15,12 @@ func NewDefaultLayersConfig() LayersConfig {
}, },
Authn: AuthnLayerConfig{ Authn: AuthnLayerConfig{
TemplateDir: "./layers/authn/templates", TemplateDir: "./layers/authn/templates",
OIDC: AuthnOIDCLayerConfig{
HTTPClient: AuthnOIDCHTTPClientConfig{
TransportConfig: NewDefaultTransportConfig(),
Timeout: NewInterpolatedDuration(10 * time.Second),
},
},
}, },
} }
} }
@ -25,5 +31,15 @@ type QueueLayerConfig struct {
} }
type AuthnLayerConfig struct { type AuthnLayerConfig struct {
TemplateDir InterpolatedString `yaml:"templateDir"` TemplateDir InterpolatedString `yaml:"templateDir"`
OIDC AuthnOIDCLayerConfig `yaml:"oidc"`
}
type AuthnOIDCLayerConfig struct {
HTTPClient AuthnOIDCHTTPClientConfig `yaml:"httpClient"`
}
type AuthnOIDCHTTPClientConfig struct {
TransportConfig
Timeout *InterpolatedDuration `yaml:"timeout"`
} }

View File

@ -17,9 +17,9 @@ func (c *BasicAuthConfig) CredentialsMap() map[string]string {
return map[string]string{} return map[string]string{}
} }
credentials := make(map[string]string, len(*c.Credentials)) credentials := make(map[string]string, len(c.Credentials.Data))
for k, v := range *c.Credentials { for k, v := range c.Credentials.Data {
credentials[k] = fmt.Sprintf("%v", v) credentials[k] = fmt.Sprintf("%v", v)
} }

View File

@ -1,6 +1,10 @@
package config package config
import "time" import (
"crypto/tls"
"net/http"
"time"
)
type ProxyServerConfig struct { type ProxyServerConfig struct {
HTTP HTTPConfig `yaml:"http"` HTTP HTTPConfig `yaml:"http"`
@ -25,6 +29,33 @@ type TransportConfig struct {
WriteBufferSize InterpolatedInt `yaml:"writeBufferSize"` WriteBufferSize InterpolatedInt `yaml:"writeBufferSize"`
ReadBufferSize InterpolatedInt `yaml:"readBufferSize"` ReadBufferSize InterpolatedInt `yaml:"readBufferSize"`
MaxResponseHeaderBytes InterpolatedInt `yaml:"maxResponseHeaderBytes"` MaxResponseHeaderBytes InterpolatedInt `yaml:"maxResponseHeaderBytes"`
InsecureSkipVerify InterpolatedBool `yaml:"insecureSkipVerify"`
}
func (c TransportConfig) AsTransport() *http.Transport {
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
httpTransport.Proxy = http.ProxyFromEnvironment
httpTransport.ForceAttemptHTTP2 = bool(c.ForceAttemptHTTP2)
httpTransport.MaxIdleConns = int(c.MaxIdleConns)
httpTransport.MaxIdleConnsPerHost = int(c.MaxIdleConnsPerHost)
httpTransport.MaxConnsPerHost = int(c.MaxConnsPerHost)
httpTransport.IdleConnTimeout = time.Duration(*c.IdleConnTimeout)
httpTransport.TLSHandshakeTimeout = time.Duration(*c.TLSHandshakeTimeout)
httpTransport.ExpectContinueTimeout = time.Duration(*c.ExpectContinueTimeout)
httpTransport.DisableKeepAlives = bool(c.DisableKeepAlives)
httpTransport.DisableCompression = bool(c.DisableCompression)
httpTransport.ResponseHeaderTimeout = time.Duration(*c.ResponseHeaderTimeout)
httpTransport.WriteBufferSize = int(c.WriteBufferSize)
httpTransport.ReadBufferSize = int(c.ReadBufferSize)
httpTransport.MaxResponseHeaderBytes = int64(c.MaxResponseHeaderBytes)
if httpTransport.TLSClientConfig == nil {
httpTransport.TLSClientConfig = &tls.Config{}
}
httpTransport.TLSClientConfig.InsecureSkipVerify = bool(c.InsecureSkipVerify)
return httpTransport
} }
func NewDefaultProxyServerConfig() ProxyServerConfig { func NewDefaultProxyServerConfig() ProxyServerConfig {
@ -69,5 +100,6 @@ func NewDefaultTransportConfig() TransportConfig {
ReadBufferSize: 4096, ReadBufferSize: 4096,
WriteBufferSize: 4096, WriteBufferSize: 4096,
MaxResponseHeaderBytes: 0, MaxResponseHeaderBytes: 0,
InsecureSkipVerify: false,
} }
} }

View File

@ -0,0 +1,6 @@
prop1: "${TEST_PROP1}"
prop2: 1
sub:
subProp1: "${TEST_SUB_PROP1}"
sub2:
sub2Prop1: ["${TEST_SUB2_PROP1}", "test"]

View File

@ -0,0 +1 @@
foo: http://${BAR}

View File

@ -1,11 +1,18 @@
package oidc package oidc
import ( import (
"bytes"
"context" "context"
"crypto/tls"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strings"
"text/template"
"time"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director" "forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn" "forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store" "forge.cadoles.com/cadoles/bouncer/internal/store"
@ -17,7 +24,9 @@ import (
) )
type Authenticator struct { type Authenticator struct {
store sessions.Store store sessions.Store
httpTransport *http.Transport
httpClientTimeout time.Duration
} }
func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error { func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error {
@ -38,16 +47,30 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
logger.Error(ctx, "could not retrieve session", logger.E(errors.WithStack(err))) logger.Error(ctx, "could not retrieve session", logger.E(errors.WithStack(err)))
} }
redirectURL := a.getRedirectURL(layer.Proxy, layer.Name, originalURL, options) loginCallbackURL, err := a.getLoginCallbackURL(originalURL, layer.Proxy, layer.Name, options)
logoutURL := a.getLogoutURL(layer.Proxy, layer.Name, originalURL, options)
client, err := a.getClient(options, redirectURL.String())
if err != nil { if err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
switch r.URL.Path { client, err := a.getClient(options, loginCallbackURL.String())
case redirectURL.Path: if err != nil {
return errors.WithStack(err)
}
loginCallbackPathPattern, err := a.templatize(options.OIDC.MatchLoginCallbackPath, layer.Proxy, layer.Name)
if err != nil {
return errors.WithStack(err)
}
logoutPathPattern, err := a.templatize(options.OIDC.MatchLogoutPath, layer.Proxy, layer.Name)
if err != nil {
return errors.WithStack(err)
}
logger.Debug(ctx, "checking url", logger.F("loginCallbackPathPattern", loginCallbackPathPattern), logger.F("logoutPathPattern", logoutPathPattern))
switch {
case wildcard.Match(originalURL.Path, loginCallbackPathPattern):
if err := client.HandleCallback(w, r, sess); err != nil { if err := client.HandleCallback(w, r, sess); err != nil {
return errors.WithStack(err) return errors.WithStack(err)
} }
@ -57,10 +80,23 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
metricLabelProxy: string(layer.Proxy), metricLabelProxy: string(layer.Proxy),
}).Add(1) }).Add(1)
case logoutURL.Path: case wildcard.Match(originalURL.Path, logoutPathPattern):
postLogoutRedirectURL := options.OIDC.PostLogoutRedirectURL postLogoutRedirectURL := r.URL.Query().Get("redirect")
if options.OIDC.PostLogoutRedirectURL == "" {
postLogoutRedirectURL = originalURL.Scheme + "://" + originalURL.Host if postLogoutRedirectURL != "" {
isAuthorized := slices.Contains(options.OIDC.PostLogoutRedirectURLs, postLogoutRedirectURL)
if !isAuthorized {
http.Error(w, "unauthorized post-logout redirect", http.StatusBadRequest)
return errors.WithStack(authn.ErrSkipRequest)
}
}
if postLogoutRedirectURL == "" {
if options.OIDC.PublicBaseURL != "" {
postLogoutRedirectURL = options.OIDC.PublicBaseURL
} else {
postLogoutRedirectURL = originalURL.Scheme + "://" + originalURL.Host
}
} }
if err := client.HandleLogout(w, r, sess, postLogoutRedirectURL); err != nil { if err := client.HandleLogout(w, r, sess, postLogoutRedirectURL); err != nil {
@ -80,11 +116,6 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) { func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) {
ctx := r.Context() ctx := r.Context()
originalURL, err := director.OriginalURL(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
options, err := fromStoreOptions(layer.Options) options, err := fromStoreOptions(layer.Options)
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
@ -117,14 +148,27 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
sess.Options.SameSite = http.SameSiteDefaultMode sess.Options.SameSite = http.SameSiteDefaultMode
} }
redirectURL := a.getRedirectURL(layer.Proxy, layer.Name, originalURL, options) originalURL, err := director.OriginalURL(ctx)
client, err := a.getClient(options, redirectURL.String())
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }
idToken, err := client.Authenticate(w, r, sess) loginCallbackURL, err := a.getLoginCallbackURL(originalURL, layer.Proxy, layer.Name, options)
if err != nil {
return nil, errors.WithStack(err)
}
client, err := a.getClient(options, loginCallbackURL.String())
if err != nil {
return nil, errors.WithStack(err)
}
postLoginRedirectURL, err := a.mergeURL(originalURL, originalURL.Path, options.OIDC.PublicBaseURL, true)
if err != nil {
return nil, errors.WithStack(err)
}
idToken, err := client.Authenticate(w, r, sess, postLoginRedirectURL.String())
if err != nil { if err != nil {
if errors.Is(err, ErrLoginRequired) { if errors.Is(err, ErrLoginRequired) {
metricLoginRequestsTotal.With(prometheus.Labels{ metricLoginRequestsTotal.With(prometheus.Labels{
@ -138,7 +182,7 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }
user, err := a.toUser(idToken, layer.Proxy, layer.Name, originalURL, options, sess) user, err := a.toUser(originalURL, idToken, layer.Proxy, layer.Name, options, sess)
if err != nil { if err != nil {
return nil, errors.WithStack(err) return nil, errors.WithStack(err)
} }
@ -196,7 +240,7 @@ func (c claims) AsAttrs() map[string]any {
return attrs return attrs
} }
func (a *Authenticator) toUser(idToken *oidc.IDToken, proxyName store.ProxyName, layerName store.LayerName, originalURL *url.URL, options *LayerOptions, sess *sessions.Session) (*authn.User, error) { func (a *Authenticator) toUser(originalURL *url.URL, idToken *oidc.IDToken, proxyName store.ProxyName, layerName store.LayerName, options *LayerOptions, sess *sessions.Session) (*authn.User, error) {
var claims claims var claims claims
if err := idToken.Claims(&claims); err != nil { if err := idToken.Claims(&claims); err != nil {
@ -209,7 +253,11 @@ func (a *Authenticator) toUser(idToken *oidc.IDToken, proxyName store.ProxyName,
attrs := claims.AsAttrs() attrs := claims.AsAttrs()
logoutURL := a.getLogoutURL(proxyName, layerName, originalURL, options) logoutURL, err := a.getLogoutURL(originalURL, proxyName, layerName, options)
if err != nil {
return nil, errors.WithStack(err)
}
attrs["logout_url"] = logoutURL.String() attrs["logout_url"] = logoutURL.String()
if accessToken, exists := sess.Values[sessionKeyAccessToken]; exists && accessToken != nil { if accessToken, exists := sess.Values[sessionKeyAccessToken]; exists && accessToken != nil {
@ -229,25 +277,109 @@ func (a *Authenticator) toUser(idToken *oidc.IDToken, proxyName store.ProxyName,
return user, nil return user, nil
} }
func (a *Authenticator) getRedirectURL(proxyName store.ProxyName, layerName store.LayerName, u *url.URL, options *LayerOptions) *url.URL { func (a *Authenticator) getLoginCallbackURL(originalURL *url.URL, proxyName store.ProxyName, layerName store.LayerName, options *LayerOptions) (*url.URL, error) {
return &url.URL{ path, err := a.templatize(options.OIDC.LoginCallbackPath, proxyName, layerName)
Scheme: u.Scheme, if err != nil {
Host: u.Host, return nil, errors.WithStack(err)
Path: fmt.Sprintf(options.OIDC.LoginCallbackPath, fmt.Sprintf("%s/%s", proxyName, layerName)),
} }
merged, err := a.mergeURL(originalURL, path, options.OIDC.PublicBaseURL, false)
if err != nil {
return nil, errors.WithStack(err)
}
return merged, nil
} }
func (a *Authenticator) getLogoutURL(proxyName store.ProxyName, layerName store.LayerName, u *url.URL, options *LayerOptions) *url.URL { func (a *Authenticator) getLogoutURL(originalURL *url.URL, proxyName store.ProxyName, layerName store.LayerName, options *LayerOptions) (*url.URL, error) {
return &url.URL{ path, err := a.templatize(options.OIDC.LogoutPath, proxyName, layerName)
Scheme: u.Scheme, if err != nil {
Host: u.Host, return nil, errors.WithStack(err)
Path: fmt.Sprintf(options.OIDC.LogoutPath, fmt.Sprintf("%s/%s", proxyName, layerName)),
} }
merged, err := a.mergeURL(originalURL, path, options.OIDC.PublicBaseURL, true)
if err != nil {
return nil, errors.WithStack(err)
}
return merged, nil
}
func (a *Authenticator) mergeURL(base *url.URL, path string, overlay string, withQuery bool) (*url.URL, error) {
merged := &url.URL{
Scheme: base.Scheme,
Host: base.Host,
Path: path,
}
if withQuery {
merged.RawQuery = base.RawQuery
}
if overlay != "" {
overlayURL, err := url.Parse(overlay)
if err != nil {
return nil, errors.WithStack(err)
}
merged.Scheme = overlayURL.Scheme
merged.Host = overlayURL.Host
merged.Path = overlayURL.Path + strings.TrimPrefix(path, "/")
for key, values := range overlayURL.Query() {
query := merged.Query()
for _, v := range values {
query.Add(key, v)
}
merged.RawQuery = query.Encode()
}
}
return merged, nil
}
func (a *Authenticator) templatize(rawTemplate string, proxyName store.ProxyName, layerName store.LayerName) (string, error) {
tmpl, err := template.New("").Parse(rawTemplate)
if err != nil {
return "", errors.WithStack(err)
}
var raw bytes.Buffer
err = tmpl.Execute(&raw, struct {
ProxyName store.ProxyName
LayerName store.LayerName
}{
ProxyName: proxyName,
LayerName: layerName,
})
if err != nil {
return "", errors.WithStack(err)
}
return raw.String(), nil
} }
func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*Client, error) { func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*Client, error) {
ctx := context.Background() ctx := context.Background()
transport := a.httpTransport.Clone()
if options.OIDC.TLSInsecureSkipVerify {
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.InsecureSkipVerify = true
}
httpClient := &http.Client{
Timeout: a.httpClientTimeout,
Transport: transport,
}
ctx = oidc.ClientContext(ctx, httpClient)
if options.OIDC.SkipIssuerVerification { if options.OIDC.SkipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL) ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
} }
@ -263,6 +395,7 @@ func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*C
WithRedirectURL(redirectURL), WithRedirectURL(redirectURL),
WithScopes(options.OIDC.Scopes...), WithScopes(options.OIDC.Scopes...),
WithAuthParams(options.OIDC.AuthParams), WithAuthParams(options.OIDC.AuthParams),
WithHTTPClient(httpClient),
) )
return client, nil return client, nil

View File

@ -6,7 +6,6 @@ import (
"net/url" "net/url"
"strings" "strings"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"github.com/dchest/uniuri" "github.com/dchest/uniuri"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
@ -30,6 +29,7 @@ var (
) )
type Client struct { type Client struct {
httpClient *http.Client
oauth2 *oauth2.Config oauth2 *oauth2.Config
provider *oidc.Provider provider *oidc.Provider
verifier *oidc.IDTokenVerifier verifier *oidc.IDTokenVerifier
@ -44,12 +44,12 @@ func (c *Client) Provider() *oidc.Provider {
return c.provider return c.provider
} }
func (c *Client) Authenticate(w http.ResponseWriter, r *http.Request, sess *sessions.Session) (*oidc.IDToken, error) { func (c *Client) Authenticate(w http.ResponseWriter, r *http.Request, sess *sessions.Session, postLoginRedirectURL string) (*oidc.IDToken, error) {
idToken, err := c.getIDToken(r, sess) idToken, err := c.getIDToken(r, sess)
if err != nil { if err != nil {
logger.Error(r.Context(), "could not retrieve idtoken", logger.E(errors.WithStack(err))) logger.Warn(r.Context(), "could not retrieve idtoken", logger.E(errors.WithStack(err)))
c.login(w, r, sess) c.login(w, r, sess, postLoginRedirectURL)
return nil, errors.WithStack(ErrLoginRequired) return nil, errors.WithStack(ErrLoginRequired)
} }
@ -57,23 +57,15 @@ func (c *Client) Authenticate(w http.ResponseWriter, r *http.Request, sess *sess
return idToken, nil return idToken, nil
} }
func (c *Client) login(w http.ResponseWriter, r *http.Request, sess *sessions.Session) { func (c *Client) login(w http.ResponseWriter, r *http.Request, sess *sessions.Session, postLoginRedirectURL string) {
ctx := r.Context() ctx := r.Context()
state := uniuri.New() state := uniuri.New()
nonce := uniuri.New() nonce := uniuri.New()
originalURL, err := director.OriginalURL(ctx)
if err != nil {
logger.Error(ctx, "could not retrieve original url", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
sess.Values[sessionKeyLoginState] = state sess.Values[sessionKeyLoginState] = state
sess.Values[sessionKeyLoginNonce] = nonce sess.Values[sessionKeyLoginNonce] = nonce
sess.Values[sessionKeyPostLoginRedirectURL] = originalURL.String() sess.Values[sessionKeyPostLoginRedirectURL] = postLoginRedirectURL
if err := sess.Save(r, w); err != nil { if err := sess.Save(r, w); err != nil {
logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err))) logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err)))
@ -210,6 +202,7 @@ func (c *Client) sessionEndURL(idTokenHint, state, postLogoutRedirectURL string)
func (c *Client) validate(r *http.Request, sess *sessions.Session) (*oauth2.Token, *oidc.IDToken, string, error) { func (c *Client) validate(r *http.Request, sess *sessions.Session) (*oauth2.Token, *oidc.IDToken, string, error) {
ctx := r.Context() ctx := r.Context()
ctx = oidc.ClientContext(ctx, c.httpClient)
rawStoredState := sess.Values[sessionKeyLoginState] rawStoredState := sess.Values[sessionKeyLoginState]
receivedState := r.URL.Query().Get("state") receivedState := r.URL.Query().Get("state")
@ -246,7 +239,7 @@ func (c *Client) validate(r *http.Request, sess *sessions.Session) (*oauth2.Toke
func (c *Client) getRawIDToken(sess *sessions.Session) (string, error) { func (c *Client) getRawIDToken(sess *sessions.Session) (string, error) {
rawIDToken, ok := sess.Values[sessionKeyIDToken].(string) rawIDToken, ok := sess.Values[sessionKeyIDToken].(string)
if !ok || rawIDToken == "" { if !ok || rawIDToken == "" {
return "", errors.New("invalid id token") return "", errors.New("id token not found")
} }
return rawIDToken, nil return rawIDToken, nil
@ -287,5 +280,6 @@ func NewClient(funcs ...ClientOptionFunc) *Client {
provider: opts.Provider, provider: opts.Provider,
verifier: verifier, verifier: verifier,
authParams: opts.AuthParams, authParams: opts.AuthParams,
httpClient: opts.HTTPClient,
} }
} }

View File

@ -2,6 +2,7 @@ package oidc
import ( import (
"context" "context"
"net/http"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
) )
@ -14,6 +15,7 @@ type ClientOptions struct {
Scopes []string Scopes []string
AuthParams map[string]string AuthParams map[string]string
SkipIssuerCheck bool SkipIssuerCheck bool
HTTPClient *http.Client
} }
type ClientOptionFunc func(*ClientOptions) type ClientOptionFunc func(*ClientOptions)
@ -63,9 +65,16 @@ func WithProvider(provider *oidc.Provider) ClientOptionFunc {
} }
} }
func WithHTTPClient(client *http.Client) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.HTTPClient = client
}
}
func NewClientOptions(funcs ...ClientOptionFunc) *ClientOptions { func NewClientOptions(funcs ...ClientOptionFunc) *ClientOptions {
opt := &ClientOptions{ opt := &ClientOptions{
Scopes: []string{oidc.ScopeOpenID, "profile"}, Scopes: []string{oidc.ScopeOpenID, "profile"},
HTTPClient: http.DefaultClient,
} }
for _, f := range funcs { for _, f := range funcs {

View File

@ -17,9 +17,13 @@
"title": "URL de base du fournisseur OpenID Connect (racine du .well-known/openid-configuration)", "title": "URL de base du fournisseur OpenID Connect (racine du .well-known/openid-configuration)",
"type": "string" "type": "string"
}, },
"postLogoutRedirectURL": { "postLogoutRedirectURLs": {
"title": "URL de redirection après déconnexion", "title": "URLs de redirection après déconnexion autorisées",
"type": "string" "description": "La variable d'URL 'redirect=<url>' peut être utilisée pour spécifier une redirection après déconnexion.",
"type": "array",
"item": {
"type": "string"
}
}, },
"scopes": { "scopes": {
"title": "Scopes associés au client OpenID Connect", "title": "Scopes associés au client OpenID Connect",
@ -44,20 +48,43 @@
}, },
"loginCallbackPath": { "loginCallbackPath": {
"title": "Chemin associé à l'URL de callback OpenID Connect", "title": "Chemin associé à l'URL de callback OpenID Connect",
"default": "/.bouncer/authn/oidc/%s/callback", "default": "/.bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/callback",
"description": "Le marqueur '%s' peut être utilisé pour injecter l'espace de nom '<proxy>/<layer>'.", "description": "Les marqueurs '{{ .ProxyName }}' et '{{ .LayerName }}' peuvent être utilisés pour injecter le nom du proxy ainsi que celui du layer.",
"type": "string"
},
"matchLoginCallbackPath": {
"title": "Patron de correspondance du chemin interne de callback OpenID Connect",
"default": "*.bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/callback",
"description": "Les marqueurs '{{ .ProxyName }}' et '{{ .LayerName }}' peuvent être utilisés pour injecter le nom du proxy ainsi que celui du layer.",
"type": "string" "type": "string"
}, },
"logoutPath": { "logoutPath": {
"title": "Chemin associé à l'URL de déconnexion", "title": "Chemin associé à l'URL de déconnexion",
"default": "/.bouncer/authn/oidc/%s/logout", "default": "/.bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/logout",
"description": "Le marqueur '%s' peut être utilisé pour injecter l'espace de nom '<proxy>/<layer>'.", "description": "Les marqueurs '{{ .ProxyName }}' et '{{ .LayerName }}' peuvent être utilisés pour injecter le nom du proxy ainsi que celui du layer.",
"type": "string"
},
"publicBaseURL": {
"title": "URL publique de base associée au service distant",
"default": "",
"description": "Peut être utilisé par exemple si il y a discordance de nom d'hôte ou de chemin sur les URLs publiques/internes.",
"type": "string"
},
"matchLogoutPath": {
"title": "Patron de correspondance du chemin interne de déconnexion",
"default": "*.bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/logout",
"description": "Les marqueurs '{{ .ProxyName }}' et '{{ .LayerName }}' peuvent être utilisés pour injecter le nom du proxy ainsi que celui du layer.",
"type": "string" "type": "string"
}, },
"skipIssuerVerification": { "skipIssuerVerification": {
"title": "Activer/désactiver la vérification de concordance de l'identifiant du fournisseur d'identité", "title": "Activer/désactiver la vérification de concordance de l'identifiant du fournisseur d'identité",
"default": false, "default": false,
"type": "boolean" "type": "boolean"
},
"tlsInsecureSkipVerify": {
"title": "Activer/désactiver la vérification du certificat TLS distant",
"default": false,
"type": "boolean"
} }
}, },
"additionalProperties": false, "additionalProperties": false,

View File

@ -8,6 +8,11 @@ import (
const LayerType store.LayerType = "authn-oidc" const LayerType store.LayerType = "authn-oidc"
func NewLayer(store sessions.Store) *authn.Layer { func NewLayer(store sessions.Store, funcs ...OptionFunc) *authn.Layer {
return authn.NewLayer(LayerType, &Authenticator{store: store}) opts := NewOptions(funcs...)
return authn.NewLayer(LayerType, &Authenticator{
httpTransport: opts.HTTPTransport,
httpClientTimeout: opts.HTTPClientTimeout,
store: store,
})
} }

View File

@ -19,11 +19,15 @@ type LayerOptions struct {
type OIDCOptions struct { type OIDCOptions struct {
ClientID string `mapstructure:"clientId"` ClientID string `mapstructure:"clientId"`
ClientSecret string `mapstructure:"clientSecret"` ClientSecret string `mapstructure:"clientSecret"`
PublicBaseURL string `mapstructure:"publicBaseURL"`
LoginCallbackPath string `mapstructure:"loginCallbackPath"` LoginCallbackPath string `mapstructure:"loginCallbackPath"`
MatchLoginCallbackPath string `mapstructure:"matchLoginCallbackPath"`
LogoutPath string `mapstructure:"logoutPath"` LogoutPath string `mapstructure:"logoutPath"`
MatchLogoutPath string `mapstructure:"matchLogoutPath"`
IssuerURL string `mapstructure:"issuerURL"` IssuerURL string `mapstructure:"issuerURL"`
SkipIssuerVerification bool `mapstructure:"skipIssuerVerification"` SkipIssuerVerification bool `mapstructure:"skipIssuerVerification"`
PostLogoutRedirectURL string `mapstructure:"postLogoutRedirectURL"` PostLogoutRedirectURLs []string `mapstructure:"postLogoutRedirectURLs"`
TLSInsecureSkipVerify bool `mapstructure:"tlsInsecureSkipVerify"`
Scopes []string `mapstructure:"scopes"` Scopes []string `mapstructure:"scopes"`
AuthParams map[string]string `mapstructure:"authParams"` AuthParams map[string]string `mapstructure:"authParams"`
} }
@ -39,12 +43,18 @@ type CookieOptions struct {
} }
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) { func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
loginCallbackPath := ".bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/callback"
logoutPath := ".bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/logout"
layerOptions := LayerOptions{ layerOptions := LayerOptions{
LayerOptions: authn.DefaultLayerOptions(), LayerOptions: authn.DefaultLayerOptions(),
OIDC: OIDCOptions{ OIDC: OIDCOptions{
LoginCallbackPath: "/.bouncer/authn/oidc/%s/callback", PublicBaseURL: "",
LogoutPath: "/.bouncer/authn/oidc/%s/logout", LoginCallbackPath: loginCallbackPath,
Scopes: []string{"openid"}, MatchLoginCallbackPath: "*" + loginCallbackPath,
LogoutPath: logoutPath,
MatchLogoutPath: "*" + logoutPath,
Scopes: []string{"openid"},
}, },
Cookie: CookieOptions{ Cookie: CookieOptions{
Name: defaultCookieName, Name: defaultCookieName,

View File

@ -0,0 +1,38 @@
package oidc
import (
"net/http"
"time"
)
type Options struct {
HTTPTransport *http.Transport
HTTPClientTimeout time.Duration
}
type OptionFunc func(opts *Options)
func WithHTTPTransport(transport *http.Transport) OptionFunc {
return func(opts *Options) {
opts.HTTPTransport = transport
}
}
func WithHTTPClientTimeout(timeout time.Duration) OptionFunc {
return func(opts *Options) {
opts.HTTPClientTimeout = timeout
}
}
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
HTTPTransport: http.DefaultTransport.(*http.Transport),
HTTPClientTimeout: 30 * time.Second,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}

View File

@ -159,26 +159,10 @@ func (s *Server) createReverseProxy(ctx context.Context, target *url.URL) *httpu
DualStack: bool(dialConfig.DualStack), DualStack: bool(dialConfig.DualStack),
} }
transportConfig := s.serverConfig.Transport httpTransport := s.serverConfig.Transport.AsTransport()
httpTransport.DialContext = dialer.DialContext
reverseProxy.Transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: bool(transportConfig.ForceAttemptHTTP2),
MaxIdleConns: int(transportConfig.MaxIdleConns),
MaxIdleConnsPerHost: int(transportConfig.MaxIdleConnsPerHost),
MaxConnsPerHost: int(transportConfig.MaxConnsPerHost),
IdleConnTimeout: time.Duration(*transportConfig.IdleConnTimeout),
TLSHandshakeTimeout: time.Duration(*transportConfig.TLSHandshakeTimeout),
ExpectContinueTimeout: time.Duration(*transportConfig.ExpectContinueTimeout),
DisableKeepAlives: bool(transportConfig.DisableKeepAlives),
DisableCompression: bool(transportConfig.DisableCompression),
ResponseHeaderTimeout: time.Duration(*transportConfig.ResponseHeaderTimeout),
WriteBufferSize: int(transportConfig.WriteBufferSize),
ReadBufferSize: int(transportConfig.ReadBufferSize),
MaxResponseHeaderBytes: int64(transportConfig.MaxResponseHeaderBytes),
}
reverseProxy.Transport = httpTransport
reverseProxy.ErrorHandler = s.errorHandler reverseProxy.ErrorHandler = s.errorHandler
return reverseProxy return reverseProxy

View File

@ -1,6 +1,8 @@
package setup package setup
import ( import (
"time"
"forge.cadoles.com/cadoles/bouncer/internal/config" "forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director" "forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn" "forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
@ -25,5 +27,11 @@ func setupAuthnOIDCLayer(conf *config.Config) (director.Layer, error) {
adapter := redis.NewStoreAdapter(rdb) adapter := redis.NewStoreAdapter(rdb)
store := session.NewStore(adapter) store := session.NewStore(adapter)
return oidc.NewLayer(store), nil transport := conf.Layers.Authn.OIDC.HTTPClient.AsTransport()
return oidc.NewLayer(
store,
oidc.WithHTTPTransport(transport),
oidc.WithHTTPClientTimeout(time.Duration(*conf.Layers.Authn.OIDC.HTTPClient.Timeout)),
), nil
} }