Skip to content

Commit

Permalink
Implement configurable OIDC claims extraction (#44)
Browse files Browse the repository at this point in the history
This PR expands the authentication agent with methods to extract an OAuth2 token claims and set their values to HAProxy session variables.

The token claims are prefixed with "token_claim_" and can be used as in an example below:

```
http-request set-header X-OIDC-Username %[var(sess.auth.token_claim_name)] if acl_app3 authenticated
```
---------

Signed-off-by: Dmitrii Ermakov <dmitrii.ermakov@maxiv.lu.se>
Co-authored-by: Dmitrii Ermakov <dmitrii.ermakov@maxiv.lu.se>
  • Loading branch information
ErmakovDmitriy and Dmitrii Ermakov authored Jul 4, 2024
1 parent 9b7a59e commit 8f05dde
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 24 deletions.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
github.com/tebeka/selenium v0.9.9
github.com/tidwall/gjson v1.17.1
github.com/vmihailenco/msgpack/v5 v5.4.1
golang.org/x/oauth2 v0.21.0
)
Expand All @@ -34,6 +35,8 @@ require (
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tebeka/selenium v0.9.9 h1:cNziB+etNgyH/7KlNI7RMC1ua5aH1+5wUlFQyzeMh+w=
github.com/tebeka/selenium v0.9.9/go.mod h1:5Fr8+pUvU6B1OiPfkdCKdXZyr5znvVkxuPd0NOdZCQc=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
Expand Down
2 changes: 1 addition & 1 deletion internal/auth/aes_encryptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func (ae *AESEncryptor) Decrypt(securemess string) (string, error) {

ciphertextAndNonce, err := base64.StdEncoding.DecodeString(securemess)
if err != nil {
return "", fmt.Errorf("unable to b64 decode secure message: %v", err)
return "", fmt.Errorf("unable to b64 decode secure message: %w", err)
}

block, err := aes.NewCipher(ae.Key)
Expand Down
86 changes: 66 additions & 20 deletions internal/auth/authenticator_oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"net/http"
"strings"
Expand Down Expand Up @@ -79,13 +80,14 @@ type OIDCAuthenticator struct {
}

type OAuthArgs struct {
ssl bool
host string
pathq string
clientid string
ssl bool
host string
pathq string
clientid string
clientsecret string
redirecturl string
cookie string
redirecturl string
cookie string
tokenClaims []string
}

// NewOIDCAuthenticator create an instance of an OIDC authenticator
Expand Down Expand Up @@ -126,14 +128,14 @@ func NewOIDCAuthenticator(options OIDCAuthenticatorOptions) *OIDCAuthenticator {
http.HandleFunc(options.LogoutPath, oa.handleOAuth2Logout())
logrus.Infof("OIDC API is exposed on %s", options.CallbackAddr)
http.HandleFunc(options.HealthCheckPath, handleHealthCheck)
http.ListenAndServe(options.CallbackAddr, nil)
logrus.Fatalln(http.ListenAndServe(options.CallbackAddr, nil))
}()

return oa
}

func handleHealthCheck(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
_, _ = w.Write([]byte("OK"))
}

func (oa *OIDCAuthenticator) withOAuth2Config(domain string, callback func(c oauth2.Config) error) error {
Expand Down Expand Up @@ -166,24 +168,25 @@ func (oa *OIDCAuthenticator) verifyIDToken(context context.Context, domain strin
// Parse and verify ID Token payload.
idToken, err := verifier.Verify(context, rawIDToken)
if err != nil {
return nil, fmt.Errorf("unable to verify ID Token: %v", err)
return nil, fmt.Errorf("unable to verify ID Token: %w", err)
}
return idToken, nil
}

func (oa *OIDCAuthenticator) checkCookie(cookieValue string, domain string) error {
func (oa *OIDCAuthenticator) decryptCookie(cookieValue string, domain string) (*oidc.IDToken, error) {
idToken, err := oa.encryptor.Decrypt(cookieValue)
if err != nil {
return fmt.Errorf("unable to decrypt session cookie: %v", err)
return nil, fmt.Errorf("unable to decrypt session cookie: %w", err)
}

_, err = oa.verifyIDToken(context.Background(), domain, idToken)
return err
token, err := oa.verifyIDToken(context.Background(), domain, idToken)
return token, err
}

func extractOAuth2Args(msg *message.Message, readClientInfoFromMessages bool) (OAuthArgs, error) {
var cookie string
var clientid, clientsecret, redirecturl *string
var tokenClaims []string

// ssl
sslValue, ok := msg.KV.Get("arg_ssl")
Expand Down Expand Up @@ -225,6 +228,15 @@ func extractOAuth2Args(msg *message.Message, readClientInfoFromMessages bool) (O
cookieValue, ok := msg.KV.Get("arg_cookie")
if ok {
cookie, _ = cookieValue.(string)

// Token claims
tokenClaimsValue, ok := msg.KV.Get("arg_token_claims")
if ok {
strV, ok := tokenClaimsValue.(string)
if ok {
tokenClaims = strings.Split(strV, " ")
}
}
}

if readClientInfoFromMessages {
Expand Down Expand Up @@ -281,8 +293,9 @@ func extractOAuth2Args(msg *message.Message, readClientInfoFromMessages bool) (O
clientsecret = &temp
}
return OAuthArgs{ssl: ssl, host: host, pathq: pathq,
cookie: cookie, clientid: *clientid,
clientsecret: *clientsecret, redirecturl: *redirecturl},
cookie: cookie, clientid: *clientid,
clientsecret: *clientsecret, redirecturl: *redirecturl,
tokenClaims: tokenClaims},
nil
}

Expand Down Expand Up @@ -328,14 +341,47 @@ func (oa *OIDCAuthenticator) Authenticate(msg *message.Message) (bool, []action.

// Verify the cookie to make sure the user is authenticated
if oauthArgs.cookie != "" {
err := oa.checkCookie(oauthArgs.cookie, extractDomainFromHost(oauthArgs.host))
idToken, err := oa.decryptCookie(oauthArgs.cookie, domain)
if err != nil {
// CoreOS/go-oidc does not have error types, so the errors are handled using strings
// comparison.
if errors.Is(err, &oidc.TokenExpiredError{}) || strings.Contains(err.Error(), "oidc:") {
authorizationURL, e := oa.buildAuthorizationURL(domain, oauthArgs)
if e != nil {
return false, nil, e
}

logrus.Infof("Authentication failed, redirecting to OIDC provider %s, reason: %s", authorizationURL, err)

return false, []action.Action{BuildRedirectURLMessage(authorizationURL)}, nil
}

return false, nil, err
} else {
}

if len(oauthArgs.tokenClaims) == 0 {
return true, nil, nil
} else {
// Extract token claims.
actions, err := BuildTokenClaimsMessage(idToken, oauthArgs.tokenClaims)
if err != nil {
return false, nil, err
}

return true, actions, nil
}

}

authorizationURL, err := oa.buildAuthorizationURL(domain, oauthArgs)
if err != nil {
return false, nil, err
}

return false, []action.Action{BuildRedirectURLMessage(authorizationURL)}, nil
}

func (oa *OIDCAuthenticator) buildAuthorizationURL(domain string, oauthArgs OAuthArgs) (string, error) {
currentTime := time.Now()

var state State
Expand All @@ -346,7 +392,7 @@ func (oa *OIDCAuthenticator) Authenticate(msg *message.Message) (bool, []action.

stateBytes, err := msgpack.Marshal(state)
if err != nil {
return false, nil, fmt.Errorf("unable to marshal the state")
return "", fmt.Errorf("unable to marshal the state")
}

var authorizationURL string
Expand All @@ -355,10 +401,10 @@ func (oa *OIDCAuthenticator) Authenticate(msg *message.Message) (bool, []action.
return nil
})
if err != nil {
return false, nil, fmt.Errorf("unable to build authorize url: %w", err)
return "", fmt.Errorf("unable to build authorize url: %w", err)
}

return false, []action.Action{BuildRedirectURLMessage(authorizationURL)}, nil
return authorizationURL, nil
}

func (oa *OIDCAuthenticator) handleOAuth2Logout() http.HandlerFunc {
Expand Down
77 changes: 76 additions & 1 deletion internal/auth/messages.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package auth

import action "github.com/negasus/haproxy-spoe-go/action"
import (
"encoding/json"
"fmt"
"strings"

"github.com/coreos/go-oidc/v3/oidc"
action "github.com/negasus/haproxy-spoe-go/action"
"github.com/tidwall/gjson"
)

// BuildRedirectURLMessage build a message containing the URL the user should be redirected too
func BuildRedirectURLMessage(url string) action.Action {
Expand All @@ -16,3 +24,70 @@ func BuildHasErrorMessage() action.Action {
func AuthenticatedUserMessage(username string) action.Action {
return action.NewSetVar(action.ScopeSession, "authenticated_user", username)
}

func BuildTokenClaimsMessage(idToken *oidc.IDToken, claimsFilter []string) ([]action.Action, error) {
var claimsData json.RawMessage

if err := idToken.Claims(&claimsData); err != nil {
return nil, fmt.Errorf("unable to load OIDC claims: %w", err)
}

claimsVals := gjson.ParseBytes(claimsData)
result := make([]action.Action, 0, len(claimsFilter))

for i := range claimsFilter {
value := claimsVals.Get(claimsFilter[i])

if !value.Exists() {
continue
}

key := computeSPOEKey(claimsFilter[i])
result = append(result, action.NewSetVar(action.ScopeSession, key, gjsonToSPOEValue(&value)))
}

return result, nil
}

var spoeKeyReplacer = strings.NewReplacer("-", "_", ".", "_")

func computeSPOEKey(key string) string {
return "token_claim_" + spoeKeyReplacer.Replace(key)
}

func gjsonToSPOEValue(value *gjson.Result) interface{} {
switch value.Type {
case gjson.Null:
// Null is a null json value
return nil

case gjson.Number:
// Number is json number
return value.Int()

case gjson.String:
// String is a json string
return value.String()

default:
if value.IsArray() {
// Make a comma separated list.
tmp := value.Array()
lastInd := len(tmp) - 1
sb := &strings.Builder{}

for i := 0; i <= lastInd; i++ {
sb.WriteString(tmp[i].String())

if i != lastInd {
sb.WriteRune(',')
}
}

return sb.String()
}

// Other types such as True, False, JSON.
return value.String()
}
}
3 changes: 2 additions & 1 deletion resources/configuration/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ ldap:
# The DN and password of the user to bind with in order to perform the search query to find the user
user_dn: cn=admin,dc=example,dc=com
password: password
# The base DN used for the search queries
# The base DN used for the search queries
base_dn: dc=example,dc=com
# The filter for the query searching for the user provided
user_filter: "(&(cn={login})(ou:dn:={group}))"
Expand All @@ -36,6 +36,7 @@ oidc:
# Various properties of the cookie holding the ID Token of the user
cookie_name: authsession
cookie_secure: false
# If not set, then uses ID token expire timestamp
cookie_ttl_seconds: 3600
# The secret used to sign the state parameter
signature_secret: myunsecuresecret
Expand Down
11 changes: 11 additions & 0 deletions resources/haproxy/haproxy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ frontend haproxynode
http-request set-var(req.oidc_client_id) str(app3-client) if acl_app3
http-request set-var(req.oidc_client_secret) str(app3-secret) if acl_app3
http-request set-var(req.oidc_redirect_url) str(http://app3.example.com:9080/oauth2/callback) if acl_app3
## Request extra OpenID token claims, space separated
## The extra claims will be set as variables with keys: "token_claim_" + {{ claim name }},
## where '.' and '-' are replaced with '_'.
## Nested claims are supported.
http-request set-var(req.oidc_token_claims) str("name roles org-groups resource_access.servicename.roles") if acl_app3

acl oauth2callback path_beg /oauth2/callback
acl oauth2logout path_beg /oauth2/logout
Expand All @@ -58,6 +63,12 @@ frontend haproxynode
use_backend backend_redirect if acl_app2 ! authenticated
use_backend backend_app if acl_app2 authenticated

# Set headers based on OpenID token claims
http-request set-header X-OIDC-Username %[var(sess.auth.token_claim_name)] if acl_app3 authenticated
http-request set-header X-OIDC-Roles %[var(sess.auth.token_claim_roles)] if acl_app3 authenticated
http-request set-header X-OIDC-Groups %[var(sess.auth.token_claim_org_groups)] if acl_app3 authenticated
http-request set-header X-OIDC-Resource-Access %[var(sess.auth.token_claim_resource_access_servicename_roles)] if acl_app3 authenticated

# app3 redirects the user to the OAuth2 server when not authenticated
use_backend backend_redirect if acl_app3 ! authenticated
use_backend backend_app if acl_app3 authenticated
Expand Down
2 changes: 1 addition & 1 deletion resources/haproxy/spoe-auth.conf
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ spoe-message try-auth-ldap
event on-frontend-http-request if { hdr_beg(host) -i app1.example.com } || { hdr_beg(host) -i app2.example.com } || { hdr_beg(host) -i app3.example.com }

spoe-message try-auth-oidc
args arg_ssl=ssl_fc arg_host=req.hdr(Host) arg_pathq=pathq arg_cookie=req.cook(authsession) arg_client_id=var(req.oidc_client_id) arg_client_secret=var(req.oidc_client_secret) arg_redirect_url=var(req.oidc_redirect_url)
args arg_ssl=ssl_fc arg_host=req.hdr(Host) arg_pathq=pathq arg_cookie=req.cook(authsession) arg_client_id=var(req.oidc_client_id) arg_client_secret=var(req.oidc_client_secret) arg_redirect_url=var(req.oidc_redirect_url) arg_token_claims=var(req.oidc_token_claims)
event on-frontend-http-request if { hdr_beg(host) -i app1.example.com } || { hdr_beg(host) -i app2.example.com } || { hdr_beg(host) -i app3.example.com }

0 comments on commit 8f05dde

Please sign in to comment.