Skip to content

Commit

Permalink
resolve Aggregated and Distributed Claims
Browse files Browse the repository at this point in the history
fixes: zalando#1955

This solution is scoped to Azure behaviour, taking into account the specs from
https://openid.net/specs/openid-connect-core-1_0.html#AggregatedDistributedClaims

There are some Azure related API calls included but trying to support other providers, which is though unknown at this time.

it transforms a distributed claim

```json
{
    "_claim_names": {
        "groups": "src1"
    },
    "_claim_sources": {
        "src1": {
            "endpoint": "https://graph.windows.net/.../getMemberObjects"
        }
    }
}
```

into a full populated token, which is saved in `statebag` and in the `cookie` for follow up processing

```json
{
    "_claim_names": {
        "groups": "src1"
    },
    "_claim_sources": {
        "src1": {
            "endpoint": "https://graph.windows.net/.../getMemberObjects"
        }
    },
    "groups": [
        "group1",
        "group2",
        ...
    ]
}
```

Signed-off-by: Samuel Lang <gh@lang-sam.de>
  • Loading branch information
universam1 committed Mar 2, 2022
1 parent 2c79795 commit 2dff9a5
Show file tree
Hide file tree
Showing 4 changed files with 288 additions and 30 deletions.
190 changes: 184 additions & 6 deletions filters/auth/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
Expand All @@ -19,9 +20,11 @@ import (
"time"

"github.com/coreos/go-oidc"
"github.com/opentracing/opentracing-go"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/zalando/skipper/filters"
snet "github.com/zalando/skipper/net"
"github.com/zalando/skipper/secrets"
"golang.org/x/oauth2"
)
Expand All @@ -38,8 +41,31 @@ const (
stateValidity = 1 * time.Minute
oidcInfoHeader = "Skipper-Oidc-Info"
cookieMaxSize = 4093 // common cookie size limit http://browsercookielimits.squawky.net/

// Deprecated: The host of the Azure Active Directory (AAD) graph API
azureADGraphHost = "graph.windows.net"
)

type distributedClaims struct {
ClaimNames map[string]string `json:"_claim_names"`
ClaimSources map[string]claimSource `json:"_claim_sources"`
}

type claimSource struct {
Endpoint string `json:"endpoint"`
AccessToken string `json:"access_token,omitempty"`
}

// The host of the Microsoft Graph API
var microsoftGraphHost = "graph.microsoft.com" // global for testing purposes
type azureGraphGroups struct {
OdataNextLink string `json:"@odata.nextLink,omitempty"`
Value []struct {
DisplayName string `json:"displayName"`
ID string `json:"id"`
} `json:"value"`
}

// Filter parameter:
//
// oauthOidc...("https://oidc-provider.example.com", "client_id", "client_secret",
Expand All @@ -57,11 +83,18 @@ const (
paramSubdomainsToRemove
)

type OidcOptions struct {
MaxIdleConns int
Timeout time.Duration
Tracer opentracing.Tracer
}

type (
tokenOidcSpec struct {
typ roleCheckType
SecretsFile string
secretsRegistry secrets.EncrypterCreator
options OidcOptions
}

tokenOidcFilter struct {
Expand Down Expand Up @@ -98,21 +131,36 @@ type (
}
)

// NewOAuthOidcUserInfos creates filter spec which tests user info.
// NewOAuthOidcUserInfosWithOptions creates filter spec which tests user info.
func NewOAuthOidcUserInfosWithOptions(secretsFile string, secretsRegistry secrets.EncrypterCreator, o OidcOptions) filters.Spec {
return &tokenOidcSpec{typ: checkOIDCUserInfo, SecretsFile: secretsFile, secretsRegistry: secretsRegistry, options: o}
}

// Deprecated: use NewOAuthOidcUserInfosWithOptions instead.
func NewOAuthOidcUserInfos(secretsFile string, secretsRegistry secrets.EncrypterCreator) filters.Spec {
return &tokenOidcSpec{typ: checkOIDCUserInfo, SecretsFile: secretsFile, secretsRegistry: secretsRegistry}
return NewOAuthOidcUserInfosWithOptions(secretsFile, secretsRegistry, OidcOptions{})
}

// NewOAuthOidcAnyClaims creates a filter spec which verifies that the token
// NewOAuthOidcAnyClaimsWithOptions creates a filter spec which verifies that the token
// has one of the claims specified
func NewOAuthOidcAnyClaimsWithOptions(secretsFile string, secretsRegistry secrets.EncrypterCreator, o OidcOptions) filters.Spec {
return &tokenOidcSpec{typ: checkOIDCAnyClaims, SecretsFile: secretsFile, secretsRegistry: secretsRegistry, options: o}
}

// Deprecated: use NewOAuthOidcAnyClaimsWithOptions instead.
func NewOAuthOidcAnyClaims(secretsFile string, secretsRegistry secrets.EncrypterCreator) filters.Spec {
return &tokenOidcSpec{typ: checkOIDCAnyClaims, SecretsFile: secretsFile, secretsRegistry: secretsRegistry}
return NewOAuthOidcAnyClaimsWithOptions(secretsFile, secretsRegistry, OidcOptions{})
}

// NewOAuthOidcAllClaims creates a filter spec which verifies that the token
// NewOAuthOidcAllClaimsWithOptions creates a filter spec which verifies that the token
// has all the claims specified
func NewOAuthOidcAllClaimsWithOptions(secretsFile string, secretsRegistry secrets.EncrypterCreator, o OidcOptions) filters.Spec {
return &tokenOidcSpec{typ: checkOIDCAllClaims, SecretsFile: secretsFile, secretsRegistry: secretsRegistry, options: o}
}

// Deprecated: use NewOAuthOidcAllClaimsWithOptions instead.
func NewOAuthOidcAllClaims(secretsFile string, secretsRegistry secrets.EncrypterCreator) filters.Spec {
return &tokenOidcSpec{typ: checkOIDCAllClaims, SecretsFile: secretsFile, secretsRegistry: secretsRegistry}
return NewOAuthOidcAllClaimsWithOptions(secretsFile, secretsRegistry, OidcOptions{})
}

// CreateFilter creates an OpenID Connect authorization filter.
Expand Down Expand Up @@ -200,6 +248,17 @@ func (s *tokenOidcSpec) CreateFilter(args []interface{}) (filters.Filter, error)
subdomainsToRemove: subdomainsToRemove,
}

if distributedClaimsClient == nil {
distributedClaimsClient = snet.NewClient(snet.Options{
ResponseHeaderTimeout: s.options.Timeout,
TLSHandshakeTimeout: s.options.Timeout,
MaxIdleConnsPerHost: s.options.MaxIdleConns,
Tracer: s.options.Tracer,
OpentracingComponentTag: "skipper",
OpentracingSpanName: "distributedClaims",
})
}

// user defined scopes
scopes := strings.Split(sargs[paramScopes], " ")
if len(sargs[paramScopes]) == 0 {
Expand Down Expand Up @@ -795,6 +854,10 @@ func (f *tokenOidcFilter) tokenClaims(ctx filters.FilterContext, oauth2Token *oa
return nil, "", requestErrorf("claims do not contain sub")
}

if err = f.handleDistributedClaims(r.Context(), idToken, oauth2Token, tokenMap); err != nil {
log.Error(err)
}

return tokenMap, sub, nil
}

Expand Down Expand Up @@ -860,6 +923,121 @@ func (f *tokenOidcFilter) getTokenWithExchange(state *OauthState, ctx filters.Fi
return oauth2Token, err
}

// handleDistributedClaims handles if user has a distributed / overage token.
// https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens#groups-overage-claim
// In Azure, if you are indirectly member of more than 200 groups, they will
// send _claim_names and _claim_sources instead of the groups, per OIDC Core 1.0, section 5.6.2:
// https://openid.net/specs/openid-connect-core-1_0.html#AggregatedDistributedClaims
// Example:
//
// {
// "_claim_names": {
// "groups": "src1"
// },
// "_claim_sources": {
// "src1": {
// "endpoint": "https://graph.windows.net/.../getMemberObjects"
// }
// }
// }
//
func (f *tokenOidcFilter) handleDistributedClaims(ctx context.Context, idToken *oidc.IDToken, oauth2Token *oauth2.Token, claimsMap map[string]interface{}) error {
// https://github.com/coreos/go-oidc/issues/171#issuecomment-1044286153
var distClaims distributedClaims
err := idToken.Claims(&distClaims)
if err != nil {
return err
}
if len(distClaims.ClaimNames) == 0 || len(distClaims.ClaimSources) == 0 {
log.Debugf("No distributed claims found")
return nil
}

for claim, ref := range distClaims.ClaimNames {
source, ok := distClaims.ClaimSources[ref]
if !ok {
return fmt.Errorf("invalid distributed claims: missing claim source for %s", claim)
}
uri, err := url.Parse(source.Endpoint)
if err != nil {
return fmt.Errorf("failed to parse distributed claim endpoint: %w", err)
}

var results []interface{}

switch uri.Host {
case azureADGraphHost, microsoftGraphHost:
results, err = handleDistributedClaimsAzure(uri, oauth2Token, claimsMap)
if err != nil {
return fmt.Errorf("failed to get distributed Azure claim: %w", err)
}
default:
return fmt.Errorf("unsupported distributed claims endpoint '%s', please create an issue at https://github.com/zalando/skipper/issues/new/choose", uri.Host)
}

claimsMap[claim] = results
}
return nil
}

// Azure customizations https://docs.microsoft.com/en-us/graph/migrate-azure-ad-graph-overview
// If the endpoints provided in _claim_source is pointed to the deprecated "graph.windows.net" api
// replace with handcrafted url to graph.microsoft.com
func handleDistributedClaimsAzure(uri *url.URL, oauth2Token *oauth2.Token, claimsMap map[string]interface{}) (values []interface{}, err error) {
uri.Host = microsoftGraphHost
// transitiveMemberOf for group names
userID, ok := claimsMap["oid"].(string)
if !ok {
return nil, fmt.Errorf("oid claim not found in claims map")
}
uri.Path = fmt.Sprintf("/v1.0/users/%s/transitiveMemberOf", userID)
q := uri.Query()
q.Set("$select", "displayName,id")
uri.RawQuery = q.Encode()
return resolveDistributedClaimAzure(uri.String(), oauth2Token)
}

var distributedClaimsClient *snet.Client

func resolveDistributedClaimAzure(endpoint string, oauth2Token *oauth2.Token) (values []interface{}, err error) {
var target azureGraphGroups
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("error constructing groups endpoint request: %w", err)
}
oauth2Token.SetAuthHeader(req)

res, err := distributedClaimsClient.Do(req)
if err != nil {
return nil, fmt.Errorf("unable to call API: %w", err)
}
body, err := ioutil.ReadAll(res.Body)
res.Body.Close() // closing for connection reuse
if err != nil {
return nil, fmt.Errorf("failed to read API response: %w", err)
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned error: %s", string(body))
}

err = json.Unmarshal(body, &target)
if err != nil {
return nil, fmt.Errorf("unabled to decode response: %w", err)
}
for _, v := range target.Value {
values = append(values, v.DisplayName)
}
// recursive pagination
if target.OdataNextLink != "" {
vs, err := resolveDistributedClaimAzure(target.OdataNextLink, oauth2Token)
if err != nil {
return nil, err
}
values = append(values, vs...)
}
return
}

func newDeflatePoolCompressor(level int) *deflatePoolCompressor {
return &deflatePoolCompressor{
poolWriter: &sync.Pool{
Expand Down
3 changes: 2 additions & 1 deletion filters/auth/oidc_introspection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"
"time"

jwt "github.com/golang-jwt/jwt/v4"
"github.com/stretchr/testify/assert"
"github.com/zalando/skipper/eskip"
"github.com/zalando/skipper/filters"
Expand Down Expand Up @@ -289,7 +290,7 @@ func TestOIDCQueryClaimsFilter(t *testing.T) {
t.Errorf("Failed to parse url %s: %v", proxy.URL, err)
}
reqURL.Path = tc.path
oidcServer := createOIDCServer(proxy.URL+"/redirect", validClient, "mysec")
oidcServer := createOIDCServer(proxy.URL+"/redirect", validClient, "mysec", jwt.MapClaims{"groups": []string{"CD-Administrators", "Purchasing-Department", "AppX-Test-Users", "white space"}})
defer oidcServer.Close()
t.Logf("oidc/auth server URL: %s", oidcServer.URL)
// create filter
Expand Down
Loading

0 comments on commit 2dff9a5

Please sign in to comment.