Skip to content

Commit e7cc492

Browse files
committed
microsoft: support client_credentials flow using client assertions
Fixes golang#465 - Commonly referred to as Client Certificate authentication - Similar to JWT two-legged auth but incompatible
1 parent 08078c5 commit e7cc492

File tree

4 files changed

+233
-6
lines changed

4 files changed

+233
-6
lines changed

microsoft/doc.go

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright 2020 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
// Package microsoft provides support for making OAuth2 authorized and authenticated
6+
// HTTP requests to Microsoft APIs. It supports the client credentials flow using
7+
// client certificates to sign a JWT assertion. For the client credentials flow using
8+
// a shared secret, use the clientcredentials package.
9+
//
10+
// For more information on the client credentials flow using certificates, see
11+
// https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials
12+
//
13+
// Usage
14+
//
15+
// To generate a client assertion, both the private key and certificate are required. The token is signed
16+
// using the key, but the service requires the SHA-1 hash of the certificate in order to identify the key
17+
// being used.
18+
//
19+
// Scopes requested should be in the form https://api.endpoint/.default, for example
20+
// https://graph.microsoft.com/.default
21+
//
22+
// The token URL for an Azure Active Directory tenant can be obtained with the AzureADEndpoint function.
23+
//
24+
package microsoft // import "golang.org/x/oauth2/microsoft"

microsoft/example_test.go

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2020 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
package microsoft_test
6+
7+
import (
8+
"context"
9+
10+
"golang.org/x/oauth2/microsoft"
11+
)
12+
13+
func ExampleClientCertificate() {
14+
ctx := context.Background()
15+
16+
conf := microsoft.Config{
17+
ClientID: "YOUR_CLIENT_ID",
18+
PrivateKey: []byte("YOUR_ENCODED_PRIVATE_KEY"),
19+
Certificate: []byte("YOUR_ENCODED_CERTIFICATE"),
20+
Scopes: []string{"https://graph.microsoft.com/.default"},
21+
TokenURL: microsoft.AzureADEndpoint("YOUR_TENANT_ID").TokenURL,
22+
}
23+
24+
client := conf.Client(ctx)
25+
client.Get("https://graph.microsoft.com/v1.0/me")
26+
}

microsoft/microsoft.go

+174-6
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,25 @@
66
package microsoft // import "golang.org/x/oauth2/microsoft"
77

88
import (
9+
"context"
10+
"crypto/sha1"
11+
"crypto/x509"
12+
"encoding/base64"
13+
"encoding/json"
14+
"encoding/pem"
15+
"fmt"
16+
"io"
17+
"io/ioutil"
18+
"net/http"
19+
"net/url"
20+
"strings"
21+
"time"
22+
923
"golang.org/x/oauth2"
24+
"golang.org/x/oauth2/internal"
25+
"golang.org/x/oauth2/jws"
1026
)
1127

12-
// LiveConnectEndpoint is Windows's Live ID OAuth 2.0 endpoint.
13-
var LiveConnectEndpoint = oauth2.Endpoint{
14-
AuthURL: "https://login.live.com/oauth20_authorize.srf",
15-
TokenURL: "https://login.live.com/oauth20_token.srf",
16-
}
17-
1828
// AzureADEndpoint returns a new oauth2.Endpoint for the given tenant at Azure Active Directory.
1929
// If tenant is empty, it uses the tenant called `common`.
2030
//
@@ -29,3 +39,161 @@ func AzureADEndpoint(tenant string) oauth2.Endpoint {
2939
TokenURL: "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
3040
}
3141
}
42+
43+
// Config is the configuration for using client credentials flow with a client assertion.
44+
//
45+
// For more information see:
46+
// https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials
47+
type Config struct {
48+
// ClientID is the application's ID.
49+
ClientID string
50+
51+
// PrivateKey contains the contents of an RSA private key or the
52+
// contents of a PEM file that contains a private key. The provided
53+
// private key is used to sign JWT assertions.
54+
// PEM containers with a passphrase are not supported.
55+
// You can use pkcs12.Decode to extract the private key and certificate
56+
// from a PKCS #12 archive, or alternatively with OpenSSL:
57+
//
58+
// $ openssl pkcs12 -in key.p12 -out key.pem -nodes
59+
//
60+
PrivateKey []byte
61+
62+
// Certificate contains the (optionally PEM encoded) X509 certificate registered
63+
// for the application with which you are authenticating.
64+
Certificate []byte
65+
66+
// Scopes optionally specifies a list of requested permission scopes.
67+
Scopes []string
68+
69+
// TokenURL is the token endpoint. Typically you can use the AzureADEndpoint
70+
// function to obtain this value, but it may change for non-public clouds.
71+
TokenURL string
72+
73+
// Expires optionally specifies how long the token is valid for.
74+
Expires time.Duration
75+
76+
// Audience optionally specifies the intended audience of the
77+
// request. If empty, the value of TokenURL is used as the
78+
// intended audience.
79+
Audience string
80+
}
81+
82+
// TokenSource returns a TokenSource using the configuration
83+
// in c and the HTTP client from the provided context.
84+
func (c *Config) TokenSource(ctx context.Context) oauth2.TokenSource {
85+
return oauth2.ReuseTokenSource(nil, assertionSource{ctx, c})
86+
}
87+
88+
// Client returns an HTTP client wrapping the context's
89+
// HTTP transport and adding Authorization headers with tokens
90+
// obtained from c.
91+
//
92+
// The returned client and its Transport should not be modified.
93+
func (c *Config) Client(ctx context.Context) *http.Client {
94+
return oauth2.NewClient(ctx, c.TokenSource(ctx))
95+
}
96+
97+
// assertionSource is a source that always does a signed request for a token.
98+
// It should typically be wrapped with a reuseTokenSource.
99+
type assertionSource struct {
100+
ctx context.Context
101+
conf *Config
102+
}
103+
104+
// Token refreshes the token by using a new client credentials request with signed assertion.
105+
func (a assertionSource) Token() (*oauth2.Token, error) {
106+
crt := a.conf.Certificate
107+
if der, _ := pem.Decode(a.conf.Certificate); der != nil {
108+
crt = der.Bytes
109+
}
110+
cert, err := x509.ParseCertificate(crt)
111+
if err != nil {
112+
return nil, fmt.Errorf("oauth2: cannot parse certificate: %v", err)
113+
}
114+
s := sha1.Sum(cert.Raw)
115+
fp := base64.URLEncoding.EncodeToString(s[:])
116+
h := jws.Header{
117+
Algorithm: "RS256",
118+
Typ: "JWT",
119+
KeyID: fp,
120+
}
121+
122+
claimSet := &jws.ClaimSet{
123+
Iss: a.conf.ClientID,
124+
Sub: a.conf.ClientID,
125+
Aud: a.conf.TokenURL,
126+
}
127+
if t := a.conf.Expires; t > 0 {
128+
claimSet.Exp = time.Now().Add(t).Unix()
129+
}
130+
if aud := a.conf.Audience; aud != "" {
131+
claimSet.Aud = aud
132+
}
133+
134+
pk, err := internal.ParseKey(a.conf.PrivateKey)
135+
if err != nil {
136+
return nil, err
137+
}
138+
139+
payload, err := jws.Encode(&h, claimSet, pk)
140+
if err != nil {
141+
return nil, err
142+
}
143+
144+
hc := oauth2.NewClient(a.ctx, nil)
145+
v := url.Values{
146+
"client_assertion": {payload},
147+
"client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
148+
"client_id": {a.conf.ClientID},
149+
"grant_type": {"client_credentials"},
150+
"scope": {strings.Join(a.conf.Scopes, " ")},
151+
}
152+
resp, err := hc.PostForm(a.conf.TokenURL, v)
153+
if err != nil {
154+
return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err)
155+
}
156+
157+
defer resp.Body.Close()
158+
body, err := ioutil.ReadAll(io.LimitReader(resp.Body, 1<<20))
159+
if err != nil {
160+
return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err)
161+
}
162+
163+
if c := resp.StatusCode; c < 200 || c > 299 {
164+
return nil, &oauth2.RetrieveError{
165+
Response: resp,
166+
Body: body,
167+
}
168+
}
169+
170+
var tokenRes struct {
171+
AccessToken string `json:"access_token"`
172+
TokenType string `json:"token_type"`
173+
IDToken string `json:"id_token"`
174+
Scope string `json:"scope"`
175+
ExpiresIn int64 `json:"expires_in"` // relative seconds from now
176+
ExpiresOn int64 `json:"expires_on"` // timestamp
177+
}
178+
if err := json.Unmarshal(body, &tokenRes); err != nil {
179+
return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err)
180+
}
181+
182+
token := &oauth2.Token{
183+
AccessToken: tokenRes.AccessToken,
184+
TokenType: tokenRes.TokenType,
185+
}
186+
if secs := tokenRes.ExpiresIn; secs > 0 {
187+
token.Expiry = time.Now().Add(time.Duration(secs) * time.Second)
188+
}
189+
if v := tokenRes.IDToken; v != "" {
190+
// decode returned id token to get expiry
191+
claimSet, err := jws.Decode(v)
192+
if err != nil {
193+
return nil, fmt.Errorf("oauth2: error decoding JWT token: %v", err)
194+
}
195+
token.Expiry = time.Unix(claimSet.Exp, 0)
196+
}
197+
198+
return token, nil
199+
}

microsoft/windows_live_id.go

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package microsoft
2+
3+
import "golang.org/x/oauth2"
4+
5+
// LiveConnectEndpoint is Windows's Live ID OAuth 2.0 endpoint.
6+
var LiveConnectEndpoint = oauth2.Endpoint{
7+
AuthURL: "https://login.live.com/oauth20_authorize.srf",
8+
TokenURL: "https://login.live.com/oauth20_token.srf",
9+
}

0 commit comments

Comments
 (0)