feat(zarinhub): automate access token refresh

This commit is contained in:
2026-09-21 16:27:30 +03:30
parent 7f2f26e596
commit c54332cd61
9 changed files with 621 additions and 36 deletions

View File

@@ -8,12 +8,14 @@ import (
)
type config struct {
ServerAddr string
FrontendOrigin string
ZarinHubEndpoint string
ZarinHubToken string
ZarinHubTimeout time.Duration
DatabasePath string
ServerAddr string
FrontendOrigin string
ZarinHubEndpoint string
ZarinHubAuthEndpoint string
ZarinHubUsername string
ZarinHubPassword string
ZarinHubTimeout time.Duration
DatabasePath string
}
func loadConfig() (config, error) {
@@ -21,9 +23,14 @@ func loadConfig() (config, error) {
}
func readConfig(getenv func(string) string) (config, error) {
token := strings.TrimSpace(getenv("ZARINHUB_ACCESS_TOKEN"))
if token == "" {
return config{}, fmt.Errorf("ZARINHUB_ACCESS_TOKEN is required")
username := strings.TrimSpace(getenv("ZARINHUB_API_USERNAME"))
if username == "" {
return config{}, fmt.Errorf("ZARINHUB_API_USERNAME is required")
}
password := strings.TrimSpace(getenv("ZARINHUB_API_PASSWORD"))
if password == "" {
return config{}, fmt.Errorf("ZARINHUB_API_PASSWORD is required")
}
timeoutText := valueOrDefault(
@@ -50,8 +57,13 @@ func readConfig(getenv func(string) string) (config, error) {
getenv("ZARINHUB_ENDPOINT"),
"https://zarin-hub.com/api/v5/KYC/CardToIban",
),
ZarinHubToken: token,
ZarinHubTimeout: timeout,
ZarinHubAuthEndpoint: valueOrDefault(
getenv("ZARINHUB_AUTH_ENDPOINT"),
"https://zarin-hub.com/api/v5/Authentication/GetToken",
),
ZarinHubUsername: username,
ZarinHubPassword: password,
ZarinHubTimeout: timeout,
DatabasePath: valueOrDefault(
getenv("DATABASE_PATH"),
"./card-to-iban.db",

View File

@@ -8,7 +8,8 @@ import (
func TestReadConfigUsesDefaults(t *testing.T) {
values := map[string]string{
"ZARINHUB_ACCESS_TOKEN": "test-token",
"ZARINHUB_API_USERNAME": "test-username",
"ZARINHUB_API_PASSWORD": "test-password",
}
config, err := readConfig(func(key string) string {
@@ -24,6 +25,9 @@ func TestReadConfigUsesDefaults(t *testing.T) {
if config.FrontendOrigin != "http://127.0.0.1:3000" {
t.Errorf("FrontendOrigin = %q", config.FrontendOrigin)
}
if config.ZarinHubAuthEndpoint != "https://zarin-hub.com/api/v5/Authentication/GetToken" {
t.Errorf("ZarinHubAuthEndpoint = %q", config.ZarinHubAuthEndpoint)
}
if config.ZarinHubTimeout != 5*time.Second {
t.Errorf("ZarinHubTimeout = %v", config.ZarinHubTimeout)
}
@@ -32,16 +36,30 @@ func TestReadConfigUsesDefaults(t *testing.T) {
}
}
func TestReadConfigRejectsMissingAccessToken(t *testing.T) {
func TestReadConfigRejectsMissingUsername(t *testing.T) {
_, err := readConfig(func(key string) string { return "" })
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_ACCESS_TOKEN") {
t.Fatalf("readConfig() error = %v, want missing token error", err)
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_API_USERNAME") {
t.Fatalf("readConfig() error = %v, want missing username error", err)
}
}
func TestReadConfigRejectsMissingPassword(t *testing.T) {
values := map[string]string{
"ZARINHUB_API_USERNAME": "test-username",
}
_, err := readConfig(func(key string) string {
return values[key]
})
if err == nil || !strings.Contains(err.Error(), "ZARINHUB_API_PASSWORD") {
t.Fatalf("readConfig() error = %v, want missing password error", err)
}
}
func TestReadConfigRejectsInvalidTimeout(t *testing.T) {
values := map[string]string{
"ZARINHUB_ACCESS_TOKEN": "test-token",
"ZARINHUB_API_USERNAME": "test-username",
"ZARINHUB_API_PASSWORD": "test-password",
"ZARINHUB_TIMEOUT": "later",
}

View File

@@ -38,6 +38,24 @@ func run(logger *slog.Logger) error {
return fmt.Errorf("load configuration: %w", err)
}
httpClient := &http.Client{Timeout: config.ZarinHubTimeout}
tokenManager := zarinhub.NewTokenManager(
httpClient,
config.ZarinHubAuthEndpoint,
config.ZarinHubUsername,
config.ZarinHubPassword,
)
authContext, cancelAuth := context.WithTimeout(
context.Background(),
config.ZarinHubTimeout,
)
_, err = tokenManager.AccessToken(authContext)
cancelAuth()
if err != nil {
return fmt.Errorf("authenticate with zarinhub: %w", err)
}
db, err := storage.OpenSQLite(config.DatabasePath)
if err != nil {
return err
@@ -58,9 +76,9 @@ func run(logger *slog.Logger) error {
}
zarinHubClient := zarinhub.NewClient(
&http.Client{Timeout: config.ZarinHubTimeout},
httpClient,
config.ZarinHubEndpoint,
config.ZarinHubToken,
tokenManager,
)
cardService := service.NewCardService(zarinHubClient)
auditRepository := repository.NewAuditRepository(db)

View File

@@ -16,28 +16,60 @@ import (
const maxResponseBodySize = 1 << 20
type Client struct {
httpClient *http.Client
endpointURL string
accessToken string
httpClient *http.Client
endpointURL string
tokenProvider TokenProvider
}
var _ service.CardToIBANClient = (*Client)(nil)
type TokenProvider interface {
AccessToken(ctx context.Context) (string, error)
Invalidate(token string)
}
func NewClient(
httpClient *http.Client,
endpointURL string,
accessToken string,
tokenProvider TokenProvider,
) *Client {
return &Client{
httpClient: httpClient,
endpointURL: endpointURL,
accessToken: accessToken,
httpClient: httpClient,
endpointURL: endpointURL,
tokenProvider: tokenProvider,
}
}
func (c *Client) Convert(
ctx context.Context,
cardNumber string,
) (service.ConversionResult, error) {
accessToken, err := c.tokenProvider.AccessToken(ctx)
if err != nil {
return service.ConversionResult{},
fmt.Errorf("get zarinhub access token: %w", err)
}
result, err := c.convert(ctx, cardNumber, accessToken)
if !errors.Is(err, ErrAuthentication) ||
result.UpstreamHTTPStatus != http.StatusUnauthorized {
return result, err
}
c.tokenProvider.Invalidate(accessToken)
accessToken, err = c.tokenProvider.AccessToken(ctx)
if err != nil {
return result, fmt.Errorf("refresh zarinhub access token: %w", err)
}
return c.convert(ctx, cardNumber, accessToken)
}
func (c *Client) convert(
ctx context.Context,
cardNumber string,
accessToken string,
) (service.ConversionResult, error) {
payload, err := json.Marshal(convertRequest{
Card: cardNumber,
@@ -60,7 +92,7 @@ func (c *Client) Convert(
req.Header.Set(
"Authorization",
"Bearer "+c.accessToken,
"Bearer "+accessToken,
)
req.Header.Set(

View File

@@ -97,7 +97,7 @@ func TestClientConvertSuccess(t *testing.T) {
Timeout: time.Second,
},
server.URL,
token,
&fakeTokenProvider{tokens: []string{token}},
)
result, err := client.Convert(
@@ -171,7 +171,7 @@ func TestClientReturnsRejectedForApplicationFailure(
Timeout: time.Second,
},
server.URL,
"test-token",
&fakeTokenProvider{tokens: []string{"test-token"}},
)
result, err := client.Convert(
@@ -263,7 +263,7 @@ func TestClientMapsHTTPStatusErrors(t *testing.T) {
Timeout: time.Second,
},
server.URL,
"test-token",
&fakeTokenProvider{tokens: []string{"test-token"}},
)
result, err := client.Convert(
@@ -321,7 +321,7 @@ func TestClientReturnsInvalidResponseForMalformedJSON(
Timeout: time.Second,
},
server.URL,
"test-token",
&fakeTokenProvider{tokens: []string{"test-token"}},
)
result, err := client.Convert(
@@ -366,7 +366,7 @@ func TestClientReturnsTimeout(t *testing.T) {
Timeout: 50 * time.Millisecond,
},
server.URL,
"test-token",
&fakeTokenProvider{tokens: []string{"test-token"}},
)
_, err := client.Convert(
@@ -402,7 +402,7 @@ func TestClientRejectsOversizedResponse(t *testing.T) {
client := NewClient(
&http.Client{Timeout: time.Second},
server.URL,
"test-token",
&fakeTokenProvider{tokens: []string{"test-token"}},
)
_, err := client.Convert(
@@ -414,3 +414,67 @@ func TestClientRejectsOversizedResponse(t *testing.T) {
t.Fatalf("error = %v, want ErrInvalidResponse", err)
}
}
func TestClientRefreshesAndRetriesUnauthorizedRequest(t *testing.T) {
const expectedIBAN = "IR820540102680020817909002"
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
authorization := r.Header.Get("Authorization")
if requestCount == 1 {
if authorization != "Bearer expired-token" {
t.Errorf("first authorization = %q", authorization)
}
w.WriteHeader(http.StatusUnauthorized)
return
}
if authorization != "Bearer fresh-token" {
t.Errorf("second authorization = %q", authorization)
}
fmt.Fprintf(w, `{
"data":{"iban":%q},
"meta":{"isSuccess":true,"errors":[]}
}`, expectedIBAN)
}))
defer server.Close()
tokens := &fakeTokenProvider{tokens: []string{"expired-token", "fresh-token"}}
client := NewClient(server.Client(), server.URL, tokens)
result, err := client.Convert(context.Background(), "6037997512345678")
if err != nil {
t.Fatalf("Convert() error = %v", err)
}
if result.IBAN != expectedIBAN {
t.Errorf("IBAN = %q, want %q", result.IBAN, expectedIBAN)
}
if requestCount != 2 {
t.Errorf("request count = %d, want 2", requestCount)
}
if len(tokens.invalidated) != 1 || tokens.invalidated[0] != "expired-token" {
t.Errorf("invalidated tokens = %v", tokens.invalidated)
}
}
type fakeTokenProvider struct {
tokens []string
calls int
invalidated []string
}
func (p *fakeTokenProvider) AccessToken(context.Context) (string, error) {
index := p.calls
if index >= len(p.tokens) {
index = len(p.tokens) - 1
}
p.calls++
return p.tokens[index], nil
}
func (p *fakeTokenProvider) Invalidate(token string) {
p.invalidated = append(p.invalidated, token)
}

View File

@@ -0,0 +1,213 @@
package zarinhub
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
)
const tokenRefreshBefore = time.Minute
type tokenRequest struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
RefreshToken string `json:"refreshToken,omitempty"`
}
type tokenResponse struct {
Data tokenData `json:"data"`
Meta responseMeta `json:"meta"`
}
type tokenData struct {
AccessToken string `json:"accessToken"`
TokenType string `json:"tokenType"`
ExpiresIn int `json:"expiresTn"`
RefreshToken string `json:"refreshToken"`
}
type tokenCall struct {
done chan struct{}
token string
err error
}
type TokenManager struct {
httpClient *http.Client
endpoint string
username string
password string
now func() time.Time
mu sync.Mutex
accessToken string
refreshToken string
expiresAt time.Time
inFlight *tokenCall
}
func NewTokenManager(
httpClient *http.Client,
endpoint string,
username string,
password string,
) *TokenManager {
return &TokenManager{
httpClient: httpClient,
endpoint: endpoint,
username: username,
password: password,
now: time.Now,
}
}
func (m *TokenManager) AccessToken(ctx context.Context) (string, error) {
m.mu.Lock()
if m.tokenIsValid() {
token := m.accessToken
m.mu.Unlock()
return token, nil
}
if m.inFlight != nil {
call := m.inFlight
m.mu.Unlock()
select {
case <-ctx.Done():
return "", ctx.Err()
case <-call.done:
return call.token, call.err
}
}
call := &tokenCall{done: make(chan struct{})}
m.inFlight = call
refreshToken := m.refreshToken
m.mu.Unlock()
data, err := m.fetchToken(ctx, refreshToken)
if err != nil && refreshToken != "" && errors.Is(err, ErrAuthentication) {
data, err = m.fetchToken(ctx, "")
}
m.mu.Lock()
if err == nil {
m.accessToken = data.AccessToken
m.refreshToken = data.RefreshToken
m.expiresAt = m.now().Add(time.Duration(data.ExpiresIn) * time.Second)
call.token = data.AccessToken
}
call.err = err
close(call.done)
m.inFlight = nil
m.mu.Unlock()
return call.token, call.err
}
func (m *TokenManager) Invalidate(token string) {
m.mu.Lock()
defer m.mu.Unlock()
if token == m.accessToken {
m.expiresAt = time.Time{}
}
}
func (m *TokenManager) tokenIsValid() bool {
return m.accessToken != "" &&
m.now().Add(tokenRefreshBefore).Before(m.expiresAt)
}
func (m *TokenManager) fetchToken(
ctx context.Context,
refreshToken string,
) (tokenData, error) {
request := tokenRequest{RefreshToken: refreshToken}
if refreshToken == "" {
request.Username = m.username
request.Password = m.password
}
payload, err := json.Marshal(request)
if err != nil {
return tokenData{}, fmt.Errorf("marshal zarinhub token request: %w", err)
}
httpRequest, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
m.endpoint,
bytes.NewReader(payload),
)
if err != nil {
return tokenData{}, fmt.Errorf("create zarinhub token request: %w", err)
}
httpRequest.Header.Set("Content-Type", "application/json")
response, err := m.httpClient.Do(httpRequest)
if err != nil {
return tokenData{}, tokenTransportError(err)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBodySize+1))
if err != nil {
return tokenData{}, fmt.Errorf("%w: failed to read token response", ErrInvalidResponse)
}
if len(body) > maxResponseBodySize {
return tokenData{}, fmt.Errorf("%w: token response body is too large", ErrInvalidResponse)
}
switch {
case response.StatusCode == http.StatusRequestTimeout:
return tokenData{}, ErrTimeout
case response.StatusCode == http.StatusTooManyRequests ||
response.StatusCode >= http.StatusInternalServerError:
return tokenData{}, ErrUnavailable
case response.StatusCode < http.StatusOK ||
response.StatusCode >= http.StatusMultipleChoices:
return tokenData{}, ErrAuthentication
}
var decoded tokenResponse
if err := json.Unmarshal(body, &decoded); err != nil {
return tokenData{}, fmt.Errorf("%w: malformed token response", ErrInvalidResponse)
}
if !decoded.Meta.IsSuccess {
return tokenData{}, ErrAuthentication
}
if decoded.Data.AccessToken == "" ||
decoded.Data.RefreshToken == "" ||
decoded.Data.ExpiresIn <= 0 {
return tokenData{}, fmt.Errorf("%w: incomplete token response", ErrInvalidResponse)
}
return decoded.Data, nil
}
func tokenTransportError(err error) error {
switch {
case errors.Is(err, context.DeadlineExceeded):
return fmt.Errorf("%w: token request timed out", ErrTimeout)
case errors.Is(err, context.Canceled):
return fmt.Errorf("zarinhub token request canceled: %w", err)
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return fmt.Errorf("%w: token request timed out", ErrTimeout)
}
return fmt.Errorf("%w: token request failed", ErrUnavailable)
}

View File

@@ -0,0 +1,222 @@
package zarinhub
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func TestTokenManagerAuthenticatesAndCachesToken(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var request tokenRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
if request.Username != "test-username" || request.Password != "test-password" {
t.Errorf("credentials = %q/%q", request.Username, request.Password)
}
if request.RefreshToken != "" {
t.Errorf("refresh token = %q, want empty", request.RefreshToken)
}
writeTokenResponse(t, w, "access-1", "refresh-1", 28800)
}))
defer server.Close()
manager := NewTokenManager(
server.Client(),
server.URL,
"test-username",
"test-password",
)
for range 2 {
token, err := manager.AccessToken(context.Background())
if err != nil {
t.Fatalf("AccessToken() error = %v", err)
}
if token != "access-1" {
t.Errorf("token = %q, want access-1", token)
}
}
if calls != 1 {
t.Errorf("authentication calls = %d, want 1", calls)
}
}
func TestTokenManagerRefreshesExpiringToken(t *testing.T) {
var calls int
currentTime := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var request tokenRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
if calls == 1 {
writeTokenResponse(t, w, "access-1", "refresh-1", 120)
return
}
if request.RefreshToken != "refresh-1" {
t.Errorf("refresh token = %q, want refresh-1", request.RefreshToken)
}
if request.Username != "" || request.Password != "" {
t.Error("refresh request included credentials")
}
writeTokenResponse(t, w, "access-2", "refresh-2", 28800)
}))
defer server.Close()
manager := NewTokenManager(server.Client(), server.URL, "test-username", "test-password")
manager.now = func() time.Time { return currentTime }
if _, err := manager.AccessToken(context.Background()); err != nil {
t.Fatalf("initial AccessToken() error = %v", err)
}
currentTime = currentTime.Add(61 * time.Second)
token, err := manager.AccessToken(context.Background())
if err != nil {
t.Fatalf("refreshed AccessToken() error = %v", err)
}
if token != "access-2" {
t.Errorf("token = %q, want access-2", token)
}
}
func TestTokenManagerFallsBackToCredentialsWhenRefreshIsRejected(t *testing.T) {
var calls int
currentTime := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var request tokenRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatalf("decode request: %v", err)
}
switch calls {
case 1:
writeTokenResponse(t, w, "access-1", "refresh-1", 61)
case 2:
if request.RefreshToken != "refresh-1" {
t.Errorf("refresh token = %q, want refresh-1", request.RefreshToken)
}
w.WriteHeader(http.StatusUnauthorized)
case 3:
if request.Username != "test-username" || request.Password != "test-password" {
t.Error("credential fallback did not include API credentials")
}
writeTokenResponse(t, w, "access-2", "refresh-2", 28800)
default:
t.Errorf("unexpected authentication call %d", calls)
}
}))
defer server.Close()
manager := NewTokenManager(server.Client(), server.URL, "test-username", "test-password")
manager.now = func() time.Time { return currentTime }
if _, err := manager.AccessToken(context.Background()); err != nil {
t.Fatalf("initial AccessToken() error = %v", err)
}
currentTime = currentTime.Add(2 * time.Second)
token, err := manager.AccessToken(context.Background())
if err != nil {
t.Fatalf("fallback AccessToken() error = %v", err)
}
if token != "access-2" {
t.Errorf("token = %q, want access-2", token)
}
if calls != 3 {
t.Errorf("authentication calls = %d, want 3", calls)
}
}
func TestTokenManagerSharesConcurrentAuthentication(t *testing.T) {
var (
mu sync.Mutex
calls int
)
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls++
mu.Unlock()
<-release
writeTokenResponse(t, w, "shared-access", "shared-refresh", 28800)
}))
defer server.Close()
manager := NewTokenManager(server.Client(), server.URL, "test-username", "test-password")
results := make(chan string, 5)
errors := make(chan error, 5)
for range 5 {
go func() {
token, err := manager.AccessToken(context.Background())
results <- token
errors <- err
}()
}
time.Sleep(20 * time.Millisecond)
close(release)
for range 5 {
if err := <-errors; err != nil {
t.Errorf("AccessToken() error = %v", err)
}
if token := <-results; token != "shared-access" {
t.Errorf("token = %q, want shared-access", token)
}
}
mu.Lock()
defer mu.Unlock()
if calls != 1 {
t.Errorf("authentication calls = %d, want 1", calls)
}
}
func writeTokenResponse(
t *testing.T,
w http.ResponseWriter,
accessToken string,
refreshToken string,
expiresIn int,
) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"accessToken": accessToken,
"tokenType": "Bearer",
"expiresTn": expiresIn,
"refreshToken": refreshToken,
},
"meta": map[string]any{
"isSuccess": true,
},
})
if err != nil {
t.Fatalf("encode response: %v", err)
}
}