Skip to content

Commit d4025d6

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 d4025d6

File tree

5 files changed

+246
-0
lines changed

5 files changed

+246
-0
lines changed

clientcredentials/clientcredentials.go

+28
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,25 @@ 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+
// JWTExpires optionally specifies how long the jwt token is valid for.
55+
JWTExpires time.Duration
56+
57+
// PrivateKey contains the contents of an RSA private key or the
58+
// contents of a PEM file that contains a private key. The provided
59+
// private key is used to sign JWT payloads.
60+
// PEM containers with a passphrase are not supported.
61+
// Use the following command to convert a PKCS 12 file into a PEM.
62+
//
63+
// $ openssl pkcs12 -in key.p12 -out key.pem -nodes
64+
//
65+
PrivateKey []byte
66+
67+
// KeyID contains an optional hint indicating which key is being
68+
// used.
69+
KeyID string
5070
}
5171

5272
// Token uses client credentials to retrieve a token.
@@ -91,6 +111,14 @@ func (c *tokenSource) Token() (*oauth2.Token, error) {
91111
v := url.Values{
92112
"grant_type": {"client_credentials"},
93113
}
114+
if c.conf.AuthStyle == oauth2.AuthStylePrivateKeyJWT {
115+
var err error
116+
v, err = c.jwtAssertionValues()
117+
if err != nil {
118+
return nil, err
119+
}
120+
121+
}
94122
if len(c.conf.Scopes) > 0 {
95123
v.Set("scope", strings.Join(c.conf.Scopes, " "))
96124
}

clientcredentials/clientcredentials_test.go

+136
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,136 @@ 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 := "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 != 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+
for _, conf := range []*Config{
192+
{
193+
ClientID: "CLIENT_ID",
194+
Scopes: strings.Split(scopes, " "),
195+
TokenURL: ts.URL + "/token",
196+
EndpointParams: url.Values{"audience": {audience}},
197+
AuthStyle: oauth2.AuthStylePrivateKeyJWT,
198+
PrivateKey: dummyPrivateKey,
199+
KeyID: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
200+
},
201+
{
202+
ClientID: "CLIENT_ID_set_jwt_expiration_time",
203+
Scopes: strings.Split(scopes, " "),
204+
TokenURL: ts.URL + "/token",
205+
EndpointParams: url.Values{"audience": {audience}},
206+
AuthStyle: oauth2.AuthStylePrivateKeyJWT,
207+
PrivateKey: dummyPrivateKey,
208+
KeyID: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
209+
JWTExpires: time.Minute,
210+
},
211+
} {
212+
t.Run(conf.ClientID, func(t *testing.T) {
213+
_, err := conf.TokenSource(context.Background()).Token()
214+
if err != nil {
215+
t.Fatalf("Failed to fetch token: %v", err)
216+
}
217+
parts := strings.Split(assertion, ".")
218+
if len(parts) != 3 {
219+
t.Fatalf("assertion = %q; want 3 parts", assertion)
220+
}
221+
gotJson, err := base64.RawURLEncoding.DecodeString(parts[1])
222+
if err != nil {
223+
t.Fatalf("invalid token payload; err = %v", err)
224+
}
225+
claimSet := jws.ClaimSet{}
226+
if err := json.Unmarshal(gotJson, &claimSet); err != nil {
227+
t.Errorf("failed to unmarshal json token payload = %q; err = %v", gotJson, err)
228+
}
229+
if got, want := claimSet.Iss, conf.ClientID; got != want {
230+
t.Errorf("payload iss = %q; want %q", got, want)
231+
}
232+
if claimSet.Jti == "" {
233+
t.Errorf("payload jti is empty")
234+
}
235+
expectedDuration := time.Hour
236+
if conf.JWTExpires > 0 {
237+
expectedDuration = conf.JWTExpires
238+
}
239+
if got, want := claimSet.Exp, time.Now().Add(expectedDuration).Unix(); got != want {
240+
t.Errorf("payload exp = %q; want %q", got, want)
241+
}
242+
if got, want := claimSet.Aud, conf.TokenURL; got != want {
243+
t.Errorf("payload aud = %q; want %q", got, want)
244+
}
245+
if got, want := claimSet.Sub, conf.ClientID; got != want {
246+
t.Errorf("payload sub = %q; want %q", got, want)
247+
}
248+
})
249+
}
250+
}
251+
116252
func TestTokenRefreshRequest(t *testing.T) {
117253
internal.ResetAuthCache()
118254
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

clientcredentials/jwt.go

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

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) {

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)