feat: new openid connect authentication layer
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
This commit is contained in:
278
internal/proxy/director/layer/authn/oidc/authenticator.go
Normal file
278
internal/proxy/director/layer/authn/oidc/authenticator.go
Normal file
@ -0,0 +1,278 @@
|
||||
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"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"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)
|
||||
}
|
||||
|
||||
metricLoginSuccessesTotal.With(prometheus.Labels{
|
||||
metricLabelLayer: string(layer.Name),
|
||||
metricLabelProxy: string(layer.Proxy),
|
||||
}).Add(1)
|
||||
|
||||
case logoutURL.Path:
|
||||
postLogoutRedirectURL := options.OIDC.PostLogoutRedirectURL
|
||||
if options.OIDC.PostLogoutRedirectURL == "" {
|
||||
postLogoutRedirectURL = originalURL.Scheme + "://" + originalURL.Host
|
||||
}
|
||||
|
||||
if err := client.HandleLogout(w, r, sess, postLogoutRedirectURL); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
metricLogoutsTotal.With(prometheus.Labels{
|
||||
metricLabelLayer: string(layer.Name),
|
||||
metricLabelProxy: string(layer.Proxy),
|
||||
}).Add(1)
|
||||
}
|
||||
|
||||
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) {
|
||||
metricLoginRequestsTotal.With(prometheus.Labels{
|
||||
metricLabelLayer: string(layer.Name),
|
||||
metricLabelProxy: string(layer.Proxy),
|
||||
}).Add(1)
|
||||
|
||||
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{}
|
||||
)
|
291
internal/proxy/director/layer/authn/oidc/client.go
Normal file
291
internal/proxy/director/layer/authn/oidc/client.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
76
internal/proxy/director/layer/authn/oidc/client_options.go
Normal file
76
internal/proxy/director/layer/authn/oidc/client_options.go
Normal file
@ -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
|
||||
}
|
127
internal/proxy/director/layer/authn/oidc/layer-options.json
Normal file
127
internal/proxy/director/layer/authn/oidc/layer-options.json
Normal file
@ -0,0 +1,127 @@
|
||||
{
|
||||
"$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 '<proxy>/<layer>'.",
|
||||
"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 '<proxy>/<layer>'.",
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"cookie": {
|
||||
"title": "Configuration du cookie porteur de la session utilisateur",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Nom du cookie",
|
||||
"default": "_bouncer_authn_oidc",
|
||||
"type": "string"
|
||||
},
|
||||
"domain": {
|
||||
"title": "Domaine associé au cookie",
|
||||
"description": "Par défaut le domaine associé à la requête HTTP",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"title": "Chemin associé au cookie",
|
||||
"type": "string",
|
||||
"default": "/"
|
||||
},
|
||||
"sameSite": {
|
||||
"title": "Attribut sameSite du cookie",
|
||||
"description": "Voir https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"lax",
|
||||
"none",
|
||||
"strict",
|
||||
""
|
||||
],
|
||||
"default": ""
|
||||
},
|
||||
"httpOnly": {
|
||||
"title": "Interdire ou non l'accès au cookie en Javascript",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"secure": {
|
||||
"title": "Transmettre le cookie uniquement en HTTPS",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"maxAge": {
|
||||
"title": "Temps de vie du cookie et de la session associée.",
|
||||
"description": "Voir https://pkg.go.dev/time#ParseDuration pour le format attendu.",
|
||||
"default": "1h",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"oidc"
|
||||
]
|
||||
}
|
13
internal/proxy/director/layer/authn/oidc/layer.go
Normal file
13
internal/proxy/director/layer/authn/oidc/layer.go
Normal file
@ -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})
|
||||
}
|
62
internal/proxy/director/layer/authn/oidc/layer_options.go
Normal file
62
internal/proxy/director/layer/authn/oidc/layer_options.go
Normal file
@ -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
|
||||
}
|
39
internal/proxy/director/layer/authn/oidc/metrics.go
Normal file
39
internal/proxy/director/layer/authn/oidc/metrics.go
Normal file
@ -0,0 +1,39 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
const (
|
||||
metricNamespace = "bouncer_layer_authn_oidc"
|
||||
metricLabelProxy = "proxy"
|
||||
metricLabelLayer = "layer"
|
||||
)
|
||||
|
||||
var (
|
||||
metricLoginRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "login_requests_total",
|
||||
Help: "Bouncer's authn-oidc layer total login requests",
|
||||
Namespace: metricNamespace,
|
||||
},
|
||||
[]string{metricLabelProxy, metricLabelLayer},
|
||||
)
|
||||
metricLoginSuccessesTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "login_successes_total",
|
||||
Help: "Bouncer's authn-oidc layer total login successes",
|
||||
Namespace: metricNamespace,
|
||||
},
|
||||
[]string{metricLabelProxy, metricLabelLayer},
|
||||
)
|
||||
metricLogoutsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "logout_total",
|
||||
Help: "Bouncer's authn-oidc layer total logouts",
|
||||
Namespace: metricNamespace,
|
||||
},
|
||||
[]string{metricLabelProxy, metricLabelLayer},
|
||||
)
|
||||
)
|
8
internal/proxy/director/layer/authn/oidc/schema.go
Normal file
8
internal/proxy/director/layer/authn/oidc/schema.go
Normal file
@ -0,0 +1,8 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed layer-options.json
|
||||
var RawLayerOptionsSchema []byte
|
Reference in New Issue
Block a user