Files
card-to-iban/backend/internal/zarinhub/token_manager.go

214 lines
4.8 KiB
Go

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