Skip to content

Commit

Permalink
BearerTokenPolicy handles CAE claims challenges by default (#23414)
Browse files Browse the repository at this point in the history
  • Loading branch information
chlowell authored Oct 10, 2024
1 parent 8759ee6 commit 1c2a108
Show file tree
Hide file tree
Showing 7 changed files with 433 additions and 155 deletions.
4 changes: 2 additions & 2 deletions sdk/azcore/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Release History

## 1.14.1 (Unreleased)
## 1.15.0 (2024-10-10)

### Features Added

### Breaking Changes
* `BearerTokenPolicy` handles CAE claims challenges

### Bugs Fixed

Expand Down
48 changes: 2 additions & 46 deletions sdk/azcore/arm/runtime/policy_bearer_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package runtime

import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
Expand Down Expand Up @@ -66,31 +65,16 @@ func NewBearerTokenPolicy(cred azcore.TokenCredential, opts *armpolicy.BearerTok
p.btp = azruntime.NewBearerTokenPolicy(cred, opts.Scopes, &azpolicy.BearerTokenOptions{
InsecureAllowCredentialWithHTTP: opts.InsecureAllowCredentialWithHTTP,
AuthorizationHandler: azpolicy.AuthorizationHandler{
OnChallenge: p.onChallenge,
OnRequest: p.onRequest,
OnRequest: p.onRequest,
},
})
return p
}

func (b *BearerTokenPolicy) onChallenge(req *azpolicy.Request, res *http.Response, authNZ func(azpolicy.TokenRequestOptions) error) error {
challenge := res.Header.Get(shared.HeaderWWWAuthenticate)
claims, err := parseChallenge(challenge)
if err != nil {
// the challenge contains claims we can't parse
return err
} else if claims != "" {
// request a new token having the specified claims, send the request again
return authNZ(azpolicy.TokenRequestOptions{Claims: claims, EnableCAE: true, Scopes: b.scopes})
}
// auth challenge didn't include claims, so this is a simple authorization failure
return azruntime.NewResponseError(res)
}

// onRequest authorizes requests with one or more bearer tokens
func (b *BearerTokenPolicy) onRequest(req *azpolicy.Request, authNZ func(azpolicy.TokenRequestOptions) error) error {
// authorize the request with a token for the primary tenant
err := authNZ(azpolicy.TokenRequestOptions{EnableCAE: true, Scopes: b.scopes})
err := authNZ(azpolicy.TokenRequestOptions{Scopes: b.scopes})
if err != nil || len(b.auxResources) == 0 {
return err
}
Expand All @@ -116,31 +100,3 @@ func (b *BearerTokenPolicy) onRequest(req *azpolicy.Request, authNZ func(azpolic
func (b *BearerTokenPolicy) Do(req *azpolicy.Request) (*http.Response, error) {
return b.btp.Do(req)
}

// parseChallenge parses claims from an authentication challenge issued by ARM so a client can request a token
// that will satisfy conditional access policies. It returns a non-nil error when the given value contains
// claims it can't parse. If the value contains no claims, it returns an empty string and a nil error.
func parseChallenge(wwwAuthenticate string) (string, error) {
claims := ""
var err error
for _, param := range strings.Split(wwwAuthenticate, ",") {
if _, after, found := strings.Cut(param, "claims="); found {
if claims != "" {
// The header contains multiple challenges, at least two of which specify claims. The specs allow this
// but it's unclear what a client should do in this case and there's as yet no concrete example of it.
err = fmt.Errorf("found multiple claims challenges in %q", wwwAuthenticate)
break
}
// trim stuff that would get an error from RawURLEncoding; claims may or may not be padded
claims = strings.Trim(after, `\"=`)
// we don't return this error because it's something unhelpful like "illegal base64 data at input byte 42"
if b, decErr := base64.RawURLEncoding.DecodeString(claims); decErr == nil {
claims = string(b)
} else {
err = fmt.Errorf("failed to parse claims from %q", wwwAuthenticate)
break
}
}
}
return claims, err
}
82 changes: 0 additions & 82 deletions sdk/azcore/arm/runtime/policy_bearer_token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,88 +205,6 @@ func TestAuxiliaryTenants(t *testing.T) {
}
}

func TestBearerTokenPolicyChallengeParsing(t *testing.T) {
for _, test := range []struct {
challenge, desc, expectedClaims string
err error
}{
{
desc: "no challenge",
},
{
desc: "no claims",
challenge: `Bearer authorization_uri="https://login.windows.net/", error="invalid_token", error_description="The authentication failed because of missing 'Authorization' header."`,
err: (*azcore.ResponseError)(nil),
},
{
desc: "parsing error",
challenge: `Bearer claims="invalid"`,
// the specific error type isn't important but it must be nonretriable
err: (errorinfo.NonRetriable)(nil),
},
// CAE claims challenges. Position of the "claims" parameter within the challenge shouldn't affect parsing.
{
desc: "insufficient claims",
challenge: `Bearer realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000", error="insufficient_claims", claims="eyJhY2Nlc3NfdG9rZW4iOiB7ImZvbyI6ICJiYXIifX0="`,
expectedClaims: `{"access_token": {"foo": "bar"}}`,
},
{
desc: "insufficient claims",
challenge: `Bearer claims="eyJhY2Nlc3NfdG9rZW4iOiB7ImZvbyI6ICJiYXIifX0=", realm="", authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize", client_id="00000003-0000-0000-c000-000000000000", error="insufficient_claims"`,
expectedClaims: `{"access_token": {"foo": "bar"}}`,
},
{
desc: "sessions revoked",
challenge: `Bearer authorization_uri="https://login.windows.net/", error="invalid_token", error_description="User session has been revoked", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0="`,
expectedClaims: `{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}`,
},
{
desc: "sessions revoked",
challenge: `Bearer authorization_uri="https://login.windows.net/", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwgInZhbHVlIjoiMTYwMzc0MjgwMCJ9fX0=", error="invalid_token", error_description="User session has been revoked"`,
expectedClaims: `{"access_token":{"nbf":{"essential":true, "value":"1603742800"}}}`,
},
{
desc: "IP policy",
challenge: `Bearer authorization_uri="https://login.windows.net/", error="invalid_token", error_description="Tenant IP Policy validate failed.", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNjEwNTYzMDA2In0sInhtc19ycF9pcGFkZHIiOnsidmFsdWUiOiIxLjIuMy40In19fQ"`,
expectedClaims: `{"access_token":{"nbf":{"essential":true,"value":"1610563006"},"xms_rp_ipaddr":{"value":"1.2.3.4"}}}`,
},
{
desc: "IP policy",
challenge: `Bearer authorization_uri="https://login.windows.net/", error="invalid_token", claims="eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNjEwNTYzMDA2In0sInhtc19ycF9pcGFkZHIiOnsidmFsdWUiOiIxLjIuMy40In19fQ", error_description="Tenant IP Policy validate failed."`,
expectedClaims: `{"access_token":{"nbf":{"essential":true,"value":"1610563006"},"xms_rp_ipaddr":{"value":"1.2.3.4"}}}`,
},
} {
t.Run(test.desc, func(t *testing.T) {
srv, close := mock.NewTLSServer()
defer close()
srv.SetResponse(mock.WithHeader(shared.HeaderWWWAuthenticate, test.challenge), mock.WithStatusCode(http.StatusUnauthorized))
calls := 0
cred := mockCredential{
getTokenImpl: func(ctx context.Context, actual azpolicy.TokenRequestOptions) (azcore.AccessToken, error) {
calls += 1
if calls == 2 && test.expectedClaims != "" {
require.Equal(t, test.expectedClaims, actual.Claims)
}
return azcore.AccessToken{Token: "...", ExpiresOn: time.Now().Add(time.Hour).UTC()}, nil
},
}
b := NewBearerTokenPolicy(cred, &armpolicy.BearerTokenOptions{Scopes: []string{scope}})
pipeline := newTestPipeline(&azpolicy.ClientOptions{Transport: srv, PerRetryPolicies: []azpolicy.Policy{b}})
req, err := runtime.NewRequest(context.Background(), http.MethodGet, srv.URL())
require.NoError(t, err)
_, err = pipeline.Do(req)
if test.err != nil {
require.ErrorAs(t, err, &test.err)
} else {
require.NoError(t, err)
}
if test.expectedClaims != "" {
require.Equal(t, 2, calls, "policy should have requested a new token upon receiving the challenge")
}
})
}
}

func TestBearerTokenPolicyRequiresHTTPS(t *testing.T) {
srv, close := mock.NewServer()
defer close()
Expand Down
2 changes: 1 addition & 1 deletion sdk/azcore/internal/shared/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,5 @@ const (
Module = "azcore"

// Version is the semantic version (see http://semver.org) of this module.
Version = "v1.14.1"
Version = "v1.15.0"
)
23 changes: 12 additions & 11 deletions sdk/azcore/policy/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,19 +161,20 @@ type BearerTokenOptions struct {

// AuthorizationHandler allows SDK developers to insert custom logic that runs when BearerTokenPolicy must authorize a request.
type AuthorizationHandler struct {
// OnRequest is called each time the policy receives a request. Its func parameter authorizes the request with a token
// from the policy's given credential. Implementations that need to perform I/O should use the Request's context,
// available from Request.Raw().Context(). When OnRequest returns an error, the policy propagates that error and doesn't
// send the request. When OnRequest is nil, the policy follows its default behavior, authorizing the request with a
// token from its credential according to its configuration.
// OnRequest provides TokenRequestOptions the policy can use to acquire a token for a request. The policy calls OnRequest
// whenever it needs a token and may call it multiple times for the same request. Its func parameter authorizes the request
// with a token from the policy's credential. Implementations that need to perform I/O should use the Request's context,
// available from Request.Raw().Context(). When OnRequest returns an error, the policy propagates that error and doesn't send
// the request. When OnRequest is nil, the policy follows its default behavior, which is to authorize the request with a token
// from its credential according to its configuration.
OnRequest func(*Request, func(TokenRequestOptions) error) error

// OnChallenge is called when the policy receives a 401 response, allowing the AuthorizationHandler to re-authorize the
// request according to an authentication challenge (the Response's WWW-Authenticate header). OnChallenge is responsible
// for parsing parameters from the challenge. Its func parameter will authorize the request with a token from the policy's
// given credential. Implementations that need to perform I/O should use the Request's context, available from
// Request.Raw().Context(). When OnChallenge returns nil, the policy will send the request again. When OnChallenge is nil,
// the policy will return any 401 response to the client.
// OnChallenge allows clients to implement custom HTTP authentication challenge handling. BearerTokenPolicy calls it upon
// receiving a 401 response containing multiple Bearer challenges or a challenge BearerTokenPolicy itself can't handle.
// OnChallenge is responsible for parsing challenge(s) (the Response's WWW-Authenticate header) and reauthorizing the
// Request accordingly. Its func argument authorizes the Request with a token from the policy's credential using the given
// TokenRequestOptions. OnChallenge should honor the Request's context, available from Request.Raw().Context(). When
// OnChallenge returns nil, the policy will send the Request again.
OnChallenge func(*Request, *http.Response, func(TokenRequestOptions) error) error
}

Expand Down
Loading

0 comments on commit 1c2a108

Please sign in to comment.