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

feat: add order of the JWT source header/cookie/query as config #71

Merged
merged 1 commit into from
Jun 29, 2024
Merged
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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@ JwtHeaders | Map used to inject JWT payload fields as HTTP request headers.
OpaHeaders | Map used to inject OPA result fields as HTTP request headers. Populated if request is allowed by OPA. Only 1st level keys from OPA document are supported.
OpaResponseHeaders | Map used to inject OPA result fields as HTTP response headers. Populated if OPA response has `OpaAllowField` present, regardless of value. Only 1st level keys from OPA document are supported.
OpaHttpStatusField | Field in OPA JSON result, which contains int or string HTTP status code that will be returned in case of disallowed OPA response. Accepted range is >= 300 and < 600. Only 1st level keys from OPA document are supported.
JwtCookieKey | Name of the cookie to extract JWT if not found in `Authorization` header.
JwtQueryKey | Name of the query parameter to extract JWT if not found in `Authorization` header or in the specified cookie.
JwtCookieKey | (Deprecated, use JwtSources)Name of the cookie to extract JWT if not found in `Authorization` header.
JwtQueryKey | (Deprecated, use JwtSources) Name of the query parameter to extract JWT if not found in `Authorization` header or in the specified cookie.
JwtSources | Ordered List of sources [bearer, header, query, cookie] of the JWT. config format is a list of maps e.g. [{type: bearer, key: Authorization}, {type: query, key, jwt}]


### Example configuration

Expand Down Expand Up @@ -98,7 +100,11 @@ spec:
OpaResponseHeaders:
X-Allowed: allow
OpaHttpStatusField: allow_status_code
JwtCookieKey: jwt
JwtSources:
- type: bearer
key: Authorization
- type: cookie
key: jwt
---
apiVersion: networking.k8s.io/v1
kind: Ingress
Expand Down
77 changes: 57 additions & 20 deletions jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ type Config struct {
JwksHeaders map[string]string
OpaResponseHeaders map[string]string
OpaHttpStatusField string
JwtCookieKey string
JwtQueryKey string
JwtCookieKey string // Deprecated: use JwtSources instead
JwtQueryKey string // Deprecated: use JwtSources instead
JwtSources []map[string]string
}

// CreateConfig creates a new OPA Config
Expand Down Expand Up @@ -77,8 +78,7 @@ type JwtPlugin struct {
jwksHeaders map[string]string
opaResponseHeaders map[string]string
opaHttpStatusField string
jwtCookieKey string
jwtQueryKey string
jwtSources []map[string]string

name string
keysLock sync.RWMutex
Expand Down Expand Up @@ -191,10 +191,19 @@ func New(ctx context.Context, next http.Handler, config *Config, pluginName stri
jwksHeaders: config.JwksHeaders,
opaResponseHeaders: config.OpaResponseHeaders,
opaHttpStatusField: config.OpaHttpStatusField,
jwtCookieKey: config.JwtCookieKey,
jwtQueryKey: config.JwtQueryKey,
jwtSources: config.JwtSources,
name: pluginName,
}
// use default order if jwtSourceOrder is set
if len(jwtPlugin.jwtSources) == 0 {
jwtPlugin.jwtSources = []map[string]string{{"type": "bearer", "key": "Authorization"}}
if config.JwtCookieKey != "" {
jwtPlugin.jwtSources = append(jwtPlugin.jwtSources, map[string]string{"type": "cookie", "key": config.JwtCookieKey})
}
if config.JwtQueryKey != "" {
jwtPlugin.jwtSources = append(jwtPlugin.jwtSources, map[string]string{"type": "query", "key": config.JwtQueryKey})
}
}
if len(config.Keys) > 0 {
if err := jwtPlugin.ParseKeys(config.Keys); err != nil {
return nil, err
Expand Down Expand Up @@ -486,13 +495,33 @@ func (jwtPlugin *JwtPlugin) CheckToken(request *http.Request, rw http.ResponseWr
}

func (jwtPlugin *JwtPlugin) ExtractToken(request *http.Request) (*JWT, error) {
// first check if the token is present in header and is valid
jwtTokenStr, err := jwtPlugin.extractTokenFromHeader(request)
if err != nil && jwtPlugin.jwtCookieKey != "" {
jwtTokenStr, err = jwtPlugin.extractTokenFromCookie(request)
}
if err != nil && jwtPlugin.jwtQueryKey != "" {
jwtTokenStr, err = jwtPlugin.extractTokenFromQuery(request)
// extract from header, cookie, or query with given priority
var jwtTokenStr string
var err error
for _, sourceconfig := range jwtPlugin.jwtSources {
sourcetype, oktype := sourceconfig["type"]
if !oktype || (sourcetype != "bearer" && sourcetype != "header" && sourcetype != "cookie" && sourcetype != "query") {
jwtTokenStr, err = "", fmt.Errorf("source type unknown")
continue
}
sourcekey, okkey := sourceconfig["key"]
if !okkey || sourcekey == "" {
jwtTokenStr, err = "", fmt.Errorf("source key not found or empty")
continue
}
switch sourcetype {
case "bearer":
jwtTokenStr, err = jwtPlugin.extractTokenFromBearer(request, sourcekey)
case "header":
jwtTokenStr, err = jwtPlugin.extractTokenFromHeader(request, sourcekey)
case "cookie":
jwtTokenStr, err = jwtPlugin.extractTokenFromCookie(request, sourcekey)
case "query":
jwtTokenStr, err = jwtPlugin.extractTokenFromQuery(request, sourcekey)
}
if err == nil && jwtTokenStr != "" {
break
}
}
if err != nil {
return nil, err
Expand Down Expand Up @@ -535,32 +564,40 @@ func (jwtPlugin *JwtPlugin) ExtractToken(request *http.Request) (*JWT, error) {
return &jwtToken, nil
}

func (jwtPlugin *JwtPlugin) extractTokenFromHeader(request *http.Request) (string, error) {
authHeader, ok := request.Header["Authorization"]
func (jwtPlugin *JwtPlugin) extractTokenFromHeader(request *http.Request, key string) (string, error) {
authHeader, ok := request.Header[key]
if !ok {
return "", fmt.Errorf("authorization header missing")
}
auth := authHeader[0]
return auth, nil
}

func (jwtPlugin *JwtPlugin) extractTokenFromBearer(request *http.Request, key string) (string, error) {
auth, err := jwtPlugin.extractTokenFromHeader(request, key)
if err != nil {
return auth, err
}
if !strings.HasPrefix(strings.ToLower(auth), "bearer ") {
return "", fmt.Errorf("authorization type not Bearer")
}
return auth[7:], nil
}

func (jwtPlugin *JwtPlugin) extractTokenFromCookie(request *http.Request) (string, error) {
cookie, err := request.Cookie(jwtPlugin.jwtCookieKey)
func (jwtPlugin *JwtPlugin) extractTokenFromCookie(request *http.Request, key string) (string, error) {
cookie, err := request.Cookie(key)
if err != nil {
return "", err
}
return cookie.Value, nil
}

func (jwtPlugin *JwtPlugin) extractTokenFromQuery(request *http.Request) (string, error) {
func (jwtPlugin *JwtPlugin) extractTokenFromQuery(request *http.Request, key string) (string, error) {
query := request.URL.Query()
if !query.Has(jwtPlugin.jwtQueryKey) {
if !query.Has(key) {
return "", fmt.Errorf("query parameter missing")
}
parameter := query.Get(jwtPlugin.jwtQueryKey)
parameter := query.Get(key)
return parameter, nil
}

Expand Down
54 changes: 54 additions & 0 deletions jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1193,6 +1193,60 @@ func TestTokenFromQueryConfiguredButNotInURL(t *testing.T) {
}
}

func TestTokenFromHeaderConfigured(t *testing.T) {
cfg := *CreateConfig()
cfg.JwtSources = []map[string]string{{"type": "header", "key": "X-JWT"}}
ctx := context.Background()
nextCalled := false
next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { nextCalled = true })

jwt, err := New(ctx, next, &cfg, "test-traefik-jwt-plugin")
if err != nil {
t.Fatal(err)
}

recorder := httptest.NewRecorder()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil)
if err != nil {
t.Fatal(err)
}
req.Header["X-JWT"] = []string{"eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.TnHVsM5_N0SKi_HCwlz3ys1cDktu10g_sKkjqzVe5k09z-bmByflWPFWjAbwgRCKAc77kF8BjDNv0gisAPurBxgxNGxioDFehhcb0IS0YeCAWpzRfBMT6gQZ1gZeNM2Dg_yf4shPhF4rcUCGqnFFzIDSU9Rv2NNMK5DPO4512uTxAQUMHpi5PGTki-zykqTB10Ju1L4jRhmJwJDtGcfdHPlEKKUrFPfYl3RPZLOfdyAqSJ8Gi0R3ymDffmXHz08AJUAY_Kapk8laggIYcvFJhYGJBWZpcy7NWMiOIjEI3bogki4o7z0-Z1xMZdZ9rqypQ1MB44F8VZS2KkPfEmhSog"}

jwt.ServeHTTP(recorder, req)

if nextCalled == false {
t.Fatal("next.ServeHTTP was not called")
}
}

func TestTokenSourceOrder(t *testing.T) {
cfg := *CreateConfig()
cfg.JwtSources = []map[string]string{{"type": "header", "key": "X-JWT"}, {"type": "cookie", "key": "jwt"}}
ctx := context.Background()
nextCalled := false
next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { nextCalled = true })

jwt, err := New(ctx, next, &cfg, "test-traefik-jwt-plugin")
if err != nil {
t.Fatal(err)
}

recorder := httptest.NewRecorder()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "jwt", Value: "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.TnHVsM5_N0SKi_HCwlz3ys1cDktu10g_sKkjqzVe5k09z-bmByflWPFWjAbwgRCKAc77kF8BjDNv0gisAPurBxgxNGxioDFehhcb0IS0YeCAWpzRfBMT6gQZ1gZeNM2Dg_yf4shPhF4rcUCGqnFFzIDSU9Rv2NNMK5DPO4512uTxAQUMHpi5PGTki-zykqTB10Ju1L4jRhmJwJDtGcfdHPlEKKUrFPfYl3RPZLOfdyAqSJ8Gi0R3ymDffmXHz08AJUAY_Kapk8laggIYcvFJhYGJBWZpcy7NWMiOIjEI3bogki4o7z0-Z1xMZdZ9rqypQ1MB44F8VZS2KkPfEmhSog"})

jwt.ServeHTTP(recorder, req)

if nextCalled == false {
t.Fatal("next.ServeHTTP was not called")
}
}

func TestJwksHeaders(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading