mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 17:10:29 +02:00
553 lines
14 KiB
Go
553 lines
14 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
func validateExtensionAuthURL(urlStr string) error {
|
|
parsed, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid auth URL: %w", err)
|
|
}
|
|
|
|
if parsed.Scheme != "https" {
|
|
return fmt.Errorf("invalid auth URL: only https is allowed")
|
|
}
|
|
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
return fmt.Errorf("invalid auth URL: hostname is required")
|
|
}
|
|
|
|
if parsed.User != nil {
|
|
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
|
|
}
|
|
|
|
if isPrivateIP(host) {
|
|
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func summarizeURLForLog(urlStr string) string {
|
|
parsed, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return urlStr
|
|
}
|
|
if parsed.Host == "" {
|
|
return parsed.Scheme + "://"
|
|
}
|
|
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
|
}
|
|
|
|
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "auth URL is required",
|
|
})
|
|
}
|
|
|
|
authURL := call.Arguments[0].String()
|
|
callbackURL := ""
|
|
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) {
|
|
callbackURL = call.Arguments[1].String()
|
|
}
|
|
|
|
if err := validateExtensionAuthURL(authURL); err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
pendingAuthRequestsMu.Lock()
|
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
|
ExtensionID: r.extensionID,
|
|
AuthURL: authURL,
|
|
CallbackURL: callbackURL,
|
|
}
|
|
pendingAuthRequestsMu.Unlock()
|
|
|
|
extensionAuthStateMu.Lock()
|
|
state, exists := extensionAuthState[r.extensionID]
|
|
if !exists {
|
|
state = &ExtensionAuthState{}
|
|
extensionAuthState[r.extensionID] = state
|
|
}
|
|
state.PendingAuthURL = authURL
|
|
state.AuthCode = ""
|
|
extensionAuthStateMu.Unlock()
|
|
|
|
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
|
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": true,
|
|
"message": "Auth URL will be opened by the app",
|
|
})
|
|
}
|
|
|
|
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
|
extensionAuthStateMu.RLock()
|
|
defer extensionAuthStateMu.RUnlock()
|
|
|
|
state, exists := extensionAuthState[r.extensionID]
|
|
if !exists || state.AuthCode == "" {
|
|
return goja.Undefined()
|
|
}
|
|
|
|
return r.vm.ToValue(state.AuthCode)
|
|
}
|
|
|
|
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
arg := call.Arguments[0].Export()
|
|
|
|
extensionAuthStateMu.Lock()
|
|
defer extensionAuthStateMu.Unlock()
|
|
|
|
state, exists := extensionAuthState[r.extensionID]
|
|
if !exists {
|
|
state = &ExtensionAuthState{}
|
|
extensionAuthState[r.extensionID] = state
|
|
}
|
|
|
|
switch v := arg.(type) {
|
|
case string:
|
|
state.AuthCode = v
|
|
case map[string]interface{}:
|
|
if code, ok := v["code"].(string); ok {
|
|
state.AuthCode = code
|
|
}
|
|
if accessToken, ok := v["access_token"].(string); ok {
|
|
state.AccessToken = accessToken
|
|
state.IsAuthenticated = true
|
|
}
|
|
if refreshToken, ok := v["refresh_token"].(string); ok {
|
|
state.RefreshToken = refreshToken
|
|
}
|
|
if expiresIn, ok := v["expires_in"].(float64); ok {
|
|
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
|
}
|
|
}
|
|
|
|
return r.vm.ToValue(true)
|
|
}
|
|
|
|
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
|
extensionAuthStateMu.Lock()
|
|
delete(extensionAuthState, r.extensionID)
|
|
extensionAuthStateMu.Unlock()
|
|
|
|
pendingAuthRequestsMu.Lock()
|
|
delete(pendingAuthRequests, r.extensionID)
|
|
pendingAuthRequestsMu.Unlock()
|
|
|
|
GoLog("[Extension:%s] Auth state cleared\n", r.extensionID)
|
|
return r.vm.ToValue(true)
|
|
}
|
|
|
|
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
|
extensionAuthStateMu.RLock()
|
|
defer extensionAuthStateMu.RUnlock()
|
|
|
|
state, exists := extensionAuthState[r.extensionID]
|
|
if !exists {
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
|
return r.vm.ToValue(false)
|
|
}
|
|
|
|
return r.vm.ToValue(state.IsAuthenticated)
|
|
}
|
|
|
|
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
|
extensionAuthStateMu.RLock()
|
|
defer extensionAuthStateMu.RUnlock()
|
|
|
|
state, exists := extensionAuthState[r.extensionID]
|
|
if !exists {
|
|
return r.vm.ToValue(map[string]interface{}{})
|
|
}
|
|
|
|
result := map[string]interface{}{
|
|
"access_token": state.AccessToken,
|
|
"refresh_token": state.RefreshToken,
|
|
"is_authenticated": state.IsAuthenticated,
|
|
}
|
|
|
|
if !state.ExpiresAt.IsZero() {
|
|
result["expires_at"] = state.ExpiresAt.Unix()
|
|
result["is_expired"] = time.Now().After(state.ExpiresAt)
|
|
}
|
|
|
|
return r.vm.ToValue(result)
|
|
}
|
|
|
|
// Length should be between 43-128 characters (RFC 7636)
|
|
func generatePKCEVerifier(length int) (string, error) {
|
|
if length < 43 {
|
|
length = 43
|
|
}
|
|
if length > 128 {
|
|
length = 128
|
|
}
|
|
|
|
bytes := make([]byte, length)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
verifier := base64.RawURLEncoding.EncodeToString(bytes)
|
|
|
|
if len(verifier) > length {
|
|
verifier = verifier[:length]
|
|
}
|
|
|
|
return verifier, nil
|
|
}
|
|
|
|
func generatePKCEChallenge(verifier string) string {
|
|
hash := sha256.Sum256([]byte(verifier))
|
|
// Base64url encode without padding (RFC 7636)
|
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
|
}
|
|
|
|
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
|
length := 64
|
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
|
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
|
length = int(l)
|
|
}
|
|
}
|
|
|
|
verifier, err := generatePKCEVerifier(length)
|
|
if err != nil {
|
|
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
challenge := generatePKCEChallenge(verifier)
|
|
|
|
extensionAuthStateMu.Lock()
|
|
state, exists := extensionAuthState[r.extensionID]
|
|
if !exists {
|
|
state = &ExtensionAuthState{}
|
|
extensionAuthState[r.extensionID] = state
|
|
}
|
|
state.PKCEVerifier = verifier
|
|
state.PKCEChallenge = challenge
|
|
extensionAuthStateMu.Unlock()
|
|
|
|
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
|
|
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"verifier": verifier,
|
|
"challenge": challenge,
|
|
"method": "S256",
|
|
})
|
|
}
|
|
|
|
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
|
extensionAuthStateMu.RLock()
|
|
defer extensionAuthStateMu.RUnlock()
|
|
|
|
state, exists := extensionAuthState[r.extensionID]
|
|
if !exists || state.PKCEVerifier == "" {
|
|
return r.vm.ToValue(map[string]interface{}{})
|
|
}
|
|
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"verifier": state.PKCEVerifier,
|
|
"challenge": state.PKCEChallenge,
|
|
"method": "S256",
|
|
})
|
|
}
|
|
|
|
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
|
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "config object is required",
|
|
})
|
|
}
|
|
|
|
configObj := call.Arguments[0].Export()
|
|
config, ok := configObj.(map[string]interface{})
|
|
if !ok {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "config must be an object",
|
|
})
|
|
}
|
|
|
|
authURL, _ := config["authUrl"].(string)
|
|
clientID, _ := config["clientId"].(string)
|
|
redirectURI, _ := config["redirectUri"].(string)
|
|
|
|
if authURL == "" || clientID == "" || redirectURI == "" {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "authUrl, clientId, and redirectUri are required",
|
|
})
|
|
}
|
|
if err := validateExtensionAuthURL(authURL); err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
scope, _ := config["scope"].(string)
|
|
extraParams, _ := config["extraParams"].(map[string]interface{})
|
|
|
|
verifier, err := generatePKCEVerifier(64)
|
|
if err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
|
|
})
|
|
}
|
|
challenge := generatePKCEChallenge(verifier)
|
|
|
|
extensionAuthStateMu.Lock()
|
|
state, exists := extensionAuthState[r.extensionID]
|
|
if !exists {
|
|
state = &ExtensionAuthState{}
|
|
extensionAuthState[r.extensionID] = state
|
|
}
|
|
state.PKCEVerifier = verifier
|
|
state.PKCEChallenge = challenge
|
|
state.AuthCode = ""
|
|
extensionAuthStateMu.Unlock()
|
|
|
|
parsedURL, err := url.Parse(authURL)
|
|
if err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": fmt.Sprintf("invalid authUrl: %v", err),
|
|
})
|
|
}
|
|
|
|
query := parsedURL.Query()
|
|
query.Set("client_id", clientID)
|
|
query.Set("redirect_uri", redirectURI)
|
|
query.Set("response_type", "code")
|
|
query.Set("code_challenge", challenge)
|
|
query.Set("code_challenge_method", "S256")
|
|
|
|
if scope != "" {
|
|
query.Set("scope", scope)
|
|
}
|
|
|
|
for k, v := range extraParams {
|
|
query.Set(k, fmt.Sprintf("%v", v))
|
|
}
|
|
|
|
parsedURL.RawQuery = query.Encode()
|
|
fullAuthURL := parsedURL.String()
|
|
|
|
pendingAuthRequestsMu.Lock()
|
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
|
ExtensionID: r.extensionID,
|
|
AuthURL: fullAuthURL,
|
|
CallbackURL: redirectURI,
|
|
}
|
|
pendingAuthRequestsMu.Unlock()
|
|
|
|
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
|
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": true,
|
|
"authUrl": fullAuthURL,
|
|
"pkce": map[string]interface{}{
|
|
"verifier": verifier,
|
|
"challenge": challenge,
|
|
"method": "S256",
|
|
},
|
|
})
|
|
}
|
|
|
|
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
|
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
|
if len(call.Arguments) < 1 {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "config object is required",
|
|
})
|
|
}
|
|
|
|
configObj := call.Arguments[0].Export()
|
|
config, ok := configObj.(map[string]interface{})
|
|
if !ok {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "config must be an object",
|
|
})
|
|
}
|
|
|
|
tokenURL, _ := config["tokenUrl"].(string)
|
|
clientID, _ := config["clientId"].(string)
|
|
redirectURI, _ := config["redirectUri"].(string)
|
|
code, _ := config["code"].(string)
|
|
|
|
if tokenURL == "" || clientID == "" || code == "" {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "tokenUrl, clientId, and code are required",
|
|
})
|
|
}
|
|
|
|
extensionAuthStateMu.RLock()
|
|
state, exists := extensionAuthState[r.extensionID]
|
|
var verifier string
|
|
if exists {
|
|
verifier = state.PKCEVerifier
|
|
}
|
|
extensionAuthStateMu.RUnlock()
|
|
|
|
if verifier == "" {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
|
|
})
|
|
}
|
|
|
|
if err := r.validateDomain(tokenURL); err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
formData := url.Values{}
|
|
formData.Set("grant_type", "authorization_code")
|
|
formData.Set("client_id", clientID)
|
|
formData.Set("code", code)
|
|
formData.Set("code_verifier", verifier)
|
|
if redirectURI != "" {
|
|
formData.Set("redirect_uri", redirectURI)
|
|
}
|
|
|
|
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
|
for k, v := range extraParams {
|
|
formData.Set(k, fmt.Sprintf("%v", v))
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
|
if err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
|
|
|
resp, err := r.httpClient.Do(req)
|
|
if err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
bodyPreview := sanitizeSensitiveLogText(string(body))
|
|
if len(bodyPreview) > 1000 {
|
|
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
|
}
|
|
|
|
var tokenResp map[string]interface{}
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
|
"body": bodyPreview,
|
|
})
|
|
}
|
|
|
|
if errMsg, ok := tokenResp["error"].(string); ok {
|
|
errDesc, _ := tokenResp["error_description"].(string)
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": errMsg,
|
|
"error_description": errDesc,
|
|
})
|
|
}
|
|
|
|
accessToken, _ := tokenResp["access_token"].(string)
|
|
refreshToken, _ := tokenResp["refresh_token"].(string)
|
|
expiresIn, _ := tokenResp["expires_in"].(float64)
|
|
|
|
if accessToken == "" {
|
|
return r.vm.ToValue(map[string]interface{}{
|
|
"success": false,
|
|
"error": "no access_token in response",
|
|
"body": bodyPreview,
|
|
})
|
|
}
|
|
|
|
extensionAuthStateMu.Lock()
|
|
state, exists = extensionAuthState[r.extensionID]
|
|
if !exists {
|
|
state = &ExtensionAuthState{}
|
|
extensionAuthState[r.extensionID] = state
|
|
}
|
|
state.AccessToken = accessToken
|
|
state.RefreshToken = refreshToken
|
|
state.IsAuthenticated = true
|
|
if expiresIn > 0 {
|
|
state.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
|
}
|
|
state.PKCEVerifier = ""
|
|
state.PKCEChallenge = ""
|
|
extensionAuthStateMu.Unlock()
|
|
|
|
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
|
|
|
result := map[string]interface{}{
|
|
"success": true,
|
|
"access_token": accessToken,
|
|
"refresh_token": refreshToken,
|
|
"token_type": tokenResp["token_type"],
|
|
}
|
|
if expiresIn > 0 {
|
|
result["expires_in"] = expiresIn
|
|
}
|
|
if scope, ok := tokenResp["scope"].(string); ok {
|
|
result["scope"] = scope
|
|
}
|
|
|
|
return r.vm.ToValue(result)
|
|
}
|