Skip to content

Commit 7b46ef7

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 7b46ef7

File tree

5 files changed

+255
-0
lines changed

5 files changed

+255
-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

+145
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,20 @@ package clientcredentials
66

77
import (
88
"context"
9+
"encoding/base64"
10+
"encoding/json"
911
"io"
1012
"io/ioutil"
13+
"math"
1114
"net/http"
1215
"net/http/httptest"
1316
"net/url"
17+
"strings"
1418
"testing"
19+
"time"
20+
21+
"golang.org/x/oauth2"
22+
"golang.org/x/oauth2/jws"
1523
)
1624

1725
func newConf(serverURL string) *Config {
@@ -111,6 +119,143 @@ func TestTokenRequest(t *testing.T) {
111119
}
112120
}
113121

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+
240+
if got, want := claimSet.Exp, time.Now().Add(expectedDuration).Unix(); got != want {
241+
t.Errorf("payload exp = %q; want %q", got, want)
242+
}
243+
244+
errorMarginInSeconds := 5.0
245+
if got, want := claimSet.Exp, time.Now().Add(expectedDuration).Unix(); math.Abs(float64(got-want)) > errorMarginInSeconds {
246+
t.Errorf("payload exp is not within the acceptable range: got %q, want around %q", got, want)
247+
}
248+
249+
if got, want := claimSet.Aud, conf.TokenURL; got != want {
250+
t.Errorf("payload aud = %q; want %q", got, want)
251+
}
252+
if got, want := claimSet.Sub, conf.ClientID; got != want {
253+
t.Errorf("payload sub = %q; want %q", got, want)
254+
}
255+
})
256+
}
257+
}
258+
114259
func TestTokenRefreshRequest(t *testing.T) {
115260
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116261
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)