-
Notifications
You must be signed in to change notification settings - Fork 75
feat: introduce authtoken package for managing and refreshing authentication tokens with caching capabilities.
#156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| package authtoken_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/IBM/fp-go/v2/samples/http/authtoken" | ||
| ) | ||
|
|
||
| func fakeAuthServerWithToken(t *testing.T, token string, expiresIn int) *httptest.Server { | ||
| t.Helper() | ||
| return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusOK) | ||
| fmt.Fprintf(w, `{"access_token": "%s", "expires_in": %d, "token_type": "Bearer"}`, token, expiresIn) | ||
| })) | ||
| } | ||
|
|
||
| func fakeAuthServerError(t *testing.T, statusCode int, body string) *httptest.Server { | ||
| t.Helper() | ||
| return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| w.WriteHeader(statusCode) | ||
| w.Write([]byte(body)) | ||
| })) | ||
| } | ||
|
|
||
| func TestGetToken_RefreshesWhenCacheEmpty(t *testing.T) { | ||
| server := fakeAuthServerWithToken(t, "fresh-token", 3600) | ||
| defer server.Close() | ||
|
|
||
| fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) | ||
| cache := authtoken.MakeTokenCache()() | ||
| env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return fixedTime }) | ||
|
|
||
| ctx := context.Background() | ||
| token, err := authtoken.GetToken(env)(ctx) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if token.Token != "fresh-token" { | ||
| t.Errorf("expected token 'fresh-token', got '%s'", token.Token) | ||
| } | ||
| } | ||
|
|
||
| func TestGetToken_UsesCachedToken(t *testing.T) { | ||
| callCount := 0 | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| callCount++ | ||
| w.Header().Set("Content-Type", "application/json") | ||
| fmt.Fprintf(w, `{"access_token": "token-%d", "expires_in": 3600, "token_type": "Bearer"}`, callCount) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) | ||
| cache := authtoken.MakeTokenCache()() | ||
| env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return fixedTime }) | ||
|
|
||
| ctx := context.Background() | ||
|
|
||
| // First call - should hit server | ||
| token1, _ := authtoken.GetToken(env)(ctx) | ||
| // Second call - should use cache | ||
| token2, _ := authtoken.GetToken(env)(ctx) | ||
|
|
||
| if callCount != 1 { | ||
| t.Errorf("expected 1 server call, got %d", callCount) | ||
| } | ||
| if token1.Token != token2.Token { | ||
| t.Errorf("expected same token, got '%s' and '%s'", token1.Token, token2.Token) | ||
| } | ||
| } | ||
|
|
||
| func TestGetToken_RefreshesExpiredToken(t *testing.T) { | ||
| callCount := 0 | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| callCount++ | ||
| w.Header().Set("Content-Type", "application/json") | ||
| fmt.Fprintf(w, `{"access_token": "token-%d", "expires_in": 3600, "token_type": "Bearer"}`, callCount) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| currentTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) | ||
| cache := authtoken.MakeTokenCache()() | ||
| env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return currentTime }) | ||
|
|
||
| ctx := context.Background() | ||
|
|
||
| // First call | ||
| token1, _ := authtoken.GetToken(env)(ctx) | ||
|
|
||
| // Advance time past expiration | ||
| currentTime = currentTime.Add(2 * time.Hour) | ||
| env = authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return currentTime }) | ||
|
|
||
| // Second call - should refresh | ||
| token2, _ := authtoken.GetToken(env)(ctx) | ||
|
|
||
| if callCount != 2 { | ||
| t.Errorf("expected 2 server calls, got %d", callCount) | ||
| } | ||
| if token1.Token == token2.Token { | ||
| t.Errorf("expected different tokens after expiration") | ||
| } | ||
| } | ||
|
|
||
| func TestGetToken_HandlesServerError(t *testing.T) { | ||
| server := fakeAuthServerError(t, http.StatusUnauthorized, "invalid credentials") | ||
| defer server.Close() | ||
|
|
||
| fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) | ||
| cache := authtoken.MakeTokenCache()() | ||
| env := authtoken.MakeEnv(server.URL, "bad-api-key", server.Client(), cache, func() time.Time { return fixedTime }) | ||
|
|
||
| ctx := context.Background() | ||
| _, err := authtoken.GetToken(env)(ctx) | ||
|
|
||
| if err == nil { | ||
| t.Fatal("expected error for unauthorized request") | ||
| } | ||
| } | ||
|
|
||
| func TestGetTokenString_ReturnsTokenValue(t *testing.T) { | ||
| server := fakeAuthServerWithToken(t, "my-access-token", 3600) | ||
| defer server.Close() | ||
|
|
||
| fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) | ||
| cache := authtoken.MakeTokenCache()() | ||
| env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return fixedTime }) | ||
|
|
||
| ctx := context.Background() | ||
| tokenStr, err := authtoken.GetTokenString(env)(ctx) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| if tokenStr != "my-access-token" { | ||
| t.Errorf("expected 'my-access-token', got '%s'", tokenStr) | ||
| } | ||
| } | ||
|
|
||
| func TestGetAuthorizationHeader_ReturnsBearerFormat(t *testing.T) { | ||
| server := fakeAuthServerWithToken(t, "my-token", 3600) | ||
| defer server.Close() | ||
|
|
||
| fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) | ||
| cache := authtoken.MakeTokenCache()() | ||
| env := authtoken.MakeEnv(server.URL, "test-api-key", server.Client(), cache, func() time.Time { return fixedTime }) | ||
|
|
||
| ctx := context.Background() | ||
| header, err := authtoken.GetAuthorizationHeader(env)(ctx) | ||
|
|
||
| if err != nil { | ||
| t.Fatalf("unexpected error: %v", err) | ||
| } | ||
| expected := "Bearer my-token" | ||
| if header != expected { | ||
| t.Errorf("expected '%s', got '%s'", expected, header) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| package authtoken | ||
|
|
||
| import ( | ||
| "time" | ||
|
|
||
| F "github.com/IBM/fp-go/v2/function" | ||
| IO "github.com/IBM/fp-go/v2/io" | ||
| IORef "github.com/IBM/fp-go/v2/ioref" | ||
| O "github.com/IBM/fp-go/v2/option" | ||
| ) | ||
|
|
||
| // Cache is a generic thread-safe cache that stores an optional value using IORef. | ||
| type Cache[T any] struct { | ||
| ref IORef.IORef[Option[T]] | ||
| } | ||
|
|
||
| // MakeCache creates a new empty cache for type T. | ||
| func MakeCache[T any]() IO.IO[*Cache[T]] { | ||
| return F.Pipe1( | ||
| IORef.MakeIORef(O.None[T]()), | ||
| IO.Map(func(ref IORef.IORef[Option[T]]) *Cache[T] { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This extra indirection is not needed |
||
| return &Cache[T]{ref: ref} | ||
| }), | ||
| ) | ||
| } | ||
|
|
||
| // Get retrieves the currently cached value, if any. | ||
| func (c *Cache[T]) Get() IO.IO[Option[T]] { | ||
| return IORef.Read(c.ref) | ||
| } | ||
|
|
||
| // Set stores a new value in the cache, replacing any existing value. | ||
| func (c *Cache[T]) Set(value T) IO.IO[T] { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is where a race condition can occur, because when using You want to use |
||
| return F.Pipe2( | ||
| c.ref, | ||
| IORef.Write(O.Some(value)), | ||
| IO.Map(func(opt Option[T]) T { | ||
| return value | ||
| }), | ||
| ) | ||
| } | ||
|
|
||
| // Clear removes the cached value, setting the cache back to None. | ||
| func (c *Cache[T]) Clear() IO.IO[Option[T]] { | ||
| return IORef.Write(O.None[T]())(c.ref) | ||
| } | ||
|
|
||
| // GetIf retrieves the cached value only if it satisfies the predicate. | ||
| func (c *Cache[T]) GetIf(predicate func(T) bool) IO.IO[Option[T]] { | ||
| return F.Pipe1( | ||
| c.Get(), | ||
| IO.Map(O.Filter(predicate)), | ||
| ) | ||
| } | ||
|
|
||
| // Update applies a transformation function to the cached value. | ||
| func (c *Cache[T]) Update(f func(T) T) IO.IO[Option[T]] { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From a signature perspective you could write An update with an endomorphism makes sense if the update operation is a pure function. But in this case it's an effectful function, so you want to use |
||
| return IORef.Modify(O.Map(f))(c.ref) | ||
| } | ||
|
|
||
| // TokenCache is a specialized cache for CachedToken with token-specific methods. | ||
| type TokenCache struct { | ||
| cache *Cache[CachedToken] | ||
| } | ||
|
|
||
| // MakeTokenCache creates a new empty token cache. | ||
| func MakeTokenCache() IO.IO[*TokenCache] { | ||
| return F.Pipe1( | ||
| MakeCache[CachedToken](), | ||
| IO.Map(func(cache *Cache[CachedToken]) *TokenCache { | ||
| return &TokenCache{cache: cache} | ||
| }), | ||
| ) | ||
| } | ||
|
|
||
| // Get retrieves the currently cached token, if any. | ||
| func (c *TokenCache) Get() IO.IO[Option[CachedToken]] { | ||
| return c.cache.Get() | ||
| } | ||
|
|
||
| // Set stores a new token in the cache, replacing any existing token. | ||
| func (c *TokenCache) Set(token CachedToken) IO.IO[CachedToken] { | ||
| return c.cache.Set(token) | ||
| } | ||
|
|
||
| // Clear removes the cached token, setting the cache back to None. | ||
| func (c *TokenCache) Clear() IO.IO[Option[CachedToken]] { | ||
| return c.cache.Clear() | ||
| } | ||
|
|
||
| // IsExpired checks if the cached token is expired based on the current time. | ||
| func (c *TokenCache) IsExpired(currentTime time.Time) IO.IO[bool] { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return F.Pipe1( | ||
| c.Get(), | ||
| IO.Map(O.Fold( | ||
| F.Constant(true), | ||
| func(token CachedToken) bool { | ||
| return !currentTime.Before(token.ExpiresAt) | ||
| }, | ||
| )), | ||
| ) | ||
| } | ||
|
|
||
| // GetIfValid retrieves the cached token only if it's not expired. | ||
| func (c *TokenCache) GetIfValid(currentTime time.Time) IO.IO[Option[CachedToken]] { | ||
| return c.cache.GetIf(func(token CachedToken) bool { | ||
| return currentTime.Before(token.ExpiresAt) | ||
| }) | ||
| } | ||
|
|
||
| // Update applies a transformation function to the cached token. | ||
| func (c *TokenCache) Update(f func(CachedToken) CachedToken) IO.IO[Option[CachedToken]] { | ||
| return c.cache.Update(f) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You do not need to wrap
IORefinto the cache, you can use it directly and defineCachejust as a type alias.I think the content of
IORefshould beResult[T]because it's possible that the resolution of the token resulted in an error