Skip to content

Commit dc7157e

Browse files
committed
feat: Support assertion based auth
This PR brings in support for authentication:- - Using JWT Bearer token instead of user/password clientid/client-secret flow - Using Client Assertion Token instead of client Secret This is primarly used with the new JWT Bearer - Based on RFC-7523 - Feature is supported from UAA 76.23.0 & cf-cli v8.12.0 fixes: #474
1 parent 41fbb9e commit dc7157e

File tree

7 files changed

+366
-7
lines changed

7 files changed

+366
-7
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,21 @@ Client and client secret:
5757
cfg, _ := config.New("https://api.example.org", config.ClientCredentials("cf", "secret"))
5858
cf, _ := client.New(cfg)
5959
```
60+
Client and client assertion:
61+
```go
62+
cfg, _ := config.New("https://api.example.org", config.ClientCredentials("cf",""),config.ClientAssertion("client-assertion-token"))
63+
cf, _ := client.New(cfg)
64+
```
6065
Static OAuth token, which requires both an access and refresh token:
6166
```go
6267
cfg, _ := config.New("https://api.example.org", config.Token(accessToken, refreshToken))
6368
cf, _ := client.New(cfg)
6469
```
70+
Using JWT Bearer Assertion Grant
71+
```go
72+
cfg, _ := config.New("https://api.example.org",config.JWTBearerAssertion("jwt-assertion-token"))
73+
cf, _ := client.New(cfg)
74+
```
6575
For more detailed examples of using the various authentication and configuration options, see the
6676
[auth example](./examples/auth/main.go).
6777

config/config.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ const (
2525
GrantTypeRefreshToken = "refresh_token"
2626
GrantTypeClientCredentials = "client_credentials"
2727
GrantTypePassword = "password"
28-
29-
DefaultRequestTimeout = 30 * time.Second
30-
DefaultUserAgent = "Go-CF-Client/3.0"
31-
DefaultClientID = "cf"
32-
DefaultSSHClientID = "ssh-proxy"
28+
GrantTypeJwtBearer = "jwt_bearer"
29+
DefaultRequestTimeout = 30 * time.Second
30+
DefaultUserAgent = "Go-CF-Client/3.0"
31+
DefaultClientID = "cf"
32+
DefaultSSHClientID = "ssh-proxy"
3333
)
3434

3535
var ErrConfigInvalid = errors.New("configuration is invalid")
@@ -48,6 +48,8 @@ type Config struct {
4848
grantType string
4949
origin string
5050
scopes []string
51+
assertion string
52+
clientAssertion string
5153
oAuthToken *oauth2.Token
5254
httpClient *http.Client
5355
httpAuthClient *http.Client
@@ -170,6 +172,21 @@ func (c *Config) CreateOAuth2TokenSource(ctx context.Context) (oauth2.TokenSourc
170172
case GrantTypeRefreshToken:
171173
authConfig := threeLeggedAuthConfigFn()
172174
tokenSource = authConfig.TokenSource(oauthCtx, c.oAuthToken)
175+
case GrantTypeJwtBearer:
176+
var uaaEndpointURL string
177+
if c.origin != "" {
178+
// Add optional login hint to the token URL
179+
uaaEndpointURL = addLoginHintToURL(c.uaaEndpointURL+"/oauth/token", c.origin)
180+
}
181+
tokenSource = &jwt.JWTAssertionTokenSource{
182+
Assertion: c.assertion,
183+
ClientAssertion: c.clientAssertion,
184+
TokenURL: uaaEndpointURL,
185+
ClientID: c.clientID,
186+
ClientSecret: c.clientSecret,
187+
HTTPClient: c.httpClient,
188+
Scopes: c.scopes,
189+
}
173190
default:
174191
return nil, fmt.Errorf("unsupported OAuth2 grant type '%s'", c.grantType)
175192
}
@@ -252,7 +269,9 @@ func setGrantType(c *Config) error {
252269
switch {
253270
case c.username != "" && c.password != "":
254271
c.grantType = GrantTypePassword
255-
case c.clientID != "" && c.clientSecret != "":
272+
case c.assertion != "":
273+
c.grantType = GrantTypeJwtBearer
274+
case c.clientID != "" && (c.clientSecret != "" || c.clientAssertion != ""):
256275
c.grantType = GrantTypeClientCredentials
257276
case c.oAuthToken != nil:
258277
c.grantType = GrantTypeRefreshToken

config/config_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212

1313
const accessToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6InRlc3QgY2YgdG9rZW4iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUxNjIzOTAyMn0.mLvUvu-ED_lIkyI3UTXS_hUEPPFdI0BdNqRMgMThAhk"
1414
const refreshToken = "secret-refresh-token"
15+
const clientAssertion = "client-assertion-token"
16+
const jwtAssertion = "jwt-assertion-token"
1517

1618
func TestInvalidConfig(t *testing.T) {
1719
c := &Config{}
@@ -112,6 +114,17 @@ func TestClientCredentials(t *testing.T) {
112114
require.Equal(t, refreshToken, c.oAuthToken.RefreshToken)
113115
require.Equal(t, GrantTypeClientCredentials, c.grantType)
114116
})
117+
118+
t.Run("with clientID and client assertion", func(t *testing.T) {
119+
c, err := New("https://api.example.com",
120+
ClientCredentials("clientID", ""),
121+
ClientAssertion(clientAssertion),
122+
AuthTokenURL("https://login.cf.example.com", "https://token.cf.example.com")) // skip service discovery
123+
require.NoError(t, err)
124+
require.Equal(t, "clientID", c.clientID)
125+
require.Equal(t, clientAssertion, c.clientAssertion)
126+
require.Equal(t, GrantTypeClientCredentials, c.grantType)
127+
})
115128
}
116129

117130
func TestToken(t *testing.T) {
@@ -224,3 +237,14 @@ func TestNewConfigFromCFHomeDir(t *testing.T) {
224237
require.Equal(t, GrantTypePassword, cfg.grantType)
225238
})
226239
}
240+
241+
func TestJwBearerAssertion(t *testing.T) {
242+
t.Run("minimalistic setup", func(t *testing.T) {
243+
cfg, err := New("https://api.example.com",
244+
JWTBearerAssertion(jwtAssertion),
245+
AuthTokenURL("https://login.cf.example.com", "https://token.cf.example.com")) // skip service discovery
246+
require.NoError(t, err)
247+
require.Equal(t, jwtAssertion, cfg.assertion)
248+
require.Equal(t, GrantTypeJwtBearer, cfg.grantType)
249+
})
250+
}

config/options.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,24 @@ func ClientCredentials(clientID, clientSecret string) Option {
3131
}
3232
}
3333

34+
// ClientAssertion is a functional option to set client assertion.
35+
func ClientAssertion(assertion string) Option {
36+
return func(c *Config) error {
37+
// if set, must be a valid JWT token
38+
// alternative to client secret
39+
// usually used with ClientCredentials
40+
// can be combined with JWTBearerAssertion
41+
// refer RFC 7523 for more details
42+
if assertion = strings.TrimSpace(assertion); assertion == "" {
43+
return errors.New("assertion must be valid JWT Token")
44+
} else {
45+
c.clientAssertion = assertion
46+
}
47+
48+
return nil
49+
}
50+
}
51+
3452
// UserPassword is a functional option to set user credentials.
3553
func UserPassword(username, password string) Option {
3654
return func(c *Config) error {
@@ -45,6 +63,24 @@ func UserPassword(username, password string) Option {
4563
}
4664
}
4765

66+
// JWTBearerAssertion is a functional option to set JWT Bearer credentials.
67+
func JWTBearerAssertion(assertion string) Option {
68+
return func(c *Config) error {
69+
// if set, must be a valid JWT token
70+
// can be used alone
71+
// or with ClientCredentials
72+
// or with ClientCredentials + ClientAssertion
73+
// refer RFC 7523 for more details
74+
if assertion = strings.TrimSpace(assertion); assertion == "" {
75+
return errors.New("assertion is required for JWT Bearer grant type")
76+
} else {
77+
c.assertion = assertion
78+
}
79+
80+
return nil
81+
}
82+
}
83+
4884
// Token is a functional option to set the access and refresh tokens.
4985
func Token(accessToken, refreshToken string) Option {
5086
return func(c *Config) error {

examples/auth/main.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const clientID = "cf"
2020
const clientSecret = "secret"
2121
const accessToken = "<access-token>"
2222
const refreshToken = "<refresh-token>"
23+
const jwtAssertion = "<jwt-assertion>"
24+
const clientAssertion = "<client-assertion>"
25+
const origin = "<origin>"
2326

2427
func main() {
2528
err := execute()
@@ -77,6 +80,32 @@ func execute() error {
7780
return err
7881
}
7982

83+
// use the hardcoded CF API endpoint and JWT Bearer Assertion token and optional origin
84+
// minimally requires the assertion
85+
cfg, err = config.New(apiURL,
86+
config.JWTBearerAssertion(jwtAssertion),
87+
config.Origin(origin))
88+
if err != nil {
89+
return err
90+
}
91+
err = listOrganizationsWithConfig(cfg)
92+
if err != nil {
93+
return err
94+
}
95+
96+
// use the hardcoded CF API endpoint and JWT Bearer Assertion token and optional origin
97+
// minimally requires the assertion
98+
cfg, err = config.New(apiURL,
99+
config.ClientCredentials(clientID, ""),
100+
config.ClientAssertion(clientAssertion))
101+
if err != nil {
102+
return err
103+
}
104+
err = listOrganizationsWithConfig(cfg)
105+
if err != nil {
106+
return err
107+
}
108+
80109
// Unnecessarily use all config options
81110
cfg, err = config.New(apiURL,
82111
config.UserPassword(username, password),

internal/jwt/token.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,36 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"io"
9+
"log"
10+
"net/http"
11+
"net/url"
812
"strings"
913
"time"
1014

1115
"golang.org/x/oauth2"
1216
)
1317

18+
const (
19+
grantTypeJwtBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"
20+
clientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
21+
)
22+
1423
type tokenPayload struct {
1524
Expiration int64 `json:"exp"`
1625
}
1726

27+
type JWTAssertionTokenSource struct { // revive:disable-line:exported
28+
Assertion string
29+
ClientAssertion string
30+
ClientID string
31+
ClientSecret string
32+
GrantType string
33+
Scopes []string
34+
TokenURL string
35+
HTTPClient *http.Client
36+
}
37+
1838
func AccessTokenExpiration(accessToken string) (time.Time, error) {
1939
tp := strings.Split(accessToken, ".")
2040
if len(tp) != 3 {
@@ -64,3 +84,107 @@ func ToOAuth2Token(accessToken, refreshToken string) (*oauth2.Token, error) {
6484
}
6585
return oAuthToken, nil
6686
}
87+
88+
func (s *JWTAssertionTokenSource) Token() (*oauth2.Token, error) {
89+
data := url.Values{}
90+
91+
if s.TokenURL == "" {
92+
return nil, fmt.Errorf("token URL is required")
93+
}
94+
if s.GrantType == "" {
95+
data.Set("grant_type", grantTypeJwtBearer)
96+
} else {
97+
data.Set("grant_type", s.GrantType)
98+
}
99+
// Assertion is required for JWT Bearer grant type
100+
if s.Assertion == "" && (s.GrantType == grantTypeJwtBearer || s.GrantType == "") {
101+
return nil, fmt.Errorf("assertion is required for JWT Bearer grant type")
102+
}
103+
104+
if s.Assertion != "" {
105+
if err := validateJWTTokenFormat(s.Assertion); err != nil {
106+
return nil, err
107+
}
108+
data.Set("assertion", s.Assertion)
109+
}
110+
111+
// Optional client_id
112+
if s.ClientID != "" {
113+
data.Set("client_id", s.ClientID)
114+
}
115+
116+
// Optional client_secret
117+
if s.ClientSecret != "" {
118+
if s.ClientID == "" {
119+
return nil, fmt.Errorf("client_id is required when using client secret")
120+
}
121+
data.Set("client_secret", s.ClientSecret)
122+
}
123+
124+
// Optional client_assertion
125+
if s.ClientAssertion != "" {
126+
if s.ClientID == "" {
127+
return nil, fmt.Errorf("client_id is required when using client assertion")
128+
}
129+
if err := validateJWTTokenFormat(s.ClientAssertion); err != nil {
130+
return nil, err
131+
}
132+
data.Set("client_assertion_type", clientAssertionType)
133+
data.Set("client_assertion", s.ClientAssertion)
134+
}
135+
if len(s.Scopes) > 0 {
136+
data.Set("scope", strings.Join(s.Scopes, " "))
137+
}
138+
139+
req, err := http.NewRequest("POST", s.TokenURL, strings.NewReader(data.Encode()))
140+
if err != nil {
141+
return nil, fmt.Errorf("token request object creation failed: %w", err)
142+
}
143+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
144+
145+
client := s.HTTPClient
146+
if client == nil {
147+
client = http.DefaultClient
148+
}
149+
150+
resp, err := client.Do(req)
151+
if err != nil {
152+
return nil, fmt.Errorf("token request failed: %w", err)
153+
}
154+
defer func() {
155+
if err := resp.Body.Close(); err != nil {
156+
log.Printf("failed to close response body: %v", err)
157+
}
158+
}()
159+
160+
body, _ := io.ReadAll(resp.Body)
161+
if resp.StatusCode != http.StatusOK {
162+
return nil, fmt.Errorf("token request failed: %s", body)
163+
}
164+
165+
var token oauth2.Token
166+
err = json.Unmarshal(body, &token)
167+
if err != nil {
168+
return nil, fmt.Errorf("token unmarshal error: %w", err)
169+
}
170+
return &token, nil
171+
}
172+
173+
// validateJWTTokenFormat checks if the provided JWT token has a valid format.
174+
func validateJWTTokenFormat(token string) error {
175+
parts := strings.Split(token, ".")
176+
if len(parts) != 3 {
177+
return errors.New("token must have three parts separated by '.'")
178+
}
179+
180+
for i, part := range parts {
181+
if part == "" {
182+
return fmt.Errorf("token part is empty")
183+
}
184+
if _, err := base64.RawURLEncoding.DecodeString(part); err != nil {
185+
return fmt.Errorf("invalid base64 encoding in part %d", i+1)
186+
}
187+
}
188+
189+
return nil
190+
}

0 commit comments

Comments
 (0)