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

@@ -1,4 +1,6 @@
ZARINHUB_ACCESS_TOKEN=
ZARINHUB_API_USERNAME=
ZARINHUB_API_PASSWORD=
ZARINHUB_AUTH_ENDPOINT=https://zarin-hub.com/api/v5/Authentication/GetToken
ZARINHUB_ENDPOINT=https://zarin-hub.com/api/v5/KYC/CardToIban
ZARINHUB_TIMEOUT=5s

View File

@@ -16,7 +16,7 @@ The HTTP handler coordinates the public API contract and audit persistence. The
- Go 1.27 or later
- Node.js 22 or later
- A valid ZarinHub access token
- ZarinHub API credentials
## Configuration
@@ -26,11 +26,13 @@ Copy the example environment file from the project root:
Copy-Item .env.sample .env
```
Set `ZARINHUB_ACCESS_TOKEN` in the new `.env` file. This file is ignored by Git.
Set `ZARINHUB_API_USERNAME` and `ZARINHUB_API_PASSWORD` in the new `.env` file. These are the API credentials created in the ZarinHub panel, not the regular panel login credentials. The `.env` file is ignored by Git.
| Variable | Default | Purpose |
| --- | --- | --- |
| `ZARINHUB_ACCESS_TOKEN` | None; required | ZarinHub access token |
| `ZARINHUB_API_USERNAME` | None; required | API username created in the ZarinHub panel |
| `ZARINHUB_API_PASSWORD` | None; required | API password created in the ZarinHub panel |
| `ZARINHUB_AUTH_ENDPOINT` | Official GetToken endpoint | Token acquisition and refresh endpoint |
| `ZARINHUB_ENDPOINT` | Official CardToIban endpoint | External service endpoint |
| `ZARINHUB_TIMEOUT` | `5s` | External request timeout |
| `DATABASE_PATH` | `./card-to-iban.db` | SQLite database file path |
@@ -43,6 +45,8 @@ The frontend reads the following value from `frontend/.env` and uses the shown a
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
```
The backend obtains an access token during startup, keeps the access and refresh tokens in memory, and refreshes them automatically shortly before expiration. Credentials and tokens are never written to the database or logs.
## Running the Project
Start the backend from the Go module directory. The root `.env` file is loaded automatically, and the database migration runs during startup:

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)
}
}