Skip to content

Commit 203dcf1

Browse files
authored
feat(oauth2): add support for issuer URL override in client credentials flow (#1463)
* feat(oauth2): add support for issuer URL override in client credentials flow * fix(oauth2): handle nil options in client credentials flow * test(oauth2): add tests for DefaultGrantProvider behavior
1 parent cc18590 commit 203dcf1

File tree

6 files changed

+225
-5
lines changed

6 files changed

+225
-5
lines changed

oauth2/client_credentials_flow.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type GrantProvider interface {
5353

5454
type ClientCredentialsFlowOptions struct {
5555
KeyFile string
56+
IssuerURL string
5657
AdditionalScopes []string
5758
}
5859

@@ -64,13 +65,24 @@ type DefaultGrantProvider struct {
6465
// merging the scopes from both the options and the key file configuration
6566
func (p *DefaultGrantProvider) GetGrant(audience string, options *ClientCredentialsFlowOptions) (
6667
*AuthorizationGrant, error) {
68+
if options == nil {
69+
return nil, errors.New("client credentials flow options cannot be nil")
70+
}
6771
credsProvider := NewClientCredentialsProviderFromKeyFile(options.KeyFile)
6872
keyFile, err := credsProvider.GetClientCredentials()
6973
if err != nil {
7074
return nil, errors.Wrap(err, "could not get client credentials")
7175
}
7276

73-
wellKnownEndpoints, err := GetOIDCWellKnownEndpointsFromIssuerURL(keyFile.IssuerURL)
77+
issuerURL := options.IssuerURL
78+
if issuerURL == "" {
79+
issuerURL = keyFile.IssuerURL
80+
}
81+
if issuerURL == "" {
82+
return nil, errors.New("issuer url is required for client credentials flow")
83+
}
84+
85+
wellKnownEndpoints, err := GetOIDCWellKnownEndpointsFromIssuerURL(issuerURL)
7486
if err != nil {
7587
return nil, err
7688
}

oauth2/client_credentials_flow_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ package oauth2
1919

2020
import (
2121
"errors"
22+
"fmt"
23+
"net/http"
24+
"net/http/httptest"
25+
"os"
2226
"time"
2327

2428
"github.com/apache/pulsar-client-go/oauth2/clock"
@@ -49,6 +53,57 @@ var clientCredentials = KeyFile{
4953
Scope: "test_scope",
5054
}
5155

56+
func mockWellKnownServer(tokenEndpoint string) *httptest.Server {
57+
handler := http.NewServeMux()
58+
handler.HandleFunc("/.well-known/openid-configuration", func(writer http.ResponseWriter, _ *http.Request) {
59+
fmt.Fprintf(writer, "{\n \"token_endpoint\": \"%s\"\n}\n", tokenEndpoint)
60+
})
61+
return httptest.NewServer(handler)
62+
}
63+
64+
func mockKeyFileWithIssuer(issuerURL string) (string, error) {
65+
kf, err := os.CreateTemp("", "test_oauth2")
66+
if err != nil {
67+
return "", err
68+
}
69+
_, err = kf.WriteString(fmt.Sprintf(`{
70+
"type":"resource",
71+
"client_id":"client-id",
72+
"client_secret":"client-secret",
73+
"client_email":"oauth@test.org",
74+
"issuer_url":"%s"
75+
}`, issuerURL))
76+
if err != nil {
77+
_ = kf.Close()
78+
return "", err
79+
}
80+
if err := kf.Close(); err != nil {
81+
return "", err
82+
}
83+
return kf.Name(), nil
84+
}
85+
86+
func mockKeyFileWithoutIssuer() (string, error) {
87+
kf, err := os.CreateTemp("", "test_oauth2")
88+
if err != nil {
89+
return "", err
90+
}
91+
_, err = kf.WriteString(`{
92+
"type":"resource",
93+
"client_id":"client-id",
94+
"client_secret":"client-secret",
95+
"client_email":"oauth@test.org"
96+
}`)
97+
if err != nil {
98+
_ = kf.Close()
99+
return "", err
100+
}
101+
if err := kf.Close(); err != nil {
102+
return "", err
103+
}
104+
return kf.Name(), nil
105+
}
106+
52107
var _ = ginkgo.Describe("ClientCredentialsFlow", func() {
53108
ginkgo.Describe("Authorize", func() {
54109

@@ -124,6 +179,42 @@ var _ = ginkgo.Describe("ClientCredentialsFlow", func() {
124179
})
125180
})
126181

182+
var _ = ginkgo.Describe("DefaultGrantProvider", func() {
183+
ginkgo.It("prefers issuer url from options over key file", func() {
184+
keyFileTokenEndpoint := "http://keyfile.example/token"
185+
optionsTokenEndpoint := "http://options.example/token"
186+
serverFromKeyFile := mockWellKnownServer(keyFileTokenEndpoint)
187+
defer serverFromKeyFile.Close()
188+
serverFromOptions := mockWellKnownServer(optionsTokenEndpoint)
189+
defer serverFromOptions.Close()
190+
191+
keyFile, err := mockKeyFileWithIssuer(serverFromKeyFile.URL)
192+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
193+
defer os.Remove(keyFile)
194+
195+
provider := DefaultGrantProvider{}
196+
grant, err := provider.GetGrant("test-audience", &ClientCredentialsFlowOptions{
197+
KeyFile: keyFile,
198+
IssuerURL: serverFromOptions.URL,
199+
})
200+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
201+
gomega.Expect(grant.TokenEndpoint).To(gomega.Equal(optionsTokenEndpoint))
202+
})
203+
204+
ginkgo.It("returns an error when issuer url is missing", func() {
205+
keyFile, err := mockKeyFileWithoutIssuer()
206+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
207+
defer os.Remove(keyFile)
208+
209+
provider := DefaultGrantProvider{}
210+
_, err = provider.GetGrant("test-audience", &ClientCredentialsFlowOptions{
211+
KeyFile: keyFile,
212+
})
213+
gomega.Expect(err).To(gomega.HaveOccurred())
214+
gomega.Expect(err.Error()).To(gomega.Equal("issuer url is required for client credentials flow"))
215+
})
216+
})
217+
127218
var _ = ginkgo.Describe("ClientCredentialsGrantRefresher", func() {
128219

129220
ginkgo.Describe("Refresh", func() {

pulsar/auth/oauth2.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func NewAuthenticationOAuth2WithParams(params map[string]string) (Provider, erro
6161
case ConfigParamTypeClientCredentials:
6262
flow, err := oauth2.NewDefaultClientCredentialsFlow(oauth2.ClientCredentialsFlowOptions{
6363
KeyFile: params[ConfigParamKeyFile],
64+
IssuerURL: params[ConfigParamIssuerURL],
6465
AdditionalScopes: strings.Split(params[ConfigParamScope], " "),
6566
})
6667
if err != nil {

pulsar/auth/oauth2_test.go

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ var expectedClientSecret atomic.Value
3636

3737
// mockOAuthServer will mock a oauth service for the tests
3838
func mockOAuthServer() *httptest.Server {
39+
return mockOAuthServerWithToken("token-content")
40+
}
41+
42+
// mockOAuthServerWithToken will mock a oauth service for the tests with a custom token.
43+
func mockOAuthServerWithToken(token string) *httptest.Server {
3944
// prepare a port for the mocked server
4045
server := httptest.NewUnstartedServer(http.DefaultServeMux)
4146

@@ -61,7 +66,7 @@ func mockOAuthServer() *httptest.Server {
6166
http.Error(writer, "invalid client credentials", http.StatusUnauthorized)
6267
return
6368
}
64-
fmt.Fprintln(writer, "{\n \"access_token\": \"token-content\",\n \"token_type\": \"Bearer\"\n}")
69+
fmt.Fprintf(writer, "{\n \"access_token\": \"%s\",\n \"token_type\": \"Bearer\"\n}\n", token)
6570
})
6671
mockedHandler.HandleFunc("/authorize", func(writer http.ResponseWriter, _ *http.Request) {
6772
fmt.Fprintln(writer, "true")
@@ -98,6 +103,29 @@ func mockKeyFile(server string) (string, error) {
98103
return kf.Name(), nil
99104
}
100105

106+
func mockKeyFileWithoutIssuer() (string, error) {
107+
pwd, err := os.Getwd()
108+
if err != nil {
109+
return "", err
110+
}
111+
kf, err := os.CreateTemp(pwd, "test_oauth2")
112+
if err != nil {
113+
return "", err
114+
}
115+
_, err = kf.WriteString(`{
116+
"type":"resource",
117+
"client_id":"client-id",
118+
"client_secret":"client-secret",
119+
"client_email":"oauth@test.org",
120+
"scope": "test-scope"
121+
}`)
122+
if err != nil {
123+
return "", err
124+
}
125+
126+
return kf.Name(), nil
127+
}
128+
101129
func TestNewAuthenticationOAuth2WithParams(t *testing.T) {
102130
server := mockOAuthServer()
103131
defer server.Close()
@@ -162,6 +190,60 @@ func TestNewAuthenticationOAuth2WithParams(t *testing.T) {
162190
}
163191
}
164192

193+
func TestOAuth2IssuerOverrideUsesAuthParams(t *testing.T) {
194+
expectedClientID.Store("client-id")
195+
expectedClientSecret.Store("client-secret")
196+
serverFromKeyFile := mockOAuthServerWithToken("token-from-keyfile")
197+
defer serverFromKeyFile.Close()
198+
serverFromParams := mockOAuthServerWithToken("token-from-params")
199+
defer serverFromParams.Close()
200+
201+
kf, err := mockKeyFile(serverFromKeyFile.URL)
202+
defer os.Remove(kf)
203+
require.NoError(t, err)
204+
205+
params := map[string]string{
206+
ConfigParamType: ConfigParamTypeClientCredentials,
207+
ConfigParamIssuerURL: serverFromParams.URL,
208+
ConfigParamClientID: "client-id",
209+
ConfigParamAudience: "audience",
210+
ConfigParamKeyFile: kf,
211+
ConfigParamScope: "profile",
212+
}
213+
214+
auth, err := NewAuthenticationOAuth2WithParams(params)
215+
require.NoError(t, err)
216+
require.NoError(t, auth.Init())
217+
218+
token, err := auth.GetData()
219+
require.NoError(t, err)
220+
assert.Equal(t, "token-from-params", string(token))
221+
}
222+
223+
func TestOAuth2MissingIssuerReturnsError(t *testing.T) {
224+
expectedClientID.Store("client-id")
225+
expectedClientSecret.Store("client-secret")
226+
kf, err := mockKeyFileWithoutIssuer()
227+
defer os.Remove(kf)
228+
require.NoError(t, err)
229+
230+
params := map[string]string{
231+
ConfigParamType: ConfigParamTypeClientCredentials,
232+
ConfigParamClientID: "client-id",
233+
ConfigParamAudience: "audience",
234+
ConfigParamKeyFile: kf,
235+
ConfigParamScope: "profile",
236+
}
237+
238+
auth, err := NewAuthenticationOAuth2WithParams(params)
239+
require.NoError(t, err)
240+
require.NoError(t, auth.Init())
241+
242+
_, err = auth.GetData()
243+
require.Error(t, err)
244+
assert.Contains(t, err.Error(), "issuer url is required for client credentials flow")
245+
}
246+
165247
func TestOAuth2KeyFileReloading(t *testing.T) {
166248
server := mockOAuthServer()
167249
defer server.Close()

pulsaradmin/pkg/admin/auth/oauth2.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ type OAuth2Provider struct {
5252
// NewAuthenticationOAuth2WithDefaultFlow uses memory to save the grant
5353
func NewAuthenticationOAuth2WithDefaultFlow(issuer oauth2.Issuer, keyFile string) (Provider, error) {
5454
return NewAuthenticationOAuth2WithFlow(issuer, oauth2.ClientCredentialsFlowOptions{
55-
KeyFile: keyFile,
55+
KeyFile: keyFile,
56+
IssuerURL: issuer.IssuerEndpoint,
5657
})
5758
}
5859

@@ -97,7 +98,10 @@ func NewAuthenticationOAuth2WithParams(
9798
Audience: audience,
9899
}
99100

100-
flow, err := oauth2.NewDefaultClientCredentialsFlow(oauth2.ClientCredentialsFlowOptions{KeyFile: privateKey})
101+
flow, err := oauth2.NewDefaultClientCredentialsFlow(oauth2.ClientCredentialsFlowOptions{
102+
KeyFile: privateKey,
103+
IssuerURL: issuerEndpoint,
104+
})
101105
if err != nil {
102106
return nil, err
103107
}

pulsaradmin/pkg/admin/auth/oauth2_test.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,16 @@ import (
2727
"github.com/apache/pulsar-client-go/oauth2"
2828
"github.com/pkg/errors"
2929
"github.com/stretchr/testify/assert"
30+
"github.com/stretchr/testify/require"
3031
)
3132

3233
// mockOAuthServer will mock a oauth service for the tests
3334
func mockOAuthServer() *httptest.Server {
35+
return mockOAuthServerWithToken("token-content")
36+
}
37+
38+
// mockOAuthServerWithToken will mock a oauth service for the tests with a custom token.
39+
func mockOAuthServerWithToken(token string) *httptest.Server {
3440
// prepare a port for the mocked server
3541
server := httptest.NewUnstartedServer(http.DefaultServeMux)
3642

@@ -46,7 +52,7 @@ func mockOAuthServer() *httptest.Server {
4652
fmt.Fprintln(writer, s)
4753
})
4854
mockedHandler.HandleFunc("/oauth/token", func(writer http.ResponseWriter, _ *http.Request) {
49-
fmt.Fprintln(writer, "{\n \"access_token\": \"token-content\",\n \"token_type\": \"Bearer\"\n}")
55+
fmt.Fprintf(writer, "{\n \"access_token\": \"%s\",\n \"token_type\": \"Bearer\"\n}\n", token)
5056
})
5157
mockedHandler.HandleFunc("/authorize", func(writer http.ResponseWriter, _ *http.Request) {
5258
fmt.Fprintln(writer, "true")
@@ -115,3 +121,27 @@ func TestOauth2(t *testing.T) {
115121
}
116122
assert.Equal(t, "token-content", token.AccessToken)
117123
}
124+
125+
func TestOAuth2IssuerOverrideUsesAuthParams(t *testing.T) {
126+
serverFromKeyFile := mockOAuthServerWithToken("token-from-keyfile")
127+
defer serverFromKeyFile.Close()
128+
serverFromParams := mockOAuthServerWithToken("token-from-params")
129+
defer serverFromParams.Close()
130+
131+
kf, err := mockKeyFile(serverFromKeyFile.URL)
132+
defer os.Remove(kf)
133+
require.NoError(t, err)
134+
135+
provider, err := NewAuthenticationOAuth2WithParams(
136+
serverFromParams.URL,
137+
"client-id",
138+
serverFromParams.URL,
139+
kf,
140+
http.DefaultTransport,
141+
)
142+
require.NoError(t, err)
143+
144+
token, err := provider.source.Token()
145+
require.NoError(t, err)
146+
assert.Equal(t, "token-from-params", token.AccessToken)
147+
}

0 commit comments

Comments
 (0)