Skip to content

Commit 9fcabb0

Browse files
committed
Add example for federated gateway
Adds client credentials source, with caching for if a token is requested several times before expiry. GetFunction was returning FunctionDeployment, which is the input to deploy a function, not the output from a function's status. That meant there was no way to obtain the name, replicas etc. Tested with federated gateway authentication source. Add clock skew on expiry and lock for read/write Signed-off-by: Alex Ellis (OpenFaaS Ltd) <[email protected]>
1 parent 73f84ff commit 9fcabb0

File tree

7 files changed

+195
-21
lines changed

7 files changed

+195
-21
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/e2e_test.go

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,42 @@ auth := &sdk.TokenAuth{
7878
client := sdk.NewClient(gatewayURL, auth, http.DefaultClient)
7979
```
8080

81+
### Authentication with Federated Gateway
82+
83+
```go
84+
func Test_ClientCredentials(t *testing.T) {
85+
clientID := ""
86+
clientSecret := ""
87+
tokenURL := "https://keycloak.example.com/realms/openfaas/protocol/openid-connect/token"
88+
scope := "email"
89+
grantType := "client_credentials"
90+
91+
auth := NewClientCredentialsTokenSource(clientID, clientSecret, tokenURL, scope, grantType)
92+
93+
token, err := auth.Token()
94+
if err != nil {
95+
t.Fatal(err)
96+
}
97+
98+
if token == "" {
99+
t.Fatal("token is empty")
100+
}
101+
102+
u, _ := url.Parse("https://fed-gw.exit.welteki.dev")
103+
104+
client := NewClient(u, &ClientCredentialsAuth{tokenSource: auth}, http.DefaultClient)
105+
106+
fns, err := client.GetFunctions(context.Background(), "openfaas-fn")
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
111+
if len(fns) == 0 {
112+
t.Fatal("no functions found")
113+
}
114+
}
115+
```
116+
117+
## License
118+
81119
License: MIT

basic_auth.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package sdk
2+
3+
import (
4+
"net/http"
5+
)
6+
7+
// BasicAuth basic authentication for the the OpenFaaS client
8+
type BasicAuth struct {
9+
Username string
10+
Password string
11+
}
12+
13+
// Set Authorization Basic header on request
14+
func (auth *BasicAuth) Set(req *http.Request) error {
15+
req.SetBasicAuth(auth.Username, auth.Password)
16+
return nil
17+
}

client.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ func (s *Client) GetInfo(ctx context.Context) (SystemInfo, error) {
161161
}
162162

163163
// GetFunction gives a richer payload than GetFunctions, but for a specific function
164-
func (s *Client) GetFunction(ctx context.Context, name, namespace string) (types.FunctionDeployment, error) {
164+
func (s *Client) GetFunction(ctx context.Context, name, namespace string) (types.FunctionStatus, error) {
165165
u := s.GatewayURL
166166

167167
u.Path = "/system/function/" + name
@@ -174,18 +174,18 @@ func (s *Client) GetFunction(ctx context.Context, name, namespace string) (types
174174

175175
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
176176
if err != nil {
177-
return types.FunctionDeployment{}, fmt.Errorf("unable to create request for %s, error: %w", u.String(), err)
177+
return types.FunctionStatus{}, fmt.Errorf("unable to create request for %s, error: %w", u.String(), err)
178178
}
179179

180180
if s.ClientAuth != nil {
181181
if err := s.ClientAuth.Set(req); err != nil {
182-
return types.FunctionDeployment{}, fmt.Errorf("unable to set Authorization header: %w", err)
182+
return types.FunctionStatus{}, fmt.Errorf("unable to set Authorization header: %w", err)
183183
}
184184
}
185185

186186
res, err := s.Client.Do(req)
187187
if err != nil {
188-
return types.FunctionDeployment{}, fmt.Errorf("unable to make HTTP request: %w", err)
188+
return types.FunctionStatus{}, fmt.Errorf("unable to make HTTP request: %w", err)
189189
}
190190

191191
if res.Body != nil {
@@ -194,13 +194,13 @@ func (s *Client) GetFunction(ctx context.Context, name, namespace string) (types
194194

195195
body, _ := io.ReadAll(res.Body)
196196

197-
functions := types.FunctionDeployment{}
198-
if err := json.Unmarshal(body, &functions); err != nil {
199-
return types.FunctionDeployment{},
197+
function := types.FunctionStatus{}
198+
if err := json.Unmarshal(body, &function); err != nil {
199+
return types.FunctionStatus{},
200200
fmt.Errorf("unable to unmarshal value: %q, error: %w", string(body), err)
201201
}
202202

203-
return functions, nil
203+
return function, nil
204204
}
205205

206206
func (s *Client) Deploy(ctx context.Context, spec types.FunctionDeployment) (int, error) {

client_credentials_auth.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package sdk
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"sync"
11+
"time"
12+
)
13+
14+
type ClientCredentialsTokenSource struct {
15+
clientID string
16+
clientSecret string
17+
tokenURL string
18+
scope string
19+
grantType string
20+
token *ClientCredentialsToken
21+
lock sync.RWMutex
22+
}
23+
24+
type ClientCredentialsAuth struct {
25+
tokenSource *ClientCredentialsTokenSource
26+
}
27+
28+
func (cca *ClientCredentialsAuth) Set(req *http.Request) error {
29+
token, err := cca.tokenSource.Token()
30+
if err != nil {
31+
return err
32+
}
33+
req.Header.Add("Authorization", "Bearer "+token)
34+
return nil
35+
}
36+
37+
func NewClientCredentialsTokenSource(clientID, clientSecret, tokenURL, scope, grantType string) *ClientCredentialsTokenSource {
38+
return &ClientCredentialsTokenSource{
39+
clientID: clientID,
40+
clientSecret: clientSecret,
41+
tokenURL: tokenURL,
42+
scope: scope,
43+
grantType: grantType,
44+
}
45+
}
46+
47+
func (ts *ClientCredentialsTokenSource) Token() (string, error) {
48+
ts.lock.RLock()
49+
expired := ts.token == nil || ts.token.Expired()
50+
51+
if expired {
52+
ts.lock.RUnlock()
53+
54+
token, err := obtainClientCredentialsToken(ts.clientID, ts.clientSecret, ts.tokenURL, ts.scope, ts.grantType)
55+
if err != nil {
56+
return "", err
57+
}
58+
59+
ts.lock.Lock()
60+
ts.token = token
61+
ts.lock.Unlock()
62+
63+
return token.AccessToken, nil
64+
}
65+
66+
ts.lock.RUnlock()
67+
return ts.token.AccessToken, nil
68+
}
69+
70+
func obtainClientCredentialsToken(clientID, clientSecret, tokenURL, scope, grantType string) (*ClientCredentialsToken, error) {
71+
72+
reqBody := url.Values{}
73+
reqBody.Set("client_id", clientID)
74+
reqBody.Set("client_secret", clientSecret)
75+
reqBody.Set("grant_type", grantType)
76+
reqBody.Set("scope", scope)
77+
78+
buffer := []byte(reqBody.Encode())
79+
80+
req, err := http.NewRequest(http.MethodPost, tokenURL, bytes.NewBuffer(buffer))
81+
if err != nil {
82+
return nil, err
83+
}
84+
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
85+
86+
res, err := http.DefaultClient.Do(req)
87+
if err != nil {
88+
return nil, err
89+
}
90+
91+
var body []byte
92+
if res.Body != nil {
93+
defer res.Body.Close()
94+
body, _ = io.ReadAll(res.Body)
95+
}
96+
97+
if res.StatusCode != http.StatusOK {
98+
return nil, fmt.Errorf("unexpected status code %d, body: %s", res.StatusCode, string(body))
99+
}
100+
101+
token := &ClientCredentialsToken{}
102+
if err := json.Unmarshal(body, token); err != nil {
103+
return nil, fmt.Errorf("unable to unmarshal token: %s", err)
104+
}
105+
106+
token.ObtainedAt = time.Now()
107+
108+
return token, nil
109+
}
110+
111+
type ClientCredentialsToken struct {
112+
AccessToken string `json:"access_token"`
113+
TokenType string `json:"token_type"`
114+
ExpiresIn int `json:"expires_in"`
115+
ObtainedAt time.Time
116+
}
117+
118+
// Expired returns true if the token is expired
119+
// or if the expiry time is not known.
120+
// The token will always expire 10s early to avoid
121+
// clock skew.
122+
func (t *ClientCredentialsToken) Expired() bool {
123+
if t.ExpiresIn == 0 {
124+
return false
125+
}
126+
expiry := t.ObtainedAt.Add(time.Duration(t.ExpiresIn) * time.Second).Add(-expiryDelta)
127+
128+
return expiry.Before(time.Now())
129+
}

auth.go renamed to iam_auth.go

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,6 @@ import (
88
"sync"
99
)
1010

11-
// BasicAuth basic authentication for the the OpenFaaS client
12-
type BasicAuth struct {
13-
Username string
14-
Password string
15-
}
16-
17-
// Set Authorization Basic header on request
18-
func (auth *BasicAuth) Set(req *http.Request) error {
19-
req.SetBasicAuth(auth.Username, auth.Password)
20-
return nil
21-
}
22-
2311
// A TokenSource is anything that can return an OIDC ID token that can be exchanged for
2412
// an OpenFaaS token.
2513
type TokenSource interface {

token.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ type Token struct {
2727
Expiry time.Time
2828
}
2929

30-
// Expired reports whether the token is expired.
30+
// Expired reports whether the token is expired, and will start
31+
// to return false 10s before the actual expiration time.
3132
func (t *Token) Expired() bool {
3233
if t.Expiry.IsZero() {
3334
return false

0 commit comments

Comments
 (0)