Skip to content

Commit 8f05dde

Browse files
ErmakovDmitriyDmitrii Ermakov
and
Dmitrii Ermakov
authored
Implement configurable OIDC claims extraction (#44)
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>
1 parent 9b7a59e commit 8f05dde

File tree

8 files changed

+166
-24
lines changed

8 files changed

+166
-24
lines changed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/spf13/viper v1.19.0
1111
github.com/stretchr/testify v1.9.0
1212
github.com/tebeka/selenium v0.9.9
13+
github.com/tidwall/gjson v1.17.1
1314
github.com/vmihailenco/msgpack/v5 v5.4.1
1415
golang.org/x/oauth2 v0.21.0
1516
)
@@ -34,6 +35,8 @@ require (
3435
github.com/spf13/cast v1.6.0 // indirect
3536
github.com/spf13/pflag v1.0.5 // indirect
3637
github.com/subosito/gotenv v1.6.0 // indirect
38+
github.com/tidwall/match v1.1.1 // indirect
39+
github.com/tidwall/pretty v1.2.0 // indirect
3740
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
3841
go.uber.org/atomic v1.9.0 // indirect
3942
go.uber.org/multierr v1.9.0 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
126126
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
127127
github.com/tebeka/selenium v0.9.9 h1:cNziB+etNgyH/7KlNI7RMC1ua5aH1+5wUlFQyzeMh+w=
128128
github.com/tebeka/selenium v0.9.9/go.mod h1:5Fr8+pUvU6B1OiPfkdCKdXZyr5znvVkxuPd0NOdZCQc=
129+
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
130+
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
131+
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
132+
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
133+
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
134+
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
129135
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
130136
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
131137
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=

internal/auth/aes_encryptor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func (ae *AESEncryptor) Decrypt(securemess string) (string, error) {
6868

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

7474
block, err := aes.NewCipher(ae.Key)

internal/auth/authenticator_oidc.go

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/base64"
66
"encoding/binary"
7+
"errors"
78
"fmt"
89
"net/http"
910
"strings"
@@ -79,13 +80,14 @@ type OIDCAuthenticator struct {
7980
}
8081

8182
type OAuthArgs struct {
82-
ssl bool
83-
host string
84-
pathq string
85-
clientid string
83+
ssl bool
84+
host string
85+
pathq string
86+
clientid string
8687
clientsecret string
87-
redirecturl string
88-
cookie string
88+
redirecturl string
89+
cookie string
90+
tokenClaims []string
8991
}
9092

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

132134
return oa
133135
}
134136

135137
func handleHealthCheck(w http.ResponseWriter, r *http.Request) {
136-
w.Write([]byte("OK"))
138+
_, _ = w.Write([]byte("OK"))
137139
}
138140

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

174-
func (oa *OIDCAuthenticator) checkCookie(cookieValue string, domain string) error {
176+
func (oa *OIDCAuthenticator) decryptCookie(cookieValue string, domain string) (*oidc.IDToken, error) {
175177
idToken, err := oa.encryptor.Decrypt(cookieValue)
176178
if err != nil {
177-
return fmt.Errorf("unable to decrypt session cookie: %v", err)
179+
return nil, fmt.Errorf("unable to decrypt session cookie: %w", err)
178180
}
179181

180-
_, err = oa.verifyIDToken(context.Background(), domain, idToken)
181-
return err
182+
token, err := oa.verifyIDToken(context.Background(), domain, idToken)
183+
return token, err
182184
}
183185

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

188191
// ssl
189192
sslValue, ok := msg.KV.Get("arg_ssl")
@@ -225,6 +228,15 @@ func extractOAuth2Args(msg *message.Message, readClientInfoFromMessages bool) (O
225228
cookieValue, ok := msg.KV.Get("arg_cookie")
226229
if ok {
227230
cookie, _ = cookieValue.(string)
231+
232+
// Token claims
233+
tokenClaimsValue, ok := msg.KV.Get("arg_token_claims")
234+
if ok {
235+
strV, ok := tokenClaimsValue.(string)
236+
if ok {
237+
tokenClaims = strings.Split(strV, " ")
238+
}
239+
}
228240
}
229241

230242
if readClientInfoFromMessages {
@@ -281,8 +293,9 @@ func extractOAuth2Args(msg *message.Message, readClientInfoFromMessages bool) (O
281293
clientsecret = &temp
282294
}
283295
return OAuthArgs{ssl: ssl, host: host, pathq: pathq,
284-
cookie: cookie, clientid: *clientid,
285-
clientsecret: *clientsecret, redirecturl: *redirecturl},
296+
cookie: cookie, clientid: *clientid,
297+
clientsecret: *clientsecret, redirecturl: *redirecturl,
298+
tokenClaims: tokenClaims},
286299
nil
287300
}
288301

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

329342
// Verify the cookie to make sure the user is authenticated
330343
if oauthArgs.cookie != "" {
331-
err := oa.checkCookie(oauthArgs.cookie, extractDomainFromHost(oauthArgs.host))
344+
idToken, err := oa.decryptCookie(oauthArgs.cookie, domain)
332345
if err != nil {
346+
// CoreOS/go-oidc does not have error types, so the errors are handled using strings
347+
// comparison.
348+
if errors.Is(err, &oidc.TokenExpiredError{}) || strings.Contains(err.Error(), "oidc:") {
349+
authorizationURL, e := oa.buildAuthorizationURL(domain, oauthArgs)
350+
if e != nil {
351+
return false, nil, e
352+
}
353+
354+
logrus.Infof("Authentication failed, redirecting to OIDC provider %s, reason: %s", authorizationURL, err)
355+
356+
return false, []action.Action{BuildRedirectURLMessage(authorizationURL)}, nil
357+
}
358+
333359
return false, nil, err
334-
} else {
360+
}
361+
362+
if len(oauthArgs.tokenClaims) == 0 {
335363
return true, nil, nil
364+
} else {
365+
// Extract token claims.
366+
actions, err := BuildTokenClaimsMessage(idToken, oauthArgs.tokenClaims)
367+
if err != nil {
368+
return false, nil, err
369+
}
370+
371+
return true, actions, nil
336372
}
373+
337374
}
338375

376+
authorizationURL, err := oa.buildAuthorizationURL(domain, oauthArgs)
377+
if err != nil {
378+
return false, nil, err
379+
}
380+
381+
return false, []action.Action{BuildRedirectURLMessage(authorizationURL)}, nil
382+
}
383+
384+
func (oa *OIDCAuthenticator) buildAuthorizationURL(domain string, oauthArgs OAuthArgs) (string, error) {
339385
currentTime := time.Now()
340386

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

347393
stateBytes, err := msgpack.Marshal(state)
348394
if err != nil {
349-
return false, nil, fmt.Errorf("unable to marshal the state")
395+
return "", fmt.Errorf("unable to marshal the state")
350396
}
351397

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

361-
return false, []action.Action{BuildRedirectURLMessage(authorizationURL)}, nil
407+
return authorizationURL, nil
362408
}
363409

364410
func (oa *OIDCAuthenticator) handleOAuth2Logout() http.HandlerFunc {

internal/auth/messages.go

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
package auth
22

3-
import action "github.com/negasus/haproxy-spoe-go/action"
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/coreos/go-oidc/v3/oidc"
9+
action "github.com/negasus/haproxy-spoe-go/action"
10+
"github.com/tidwall/gjson"
11+
)
412

513
// BuildRedirectURLMessage build a message containing the URL the user should be redirected too
614
func BuildRedirectURLMessage(url string) action.Action {
@@ -16,3 +24,70 @@ func BuildHasErrorMessage() action.Action {
1624
func AuthenticatedUserMessage(username string) action.Action {
1725
return action.NewSetVar(action.ScopeSession, "authenticated_user", username)
1826
}
27+
28+
func BuildTokenClaimsMessage(idToken *oidc.IDToken, claimsFilter []string) ([]action.Action, error) {
29+
var claimsData json.RawMessage
30+
31+
if err := idToken.Claims(&claimsData); err != nil {
32+
return nil, fmt.Errorf("unable to load OIDC claims: %w", err)
33+
}
34+
35+
claimsVals := gjson.ParseBytes(claimsData)
36+
result := make([]action.Action, 0, len(claimsFilter))
37+
38+
for i := range claimsFilter {
39+
value := claimsVals.Get(claimsFilter[i])
40+
41+
if !value.Exists() {
42+
continue
43+
}
44+
45+
key := computeSPOEKey(claimsFilter[i])
46+
result = append(result, action.NewSetVar(action.ScopeSession, key, gjsonToSPOEValue(&value)))
47+
}
48+
49+
return result, nil
50+
}
51+
52+
var spoeKeyReplacer = strings.NewReplacer("-", "_", ".", "_")
53+
54+
func computeSPOEKey(key string) string {
55+
return "token_claim_" + spoeKeyReplacer.Replace(key)
56+
}
57+
58+
func gjsonToSPOEValue(value *gjson.Result) interface{} {
59+
switch value.Type {
60+
case gjson.Null:
61+
// Null is a null json value
62+
return nil
63+
64+
case gjson.Number:
65+
// Number is json number
66+
return value.Int()
67+
68+
case gjson.String:
69+
// String is a json string
70+
return value.String()
71+
72+
default:
73+
if value.IsArray() {
74+
// Make a comma separated list.
75+
tmp := value.Array()
76+
lastInd := len(tmp) - 1
77+
sb := &strings.Builder{}
78+
79+
for i := 0; i <= lastInd; i++ {
80+
sb.WriteString(tmp[i].String())
81+
82+
if i != lastInd {
83+
sb.WriteRune(',')
84+
}
85+
}
86+
87+
return sb.String()
88+
}
89+
90+
// Other types such as True, False, JSON.
91+
return value.String()
92+
}
93+
}

resources/configuration/config.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ ldap:
1414
# The DN and password of the user to bind with in order to perform the search query to find the user
1515
user_dn: cn=admin,dc=example,dc=com
1616
password: password
17-
# The base DN used for the search queries
17+
# The base DN used for the search queries
1818
base_dn: dc=example,dc=com
1919
# The filter for the query searching for the user provided
2020
user_filter: "(&(cn={login})(ou:dn:={group}))"
@@ -36,6 +36,7 @@ oidc:
3636
# Various properties of the cookie holding the ID Token of the user
3737
cookie_name: authsession
3838
cookie_secure: false
39+
# If not set, then uses ID token expire timestamp
3940
cookie_ttl_seconds: 3600
4041
# The secret used to sign the state parameter
4142
signature_secret: myunsecuresecret

resources/haproxy/haproxy.cfg

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ frontend haproxynode
3232
http-request set-var(req.oidc_client_id) str(app3-client) if acl_app3
3333
http-request set-var(req.oidc_client_secret) str(app3-secret) if acl_app3
3434
http-request set-var(req.oidc_redirect_url) str(http://app3.example.com:9080/oauth2/callback) if acl_app3
35+
## Request extra OpenID token claims, space separated
36+
## The extra claims will be set as variables with keys: "token_claim_" + {{ claim name }},
37+
## where '.' and '-' are replaced with '_'.
38+
## Nested claims are supported.
39+
http-request set-var(req.oidc_token_claims) str("name roles org-groups resource_access.servicename.roles") if acl_app3
3540

3641
acl oauth2callback path_beg /oauth2/callback
3742
acl oauth2logout path_beg /oauth2/logout
@@ -58,6 +63,12 @@ frontend haproxynode
5863
use_backend backend_redirect if acl_app2 ! authenticated
5964
use_backend backend_app if acl_app2 authenticated
6065

66+
# Set headers based on OpenID token claims
67+
http-request set-header X-OIDC-Username %[var(sess.auth.token_claim_name)] if acl_app3 authenticated
68+
http-request set-header X-OIDC-Roles %[var(sess.auth.token_claim_roles)] if acl_app3 authenticated
69+
http-request set-header X-OIDC-Groups %[var(sess.auth.token_claim_org_groups)] if acl_app3 authenticated
70+
http-request set-header X-OIDC-Resource-Access %[var(sess.auth.token_claim_resource_access_servicename_roles)] if acl_app3 authenticated
71+
6172
# app3 redirects the user to the OAuth2 server when not authenticated
6273
use_backend backend_redirect if acl_app3 ! authenticated
6374
use_backend backend_app if acl_app3 authenticated

resources/haproxy/spoe-auth.conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,5 @@ spoe-message try-auth-ldap
1818
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 }
1919

2020
spoe-message try-auth-oidc
21-
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)
21+
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)
2222
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 commit comments

Comments
 (0)