feat(zarinhub): automate access token refresh
This commit is contained in:
213
backend/internal/zarinhub/token_manager.go
Normal file
213
backend/internal/zarinhub/token_manager.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user