Skip to content

Commit

Permalink
Merge pull request Gwojda#23 from Fmowers/main
Browse files Browse the repository at this point in the history
Add support for scope parameter in keyclock authentication | Add TokenCookieName functionality | Add Authorization header functionality
  • Loading branch information
Gwojda authored Jan 22, 2024
2 parents fa6f1a4 + 98755d1 commit 3a66805
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 34 deletions.
5 changes: 4 additions & 1 deletion .traefik.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type: middleware
import: github.com/Gwojda/keycloakopenid

# A brief description of what your plugin is doing.
summary: This plugin for Traefik allows it to authenticate requests against Keycloak. It utilizes the Keycloak's client credentials flow to retrieve an access token, which is then set as a bearer token in the Authorization header of the incoming requests. The plugin communicates with Keycloak using the OpenID Connect protocol.
summary: This plugin for Traefik allows it to authenticate requests against Keycloak. It utilizes the Keycloak's client credentials flow to retrieve an access token, which is then set as a bearer token in the Authorization cookie and as a plain token in a custom named cookie of the incoming requests. Optionally sets the Bearer token in the Authorization header. The plugin communicates with Keycloak using the OpenID Connect protocol.

# Medias associated to the plugin (optional)
iconPath: foo/icon.png
Expand All @@ -22,3 +22,6 @@ testData:
ClientID: "<CLIENT_ID"
ClientSecret: "<CLIENT_SECRET"
KeycloakRealm: "<REALM"
Scope: "openid"
TokenCookieName: "AUTH_TOKEN"
UseAuthHeader: false
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ experimental:
plugins:
keycloakopenid:
moduleName: "github.com/Gwojda/keycloakopenid"
version: "v0.1.32"
version: "v0.1.34"
```
Usage
Expand All @@ -59,6 +59,10 @@ http:
ClientID: "<CLIENT_ID"
ClientSecret: "<CLIENT_SECRET"
KeycloakRealm: "<REALM"
Scope: "<Scope [space deliminated] (default: 'openid', example: 'openid profile email')"
TokenCookieName: "<TOKEN_COOKIE_NAME (default: 'AUTH_TOKEN')"
UseAuthHeader: "<true|false (default: false)"
IgnorePathPrefixes: "/api,/favicon.ico [comma deliminated] (optional)"
```
Alternatively, ClientID and ClientSecret can be read from a file to support Docker Secrets and Kubernetes Secrets:
Expand All @@ -73,6 +77,9 @@ http:
ClientIDFile: "/run/secrets/clientId.txt"
ClientSecretFile: "/run/secrets/clientSecret.txt"
KeycloakRealm: "<REALM"
Scope: "<SCOPE [space deliminated] (default: 'openid', example: 'openid profile email')"
TokenCookieName: "<TOKEN_COOKIE_NAME (default: 'AUTH_TOKEN')"
UseAuthHeader: "<true|false (default: false)"
```
Last but not least, each configuration can be read from environment file to support some Kubernetes configurations:
Expand All @@ -87,6 +94,9 @@ http:
ClientIDEnv: "MY_KEYCLOAK_CLIENT_ID"
ClientSecretEnv: "MY_KEYCLOAK_CLIENT_SECRET"
KeycloakRealmEnv: "MY_KEYCLOAK_REALM"
ScopeEnv: "SCOPE [space deliminated] (default: 'openid', example: 'openid profile email')"
TokenCookieNameEnv: "TOKEN_COOKIE_NAME (default: 'AUTH_TOKEN')"
UseAuthHeaderEnv: "USE_AUTH_HEADER (default: false)"
```
This plugin also sets a header with a claim from Keycloak, as it has become reasonably common. Claim name and header name can be modified.
Expand All @@ -102,6 +112,9 @@ http:
ClientID: "<CLIENT_ID"
ClientSecret: "<CLIENT_SECRET"
KeycloakRealm: "<REALM"
Scope: "<SCOPE [space deliminated] (default: 'openid', example: 'openid profile email')"
TokenCookieName: "TOKEN_COOKIE_NAME (default: "AUTH_TOKEN)"
UseAuthHeader: "true|false (default: false)"
UserClaimName: "my-uncommon-claim"
UserHeaderName: "X-Custom-Header"
```
118 changes: 91 additions & 27 deletions config_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,41 @@ import (
)

type Config struct {
KeycloakURL string `json:"url"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
KeycloakRealm string `json:"keycloak_realm"`
UserClaimName string `json:"user_claim_name"`
UserHeaderName string `json:"user_header_name"`

ClientIDFile string `json:"client_id_file"`
ClientSecretFile string `json:"client_secret_file"`
KeycloakURLEnv string `json:"url_env"`
ClientIDEnv string `json:"client_id_env"`
ClientSecretEnv string `json:"client_secret_env"`
KeycloakRealmEnv string `json:"keycloak_realm_env"`
KeycloakURL string `json:"url"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
KeycloakRealm string `json:"keycloak_realm"`
Scope string `json:"scope"`
TokenCookieName string `json:"token_cookie_name"`
UseAuthHeader bool `json:"use_auth_header"`
UserClaimName string `json:"user_claim_name"`
UserHeaderName string `json:"user_header_name"`
IgnorePathPrefixes string `json:"ignore_path_prefixes"`

ClientIDFile string `json:"client_id_file"`
ClientSecretFile string `json:"client_secret_file"`
KeycloakURLEnv string `json:"url_env"`
ClientIDEnv string `json:"client_id_env"`
ClientSecretEnv string `json:"client_secret_env"`
KeycloakRealmEnv string `json:"keycloak_realm_env"`
ScopeEnv string `json:"scope_env"`
TokenCookieNameEnv string `json:"token_cookie_name_env"`
UseAuthHeaderEnv string `json:"use_auth_header_env"`
IgnorePathPrefixesEnv string `json:"ignore_path_prefixes_env"`
}

type keycloakAuth struct {
next http.Handler
KeycloakURL *url.URL
ClientID string
ClientSecret string
KeycloakRealm string
UserClaimName string
UserHeaderName string
next http.Handler
KeycloakURL *url.URL
ClientID string
ClientSecret string
KeycloakRealm string
Scope string
TokenCookieName string
UseAuthHeader bool
UserClaimName string
UserHeaderName string
IgnorePathPrefixes []string
}

type KeycloakTokenResponse struct {
Expand Down Expand Up @@ -121,6 +133,35 @@ func readConfigEnv(config *Config) error {
}
config.KeycloakRealm = strings.TrimSpace(keycloakRealm)
}
if config.ScopeEnv != "" {
scope := os.Getenv(config.ScopeEnv)
if scope == "" {
return errors.New("ScopeEnv referenced but NOT set")
}
config.Scope = scope //Do not trim space here as it is common to use space as a separator and should be properly escaped when encoded
}
if config.TokenCookieNameEnv != "" {
tokenCookieName := os.Getenv(config.TokenCookieNameEnv)
if tokenCookieName == "" {
return errors.New("TokenCookieNameEnv referenced but NOT set")
}
config.TokenCookieName = strings.TrimSpace(tokenCookieName)
}
if config.UseAuthHeaderEnv != "" {
useAuthHeader, exists := os.LookupEnv(config.UseAuthHeaderEnv)
if !exists {
useAuthHeader = "false"
}
useAuthHeader = strings.ToLower(useAuthHeader)
config.UseAuthHeader = useAuthHeader == "true" || useAuthHeader == "1"
}
if config.IgnorePathPrefixesEnv != "" {
ignorePathPrefixes := os.Getenv(config.IgnorePathPrefixesEnv)
if ignorePathPrefixes == "" {
return errors.New("IgnorePathPrefixesEnv referenced but NOT set")
}
config.IgnorePathPrefixes = strings.TrimSpace(ignorePathPrefixes)
}
return nil
}

Expand All @@ -143,6 +184,20 @@ func New(uctx context.Context, next http.Handler, config *Config, name string) (
return nil, err
}

if config.Scope == "" {
config.Scope = "openid"
}

tokenCookieName := "AUTH_TOKEN"
if config.TokenCookieName != "" {
tokenCookieName = config.TokenCookieName
}

useAuthHeader := false
if config.UseAuthHeader {
useAuthHeader = true
}

userClaimName := "preferred_username"
if config.UserClaimName != "" {
userClaimName = config.UserClaimName
Expand All @@ -153,13 +208,22 @@ func New(uctx context.Context, next http.Handler, config *Config, name string) (
userHeaderName = config.UserHeaderName
}

ignorePathPrefixes := []string{}
if config.IgnorePathPrefixes != "" {
ignorePathPrefixes = strings.Split(config.IgnorePathPrefixes, ",")
}

return &keycloakAuth{
next: next,
KeycloakURL: parsedURL,
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
KeycloakRealm: config.KeycloakRealm,
UserClaimName: userClaimName,
UserHeaderName: userHeaderName,
next: next,
KeycloakURL: parsedURL,
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
KeycloakRealm: config.KeycloakRealm,
Scope: config.Scope,
TokenCookieName: tokenCookieName,
UseAuthHeader: useAuthHeader,
UserClaimName: userClaimName,
UserHeaderName: userHeaderName,
IgnorePathPrefixes: ignorePathPrefixes,
}, nil
}
44 changes: 39 additions & 5 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import (
)

func (k *keycloakAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
for _, substr := range k.IgnorePathPrefixes {
if strings.Contains(req.URL.Path, substr) {
k.next.ServeHTTP(rw, req)
return
}
}
cookie, err := req.Cookie("Authorization")
if err == nil && strings.HasPrefix(cookie.Value, "Bearer ") {
token := strings.TrimPrefix(cookie.Value, "Bearer ")
Expand Down Expand Up @@ -50,6 +56,12 @@ func (k *keycloakAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if err == nil {
req.Header.Set(k.UserHeaderName, user)
}

if k.UseAuthHeader {
// Optionally set the Bearer token to the Authorization header.
req.Header.Set("Authorization", "Bearer " + token)
}

k.next.ServeHTTP(rw, req)
} else {
authCode := req.URL.Query().Get("code")
Expand All @@ -74,14 +86,35 @@ func (k *keycloakAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
return
}

http.SetCookie(rw, &http.Cookie{
if k.UseAuthHeader {
// Optionally set the Bearer token to the Authorization header.
req.Header.Set("Authorization", "Bearer " + token)
}

authCookie := &http.Cookie{
Name: "Authorization",
Value: "Bearer " + token,
Secure: true,
HttpOnly: true,
Path: "/",
SameSite: http.SameSiteStrictMode,
})
SameSite: http.SameSiteLaxMode, // Allows requests originating from sibling domains (same parent diff sub domain) to access the cookie
}

tokenCookie := &http.Cookie{
Name: k.TokenCookieName, // Defaults to "AUTH_TOKEN"
Value: token,
Secure: true,
HttpOnly: true,
Path: "/",
SameSite: http.SameSiteLaxMode, // Allows requests originating from sibling domains (same parent diff sub domain) to access the cookie
}

http.SetCookie(rw, authCookie)
req.AddCookie(authCookie) // Add the cookie to the request so it is present on the redirect and prevents infite loop of redirects.

// Set the token to a default/custom cookie that doesnt require trimming the Bearer prefix for common integration compatibility
http.SetCookie(rw, tokenCookie)
req.AddCookie(tokenCookie) // Add the cookie to the request so it is present on the initial redirect below.

qry := req.URL.Query()
qry.Del("code")
Expand All @@ -94,7 +127,7 @@ func (k *keycloakAuth) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
host := req.Header.Get("X-Forwarded-Host")
originalURL := fmt.Sprintf("%s://%s%s", scheme, host, req.RequestURI)

http.Redirect(rw, req, originalURL, http.StatusFound)
http.Redirect(rw, req, originalURL, http.StatusTemporaryRedirect)
}
}

Expand Down Expand Up @@ -185,9 +218,10 @@ func (k *keycloakAuth) redirectToKeycloak(rw http.ResponseWriter, req *http.Requ
"client_id": {k.ClientID},
"redirect_uri": {originalURL},
"state": {stateBase64},
"scope": {k.Scope},
}.Encode()

http.Redirect(rw, req, redirectURL.String(), http.StatusFound)
http.Redirect(rw, req, redirectURL.String(), http.StatusTemporaryRedirect)
}

func (k *keycloakAuth) verifyToken(token string) (bool, error) {
Expand Down
1 change: 1 addition & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestServeHTTP(t *testing.T) {
config.KeycloakRealm = "bochsler"
config.ClientID = "keycloakMiddleware"
config.ClientSecret = "uc0yKKpQsOqhggsG4eK7mDU3glT81chn"
config.Scope = "openid profile email"

// Create a new instance of our middleware
keycloakMiddleware, err := New(context.TODO(), http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
Expand Down

0 comments on commit 3a66805

Please sign in to comment.