mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
9c054b9e3a
Classify verification-required and rate-limit errors from extensions, propagate Retry-After seconds through the download fallback, report session-grant success/failure, and add a completeGrant action fallback in the runtime.
622 lines
19 KiB
Go
622 lines
19 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
const signedSessionRefreshSkew = time.Hour
|
|
|
|
var (
|
|
pendingSignedSessionGrants = make(map[string]string)
|
|
pendingSignedSessionGrantsMu sync.Mutex
|
|
)
|
|
|
|
type signedSessionRecord struct {
|
|
InstallID string `json:"install_id"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
SessionSecret string `json:"session_secret,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
type signedSessionExchangeResponse struct {
|
|
SessionID string `json:"session_id,omitempty"`
|
|
SessionSecret string `json:"session_secret,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
ChallengeID string `json:"challenge_id,omitempty"`
|
|
ChallengeURL string `json:"challenge_url,omitempty"`
|
|
AuthURL string `json:"auth_url,omitempty"`
|
|
}
|
|
|
|
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
|
if config == nil {
|
|
return SignedSessionConfig{}
|
|
}
|
|
resolved := *config
|
|
if resolved.AppVersion == "" {
|
|
resolved.AppVersion = "ext-1.0"
|
|
}
|
|
if resolved.Platform == "" {
|
|
resolved.Platform = "extension"
|
|
}
|
|
if resolved.CallbackURL == "" {
|
|
resolved.CallbackURL = "spotiflac://session-grant"
|
|
}
|
|
if resolved.SchemeLabel == "" {
|
|
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
|
|
}
|
|
if resolved.HeaderPrefix == "" {
|
|
resolved.HeaderPrefix = "X-Sig-"
|
|
}
|
|
if resolved.TimeWindowSeconds <= 0 {
|
|
resolved.TimeWindowSeconds = 300
|
|
}
|
|
if resolved.Endpoints.Bootstrap == "" {
|
|
resolved.Endpoints.Bootstrap = "/bootstrap"
|
|
}
|
|
if resolved.Endpoints.Challenge == "" {
|
|
resolved.Endpoints.Challenge = "/challenge"
|
|
}
|
|
if resolved.Endpoints.Exchange == "" {
|
|
resolved.Endpoints.Exchange = "/session/exchange"
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
|
|
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
|
if namespace == "" {
|
|
return "", fmt.Errorf("signed session namespace is empty")
|
|
}
|
|
baseDir := filepath.Dir(r.dataDir)
|
|
if baseDir == "." || baseDir == "" {
|
|
baseDir = r.dataDir
|
|
}
|
|
dir := filepath.Join(baseDir, "signed_sessions")
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, namespace+".json"), nil
|
|
}
|
|
|
|
func sanitizeSignedSessionNamespace(namespace string) string {
|
|
namespace = strings.TrimSpace(strings.ToLower(namespace))
|
|
var b strings.Builder
|
|
for _, ch := range namespace {
|
|
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
|
|
b.WriteRune(ch)
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), ".-_")
|
|
}
|
|
|
|
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
|
path, err := r.signedSessionFilePath(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
record := &signedSessionRecord{}
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
_ = json.Unmarshal(data, record)
|
|
}
|
|
if strings.TrimSpace(record.InstallID) == "" {
|
|
record.InstallID = randomHex(16)
|
|
if err := r.saveSignedSession(config, record); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
|
path, err := r.signedSessionFilePath(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(record, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0600)
|
|
}
|
|
|
|
func randomHex(bytesLen int) string {
|
|
buf := make([]byte, bytesLen)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(buf)
|
|
}
|
|
|
|
func parseSignedSessionTime(value string) (time.Time, bool) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}, false
|
|
}
|
|
layouts := []string{
|
|
time.RFC3339Nano,
|
|
time.RFC3339,
|
|
"2006-01-02T15:04:05.000Z",
|
|
}
|
|
for _, layout := range layouts {
|
|
if parsed, err := time.Parse(layout, value); err == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
|
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
|
if config.Namespace == "" || config.BaseURL == "" {
|
|
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
|
|
}
|
|
record, err := r.loadSignedSession(config)
|
|
if err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
|
|
}
|
|
authenticated := record.SessionID != "" && record.SessionSecret != ""
|
|
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
|
|
authenticated = false
|
|
}
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"authenticated": authenticated,
|
|
"expires_at": record.ExpiresAt,
|
|
"install_id": record.InstallID,
|
|
})
|
|
}
|
|
|
|
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
|
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
|
record, err := r.loadSignedSession(config)
|
|
if err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
|
}
|
|
record.SessionID = ""
|
|
record.SessionSecret = ""
|
|
record.ExpiresAt = ""
|
|
if err := r.saveSignedSession(config, record); err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
|
}
|
|
return r.vm.ToValue(map[string]interface{}{"success": true})
|
|
}
|
|
|
|
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
|
|
grant := ""
|
|
if len(call.Arguments) > 0 {
|
|
grant = strings.TrimSpace(call.Arguments[0].String())
|
|
}
|
|
if grant == "" {
|
|
pendingSignedSessionGrantsMu.Lock()
|
|
grant = pendingSignedSessionGrants[r.extensionID]
|
|
delete(pendingSignedSessionGrants, r.extensionID)
|
|
pendingSignedSessionGrantsMu.Unlock()
|
|
}
|
|
if grant == "" {
|
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": "no pending grant"})
|
|
}
|
|
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
|
}
|
|
return r.vm.ToValue(map[string]interface{}{"success": true})
|
|
}
|
|
|
|
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
|
record, err := r.loadSignedSession(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload := map[string]interface{}{
|
|
"grant": grant,
|
|
"install_id": record.InstallID,
|
|
"app_version": config.AppVersion,
|
|
"platform": config.Platform,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
|
resp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := readExtensionHTTPResponseBody(resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
|
|
}
|
|
var exchanged signedSessionExchangeResponse
|
|
if err := json.Unmarshal(respBody, &exchanged); err != nil {
|
|
return fmt.Errorf("invalid session exchange response: %w", err)
|
|
}
|
|
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
|
|
return fmt.Errorf("session exchange response missing session fields")
|
|
}
|
|
record.SessionID = exchanged.SessionID
|
|
record.SessionSecret = exchanged.SessionSecret
|
|
record.ExpiresAt = exchanged.ExpiresAt
|
|
return r.saveSignedSession(config, record)
|
|
}
|
|
|
|
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 2 {
|
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "method and path are required"})
|
|
}
|
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
|
if config.Namespace == "" || config.BaseURL == "" {
|
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "signedSession is not configured"})
|
|
}
|
|
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
|
|
requestPath := call.Arguments[1].String()
|
|
body := []byte{}
|
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
|
switch v := call.Arguments[2].Export().(type) {
|
|
case string:
|
|
body = []byte(v)
|
|
case map[string]interface{}, []interface{}:
|
|
encoded, err := json.Marshal(v)
|
|
if err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
|
}
|
|
body = encoded
|
|
default:
|
|
body = []byte(call.Arguments[2].String())
|
|
}
|
|
}
|
|
extraHeaders := map[string]string{}
|
|
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
|
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
|
|
for k, v := range h {
|
|
extraHeaders[k] = fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
record, err := r.ensureSignedSession(config)
|
|
if err != nil {
|
|
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
|
return r.signedSessionVerificationRequiredValue(authURL)
|
|
}
|
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
|
}
|
|
|
|
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
|
|
if err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
|
|
record.SessionID = ""
|
|
record.SessionSecret = ""
|
|
record.ExpiresAt = ""
|
|
_ = r.saveSignedSession(config, record)
|
|
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
|
return r.signedSessionVerificationRequiredValue(authURL)
|
|
}
|
|
}
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"statusCode": resp.StatusCode,
|
|
"status": resp.StatusCode,
|
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
|
"url": resp.Request.URL.String(),
|
|
"body": string(respBody),
|
|
"headers": respHeaders,
|
|
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
|
|
})
|
|
}
|
|
|
|
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"ok": false,
|
|
"needsVerification": true,
|
|
"error": "VERIFY_REQUIRED",
|
|
"open_auth_url": authURL,
|
|
"auth_url": authURL,
|
|
})
|
|
}
|
|
|
|
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
|
record, err := r.loadSignedSession(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if record.SessionID == "" || record.SessionSecret == "" {
|
|
return nil, fmt.Errorf("signed session is not authenticated")
|
|
}
|
|
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
|
|
if time.Now().After(expiresAt) {
|
|
record.SessionID = ""
|
|
record.SessionSecret = ""
|
|
record.ExpiresAt = ""
|
|
_ = r.saveSignedSession(config, record)
|
|
return nil, fmt.Errorf("signed session expired")
|
|
}
|
|
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
|
|
_ = r.refreshSignedSession(config, record)
|
|
}
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
|
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
|
|
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
|
|
}
|
|
var refreshed signedSessionExchangeResponse
|
|
if err := json.Unmarshal(respBody, &refreshed); err != nil {
|
|
return err
|
|
}
|
|
changed := false
|
|
if refreshed.SessionID != "" {
|
|
record.SessionID = refreshed.SessionID
|
|
changed = true
|
|
}
|
|
if refreshed.SessionSecret != "" {
|
|
record.SessionSecret = refreshed.SessionSecret
|
|
changed = true
|
|
}
|
|
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
|
|
record.ExpiresAt = refreshed.ExpiresAt
|
|
changed = true
|
|
}
|
|
if changed {
|
|
return r.saveSignedSession(config, record)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) string {
|
|
record, err := r.loadSignedSession(config)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
parsed, _ := url.Parse(bootstrapURL)
|
|
query := parsed.Query()
|
|
query.Set("app_version", config.AppVersion)
|
|
query.Set("install_id", record.InstallID)
|
|
parsed.RawQuery = query.Encode()
|
|
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
|
resp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
|
|
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return ""
|
|
}
|
|
var boot signedSessionExchangeResponse
|
|
if err := json.Unmarshal(body, &boot); err != nil {
|
|
return ""
|
|
}
|
|
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
|
record.SessionID = boot.SessionID
|
|
record.SessionSecret = boot.SessionSecret
|
|
record.ExpiresAt = boot.ExpiresAt
|
|
_ = r.saveSignedSession(config, record)
|
|
return ""
|
|
}
|
|
authURL := boot.AuthURL
|
|
if authURL == "" && boot.ChallengeURL != "" {
|
|
authURL = boot.ChallengeURL
|
|
}
|
|
if authURL == "" && boot.ChallengeID != "" {
|
|
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
|
}
|
|
if authURL != "" {
|
|
pendingAuthRequestsMu.Lock()
|
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
|
ExtensionID: r.extensionID,
|
|
AuthURL: authURL,
|
|
CallbackURL: config.CallbackURL,
|
|
}
|
|
pendingAuthRequestsMu.Unlock()
|
|
}
|
|
return authURL
|
|
}
|
|
|
|
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
|
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(challengeURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
callback, err := url.Parse(config.CallbackURL)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
q := callback.Query()
|
|
q.Set("cb_version", "v2grant")
|
|
q.Set("state", r.extensionID)
|
|
callback.RawQuery = q.Encode()
|
|
|
|
query := parsed.Query()
|
|
query.Set("id", challengeID)
|
|
query.Set("cb", callback.String())
|
|
parsed.RawQuery = query.Encode()
|
|
return parsed.String()
|
|
}
|
|
|
|
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
|
|
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
|
|
if err != nil || base.Scheme != "https" || base.Host == "" {
|
|
return "", fmt.Errorf("invalid signed session baseUrl")
|
|
}
|
|
endpoint = strings.TrimSpace(endpoint)
|
|
if endpoint == "" {
|
|
return "", fmt.Errorf("signed session endpoint is empty")
|
|
}
|
|
if strings.HasPrefix(endpoint, "https://") {
|
|
return endpoint, nil
|
|
}
|
|
endpoint = strings.TrimLeft(endpoint, "/")
|
|
ref, _ := url.Parse(endpoint)
|
|
return base.ResolveReference(ref).String(), nil
|
|
}
|
|
|
|
func (r *extensionRuntime) doSignedSessionRequest(
|
|
config SignedSessionConfig,
|
|
record *signedSessionRecord,
|
|
method string,
|
|
requestPath string,
|
|
body []byte,
|
|
extraHeaders map[string]string,
|
|
) (*http.Response, []byte, map[string]interface{}, error) {
|
|
fullURL, err := signedSessionURL(config, requestPath)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
parsed, err := url.Parse(fullURL)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
|
nonce := randomHex(12)
|
|
bodyHashBytes := sha256.Sum256(body)
|
|
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
|
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
|
|
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
|
|
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
|
|
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
|
|
signingInput := strings.Join([]string{
|
|
config.SchemeLabel,
|
|
method,
|
|
parsed.EscapedPath(),
|
|
"",
|
|
bodyHash,
|
|
ts,
|
|
nonce,
|
|
record.SessionID,
|
|
config.AppVersion,
|
|
config.Platform,
|
|
}, "\n")
|
|
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
|
|
|
|
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
req = r.bindDownloadCancelContext(req)
|
|
req.Header.Set("Accept", "application/json")
|
|
if len(body) > 0 {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
|
prefix := config.HeaderPrefix
|
|
req.Header.Set(prefix+"Session", record.SessionID)
|
|
req.Header.Set(prefix+"Timestamp", ts)
|
|
req.Header.Set(prefix+"Nonce", nonce)
|
|
req.Header.Set(prefix+"Body-SHA256", bodyHash)
|
|
req.Header.Set(prefix+"Signature", sig)
|
|
req.Header.Set(prefix+"App-Version", config.AppVersion)
|
|
req.Header.Set(prefix+"Platform", config.Platform)
|
|
for k, v := range extraHeaders {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
resp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := readExtensionHTTPResponseBody(resp)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
headers := make(map[string]interface{})
|
|
for k, v := range resp.Header {
|
|
if len(v) == 1 {
|
|
headers[k] = v[0]
|
|
} else {
|
|
headers[k] = v
|
|
}
|
|
}
|
|
return resp, respBody, headers, nil
|
|
}
|
|
|
|
func signedSessionRetryAfterSeconds(resp *http.Response) int {
|
|
if resp == nil {
|
|
return 0
|
|
}
|
|
value := strings.TrimSpace(resp.Header.Get("Retry-After"))
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
if seconds, err := strconv.Atoi(value); err == nil {
|
|
if seconds < 0 {
|
|
return 0
|
|
}
|
|
return seconds
|
|
}
|
|
if retryAt, err := http.ParseTime(value); err == nil {
|
|
seconds := int(time.Until(retryAt).Seconds())
|
|
if seconds < 0 {
|
|
return 0
|
|
}
|
|
return seconds
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func hmacSHA256Bytes(key, message []byte) []byte {
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write(message)
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
func setPendingSignedSessionGrant(extensionID, grant string) {
|
|
extensionID = strings.TrimSpace(extensionID)
|
|
grant = strings.TrimSpace(grant)
|
|
if extensionID == "" || grant == "" {
|
|
return
|
|
}
|
|
pendingSignedSessionGrantsMu.Lock()
|
|
pendingSignedSessionGrants[extensionID] = grant
|
|
pendingSignedSessionGrantsMu.Unlock()
|
|
}
|