214 lines
4.8 KiB
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)
|
|
}
|