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