Skip to content

Commit 1971a7d

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 e067960 commit 1971a7d

File tree

5 files changed

+247
-0
lines changed

5 files changed

+247
-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,11 +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
5053

5154
// authStyleCache caches which auth style to use when Endpoint.AuthStyle is
5255
// the zero value (AuthStyleAutoDetect).
5356
authStyleCache internal.LazyAuthStyleCache
57+
58+
// JWTExpires optionally specifies how long the jwt token is valid for.
59+
JWTExpires time.Duration
60+
61+
// PrivateKey contains the contents of an RSA private key or the
62+
// contents of a PEM file that contains a private key. The provided
63+
// private key is used to sign JWT payloads.
64+
// PEM containers with a passphrase are not supported.
65+
// Use the following command to convert a PKCS 12 file into a PEM.
66+
//
67+
// $ openssl pkcs12 -in key.p12 -out key.pem -nodes
68+
//
69+
PrivateKey []byte
70+
71+
// KeyID contains an optional hint indicating which key is being
72+
// used.
73+
KeyID string
5474
}
5575

5676
// Token uses client credentials to retrieve a token.
@@ -95,6 +115,14 @@ func (c *tokenSource) Token() (*oauth2.Token, error) {
95115
v := url.Values{
96116
"grant_type": {"client_credentials"},
97117
}
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+
}
98126
if len(c.conf.Scopes) > 0 {
99127
v.Set("scope", strings.Join(c.conf.Scopes, " "))
100128
}

clientcredentials/clientcredentials_test.go

+137
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@ 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"
19+
20+
"golang.org/x/oauth2"
21+
"golang.org/x/oauth2/jws"
1522
)
1623

1724
func newConf(serverURL string) *Config {
@@ -111,6 +118,136 @@ func TestTokenRequest(t *testing.T) {
111118
}
112119
}
113120

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

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
@@ -103,6 +103,11 @@ const (
103103
// using HTTP Basic Authorization. This is an optional style
104104
// described in the OAuth2 RFC 6749 section 2.3.1.
105105
AuthStyleInHeader AuthStyle = 2
106+
107+
// AuthStylePrivateKeyJWT send jwt token signed by private key.
108+
// See https://openid.net/specs/openid-connect-core-1_0.html.
109+
// See https://tools.ietf.org/html/rfc7523.
110+
AuthStylePrivateKeyJWT AuthStyle = 3
106111
)
107112

108113
var (

0 commit comments

Comments
 (0)