Skip to content

Commit e4c4907

Browse files
author
Lilit Smotrova
committed
Add support private_key_jwt authentication type in client credentials flow
It is implementation of rfc7523 JWT Profile for client authentication. See https://tools.ietf.org/html/rfc7523 See https://openid.net/specs/openid-connect-core-1_0.html Fixes golang#433
1 parent 5d25da1 commit e4c4907

File tree

5 files changed

+243
-0
lines changed

5 files changed

+243
-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.
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

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

clientcredentials/jwt.go

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

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)