Compare commits

...

4 Commits

Author SHA1 Message Date
a50f926463 feat: allow bypassing of basic auth from a list of authorized cidrs (#50)
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-08-05 16:24:41 +02:00
9d10a69b0d chore: update go and alpine docker image version
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-04-03 11:12:32 +02:00
8b6e75ae77 feat: use sentry tags instead of context for better observability
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-03-19 15:04:55 +01:00
692523e54f feat: prevent call bursts on oidc provider refresh
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-03-18 15:51:25 +01:00
13 changed files with 423 additions and 180 deletions

View File

@ -1,4 +1,4 @@
FROM reg.cadoles.com/proxy_cache/library/golang:1.23 AS BUILD
FROM reg.cadoles.com/proxy_cache/library/golang:1.24.2 AS build
RUN apt-get update \
&& apt-get install -y make
@ -33,7 +33,7 @@ RUN /src/dist/bouncer_linux_amd64_v1/bouncer -c '' config dump > /src/dist/bounc
&& 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.20 AS RUNTIME
FROM reg.cadoles.com/proxy_cache/library/alpine:3.21 AS runtime
RUN apk add --no-cache ca-certificates dumb-init
@ -41,10 +41,10 @@ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
RUN mkdir -p /usr/local/bin /usr/share/bouncer/bin /etc/bouncer
COPY --from=BUILD /src/dist/bouncer_linux_amd64_v1/bouncer /usr/share/bouncer/bin/bouncer
COPY --from=BUILD /src/layers /usr/share/bouncer/layers
COPY --from=BUILD /src/templates /usr/share/bouncer/templates
COPY --from=BUILD /src/dist/bouncer_linux_amd64_v1/config.yml /etc/bouncer/config.yml
COPY --from=build /src/dist/bouncer_linux_amd64_v1/bouncer /usr/share/bouncer/bin/bouncer
COPY --from=build /src/layers /usr/share/bouncer/layers
COPY --from=build /src/templates /usr/share/bouncer/templates
COPY --from=build /src/dist/bouncer_linux_amd64_v1/config.yml /etc/bouncer/config.yml
RUN ln -s /usr/share/bouncer/bin/bouncer /usr/local/bin/bouncer

42
internal/cidr/match.go Normal file
View File

@ -0,0 +1,42 @@
package cidr
import (
"net"
"strings"
"github.com/pkg/errors"
)
func MatchAny(hostPort string, CIDRs ...string) (bool, error) {
var remoteHost string
if strings.Contains(hostPort, ":") {
var err error
remoteHost, _, err = net.SplitHostPort(hostPort)
if err != nil {
return false, errors.WithStack(err)
}
} else {
remoteHost = hostPort
}
remoteAddr := net.ParseIP(remoteHost)
if remoteAddr == nil {
return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost)
}
for _, rawCIDR := range CIDRs {
_, net, err := net.ParseCIDR(rawCIDR)
if err != nil {
return false, errors.WithStack(err)
}
match := net.Contains(remoteAddr)
if !match {
continue
}
return true, nil
}
return false, nil
}

View File

@ -1,15 +1,13 @@
package network
package cidr
import (
"context"
"fmt"
"testing"
"github.com/pkg/errors"
)
func TestMatchAuthorizedCIDRs(t *testing.T) {
func TestMatchAny(t *testing.T) {
type testCase struct {
RemoteHostPort string
AuthorizedCIDRs []string
@ -56,14 +54,16 @@ func TestMatchAuthorizedCIDRs(t *testing.T) {
},
ExpectedResult: false,
},
{
RemoteHostPort: "[2001:0db8:0000:85a3:0000:0000:ac1f:8001]:8001",
AuthorizedCIDRs: []string{"2000::/3"},
ExpectedResult: true,
},
}
auth := Authenticator{}
ctx := context.Background()
for idx, tc := range testCases {
t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
result, err := auth.matchAnyAuthorizedCIDRs(ctx, tc.RemoteHostPort, tc.AuthorizedCIDRs)
result, err := MatchAny(tc.RemoteHostPort, tc.AuthorizedCIDRs...)
if g, e := result, tc.ExpectedResult; e != g {
t.Errorf("result: expected '%v', got '%v'", e, g)

View File

@ -4,12 +4,11 @@ import (
"context"
"net/http"
"sort"
"sync"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/cache"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"forge.cadoles.com/cadoles/bouncer/internal/syncx"
"github.com/getsentry/sentry-go"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
@ -21,19 +20,18 @@ type Director struct {
layerRepository store.LayerRepository
layerRegistry *LayerRegistry
proxyCache cache.Cache[string, []*store.Proxy]
layerCache cache.Cache[string, []*store.Layer]
proxyCacheLock sync.RWMutex
layerCacheLock sync.RWMutex
cachedProxies *syncx.CachedResource[string, []*store.Proxy]
cachedLayers *syncx.CachedResource[string, []*store.Layer]
handleError HandleErrorFunc
}
const proxiesCacheKey = "proxies"
func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
ctx := r.Context()
proxies, err := d.getProxies(ctx)
proxies, _, err := d.cachedProxies.Get(ctx, proxiesCacheKey)
if err != nil {
return r, errors.WithStack(err)
}
@ -58,7 +56,7 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
metricProxyRequestsTotal.With(prometheus.Labels{metricLabelProxy: string(p.Name)}).Add(1)
proxyLayers, err := d.getLayers(proxyCtx, p.Name)
proxyLayers, _, err := d.cachedLayers.Get(proxyCtx, string(p.Name))
if err != nil {
return r, errors.WithStack(err)
}
@ -82,9 +80,10 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
r = r.WithContext(proxyCtx)
if sentryScope, _ := SentryScope(ctx); sentryScope != nil {
sentryScope.SetContext("bouncer", sentry.Context{
"proxy_name": p.Name,
"proxy_target": r.URL.String(),
sentryScope.SetTags(map[string]string{
"bouncer.proxy.name": string(p.Name),
"bouncer.proxy.target.url": r.URL.String(),
"bouncer.proxy.target.host": r.URL.Host,
})
}
@ -98,35 +97,7 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
return r, nil
}
const proxiesCacheKey = "proxies"
func (d *Director) getProxies(ctx context.Context) ([]*store.Proxy, error) {
proxies, exists := d.proxyCache.Get(proxiesCacheKey)
if exists {
logger.Debug(ctx, "using cached proxies")
return proxies, nil
}
locked := d.proxyCacheLock.TryLock()
if !locked {
d.proxyCacheLock.RLock()
proxies, exists := d.proxyCache.Get(proxiesCacheKey)
if exists {
d.proxyCacheLock.RUnlock()
logger.Debug(ctx, "using cached proxies")
return proxies, nil
}
d.proxyCacheLock.RUnlock()
}
if !locked {
d.proxyCacheLock.Lock()
}
defer d.proxyCacheLock.Unlock()
func (d *Director) getProxies(ctx context.Context, key string) ([]*store.Proxy, error) {
logger.Debug(ctx, "querying fresh proxies")
headers, err := d.proxyRepository.QueryProxy(ctx, store.WithProxyQueryEnabled(true))
@ -136,7 +107,7 @@ func (d *Director) getProxies(ctx context.Context) ([]*store.Proxy, error) {
sort.Sort(store.ByProxyWeight(headers))
proxies = make([]*store.Proxy, 0, len(headers))
proxies := make([]*store.Proxy, 0, len(headers))
for _, h := range headers {
if !h.Enabled {
@ -151,39 +122,11 @@ func (d *Director) getProxies(ctx context.Context) ([]*store.Proxy, error) {
proxies = append(proxies, proxy)
}
d.proxyCache.Set(proxiesCacheKey, proxies)
return proxies, nil
}
func (d *Director) getLayers(ctx context.Context, proxyName store.ProxyName) ([]*store.Layer, error) {
cacheKey := "layers-" + string(proxyName)
layers, exists := d.layerCache.Get(cacheKey)
if exists {
logger.Debug(ctx, "using cached layers")
return layers, nil
}
locked := d.layerCacheLock.TryLock()
if !locked {
d.layerCacheLock.RLock()
layers, exists := d.layerCache.Get(cacheKey)
if exists {
d.layerCacheLock.RUnlock()
logger.Debug(ctx, "using cached layers")
return layers, nil
}
d.layerCacheLock.RUnlock()
}
if !locked {
d.layerCacheLock.Lock()
}
defer d.layerCacheLock.Unlock()
func (d *Director) getLayers(ctx context.Context, rawProxyName string) ([]*store.Layer, error) {
proxyName := store.ProxyName(rawProxyName)
logger.Debug(ctx, "querying fresh layers")
@ -194,7 +137,7 @@ func (d *Director) getLayers(ctx context.Context, proxyName store.ProxyName) ([]
sort.Sort(store.ByLayerWeight(headers))
layers = make([]*store.Layer, 0, len(headers))
layers := make([]*store.Layer, 0, len(headers))
for _, h := range headers {
if !h.Enabled {
@ -209,8 +152,6 @@ func (d *Director) getLayers(ctx context.Context, proxyName store.ProxyName) ([]
layers = append(layers, layer)
}
d.layerCache.Set(cacheKey, layers)
return layers, nil
}
@ -322,12 +263,15 @@ func New(proxyRepository store.ProxyRepository, layerRepository store.LayerRepos
registry := NewLayerRegistry(opts.Layers...)
return &Director{
director := &Director{
proxyRepository: proxyRepository,
layerRepository: layerRepository,
layerRegistry: registry,
proxyCache: opts.ProxyCache,
layerCache: opts.LayerCache,
handleError: opts.HandleError,
}
director.cachedProxies = syncx.NewCachedResource(opts.ProxyCache, director.getProxies)
director.cachedLayers = syncx.NewCachedResource(opts.LayerCache, director.getLayers)
return director
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/cidr"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
@ -23,6 +24,16 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
return nil, errors.WithStack(err)
}
matches, err := cidr.MatchAny(r.RemoteAddr, options.AuthorizedCIDRs...)
if err != nil {
return nil, errors.WithStack(err)
}
if matches {
user := authn.NewUser(r.RemoteAddr, map[string]any{})
return user, nil
}
username, password, ok := r.BasicAuth()
unauthorized := func() {

View File

@ -0,0 +1,130 @@
package basic
import (
"encoding/base64"
"net/http/httptest"
"testing"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func TestAuthenticatorWithCredentials(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
r.Header.Set("Authorization", "Basic "+basicAuth("foo", "bar"))
authenticator := &Authenticator{}
layer := &store.Layer{
LayerHeader: store.LayerHeader{
Proxy: "test",
Name: "test",
Revision: 0,
Type: LayerType,
Enabled: true,
},
Options: store.LayerOptions{
"users": []map[string]any{
{
"username": "foo",
"passwordHash": "$2y$10$S3CfWRRMbOrOu3zUapZnfeU8xLtjH.MycWcvMRVHdc9RAty8lnn5q",
},
},
},
}
user, err := authenticator.Authenticate(w, r, layer)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if user == nil {
t.Fatalf("user should not be nil")
}
if e, g := "foo", user.Subject; e != g {
t.Fatalf("user.Subject: expected '%v', got '%v'", e, g)
}
r = httptest.NewRequest("GET", "/", nil)
w = httptest.NewRecorder()
r.Header.Set("Authorization", "Basic "+basicAuth("foo", "qsdq;sdqks"))
user, err = authenticator.Authenticate(w, r, layer)
if err == nil {
t.Errorf("err should not be nil")
}
if !errors.Is(err, authn.ErrSkipRequest) {
t.Errorf("err: expected %T, got %T", authn.ErrSkipRequest, err)
}
if user != nil {
t.Errorf("user should be nil")
}
}
func TestAuthenticatorWithAuthorizedRemoteAddr(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
// Authorized address
r.RemoteAddr = "192.168.30.21"
authenticator := &Authenticator{}
layer := &store.Layer{
LayerHeader: store.LayerHeader{
Proxy: "test",
Name: "test",
Revision: 0,
Type: LayerType,
Enabled: true,
},
Options: store.LayerOptions{
"users": []map[string]any{},
"authorizedCIDRs": []string{"192.168.30.1/24"},
},
}
user, err := authenticator.Authenticate(w, r, layer)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if user == nil {
t.Fatalf("user should not be nil")
}
if e, g := "192.168.30.21", user.Subject; e != g {
t.Fatalf("user.Subject: expected '%v', got '%v'", e, g)
}
r = httptest.NewRequest("GET", "/", nil)
w = httptest.NewRecorder()
// Unauthorized address
r.RemoteAddr = "192.168.40.36"
user, err = authenticator.Authenticate(w, r, layer)
if err == nil {
t.Errorf("err should not be nil")
}
if !errors.Is(err, authn.ErrSkipRequest) {
t.Errorf("err: expected %T, got %T", authn.ErrSkipRequest, err)
}
if user != nil {
t.Errorf("user should be nil")
}
}
func basicAuth(username, password string) string {
auth := username + ":" + password
return base64.StdEncoding.EncodeToString([]byte(auth))
}

View File

@ -34,6 +34,14 @@
],
"additionalProperties": false
}
},
"authorizedCIDRs": {
"title": "Liste des adresses réseau d'origine autorisées à contourner l'authentification (au format CIDR)",
"default": [],
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false

View File

@ -9,8 +9,9 @@ import (
type LayerOptions struct {
authn.LayerOptions
Users []User `mapstructure:"users"`
Realm string `mapstructure:"realm"`
Users []User `mapstructure:"users"`
Realm string `mapstructure:"realm"`
AuthorizedCIDRs []string `mapstructure:"authorizedCIDRs"`
}
type User struct {
@ -21,9 +22,10 @@ type User struct {
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := LayerOptions{
LayerOptions: authn.DefaultLayerOptions(),
Realm: "Restricted area",
Users: make([]User, 0),
LayerOptions: authn.DefaultLayerOptions(),
Realm: "Restricted area",
Users: make([]User, 0),
AuthorizedCIDRs: make([]string, 0),
}
config := mapstructure.DecoderConfig{

View File

@ -1,16 +1,13 @@
package network
import (
"context"
"net"
"net/http"
"strings"
"forge.cadoles.com/cadoles/bouncer/internal/cidr"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"gitlab.com/wpetit/goweb/logger"
)
type Authenticator struct {
@ -18,14 +15,12 @@ type Authenticator struct {
// Authenticate implements authn.Authenticator.
func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) {
ctx := r.Context()
options, err := fromStoreOptions(layer.Options)
if err != nil {
return nil, errors.WithStack(err)
}
matches, err := a.matchAnyAuthorizedCIDRs(ctx, r.RemoteAddr, options.AuthorizedCIDRs)
matches, err := cidr.MatchAny(r.RemoteAddr, options.AuthorizedCIDRs...)
if err != nil {
return nil, errors.WithStack(err)
}
@ -49,42 +44,6 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
return user, nil
}
func (a *Authenticator) matchAnyAuthorizedCIDRs(ctx context.Context, remoteHostPort string, CIDRs []string) (bool, error) {
var remoteHost string
if strings.Contains(remoteHostPort, ":") {
var err error
remoteHost, _, err = net.SplitHostPort(remoteHostPort)
if err != nil {
return false, errors.WithStack(err)
}
} else {
remoteHost = remoteHostPort
}
remoteAddr := net.ParseIP(remoteHost)
if remoteAddr == nil {
return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost)
}
for _, rawCIDR := range CIDRs {
_, net, err := net.ParseCIDR(rawCIDR)
if err != nil {
return false, errors.WithStack(err)
}
match := net.Contains(remoteAddr)
if !match {
continue
}
return true, nil
}
logger.Debug(ctx, "comparing remote host with authorized cidrs", logger.F("remoteAddr", remoteAddr))
return false, nil
}
var (
_ authn.Authenticator = &Authenticator{}
)

View File

@ -13,10 +13,12 @@ import (
"time"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/cache"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
"forge.cadoles.com/cadoles/bouncer/internal/cache/ttl"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"forge.cadoles.com/cadoles/bouncer/internal/syncx"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/sessions"
"github.com/pkg/errors"
@ -25,10 +27,10 @@ import (
)
type Authenticator struct {
store sessions.Store
httpTransport *http.Transport
httpClientTimeout time.Duration
oidcProviderCache cache.Cache[string, *oidc.Provider]
store sessions.Store
httpTransport *http.Transport
httpClientTimeout time.Duration
cachedOIDCProvider *syncx.CachedResource[string, *oidc.Provider]
}
func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error {
@ -54,7 +56,7 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
return errors.WithStack(err)
}
client, err := a.getClient(options, loginCallbackURL.String())
client, err := a.getClient(ctx, options, loginCallbackURL.String())
if err != nil {
return errors.WithStack(err)
}
@ -160,7 +162,7 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
return nil, errors.WithStack(err)
}
client, err := a.getClient(options, loginCallbackURL.String())
client, err := a.getClient(ctx, options, loginCallbackURL.String())
if err != nil {
return nil, errors.WithStack(err)
}
@ -362,9 +364,7 @@ func (a *Authenticator) templatize(rawTemplate string, proxyName store.ProxyName
return raw.String(), nil
}
func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*Client, error) {
ctx := context.Background()
func (a *Authenticator) getClient(ctx context.Context, options *LayerOptions, redirectURL string) (*Client, error) {
transport := a.httpTransport.Clone()
if options.OIDC.TLSInsecureSkipVerify {
@ -375,28 +375,24 @@ func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*C
transport.TLSClientConfig.InsecureSkipVerify = true
}
if options.OIDC.SkipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
}
httpClient := &http.Client{
Timeout: a.httpClientTimeout,
Transport: transport,
}
provider, exists := a.oidcProviderCache.Get(options.OIDC.IssuerURL)
if !exists {
var err error
ctx = oidc.ClientContext(ctx, httpClient)
ctx = oidc.ClientContext(ctx, httpClient)
if options.OIDC.SkipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
}
if options.OIDC.SkipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
}
logger.Debug(ctx, "refreshing oidc provider", logger.F("issuerURL", options.OIDC.IssuerURL))
provider, err = oidc.NewProvider(ctx, options.OIDC.IssuerURL)
if err != nil {
return nil, errors.Wrap(err, "could not create oidc provider")
}
a.oidcProviderCache.Set(options.OIDC.IssuerURL, provider)
provider, _, err := a.cachedOIDCProvider.Get(ctx, options.OIDC.IssuerURL)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve oidc provider")
}
client := NewClient(
@ -411,6 +407,17 @@ func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*C
return client, nil
}
func (a *Authenticator) getOIDCProvider(ctx context.Context, issuerURL string) (*oidc.Provider, error) {
logger.Debug(ctx, "refreshing oidc provider", logger.F("issuerURL", issuerURL))
provider, err := oidc.NewProvider(ctx, issuerURL)
if err != nil {
return nil, errors.Wrap(err, "could not create oidc provider")
}
return provider, nil
}
const defaultCookieNamePrefix = "_bouncer_authn_oidc"
func (a *Authenticator) getCookieName(cookieName string, proxyName store.ProxyName, layerName store.LayerName) string {
@ -421,6 +428,25 @@ func (a *Authenticator) getCookieName(cookieName string, proxyName store.ProxyNa
return strings.ToLower(fmt.Sprintf("%s_%s_%s", defaultCookieNamePrefix, proxyName, layerName))
}
func NewAuthenticator(httpTransport *http.Transport, clientTimeout time.Duration, store sessions.Store, oidcProviderCacheTimeout time.Duration) *Authenticator {
authenticator := &Authenticator{
httpTransport: httpTransport,
httpClientTimeout: clientTimeout,
store: store,
}
authenticator.cachedOIDCProvider = syncx.NewCachedResource(
ttl.NewCache(
memory.NewCache[string, *oidc.Provider](),
memory.NewCache[string, time.Time](),
oidcProviderCacheTimeout,
),
authenticator.getOIDCProvider,
)
return authenticator
}
var (
_ authn.PreAuthentication = &Authenticator{}
_ authn.Authenticator = &Authenticator{}

View File

@ -1,13 +1,8 @@
package oidc
import (
"time"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
"forge.cadoles.com/cadoles/bouncer/internal/cache/ttl"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/sessions"
)
@ -15,14 +10,11 @@ const LayerType store.LayerType = "authn-oidc"
func NewLayer(store sessions.Store, funcs ...OptionFunc) *authn.Layer {
opts := NewOptions(funcs...)
return authn.NewLayer(LayerType, &Authenticator{
httpTransport: opts.HTTPTransport,
httpClientTimeout: opts.HTTPClientTimeout,
store: store,
oidcProviderCache: ttl.NewCache(
memory.NewCache[string, *oidc.Provider](),
memory.NewCache[string, time.Time](),
opts.OIDCProviderCacheTimeout,
),
}, opts.AuthnOptions...)
authenticator := NewAuthenticator(
opts.HTTPTransport,
opts.HTTPClientTimeout,
store,
opts.OIDCProviderCacheTimeout,
)
return authn.NewLayer(LayerType, authenticator, opts.AuthnOptions...)
}

View File

@ -0,0 +1,63 @@
package syncx
import (
"context"
"sync"
"forge.cadoles.com/cadoles/bouncer/internal/cache"
"github.com/pkg/errors"
)
type RefreshFunc[K comparable, V any] func(ctx context.Context, key K) (V, error)
type CachedResource[K comparable, V any] struct {
cache cache.Cache[K, V]
lock sync.RWMutex
refresh RefreshFunc[K, V]
}
func (r *CachedResource[K, V]) Clear() {
r.cache.Clear()
}
func (r *CachedResource[K, V]) Get(ctx context.Context, key K) (V, bool, error) {
value, exists := r.cache.Get(key)
if exists {
return value, false, nil
}
locked := r.lock.TryLock()
if !locked {
r.lock.RLock()
value, exists := r.cache.Get(key)
if exists {
r.lock.RUnlock()
return value, false, nil
}
r.lock.RUnlock()
}
if !locked {
r.lock.Lock()
}
defer r.lock.Unlock()
value, err := r.refresh(ctx, key)
if err != nil {
return *new(V), false, errors.WithStack(err)
}
r.cache.Set(key, value)
return value, true, nil
}
func NewCachedResource[K comparable, V any](cache cache.Cache[K, V], refresh RefreshFunc[K, V]) *CachedResource[K, V] {
return &CachedResource[K, V]{
cache: cache,
refresh: refresh,
}
}

View File

@ -0,0 +1,66 @@
package syncx
import (
"context"
"math"
"sync"
"testing"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
"forge.cadoles.com/cadoles/bouncer/internal/cache/ttl"
"github.com/pkg/errors"
)
func TestCachedResource(t *testing.T) {
refreshCalls := 0
cacheTTL := 1*time.Second + 500*time.Millisecond
duration := 2 * time.Second
expectedCalls := math.Ceil(float64(duration) / float64(cacheTTL))
resource := NewCachedResource(
ttl.NewCache(
memory.NewCache[string, string](),
memory.NewCache[string, time.Time](),
cacheTTL,
),
func(ctx context.Context, key string) (string, error) {
refreshCalls++
return "bar", nil
},
)
concurrents := 50
key := "foo"
var wg sync.WaitGroup
wg.Add(concurrents)
for i := range concurrents {
go func(i int) {
done := time.After(duration)
defer wg.Done()
for {
select {
case <-done:
return
default:
value, fresh, err := resource.Get(context.Background(), key)
if err != nil {
t.Errorf("%+v", errors.WithStack(err))
}
t.Logf("resource retrieved for goroutine #%d: (%s, %s, %v)", i, key, value, fresh)
}
}
}(i)
}
wg.Wait()
if e, g := int(expectedCalls), refreshCalls; e != g {
t.Errorf("refreshCalls: expected '%d', got '%d'", e, g)
}
}