From 1f4273dede756c14e0a9425b400f95e32fab5751 Mon Sep 17 00:00:00 2001 From: Ivan Pidikseev Date: Fri, 9 Aug 2024 18:26:51 +0400 Subject: [PATCH] Added the skip_verification option. This option is intended for users who prefer to verify the JWT token elsewhere while still leveraging other features provided by the module. --- Makefile | 5 +-- README.md | 10 +++--- caddyfile.go | 2 ++ caddyfile_test.go | 35 ++++++++++++++++++ jwt.go | 54 +++++++++++++++++++++------- jwt_test.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index f69d7f1..93db4d3 100644 --- a/Makefile +++ b/Makefile @@ -10,9 +10,6 @@ GOTEST=$(GO) test GOCOVER=$(GO) tool cover XCADDY=xcaddy -example: - $(XCADDY) run -config=example/caddy.json - debug: XCADDY_DEBUG=1 $(XCADDY) build --with github.com/ggicci/caddy-jwt=$(shell pwd) @@ -24,4 +21,4 @@ test/cover: test/report: $(GOCOVER) -html=main.cover.out -.PHONY: example debug test test/cover test/report +.PHONY: debug test test/cover test/report diff --git a/README.md b/README.md index 1cc7f67..1691b0d 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,12 @@ Module **caddy-jwt** behaves like a **"JWT Validator"**. The authentication flow │ 3. cookies │ └────────┬─────────┘ │ - ┌───────▼────────┐ - │ is valid? │ - │using `sign_key`├────NO───────┐ - └───────┬────────┘ │ + ┌───────▼───────────┐ + │ is valid? │ + │ using `sign_key` │ + │ or validation is │ + │ disabled ├─NO───────┐ + └───────┬───────────┘ │ │YES │ ┌───────────▼───────────┐ │ │Populate {http.user.id}│ │ diff --git a/caddyfile.go b/caddyfile.go index b50da9f..99031ad 100644 --- a/caddyfile.go +++ b/caddyfile.go @@ -40,6 +40,8 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) if !h.AllArgs(&ja.JWKURL) { return nil, h.Errf("invalid jwk_url: %q", ja.JWKURL) } + case "skip_verification": + ja.SkipVerification = true case "from_query": ja.FromQuery = h.RemainingArgs() diff --git a/caddyfile_test.go b/caddyfile_test.go index 1a4e1ed..005bdb0 100644 --- a/caddyfile_test.go +++ b/caddyfile_test.go @@ -174,3 +174,38 @@ func TestParseMetaClaim(t *testing.T) { } } } + +func TestParsingCaddyfileWithSkipVerification(t *testing.T) { + helper := httpcaddyfile.Helper{ + Dispenser: caddyfile.NewTestDispenser(` + jwtauth { + skip_verification + from_query access_token token _tok + from_header X-Api-Key + from_cookies user_session SESSID + issuer_whitelist https://api.example.com + audience_whitelist https://api.example.io https://learn.example.com + user_claims uid user_id login username + meta_claims "IsAdmin -> is_admin" "gender" + } + `), + } + expectedJA := &JWTAuth{ + SkipVerification: true, + FromQuery: []string{"access_token", "token", "_tok"}, + FromHeader: []string{"X-Api-Key"}, + FromCookies: []string{"user_session", "SESSID"}, + IssuerWhitelist: []string{"https://api.example.com"}, + AudienceWhitelist: []string{"https://api.example.io", "https://learn.example.com"}, + UserClaims: []string{"uid", "user_id", "login", "username"}, + MetaClaims: map[string]string{"IsAdmin": "is_admin", "gender": "gender"}, + } + + h, err := parseCaddyfile(helper) + assert.Nil(t, err) + auth, ok := h.(caddyauth.Authentication) + assert.True(t, ok) + jsonConfig, ok := auth.ProvidersRaw["jwt"] + assert.True(t, ok) + assert.Equal(t, caddyconfig.JSON(expectedJA, nil), jsonConfig) +} diff --git a/jwt.go b/jwt.go index 54d4cf9..834fe46 100644 --- a/jwt.go +++ b/jwt.go @@ -52,7 +52,7 @@ type JWTAuth struct { // If you'd like to use JWK, set this field and leave SignKey unset. JWKURL string `json:"jwk_url"` - // SignAlgorithm is the the signing algorithm used. Available values are defined in + // SignAlgorithm is the signing algorithm used. Available values are defined in // https://www.rfc-editor.org/rfc/rfc7518#section-3.1 // This is an optional field, which is used for determining the signing algorithm. // We will try to determine the algorithm automatically from the following sources: @@ -61,6 +61,19 @@ type JWTAuth struct { // 3. The value set here. SignAlgorithm string `json:"sign_alg"` + // SkipVerification disables the verification of the JWT token signature. + // + // Use this option with caution, as it bypasses JWT signature verification. + // This can be useful if the token's signature has already been verified before + // reaching this proxy server or will be verified later, preventing redundant + // verifications and handling of the same token multiple times. + // + // This is particularly relevant if you want to use this plugin for routing + // based on the JWT payload, while avoiding unnecessary signature checks. + // + // This flag also disables usage and check of both JWKURL and SignAlgorithm options. + SkipVerification bool `json:"skip_verification"` + // FromQuery defines a list of names to get tokens from the query parameters // of an HTTP request. // @@ -182,6 +195,26 @@ func (ja *JWTAuth) refreshJWKCache() { // Validate implements caddy.Validator interface. func (ja *JWTAuth) Validate() error { + if !ja.SkipVerification { + if err := ja.validateSignatureKeys(); err != nil { + return err + } + } + + if len(ja.UserClaims) == 0 { + ja.UserClaims = []string{ + "sub", + } + } + for claim, placeholder := range ja.MetaClaims { + if claim == "" || placeholder == "" { + return fmt.Errorf("invalid meta claim: %s -> %s", claim, placeholder) + } + } + return nil +} + +func (ja *JWTAuth) validateSignatureKeys() error { if ja.usingJWK() { ja.setupJWKLoader() } else { @@ -205,16 +238,6 @@ func (ja *JWTAuth) Validate() error { } } - if len(ja.UserClaims) == 0 { - ja.UserClaims = []string{ - "sub", - } - } - for claim, placeholder := range ja.MetaClaims { - if claim == "" || placeholder == "" { - return fmt.Errorf("invalid meta claim: %s -> %s", claim, placeholder) - } - } return nil } @@ -277,7 +300,14 @@ func (ja *JWTAuth) Authenticate(rw http.ResponseWriter, r *http.Request) (User, continue } - gotToken, err = jwt.ParseString(tokenString, jwt.WithKeyProvider(ja.keyProvider())) + jwtOptions := []jwt.ParseOption{ + jwt.WithVerify(!ja.SkipVerification), + } + if !ja.SkipVerification { + jwtOptions = append(jwtOptions, jwt.WithKeyProvider(ja.keyProvider())) + } + gotToken, err = jwt.ParseString(tokenString, jwtOptions...) + checked[tokenString] = struct{}{} logger := ja.logger.With(zap.String("token_string", desensitizedTokenString(tokenString))) diff --git a/jwt_test.go b/jwt_test.go index 2d954f2..cb9597e 100644 --- a/jwt_test.go +++ b/jwt_test.go @@ -191,6 +191,17 @@ func TestValidate_usingJWK(t *testing.T) { assert.Nil(t, err) } +// TestValidate_SkipVerification checks that validation does not fail when +// SkipVerification is enabled and no keys are provided. This ensures that +// enabling SkipVerification bypasses signature and keys validation without errors. +func TestValidate_SkipVerification(t *testing.T) { + // skipping verification + ja := &JWTAuth{ + SkipVerification: true, + } + assert.NoError(t, ja.Validate()) +} + func TestValidate_InvalidMetaClaims(t *testing.T) { ja := &JWTAuth{ SignKey: TestSignKey, @@ -247,6 +258,87 @@ func TestAuthenticate_FromCustomHeader(t *testing.T) { assert.Equal(t, User{ID: "ggicci"}, gotUser) } +func TestAuthenticate_FromQueryWithSkipVerification(t *testing.T) { + var ( + claims = MapClaims{"sub": "ggicci"} + ja = &JWTAuth{ + FromQuery: []string{"access_token", "token"}, + SkipVerification: true, + logger: testLogger, + } + tokenString = issueTokenString(claims) + + err error + rw *httptest.ResponseRecorder + r *http.Request + params url.Values + gotUser User + authenticated bool + ) + assert.Nil(t, ja.Validate()) + + // trying "access_token" without signature key + rw = httptest.NewRecorder() + r, _ = http.NewRequest("GET", "/", nil) + params = make(url.Values) + params.Add("access_token", tokenString) + r.URL.RawQuery = params.Encode() + gotUser, authenticated, err = ja.Authenticate(rw, r) + assert.Nil(t, err) + assert.True(t, authenticated) + assert.Equal(t, User{ID: "ggicci"}, gotUser) + + // invalid "token" without signature key + rw = httptest.NewRecorder() + r, _ = http.NewRequest("GET", "/", nil) + params = make(url.Values) + params.Add("access_token", tokenString+"INVALID") + params.Add("token", tokenString) + r.URL.RawQuery = params.Encode() + gotUser, authenticated, err = ja.Authenticate(rw, r) + assert.Nil(t, err) + assert.True(t, authenticated) + assert.Equal(t, User{ID: "ggicci"}, gotUser) +} + +func TestAuthenticate_PopulateUserMetadataWithSkipVerification(t *testing.T) { + ja := &JWTAuth{ + SkipVerification: true, + MetaClaims: map[string]string{ + "jti": "jti", + "IsAdmin": "is_admin", + "settings.role": "role", + "settings.payout.paypal.enabled": "is_paypal_enabled", + }, + logger: testLogger, + } + assert.Nil(t, ja.Validate()) + + claimsWithMetadata := MapClaims{ + "jti": "a976475a-186a-4c1f-b182-95b3f886e2b4", + "sub": "ggicci", + "IsAdmin": true, + "settings": map[string]interface{}{ + "role": "admin", + "payout": map[string]interface{}{ + "paypal": map[string]interface{}{ + "enabled": true, + }, + }, + }, + } + rw := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "/", nil) + r.Header.Add("Authorization", issueTokenString(claimsWithMetadata)) + gotUser, authenticated, err := ja.Authenticate(rw, r) + assert.Nil(t, err) + assert.True(t, authenticated) + assert.Equal(t, "a976475a-186a-4c1f-b182-95b3f886e2b4", gotUser.Metadata["jti"]) + assert.Equal(t, "true", gotUser.Metadata["is_admin"]) + assert.Equal(t, "admin", gotUser.Metadata["role"]) + assert.Equal(t, "true", gotUser.Metadata["is_paypal_enabled"]) +} + func TestAuthenticate_FromQuery(t *testing.T) { var ( claims = MapClaims{"sub": "ggicci"}