Skip to content

Commit 267a86f

Browse files
committed
Add support rfc7523 in client credentials flow
Implement JSON Web Token Profile for OAuth 2.0 Client Authentication in client credentials flow. See https://tools.ietf.org/html/rfc7523 See https://openid.net/specs/openid-connect-core-1_0.html Fixes #433
1 parent 5d25da1 commit 267a86f

File tree

5 files changed

+257
-0
lines changed

5 files changed

+257
-0
lines changed

Diff for: clientcredentials/clientcredentials.go

+32
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
// server.
1212
//
1313
// See https://tools.ietf.org/html/rfc6749#section-4.4
14+
// See https://tools.ietf.org/html/rfc7523
1415
package clientcredentials // import "golang.org/x/oauth2/clientcredentials"
1516

1617
import (
@@ -19,6 +20,7 @@ import (
1920
"net/http"
2021
"net/url"
2122
"strings"
23+
"time"
2224

2325
"golang.org/x/oauth2"
2426
"golang.org/x/oauth2/internal"
@@ -46,7 +48,29 @@ type Config struct {
4648
// AuthStyle optionally specifies how the endpoint wants the
4749
// client ID & client secret sent. The zero value means to
4850
// auto-detect.
51+
// See https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication.
4952
AuthStyle oauth2.AuthStyle
53+
54+
// JWTTokenExpirationDuration optionally specifies how long the jwt token is valid for.
55+
// It is used only for the private_key_jwt auth style.
56+
// Default value is one hour.
57+
JWTTokenExpirationDuration time.Duration
58+
59+
// PrivateKey contains the contents of an RSA private key or the
60+
// contents of a PEM file that contains a private key. The provided
61+
// private key is used to sign JWT payloads.
62+
// PEM containers with a passphrase are not supported.
63+
// Use the following command to convert a PKCS 12 file into a PEM.
64+
//
65+
// $ openssl pkcs12 -in key.p12 -out key.pem -nodes
66+
//
67+
// It is required only for the private_key_jwt auth style.
68+
PrivateKey []byte
69+
70+
// KeyID contains an optional hint indicating which key is being
71+
// used.
72+
// It is required only for the private_key_jwt auth style.
73+
KeyID string
5074
}
5175

5276
// Token uses client credentials to retrieve a token.
@@ -91,6 +115,14 @@ func (c *tokenSource) Token() (*oauth2.Token, error) {
91115
v := url.Values{
92116
"grant_type": {"client_credentials"},
93117
}
118+
if c.conf.AuthStyle == oauth2.AuthStylePrivateKeyJWT {
119+
var err error
120+
v, err = c.jwtAssertionValues()
121+
if err != nil {
122+
return nil, err
123+
}
124+
125+
}
94126
if len(c.conf.Scopes) > 0 {
95127
v.Set("scope", strings.Join(c.conf.Scopes, " "))
96128
}

Diff for: clientcredentials/clientcredentials_test.go

+146
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@ package clientcredentials
66

77
import (
88
"context"
9+
"encoding/base64"
10+
"encoding/json"
911
"io"
1012
"io/ioutil"
1113
"net/http"
1214
"net/http/httptest"
1315
"net/url"
16+
"strings"
1417
"testing"
18+
"time"
1519

20+
"golang.org/x/oauth2"
1621
"golang.org/x/oauth2/internal"
22+
"golang.org/x/oauth2/jws"
1723
)
1824

1925
func newConf(serverURL string) *Config {
@@ -113,6 +119,146 @@ func TestTokenRequest(t *testing.T) {
113119
}
114120
}
115121

122+
var dummyPrivateKey = []byte(`-----BEGIN RSA PRIVATE KEY-----
123+
MIIEpAIBAAKCAQEAx4fm7dngEmOULNmAs1IGZ9Apfzh+BkaQ1dzkmbUgpcoghucE
124+
DZRnAGd2aPyB6skGMXUytWQvNYav0WTR00wFtX1ohWTfv68HGXJ8QXCpyoSKSSFY
125+
fuP9X36wBSkSX9J5DVgiuzD5VBdzUISSmapjKm+DcbRALjz6OUIPEWi1Tjl6p5RK
126+
1w41qdbmt7E5/kGhKLDuT7+M83g4VWhgIvaAXtnhklDAggilPPa8ZJ1IFe31lNlr
127+
k4DRk38nc6sEutdf3RL7QoH7FBusI7uXV03DC6dwN1kP4GE7bjJhcRb/7jYt7CQ9
128+
/E9Exz3c0yAp0yrTg0Fwh+qxfH9dKwN52S7SBwIDAQABAoIBAQCaCs26K07WY5Jt
129+
3a2Cw3y2gPrIgTCqX6hJs7O5ByEhXZ8nBwsWANBUe4vrGaajQHdLj5OKfsIDrOvn
130+
2NI1MqflqeAbu/kR32q3tq8/Rl+PPiwUsW3E6Pcf1orGMSNCXxeducF2iySySzh3
131+
nSIhCG5uwJDWI7a4+9KiieFgK1pt/Iv30q1SQS8IEntTfXYwANQrfKUVMmVF9aIK
132+
6/WZE2yd5+q3wVVIJ6jsmTzoDCX6QQkkJICIYwCkglmVy5AeTckOVwcXL0jqw5Kf
133+
5/soZJQwLEyBoQq7Kbpa26QHq+CJONetPP8Ssy8MJJXBT+u/bSseMb3Zsr5cr43e
134+
DJOhwsThAoGBAPY6rPKl2NT/K7XfRCGm1sbWjUQyDShscwuWJ5+kD0yudnT/ZEJ1
135+
M3+KS/iOOAoHDdEDi9crRvMl0UfNa8MAcDKHflzxg2jg/QI+fTBjPP5GOX0lkZ9g
136+
z6VePoVoQw2gpPFVNPPTxKfk27tEzbaffvOLGBEih0Kb7HTINkW8rIlzAoGBAM9y
137+
1yr+jvfS1cGFtNU+Gotoihw2eMKtIqR03Yn3n0PK1nVCDKqwdUqCypz4+ml6cxRK
138+
J8+Pfdh7D+ZJd4LEG6Y4QRDLuv5OA700tUoSHxMSNn3q9As4+T3MUyYxWKvTeu3U
139+
f2NWP9ePU0lV8ttk7YlpVRaPQmc1qwooBA/z/8AdAoGAW9x0HWqmRICWTBnpjyxx
140+
QGlW9rQ9mHEtUotIaRSJ6K/F3cxSGUEkX1a3FRnp6kPLcckC6NlqdNgNBd6rb2rA
141+
cPl/uSkZP42Als+9YMoFPU/xrrDPbUhu72EDrj3Bllnyb168jKLa4VBOccUvggxr
142+
Dm08I1hgYgdN5huzs7y6GeUCgYEAj+AZJSOJ6o1aXS6rfV3mMRve9bQ9yt8jcKXw
143+
5HhOCEmMtaSKfnOF1Ziih34Sxsb7O2428DiX0mV/YHtBnPsAJidL0SdLWIapBzeg
144+
KHArByIRkwE6IvJvwpGMdaex1PIGhx5i/3VZL9qiq/ElT05PhIb+UXgoWMabCp84
145+
OgxDK20CgYAeaFo8BdQ7FmVX2+EEejF+8xSge6WVLtkaon8bqcn6P0O8lLypoOhd
146+
mJAYH8WU+UAy9pecUnDZj14LAGNVmYcse8HFX71MoshnvCTFEPVo4rZxIAGwMpeJ
147+
5jgQ3slYLpqrGlcbLgUXBUgzEO684Wk/UV9DFPlHALVqCfXQ9dpJPg==
148+
-----END RSA PRIVATE KEY-----`)
149+
150+
func TestTokenJWTRequest(t *testing.T) {
151+
var assertion string
152+
audience := "audience1"
153+
scopes := []string{"scope1 scope2"}
154+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+
if r.URL.String() != "/token" {
156+
t.Errorf("authenticate client request URL = %q; want %q", r.URL, "/token")
157+
}
158+
if got, want := r.Header.Get("Content-Type"), "application/x-www-form-urlencoded"; got != want {
159+
t.Errorf("Content-Type header = %q; want %q", got, want)
160+
}
161+
err := r.ParseForm()
162+
if err != nil {
163+
t.Fatal(err)
164+
}
165+
166+
if got, want := r.Form.Get("scope"), scopes; got != strings.Join(want, " ") {
167+
t.Errorf("scope = %q; want %q", got, want)
168+
}
169+
if got, want := r.Form.Get("audience"), audience; got != want {
170+
t.Errorf("audience = %q; want %q", got, want)
171+
}
172+
if got, want := r.Form.Get("grant_type"), "client_credentials"; got != want {
173+
t.Errorf("grant_type = %q; want %q", got, want)
174+
}
175+
expectedAssertionType := "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
176+
if got, want := r.Form.Get("client_assertion_type"), expectedAssertionType; got != want {
177+
t.Errorf("client_assertion_type = %q; want %q", got, want)
178+
}
179+
180+
assertion = r.Form.Get("client_assertion")
181+
182+
w.Header().Set("Content-Type", "application/json")
183+
w.Write([]byte(`{
184+
"access_token": "90d64460d14870c08c81352a05dedd3465940a7c",
185+
"token_type": "bearer",
186+
"expires_in": 3600
187+
}`))
188+
}))
189+
defer ts.Close()
190+
191+
testCases := []struct {
192+
name string
193+
config *Config
194+
}{
195+
{
196+
name: "success",
197+
config: &Config{
198+
ClientID: "CLIENT_ID",
199+
Scopes: scopes,
200+
TokenURL: ts.URL + "/token",
201+
EndpointParams: url.Values{"audience": {audience}},
202+
AuthStyle: oauth2.AuthStylePrivateKeyJWT,
203+
PrivateKey: dummyPrivateKey,
204+
KeyID: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
205+
},
206+
},
207+
{
208+
name: "success_witt_set_jwt_expiration_time",
209+
config: &Config{
210+
ClientID: "CLIENT_ID_set_jwt_expiration_time",
211+
Scopes: scopes,
212+
TokenURL: ts.URL + "/token",
213+
EndpointParams: url.Values{"audience": {audience}},
214+
AuthStyle: oauth2.AuthStylePrivateKeyJWT,
215+
PrivateKey: dummyPrivateKey,
216+
KeyID: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
217+
JWTTokenExpirationDuration: 1 * time.Minute,
218+
},
219+
},
220+
}
221+
for _, testCase := range testCases {
222+
t.Run(testCase.name, func(t *testing.T) {
223+
_, err := testCase.config.TokenSource(context.Background()).Token()
224+
if err != nil {
225+
t.Fatalf("failed to fetch token: %v", err)
226+
}
227+
parts := strings.Split(assertion, ".")
228+
if len(parts) != 3 {
229+
t.Fatalf("assertion = %q. Want 3 parts", assertion)
230+
}
231+
gotJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
232+
if err != nil {
233+
t.Fatalf("invalid token payload; err = %v", err)
234+
}
235+
var claims jws.ClaimSet
236+
if err := json.Unmarshal(gotJSON, &claims); err != nil {
237+
t.Errorf("failed to unmarshal json token payload = %q; err = %v", gotJSON, err)
238+
}
239+
if got, want := claims.Iss, testCase.config.ClientID; got != want {
240+
t.Errorf("payload iss = %q; want %q", got, want)
241+
}
242+
if claims.Jti == "" {
243+
t.Errorf("payload jti is empty")
244+
}
245+
expectedDuration := 1 * time.Hour
246+
if testCase.config.JWTTokenExpirationDuration > 0 {
247+
expectedDuration = testCase.config.JWTTokenExpirationDuration
248+
}
249+
if got, want := claims.Exp, time.Now().Add(expectedDuration).Unix(); got != want {
250+
t.Errorf("payload exp = %q; want %q", got, want)
251+
}
252+
if got, want := claims.Aud, testCase.config.TokenURL; got != want {
253+
t.Errorf("payload aud = %q; want %q", got, want)
254+
}
255+
if got, want := claims.Sub, testCase.config.ClientID; got != want {
256+
t.Errorf("payload sub = %q; want %q", got, want)
257+
}
258+
})
259+
}
260+
}
261+
116262
func TestTokenRefreshRequest(t *testing.T) {
117263
internal.ResetAuthCache()
118264
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

Diff for: clientcredentials/jwt.go

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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 clientcredentials
6+
7+
import (
8+
"crypto/rand"
9+
"fmt"
10+
"io"
11+
"math/big"
12+
"net/url"
13+
"time"
14+
15+
"golang.org/x/oauth2/internal"
16+
"golang.org/x/oauth2/jws"
17+
)
18+
19+
const (
20+
clientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
21+
)
22+
23+
func randJWTID() (string, error) {
24+
n := 36
25+
bytes := make([]byte, n/2)
26+
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
27+
return "", err
28+
}
29+
return fmt.Sprintf("%x", big.NewInt(int64(n)).SetBytes(bytes)), nil
30+
}
31+
32+
func (c *tokenSource) jwtAssertionValues() (url.Values, error) {
33+
v := url.Values{
34+
"grant_type": {"client_credentials"},
35+
}
36+
pk, err := internal.ParseKey(c.conf.PrivateKey)
37+
if err != nil {
38+
return nil, err
39+
}
40+
41+
jti, err := randJWTID()
42+
if err != nil {
43+
return nil, err
44+
}
45+
exp := time.Now().Add(time.Hour).Unix()
46+
if t := c.conf.JWTTokenExpirationDuration; t > 0 {
47+
exp = time.Now().Add(t).Unix()
48+
}
49+
50+
claimSet := &jws.ClaimSet{
51+
Iss: c.conf.ClientID,
52+
Sub: c.conf.ClientID,
53+
Aud: c.conf.TokenURL,
54+
Jti: jti,
55+
Exp: exp,
56+
}
57+
h := jws.Header{
58+
Algorithm: "RS256",
59+
Typ: "JWT",
60+
KeyID: c.conf.KeyID,
61+
}
62+
payload, err := jws.Encode(&h, claimSet, pk)
63+
if err != nil {
64+
return nil, err
65+
}
66+
v.Set("client_assertion", payload)
67+
v.Set("client_assertion_type", clientAssertionType)
68+
69+
return v, nil
70+
}

Diff for: jws/jws.go

+4
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ type ClaimSet struct {
4949
// See http://tools.ietf.org/html/draft-jones-json-web-token-10#section-4.3
5050
// This array is marshalled using custom code (see (c *ClaimSet) encode()).
5151
PrivateClaims map[string]interface{} `json:"-"`
52+
53+
// See https://tools.ietf.org/html/rfc7523#section-3.
54+
// Unique identifier for the jwt token.
55+
Jti string `json:"jti"`
5256
}
5357

5458
func (c *ClaimSet) encode() (string, error) {

Diff for: oauth2.go

+5
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ const (
9797
// using HTTP Basic Authorization. This is an optional style
9898
// described in the OAuth2 RFC 6749 section 2.3.1.
9999
AuthStyleInHeader AuthStyle = 2
100+
101+
// AuthStylePrivateKeyJWT send jwt token signed by private key.
102+
// See https://openid.net/specs/openid-connect-core-1_0.html.
103+
// See https://tools.ietf.org/html/rfc7523.
104+
AuthStylePrivateKeyJWT AuthStyle = 3
100105
)
101106

102107
var (

0 commit comments

Comments
 (0)