Skip to content

Commit a140d4a

Browse files
authored
Add azidentity.ClientAssertionCredential (Azure#18807)
1 parent c60d345 commit a140d4a

8 files changed

+202
-25
lines changed

sdk/azidentity/CHANGELOG.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
# Release History
22

3-
## 1.2.0-beta.2 (Unreleased)
3+
## 1.2.0-beta.2 (2022-08-10)
44

55
### Features Added
6-
7-
### Breaking Changes
8-
9-
### Bugs Fixed
6+
* Added `ClientAssertionCredential` to enable applications to authenticate
7+
with custom client assertions
108

119
### Other Changes
1210
* Updated AuthenticationFailedError with links to TROUBLESHOOTING.md for relevant errors
11+
* Upgraded `microsoft-authentication-library-for-go` requirement to v0.6.0
1312

1413
## 1.2.0-beta.1 (2022-06-07)
1514

sdk/azidentity/azidentity_test.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,7 @@ const (
3030
accessTokenRespSuccess = `{"access_token": "` + tokenValue + `", "expires_in": 3600}`
3131
accessTokenRespMalformed = `{"access_token": 0, "expires_in": 3600}`
3232
badTenantID = "bad_tenant"
33-
tokenValue = "new_token"
34-
)
35-
36-
// constants for this file
37-
const (
38-
testHost = "https://localhost"
39-
tenantDiscoveryResponse = `{
33+
tenantDiscoveryResponse = `{
4034
"token_endpoint": "https://login.microsoftonline.com/3c631bb7-a9f7-4343-a5ba-a6159135f1fc/oauth2/v2.0/token",
4135
"token_endpoint_auth_methods_supported": [
4236
"client_secret_post",
@@ -103,6 +97,29 @@ const (
10397
"msgraph_host": "graph.microsoft.com",
10498
"rbac_url": "https://pas.windows.net"
10599
}`
100+
tokenValue = "new_token"
101+
)
102+
103+
var instanceDiscoveryResponse = []byte(`{
104+
"tenant_discovery_endpoint": "https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration",
105+
"api-version": "1.1",
106+
"metadata": [
107+
{
108+
"preferred_network": "login.microsoftonline.com",
109+
"preferred_cache": "login.windows.net",
110+
"aliases": [
111+
"login.microsoftonline.com",
112+
"login.windows.net",
113+
"login.microsoft.com",
114+
"sts.windows.net"
115+
]
116+
}
117+
]
118+
}`)
119+
120+
// constants for this file
121+
const (
122+
testHost = "https://localhost"
106123
)
107124

108125
func validateJWTRequestContainsHeader(t *testing.T, headerName string) mock.ResponsePredicate {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//go:build go1.18
2+
// +build go1.18
3+
4+
// Copyright (c) Microsoft Corporation. All rights reserved.
5+
// Licensed under the MIT License.
6+
7+
package azidentity
8+
9+
import (
10+
"context"
11+
"errors"
12+
"os"
13+
14+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
15+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
16+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
17+
"github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential"
18+
)
19+
20+
const credNameAssertion = "ClientAssertionCredential"
21+
22+
// ClientAssertionCredential authenticates an application with assertions provided by a callback function.
23+
// This credential is for advanced scenarios. ClientCertificateCredential has a more convenient API for
24+
// the most common assertion scenario, authenticating a service principal with a certificate. See
25+
// [Azure AD documentation] for details of the assertion format.
26+
//
27+
// [Azure AD documentation]: https://docs.microsoft.com/azure/active-directory/develop/active-directory-certificate-credentials#assertion-format
28+
type ClientAssertionCredential struct {
29+
client confidentialClient
30+
}
31+
32+
// ClientAssertionCredentialOptions contains optional parameters for ClientAssertionCredential.
33+
type ClientAssertionCredentialOptions struct {
34+
azcore.ClientOptions
35+
}
36+
37+
// NewClientAssertionCredential constructs a ClientAssertionCredential. The getAssertion function must be thread safe. Pass nil for options to accept defaults.
38+
func NewClientAssertionCredential(tenantID, clientID string, getAssertion func(context.Context) (string, error), options *ClientAssertionCredentialOptions) (*ClientAssertionCredential, error) {
39+
if getAssertion == nil {
40+
return nil, errors.New("getAssertion must be a function that returns assertions")
41+
}
42+
if !validTenantID(tenantID) {
43+
return nil, errors.New(tenantIDValidationErr)
44+
}
45+
if options == nil {
46+
options = &ClientAssertionCredentialOptions{}
47+
}
48+
authorityHost, err := setAuthorityHost(options.Cloud)
49+
if err != nil {
50+
return nil, err
51+
}
52+
cred := confidential.NewCredFromAssertionCallback(
53+
func(ctx context.Context, _ confidential.AssertionRequestOptions) (string, error) {
54+
return getAssertion(ctx)
55+
},
56+
)
57+
c, err := confidential.New(clientID, cred,
58+
confidential.WithAuthority(runtime.JoinPaths(authorityHost, tenantID)),
59+
confidential.WithAzureRegion(os.Getenv(azureRegionalAuthorityName)),
60+
confidential.WithHTTPClient(newPipelineAdapter(&options.ClientOptions)),
61+
)
62+
if err != nil {
63+
return nil, err
64+
}
65+
return &ClientAssertionCredential{client: c}, nil
66+
}
67+
68+
// GetToken requests an access token from Azure Active Directory. This method is called automatically by Azure SDK clients.
69+
func (c *ClientAssertionCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
70+
if len(opts.Scopes) == 0 {
71+
return azcore.AccessToken{}, errors.New(credNameAssertion + ": GetToken() requires at least one scope")
72+
}
73+
ar, err := c.client.AcquireTokenSilent(ctx, opts.Scopes)
74+
if err == nil {
75+
logGetTokenSuccess(c, opts)
76+
return azcore.AccessToken{Token: ar.AccessToken, ExpiresOn: ar.ExpiresOn.UTC()}, err
77+
}
78+
79+
ar, err = c.client.AcquireTokenByCredential(ctx, opts.Scopes)
80+
if err != nil {
81+
return azcore.AccessToken{}, newAuthenticationFailedErrorFromMSALError(credNameAssertion, err)
82+
}
83+
logGetTokenSuccess(c, opts)
84+
return azcore.AccessToken{Token: ar.AccessToken, ExpiresOn: ar.ExpiresOn.UTC()}, err
85+
}
86+
87+
var _ azcore.TokenCredential = (*ClientAssertionCredential)(nil)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//go:build go1.18
2+
// +build go1.18
3+
4+
// Copyright (c) Microsoft Corporation. All rights reserved.
5+
// Licensed under the MIT License.
6+
7+
package azidentity
8+
9+
import (
10+
"context"
11+
"errors"
12+
"strings"
13+
"testing"
14+
15+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
16+
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
17+
"github.com/Azure/azure-sdk-for-go/sdk/internal/mock"
18+
)
19+
20+
func TestClientAssertionCredential(t *testing.T) {
21+
srv, close := mock.NewServer(mock.WithTransformAllRequestsToTestServerUrl())
22+
defer close()
23+
srv.AppendResponse(mock.WithBody(instanceDiscoveryResponse))
24+
srv.AppendResponse(mock.WithBody([]byte(tenantDiscoveryResponse)))
25+
srv.AppendResponse(mock.WithBody([]byte(accessTokenRespSuccess)))
26+
27+
key := struct{}{}
28+
calls := 0
29+
getAssertion := func(c context.Context) (string, error) {
30+
if v := c.Value(key); v == nil || !v.(bool) {
31+
t.Fatal("unexpected context in getAssertion")
32+
}
33+
calls++
34+
return "assertion", nil
35+
}
36+
cred, err := NewClientAssertionCredential("tenant", "clientID", getAssertion, &ClientAssertionCredentialOptions{
37+
ClientOptions: azcore.ClientOptions{Transport: srv},
38+
})
39+
if err != nil {
40+
t.Fatal(err)
41+
}
42+
ctx := context.WithValue(context.Background(), key, true)
43+
_, err = cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{liveTestScope}})
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
if calls != 1 {
48+
t.Fatalf("expected 1 call, got %d", calls)
49+
}
50+
// silent authentication should now succeed
51+
_, err = cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{liveTestScope}})
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
if calls != 1 {
56+
t.Fatalf("expected 1 call, got %d", calls)
57+
}
58+
}
59+
60+
func TestClientAssertionCredentialCallbackError(t *testing.T) {
61+
srv, close := mock.NewServer(mock.WithTransformAllRequestsToTestServerUrl())
62+
defer close()
63+
srv.AppendResponse(mock.WithBody(instanceDiscoveryResponse))
64+
srv.AppendResponse(mock.WithBody([]byte(tenantDiscoveryResponse)))
65+
srv.AppendResponse(mock.WithBody([]byte(accessTokenRespSuccess)))
66+
67+
expectedError := errors.New("it didn't work")
68+
getAssertion := func(c context.Context) (string, error) { return "", expectedError }
69+
cred, err := NewClientAssertionCredential("tenant", "clientID", getAssertion, &ClientAssertionCredentialOptions{
70+
ClientOptions: azcore.ClientOptions{Transport: srv},
71+
})
72+
if err != nil {
73+
t.Fatal(err)
74+
}
75+
_, err = cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{liveTestScope}})
76+
if err == nil || !strings.Contains(err.Error(), expectedError.Error()) {
77+
t.Fatalf(`unexpected error: "%v"`, err)
78+
}
79+
}

sdk/azidentity/client_certificate_credential_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ func TestClientCertificateCredential_GetTokenSuccess_withCertificateChain_mock(t
111111
test := allCertTests[0]
112112
srv, close := mock.NewServer(mock.WithTransformAllRequestsToTestServerUrl())
113113
defer close()
114-
srv.AppendResponse()
114+
srv.AppendResponse(mock.WithBody(instanceDiscoveryResponse))
115115
srv.AppendResponse(mock.WithBody([]byte(tenantDiscoveryResponse)))
116116
srv.AppendResponse(mock.WithPredicate(validateJWTRequestContainsHeader(t, "x5c")), mock.WithBody([]byte(accessTokenRespSuccess)))
117117
srv.AppendResponse()

sdk/azidentity/environment_credential_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ func TestEnvironmentCredential_SendCertificateChain(t *testing.T) {
198198
resetEnvironmentVarsForTest()
199199
srv, close := mock.NewServer(mock.WithTransformAllRequestsToTestServerUrl())
200200
defer close()
201-
srv.AppendResponse()
201+
srv.AppendResponse(mock.WithBody(instanceDiscoveryResponse))
202202
srv.AppendResponse(mock.WithBody([]byte(tenantDiscoveryResponse)))
203203
srv.AppendResponse(mock.WithPredicate(validateJWTRequestContainsHeader(t, "x5c")), mock.WithBody([]byte(accessTokenRespSuccess)))
204204
srv.AppendResponse()

sdk/azidentity/go.mod

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@ go 1.18
55
require (
66
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0
77
github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0
8-
github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1
9-
github.com/golang-jwt/jwt/v4 v4.2.0
8+
github.com/AzureAD/microsoft-authentication-library-for-go v0.6.0
9+
github.com/golang-jwt/jwt/v4 v4.4.2
1010
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88
1111
)
1212

1313
require (
1414
github.com/davecgh/go-spew v1.1.1 // indirect
1515
github.com/dnaeon/go-vcr v1.1.0 // indirect
16-
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
1716
github.com/google/uuid v1.1.1 // indirect
1817
github.com/kylelemons/godebug v1.1.0 // indirect
1918
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 // indirect

sdk/azidentity/go.sum

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,19 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0 h1:sVPhtT2qjO86rTUaWMr4WoES4
22
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U=
33
github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0 h1:jp0dGvZ7ZK0mgqnTSClMxa5xuRL7NZgHameVYF6BurY=
44
github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
5-
github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1 h1:BWe8a+f/t+7KY7zH2mqygeUD0t8hNFXe08p1Pb3/jKE=
6-
github.com/AzureAD/microsoft-authentication-library-for-go v0.5.1/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4=
5+
github.com/AzureAD/microsoft-authentication-library-for-go v0.6.0 h1:XMEdVDFxgulDDl0lQmAZS6j8gRQ/0pJ+ZpXH2FHVtDc=
6+
github.com/AzureAD/microsoft-authentication-library-for-go v0.6.0/go.mod h1:BDJ5qMFKx9DugEg3+uQSDCdbYPr5s9vBTrL9P8TpqOU=
77
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
88
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
99
github.com/dnaeon/go-vcr v1.1.0 h1:ReYa/UBrRyQdant9B4fNHGoCNKw6qh6P0fsdGmZpR7c=
1010
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
11-
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
12-
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
13-
github.com/golang-jwt/jwt/v4 v4.2.0 h1:besgBTC8w8HjP6NzQdxwKH9Z5oQMZ24ThTrHp3cZ8eU=
14-
github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
11+
github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
12+
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
1513
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
1614
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
1715
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
1816
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
1917
github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8=
20-
github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
2118
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 h1:Qj1ukM4GlMWXNdMBuXcXfz/Kw9s1qm0CLY32QxuSImI=
2219
github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
2320
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -26,7 +23,6 @@ golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88 h1:Tgea0cVUD0ivh5ADBX4Wwu
2623
golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
2724
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4 h1:HVyaeDAYux4pnY+D/SiwmLOR36ewZ4iGQIIrtnuCjFA=
2825
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
29-
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
3026
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
3127
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3228
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=

0 commit comments

Comments
 (0)