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

Conditional backend request skipping based on JWT type #928

Closed
Maximax67 opened this issue Oct 4, 2024 · 1 comment
Closed

Conditional backend request skipping based on JWT type #928

Maximax67 opened this issue Oct 4, 2024 · 1 comment
Labels

Comments

@Maximax67
Copy link

Environment info:

  • KrakenD version: 2.7.2

Describe what are you trying to do:
I have two types of JWT tokens: ACCESS and API. The token type is stored as a string in a claim named type. My goal is to apply conditional logic based on this type before forwarding the request to the backend:

  • If the token type is API, I want to validate the token by calling an authentication microservice (/auth/api-tokens/validate/{JWT.jti}) and after that send request to target endpoint.
  • If the token type is ACCESS, I want to skip the validation step and send the request directly to the target endpoint.

I need a way to skip one backend request (the token validation) based on the JWT type. I'm unsure if this is possible in KrakenD.

I created the following configuration, but it always sends requests to the /auth/api-tokens/validate/{JWT.jti} endpoint, even for access tokens. I would like to bypass this validation for access tokens and send the request directly to the second backend if the token type is access.

{
  "endpoint": "/users/me",
  {{ include "input_headers_jwt_validation.tmpl" }},
  "method": "GET",
  "backend": [
    {
      "group": "api_token_validation",
      "url_pattern": "/auth/api-tokens/validate/{JWT.jti}",
      "method": "GET",
      "host": ["{{ .env.auth_host }}"],
      "extra_config": {
        "validation/cel": [
          {
            "check_expr": "JWT.type == 'API'"
          }
        ],
        {{ include "rate_limit_backend.tmpl" }}
      }
    },
    {
      "url_pattern": "/users/me",
      "method": "GET",
      "extra_config": {
        "validation/cel": [
          {
            "check_expr": "JWT.type == 'ACCESS' || resp_data.api_token_validation.isValid"
          }
        ],
        {{ include "rate_limit_backend.tmpl" }}
      },
      "host": ["{{ .env.main_host }}"]
    }
  ],
  "extra_config": {
    "proxy": {
      "sequential": true
    },
    {{ template "auth_validator.tmpl" . }}
  }
}

Is there a way to conditionally skip the first backend (the token validation) if the JWT type is ACCESS? If so, how can I configure KrakenD to do this?

@Maximax67
Copy link
Author

Maximax67 commented Oct 8, 2024

I managed to solve this issue by writing a custom plugin for KrakenD. But I updated the logic: jti is now passed in the request body instead of the URL path. Also I implemented a mechanism to reject access tokens that were issued before KrakenD was started.

Everything works as I expected. Maybe it will be useful to someone. I will leave the code below.

main.go

package main

import (
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
)

func main() {}

// ModifierRegisterer registers the plugin
var ModifierRegisterer = registerer("krakend-tokens-validation")

var errUnauthorized = HTTPResponseError{
	Code:         http.StatusUnauthorized,
	Msg:          "",
	HTTPEncoding: "application/json",
}

var logger Logger = nil

type registerer string

var serverStartupTime int64

func (r registerer) RegisterModifiers(f func(
	name string,
	modifierFactory func(map[string]interface{}) func(interface{}) (interface{}, error),
	appliesToRequest bool,
	appliesToResponse bool,
)) {
	f(string(r), r.modifierFactory, true, false)
}

func (r registerer) RegisterLogger(in interface{}) {
	serverStartupTime = time.Now().Unix()

	l, ok := in.(Logger)
	if !ok {
		return
	}

	logger = l
	logger.Debug(fmt.Sprintf("[PLUGIN: %s] Logger loaded", ModifierRegisterer))
}

func (r registerer) modifierFactory(config map[string]interface{}) func(interface{}) (interface{}, error) {
	validatorUrl, ok := config["api-tokens-validator-url"].(string)
	if !ok {
		if logger != nil {
			logger.Error(fmt.Sprintf("[PLUGIN: %s] api-tokens-validator-url not provided in config", ModifierRegisterer))
		}

		return nil
	}

	verifyApiTokens, ok := config["verify-api-tokens"].(bool)
	if !ok {
		if logger != nil {
			logger.Error(fmt.Sprintf("[PLUGIN: %s] verify-api-tokens not provided in config", ModifierRegisterer))
		}

		return nil
	}

	return func(input interface{}) (interface{}, error) {
		req, ok := input.(RequestWrapper)
		if !ok {
			return nil, errors.New("unknown request type")
		}

		headers := req.Headers()

		// Extract X-Token-Iat header
		tokenIat := getHeader(headers, "X-Token-Iat")
		if tokenIat == "" {
			if logger != nil {
				logger.Debug(fmt.Sprintf("[PLUGIN: %s] Token rejected: iat is missing", ModifierRegisterer))
			}

			return nil, errUnauthorized
		}

		// Convert X-Token-Iat to Unix timestamp and compare it with server startup time
		tokenIatTime, err := strconv.ParseInt(tokenIat, 10, 64)
		if err != nil {
			if logger != nil {
				logger.Debug(fmt.Sprintf("[PLUGIN: %s] Token rejected: invalid iat", ModifierRegisterer))
			}

			return nil, errUnauthorized
		}

		// Extract X-Token-Jti header
		tokenJti := getHeader(headers, "X-Token-Jti")
		if tokenJti == "" {
			if logger != nil {
				logger.Debug(fmt.Sprintf("[PLUGIN: %s] Token rejected: jti is missing", ModifierRegisterer))
			}

			return nil, errUnauthorized
		}

		// Check for X-Token-Type header
		tokenType := getHeader(headers, "X-Token-Type")
		if tokenType != "API" && tokenType != "" {
			// Reject ACCESS tokens issued before server startup time
			if tokenType == "ACCESS" && tokenIatTime < serverStartupTime {
				if logger != nil {
					logger.Debug(fmt.Sprintf("[PLUGIN: %s] Token rejected: issued before server startup", ModifierRegisterer))
				}

				return nil, errUnauthorized
			}

			tokenExp := getHeader(headers, "X-Token-Exp")
			if tokenExp == "" {
				if logger != nil {
					logger.Debug(fmt.Sprintf("[PLUGIN: %s] Token rejected: expiration date is missing", ModifierRegisterer))
				}

				return nil, errUnauthorized
			}

			return req, nil
		}

		if !verifyApiTokens {
			return req, nil
		}

		// Validate the API token JTI by sending it to the auth server
		if err := validateTokenJti(validatorUrl, tokenJti); err != nil {
			return nil, err
		}

		// If validation succeeds, continue with the request
		return req, nil
	}
}

// Helper function to get a specific header from the request
func getHeader(headers map[string][]string, key string) string {
	if values, ok := headers[key]; ok && len(values) > 0 {
		return values[0]
	}

	return ""
}

// Helper function to validate the token JTI with the auth server via POST
func validateTokenJti(validatorUrl, tokenJti string) error {
	// Create the JSON payload with the JTI
	jsonBody := fmt.Sprintf(`{"jti": "%s"}`, tokenJti)
	bodyReader := strings.NewReader(jsonBody)

	// Create a new HTTP POST request
	req, err := http.NewRequest("POST", validatorUrl, bodyReader)
	if err != nil {
		return err
	}

	req.Header.Set("Content-Type", "application/json")

	// Initialize the HTTP client and send the request
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	// If the auth server returns 200, the token is valid
	if resp.StatusCode == http.StatusOK {
		return nil
	}

	// If the status is 403, return a custom error with the body message
	if resp.StatusCode == http.StatusForbidden {
		// Read the response body
		bodyBytes, err := io.ReadAll(resp.Body)
		if err != nil {
			return err
		}

		bodyString := string(bodyBytes)

		return HTTPResponseError{
			Code:         http.StatusForbidden,
			Msg:          bodyString,
			HTTPEncoding: "application/json",
		}
	}

	// Any other responses
	return errUnauthorized
}

// RequestWrapper interface to work with requests
type RequestWrapper interface {
	Params() map[string]string
	Headers() map[string][]string
	Body() io.ReadCloser
	Method() string
	URL() *url.URL
	Query() url.Values
	Path() string
}

// Logger interface for logging
type Logger interface {
	Debug(v ...interface{})
	Info(v ...interface{})
	Warning(v ...interface{})
	Error(v ...interface{})
	Critical(v ...interface{})
	Fatal(v ...interface{})
}

type HTTPResponseError struct {
	Code         int    `json:"http_status_code"`
	Msg          string `json:"http_body,omitempty"`
	HTTPEncoding string `json:"http_encoding"`
}

// Error returns the error message
func (r HTTPResponseError) Error() string {
	return r.Msg
}

// StatusCode returns the status code returned by the backend
func (r HTTPResponseError) StatusCode() int {
	return r.Code
}

// Encoding returns the HTTP output encoding
func (r HTTPResponseError) Encoding() string {
	return r.HTTPEncoding
}

go.mod

module krakend-api-tokens-validation

go 1.22.7

And parts of config.json:

{
  "$schema": "https://www.krakend.io/schema/v3.json",
  "version": 3,
  "name": "Test API",
  "port": 8080,
  "timeout": "5s",
  "cache_ttl": "300s",
  "plugin": {
    "pattern": ".so",
    "folder": "/opt/krakend/plugins"
  },
  ...
{
    "endpoint": "/users/me",
    "input_headers": [
      "Content-Type",
      "Cookie",
      "Authorization",
      "x-user",
      "x-token-limits",
      "x-token-type",
      "x-token-jti",
      "x-token-iat",
      "x-token-exp"
    ],
    "method": "GET",
    "output_encoding": "no-op",
    "backend": [
      {
        "url_pattern": "/users/me",
        "encoding": "no-op",
        "method": "GET",
        "input_headers": [
          "Content-Type",
          "x-user",
          "x-token-limits",
          "x-token-type"
        ],
        "extra_config": {
          "qos/ratelimit/proxy": {
            "max_rate": 120,
            "capacity": 50,
            "every": "1m"
          }
        },
        "host": ["http://192.168.0.103:3001"]
      }
    ],
    "extra_config": {
      "auth/validator": {
        "alg": "RS256",
        "jwk_url": "http://192.168.0.103:3000/auth/jwks",
        "cookie_key": "accessToken",
        "cache": true,
        "disable_jwk_security": true,
        "propagate_claims": [
          ["userId", "x-user"],
          ["limits", "x-token-limits"],
          ["type", "x-token-type"],
          ["jti", "x-token-jti"],
          ["iat", "x-token-iat"],
          ["exp", "x-token-exp"]
        ]
      },
      "validation/cel": [
        {
          "check_expr": "has(JWT.userId) && has(JWT.limits) && has(JWT.iat) && JWT.type in ['ACCESS', 'API']"
        },
        {
          "check_expr": "(JWT.type == 'ACCESS' && has(JWT.exp)) || JWT.limits in ['READ_ONLY', 'DEFAULT']"
        }
      ],
      "plugin/req-resp-modifier": {
        "name": ["krakend-tokens-validation"],
        "api-tokens-validator-url": "http://192.168.0.103:3000/auth/api-tokens/validate",
        "verify-api-tokens": true
      }
    }
  }

Build plugin command:

docker run -it --rm \
-v "$(CURDIR)/plugins:/app" \
-w /app/krakend-tokens-validation \
krakend/builder \
go build -buildmode=plugin \
-o /app/krakend-tokens-validation.so \
/app/krakend-tokens-validation/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant