-
-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #9 from WhySoBad/main
Add support for array and nested object assertions
- Loading branch information
Showing
26 changed files
with
5,920 additions
and
57 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
name: Run Go tests | ||
on: [ push ] | ||
|
||
jobs: | ||
build: | ||
runs-on: ubuntu-latest | ||
|
||
steps: | ||
- uses: actions/checkout@v4 | ||
|
||
- name: Setup Go | ||
uses: actions/setup-go@v5 | ||
with: | ||
go-version: '1.23.x' | ||
|
||
- name: Install dependencies | ||
run: go get . | ||
|
||
- name: Build | ||
run: go build | ||
|
||
- name: Test with the Go CLI | ||
run: go test |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,8 +8,8 @@ | |
|
||
A traefik Plugin for securing the upstream service with OpenID Connect acting as a relying party. | ||
|
||
> [!NOTE] | ||
> This document always represents the latest version, which may not have been released yet. | ||
> [!NOTE] | ||
> This document always represents the latest version, which may not have been released yet. | ||
> Therefore, some features may not be available currently but will be available soon. | ||
> You can use the GIT-Tags to check individual versions. | ||
|
@@ -51,14 +51,18 @@ http: | |
Authorization: | ||
AssertClaims: | ||
- Name: "preferred_username" | ||
Values: "[email protected],[email protected]" | ||
AnyOf: ["[email protected]", "[email protected]"] | ||
- Name: "roles" | ||
AllOf: ["admin", "media"] | ||
- Name: "user.first_name" | ||
AnyOf: ["Alice"] | ||
Headers: | ||
MapClaims: | ||
- Claim: "preferred_username" | ||
Header: "X-Oidc-Username" | ||
- Claim: "sub" | ||
Header: "X-Oidc-Subject" | ||
|
||
routers: | ||
whoami: | ||
entryPoints: ["web"] | ||
|
@@ -110,11 +114,69 @@ http: | |
|
||
### ClaimAssertion Block | ||
|
||
If only the `Name` property is set and no additional assertions are defined it is only checked whether there exist any matches for the name of this claim without any verification on their values. | ||
Additionaly, the `Name` field can be any [json path](https://jsonpath.com/). The `Name` gets prefixed with `$.` to match from the root element. The usage of json paths allows for assertions on deeply nested json structures. | ||
|
||
| Name | Required | Type | Default | Description | | ||
|---|---|---|---|---| | ||
| Name | yes | `string` | *none* | The name of the claim in the access token. | | ||
| Value | no | `string` | *none* | The required value of the claim. If *Value* and *Values* are not set, only the presence of the claim will be checked. | | ||
| Values | no | `string[]` | *none* | An array of allowed strings. The user is authorized if the claim matched any of these. | | ||
| AnyOf | no | `string[]` | *none* | An array of allowed strings. The user is authorized if any value matching the name of the claim contains (or is) a value of this array. | | ||
| AllOf | no | `string[]` | *none* | An array of required strings. The user is only authorized if any value matching the name of the claim contains (or is) a value of this array and all values of this array are covered in the end. | | ||
|
||
It is possible to combine `AnyOf` and `AllOf` quantifiers for one assertion | ||
|
||
<details> | ||
<summary> | ||
<b>Examples</b> | ||
</summary> | ||
All of the examples below work on this json structure: | ||
|
||
```json | ||
{ | ||
"store": { | ||
"bicycle": { | ||
"color": "red", | ||
"price": 19.95 | ||
}, | ||
"book": [ | ||
{ | ||
"author": "Herman Melville", | ||
"category": "fiction", | ||
"isbn": "0-553-21311-3", | ||
"price": 8.99, | ||
"title": "Moby Dick" | ||
}, | ||
{ | ||
"author": "J. R. R. Tolkien", | ||
"category": "fiction", | ||
"isbn": "0-395-19395-8", | ||
"price": 22.99, | ||
"title": "The Lord of the Rings" | ||
} | ||
], | ||
} | ||
} | ||
``` | ||
|
||
**Example**: Expect array to contain a set of values | ||
```yaml | ||
Name: store.book[*].price | ||
AllOf: [ 22.99, 8.99 ] | ||
``` | ||
This assertion would succeed as the `book` array contains all values specified by the `AllOf` quantifier | ||
```yaml | ||
Name: store.book[*].price | ||
AllOf: [ 22.99, 8.99, 1 ] | ||
``` | ||
This assertion would fail as the `book` array contains no entry for which the `price` is `1` | ||
|
||
**Example**: Expect object key to be any value of a set of values | ||
```yaml | ||
Name: store.bicycle.color | ||
AnyOf: [ "red", "blue", "green" ] | ||
``` | ||
This assertion would succeed as the `store` object contains a `bicycle` object whose `color` is `red` | ||
</details> | ||
|
||
### Headers Block | ||
|
||
|
@@ -141,5 +203,5 @@ CLIENT_SECRET=... | |
|
||
The run `docker compose up` to run traefik locally. | ||
|
||
Now browse to http://localhost:9080. You should be redirected to your IDP. | ||
Now browse to http://localhost:9080. You should be redirected to your IDP. | ||
After you've logged in, you should be redirected back to http://localhost:9080 and see a WHOAMI page. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
package traefik_oidc_auth | ||
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"slices" | ||
"strings" | ||
|
||
"github.com/golang-jwt/jwt/v5" | ||
"github.com/spyzhov/ajson" | ||
) | ||
|
||
func (toa *TraefikOidcAuth) isAuthorized(claims *jwt.MapClaims) bool { | ||
authorization := toa.Config.Authorization | ||
|
||
if authorization.AssertClaims != nil && len(authorization.AssertClaims) > 0 { | ||
parsed, err := json.Marshal(*claims) | ||
if err != nil { | ||
log(toa.Config.LogLevel, LogLevelWarn, "Error whilst marshalling claims object: %s", err.Error()) | ||
return false | ||
} | ||
|
||
assertions: | ||
for _, assertion := range authorization.AssertClaims { | ||
value, err := ajson.JSONPath(parsed, fmt.Sprintf("$.%s", assertion.Name)) | ||
if err != nil { | ||
log(toa.Config.LogLevel, LogLevelWarn, "Error whilst parsing path for claim %s in token claims: %s", assertion.Name, err.Error()) | ||
return false | ||
} else if len(value) == 0 { | ||
log(toa.Config.LogLevel, LogLevelWarn, "Unauthorized. Unable to find claim %s in token claims.", assertion.Name) | ||
return false | ||
} | ||
|
||
if len(assertion.AllOf) == 0 && len(assertion.AnyOf) == 0 { | ||
log(toa.Config.LogLevel, LogLevelDebug, "Authorized claim %s. No assertions were defined and claim exists", assertion.Name) | ||
continue assertions | ||
} | ||
|
||
// check all matched nodes whether for one of the nodes all assertions hold | ||
// should the assertions hold for no node we return `false` to indicate | ||
// an unauthorized state | ||
|
||
allMatches := make([]bool, len(assertion.AllOf)) | ||
anyMatch := false | ||
|
||
matches: | ||
for _, val := range value { | ||
unpacked, err := val.Unpack() | ||
if err != nil { | ||
log(toa.Config.LogLevel, LogLevelError, "Error whilst unpacking json node: %s", err.Error()) | ||
continue matches | ||
} | ||
|
||
switch val := unpacked.(type) { | ||
// the value is any array | ||
case []interface{}: | ||
mapped := make([]string, len(val)) | ||
for i, rawVal := range val { | ||
mapped[i] = fmt.Sprintf("%v", rawVal) | ||
} | ||
|
||
// first check whether allOf assertion is fulfilled -> return false if not | ||
if len(assertion.AllOf) > 0 { | ||
for _, assert := range assertion.AllOf { | ||
if !slices.Contains(mapped, assert) { | ||
break matches | ||
} | ||
} | ||
} | ||
// should allOf assertion be fulfilled check whether anyOf assertion is fulfilled -> return true when fulfilled | ||
if len(assertion.AnyOf) > 0 { | ||
for _, assert := range assertion.AnyOf { | ||
if slices.Contains(mapped, assert) { | ||
log(toa.Config.LogLevel, LogLevelDebug, "Authorized claim %s: Found value %s which is any of [%s]", assertion.Name, assert, strings.Join(assertion.AnyOf, ", ")) | ||
continue assertions | ||
} | ||
} | ||
continue matches | ||
} | ||
log(toa.Config.LogLevel, LogLevelDebug, "Authorized claim %s: Found all values of [%s]", assertion.Name, strings.Join(assertion.AllOf, ", ")) | ||
continue assertions | ||
// the value is any other json type | ||
default: | ||
strVal := fmt.Sprintf("%v", val) | ||
if len(assertion.AnyOf) > 0 { | ||
if slices.Contains(assertion.AnyOf, strVal) { | ||
anyMatch = true | ||
} | ||
} | ||
if len(assertion.AllOf) > 0 { | ||
for i, assert := range assertion.AllOf { | ||
if assert == strVal { | ||
allMatches[i] = true | ||
break | ||
} | ||
} | ||
} | ||
continue matches | ||
} | ||
} | ||
|
||
if len(assertion.AnyOf) > 0 && anyMatch && len(assertion.AllOf) > 0 && !slices.Contains(allMatches, false) { | ||
log(toa.Config.LogLevel, LogLevelDebug, "Authorized claim %s: Found any value of [%s] and all values of [%s]", assertion.Name, strings.Join(assertion.AnyOf, ", "), strings.Join(assertion.AllOf, ", ")) | ||
continue assertions | ||
} else if len(assertion.AnyOf) > 0 && anyMatch && len(assertion.AllOf) == 0 { | ||
log(toa.Config.LogLevel, LogLevelDebug, "Authorized claim %s: Found any value of [%s]", assertion.Name, strings.Join(assertion.AnyOf, ", ")) | ||
continue assertions | ||
} else if len(assertion.AllOf) > 0 && !slices.Contains(allMatches, false) && len(assertion.AnyOf) == 0 { | ||
log(toa.Config.LogLevel, LogLevelDebug, "Authorized claim %s: Found all values of [%s]", assertion.Name, strings.Join(assertion.AllOf, ", ")) | ||
continue assertions | ||
} | ||
|
||
if len(assertion.AllOf) > 0 && len(assertion.AnyOf) > 0 { | ||
log(toa.Config.LogLevel, LogLevelWarn, "Unauthorized. Expected claim %s to contain any value of [%s] and all values of [%s]", assertion.Name, strings.Join(assertion.AnyOf, ", "), strings.Join(assertion.AllOf, ", ")) | ||
} else if len(assertion.AllOf) > 0 { | ||
log(toa.Config.LogLevel, LogLevelWarn, "Unauthorized. Expected claim %s to contain all values of [%s]", assertion.Name, strings.Join(assertion.AllOf, ", ")) | ||
} else if len(assertion.AnyOf) > 0 { | ||
log(toa.Config.LogLevel, LogLevelWarn, "Unauthorized. Expected claim %s to contain any value of [%s]", assertion.Name, strings.Join(assertion.AnyOf, ", ")) | ||
} | ||
|
||
log(toa.Config.LogLevel, LogLevelDebug, "Available claims are:") | ||
for key, val := range *claims { | ||
log(toa.Config.LogLevel, LogLevelDebug, " %v = %v", key, val) | ||
} | ||
|
||
return false | ||
} | ||
} | ||
|
||
return true | ||
} |
Oops, something went wrong.