diff --git a/Dockerfile b/Dockerfile
index 2228e9e..95dbe8d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM reg.cadoles.com/proxy_cache/library/golang:1.21.6 AS BUILD
+FROM reg.cadoles.com/proxy_cache/library/golang:1.22.0 AS BUILD
RUN apt-get update \
&& apt-get install -y make
diff --git a/Makefile b/Makefile
index 4d62fb2..e914877 100644
--- a/Makefile
+++ b/Makefile
@@ -19,7 +19,11 @@ OPENWRT_DEVICE ?= 192.168.1.1
SIEGE_URLS_FILE ?= misc/siege/urls.txt
SIEGE_CONCURRENCY ?= 100
-watch: tools/modd/bin/modd deps ## Watching updated files - live reload
+data/bootstrap.d/dummy.yml:
+ mkdir -p data/bootstrap.d
+ cp misc/bootstrap.d/dummy.yml data/bootstrap.d/dummy.yml
+
+watch: tools/modd/bin/modd deps data/bootstrap.d/dummy.yml ## Watching updated files - live reload
( set -o allexport && source .env && set +o allexport && tools/modd/bin/modd )
.PHONY: test
diff --git a/cmd/dummy/index.gohtml b/cmd/dummy/index.gohtml
new file mode 100644
index 0000000..d302a59
--- /dev/null
+++ b/cmd/dummy/index.gohtml
@@ -0,0 +1,63 @@
+
+
+ Incoming request
+ Headers
+
+
+
+ Key |
+ Value |
+
+
+
+ {{ range $key, $val := .Request.Header }}
+
+
+ {{ $key }}
+ |
+
+ {{ $val }}
+ |
+
+ {{
+ end
+ }}
+
+
+ Cookies
+
+
+
+ Name |
+ Domain |
+ Path |
+ Secure |
+ MaxAge |
+ HttpOnly |
+ Expires |
+ Value |
+
+
+
+ {{ range $cookie := .Request.Cookies }}
+
+
+ {{ $cookie.Name }}
+ |
+ {{ $cookie.Domain }} |
+ {{ $cookie.Path }} |
+ {{ $cookie.Secure }} |
+ {{ $cookie.MaxAge }} |
+ {{ $cookie.HttpOnly }} |
+ {{ $cookie.Expires }} |
+
+ {{ $cookie.Value }}
+ |
+
+ {{
+ end
+ }}
+
+
+
+
diff --git a/cmd/dummy/main.go b/cmd/dummy/main.go
new file mode 100644
index 0000000..4cac91b
--- /dev/null
+++ b/cmd/dummy/main.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+ "html/template"
+ "log"
+ "net/http"
+
+ _ "embed"
+
+ "github.com/pkg/errors"
+)
+
+var (
+ //go:embed index.gohtml
+ indexTmpl string
+)
+
+func main() {
+ tmpl, err := template.New("").Parse(indexTmpl)
+ if err != nil {
+ log.Fatalf("[FATAL] %+v", errors.WithStack(err))
+ }
+
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ data := struct {
+ Request *http.Request
+ }{
+ Request: r,
+ }
+
+ if err := tmpl.Execute(w, data); err != nil {
+ log.Printf("[ERROR] %+v", errors.WithStack(err))
+ }
+ })
+
+ addr := ":8082"
+
+ log.Printf("[INFO] listening on '%s'", addr)
+
+ if err := http.ListenAndServe(addr, handler); err != nil {
+ log.Fatalf("[FATAL] %+v", errors.WithStack(err))
+ }
+}
diff --git a/doc/README.md b/doc/README.md
index e41fafc..4d5e8dc 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -19,6 +19,7 @@
### Utilisation
- [(FR) - Ajouter un layer de type "file d'attente"](./fr/tutorials/add-queue-layer.md)
+- [(FR) - Ajouter une authentification OpenID Connect](./fr/tutorials/add-queue-layer.md)
- [(FR) - Amorçage d'un serveur Bouncer via la configuration](./fr/tutorials/bootstrapping.md)
- [(FR) - Intégration avec Kubernetes](./fr/tutorials/kubernetes-integration.md)
diff --git a/doc/fr/references/layers/README.md b/doc/fr/references/layers/README.md
index 24c3f86..cd21b3a 100644
--- a/doc/fr/references/layers/README.md
+++ b/doc/fr/references/layers/README.md
@@ -2,5 +2,6 @@
Vous trouverez ci-dessous la liste des entités "Layer" activables sur vos entité "Proxy":
+- [Authn (`authn-*`)](./authn/README.md) - Authentification des accès (SSO)
- [Queue](./queue.md) - File d'attente dynamique
-- [Circuit Breaker](./circuitbreaker.md) - Coupure d'accès à un site ou une sous section de celui ci
\ No newline at end of file
+- [Circuit Breaker](./circuitbreaker.md) - Coupure d'accès à un site ou une sous section de celui ci
diff --git a/doc/fr/references/layers/authn/README.md b/doc/fr/references/layers/authn/README.md
new file mode 100644
index 0000000..03ec90a
--- /dev/null
+++ b/doc/fr/references/layers/authn/README.md
@@ -0,0 +1,61 @@
+# Les layers `authn-*`
+
+Les layers `authn-*` permettent d'activer différents modes d'authentification au sein d'un proxy Bouncer.
+
+Les informations liées à l'utilisateur authentifié sont ensuite injectables dans les entêtes HTTP de la requête permettant ainsi une authentification unique("SSO") basée sur les entêtes HTTP ("Trusted headers SSO").
+
+## Layers
+
+- [`authn-oidc`](./oidc.md) - Authentification OpenID Connect
+
+## Schéma des options
+
+En plus de leurs options spécifiques tous les layers `authn-*` partagent un certain nombre d'options communes.
+
+Voir le [schéma](../../../../../internal/proxy/director/layer/authn/layer-options.json).
+
+## Règles d'injection d'entêtes
+
+L'option `headers.rules` permet de définir une liste de règles utilisant un DSL permettant de définir de manière dynamique quels entêtes seront injectés dans la requête transitant par le layer à destination du service distant.
+
+La liste des instructions est exécutée séquentiellement.
+
+Bouncer utilise le projet [`expr`](https://expr-lang.org/) comme DSL. En plus des fonctionnalités natives du langage, Bouncer ajoute un certain nombre de fonction spécifique à son contexte.
+
+Le comportement des règles par défaut est le suivant:
+
+1. L'ensemble des entêtes HTTP correspondant au patron `Remote-*` sont supprimés ;
+2. L'identifiant de l'utilisateur identifié (`user.subject`) est exporté sous la forme de l'entête HTTP `Remote-User` ;
+3. L'ensemble des attributs de l'utilisateur identifié (`user.attrs`) sont exportés sous la forme `Remote-User-Attr-` où `` est le nom de l'attribut en minuscule, avec les `_` transformés en `-`.
+
+### Fonctions
+
+#### `set_header(name string, value string)`
+
+Définir la valeur d'une entête HTTP via son nom `name` et sa valeur `value`.
+
+#### `del_headers(pattern string)`
+
+Supprimer un ou plusieurs entêtes HTTP dont le nom correspond au patron `pattern`.
+
+Le patron est défini par une chaîne comprenant un ou plusieurs caractères `*`, signifiant un ou plusieurs caractères arbitraires.
+
+### Environnement
+
+Les règles ont accès aux variables suivantes pendant leur exécution.
+
+#### `user`
+
+L'utilisateur identifié par le layer.
+
+```json
+{
+ // Identifiant de l'utilisateur, tel que récupéré par le layer
+ "subject": "",
+ // Table associative des attributs associés à l'utilisateur
+ // La liste de ces attributs dépend du layer d'authentification
+ "attrs": {
+ "key": ""
+ }
+}
+```
diff --git a/doc/fr/references/layers/authn/oidc.md b/doc/fr/references/layers/authn/oidc.md
new file mode 100644
index 0000000..2ca44b6
--- /dev/null
+++ b/doc/fr/references/layers/authn/oidc.md
@@ -0,0 +1,21 @@
+# Layer `authn-oidc`
+
+## Description
+
+Ce layer permet d'ajouter une authentification OpenID Connect au service distant.
+
+Voir le tutoriel ["Ajouter une authentification OpenID Connect"](../../../tutorials/add-oidc-authn-layer.md) pour plus d'informations quant à son utilisation.
+
+## Type
+
+`authn-oidc`
+
+## Schéma des options
+
+Les options disponibles pour le layer sont décrites via un [schéma JSON](https://json-schema.org/specification). Elles sont documentées dans le [schéma visible ici](../../../../../internal/proxy/director/layer/authn/oidc/layer-options.json).
+
+En plus de ces options spécifiques le layer peut également être configuré via [les options communes aux layers `authn-*`](../../../../../internal/proxy/director/layer/authn/layer-options.json).
+
+## Métriques
+
+_Aucune [métrique Prometheus](../metrics.md) n'est exportée par ce layer._
diff --git a/doc/fr/tutorials/add-oidc-authn-layer.md b/doc/fr/tutorials/add-oidc-authn-layer.md
new file mode 100644
index 0000000..609ea55
--- /dev/null
+++ b/doc/fr/tutorials/add-oidc-authn-layer.md
@@ -0,0 +1,62 @@
+# Ajouter une authentification OpenID Connect
+
+Dans ce tutoriel nous verrons comment ajouter un layer de type `oidc-authn` à un proxy pour ajouter une authentification OpenID Connect à notre service distant.
+
+## Prérequis
+
+Pour réaliser ce tutoriel nous allons utiliser la forge Cadoles comme fournisseur d'identité. Vous devrez donc créer une application OAuth2 avec votre compte Cadoles sur https://forge.cadoles.com/user/settings/applications et collecter les informations suivantes:
+
+- Identifiant du client ;
+- Secret du client.
+
+Concernant l'URL de redirection, si vous ne modifiez pas l'option `oidc.loginCallbackPath` vous devrez renseigner une URL répondant au modèle suivant:
+
+```
+/.bouncer/authn/oidc///callback
+```
+
+Où
+
+- `` est l'URL de base d'accès à votre instance Bouncer, par exemple `http://localhost:8080` si vous avez travaillez avec une instance Bouncer locale avec la configuration par défaut ;
+- `` est le nom du proxy créé dans Bouncer, dans ce tutoriel `my-proxy` ;
+- `` est le nom du layer créé dans Bouncer, dans ce tutoriel `my-layer`.
+
+## Étapes
+
+1. Avec le client d'administration de Bouncer en ligne de commande, créer un nouveau proxy
+
+ ```shell
+ bouncer admin proxy create --proxy-name my-proxy --proxy-to https://www.cadoles.com
+ ```
+
+2. Activer le proxy `my-proxy`
+
+ ```shell
+ bouncer admin proxy update --proxy-name my-proxy --proxy-enabled
+ ```
+
+3. À ce stade, vous devriez pouvoir afficher le site Cadoles en ouvrant l'URL de votre instance Bouncer, par exemple `http://localhost:8080` si vous avez travaillez avec une instance Bouncer locale avec la configuration par défaut
+
+4. Créer un layer de type `authn-oidc` pour notre nouveau proxy
+
+ ```shell
+ bouncer admin layer create --proxy-name my-proxy --layer-name my-layer --layer-type authn-oidc
+ ```
+
+5. Configurer le nouveau layer `my-layer` avec les options collectée dans les prérequis et l'activer
+
+ ```shell
+ bouncer admin layer update --proxy-name my-proxy --layer-name my-layer --layer-options '{ "oidc":{"clientId": "", "clientSecret":"", "issuerURL": "https://forge.cadoles.com/", "postLogoutRedirectURL": "" }}' --layer-enabled
+ ```
+
+ Où:
+
+ - `` est l'identifiant du client OIDC récupéré dans les prérequis ;
+ - `` est le secret du client OIDC récupéré dans les prérequis ;
+ - `` est l'URL de redirection après déconnexion, par exemple `http://localhost:8080` si vous avez travaillez avec une instance Bouncer locale avec la configuration par défaut.
+
+6. À ce stade en ouvrant l'URL de votre instance Bouncer vous devriez être redirigé vers la forge Cadoles vous demandant de vous authentifier.
+
+## Ressources
+
+- [Référence du layer `authn-oidc`](../../fr/references/layers/authn/oidc.md)
diff --git a/go.mod b/go.mod
index e17b607..a41a5ca 100644
--- a/go.mod
+++ b/go.mod
@@ -9,9 +9,13 @@ require (
github.com/Masterminds/sprig/v3 v3.2.3
github.com/bsm/redislock v0.9.4
github.com/btcsuite/btcd/btcutil v1.1.3
+ github.com/coreos/go-oidc/v3 v3.10.0
+ github.com/dchest/uniuri v1.2.0
github.com/drone/envsubst v1.0.3
+ github.com/expr-lang/expr v1.16.7
github.com/getsentry/sentry-go v0.22.0
github.com/go-chi/chi/v5 v5.0.8
+ github.com/gorilla/sessions v1.2.2
github.com/jedib0t/go-pretty/v6 v6.4.6
github.com/mitchellh/mapstructure v1.4.1
github.com/oklog/ulid/v2 v2.1.0
@@ -19,6 +23,7 @@ require (
github.com/prometheus/client_golang v1.16.0
github.com/qri-io/jsonschema v0.2.1
github.com/redis/go-redis/v9 v9.0.4
+ golang.org/x/oauth2 v0.13.0
k8s.io/api v0.29.3
k8s.io/apimachinery v0.29.3
k8s.io/client-go v0.29.3
@@ -42,6 +47,7 @@ require (
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
+ github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
@@ -51,6 +57,7 @@ require (
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
github.com/huandu/xstrings v1.3.3 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/josharian/intern v1.0.0 // indirect
@@ -80,10 +87,9 @@ require (
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
golang.org/x/net v0.19.0 // indirect
- golang.org/x/oauth2 v0.10.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.3.0 // indirect
- google.golang.org/appengine v1.6.7 // indirect
+ google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
@@ -126,10 +132,10 @@ require (
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b
go.opencensus.io v0.24.0 // indirect
- golang.org/x/crypto v0.17.0 // indirect
+ golang.org/x/crypto v0.19.0 // indirect
golang.org/x/mod v0.14.0 // indirect
- golang.org/x/sys v0.15.0 // indirect
- golang.org/x/term v0.15.0 // indirect
+ golang.org/x/sys v0.17.0 // indirect
+ golang.org/x/term v0.17.0 // indirect
golang.org/x/tools v0.16.1 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
gopkg.in/go-playground/validator.v9 v9.29.1 // indirect
diff --git a/go.sum b/go.sum
index 99aee0e..9fed90b 100644
--- a/go.sum
+++ b/go.sum
@@ -132,6 +132,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg=
github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM=
+github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
+github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
@@ -146,6 +148,8 @@ github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
+github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
@@ -179,6 +183,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/expr-lang/expr v1.16.7 h1:gCIiHt5ODA0xIaDbD0DPKyZpM9Drph3b3lolYAYq2Kw=
+github.com/expr-lang/expr v1.16.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
@@ -199,6 +205,8 @@ github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
+github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
@@ -317,7 +325,11 @@ github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAk
github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
+github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
+github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -547,8 +559,8 @@ golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
-golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
-golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
+golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -647,8 +659,8 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
-golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
+golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
+golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -734,13 +746,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
-golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
-golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
-golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
+golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -750,6 +762,7 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
@@ -857,8 +870,9 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
+google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
diff --git a/internal/admin/bootstrap.go b/internal/admin/bootstrap.go
index 0e49f1e..a39eefe 100644
--- a/internal/admin/bootstrap.go
+++ b/internal/admin/bootstrap.go
@@ -28,17 +28,27 @@ func (s *Server) bootstrapProxies(ctx context.Context) error {
logger.Info(ctx, "bootstrapping proxies")
for proxyName, proxyConfig := range s.bootstrapConfig.Proxies {
+ loopCtx := logger.With(ctx, logger.F("proxyName", proxyName), logger.F("proxyFrom", proxyConfig.From), logger.F("proxyTo", proxyConfig.To))
+
_, err := s.proxyRepository.GetProxy(ctx, proxyName)
if !errors.Is(err, store.ErrNotFound) {
if err != nil {
return errors.WithStack(err)
}
- logger.Info(ctx, "ignoring existing proxy", logger.F("proxyName", proxyName))
- continue
+ if proxyConfig.Recreate {
+ logger.Info(loopCtx, "force recreating proxy")
+
+ if err := s.deleteProxyAndLayers(ctx, proxyName); err != nil {
+ return errors.WithStack(err)
+ }
+ } else {
+ logger.Info(loopCtx, "ignoring existing proxy")
+ continue
+ }
}
- logger.Info(ctx, "creating proxy", logger.F("proxyName", proxyName))
+ logger.Info(loopCtx, "creating proxy")
if _, err := proxyRepo.CreateProxy(ctx, proxyName, string(proxyConfig.To), proxyConfig.From...); err != nil {
return errors.WithStack(err)
diff --git a/internal/admin/proxy_route.go b/internal/admin/proxy_route.go
index e052c89..5ac304e 100644
--- a/internal/admin/proxy_route.go
+++ b/internal/admin/proxy_route.go
@@ -101,7 +101,7 @@ func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- if err := s.proxyRepository.DeleteProxy(ctx, proxyName); err != nil {
+ if err := s.deleteProxyAndLayers(ctx, proxyName); err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
@@ -114,23 +114,6 @@ func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
return
}
- layers, err := s.layerRepository.QueryLayers(ctx, proxyName)
- if err != nil {
- logAndCaptureError(ctx, "could not query proxy's layers", errors.WithStack(err))
- api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
-
- return
- }
-
- for _, layer := range layers {
- if err := s.layerRepository.DeleteLayer(ctx, proxyName, layer.Name); err != nil {
- logAndCaptureError(ctx, "could not delete layer", errors.WithStack(err))
- api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
-
- return
- }
- }
-
api.DataResponse(w, http.StatusOK, DeleteProxyResponse{
ProxyName: proxyName,
})
diff --git a/internal/admin/util.go b/internal/admin/util.go
new file mode 100644
index 0000000..34d081f
--- /dev/null
+++ b/internal/admin/util.go
@@ -0,0 +1,29 @@
+package admin
+
+import (
+ "context"
+
+ "forge.cadoles.com/cadoles/bouncer/internal/store"
+ "github.com/pkg/errors"
+)
+
+func (s *Server) deleteProxyAndLayers(ctx context.Context, proxyName store.ProxyName) error {
+ if err := s.proxyRepository.DeleteProxy(ctx, proxyName); err != nil {
+ if !errors.Is(err, store.ErrNotFound) {
+ return errors.WithStack(err)
+ }
+ }
+
+ layers, err := s.layerRepository.QueryLayers(ctx, proxyName)
+ if err != nil {
+ return errors.WithStack(err)
+ }
+
+ for _, layer := range layers {
+ if err := s.layerRepository.DeleteLayer(ctx, proxyName, layer.Name); err != nil {
+ return errors.WithStack(err)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/config/bootstrap.go b/internal/config/bootstrap.go
index c113250..e1a2b67 100644
--- a/internal/config/bootstrap.go
+++ b/internal/config/bootstrap.go
@@ -47,11 +47,12 @@ func (c *BootstrapConfig) UnmarshalYAML(unmarshal func(interface{}) error) error
}
type BootstrapProxyConfig struct {
- Enabled InterpolatedBool `yaml:"enabled"`
- Weight InterpolatedInt `yaml:"weight"`
- To InterpolatedString `yaml:"to"`
- From InterpolatedStringSlice `yaml:"from"`
- Layers map[store.LayerName]BootstrapLayerConfig `yaml:"layers"`
+ Enabled InterpolatedBool `yaml:"enabled"`
+ Weight InterpolatedInt `yaml:"weight"`
+ To InterpolatedString `yaml:"to"`
+ From InterpolatedStringSlice `yaml:"from"`
+ Layers map[store.LayerName]BootstrapLayerConfig `yaml:"layers"`
+ Recreate InterpolatedBool `yaml:"recreate"`
}
type BootstrapLayerConfig struct {
diff --git a/internal/proxy/director/context.go b/internal/proxy/director/context.go
index 1b00a5e..3dc1868 100644
--- a/internal/proxy/director/context.go
+++ b/internal/proxy/director/context.go
@@ -2,6 +2,7 @@ package director
import (
"context"
+ "net/url"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
@@ -10,8 +11,9 @@ import (
type contextKey string
const (
- contextKeyProxy contextKey = "proxy"
- contextKeyLayers contextKey = "layers"
+ contextKeyProxy contextKey = "proxy"
+ contextKeyLayers contextKey = "layers"
+ contextKeyOriginalURL contextKey = "originalURL"
)
var (
@@ -19,6 +21,19 @@ var (
errUnexpectedContextValue = errors.New("unexpected context value")
)
+func withOriginalURL(ctx context.Context, url *url.URL) context.Context {
+ return context.WithValue(ctx, contextKeyOriginalURL, url)
+}
+
+func OriginalURL(ctx context.Context) (*url.URL, error) {
+ url, err := ctxValue[*url.URL](ctx, contextKeyOriginalURL)
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ return url, nil
+}
+
func withProxy(ctx context.Context, proxy *store.Proxy) context.Context {
return context.WithValue(ctx, contextKeyProxy, proxy)
}
diff --git a/internal/proxy/director/director.go b/internal/proxy/director/director.go
index 2bc4096..5deab83 100644
--- a/internal/proxy/director/director.go
+++ b/internal/proxy/director/director.go
@@ -28,7 +28,9 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
}
url := getRequestURL(r)
- ctx = logger.With(r.Context(), logger.F("url", url.String()))
+
+ ctx = withOriginalURL(ctx, url)
+ ctx = logger.With(ctx, logger.F("url", url.String()))
var match *store.Proxy
diff --git a/internal/proxy/director/layer/authn/authenticator.go b/internal/proxy/director/layer/authn/authenticator.go
new file mode 100644
index 0000000..a448f66
--- /dev/null
+++ b/internal/proxy/director/layer/authn/authenticator.go
@@ -0,0 +1,26 @@
+package authn
+
+import (
+ "net/http"
+
+ "forge.cadoles.com/cadoles/bouncer/internal/store"
+ "github.com/pkg/errors"
+)
+
+var (
+ ErrUnauthorized = errors.New("unauthorized")
+ ErrForbidden = errors.New("forbidden")
+ ErrSkipRequest = errors.New("skip request")
+)
+
+type Authenticator interface {
+ Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*User, error)
+}
+
+type PreAuthentication interface {
+ PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error
+}
+
+type PostAuthentication interface {
+ PostAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer, user *User) error
+}
diff --git a/internal/proxy/director/layer/authn/header.go b/internal/proxy/director/layer/authn/header.go
new file mode 100644
index 0000000..71c9fe3
--- /dev/null
+++ b/internal/proxy/director/layer/authn/header.go
@@ -0,0 +1,94 @@
+package authn
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "forge.cadoles.com/Cadoles/go-proxy/wildcard"
+ "github.com/expr-lang/expr"
+ "github.com/pkg/errors"
+)
+
+func (l *Layer) getRuleOptions(r *http.Request) []expr.Option {
+ options := make([]expr.Option, 0)
+
+ setHeader := expr.Function(
+ "set_header",
+ func(params ...any) (any, error) {
+ name := params[0].(string)
+ rawValue := params[1]
+
+ var value string
+ switch v := rawValue.(type) {
+ case []string:
+ value = strings.Join(v, ",")
+ case time.Time:
+ value = strconv.FormatInt(v.UTC().Unix(), 10)
+ case time.Duration:
+ value = strconv.FormatInt(int64(v.Seconds()), 10)
+ default:
+ value = fmt.Sprintf("%v", rawValue)
+ }
+
+ r.Header.Set(name, value)
+
+ return true, nil
+ },
+ new(func(string, string) bool),
+ )
+
+ options = append(options, setHeader)
+
+ delHeaders := expr.Function(
+ "del_headers",
+ func(params ...any) (any, error) {
+ pattern := params[0].(string)
+ deleted := false
+
+ for key := range r.Header {
+ if !wildcard.Match(key, pattern) {
+ continue
+ }
+
+ r.Header.Del(key)
+ deleted = true
+ }
+
+ return deleted, nil
+ },
+ new(func(string) bool),
+ )
+
+ options = append(options, delHeaders)
+
+ return options
+}
+
+func (l *Layer) injectHeaders(r *http.Request, options *LayerOptions, user *User) error {
+ rules := options.Headers.Rules
+ if len(rules) == 0 {
+ return nil
+ }
+
+ env := map[string]any{
+ "user": user,
+ }
+
+ rulesOptions := l.getRuleOptions(r)
+
+ for i, r := range rules {
+ program, err := expr.Compile(r, rulesOptions...)
+ if err != nil {
+ return errors.Wrapf(err, "could not compile header rule #%d", i)
+ }
+
+ if _, err := expr.Run(program, env); err != nil {
+ return errors.Wrapf(err, "could not execute header rule #%d", i)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/proxy/director/layer/authn/layer-options.json b/internal/proxy/director/layer/authn/layer-options.json
new file mode 100644
index 0000000..58fbde4
--- /dev/null
+++ b/internal/proxy/director/layer/authn/layer-options.json
@@ -0,0 +1,38 @@
+{
+ "$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/authn-options",
+ "title": "Options de configuration commune des layers 'authn-*'",
+ "type": "object",
+ "properties": {
+ "matchURLs": {
+ "title": "Liste de filtrage des URLs sur lesquelles le layer est actif.",
+ "description": "Par exemple, si vous souhaitez limiter votre layer à l'ensemble d'une section '`/blog`' d'un site, vous pouvez déclarer la valeur `['*/blog*']`. Les autres URLs du site ne seront pas affectées par ce layer.",
+ "default": [
+ "*"
+ ],
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "headers": {
+ "title": "Options de configuration du mécanisme d'injection d'entêtes HTTP liés à l'authentification",
+ "type": "object",
+ "properties": {
+ "rules": {
+ "title": "Liste des règles définissant les actions d'injection/réécriture d'entêtes HTTP",
+ "description": "Voir la documentation (ficher 'doc/fr/references/layers/authn/README.md', section 'Règles d'injection d'entêtes') pour plus d'informations sur le fonctionnement des règles",
+ "type": "array",
+ "default": [
+ "del_headers('Remote-*')",
+ "set_header('Remote-User', user.subject)",
+ "map( toPairs(user.attrs), { let name = replace(lower(string(get(#, 0))), '_', '-'); set_header('Remote-User-Attr-' + name, get(#, 1)) })"
+ ],
+ "item": {
+ "type": "string"
+ }
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/internal/proxy/director/layer/authn/layer.go b/internal/proxy/director/layer/authn/layer.go
new file mode 100644
index 0000000..afc05eb
--- /dev/null
+++ b/internal/proxy/director/layer/authn/layer.go
@@ -0,0 +1,103 @@
+package authn
+
+import (
+ "net/http"
+
+ "forge.cadoles.com/Cadoles/go-proxy"
+ "forge.cadoles.com/Cadoles/go-proxy/wildcard"
+ "forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
+ "forge.cadoles.com/cadoles/bouncer/internal/store"
+ "github.com/pkg/errors"
+ "gitlab.com/wpetit/goweb/logger"
+)
+
+type Layer struct {
+ layerType store.LayerType
+ auth Authenticator
+}
+
+func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
+ return func(next http.Handler) http.Handler {
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ options, err := fromStoreOptions(layer.Options)
+ if err != nil {
+ logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+
+ return
+ }
+
+ if preAuth, ok := l.auth.(PreAuthentication); ok {
+ if err := preAuth.PreAuthentication(w, r, layer); err != nil {
+ if errors.Is(err, ErrSkipRequest) {
+ return
+ }
+
+ logger.Error(ctx, "could not execute pre-auth hook", logger.E(errors.WithStack(err)))
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+
+ return
+ }
+ }
+
+ matches := wildcard.MatchAny(r.URL.String(), options.MatchURLs...)
+ if !matches {
+ next.ServeHTTP(w, r)
+
+ return
+ }
+
+ user, err := l.auth.Authenticate(w, r, layer)
+ if err != nil {
+ if errors.Is(err, ErrSkipRequest) {
+ return
+ }
+
+ logger.Error(ctx, "could not authenticate user", logger.E(errors.WithStack(err)))
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+
+ return
+ }
+
+ if err := l.injectHeaders(r, options, user); err != nil {
+ logger.Error(ctx, "could not inject headers", logger.E(errors.WithStack(err)))
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+
+ return
+ }
+
+ if postAuth, ok := l.auth.(PostAuthentication); ok {
+ if err := postAuth.PostAuthentication(w, r, layer, user); err != nil {
+ if errors.Is(err, ErrSkipRequest) {
+ return
+ }
+
+ logger.Error(ctx, "could not execute post-auth hook", logger.E(errors.WithStack(err)))
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+
+ return
+ }
+ }
+
+ next.ServeHTTP(w, r)
+ }
+
+ return http.HandlerFunc(fn)
+ }
+}
+
+// LayerType implements director.MiddlewareLayer
+func (l *Layer) LayerType() store.LayerType {
+ return l.layerType
+}
+
+func NewLayer(layerType store.LayerType, auth Authenticator) *Layer {
+ return &Layer{
+ layerType: layerType,
+ auth: auth,
+ }
+}
+
+var _ director.MiddlewareLayer = &Layer{}
diff --git a/internal/proxy/director/layer/authn/layer_options.go b/internal/proxy/director/layer/authn/layer_options.go
new file mode 100644
index 0000000..a2e06ad
--- /dev/null
+++ b/internal/proxy/director/layer/authn/layer_options.go
@@ -0,0 +1,92 @@
+package authn
+
+import (
+ "reflect"
+ "time"
+
+ "forge.cadoles.com/cadoles/bouncer/internal/store"
+ "github.com/mitchellh/mapstructure"
+ "github.com/pkg/errors"
+)
+
+type LayerOptions struct {
+ MatchURLs []string `mapstructure:"matchURLs"`
+ Headers HeadersOptions `mapstructure:"headers"`
+}
+
+type HeadersOptions struct {
+ Rules []string `mapstructure:"rules"`
+}
+
+func DefaultLayerOptions() LayerOptions {
+ return LayerOptions{
+ MatchURLs: []string{"*"},
+ Headers: HeadersOptions{
+ Rules: []string{
+ "del_headers('Remote-*')",
+ "set_header('Remote-User', user.subject)",
+ `map(
+ toPairs(user.attrs), {
+ let name = replace(lower(string(get(#, 0))), '_', '-');
+ set_header(
+ 'Remote-User-Attr-' + name,
+ get(#, 1)
+ )
+ })
+ `,
+ },
+ },
+ }
+}
+
+func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
+ layerOptions := DefaultLayerOptions()
+
+ if err := FromStoreOptions(storeOptions, &layerOptions); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ return &layerOptions, nil
+}
+
+func FromStoreOptions(storeOptions store.LayerOptions, dest any) error {
+ config := mapstructure.DecoderConfig{
+ Result: dest,
+ ZeroFields: true,
+ DecodeHook: mapstructure.ComposeDecodeHookFunc(
+ toDurationHookFunc(),
+ ),
+ }
+
+ decoder, err := mapstructure.NewDecoder(&config)
+ if err != nil {
+ return errors.WithStack(err)
+ }
+
+ if err := decoder.Decode(storeOptions); err != nil {
+ return errors.WithStack(err)
+ }
+
+ return nil
+}
+
+func toDurationHookFunc() mapstructure.DecodeHookFunc {
+ return func(
+ f reflect.Type,
+ t reflect.Type,
+ data interface{}) (interface{}, error) {
+ if t != reflect.TypeOf(*new(time.Duration)) {
+ return data, nil
+ }
+
+ switch f.Kind() {
+ case reflect.String:
+ return time.ParseDuration(data.(string))
+ case reflect.Int64:
+ return time.Duration(data.(int64) * int64(time.Second)), nil
+ default:
+ return data, nil
+ }
+ // Convert it by parsing
+ }
+}
diff --git a/internal/proxy/director/layer/authn/oidc/authenticator.go b/internal/proxy/director/layer/authn/oidc/authenticator.go
new file mode 100644
index 0000000..a3b67b1
--- /dev/null
+++ b/internal/proxy/director/layer/authn/oidc/authenticator.go
@@ -0,0 +1,257 @@
+package oidc
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "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"
+ "github.com/coreos/go-oidc/v3/oidc"
+ "github.com/gorilla/sessions"
+ "github.com/pkg/errors"
+ "gitlab.com/wpetit/goweb/logger"
+)
+
+type Authenticator struct {
+ store sessions.Store
+}
+
+func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error {
+ ctx := r.Context()
+
+ originalURL, err := director.OriginalURL(ctx)
+ if err != nil {
+ return errors.WithStack(err)
+ }
+
+ options, err := fromStoreOptions(layer.Options)
+ if err != nil {
+ return errors.WithStack(err)
+ }
+
+ sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Name))
+ if err != nil {
+ logger.Error(ctx, "could not retrieve session", logger.E(errors.WithStack(err)))
+ }
+
+ redirectURL := a.getRedirectURL(layer.Proxy, layer.Name, originalURL, options)
+ logoutURL := a.getLogoutURL(layer.Proxy, layer.Name, originalURL, options)
+
+ client, err := a.getClient(options, redirectURL.String())
+ if err != nil {
+ return errors.WithStack(err)
+ }
+
+ switch r.URL.Path {
+ case redirectURL.Path:
+ if err := client.HandleCallback(w, r, sess); err != nil {
+ return errors.WithStack(err)
+ }
+
+ case logoutURL.Path:
+ if err := client.HandleLogout(w, r, sess, options.OIDC.PostLogoutRedirectURL); err != nil {
+ return errors.WithStack(err)
+ }
+ }
+
+ return nil
+}
+
+// Authenticate implements authn.Authenticator.
+func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) {
+ ctx := r.Context()
+
+ originalURL, err := director.OriginalURL(ctx)
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ options, err := fromStoreOptions(layer.Options)
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Name))
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ defer func() {
+ if err := sess.Save(r, w); err != nil {
+ logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err)))
+ }
+ }()
+
+ sess.Options.Domain = options.Cookie.Domain
+ sess.Options.HttpOnly = options.Cookie.HTTPOnly
+ sess.Options.MaxAge = int(options.Cookie.MaxAge.Seconds())
+ sess.Options.Path = options.Cookie.Path
+
+ switch options.Cookie.SameSite {
+ case "lax":
+ sess.Options.SameSite = http.SameSiteLaxMode
+ case "strict":
+ sess.Options.SameSite = http.SameSiteStrictMode
+ case "none":
+ sess.Options.SameSite = http.SameSiteNoneMode
+ default:
+ sess.Options.SameSite = http.SameSiteDefaultMode
+ }
+
+ redirectURL := a.getRedirectURL(layer.Proxy, layer.Name, originalURL, options)
+
+ client, err := a.getClient(options, redirectURL.String())
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ idToken, err := client.Authenticate(w, r, sess)
+ if err != nil {
+ if errors.Is(err, ErrLoginRequired) {
+ return nil, errors.WithStack(authn.ErrSkipRequest)
+ }
+
+ return nil, errors.WithStack(err)
+ }
+
+ user, err := a.toUser(idToken, layer.Proxy, layer.Name, originalURL, options, sess)
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ return user, nil
+}
+
+type claims struct {
+ Issuer string `json:"iss"`
+ Subject string `json:"sub"`
+ Expiration int64 `json:"exp"`
+ IssuedAt int64 `json:"iat"`
+ AuthTime int64 `json:"auth_time"`
+ Nonce string `json:"nonce"`
+ ACR string `json:"acr"`
+ AMR string `json:"amr"`
+ AZP string `json:"amp"`
+ Others map[string]any `json:"-"`
+}
+
+func (c claims) AsAttrs() map[string]any {
+ attrs := make(map[string]any)
+
+ for key, val := range c.Others {
+ if val != nil {
+ attrs["claim_"+key] = val
+ }
+ }
+
+ attrs["claim_iss"] = c.Issuer
+ attrs["claim_sub"] = c.Subject
+ attrs["claim_exp"] = c.Expiration
+ attrs["claim_iat"] = c.IssuedAt
+
+ if c.AuthTime != 0 {
+ attrs["claim_auth_time"] = c.AuthTime
+ }
+
+ if c.Nonce != "" {
+ attrs["claim_nonce"] = c.Nonce
+ }
+
+ if c.ACR != "" {
+ attrs["claim_arc"] = c.ACR
+ }
+
+ if c.AMR != "" {
+ attrs["claim_amr"] = c.AMR
+ }
+
+ if c.AZP != "" {
+ attrs["claim_azp"] = c.AZP
+ }
+
+ 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) {
+ var claims claims
+
+ if err := idToken.Claims(&claims); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ if err := idToken.Claims(&claims.Others); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ attrs := claims.AsAttrs()
+
+ logoutURL := a.getLogoutURL(proxyName, layerName, originalURL, options)
+ attrs["logout_url"] = logoutURL.String()
+
+ if accessToken, exists := sess.Values[sessionKeyAccessToken]; exists && accessToken != nil {
+ attrs["access_token"] = accessToken
+ }
+
+ if refreshToken, exists := sess.Values[sessionKeyRefreshToken]; exists && refreshToken != nil {
+ attrs["refresh_token"] = refreshToken
+ }
+
+ if tokenExpiry, exists := sess.Values[sessionKeyTokenExpiry]; exists && tokenExpiry != nil {
+ attrs["token_expiry"] = tokenExpiry
+ }
+
+ user := authn.NewUser(idToken.Subject, attrs)
+
+ return user, nil
+}
+
+func (a *Authenticator) getRedirectURL(proxyName store.ProxyName, layerName store.LayerName, u *url.URL, options *LayerOptions) *url.URL {
+ return &url.URL{
+ Scheme: u.Scheme,
+ Host: u.Host,
+ Path: fmt.Sprintf(options.OIDC.LoginCallbackPath, fmt.Sprintf("%s/%s", proxyName, layerName)),
+ }
+}
+
+func (a *Authenticator) getLogoutURL(proxyName store.ProxyName, layerName store.LayerName, u *url.URL, options *LayerOptions) *url.URL {
+ return &url.URL{
+ Scheme: u.Scheme,
+ Host: u.Host,
+ Path: fmt.Sprintf(options.OIDC.LogoutPath, fmt.Sprintf("%s/%s", proxyName, layerName)),
+ }
+}
+
+func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*Client, error) {
+ ctx := context.Background()
+
+ if options.OIDC.SkipIssuerVerification {
+ ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
+ }
+
+ provider, err := oidc.NewProvider(ctx, options.OIDC.IssuerURL)
+ if err != nil {
+ return nil, errors.Wrap(err, "could not create oidc provider")
+ }
+
+ client := NewClient(
+ WithCredentials(options.OIDC.ClientID, options.OIDC.ClientSecret),
+ WithProvider(provider),
+ WithRedirectURL(redirectURL),
+ WithScopes(options.OIDC.Scopes...),
+ WithAuthParams(options.OIDC.AuthParams),
+ )
+
+ return client, nil
+}
+
+func (a *Authenticator) getCookieName(cookieName string, layerName store.LayerName) string {
+ return fmt.Sprintf("%s_%s", cookieName, layerName)
+}
+
+var (
+ _ authn.PreAuthentication = &Authenticator{}
+ _ authn.Authenticator = &Authenticator{}
+)
diff --git a/internal/proxy/director/layer/authn/oidc/client.go b/internal/proxy/director/layer/authn/oidc/client.go
new file mode 100644
index 0000000..417fb97
--- /dev/null
+++ b/internal/proxy/director/layer/authn/oidc/client.go
@@ -0,0 +1,291 @@
+package oidc
+
+import (
+ "bytes"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
+ "github.com/coreos/go-oidc/v3/oidc"
+ "github.com/dchest/uniuri"
+ "github.com/gorilla/sessions"
+ "github.com/pkg/errors"
+ "gitlab.com/wpetit/goweb/logger"
+ "golang.org/x/oauth2"
+)
+
+const (
+ sessionKeyAccessToken = "access-token"
+ sessionKeyRefreshToken = "refresh-token"
+ sessionKeyTokenExpiry = "token-expiry"
+ sessionKeyIDToken = "id-token"
+ sessionKeyPostLoginRedirectURL = "post-login-redirect-url"
+ sessionKeyLoginState = "login-state"
+ sessionKeyLoginNonce = "login-nonce"
+)
+
+var (
+ ErrLoginRequired = errors.New("login required")
+)
+
+type Client struct {
+ oauth2 *oauth2.Config
+ provider *oidc.Provider
+ verifier *oidc.IDTokenVerifier
+ authParams map[string]string
+}
+
+func (c *Client) Verifier() *oidc.IDTokenVerifier {
+ return c.verifier
+}
+
+func (c *Client) Provider() *oidc.Provider {
+ return c.provider
+}
+
+func (c *Client) Authenticate(w http.ResponseWriter, r *http.Request, sess *sessions.Session) (*oidc.IDToken, error) {
+ idToken, err := c.getIDToken(r, sess)
+ if err != nil {
+ logger.Error(r.Context(), "could not retrieve idtoken", logger.E(errors.WithStack(err)))
+
+ c.login(w, r, sess)
+
+ return nil, errors.WithStack(ErrLoginRequired)
+ }
+
+ return idToken, nil
+}
+
+func (c *Client) login(w http.ResponseWriter, r *http.Request, sess *sessions.Session) {
+ ctx := r.Context()
+
+ state := 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[sessionKeyLoginNonce] = nonce
+ sess.Values[sessionKeyPostLoginRedirectURL] = originalURL.String()
+
+ if err := sess.Save(r, w); err != nil {
+ logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err)))
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+
+ return
+ }
+
+ authCodeOptions := []oauth2.AuthCodeOption{}
+ authCodeOptions = append(authCodeOptions, oidc.Nonce(nonce))
+
+ for key, val := range c.authParams {
+ authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam(key, val))
+ }
+
+ authCodeURL := c.oauth2.AuthCodeURL(
+ state,
+ authCodeOptions...,
+ )
+
+ http.Redirect(w, r, authCodeURL, http.StatusFound)
+}
+
+func (c *Client) HandleCallback(w http.ResponseWriter, r *http.Request, sess *sessions.Session) error {
+ token, _, rawIDToken, err := c.validate(r, sess)
+ if err != nil {
+ return errors.Wrap(err, "could not validate oidc token")
+ }
+
+ sess.Values[sessionKeyIDToken] = rawIDToken
+ sess.Values[sessionKeyAccessToken] = token.AccessToken
+ sess.Values[sessionKeyRefreshToken] = token.RefreshToken
+ sess.Values[sessionKeyTokenExpiry] = token.Expiry.UTC().Unix()
+
+ if err := sess.Save(r, w); err != nil {
+ return errors.WithStack(err)
+ }
+
+ rawPostLoginRedirectURL, exists := sess.Values[sessionKeyPostLoginRedirectURL]
+ if !exists {
+ return errors.Wrap(err, "could not find post login redirect url")
+ }
+
+ postLoginRedirectURL, ok := rawPostLoginRedirectURL.(string)
+ if !ok {
+ return errors.Wrapf(err, "unexpected value '%v' for post login redirect url", rawPostLoginRedirectURL)
+ }
+
+ http.Redirect(w, r, postLoginRedirectURL, http.StatusTemporaryRedirect)
+
+ return nil
+}
+
+func (c *Client) HandleLogout(w http.ResponseWriter, r *http.Request, sess *sessions.Session, postLogoutRedirectURL string) error {
+ state := uniuri.New()
+ sess.Values[sessionKeyLoginState] = state
+
+ ctx := r.Context()
+
+ rawIDToken, err := c.getRawIDToken(sess)
+ if err != nil {
+ logger.Error(ctx, "could not retrieve raw id token", logger.E(errors.WithStack(err)))
+ }
+
+ sess.Values[sessionKeyIDToken] = nil
+ sess.Values[sessionKeyAccessToken] = nil
+ sess.Values[sessionKeyRefreshToken] = nil
+ sess.Values[sessionKeyTokenExpiry] = nil
+ sess.Options.MaxAge = -1
+
+ if err := sess.Save(r, w); err != nil {
+ return errors.Wrap(err, "could not save session")
+ }
+
+ if rawIDToken == "" {
+ http.Redirect(w, r, postLogoutRedirectURL, http.StatusFound)
+ return nil
+ }
+
+ sessionEndURL, err := c.sessionEndURL(rawIDToken, state, postLogoutRedirectURL)
+ if err != nil {
+ return errors.Wrap(err, "could not retrieve session end url")
+ }
+
+ if sessionEndURL != "" {
+ http.Redirect(w, r, sessionEndURL, http.StatusFound)
+ } else {
+ http.Redirect(w, r, postLogoutRedirectURL, http.StatusFound)
+ }
+
+ return nil
+}
+
+func (c *Client) sessionEndURL(idTokenHint, state, postLogoutRedirectURL string) (string, error) {
+ sessionEndEndpoint := &struct {
+ URL string `json:"end_session_endpoint"`
+ }{}
+
+ if err := c.provider.Claims(&sessionEndEndpoint); err != nil {
+ return "", errors.Wrap(err, "could not unmarshal claims")
+ }
+
+ if sessionEndEndpoint.URL == "" {
+ return "", nil
+ }
+
+ var buf bytes.Buffer
+ buf.WriteString(sessionEndEndpoint.URL)
+
+ v := url.Values{}
+
+ if idTokenHint != "" {
+ v.Set("id_token_hint", idTokenHint)
+ }
+
+ if postLogoutRedirectURL != "" {
+ v.Set("post_logout_redirect_uri", postLogoutRedirectURL)
+ }
+
+ if state != "" {
+ v.Set("state", state)
+ }
+
+ if strings.Contains(sessionEndEndpoint.URL, "?") {
+ buf.WriteByte('&')
+ } else {
+ buf.WriteByte('?')
+ }
+
+ buf.WriteString(v.Encode())
+
+ return buf.String(), nil
+}
+
+func (c *Client) validate(r *http.Request, sess *sessions.Session) (*oauth2.Token, *oidc.IDToken, string, error) {
+ ctx := r.Context()
+
+ rawStoredState := sess.Values[sessionKeyLoginState]
+ receivedState := r.URL.Query().Get("state")
+
+ storedState, ok := rawStoredState.(string)
+ if !ok {
+ return nil, nil, "", errors.New("could not find state in session")
+ }
+
+ if receivedState != storedState {
+ return nil, nil, "", errors.New("state mismatch")
+ }
+
+ code := r.URL.Query().Get("code")
+
+ token, err := c.oauth2.Exchange(ctx, code)
+ if err != nil {
+ return nil, nil, "", errors.Wrap(err, "could not exchange token")
+ }
+
+ rawIDToken, ok := token.Extra("id_token").(string)
+ if !ok {
+ return nil, nil, "", errors.New("could not find id token")
+ }
+
+ idToken, err := c.verifier.Verify(ctx, rawIDToken)
+ if err != nil {
+ return nil, nil, "", errors.Wrap(err, "could not verify id token")
+ }
+
+ return token, idToken, rawIDToken, nil
+}
+
+func (c *Client) getRawIDToken(sess *sessions.Session) (string, error) {
+ rawIDToken, ok := sess.Values[sessionKeyIDToken].(string)
+ if !ok || rawIDToken == "" {
+ return "", errors.New("invalid id token")
+ }
+
+ return rawIDToken, nil
+}
+
+func (c *Client) getIDToken(r *http.Request, sess *sessions.Session) (*oidc.IDToken, error) {
+ rawIDToken, err := c.getRawIDToken(sess)
+ if err != nil {
+ return nil, errors.Wrap(err, "could not retrieve raw idtoken")
+ }
+
+ idToken, err := c.verifier.Verify(r.Context(), rawIDToken)
+ if err != nil {
+ return nil, errors.Wrap(err, "could not verify id token")
+ }
+
+ return idToken, nil
+}
+
+func NewClient(funcs ...ClientOptionFunc) *Client {
+ opts := NewClientOptions(funcs...)
+
+ oauth2 := &oauth2.Config{
+ ClientID: opts.ClientID,
+ ClientSecret: opts.ClientSecret,
+ Endpoint: opts.Provider.Endpoint(),
+ RedirectURL: opts.RedirectURL,
+ Scopes: opts.Scopes,
+ }
+
+ verifier := opts.Provider.Verifier(&oidc.Config{
+ ClientID: opts.ClientID,
+ SkipIssuerCheck: opts.SkipIssuerCheck,
+ })
+
+ return &Client{
+ oauth2: oauth2,
+ provider: opts.Provider,
+ verifier: verifier,
+ authParams: opts.AuthParams,
+ }
+}
diff --git a/internal/proxy/director/layer/authn/oidc/client_options.go b/internal/proxy/director/layer/authn/oidc/client_options.go
new file mode 100644
index 0000000..2c7b53e
--- /dev/null
+++ b/internal/proxy/director/layer/authn/oidc/client_options.go
@@ -0,0 +1,76 @@
+package oidc
+
+import (
+ "context"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+)
+
+type ClientOptions struct {
+ Provider *oidc.Provider
+ ClientID string
+ ClientSecret string
+ RedirectURL string
+ Scopes []string
+ AuthParams map[string]string
+ SkipIssuerCheck bool
+}
+
+type ClientOptionFunc func(*ClientOptions)
+
+func WithRedirectURL(url string) ClientOptionFunc {
+ return func(opt *ClientOptions) {
+ opt.RedirectURL = url
+ }
+}
+
+func WithCredentials(clientID, clientSecret string) ClientOptionFunc {
+ return func(opt *ClientOptions) {
+ opt.ClientID = clientID
+ opt.ClientSecret = clientSecret
+ }
+}
+
+func WithScopes(scopes ...string) ClientOptionFunc {
+ return func(opt *ClientOptions) {
+ opt.Scopes = scopes
+ }
+}
+
+func WithAuthParams(params map[string]string) ClientOptionFunc {
+ return func(opt *ClientOptions) {
+ opt.AuthParams = params
+ }
+}
+
+func WithSkipIssuerCheck(skip bool) ClientOptionFunc {
+ return func(opt *ClientOptions) {
+ opt.SkipIssuerCheck = skip
+ }
+}
+
+func NewProvider(ctx context.Context, issuer string, skipIssuerVerification bool) (*oidc.Provider, error) {
+ if skipIssuerVerification {
+ ctx = oidc.InsecureIssuerURLContext(ctx, issuer)
+ }
+
+ return oidc.NewProvider(ctx, issuer)
+}
+
+func WithProvider(provider *oidc.Provider) ClientOptionFunc {
+ return func(opt *ClientOptions) {
+ opt.Provider = provider
+ }
+}
+
+func NewClientOptions(funcs ...ClientOptionFunc) *ClientOptions {
+ opt := &ClientOptions{
+ Scopes: []string{oidc.ScopeOpenID, "profile"},
+ }
+
+ for _, f := range funcs {
+ f(opt)
+ }
+
+ return opt
+}
diff --git a/internal/proxy/director/layer/authn/oidc/layer-options.json b/internal/proxy/director/layer/authn/oidc/layer-options.json
new file mode 100644
index 0000000..da5c8d8
--- /dev/null
+++ b/internal/proxy/director/layer/authn/oidc/layer-options.json
@@ -0,0 +1,108 @@
+{
+ "$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/authn-oidc-layer-options",
+ "title": "Options de configuration du layer 'authn-oidc'",
+ "type": "object",
+ "properties": {
+ "oidc": {
+ "title": "Configuration du client OpenID Connect",
+ "type": "object",
+ "properties": {
+ "clientId": {
+ "title": "Identifiant du client OpenID Connect",
+ "type": "string"
+ },
+ "clientSecret": {
+ "title": "Secret du client OpenID Connect",
+ "type": "string"
+ },
+ "issuerURL": {
+ "title": "URL de base du fournisseur OpenID Connect (racine du .well-known/openid-configuration)",
+ "type": "string"
+ },
+ "postLogoutRedirectURL": {
+ "title": "URL de redirection après déconnexion",
+ "type": "string"
+ },
+ "scopes": {
+ "title": "Scopes associés au client OpenID Connect",
+ "default": [
+ "openid"
+ ],
+ "type": "array",
+ "item": {
+ "type": "string"
+ }
+ },
+ "authParams": {
+ "title": "Paramètres d'URL supplémentaires à ajouter à la requête d'authentification OpenID Connect",
+ "default": {},
+ "description": "L'ensemble des clés valeurs renseignées seront transformées en variables d'URL lors de la requête d'authentification initiale. Permet par exemple d'ajouter les 'acr_values' requises par certains fournisseurs d'identité OpenID Connect.",
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": "string"
+ }
+ }
+ },
+ "loginCallbackPath": {
+ "title": "Chemin associé à l'URL de callback OpenID Connect",
+ "default": "/.bouncer/authn/oidc/%s/callback",
+ "description": "Le marqueur '%s' peut être utilisé pour injecter l'espace de nom '/'.",
+ "type": "string"
+ },
+ "logoutPath": {
+ "title": "Chemin associé à l'URL de déconnexion",
+ "default": "/.bouncer/authn/oidc/%s/logout",
+ "description": "Le marqueur '%s' peut être utilisé pour injecter l'espace de nom '/'.",
+ "type": "string"
+ },
+ "skipIssuerVerification": {
+ "title": "Activer/désactiver la vérification de concordance de l'identifiant du fournisseur d'identité",
+ "default": false,
+ "type": "boolean"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "clientId",
+ "clientSecret",
+ "issuerURL",
+ "postLogoutRedirectURL"
+ ]
+ },
+ "cookie": {
+ "title": "Configuration du cookie porteur de la session utilisateur",
+ "type": "object",
+ "properties": {
+ "name": {
+ "title": "Nom du cookie",
+ "default": "",
+ "type": "string"
+ },
+ "domain": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "sameSite": {
+ "type": "string"
+ },
+ "httpOnly": {
+ "type": "boolean"
+ },
+ "secure": {
+ "type": "boolean"
+ },
+ "maxAge": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "oidc"
+ ]
+}
\ No newline at end of file
diff --git a/internal/proxy/director/layer/authn/oidc/layer.go b/internal/proxy/director/layer/authn/oidc/layer.go
new file mode 100644
index 0000000..4a0d87c
--- /dev/null
+++ b/internal/proxy/director/layer/authn/oidc/layer.go
@@ -0,0 +1,13 @@
+package oidc
+
+import (
+ "forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
+ "forge.cadoles.com/cadoles/bouncer/internal/store"
+ "github.com/gorilla/sessions"
+)
+
+const LayerType store.LayerType = "authn-oidc"
+
+func NewLayer(store sessions.Store) *authn.Layer {
+ return authn.NewLayer(LayerType, &Authenticator{store: store})
+}
diff --git a/internal/proxy/director/layer/authn/oidc/layer_options.go b/internal/proxy/director/layer/authn/oidc/layer_options.go
new file mode 100644
index 0000000..4317840
--- /dev/null
+++ b/internal/proxy/director/layer/authn/oidc/layer_options.go
@@ -0,0 +1,62 @@
+package oidc
+
+import (
+ "time"
+
+ "forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
+ "forge.cadoles.com/cadoles/bouncer/internal/store"
+ "github.com/pkg/errors"
+)
+
+const defaultCookieName = "_bouncer_authn_oidc"
+
+type LayerOptions struct {
+ authn.LayerOptions
+ OIDC OIDCOptions `mapstructure:"oidc"`
+ Cookie CookieOptions `mapstructure:"cookie"`
+}
+
+type OIDCOptions struct {
+ ClientID string `mapstructure:"clientId"`
+ ClientSecret string `mapstructure:"clientSecret"`
+ LoginCallbackPath string `mapstructure:"loginCallbackPath"`
+ LogoutPath string `mapstructure:"logoutPath"`
+ IssuerURL string `mapstructure:"issuerURL"`
+ SkipIssuerVerification bool `mapstructure:"skipIssuerVerification"`
+ PostLogoutRedirectURL string `mapstructure:"postLogoutRedirectURL"`
+ Scopes []string `mapstructure:"scopes"`
+ AuthParams map[string]string `mapstructure:"authParams"`
+}
+
+type CookieOptions struct {
+ Name string `mapstructure:"name"`
+ Domain string `mapstructure:"domain"`
+ Path string `mapstructure:"path"`
+ SameSite string `mapstructure:"sameSite"`
+ Secure bool `mapstructure:"secure"`
+ HTTPOnly bool `mapstructure:"httpOnly"`
+ MaxAge time.Duration `mapstructure:"maxAge"`
+}
+
+func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
+ layerOptions := LayerOptions{
+ LayerOptions: authn.DefaultLayerOptions(),
+ OIDC: OIDCOptions{
+ LoginCallbackPath: "/.bouncer/authn/oidc/%s/callback",
+ LogoutPath: "/.bouncer/authn/oidc/%s/logout",
+ Scopes: []string{"openid"},
+ },
+ Cookie: CookieOptions{
+ Name: defaultCookieName,
+ Path: "/",
+ HTTPOnly: true,
+ MaxAge: time.Hour,
+ },
+ }
+
+ if err := authn.FromStoreOptions(storeOptions, &layerOptions); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ return &layerOptions, nil
+}
diff --git a/internal/proxy/director/layer/authn/oidc/schema.go b/internal/proxy/director/layer/authn/oidc/schema.go
new file mode 100644
index 0000000..a020e6d
--- /dev/null
+++ b/internal/proxy/director/layer/authn/oidc/schema.go
@@ -0,0 +1,8 @@
+package oidc
+
+import (
+ _ "embed"
+)
+
+//go:embed layer-options.json
+var RawLayerOptionsSchema []byte
diff --git a/internal/proxy/director/layer/authn/schema.go b/internal/proxy/director/layer/authn/schema.go
new file mode 100644
index 0000000..10d4a95
--- /dev/null
+++ b/internal/proxy/director/layer/authn/schema.go
@@ -0,0 +1,8 @@
+package authn
+
+import (
+ _ "embed"
+)
+
+//go:embed layer-options.json
+var RawLayerOptionsSchema []byte
diff --git a/internal/proxy/director/layer/authn/user.go b/internal/proxy/director/layer/authn/user.go
new file mode 100644
index 0000000..551af83
--- /dev/null
+++ b/internal/proxy/director/layer/authn/user.go
@@ -0,0 +1,17 @@
+package authn
+
+type User struct {
+ Subject string `json:"subject" expr:"subject"`
+ Attrs map[string]any `json:"attrs" expr:"attrs"`
+}
+
+func NewUser(subject string, attrs map[string]any) *User {
+ if attrs == nil {
+ attrs = make(map[string]any)
+ }
+
+ return &User{
+ Subject: subject,
+ Attrs: attrs,
+ }
+}
diff --git a/internal/proxy/director/layer/queue/queue.go b/internal/proxy/director/layer/queue/queue.go
index f5c8f10..fa72c34 100644
--- a/internal/proxy/director/layer/queue/queue.go
+++ b/internal/proxy/director/layer/queue/queue.go
@@ -254,7 +254,7 @@ func (q *Queue) updateMetrics(ctx context.Context, proxyName store.ProxyName, la
}
func (q *Queue) getCookieName(layerName store.LayerName) string {
- return fmt.Sprintf("_%s_%s", LayerType, layerName)
+ return fmt.Sprintf("_bouncer_%s_%s", LayerType, layerName)
}
func New(adapter Adapter, funcs ...OptionFunc) *Queue {
diff --git a/internal/schema/extend.go b/internal/schema/extend.go
new file mode 100644
index 0000000..2ea1dae
--- /dev/null
+++ b/internal/schema/extend.go
@@ -0,0 +1,39 @@
+package schema
+
+import (
+ "encoding/json"
+
+ "github.com/pkg/errors"
+)
+
+func Extend(base []byte, schema []byte) ([]byte, error) {
+ var (
+ extension map[string]any
+ extended map[string]any
+ )
+
+ if err := json.Unmarshal(base, &extended); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ if err := json.Unmarshal(schema, &extension); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ extended["$id"] = extension["$id"]
+ extended["title"] = extension["title"]
+
+ props := extension["properties"].(map[string]any)
+ extendedProps := extended["properties"].(map[string]any)
+ for key, val := range props {
+ extendedProps[key] = val
+ }
+ extended["properties"] = extendedProps
+
+ data, err := json.MarshalIndent(extended, "", " ")
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ return data, nil
+}
diff --git a/internal/session/adapter/redis/adapter.go b/internal/session/adapter/redis/adapter.go
new file mode 100644
index 0000000..97cf09d
--- /dev/null
+++ b/internal/session/adapter/redis/adapter.go
@@ -0,0 +1,62 @@
+package redis
+
+import (
+ "context"
+ "time"
+
+ "forge.cadoles.com/cadoles/bouncer/internal/session"
+ "github.com/pkg/errors"
+ "github.com/redis/go-redis/v9"
+)
+
+type StoreAdapter struct {
+ client redis.UniversalClient
+}
+
+// Del implements authn.StoreAdapter.
+func (s *StoreAdapter) Del(ctx context.Context, key string) error {
+ if err := s.client.Del(ctx, key).Err(); err != nil {
+ return errors.WithStack(err)
+ }
+
+ return nil
+}
+
+// Get implements authn.StoreAdapter.
+func (s *StoreAdapter) Get(ctx context.Context, key string) ([]byte, error) {
+ cmd := s.client.Get(ctx, key)
+
+ if err := cmd.Err(); err != nil {
+ if errors.Is(err, redis.Nil) {
+ return nil, errors.WithStack(session.ErrNotFound)
+ }
+
+ return nil, errors.WithStack(err)
+ }
+
+ data, err := cmd.Bytes()
+ if err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ return data, nil
+}
+
+// Set implements authn.StoreAdapter.
+func (s *StoreAdapter) Set(ctx context.Context, key string, data []byte, ttl time.Duration) error {
+ if err := s.client.Set(ctx, key, data, ttl).Err(); err != nil {
+ return errors.WithStack(err)
+ }
+
+ return nil
+}
+
+func NewStoreAdapter(client redis.UniversalClient) *StoreAdapter {
+ return &StoreAdapter{
+ client: client,
+ }
+}
+
+var (
+ _ session.StoreAdapter = &StoreAdapter{}
+)
diff --git a/internal/session/options.go b/internal/session/options.go
new file mode 100644
index 0000000..7110beb
--- /dev/null
+++ b/internal/session/options.go
@@ -0,0 +1,47 @@
+package session
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gorilla/sessions"
+)
+
+type Options struct {
+ Session sessions.Options
+ KeyPrefix string
+}
+
+type OptionFunc func(opts *Options)
+
+func NewOptions(funcs ...OptionFunc) *Options {
+ opts := &Options{
+ Session: sessions.Options{
+ Path: "/",
+ Domain: "",
+ MaxAge: int(time.Hour.Seconds()),
+ HttpOnly: true,
+ Secure: false,
+ SameSite: http.SameSiteDefaultMode,
+ },
+ KeyPrefix: "session:",
+ }
+
+ for _, fn := range funcs {
+ fn(opts)
+ }
+
+ return opts
+}
+
+func WithSessionOptions(options sessions.Options) OptionFunc {
+ return func(opts *Options) {
+ opts.Session = options
+ }
+}
+
+func WithKeyPrefix(prefix string) OptionFunc {
+ return func(opts *Options) {
+ opts.KeyPrefix = prefix
+ }
+}
diff --git a/internal/session/store.go b/internal/session/store.go
new file mode 100644
index 0000000..5103bd3
--- /dev/null
+++ b/internal/session/store.go
@@ -0,0 +1,182 @@
+package session
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/base32"
+ "encoding/gob"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gorilla/sessions"
+ "github.com/pkg/errors"
+)
+
+var (
+ ErrNotFound = errors.New("not found")
+)
+
+type StoreAdapter interface {
+ Set(ctx context.Context, key string, data []byte, ttl time.Duration) error
+ Del(ctx context.Context, key string) error
+ Get(ctx context.Context, key string) ([]byte, error)
+}
+
+type Store struct {
+ adapter StoreAdapter
+ options sessions.Options
+ keyPrefix string
+ keyGen KeyGenFunc
+ serializer SessionSerializer
+}
+
+type KeyGenFunc func() (string, error)
+
+func NewStore(adapter StoreAdapter, funcs ...OptionFunc) *Store {
+ opts := NewOptions(funcs...)
+ rs := &Store{
+ options: opts.Session,
+ adapter: adapter,
+ keyPrefix: opts.KeyPrefix,
+ keyGen: generateRandomKey,
+ serializer: GobSerializer{},
+ }
+
+ return rs
+}
+
+func (s *Store) Get(r *http.Request, name string) (*sessions.Session, error) {
+ return sessions.GetRegistry(r).Get(s, name)
+}
+
+func (s *Store) New(r *http.Request, name string) (*sessions.Session, error) {
+ session := sessions.NewSession(s, name)
+ opts := s.options
+ session.Options = &opts
+ session.IsNew = true
+
+ c, err := r.Cookie(name)
+ if err != nil {
+ return session, nil
+ }
+ session.ID = c.Value
+
+ err = s.load(r.Context(), session)
+ if err == nil {
+ session.IsNew = false
+ } else if !errors.Is(err, ErrNotFound) {
+ return nil, errors.WithStack(err)
+ }
+
+ return session, nil
+}
+
+func (s *Store) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
+ if session.Options.MaxAge <= 0 {
+ if err := s.delete(r.Context(), session); err != nil {
+ return errors.WithStack(err)
+ }
+ http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))
+ return nil
+ }
+
+ if session.ID == "" {
+ id, err := s.keyGen()
+ if err != nil {
+ return errors.Wrap(err, "failed to generate session id")
+ }
+ session.ID = id
+ }
+ if err := s.save(r.Context(), session); err != nil {
+ return errors.WithStack(err)
+ }
+
+ http.SetCookie(w, sessions.NewCookie(session.Name(), session.ID, session.Options))
+ return nil
+}
+
+func (s *Store) Options(opts sessions.Options) {
+ s.options = opts
+}
+
+func (s *Store) KeyPrefix(keyPrefix string) {
+ s.keyPrefix = keyPrefix
+}
+
+func (s *Store) KeyGen(f KeyGenFunc) {
+ s.keyGen = f
+}
+
+func (s *Store) Serializer(ss SessionSerializer) {
+ s.serializer = ss
+}
+
+func (s *Store) save(ctx context.Context, session *sessions.Session) error {
+ b, err := s.serializer.Serialize(session)
+ if err != nil {
+ return errors.WithStack(err)
+ }
+
+ if err := s.adapter.Set(ctx, s.keyPrefix+session.ID, b, time.Duration(session.Options.MaxAge)*time.Second); err != nil {
+ return errors.WithStack(err)
+ }
+
+ return nil
+}
+
+// load reads session from Redis
+func (s *Store) load(ctx context.Context, session *sessions.Session) error {
+ data, err := s.adapter.Get(ctx, s.keyPrefix+session.ID)
+ if err != nil {
+ return errors.WithStack(err)
+ }
+
+ return s.serializer.Deserialize(data, session)
+}
+
+// delete deletes session in Redis
+func (s *Store) delete(ctx context.Context, session *sessions.Session) error {
+ if err := s.adapter.Del(ctx, s.keyPrefix+session.ID); err != nil {
+ return errors.WithStack(err)
+ }
+
+ return nil
+}
+
+// SessionSerializer provides an interface for serialize/deserialize a session
+type SessionSerializer interface {
+ Serialize(s *sessions.Session) ([]byte, error)
+ Deserialize(b []byte, s *sessions.Session) error
+}
+
+// Gob serializer
+type GobSerializer struct{}
+
+func (gs GobSerializer) Serialize(s *sessions.Session) ([]byte, error) {
+ buf := new(bytes.Buffer)
+ enc := gob.NewEncoder(buf)
+
+ if err := enc.Encode(s.Values); err != nil {
+ return nil, errors.WithStack(err)
+ }
+
+ return buf.Bytes(), nil
+
+}
+
+func (gs GobSerializer) Deserialize(d []byte, s *sessions.Session) error {
+ dec := gob.NewDecoder(bytes.NewBuffer(d))
+ return dec.Decode(&s.Values)
+}
+
+// generateRandomKey returns a new random key
+func generateRandomKey() (string, error) {
+ k := make([]byte, 64)
+ if _, err := io.ReadFull(rand.Reader, k); err != nil {
+ return "", errors.WithStack(err)
+ }
+ return strings.TrimRight(base32.StdEncoding.EncodeToString(k), "="), nil
+}
diff --git a/internal/setup/authn_oidc_layer.go b/internal/setup/authn_oidc_layer.go
new file mode 100644
index 0000000..41ca45e
--- /dev/null
+++ b/internal/setup/authn_oidc_layer.go
@@ -0,0 +1,29 @@
+package setup
+
+import (
+ "forge.cadoles.com/cadoles/bouncer/internal/config"
+ "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/oidc"
+ "forge.cadoles.com/cadoles/bouncer/internal/schema"
+ "forge.cadoles.com/cadoles/bouncer/internal/session"
+ "forge.cadoles.com/cadoles/bouncer/internal/session/adapter/redis"
+ "github.com/pkg/errors"
+)
+
+func init() {
+ extended, err := schema.Extend(authn.RawLayerOptionsSchema, oidc.RawLayerOptionsSchema)
+ if err != nil {
+ panic(errors.Wrap(err, "could not extend authn base layer options schema"))
+ }
+
+ RegisterLayer(oidc.LayerType, setupAuthnOIDCLayer, extended)
+}
+
+func setupAuthnOIDCLayer(conf *config.Config) (director.Layer, error) {
+ rdb := newRedisClient(conf.Redis)
+ adapter := redis.NewStoreAdapter(rdb)
+ store := session.NewStore(adapter)
+
+ return oidc.NewLayer(store), nil
+}
diff --git a/misc/bootstrap.d/dummy.yml b/misc/bootstrap.d/dummy.yml
new file mode 100644
index 0000000..558f798
--- /dev/null
+++ b/misc/bootstrap.d/dummy.yml
@@ -0,0 +1,27 @@
+from: ["*"]
+to: http://localhost:8082
+enabled: true
+weight: 0
+recreate: true
+layers:
+ my-queue:
+ type: queue
+ enabled: true
+ weight: 100
+ options:
+ capacity: 100
+ # oidc:
+ # type: authn-oidc
+ # enabled: true
+ # weight: 0
+ # options:
+ # oidc:
+ # clientId: my-client-id
+ # clientSecret: my-client-id
+ # issuerURL: https://forge.cadoles.com/
+ # postLogoutRedirectURL: http://localhost:8080
+ # scopes: ["profile", "openid", "email"]
+ # authParams:
+ # acr_values: "eidas2"
+ # cookie:
+ # maxAge: 60m
diff --git a/modd.conf b/modd.conf
index 1873e0c..91e9c83 100644
--- a/modd.conf
+++ b/modd.conf
@@ -1,17 +1,3 @@
-**/*.go
-internal/**/*.json
-modd.conf
-config.yml
-layers/**
-.env {
- prep: make RUN_INSTALL_TESTS=no GOTEST_ARGS="-short" test
- prep: make build-bouncer
- prep: make config.yml
- prep: make .bouncer-token
- daemon: make run BOUNCER_CMD="--debug --config config.yml server admin run"
- daemon: make run BOUNCER_CMD="--debug --config config.yml server proxy run"
-}
-
{
daemon +sigint: make run-redis
}
@@ -19,4 +5,26 @@ layers/**
misc/prometheus/prometheus.yml
{
daemon +sigint: make run-prometheus
-}
\ No newline at end of file
+}
+
+
+./cmd/dummy/*.go
+./cmd/dummy/*.gohtml
+{
+ daemon: go run ./cmd/dummy
+}
+
+**/*.go
+internal/**/*.json
+modd.conf
+config.yml
+layers/**
+data/bootstrap.d/*.yml
+.env {
+ prep: make RUN_INSTALL_TESTS=no GOTEST_ARGS="-short" test
+ prep: make build-bouncer
+ prep: make config.yml
+ prep: make .bouncer-token
+ daemon: make run BOUNCER_CMD="--debug --config config.yml server admin run"
+ daemon: make run BOUNCER_CMD="--debug --config config.yml server proxy run"
+}