Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement OpenID token expressions evaluation #63

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@

build/
haproxy-ldap-auth

.vscode/
2 changes: 2 additions & 0 deletions cmd/haproxy-spoe-auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Build artefact
haproxy-spoe-auth
181 changes: 158 additions & 23 deletions cmd/haproxy-spoe-auth/main.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package main

import (
"flag"
"fmt"
"net/http"
_ "net/http/pprof"
"time"

"github.com/criteo/haproxy-spoe-auth/internal/agent"
"github.com/criteo/haproxy-spoe-auth/internal/auth"
"github.com/sirupsen/logrus"
"github.com/spf13/pflag"
"github.com/spf13/viper"
)

// DefaultStateTTL is the amount of time before the state is considered expired. This will be replaced
// by an expiration in a JWT token in a future review.
const DefaultStateTTL = 5 * time.Minute

func LogLevelFromLogString(level string) logrus.Level {
switch level {
case "info":
Expand All @@ -22,14 +28,45 @@ func LogLevelFromLogString(level string) logrus.Level {
}
}

type flagsConfig struct {
dynamicClientInfo bool
configFile string
pprofBind string
}

func parseFlags() flagsConfig {
var cfg flagsConfig

pflag.StringP("config", "c", "", "The path to the configuration file")
pflag.BoolP("dynamic", "d", false, "Dynamically read client information")
pflag.StringP("pprof", "p", "", "pprof socket to listen to")
pflag.Parse()

if err := viper.BindPFlags(pflag.CommandLine); err != nil {
logrus.WithError(err).Fatalln("Can not init cmd flags")
}

viper.SetEnvPrefix("HAPROXY_SPOE_AUTH")

vars := []string{"config", "dynamic", "pprof"}
for _, v := range vars {
if err := viper.BindEnv(v); err != nil {
logrus.WithError(err).Fatalln("Can not bind Viper environment variable")
}
}

cfg.configFile = viper.GetString("config")
cfg.dynamicClientInfo = viper.GetBool("dynamic")
cfg.pprofBind = viper.GetString("pprof")

return cfg
}

func main() {
var configFile string
flag.StringVar(&configFile, "config", "", "The path to the configuration file")
dynamicClientInfo := flag.Bool("dynamic-client-info", false, "Dynamically read client information")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems breaking. If I read correctly, you're replacing option -dynamic-client-info by option -dynamic/-d which clearly breaks the bakward compatibility. If so please mark it as breaking

flag.Parse()
flagsCfg := parseFlags()

if configFile != "" {
viper.SetConfigFile(configFile)
if flagsCfg.configFile != "" {
viper.SetConfigFile(flagsCfg.configFile)
} else {
viper.SetConfigName("config") // name of config file (without extension)
viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name
Expand All @@ -42,52 +79,150 @@ func main() {

logrus.SetLevel(LogLevelFromLogString(viper.GetString("server.log_level")))

if viper.GetBool("server.log_json") {
logrus.SetFormatter(&logrus.JSONFormatter{})
}

logrus.WithFields(logrus.Fields{
"config": flagsCfg.configFile,
"dynamic-client-info": flagsCfg.dynamicClientInfo,
"pprof": flagsCfg.pprofBind,
}).Info("Command line flags")

authenticators := map[string]auth.Authenticator{}

if viper.IsSet("ldap") {
ldapAuthentifier := auth.NewLDAPAuthenticator(auth.LDAPConnectionDetails{
var SPOEMessageName = viper.GetString("ldap.spoe_message")
if SPOEMessageName == "" {
logrus.Fatal("Configuration field ldap.spoe_message is not defined")
}

var ldapConnCfg = auth.LDAPConnectionDetails{
URI: viper.GetString("ldap.uri"),
Port: viper.GetInt("ldap.port"),
UserDN: viper.GetString("ldap.user_dn"),
Password: viper.GetString("ldap.password"),
BaseDN: viper.GetString("ldap.base_dn"),
UserFilter: viper.GetString("ldap.user_filter"),
VerifyTLS: viper.GetBool("ldap.verify_tls"),
})
authenticators["try-auth-ldap"] = ldapAuthentifier
}

ldapAuthentifier := auth.NewLDAPAuthenticator(ldapConnCfg)
authenticators[SPOEMessageName] = ldapAuthentifier

// Print configuration.
logrus.WithFields(logrus.Fields{
"authenticator": "LDAP",
"SPOE_message": SPOEMessageName,
"URI": ldapConnCfg.URI,
"port": ldapConnCfg.Port,
"user_dn": ldapConnCfg.UserDN,
"user_filter": ldapConnCfg.UserFilter,
"tls_verify": ldapConnCfg.VerifyTLS,
}).Info("LDAP authenticator configuration")
} else {
logrus.WithField("authenticator", "LDAP").Info("LDAP authentication is not configured")
}

if viper.IsSet("oidc") {
var SPOEMessageName = viper.GetString("oidc.spoe_message")
if SPOEMessageName == "" {
logrus.Fatal("Configuration field oidc.spoe_message is not defined")
}

var clientsStore auth.OIDCClientsStore
if !*dynamicClientInfo {
// TODO: watch the config file to update the list of clients dynamically
var clientsConfig map[string]auth.OIDCClientConfig
err := viper.UnmarshalKey("oidc.clients", &clientsConfig)
if err != nil {
logrus.Panic(err)
}
clientsStore = auth.NewStaticOIDCClientStore(clientsConfig)

// TODO: watch the config file to update the list of clients dynamically
var clientsConfig map[string]auth.OIDCClientConfig
err := viper.UnmarshalKey("oidc.clients", &clientsConfig)
if err != nil {
logrus.Panic(err)
}
clientsStore = auth.NewStaticOIDCClientStore(clientsConfig)

// Load Cookie and State TTLs and set defaults.
var (
cookieTTL time.Duration
stateTTL time.Duration
)

if v := viper.GetUint64("oidc.cookie_ttl_seconds"); v != 0 {
cookieTTL = time.Duration(v) * time.Second
}

if v := viper.GetUint64("oidc.state_ttl_seconds"); v != 0 {
stateTTL = time.Duration(v) * time.Second
} else {
clientsStore = auth.NewEmptyStaticOIDCClientStore()
stateTTL = DefaultStateTTL
}

oidcAuthenticator := auth.NewOIDCAuthenticator(auth.OIDCAuthenticatorOptions{
oidcAuthConfig := auth.OIDCAuthenticatorOptions{
OAuth2AuthenticatorOptions: auth.OAuth2AuthenticatorOptions{
RedirectCallbackPath: viper.GetString("oidc.oauth2_callback_path"),
LogoutPath: viper.GetString("oidc.oauth2_logout_path"),
HealthCheckPath: viper.GetString("oidc.oauth2_healthcheck_path"),
CallbackAddr: viper.GetString("oidc.callback_addr"),
CookieName: viper.GetString("oidc.cookie_name"),
CookieSecure: viper.GetBool("oidc.cookie_secure"),
CookieTTL: viper.GetDuration("oidc.cookie_ttl_seconds") * time.Second,
CookieTTL: cookieTTL,
StateTTL: stateTTL,
SignatureSecret: viper.GetString("oidc.signature_secret"),
SupportEmailAddress: viper.GetString("oidc.server.contacts.email"),
SupportEmailSubject: viper.GetString("oidc.server.contacts.subject"),
ClientsStore: clientsStore,
ReadClientInfoFromMessages: *dynamicClientInfo,
ReadClientInfoFromMessages: flagsCfg.dynamicClientInfo,
},
ProviderURL: viper.GetString("oidc.provider_url"),
EncryptionSecret: viper.GetString("oidc.encryption_secret"),
}

oidcAuthenticator := auth.NewOIDCAuthenticator(oidcAuthConfig)
authenticators[SPOEMessageName] = oidcAuthenticator

// Print configuration.
logrus.WithFields(logrus.Fields{
"authenticator": "OAuth2",
"SPOE_message": SPOEMessageName,
"oauth2_callback_path": oidcAuthConfig.RedirectCallbackPath,
"oauth2_logout_path": oidcAuthConfig.LogoutPath,
"oauth2_healthcheck_path": oidcAuthConfig.HealthCheckPath,
"callback_addr": oidcAuthConfig.CallbackAddr,
"cookie_name": oidcAuthConfig.CookieName,
"cookie_secure": oidcAuthConfig.CookieSecure,
"cookie_ttl_seconds": oidcAuthConfig.CookieTTL.Seconds(),
"state_ttl_seconds": oidcAuthConfig.StateTTL.Seconds(),
"dynamic_client_info": oidcAuthConfig.ReadClientInfoFromMessages,
"provider_url": oidcAuthConfig.ProviderURL,
"support_email_address": oidcAuthConfig.SupportEmailAddress,
"support_email_subject": oidcAuthConfig.SupportEmailSubject,
}).Info("OAuth2 authenticator configuration")

var clientsLog = logrus.WithFields(logrus.Fields{
"authenticator": "OAuth2",
"message_type": "client_info",
})
authenticators["try-auth-oidc"] = oidcAuthenticator
for k, v := range clientsConfig {
clientsLog.WithFields(logrus.Fields{
"client_domain": k,
"client_id": v.ClientID,
"redirect_url": v.RedirectURL,
}).Info("OAuth2 static client configuration")
}
} else {
logrus.WithField("authenticator", "OAuth2").Info("OAuth2 authentication is not configured")
}

// Starting profiler.
if flagsCfg.pprofBind != "" {
go func() {
logrus.WithField("listen_socket", flagsCfg.pprofBind).Info("Starting pprof server")

if err := http.ListenAndServe(flagsCfg.pprofBind, nil); err != nil {
logrus.WithError(err).Fatal("Can not start pprof server")
}

logrus.Info("Stopped pprof server")
}()
}

agent.StartAgent(viper.GetString("server.addr"), authenticators)
Expand Down
59 changes: 57 additions & 2 deletions docs/openidconnect.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,72 @@ Cookies are secure by default. There is an option to disable that flag but pleas
disable the flag. It would expose your users' sessions to leakage on the Internet. This flag is only here for test
purposes.

### Cookie TTL
### Cookie TTL

The cookie has a TTL set to 1h by default. This is the amount of time before the user does a new round trip to the
OAuth2 server. If the server has kept the user logged in, there will be no authentication involved but the user will be
redirected. You need to find the right balance between longevity of the session and security. The more the session lives,
the more there are chances the session has been compromised.

### Token Claims

The SPOE agent supports information extraction from an OpenID ID token claims data.

The required claims list must be passed from HAProxy in a variable `arg_token_claims`
as JSON paths separated by spaces.
If the claims themselves contain spaces, dashes or other characters not in [a-zA-Z0-9], the characters must be URL Query encoded.
On successful authentication, the agent
will set HAProxy session variables, one variable per requested claim as:

```
token_claim_{{ JSON path | replace with '_' everything except a-z, A-Z, 0-9 }}={{ claim value }}
```

See [messages_test.go](../internal/auth/messages_test.go) for examples.

### Token Expressions

The SPOE agent supports simple expressions evaluation based on an OpenID ID token claims data.
If the claims or their values contain spaces, dashes or other characters not in [a-zA-Z0-9], the characters must be URL Query encoded.

Supported operations are:

- exists
- doesnotexist
- in
- notin

The expressions must be passed from HAProxy in a variable `arg_token_expressions` in a format:

```
{{ operation }};{{ claim JSON path }};{{ value }}
```

for `in` and `notin` and

```
{{ operation }};{{ claim JSON path }}
```

for `exists` and `doesnotexit`.

The operations `in` and `notin` expect that the `JSON path` points to a list of values.

The agent evaluates the requested operations and passes results in HAProxy session variables as
```
token_expression_{{ operation }}_{{ claim JSON path | replace with '_' everything except a-z, A-Z, 0-9 }}_{{ value | replace with '_' everything except a-z, A-Z, 0-9 }}=(1|0)
```
for `in`, `notin` and

```
token_expression_{{ operation }}_{{ claim JSON path | replace with '_' everything except a-z, A-Z, 0-9 }}=(1|0)
```
for `exists`, `doesnotexist`.

See [messages_test.go](../internal/auth/messages_test.go) for examples.

## TODO

* Add a mechanism to denylist a token.
* Allow the SPOE agent to match a certain list of groups the user belongs to.
* Think about secret rotation.
* Prevent replay attacks by putting some kind of nonce in the state.
Loading