From 9797ec07f81dc36922c60b3d49bd847e94612a32 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Wed, 22 May 2024 12:50:45 +0200 Subject: [PATCH 01/14] Don't modify the client gateway url struct in client methods Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- client.go | 41 ++++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/client.go b/client.go index 88ab9f2..ef21fa2 100644 --- a/client.go +++ b/client.go @@ -84,8 +84,9 @@ func NewClient(gatewayURL *url.URL, auth ClientAuth, client *http.Client) *Clien // GetNamespaces get openfaas namespaces func (s *Client) GetNamespaces(ctx context.Context) ([]string, error) { - u := s.GatewayURL namespaces := []string{} + + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/namespaces" req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) @@ -130,7 +131,7 @@ func (s *Client) GetNamespaces(ctx context.Context) ([]string, error) { // GetNamespaces get openfaas namespaces func (s *Client) GetNamespace(ctx context.Context, namespace string) (types.FunctionNamespace, error) { - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = fmt.Sprintf("/system/namespace/%s", namespace) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) @@ -196,7 +197,7 @@ func (s *Client) CreateNamespace(ctx context.Context, spec types.FunctionNamespa bodyReader := bytes.NewReader(bodyBytes) - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/namespace/" req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bodyReader) @@ -255,7 +256,7 @@ func (s *Client) UpdateNamespace(ctx context.Context, spec types.FunctionNamespa bodyReader := bytes.NewReader(bodyBytes) - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = fmt.Sprintf("/system/namespace/%s", spec.Name) req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), bodyReader) @@ -310,7 +311,7 @@ func (s *Client) DeleteNamespace(ctx context.Context, namespace string) error { bodyBytes, _ := json.Marshal(delReq) bodyReader := bytes.NewReader(bodyBytes) - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = fmt.Sprintf("/system/namespace/%s", namespace) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), bodyReader) @@ -357,8 +358,7 @@ func (s *Client) DeleteNamespace(ctx context.Context, namespace string) error { // GetFunctions lists all functions func (s *Client) GetFunctions(ctx context.Context, namespace string) ([]types.FunctionStatus, error) { - u := s.GatewayURL - + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/functions" if len(namespace) > 0 { @@ -399,8 +399,7 @@ func (s *Client) GetFunctions(ctx context.Context, namespace string) ([]types.Fu } func (s *Client) GetInfo(ctx context.Context) (SystemInfo, error) { - u := s.GatewayURL - + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/info" req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) @@ -436,8 +435,7 @@ func (s *Client) GetInfo(ctx context.Context) (SystemInfo, error) { // GetFunction gives a richer payload than GetFunctions, but for a specific function func (s *Client) GetFunction(ctx context.Context, name, namespace string) (types.FunctionStatus, error) { - u := s.GatewayURL - + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/function/" + name if len(namespace) > 0 { @@ -495,7 +493,7 @@ func (s *Client) deploy(ctx context.Context, method string, spec types.FunctionD bodyReader := bytes.NewReader(bodyBytes) - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/functions" req, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader) @@ -546,11 +544,8 @@ func (s *Client) ScaleFunction(ctx context.Context, functionName, namespace stri bodyBytes, _ := json.Marshal(scaleReq) bodyReader := bytes.NewReader(bodyBytes) - u := s.GatewayURL - - functionPath := filepath.Join("/system/scale-function", functionName) - - u.Path = functionPath + u, _ := url.Parse(s.GatewayURL.String()) + u.Path = filepath.Join("/system/scale-function", functionName) req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bodyReader) if err != nil { @@ -607,7 +602,7 @@ func (s *Client) DeleteFunction(ctx context.Context, functionName, namespace str bodyBytes, _ := json.Marshal(delReq) bodyReader := bytes.NewReader(bodyBytes) - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/functions" req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), bodyReader) @@ -654,7 +649,7 @@ func (s *Client) DeleteFunction(ctx context.Context, functionName, namespace str // GetSecrets list all secrets func (s *Client) GetSecrets(ctx context.Context, namespace string) ([]types.Secret, error) { - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/secrets" if len(namespace) > 0 { @@ -704,7 +699,7 @@ func (s *Client) CreateSecret(ctx context.Context, spec types.Secret) (int, erro bodyReader := bytes.NewReader(bodyBytes) - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/secrets" req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bodyReader) @@ -751,7 +746,7 @@ func (s *Client) UpdateSecret(ctx context.Context, spec types.Secret) (int, erro bodyReader := bytes.NewReader(bodyBytes) - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/secrets" req, err := http.NewRequestWithContext(ctx, http.MethodPut, u.String(), bodyReader) @@ -804,7 +799,7 @@ func (s *Client) DeleteSecret(ctx context.Context, secretName, namespace string) bodyBytes, _ := json.Marshal(delReq) bodyReader := bytes.NewReader(bodyBytes) - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/secrets" req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), bodyReader) @@ -877,7 +872,7 @@ func (s *Client) GetLogs(ctx context.Context, functionName, namespace string, fo var err error - u := s.GatewayURL + u, _ := url.Parse(s.GatewayURL.String()) u.Path = "/system/logs" req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) From ca9c2e9e952aff56f306d622ba44c0a7f1044651 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Fri, 17 May 2024 12:13:49 +0200 Subject: [PATCH 02/14] Refactor ClientCredentialsTokenSource to use Token struct Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- client_credentials_auth.go | 70 +++++++++++++------------------------- token.go | 2 +- 2 files changed, 25 insertions(+), 47 deletions(-) diff --git a/client_credentials_auth.go b/client_credentials_auth.go index 0696eea..9acb353 100644 --- a/client_credentials_auth.go +++ b/client_credentials_auth.go @@ -1,14 +1,13 @@ package sdk import ( - "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" + "strings" "sync" - "time" ) type ClientCredentialsAuth struct { @@ -41,7 +40,7 @@ type ClientCredentialsTokenSource struct { scope string grantType string audience string - token *ClientCredentialsToken + token *Token lock sync.RWMutex } @@ -72,28 +71,26 @@ func (ts *ClientCredentialsTokenSource) Token() (string, error) { ts.token = token ts.lock.Unlock() - return token.AccessToken, nil + return ts.token.IDToken, nil } ts.lock.RUnlock() - return ts.token.AccessToken, nil + return ts.token.IDToken, nil } -func obtainClientCredentialsToken(clientID, clientSecret, tokenURL, scope, grantType, audience string) (*ClientCredentialsToken, error) { +func obtainClientCredentialsToken(clientID, clientSecret, tokenURL, scope, grantType, audience string) (*Token, error) { - reqBody := url.Values{} - reqBody.Set("client_id", clientID) - reqBody.Set("client_secret", clientSecret) - reqBody.Set("grant_type", grantType) - reqBody.Set("scope", scope) + v := url.Values{} + v.Set("client_id", clientID) + v.Set("client_secret", clientSecret) + v.Set("grant_type", grantType) + v.Set("scope", scope) if len(audience) > 0 { - reqBody.Set("audience", audience) + v.Set("audience", audience) } - buffer := []byte(reqBody.Encode()) - - req, err := http.NewRequest(http.MethodPost, tokenURL, bytes.NewBuffer(buffer)) + req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(v.Encode())) if err != nil { return nil, err } @@ -104,45 +101,26 @@ func obtainClientCredentialsToken(clientID, clientSecret, tokenURL, scope, grant return nil, err } - var body []byte if res.Body != nil { defer res.Body.Close() - body, _ = io.ReadAll(res.Body) } - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code %d, body: %s", res.StatusCode, string(body)) + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("cannot fetch token: %v", err) } - token := &ClientCredentialsToken{} - if err := json.Unmarshal(body, token); err != nil { - return nil, fmt.Errorf("unable to unmarshal token: %s", err) + if code := res.StatusCode; code < 200 || code > 299 { + return nil, fmt.Errorf("cannot fetch token: %v\nResponse: %s", res.Status, body) } - token.ObtainedAt = time.Now() - - return token, nil -} - -// ClientCredentialsToken represents an access_token -// obtained through the client credentials grant type. -// This token is not associated with a human user. -type ClientCredentialsToken struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - ObtainedAt time.Time -} - -// Expired returns true if the token is expired -// or if the expiry time is not known. -// The token will always expire 10s early to avoid -// clock skew. -func (t *ClientCredentialsToken) Expired() bool { - if t.ExpiresIn == 0 { - return false + tj := &tokenJSON{} + if err := json.Unmarshal(body, tj); err != nil { + return nil, fmt.Errorf("unable to unmarshal token: %s", err) } - expiry := t.ObtainedAt.Add(time.Duration(t.ExpiresIn) * time.Second).Add(-expiryDelta) - return expiry.Before(time.Now()) + return &Token{ + IDToken: tj.AccessToken, + Expiry: tj.expiry(), + }, nil } diff --git a/token.go b/token.go index 981fc53..99d4699 100644 --- a/token.go +++ b/token.go @@ -9,7 +9,7 @@ import ( // expirations due to client-server time mismatches. const expiryDelta = 10 * time.Second -// Token represents an OpenFaaS ID token +// Token represents an OIDC token type Token struct { // IDToken is the OIDC access token that authorizes and authenticates // the requests. From b73e79e2d198ef3389a1fd53942a962bcb2cdc63 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Fri, 17 May 2024 12:14:41 +0200 Subject: [PATCH 03/14] Add scope to token Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- exchange.go | 1 + token.go | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/exchange.go b/exchange.go index db13ff2..6ea4964 100644 --- a/exchange.go +++ b/exchange.go @@ -53,5 +53,6 @@ func ExchangeIDToken(tokenURL, rawIDToken string) (*Token, error) { return &Token{ IDToken: tj.AccessToken, Expiry: tj.expiry(), + Scope: tj.scope(), }, nil } diff --git a/token.go b/token.go index 99d4699..66fc6bb 100644 --- a/token.go +++ b/token.go @@ -1,6 +1,7 @@ package sdk import ( + "strings" "time" ) @@ -19,6 +20,9 @@ type Token struct { // // A zero value means the token never expires. Expiry time.Time + + // Scope is the scope of the access token + Scope []string } // Expired reports whether the token is expired, and will start @@ -31,13 +35,13 @@ func (t *Token) Expired() bool { return t.Expiry.Round(0).Add(-expiryDelta).Before(time.Now()) } +// tokenJson represents an OAuth token response type tokenJSON struct { AccessToken string `json:"access_token"` - IDToken string `json:"id_token"` - - TokenType string `json:"token_type"` + TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` } func (t *tokenJSON) expiry() (exp time.Time) { @@ -46,3 +50,11 @@ func (t *tokenJSON) expiry() (exp time.Time) { } return } + +func (t *tokenJSON) scope() []string { + if len(t.Scope) > 0 { + return strings.Split(t.Scope, " ") + } + + return []string{} +} From 84bf8cce90ac2a821f132365b40dbdabaf711c9c Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Fri, 17 May 2024 14:05:27 +0200 Subject: [PATCH 04/14] Add exchange options Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- exchange.go | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/exchange.go b/exchange.go index 6ea4964..31e20f7 100644 --- a/exchange.go +++ b/exchange.go @@ -12,12 +12,26 @@ import ( // Exchange an OIDC ID Token from an IdP for OpenFaaS token // using the token exchange grant type. // tokenURL should be the OpenFaaS token endpoint within the internal OIDC service -func ExchangeIDToken(tokenURL, rawIDToken string) (*Token, error) { +func ExchangeIDToken(tokenURL, rawIDToken string, options ...ExchangeOption) (*Token, error) { + c := &ExchangeConfig{} + + for _, option := range options { + option(c) + } + v := url.Values{} v.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") v.Set("subject_token_type", "urn:ietf:params:oauth:token-type:id_token") v.Set("subject_token", rawIDToken) + for _, aud := range c.Audience { + v.Add("audience", aud) + } + + if len(c.Scope) > 0 { + v.Set("scope", strings.Join(c.Scope, " ")) + } + u, _ := url.Parse(tokenURL) req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(v.Encode())) @@ -56,3 +70,28 @@ func ExchangeIDToken(tokenURL, rawIDToken string) (*Token, error) { Scope: tj.scope(), }, nil } + +type ExchangeConfig struct { + Audience []string + Scope []string +} + +// ExchangeOption is used to implement functional-style options that modify the +// config setting for the OpenFaaS token exchange. +type ExchangeOption func(*ExchangeConfig) + +// WithAudience is an option to configure the audience requested +// in the token exchange. +func WithAudience(audience []string) ExchangeOption { + return func(c *ExchangeConfig) { + c.Audience = audience + } +} + +// WithScope is an option to configure the scope requested +// in the token exchange. +func WithScope(scope []string) ExchangeOption { + return func(c *ExchangeConfig) { + c.Scope = scope + } +} From 5303651905bbb252332e939f241599056d196752 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Fri, 17 May 2024 17:28:01 +0200 Subject: [PATCH 05/14] Have TokenAuth type implement the TokenSource interface Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- iam_auth.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/iam_auth.go b/iam_auth.go index e672bed..bd35c23 100644 --- a/iam_auth.go +++ b/iam_auth.go @@ -32,24 +32,37 @@ type TokenAuth struct { // Set validates the token expiry on each call. If it's expired it will exchange // an ID token from the TokenSource for a new OpenFaaS token. func (a *TokenAuth) Set(req *http.Request) error { + token, err := a.getToken() + if err != nil { + return err + } + + req.Header.Add("Authorization", "Bearer "+token) + return nil +} + +func (a *TokenAuth) Token() (string, error) { + return a.getToken() +} + +func (a *TokenAuth) getToken() (string, error) { a.lock.Lock() defer a.lock.Unlock() if a.token == nil || a.token.Expired() { idToken, err := a.TokenSource.Token() if err != nil { - return err + return "", err } token, err := ExchangeIDToken(a.TokenURL, idToken) if err != nil { - return err + return "", err } a.token = token } - req.Header.Add("Authorization", "Bearer "+a.token.IDToken) - return nil + return a.token.IDToken, nil } // A TokenSource to get ID token by reading a Kubernetes projected service account token From 9bd4a1106425e4ecb5a8367123979d62849e80fb Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Wed, 22 May 2024 12:40:28 +0200 Subject: [PATCH 06/14] Improve errors for token exchange functions Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- client_credentials_auth.go | 2 +- exchange.go | 9 ++++++++- iam_auth.go | 9 ++++++++- token.go | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/client_credentials_auth.go b/client_credentials_auth.go index 9acb353..3026504 100644 --- a/client_credentials_auth.go +++ b/client_credentials_auth.go @@ -111,7 +111,7 @@ func obtainClientCredentialsToken(clientID, clientSecret, tokenURL, scope, grant } if code := res.StatusCode; code < 200 || code > 299 { - return nil, fmt.Errorf("cannot fetch token: %v\nResponse: %s", res.Status, body) + return nil, fmt.Errorf("unexpected status code: %v\nResponse: %s", res.Status, body) } tj := &tokenJSON{} diff --git a/exchange.go b/exchange.go index 31e20f7..3b042ac 100644 --- a/exchange.go +++ b/exchange.go @@ -55,8 +55,15 @@ func ExchangeIDToken(tokenURL, rawIDToken string, options ...ExchangeOption) (*T return nil, fmt.Errorf("cannot fetch token: %v", err) } + if res.StatusCode == http.StatusBadRequest { + authErr := &OAuthError{} + if err := json.Unmarshal(body, authErr); err == nil { + return nil, authErr + } + } + if code := res.StatusCode; code < 200 || code > 299 { - return nil, fmt.Errorf("cannot fetch token: %v\nResponse: %s", res.Status, body) + return nil, fmt.Errorf("unexpected status code: %v\nResponse: %s", res.Status, body) } tj := &tokenJSON{} diff --git a/iam_auth.go b/iam_auth.go index bd35c23..8760334 100644 --- a/iam_auth.go +++ b/iam_auth.go @@ -1,6 +1,7 @@ package sdk import ( + "errors" "fmt" "net/http" "os" @@ -56,9 +57,15 @@ func (a *TokenAuth) getToken() (string, error) { } token, err := ExchangeIDToken(a.TokenURL, idToken) + + var authError *OAuthError + if errors.As(err, &authError) { + return "", fmt.Errorf("failed to exchange token for an OpenFaaS token: %s", authError.Description) + } if err != nil { - return "", err + return "", fmt.Errorf("failed to exchange token for an OpenFaaS token: %s", err) } + a.token = token } diff --git a/token.go b/token.go index 66fc6bb..2b48a73 100644 --- a/token.go +++ b/token.go @@ -1,6 +1,7 @@ package sdk import ( + "fmt" "strings" "time" ) @@ -58,3 +59,16 @@ func (t *tokenJSON) scope() []string { return []string{} } + +// OAuthError represents an OAuth error response. +type OAuthError struct { + Err string `json:"error"` + Description string `json:"error_description,omitempty"` +} + +func (e *OAuthError) Error() string { + if len(e.Description) > 0 { + return fmt.Sprintf("%s: %s", e.Err, e.Description) + } + return e.Err +} From 6c37a68da4a1522ff051a18ff144dc967faf2ea0 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Fri, 17 May 2024 18:28:53 +0200 Subject: [PATCH 07/14] Add support for invoking functions using the client Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- client.go | 53 +++++++++++++++++++++++++++++++++++++----- functions.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 functions.go diff --git a/client.go b/client.go index ef21fa2..88e5f66 100644 --- a/client.go +++ b/client.go @@ -21,9 +21,11 @@ import ( // Client is used to manage OpenFaaS functions type Client struct { - GatewayURL *url.URL - client *http.Client - ClientAuth ClientAuth + GatewayURL *url.URL + ClientAuth ClientAuth + FunctionTokenSource TokenSource + + client *http.Client } // Wrap http request Do function to support debug capabilities @@ -73,13 +75,52 @@ type ClientAuth interface { Set(req *http.Request) error } +type ClientOption func(*Client) + +func WithFunctionTokenSource(tokenSource TokenSource) ClientOption { + return func(c *Client) { + c.FunctionTokenSource = tokenSource + } +} + +func WithAuthentication(auth ClientAuth) ClientOption { + return func(c *Client) { + c.ClientAuth = auth + } +} + // NewClient creates an Client for managing OpenFaaS func NewClient(gatewayURL *url.URL, auth ClientAuth, client *http.Client) *Client { - return &Client{ + return NewClientWithOpts(gatewayURL, client, WithAuthentication(auth)) +} + +func NewClientWithOpts(gatewayURL *url.URL, client *http.Client, options ...ClientOption) *Client { + c := &Client{ GatewayURL: gatewayURL, - client: client, - ClientAuth: auth, + + client: client, + } + + for _, option := range options { + option(c) + } + + if c.ClientAuth != nil && c.FunctionTokenSource == nil { + // Use auth as the default function token source for IAM function authentication + // if it implements the TokenSource interface. + functionTokenSource, ok := c.ClientAuth.(TokenSource) + if ok { + c.FunctionTokenSource = functionTokenSource + } } + + return c +} + +func (s *Client) WithFunctionTokenSource(tokenSource TokenSource) *Client { + s.FunctionTokenSource = tokenSource + + return s } // GetNamespaces get openfaas namespaces diff --git a/functions.go b/functions.go new file mode 100644 index 0000000..900dc0e --- /dev/null +++ b/functions.go @@ -0,0 +1,65 @@ +package sdk + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" +) + +const DefaultNamespace = "openfaas-fn" + +func (c *Client) InvokeFunction(ctx context.Context, name, namespace string, method string, header http.Header, query url.Values, body io.Reader, async bool, auth bool) (*http.Response, error) { + fnEndpoint := "/function" + if async { + fnEndpoint = "/async-function" + } + + if len(namespace) == 0 { + namespace = DefaultNamespace + } + + u, _ := url.Parse(c.GatewayURL.String()) + u.Path = fmt.Sprintf("%s/%s.%s", fnEndpoint, name, namespace) + u.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, err + } + + for key, values := range header { + for _, value := range values { + req.Header.Add(key, value) + } + } + + if auth && c.FunctionTokenSource != nil { + tokenURL := fmt.Sprintf("%s/oauth/token", c.GatewayURL.String()) + scope := []string{"function"} + audience := []string{fmt.Sprintf("%s:%s", namespace, name)} + + idToken, err := c.FunctionTokenSource.Token() + if err != nil { + return nil, fmt.Errorf("failed to get function access token: %w", err) + } + + // Consider caching the token in memory as long as the token is valid + // to prevent having to do a token exchange each time the function is invoked. + functionToken, err := ExchangeIDToken(tokenURL, idToken, WithScope(scope), WithAudience(audience)) + + var authError *OAuthError + if errors.As(err, &authError) { + return nil, fmt.Errorf("failed to get function access token: %s", authError.Description) + } + if err != nil { + return nil, fmt.Errorf("failed to get function access token: %w", err) + } + + req.Header.Add("Authorization", "Bearer "+functionToken.IDToken) + } + + return c.do(req) +} From 7c850b90ca0ba7762a9a5acbcd0c7409b8d2a690 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Mon, 27 May 2024 12:44:53 +0200 Subject: [PATCH 08/14] Cache function invocation tokens Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- client.go | 5 ++++- functions.go | 38 ++++++++++++++++++++++---------- tokencache.go | 38 ++++++++++++++++++++++++++++++++ tokencache_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 tokencache.go create mode 100644 tokencache_test.go diff --git a/client.go b/client.go index 88e5f66..b4c16f1 100644 --- a/client.go +++ b/client.go @@ -26,6 +26,8 @@ type Client struct { FunctionTokenSource TokenSource client *http.Client + // Cache for OpenFaaS function access tokens for invoking functions. + fnTokenCache *TokenCache } // Wrap http request Do function to support debug capabilities @@ -98,7 +100,8 @@ func NewClientWithOpts(gatewayURL *url.URL, client *http.Client, options ...Clie c := &Client{ GatewayURL: gatewayURL, - client: client, + client: client, + fnTokenCache: NewTokenCache(), } for _, option := range options { diff --git a/functions.go b/functions.go index 900dc0e..5142f71 100644 --- a/functions.go +++ b/functions.go @@ -2,6 +2,7 @@ package sdk import ( "context" + "crypto/sha256" "errors" "fmt" "io" @@ -37,10 +38,6 @@ func (c *Client) InvokeFunction(ctx context.Context, name, namespace string, met } if auth && c.FunctionTokenSource != nil { - tokenURL := fmt.Sprintf("%s/oauth/token", c.GatewayURL.String()) - scope := []string{"function"} - audience := []string{fmt.Sprintf("%s:%s", namespace, name)} - idToken, err := c.FunctionTokenSource.Token() if err != nil { return nil, fmt.Errorf("failed to get function access token: %w", err) @@ -48,14 +45,24 @@ func (c *Client) InvokeFunction(ctx context.Context, name, namespace string, met // Consider caching the token in memory as long as the token is valid // to prevent having to do a token exchange each time the function is invoked. - functionToken, err := ExchangeIDToken(tokenURL, idToken, WithScope(scope), WithAudience(audience)) + cacheKey := getFunctionTokenCacheKey(idToken, fmt.Sprintf("%s.%s", name, namespace)) + functionToken, ok := c.fnTokenCache.Get(cacheKey) + if !ok { + tokenURL := fmt.Sprintf("%s/oauth/token", c.GatewayURL.String()) + scope := []string{"function"} + audience := []string{fmt.Sprintf("%s:%s", namespace, name)} - var authError *OAuthError - if errors.As(err, &authError) { - return nil, fmt.Errorf("failed to get function access token: %s", authError.Description) - } - if err != nil { - return nil, fmt.Errorf("failed to get function access token: %w", err) + functionToken, err = ExchangeIDToken(tokenURL, idToken, WithScope(scope), WithAudience(audience)) + + var authError *OAuthError + if errors.As(err, &authError) { + return nil, fmt.Errorf("failed to get function access token: %s", authError.Description) + } + if err != nil { + return nil, fmt.Errorf("failed to get function access token: %w", err) + } + + c.fnTokenCache.Set(cacheKey, functionToken) } req.Header.Add("Authorization", "Bearer "+functionToken.IDToken) @@ -63,3 +70,12 @@ func (c *Client) InvokeFunction(ctx context.Context, name, namespace string, met return c.do(req) } + +func getFunctionTokenCacheKey(idToken string, serviceName string) string { + hash := sha256.New() + hash.Write([]byte(idToken)) + hash.Write([]byte(serviceName)) + + sum := hash.Sum(nil) + return fmt.Sprintf("%x", sum) +} diff --git a/tokencache.go b/tokencache.go new file mode 100644 index 0000000..9ecf306 --- /dev/null +++ b/tokencache.go @@ -0,0 +1,38 @@ +package sdk + +import "sync" + +type TokenCache struct { + tokens map[string]*Token + + lock sync.RWMutex +} + +func NewTokenCache() *TokenCache { + return &TokenCache{ + tokens: map[string]*Token{}, + } +} + +func (c *TokenCache) Set(key string, token *Token) { + c.lock.Lock() + defer c.lock.Unlock() + + c.tokens[key] = token +} + +func (c *TokenCache) Get(key string) (*Token, bool) { + c.lock.RLock() + token, ok := c.tokens[key] + c.lock.RUnlock() + + if ok && token.Expired() { + c.lock.Lock() + delete(c.tokens, key) + c.lock.Unlock() + + return nil, false + } + + return token, ok +} diff --git a/tokencache_test.go b/tokencache_test.go new file mode 100644 index 0000000..664db41 --- /dev/null +++ b/tokencache_test.go @@ -0,0 +1,54 @@ +package sdk + +import ( + "reflect" + "testing" + "time" +) + +func Test_TokenCache(t *testing.T) { + cache := NewTokenCache() + + t.Run("Cache hit for token", func(t *testing.T) { + token := &Token{ + IDToken: "token1", + Scope: []string{"function"}, + } + + cache.Set("token1", token) + + got, ok := cache.Get("token1") + + if !ok { + t.Errorf("Want cache hit") + } + + if !reflect.DeepEqual(token, got) { + t.Errorf("Want cached token: %v, got: %v", token, got) + } + }) + + t.Run("No cache hit for missing key", func(t *testing.T) { + got, ok := cache.Get("token2") + + if ok { + t.Errorf("Want cache miss, got: %v", got) + } + }) + + t.Run("No cache hit for expired token", func(t *testing.T) { + token := &Token{ + IDToken: "token3", + Expiry: time.Now().Add(time.Minute * -10), + Scope: []string{"function"}, + } + + cache.Set("token3", token) + + got, ok := cache.Get("token3") + + if ok { + t.Errorf("Want cache miss, got: %v", got) + } + }) +} From d45b4a925209db1cc3bccc561adaed4ea2132915 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Mon, 27 May 2024 15:55:52 +0200 Subject: [PATCH 09/14] Improve code comments and update README Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ client.go | 28 +++++++++++++++++----------- functions.go | 7 ++++++- 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9ef8984..b4a9e2d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,56 @@ if err != nil { Please refer [examples](https://github.com/openfaas/go-sdk/tree/master/examples) folder for code examples of each operation +## Invoke functions + +```go +header := http.Header{} +header.Set("Content-Type", "text/plain") + +body := strings.NewReader("OpenFaaS") + +async := false +authenticate := false + +// Make a POST request to a figlet function in the openfaas-fn namespace +res, err := client.InvokeFunction(context.Background(), "figlet", "openfaas-fn", http.MethodPost, header, nil, body, async, authenticate) +if err != nil { + log.Printf("Failed to invoke function: %s", err) + return +} + +if res.Body != nil { + defer res.Body.Close() +} + +// Read the response body +body, err := io.ReadAll(res.Body) +if err != nil { + log.Printf("Error reading response body: %s", err) + return +} + +// Print the response +fmt.Printf("Response status code: %s\n", res.Status) +fmt.Printf("Response body: %s\n", string(body)) +``` + +### Authenticate function invocations + +The SDK supports invoking functions if you are using OpenFaaS IAM with [built-in authentication for functions](https://www.openfaas.com/blog/built-in-function-authentication/). + +Set the `auth` argument to `true` when calling `InvokeFunction` to authenticate the request with an OpenFaaS function access token. + +The `Client` needs a `TokenSource` to get an ID token that can be exchanged for a function access token to make authenticated function invocations. By default the `TokenAuth` provider that was set when constructing a new `Client` is used. + +It is also possible to provide a custom `TokenSource` for the function token exchange: + +```go +ts := sdk.NewClientCredentialsTokenSource(clientID, clientSecret, tokenURL, scope, grantType, audience) + +client := sdk.NewClientWithOpts(gatewayURL, http.DefaultClient, sdk.WithFunctionTokenSource(ts)) +``` + ## License License: MIT diff --git a/client.go b/client.go index b4c16f1..f78a1d5 100644 --- a/client.go +++ b/client.go @@ -19,14 +19,22 @@ import ( "github.com/openfaas/faas-provider/types" ) -// Client is used to manage OpenFaaS functions +// Client is used to manage OpenFaaS and invoke functions type Client struct { - GatewayURL *url.URL - ClientAuth ClientAuth + // URL of the OpenFaaS gateway + GatewayURL *url.URL + + // Authentication provider for authenticating request to the OpenFaaS API. + ClientAuth ClientAuth + + // TokenSource for getting an ID token that can be exchanged for an + // OpenFaaS function access token to invoke functions. FunctionTokenSource TokenSource + // Http client used for calls to the OpenFaaS gateway. client *http.Client - // Cache for OpenFaaS function access tokens for invoking functions. + + // OpenFaaS function access token cache for invoking functions. fnTokenCache *TokenCache } @@ -79,23 +87,27 @@ type ClientAuth interface { type ClientOption func(*Client) +// WithFunctionTokenSource configures the function token source for the client. func WithFunctionTokenSource(tokenSource TokenSource) ClientOption { return func(c *Client) { c.FunctionTokenSource = tokenSource } } +// WithAuthentication configures the authentication provider fot the client. func WithAuthentication(auth ClientAuth) ClientOption { return func(c *Client) { c.ClientAuth = auth } } -// NewClient creates an Client for managing OpenFaaS +// NewClient creates a Client for managing OpenFaaS and invoking functions func NewClient(gatewayURL *url.URL, auth ClientAuth, client *http.Client) *Client { return NewClientWithOpts(gatewayURL, client, WithAuthentication(auth)) } +// NewClientWithOpts creates a Client for managing OpenFaaS and invoking functions +// It takes a list of ClientOptions to configure the client. func NewClientWithOpts(gatewayURL *url.URL, client *http.Client, options ...ClientOption) *Client { c := &Client{ GatewayURL: gatewayURL, @@ -120,12 +132,6 @@ func NewClientWithOpts(gatewayURL *url.URL, client *http.Client, options ...Clie return c } -func (s *Client) WithFunctionTokenSource(tokenSource TokenSource) *Client { - s.FunctionTokenSource = tokenSource - - return s -} - // GetNamespaces get openfaas namespaces func (s *Client) GetNamespaces(ctx context.Context) ([]string, error) { namespaces := []string{} diff --git a/functions.go b/functions.go index 5142f71..5b762fd 100644 --- a/functions.go +++ b/functions.go @@ -43,7 +43,7 @@ func (c *Client) InvokeFunction(ctx context.Context, name, namespace string, met return nil, fmt.Errorf("failed to get function access token: %w", err) } - // Consider caching the token in memory as long as the token is valid + // Function access tokens are cached in memory as long as the token is valid // to prevent having to do a token exchange each time the function is invoked. cacheKey := getFunctionTokenCacheKey(idToken, fmt.Sprintf("%s.%s", name, namespace)) functionToken, ok := c.fnTokenCache.Get(cacheKey) @@ -71,6 +71,11 @@ func (c *Client) InvokeFunction(ctx context.Context, name, namespace string, met return c.do(req) } +// getFunctionTokenCacheKey computes a cache key for caching a function access token based +// on the original id token that is exchanged for the function access token and the function +// name e.g. figlet.openfaas-fn. +// The original token is included in the hash to avoid cache hits for a function when the +// source token changes. func getFunctionTokenCacheKey(idToken string, serviceName string) string { hash := sha256.New() hash.Write([]byte(idToken)) From e3993cb64b4126c66ccd64b993bb58c4423c2373 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Thu, 30 May 2024 12:20:25 +0200 Subject: [PATCH 10/14] Dump exchange request when FAAS_DEBUG is set Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- client.go | 75 +++++++++++++++++++++--------------- client_test.go | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ exchange.go | 10 +++++ 3 files changed, 156 insertions(+), 31 deletions(-) diff --git a/client.go b/client.go index f78a1d5..511e240 100644 --- a/client.go +++ b/client.go @@ -41,39 +41,12 @@ type Client struct { // Wrap http request Do function to support debug capabilities func (s *Client) do(req *http.Request) (*http.Response, error) { if os.Getenv("FAAS_DEBUG") == "1" { - - fmt.Printf("%s %s\n", req.Method, req.URL.String()) - for k, v := range req.Header { - if k == "Authorization" { - auth := "[REDACTED]" - if len(v) == 0 { - auth = "[NOT_SET]" - } else { - l, _, ok := strings.Cut(v[0], " ") - if ok && (l == "Basic" || l == "Bearer") { - auth = l + " REDACTED" - } - } - fmt.Printf("%s: %s\n", k, auth) - - } else { - fmt.Printf("%s: %s\n", k, v) - } + dump, err := dumpRequest(req) + if err != nil { + return nil, err } - if req.Body != nil { - r := io.NopCloser(req.Body) - buf := new(strings.Builder) - _, err := io.Copy(buf, r) - if err != nil { - return nil, err - } - bodyDebug := buf.String() - if len(bodyDebug) > 0 { - fmt.Printf("%s\n", bodyDebug) - } - req.Body = io.NopCloser(strings.NewReader(buf.String())) - } + fmt.Println(dump) } return s.client.Do(req) @@ -985,3 +958,43 @@ func (s *Client) GetLogs(ctx context.Context, functionName, namespace string, fo } return logStream, nil } + +func dumpRequest(req *http.Request) (string, error) { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("%s %s\n", req.Method, req.URL.String())) + for k, v := range req.Header { + if k == "Authorization" { + auth := "[REDACTED]" + if len(v) == 0 { + auth = "[NOT_SET]" + } else { + l, _, ok := strings.Cut(v[0], " ") + if ok && (l == "Basic" || l == "Bearer") { + auth = l + " [REDACTED]" + } + } + sb.WriteString(fmt.Sprintf("%s: %s\n", k, auth)) + + } else { + sb.WriteString(fmt.Sprintf("%s: %s\n", k, v)) + } + } + + if req.Body != nil { + r := io.NopCloser(req.Body) + buf := new(strings.Builder) + _, err := io.Copy(buf, r) + if err != nil { + return "", err + } + bodyDebug := buf.String() + if len(bodyDebug) > 0 { + sb.WriteString(fmt.Sprintf("%s\n", bodyDebug)) + + } + req.Body = io.NopCloser(strings.NewReader(buf.String())) + } + + return sb.String(), nil +} diff --git a/client_test.go b/client_test.go index 2c8db1e..9da0596 100644 --- a/client_test.go +++ b/client_test.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "io" "log" "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" @@ -783,3 +785,103 @@ func TestSdk_DeleteSecret(t *testing.T) { }) } } + +func Test_dumpRequest(t *testing.T) { + tests := []struct { + name string + req *http.Request + want string + }{ + { + name: "request without body", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n", + }, + { + name: "request with headers", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + Header: http.Header{ + "Content-Type": []string{"text/plain"}, + "User-Agent": []string{"openfaas-go-sdk"}, + }, + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n" + + "Content-Type: [text/plain]\n" + + "User-Agent: [openfaas-go-sdk]\n", + }, + { + name: "request with body", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader("Hello OpenFaaS!!")), + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n" + + "Hello OpenFaaS!!\n", + }, + { + name: "request with bearer auth", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + Header: http.Header{ + "Authorization": []string{"Bearer secret openfaas-token"}, + }, + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n" + + "Authorization: Bearer [REDACTED]\n", + }, + { + name: "request with basic auth", + req: &http.Request{ + Method: http.MethodPost, + URL: &url.URL{ + Scheme: "https", + Host: "gw.example.com", + Path: "/function/env.openfaas-fn", + }, + Header: http.Header{ + "Authorization": []string{"Basic username:password"}, + }, + }, + want: "POST https://gw.example.com/function/env.openfaas-fn\n" + + "Authorization: Basic [REDACTED]\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := dumpRequest(test.req) + + if err != nil { + t.Errorf("want %s, but got error: %s", test.want, err) + } + + if test.want != got { + t.Errorf("want %s, but got: %s", test.want, got) + } + }) + } +} diff --git a/exchange.go b/exchange.go index 3b042ac..a76a0c8 100644 --- a/exchange.go +++ b/exchange.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "os" "strings" ) @@ -41,6 +42,15 @@ func ExchangeIDToken(tokenURL, rawIDToken string, options ...ExchangeOption) (*T req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if os.Getenv("FAAS_DEBUG") == "1" { + dump, err := dumpRequest(req) + if err != nil { + return nil, err + } + + fmt.Println(dump) + } + res, err := http.DefaultClient.Do(req) if err != nil { return nil, err From e29d9eec631014929a51cb5634d0e5866124ae1f Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Thu, 30 May 2024 12:42:05 +0200 Subject: [PATCH 11/14] Make function token cache optional in client Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- client.go | 13 ++++++++++--- functions.go | 37 ++++++++++++++++++++++--------------- tokencache.go | 15 ++++++++++----- tokencache_test.go | 2 +- 4 files changed, 43 insertions(+), 24 deletions(-) diff --git a/client.go b/client.go index 511e240..9ca378f 100644 --- a/client.go +++ b/client.go @@ -35,7 +35,7 @@ type Client struct { client *http.Client // OpenFaaS function access token cache for invoking functions. - fnTokenCache *TokenCache + fnTokenCache TokenCache } // Wrap http request Do function to support debug capabilities @@ -74,6 +74,14 @@ func WithAuthentication(auth ClientAuth) ClientOption { } } +// WithFunctionTokenCache configures the token cache used by the client to cache access +// tokens for function invocations. +func WithFunctionTokenCache(cache TokenCache) ClientOption { + return func(c *Client) { + c.fnTokenCache = cache + } +} + // NewClient creates a Client for managing OpenFaaS and invoking functions func NewClient(gatewayURL *url.URL, auth ClientAuth, client *http.Client) *Client { return NewClientWithOpts(gatewayURL, client, WithAuthentication(auth)) @@ -85,8 +93,7 @@ func NewClientWithOpts(gatewayURL *url.URL, client *http.Client, options ...Clie c := &Client{ GatewayURL: gatewayURL, - client: client, - fnTokenCache: NewTokenCache(), + client: client, } for _, option := range options { diff --git a/functions.go b/functions.go index 5b762fd..e8d8955 100644 --- a/functions.go +++ b/functions.go @@ -3,7 +3,6 @@ package sdk import ( "context" "crypto/sha256" - "errors" "fmt" "io" "net/http" @@ -43,29 +42,37 @@ func (c *Client) InvokeFunction(ctx context.Context, name, namespace string, met return nil, fmt.Errorf("failed to get function access token: %w", err) } - // Function access tokens are cached in memory as long as the token is valid - // to prevent having to do a token exchange each time the function is invoked. - cacheKey := getFunctionTokenCacheKey(idToken, fmt.Sprintf("%s.%s", name, namespace)) - functionToken, ok := c.fnTokenCache.Get(cacheKey) - if !ok { - tokenURL := fmt.Sprintf("%s/oauth/token", c.GatewayURL.String()) - scope := []string{"function"} - audience := []string{fmt.Sprintf("%s:%s", namespace, name)} + tokenURL := fmt.Sprintf("%s/oauth/token", c.GatewayURL.String()) + scope := []string{"function"} + audience := []string{fmt.Sprintf("%s:%s", namespace, name)} - functionToken, err = ExchangeIDToken(tokenURL, idToken, WithScope(scope), WithAudience(audience)) + var bearer string + if c.fnTokenCache != nil { + // Function access tokens are cached as long as the token is valid + // to prevent having to do a token exchange each time the function is invoked. + cacheKey := getFunctionTokenCacheKey(idToken, fmt.Sprintf("%s.%s", name, namespace)) - var authError *OAuthError - if errors.As(err, &authError) { - return nil, fmt.Errorf("failed to get function access token: %s", authError.Description) + token, ok := c.fnTokenCache.Get(cacheKey) + if !ok { + token, err = ExchangeIDToken(tokenURL, idToken, WithScope(scope), WithAudience(audience)) + if err != nil { + return nil, fmt.Errorf("failed to get function access token: %w", err) + } + + c.fnTokenCache.Set(cacheKey, token) } + + bearer = token.IDToken + } else { + token, err := ExchangeIDToken(tokenURL, idToken, WithScope(scope), WithAudience(audience)) if err != nil { return nil, fmt.Errorf("failed to get function access token: %w", err) } - c.fnTokenCache.Set(cacheKey, functionToken) + bearer = token.IDToken } - req.Header.Add("Authorization", "Bearer "+functionToken.IDToken) + req.Header.Add("Authorization", "Bearer "+bearer) } return c.do(req) diff --git a/tokencache.go b/tokencache.go index 9ecf306..7d1f121 100644 --- a/tokencache.go +++ b/tokencache.go @@ -2,26 +2,31 @@ package sdk import "sync" -type TokenCache struct { +type TokenCache interface { + Get(key string) (*Token, bool) + Set(key string, token *Token) +} + +type MemoryTokenCache struct { tokens map[string]*Token lock sync.RWMutex } -func NewTokenCache() *TokenCache { - return &TokenCache{ +func NewMemoryTokenCache() *MemoryTokenCache { + return &MemoryTokenCache{ tokens: map[string]*Token{}, } } -func (c *TokenCache) Set(key string, token *Token) { +func (c *MemoryTokenCache) Set(key string, token *Token) { c.lock.Lock() defer c.lock.Unlock() c.tokens[key] = token } -func (c *TokenCache) Get(key string) (*Token, bool) { +func (c *MemoryTokenCache) Get(key string) (*Token, bool) { c.lock.RLock() token, ok := c.tokens[key] c.lock.RUnlock() diff --git a/tokencache_test.go b/tokencache_test.go index 664db41..5a487c4 100644 --- a/tokencache_test.go +++ b/tokencache_test.go @@ -7,7 +7,7 @@ import ( ) func Test_TokenCache(t *testing.T) { - cache := NewTokenCache() + cache := NewMemoryTokenCache() t.Run("Cache hit for token", func(t *testing.T) { token := &Token{ From 6b9d6a4ee9ae6fcc8fa8b5974e2f7e8ec78caf68 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Thu, 30 May 2024 13:16:06 +0200 Subject: [PATCH 12/14] Support configuring the http client for token exchange requests Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- exchange.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/exchange.go b/exchange.go index a76a0c8..36c37f6 100644 --- a/exchange.go +++ b/exchange.go @@ -14,7 +14,9 @@ import ( // using the token exchange grant type. // tokenURL should be the OpenFaaS token endpoint within the internal OIDC service func ExchangeIDToken(tokenURL, rawIDToken string, options ...ExchangeOption) (*Token, error) { - c := &ExchangeConfig{} + c := &ExchangeConfig{ + Client: http.DefaultClient, + } for _, option := range options { option(c) @@ -51,7 +53,7 @@ func ExchangeIDToken(tokenURL, rawIDToken string, options ...ExchangeOption) (*T fmt.Println(dump) } - res, err := http.DefaultClient.Do(req) + res, err := c.Client.Do(req) if err != nil { return nil, err } @@ -91,6 +93,7 @@ func ExchangeIDToken(tokenURL, rawIDToken string, options ...ExchangeOption) (*T type ExchangeConfig struct { Audience []string Scope []string + Client *http.Client } // ExchangeOption is used to implement functional-style options that modify the @@ -112,3 +115,11 @@ func WithScope(scope []string) ExchangeOption { c.Scope = scope } } + +// WithHttpClient is an option to configure the http client +// used to make the token exchange request. +func WithHttpClient(client *http.Client) ExchangeOption { + return func(c *ExchangeConfig) { + c.Client = client + } +} From 343aee3503c56262b91b3276f879cbbaa3d35c31 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Thu, 30 May 2024 18:38:10 +0200 Subject: [PATCH 13/14] Support garbage collection of expired tokens from cache Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- README.md | 15 +++++++++++++++ tokencache.go | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b4a9e2d..0763041 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,21 @@ ts := sdk.NewClientCredentialsTokenSource(clientID, clientSecret, tokenURL, scop client := sdk.NewClientWithOpts(gatewayURL, http.DefaultClient, sdk.WithFunctionTokenSource(ts)) ``` +Optionally a `TokenCache` can be configured to cache function access tokens and prevent the client from having to do a token exchange each time a function is invoked. + +```go +fnTokenCache := sdk.NewMemoryTokenCache() +// Start garbage collection to remove expired tokens from the cache. +go fnTokenCache.StartGC(context.Background(), time.Second*10) + +client := sdk.NewClientWithOpts( + gatewayUrl, + httpClient, + sdk.WithAuthentication(auth), + sdk.WithFunctionTokenCache(fnTokenCache), +) +``` + ## License License: MIT diff --git a/tokencache.go b/tokencache.go index 7d1f121..9935245 100644 --- a/tokencache.go +++ b/tokencache.go @@ -1,24 +1,31 @@ package sdk -import "sync" +import ( + "context" + "sync" + "time" +) type TokenCache interface { Get(key string) (*Token, bool) Set(key string, token *Token) } +// MemoryTokenCache is a basic in-memory token cache implementation. type MemoryTokenCache struct { tokens map[string]*Token lock sync.RWMutex } +// NewMemoryTokenCache creates a new in memory token cache instance. func NewMemoryTokenCache() *MemoryTokenCache { return &MemoryTokenCache{ tokens: map[string]*Token{}, } } +// Set adds or updates a token with the given key in the cache. func (c *MemoryTokenCache) Set(key string, token *Token) { c.lock.Lock() defer c.lock.Unlock() @@ -26,6 +33,8 @@ func (c *MemoryTokenCache) Set(key string, token *Token) { c.tokens[key] = token } +// Get retrieves the token associated with the given key from the cache. The bool +// return value will be false if no matching key is found, and true otherwise. func (c *MemoryTokenCache) Get(key string) (*Token, bool) { c.lock.RLock() token, ok := c.tokens[key] @@ -41,3 +50,33 @@ func (c *MemoryTokenCache) Get(key string) (*Token, bool) { return token, ok } + +// StartGC starts garbage collection of expired tokens. +func (c *MemoryTokenCache) StartGC(ctx context.Context, gcInterval time.Duration) { + if gcInterval <= 0 { + return + } + + ticker := time.NewTicker(gcInterval) + + for { + select { + case <-ticker.C: + c.clearExpired() + case <-ctx.Done(): + ticker.Stop() + return + } + } +} + +// clearExpired removes all expired tokens from the cache. +func (c *MemoryTokenCache) clearExpired() { + for key, token := range c.tokens { + if token.Expired() { + c.lock.Lock() + delete(c.tokens, key) + c.lock.Unlock() + } + } +} From 20a2d09adab3f77a4d69d0f57ead4002dd44d990 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Thu, 30 May 2024 18:39:49 +0200 Subject: [PATCH 14/14] Accept a http.Request as argument to InvokeFunction Allow the caller to construct and configure requests as required without limitations imposed by the signature of InvokeFunction. Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- README.md | 11 +++++++---- functions.go | 22 ++++------------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0763041..6f30a58 100644 --- a/README.md +++ b/README.md @@ -146,16 +146,19 @@ Please refer [examples](https://github.com/openfaas/go-sdk/tree/master/examples) ## Invoke functions ```go -header := http.Header{} -header.Set("Content-Type", "text/plain") - body := strings.NewReader("OpenFaaS") +req, err := http.NewRequestWithContext(context.TODO(), http.MethodPost, "/", body) +if err != nil { + panic(err) +} + +req.Header.Set("Content-Type", "text/plain") async := false authenticate := false // Make a POST request to a figlet function in the openfaas-fn namespace -res, err := client.InvokeFunction(context.Background(), "figlet", "openfaas-fn", http.MethodPost, header, nil, body, async, authenticate) +res, err := client.InvokeFunction(context.Background(), "figlet", "openfaas-fn", async, authenticate, req) if err != nil { log.Printf("Failed to invoke function: %s", err) return diff --git a/functions.go b/functions.go index e8d8955..f59ee32 100644 --- a/functions.go +++ b/functions.go @@ -1,17 +1,14 @@ package sdk import ( - "context" "crypto/sha256" "fmt" - "io" "net/http" - "net/url" ) const DefaultNamespace = "openfaas-fn" -func (c *Client) InvokeFunction(ctx context.Context, name, namespace string, method string, header http.Header, query url.Values, body io.Reader, async bool, auth bool) (*http.Response, error) { +func (c *Client) InvokeFunction(name, namespace string, async bool, auth bool, req *http.Request) (*http.Response, error) { fnEndpoint := "/function" if async { fnEndpoint = "/async-function" @@ -21,20 +18,9 @@ func (c *Client) InvokeFunction(ctx context.Context, name, namespace string, met namespace = DefaultNamespace } - u, _ := url.Parse(c.GatewayURL.String()) - u.Path = fmt.Sprintf("%s/%s.%s", fnEndpoint, name, namespace) - u.RawQuery = query.Encode() - - req, err := http.NewRequestWithContext(ctx, method, u.String(), body) - if err != nil { - return nil, err - } - - for key, values := range header { - for _, value := range values { - req.Header.Add(key, value) - } - } + req.URL.Scheme = c.GatewayURL.Scheme + req.URL.Host = c.GatewayURL.Host + req.URL.Path = fmt.Sprintf("%s/%s.%s", fnEndpoint, name, namespace) if auth && c.FunctionTokenSource != nil { idToken, err := c.FunctionTokenSource.Token()