Skip to content

Commit

Permalink
Merge pull request #9 from WhySoBad/main
Browse files Browse the repository at this point in the history
Add support for array and nested object assertions
  • Loading branch information
sevensolutions authored Oct 1, 2024
2 parents f6e04d4 + e5abe21 commit b237c77
Show file tree
Hide file tree
Showing 26 changed files with 5,920 additions and 57 deletions.
23 changes: 23 additions & 0 deletions .github/workflows/testing.yaml
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
76 changes: 69 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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

Expand All @@ -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.
131 changes: 131 additions & 0 deletions authorization.go
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
}
Loading

0 comments on commit b237c77

Please sign in to comment.