Skip to content

Commit

Permalink
[BREAKING CHANGE] Change how claims are handled (#221)
Browse files Browse the repository at this point in the history
This is a breaking change. Please read the updated docs and the PR description thoroughly.

The major changes are:

Claims validation is now provided as a function when configuring the handler
Claims are typed based on the provided type (generics are used)
options.WithRequiredClaims() is deprecated
Now, configuring a handler requires you to define what type the claims are. You can still use map[string]interface{} if you want and the parameter for ClaimsValidationFn can be set to nil.
  • Loading branch information
simongottschlag committed Nov 26, 2022
1 parent 7b48fd1 commit 2f273bd
Show file tree
Hide file tree
Showing 42 changed files with 454 additions and 1,619 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pr-validation.yaml
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v3.3.1
with:
version: v1.48.0
version: v1.50.1

fmt:
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .golangci.yaml
Expand Up @@ -99,6 +99,8 @@ linters-settings:
- truncatecmp
- ruleguard
- nestingreduce
disabled-checks:
- newDeref
enabled-tags:
- performance
disabled-tags:
Expand Down
137 changes: 72 additions & 65 deletions README.md
Expand Up @@ -6,6 +6,14 @@

This is a middleware for http to make it easy to use OpenID Connect.

## Changelog

Below, large (breaking) changes will be documented:

### v0.0.37

From `v0.0.37` and forward, the `options.WithRequiredClaims()` has been deprecated and generics are used to provide the claims type. A new validation function can be provided instead of `options.WithRequiredClaims()`. If you don't need claims validation, you can pass `nil` instead of a `ClaimsValidationFn`.

## Stability notice

This library is under active development and the api will have breaking changes until `v0.1.0` - after that only breaking changes will be introduced between minor versions (`v0.1.0` -> `v0.2.0`).
Expand All @@ -29,6 +37,57 @@ This library is under active development and the api will have breaking changes

Import: `"github.com/xenitab/go-oidc-middleware/options"`

### Claims validation example

From `v0.0.37` and forward, claim validation is done using a `ClaimsValidationFn`. The below examples will use the following claims type and validation function:

```go
type AzureADClaims struct {
Aio string `json:"aio"`
Audience []string `json:"aud"`
Azp string `json:"azp"`
Azpacr string `json:"azpacr"`
ExpiresAt time.Time `json:"exp"`
IssuedAt time.Time `json:"iat"`
Idp string `json:"idp"`
Issuer string `json:"iss"`
Name string `json:"name"`
NotBefore time.Time `json:"nbf"`
Oid string `json:"oid"`
PreferredUsername string `json:"preferred_username"`
Rh string `json:"rh"`
Scope string `json:"scp"`
Subject string `json:"sub"`
TenantId string `json:"tid"`
Uti string `json:"uti"`
TokenVersion string `json:"ver"`
}

func GetAzureADClaimsValidationFn(requiredTenantId string) options.ClaimsValidationFn[AzureADClaims] {
return func(claims *AzureADClaims) error {
if requiredTenantId != "" && claims.TenantId != requiredTenantId {
return fmt.Errorf("tid claim is required to be %q but was: %s", requiredTenantId, claims.TenantId)
}

return nil
}
}
```

If you don't want typed claims, use `type Claims map[string]interface{}` and provide it. If you don't want to use a `ClaimsValidationFn` (as it will provide the type) the handlers will need to be configured as below:

```go
type Claims map[string]interface{}

oidcHandler := oidchttp.New[Claims](h, nil, opts...)
```

or

```go
oidcHandler := oidchttp.New[map[string]interface{}](h, nil, opts...)
```

### net/http, mux & chi

**Import**
Expand All @@ -39,13 +98,11 @@ Import: `"github.com/xenitab/go-oidc-middleware/options"`

```go
oidcHandler := oidchttp.New(h,
GetAzureADClaimsValidationFn(cfg.TenantID),
options.WithIssuer(cfg.Issuer),
options.WithRequiredTokenType("JWT"),
options.WithRequiredAudience(cfg.Audience),
options.WithFallbackSignatureAlgorithm(cfg.FallbackSignatureAlgorithm),
options.WithRequiredClaims(map[string]interface{}{
"tid": cfg.TenantID,
}),
)
```

Expand All @@ -54,7 +111,7 @@ oidcHandler := oidchttp.New(h,
```go
func newClaimsHandler() http.HandlerFunc {
fn := func(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(options.DefaultClaimsContextKeyName).(map[string]interface{})
claims, ok := r.Context().Value(options.DefaultClaimsContextKeyName).(AzureADClaims)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
Expand Down Expand Up @@ -82,13 +139,11 @@ func newClaimsHandler() http.HandlerFunc {

```go
oidcHandler := oidcgin.New(
GetAzureADClaimsValidationFn(cfg.TenantID),
options.WithIssuer(cfg.Issuer),
options.WithRequiredTokenType("JWT"),
options.WithRequiredAudience(cfg.Audience),
options.WithFallbackSignatureAlgorithm(cfg.FallbackSignatureAlgorithm),
options.WithRequiredClaims(map[string]interface{}{
"tid": cfg.TenantID,
}),
)
```

Expand All @@ -103,7 +158,7 @@ func newClaimsHandler() gin.HandlerFunc {
return
}

claims, ok := claimsValue.(map[string]interface{})
claims, ok := claimsValue.(AzureADClaims)
if !ok {
c.AbortWithStatus(http.StatusUnauthorized)
return
Expand All @@ -124,13 +179,11 @@ func newClaimsHandler() gin.HandlerFunc {

```go
oidcHandler := oidcfiber.New(
GetAzureADClaimsValidationFn(cfg.TenantID),
options.WithIssuer(cfg.Issuer),
options.WithRequiredTokenType("JWT"),
options.WithRequiredAudience(cfg.Audience),
options.WithFallbackSignatureAlgorithm(cfg.FallbackSignatureAlgorithm),
options.WithRequiredClaims(map[string]interface{}{
"tid": cfg.TenantID,
}),
)
```

Expand All @@ -139,7 +192,7 @@ oidcHandler := oidcfiber.New(
```go
func newClaimsHandler() fiber.Handler {
return func(c *fiber.Ctx) error {
claims, ok := c.Locals("claims").(map[string]interface{})
claims, ok := c.Locals("claims").(AzureADClaims)
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
Expand All @@ -160,13 +213,11 @@ func newClaimsHandler() fiber.Handler {
```go
e.Use(middleware.JWTWithConfig(middleware.JWTConfig{
ParseTokenFunc: oidcechojwt.New(
GetAzureADClaimsValidationFn(cfg.TenantID),
options.WithIssuer(cfg.Issuer),
options.WithRequiredTokenType("JWT"),
options.WithRequiredAudience(cfg.Audience),
options.WithFallbackSignatureAlgorithm(cfg.FallbackSignatureAlgorithm),
options.WithRequiredClaims(map[string]interface{}{
"tid": cfg.TenantID,
}),
),
}))
```
Expand All @@ -175,7 +226,7 @@ e.Use(middleware.JWTWithConfig(middleware.JWTConfig{

```go
func newClaimsHandler(c echo.Context) error {
claims, ok := c.Get("user").(map[string]interface{})
claims, ok := c.Get("user").(AzureADClaims)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
}
Expand All @@ -194,13 +245,11 @@ func newClaimsHandler(c echo.Context) error {

```go
oidcTokenHandler := oidctoken.New(h,
GetAzureADClaimsValidationFn(cfg.TenantID),
options.WithIssuer(cfg.Issuer),
options.WithRequiredTokenType("JWT"),
options.WithRequiredAudience(cfg.Audience),
options.WithFallbackSignatureAlgorithm(cfg.FallbackSignatureAlgorithm),
options.WithRequiredClaims(map[string]interface{}{
"tid": cfg.TenantID,
}),
)

// oidctoken.GetTokenString is optional, but you will need the JWT token as a string
Expand All @@ -217,54 +266,15 @@ if err != nil {

## Other options

### Deeply nested required claims

If you want to use `options.WithRequiredClaims()` with nested values, you need to specify the actual type when configuring it and not an interface and the middleware will use this to infer what types the token claims are.

Example claims could look like this:

```json
{
"foo": {
"bar": ["uno", "dos", "baz", "tres"]
}
}
```

This would then be interpreted as the following inside the code:

```go
"foo": map[string]interface {}{
"bar":[]interface {}{
"uno",
"dos",
"baz",
"tres"
},
}
```

If you want to require the claim `foo.bar` to contain the value `baz`, it would look like this:

```go
options.WithRequiredClaims(map[string]interface{}{
"foo": map[string][]string{
"bar": {"baz"},
}
})
```

### Extract token from multiple headers

Example for `Authorization` and `Foo` headers. If token is found in `Authorization`, `Foo` will not be tried. If `Authorization` extraction fails but there's a header `Foo = Bar_baz` then `baz` would be extracted as the token.

```go
oidcHandler := oidcgin.New(
GetAzureADClaimsValidationFn(cfg.TenantID),
options.WithIssuer(cfg.Issuer),
options.WithFallbackSignatureAlgorithm(cfg.FallbackSignatureAlgorithm),
options.WithRequiredClaims(map[string]interface{}{
"cid": cfg.ClientID,
}),
options.WithTokenString(
options.WithTokenStringHeaderName("Authorization"),
options.WithTokenStringTokenPrefix("Bearer "),
Expand All @@ -284,11 +294,9 @@ The following would be used by a the Kubernetes api server, where the kubernetes

```go
oidcHandler := oidcgin.New(
GetAzureADClaimsValidationFn(cfg.TenantID),
options.WithIssuer(cfg.Issuer),
options.WithFallbackSignatureAlgorithm(cfg.FallbackSignatureAlgorithm),
options.WithRequiredClaims(map[string]interface{}{
"cid": cfg.ClientID,
}),
options.WithTokenString(
options.WithTokenStringHeaderName("Authorization"),
options.WithTokenStringTokenPrefix("Bearer "),
Expand Down Expand Up @@ -319,11 +327,9 @@ errorHandler := func(description options.ErrorDescription, err error) {
}

oidcHandler := oidcgin.New(
GetAzureADClaimsValidationFn(cfg.TenantID),
options.WithIssuer(cfg.Issuer),
options.WithFallbackSignatureAlgorithm(cfg.FallbackSignatureAlgorithm),
options.WithRequiredClaims(map[string]interface{}{
"cid": cfg.ClientID,
}),
options.WithErrorHandler(errorHandler),
)
```
Expand All @@ -348,6 +354,7 @@ func TestFoobar(t *testing.T) {
[...]

oidcHandler := oidchttp.New(h,
GetAzureADClaimsValidationFn(cfg.TenantID),
options.WithIssuer(op.GetURL(t)),
options.WithRequiredTokenType("JWT+AT"),
options.WithRequiredAudience("test-client"),
Expand Down
4 changes: 2 additions & 2 deletions examples/PROVIDER_AUTH0.md
Expand Up @@ -8,7 +8,7 @@ Create an Auth0 account and an api as well as a native app.
TOKEN_ISSUER="https://<domain>.auth0.com/"
TOKEN_AUDIENCE="https://localhost:8081"
CLIENT_ID="Auth0NativeAppClientID"
go run ./api/main.go --server [server] --provider auth0 --token-issuer ${TOKEN_ISSUER} --token-audience ${TOKEN_AUDIENCE} --required-claims azp:${CLIENT_ID} --port 8081
go run ./api/main.go --server [server] --provider auth0 --token-issuer ${TOKEN_ISSUER} --token-audience ${TOKEN_AUDIENCE} --required-auth0-client-id ${CLIENT_ID} --port 8081
```

## Test with curl
Expand All @@ -17,4 +17,4 @@ go run ./api/main.go --server [server] --provider auth0 --token-issuer ${TOKEN_I
ACCESS_TOKEN=$(go run ./pkce-cli/main.go --issuer ${TOKEN_ISSUER} --client-id ${CLIENT_ID} --extra-authz-params audience:${TOKEN_AUDIENCE} | jq -r ".access_token")
curl -s http://localhost:8081 | jq
curl -s -H "Authorization: Bearer ${ACCESS_TOKEN}" http://localhost:8081 | jq
```
```
4 changes: 2 additions & 2 deletions examples/PROVIDER_AZUREAD.md
Expand Up @@ -27,15 +27,15 @@ az rest --method PATCH --uri "https://graph.microsoft.com/beta/applications/${AZ
az rest --method PATCH --uri "https://graph.microsoft.com/beta/applications/${AZ_APP_OBJECT_ID}" --body "{\"api\":{\"preAuthorizedApplications\":[{\"appId\":\"04b07795-8ddb-461a-bbee-02f9e1bf7b46\",\"permissionIds\":[\"${AZ_APP_PERMISSION_ID}\"]}]}}"
# Add PKCE-CLI as allowed client
az rest --method PATCH --uri "https://graph.microsoft.com/beta/applications/${AZ_APP_OBJECT_ID}" --body "{\"api\":{\"preAuthorizedApplications\":[{\"appId\":\"04b07795-8ddb-461a-bbee-02f9e1bf7b46\",\"permissionIds\":[\"${AZ_APP_PERMISSION_ID}\"]},{\"appId\":\"${AZ_APP_PKCECLI_ID}\",\"permissionIds\":[\"${AZ_APP_PERMISSION_ID}\"]}]}}"
```
```

## Run web server

```shell
TENANT_ID=$(az account show -o json | jq -r .tenantId)
TOKEN_ISSUER="https://login.microsoftonline.com/${TENANT_ID}/v2.0"
TOKEN_AUDIENCE=$(az ad app list --identifier-uri ${AZ_APP_URI} | jq -r ".[0].appId")
go run ./api/main.go --server [server] --provider azuread --token-issuer ${TOKEN_ISSUER} --token-audience ${TOKEN_AUDIENCE} --required-claims tid:${TENANT_ID} --port 8081
go run ./api/main.go --server [server] --provider azuread --token-issuer ${TOKEN_ISSUER} --token-audience ${TOKEN_AUDIENCE} --required-azure-ad-tenant-id ${TENANT_ID} --port 8081
```

## Test with curl
Expand Down
4 changes: 2 additions & 2 deletions examples/PROVIDER_COGNITO.md
Expand Up @@ -7,7 +7,7 @@ Create a Cognito user pool, app client and configure the callback for the app cl
```shell
TOKEN_ISSUER="https://cognito-idp.{region}.amazonaws.com/{userPoolId}"
CLIENT_ID="CognitoClientID"
go run ./api/main.go --server [server] --provider cognito --token-issuer ${TOKEN_ISSUER} --required-claims client_id:${CLIENT_ID} --port 8081
go run ./api/main.go --server [server] --provider cognito --token-issuer ${TOKEN_ISSUER} --required-cognito-client-id ${CLIENT_ID} --port 8081
```

## Test with curl
Expand All @@ -17,4 +17,4 @@ CLIENT_SECRET="CognitoAppSecret"
ACCESS_TOKEN=$(go run ./pkce-cli/main.go --issuer ${TOKEN_ISSUER} --client-id ${CLIENT_ID} --extra-token-params client_secret:${CLIENT_SECRET} | jq -r ".access_token")
curl -s http://localhost:8081 | jq
curl -s -H "Authorization: Bearer ${ACCESS_TOKEN}" http://localhost:8081 | jq
```
```
4 changes: 2 additions & 2 deletions examples/PROVIDER_OKTA.md
Expand Up @@ -7,7 +7,7 @@ Create an Okta organization and a native app. Copy the issuer and client id.
```shell
TOKEN_ISSUER="https://<domain>.okta.com/oauth2/default"
CLIENT_ID="OktaClientID"
go run ./api/main.go --server [server] --provider okta --token-issuer ${TOKEN_ISSUER} --required-claims cid:${CLIENT_ID} --port 8081
go run ./api/main.go --server [server] --provider okta --token-issuer ${TOKEN_ISSUER} --required-okta-client-id ${CLIENT_ID} --port 8081
```

## Test with curl
Expand All @@ -16,4 +16,4 @@ go run ./api/main.go --server [server] --provider okta --token-issuer ${TOKEN_IS
ACCESS_TOKEN=$(go run ./pkce-cli/main.go --issuer ${TOKEN_ISSUER} --client-id ${CLIENT_ID} | jq -r ".access_token")
curl -s http://localhost:8081 | jq
curl -s -H "Authorization: Bearer ${ACCESS_TOKEN}" http://localhost:8081 | jq
```
```

0 comments on commit 2f273bd

Please sign in to comment.