Skip to content

Commit 765fe36

Browse files
authored
Merge pull request #58 from agilezebra/57-refactor-validation
Added AND and OR logic
2 parents 604823b + 7f54593 commit 765fe36

File tree

5 files changed

+474
-191
lines changed

5 files changed

+474
-191
lines changed

README.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ experimental:
1919
plugins:
2020
jwt:
2121
moduleName: github.com/agilezebra/jwt-middleware
22-
version: v1.3.0
22+
version: v1.3.1
2323
```
2424
1b. or with command-line options:
2525
2626
```yaml
2727
command:
2828
...
2929
- "--experimental.plugins.jwt.modulename=github.com/agilezebra/jwt-middleware"
30-
- "--experimental.plugins.jwt.version=v1.3.0"
30+
- "--experimental.plugins.jwt.version=v1.3.1"
3131
```
3232
3333
2) Configure and activate the plugin as a middleware in your dynamic traefik config:
@@ -85,7 +85,7 @@ Name | Description
8585
`skipPrefetch` | Don't prefetch keys from `issuers`. This is useful if all the expected secrets are provided in `secrets`, especially in situations where traefik or its services are frequently restarted, to save from hitting the issuer JWKS endpoint unnecessarily.
8686
`delayPrefetch` | Delay prefetching keys from `issuers` by the given duration (expressed in `time.ParseDuration` format - e.g. "300ms", "5s"). This is particularly useful if your openid server is behind the very traefik service that is loading the plugin and you need to give it time to be ready for your request. This has no effect if `skipPrefetch` is set.
8787
`refreshKeysInterval` | Arbitrarily refresh all keys from all `issuers` in a background thread every given duration (after any prefetch).
88-
`require` | A map of zero or more claims that must all be present and match against one or more values. If no claims are specified in `require`, all tokens that are validly signed by the trusted issuers or secrets will pass. If more than one claim is specified, each is required (i.e. an AND relationship exists for all the specified claims). For each claim, multiple values may be specified and the claim will be valid if any matches (i.e. an OR relationship exists for required values within a claim). fnmatch-style wildcards are optionally supported for claims in issued JWTs. If you do not wish to support wildcard claims, simply do not put such wildcards into the JWTs that you issue. See below for examples and the variables available with template interpolation.
88+
`require` | A map of zero or more claims that must all be present and match against one or more values. If no claims are specified in `require`, all tokens that are validly signed by the trusted issuers or secrets will pass. If more than one claim is specified, each is required (i.e. an AND relationship exists for all the specified claims). For each claim, multiple values may be specified and the claim will be valid if any matches (i.e. a default OR relationship exists for required values within a claim). It is possible to specify alternate logic using `$and` and `$or` operators (see Claim Matching examples below). fnmatch-style wildcards are optionally supported for claims in issued JWTs. If you do not wish to support wildcard claims, simply do not put such wildcards into the JWTs that you issue. See below for examples and the variables available with template interpolation.
8989
`headerMap` | A map in the form of header -> claim. Headers will be added (or overwritten) to the forwarded HTTP request from the claim values in the token. If the claim is not present, no action for that value is taken (and any existing header will remain unchanged).
9090
`removeMissingHeaders` | When set to `true`, remove any headers provided in the request that are named in the `headerMap` but are not present in the token as claims. This may be an important security consideration for some uses of headers if your JWT provider cannot be relied upon to provide an expected claim in all situations. Default: `false`.
9191
`cookieName` | Name of the cookie to retrieve the token from if present. Default: `Authorization`. If token retrieval from cookies must be disabled for some reason, set to an empty string. If `forwardAuth` is `false`, the cookie will be removed before forwarding to the backend.
@@ -162,6 +162,7 @@ require:
162162
"iss": "auth.example.com",
163163
"aud": "*.example.com"
164164
}
165+
165166
```
166167
Note that the wildcard claim is granted to the _user_ in their JWT, not asked for in the requirements. I.e. you are granting a key that can open multiple locks rather than creating a lock that accepts multiple keys. If you don't want to support these optional wildcards, simply do not issue such JWTs.
167168

@@ -182,6 +183,43 @@ require:
182183
}
183184
```
184185

186+
#### And logic
187+
```yaml
188+
require:
189+
role:
190+
$and: ["hr", "power"] # both are required
191+
```
192+
Note that, similar to MongoDB, the `$and` and `$or` operators are a single-value object with operator as the key and the choices as an array value
193+
194+
```json
195+
{
196+
"role": ["hr", "power"],
197+
}
198+
```
199+
200+
201+
#### Complex nested logic
202+
```yaml
203+
require:
204+
role:
205+
$or:
206+
- $and: ["hr", "power"] # both are required
207+
- "admin" # this alone will pass
208+
```
209+
Note that mixing yaml array styles here is arbitrary and both are used to enhance clarity of the structure
210+
211+
```json
212+
{
213+
"role": ["hr", "power"],
214+
}
215+
```
216+
217+
```json
218+
{
219+
"role": ["admin"],
220+
}
221+
```
222+
185223
### Examples
186224

187225
#### Interactive webserver with redirection to login and error pages

jwt.go

Lines changed: 21 additions & 184 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
"net/http"
1414
"net/url"
1515
"os"
16-
"reflect"
1716
"strings"
1817
"sync"
1918
"time"
@@ -56,7 +55,7 @@ type JWTPlugin struct {
5655
issuers []string // A list of valid issuers that we trust to fetch keys from
5756
clients map[string]*http.Client // A map of clients for specific issuers that skip certificate verification
5857
defaultClient *http.Client // A default client for fetching keys with certificate verification, optionally with custom root CAs
59-
require map[string][]Requirement // A map of requirements for each claim
58+
require Requirement // A map of requirements for each claim (which we treat simply as a Requirement to be validated)
6059
lock sync.RWMutex // Read-write lock for the keys and issuerKeys maps
6160
keys map[string]any // A map of key IDs to public keys or shared HMAC secrets
6261
issuerKeys map[string]map[string]any // A map of issuer URLs to key IDs to public keys, for reference counting / purging
@@ -77,23 +76,6 @@ type JWTPlugin struct {
7776
// This has become a map rather than a struct now because we add the environment variables to it.
7877
type TemplateVariables map[string]string
7978

80-
// Requirement is a requirement for a claim.
81-
type Requirement interface {
82-
Validate(value any, variables *TemplateVariables) bool
83-
}
84-
85-
// ValueRequirement is a requirement for a claim that is a known value.
86-
type ValueRequirement struct {
87-
value any
88-
nested any
89-
}
90-
91-
// TemplateRequirement is a dynamic requirement for a claim that uses a template that needs interpolating per request.
92-
type TemplateRequirement struct {
93-
template *template.Template
94-
nested any
95-
}
96-
9779
// CreateConfig creates the default plugin configuration.
9880
func CreateConfig() *Config {
9981
return &Config{
@@ -164,14 +146,14 @@ func New(_ context.Context, next http.Handler, config *Config, name string) (htt
164146
parser: jwt.NewParser(jwt.WithValidMethods(config.ValidMethods), jwt.WithJSONNumber()),
165147
secret: key,
166148
issuers: canonicalizeDomains(config.Issuers),
167-
clients: createClients(config.InsecureSkipVerify),
168-
defaultClient: createDefaultClient(config.RootCAs, true),
169-
require: convertRequire(config.Require),
149+
clients: NewClients(config.InsecureSkipVerify),
150+
defaultClient: NewDefaultClient(config.RootCAs, true),
151+
require: NewRequirement(config.Require, "$and"),
170152
keys: make(map[string]any),
171153
issuerKeys: make(map[string]map[string]any),
172154
optional: config.Optional,
173-
redirectUnauthorized: createTemplate(config.RedirectUnauthorized),
174-
redirectForbidden: createTemplate(config.RedirectForbidden),
155+
redirectUnauthorized: NewTemplate(config.RedirectUnauthorized),
156+
redirectForbidden: NewTemplate(config.RedirectForbidden),
175157
cookieName: config.CookieName,
176158
headerName: config.HeaderName,
177159
parameterName: config.ParameterName,
@@ -250,7 +232,7 @@ func (plugin *JWTPlugin) fetchRoutine(delayPrefetch time.Duration, refreshKeysIn
250232

251233
// ServeHTTP is the middleware entry point.
252234
func (plugin *JWTPlugin) ServeHTTP(response http.ResponseWriter, request *http.Request) {
253-
variables := plugin.createTemplateVariables(request)
235+
variables := plugin.NewTemplateVariables(request)
254236
status, err := plugin.validate(request, variables)
255237
if err == nil {
256238
// Request is valid, pass to the next handler and we're done
@@ -309,17 +291,12 @@ func (plugin *JWTPlugin) validate(request *http.Request, variables *TemplateVari
309291
}
310292

311293
claims := token.Claims.(jwt.MapClaims)
312-
313-
// Validate that claims match - AND
314-
for claim, requirements := range plugin.require {
315-
if !plugin.validateClaim(claim, claims, requirements, variables) {
316-
err := fmt.Errorf("claim is not valid: %s", claim)
317-
// If the token is older than our freshness window, we allow that reauthorization might fix it
318-
if plugin.allowRefresh(claims) {
319-
return http.StatusUnauthorized, err
320-
} else {
321-
return http.StatusForbidden, err
322-
}
294+
err = plugin.require.Validate(map[string]any(claims), variables)
295+
if err != nil {
296+
if plugin.allowRefresh(claims) {
297+
return http.StatusUnauthorized, err
298+
} else {
299+
return http.StatusForbidden, err
323300
}
324301
}
325302

@@ -365,146 +342,6 @@ func (plugin *JWTPlugin) mapClaimsToHeaders(claims jwt.MapClaims, request *http.
365342
}
366343
}
367344

368-
// Validate checks value against the requirement, calling ourself recursively for object and array values.
369-
// variables is required in the interface and passed on recusrively but ultimately ignored by ValueRequirement
370-
// having been already interpolated by TemplateRequirement
371-
func (requirement ValueRequirement) Validate(value any, variables *TemplateVariables) bool {
372-
switch value := value.(type) {
373-
case []any:
374-
for _, value := range value {
375-
if requirement.Validate(value, variables) {
376-
return true
377-
}
378-
}
379-
case map[string]any:
380-
for value, nested := range value {
381-
if requirement.Validate(value, variables) && requirement.ValidateNested(nested) {
382-
return true
383-
}
384-
}
385-
case string:
386-
required, ok := requirement.value.(string)
387-
if !ok {
388-
return false
389-
}
390-
return fnmatch.Match(value, required, 0) || value == fmt.Sprintf("*.%s", required)
391-
392-
case json.Number:
393-
switch requirement.value.(type) {
394-
case int:
395-
converted, err := value.Int64()
396-
return err == nil && converted == int64(requirement.value.(int))
397-
case float64:
398-
converted, err := value.Float64()
399-
return err == nil && converted == requirement.value.(float64)
400-
default:
401-
log.Printf("unsupported requirement type for json.Number comparison: %T %v", requirement.value, requirement.value)
402-
return false
403-
}
404-
}
405-
406-
return reflect.DeepEqual(value, requirement.value)
407-
}
408-
409-
// ValidateNested checks value against the nested requirement
410-
func (requirement ValueRequirement) ValidateNested(value any) bool {
411-
// The nested requirement may be a single required value, or an OR choice of acceptable values. Convert to a slice of values.
412-
var required []any
413-
switch nested := requirement.nested.(type) {
414-
case nil:
415-
// If the nested requirement is nil, we don't care about the nested claims that brought us here and the value is always valid.
416-
return true
417-
case []any:
418-
required = nested
419-
case any:
420-
required = []any{nested}
421-
}
422-
423-
// Likewise, the value may be a single claim value or an array of several granted claims values. Convert to a slice of values.
424-
var supplied []any
425-
switch value := value.(type) {
426-
case []any:
427-
supplied = value
428-
case any:
429-
supplied = []any{value}
430-
}
431-
432-
// If any of the values match any of the nested requirement, the claim is valid.
433-
for _, required := range required {
434-
for _, supplied := range supplied {
435-
if reflect.DeepEqual(required, supplied) {
436-
return true
437-
}
438-
}
439-
}
440-
return false
441-
}
442-
443-
// Validate interpolates the requirement template with the given variables and then delegates to ValueRequirement.
444-
func (requirement TemplateRequirement) Validate(value any, variables *TemplateVariables) bool {
445-
var buffer bytes.Buffer
446-
err := requirement.template.Execute(&buffer, variables)
447-
if err != nil {
448-
log.Printf("Error executing template: %s", err)
449-
return false
450-
}
451-
return ValueRequirement{value: buffer.String(), nested: requirement.nested}.Validate(value, variables)
452-
}
453-
454-
// convertRequire converts the require configuration to a map of requirements.
455-
func convertRequire(require map[string]any) map[string][]Requirement {
456-
converted := make(map[string][]Requirement, len(require))
457-
for key, value := range require {
458-
switch value := value.(type) {
459-
case []any:
460-
requirements := make([]Requirement, len(value))
461-
for index, value := range value {
462-
requirements[index] = createRequirement(value, nil)
463-
}
464-
converted[key] = requirements
465-
case map[string]any:
466-
requirements := make([]Requirement, len(value))
467-
index := 0
468-
for key, value := range value {
469-
requirements[index] = createRequirement(key, value)
470-
index++
471-
}
472-
converted[key] = requirements
473-
default:
474-
converted[key] = []Requirement{createRequirement(value, nil)}
475-
}
476-
477-
}
478-
return converted
479-
}
480-
481-
// createRequirement creates a Requirement of the correct type from the given value (and any nested value).
482-
func createRequirement(value any, nested any) Requirement {
483-
switch value := value.(type) {
484-
case string:
485-
if strings.Contains(value, "{{") && strings.Contains(value, "}}") {
486-
return TemplateRequirement{
487-
template: template.Must(template.New("template").Option("missingkey=error").Parse(value)),
488-
nested: nested,
489-
}
490-
}
491-
}
492-
return ValueRequirement{value: value, nested: nested}
493-
}
494-
495-
// validateClaim valideates a single claim against the requirement(s) for that claim (any match with satisfy - OR).
496-
func (plugin *JWTPlugin) validateClaim(claim string, claims jwt.MapClaims, requirements []Requirement, variables *TemplateVariables) bool {
497-
value, ok := claims[claim]
498-
if ok {
499-
for _, requirement := range requirements {
500-
if requirement.Validate(value, variables) {
501-
return true
502-
}
503-
}
504-
}
505-
return false
506-
}
507-
508345
// getKey gets the key for the given key ID from the plugin's key cache.
509346
// If the key isn't present and the iss is valid according to the plugin's configuration, all keys for the iss are refreshed and the key is looked up again.
510347
func (plugin *JWTPlugin) getKey(token *jwt.Token) (any, error) {
@@ -685,8 +522,8 @@ func pemContent(value string) (string, error) {
685522
return string(content), nil
686523
}
687524

688-
// createDefaultClient returns an http.Client with the given root CAs, or a default client if no root CAs are provided.
689-
func createDefaultClient(pems []string, useSystemCertPool bool) *http.Client {
525+
// NewDefaultClient returns an http.Client with the given root CAs, or a default client if no root CAs are provided.
526+
func NewDefaultClient(pems []string, useSystemCertPool bool) *http.Client {
690527
if pems == nil {
691528
return &http.Client{}
692529
}
@@ -708,8 +545,8 @@ func createDefaultClient(pems []string, useSystemCertPool bool) *http.Client {
708545
return &http.Client{Transport: transport}
709546
}
710547

711-
// createClients reads a list of domains in the InsecureSkipVerify configuration and creates a map of domains to http.Client with InsecureSkipVerify set.
712-
func createClients(insecureSkipVerify []string) map[string]*http.Client {
548+
// NewClients reads a list of domains in the InsecureSkipVerify configuration and creates a map of domains to http.Client with InsecureSkipVerify set.
549+
func NewClients(insecureSkipVerify []string) map[string]*http.Client {
713550
// Create a single client with InsecureSkipVerify set
714551
transport := &http.Transport{
715552
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
@@ -724,8 +561,8 @@ func createClients(insecureSkipVerify []string) map[string]*http.Client {
724561
return clients
725562
}
726563

727-
// createTemplate creates a template from the given string, or nil if not specified.
728-
func createTemplate(text string) *template.Template {
564+
// NewTemplate creates a template from the given string, or nil if not specified.
565+
func NewTemplate(text string) *template.Template {
729566
if text == "" {
730567
return nil
731568
}
@@ -736,11 +573,11 @@ func createTemplate(text string) *template.Template {
736573
return template.Must(template.New("template").Funcs(functions).Option("missingkey=error").Parse(text))
737574
}
738575

739-
// createTemplateVariables creates a template data map for the given request.
576+
// NewTemplateVariables creates a template data map for the given request.
740577
// We start with a clone of our environment variables and add the the per-request variables.
741578
// The purpose of environment variables is to allow a easier way to set a configurable but then fixed value for a claim
742579
// requirement in the configuration file (as rewriting the configuration file is harder than setting environment variables).
743-
func (plugin *JWTPlugin) createTemplateVariables(request *http.Request) *TemplateVariables {
580+
func (plugin *JWTPlugin) NewTemplateVariables(request *http.Request) *TemplateVariables {
744581
// copy the environment variables
745582
variables := make(TemplateVariables, len(plugin.environment)+6)
746583
for key, value := range plugin.environment {

0 commit comments

Comments
 (0)