- Add Microsoft.Web.WebView2 package - New EbayLoginWindow using Edge (WebView2) instead of IE — handles modern eBay login pages - Intercept ThirdPartyAuthSucessFailure redirect to extract auth code - Remove invalid sell.listing scope that caused immediate invalid_scope error - Add detailed browser navigation logging for diagnostics Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
296 lines
11 KiB
C#
296 lines
11 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using System.Windows;
|
|
using EbayListingTool.Models;
|
|
using EbayListingTool.Views;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace EbayListingTool.Services;
|
|
|
|
public class EbayAuthService
|
|
{
|
|
private readonly EbaySettings _settings;
|
|
private readonly string _tokenPath;
|
|
private EbayToken? _token;
|
|
|
|
// App-level (client credentials) token — no user login needed, used for Browse API
|
|
private string? _appToken;
|
|
private DateTime _appTokenExpiry = DateTime.MinValue;
|
|
private readonly SemaphoreSlim _appTokenLock = new(1, 1); // prevents concurrent token fetches
|
|
|
|
// Shared HttpClient for all auth/token calls — avoids socket exhaustion from per-call `new HttpClient()`
|
|
private static readonly HttpClient _http = new();
|
|
|
|
private static readonly string[] Scopes =
|
|
[
|
|
"https://api.ebay.com/oauth/api_scope",
|
|
"https://api.ebay.com/oauth/api_scope/sell.inventory",
|
|
"https://api.ebay.com/oauth/api_scope/sell.fulfillment",
|
|
"https://api.ebay.com/oauth/api_scope/sell.account"
|
|
];
|
|
|
|
public EbayAuthService(IConfiguration config)
|
|
{
|
|
_settings = config.GetSection("Ebay").Get<EbaySettings>() ?? new EbaySettings();
|
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
|
var dir = Path.Combine(appData, "EbayListingTool");
|
|
Directory.CreateDirectory(dir);
|
|
_tokenPath = Path.Combine(dir, "tokens.json");
|
|
}
|
|
|
|
public string? ConnectedUsername => _token?.EbayUsername;
|
|
public bool IsConnected => _token?.IsAccessTokenValid == true || _token?.IsRefreshTokenValid == true;
|
|
|
|
public string BaseUrl => _settings.Sandbox
|
|
? "https://api.sandbox.ebay.com"
|
|
: "https://api.ebay.com";
|
|
|
|
public async Task<string> GetValidAccessTokenAsync()
|
|
{
|
|
_token ??= LoadToken();
|
|
|
|
if (_token == null)
|
|
throw new InvalidOperationException("Not authenticated. Please connect to eBay first.");
|
|
|
|
if (_token.IsAccessTokenValid)
|
|
return _token.AccessToken;
|
|
|
|
if (_token.IsRefreshTokenValid)
|
|
{
|
|
await RefreshAccessTokenAsync();
|
|
return _token.AccessToken;
|
|
}
|
|
|
|
throw new InvalidOperationException("eBay session expired. Please reconnect.");
|
|
}
|
|
|
|
public async Task<string> LoginAsync()
|
|
{
|
|
var scopeString = Uri.EscapeDataString(string.Join(" ", Scopes));
|
|
var authBase = _settings.Sandbox
|
|
? "https://auth.sandbox.ebay.com/oauth2/authorize"
|
|
: "https://auth.ebay.com/oauth2/authorize";
|
|
|
|
var authUrl = $"{authBase}?client_id={_settings.ClientId}" +
|
|
$"&redirect_uri={Uri.EscapeDataString(_settings.RuName)}" +
|
|
$"&response_type=code&scope={scopeString}";
|
|
|
|
Log($"LoginAsync start — RuName={_settings.RuName}, ClientId={_settings.ClientId}");
|
|
|
|
string? code = null;
|
|
|
|
// Open embedded browser dialog on UI thread and block until it closes
|
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
|
{
|
|
var win = new EbayLoginWindow(authUrl);
|
|
var result = win.ShowDialog();
|
|
if (result == true)
|
|
code = win.AuthCode;
|
|
});
|
|
|
|
if (string.IsNullOrEmpty(code))
|
|
{
|
|
Log("User cancelled login or no code returned");
|
|
throw new InvalidOperationException("eBay login was cancelled or did not return an authorisation code.");
|
|
}
|
|
|
|
Log($"Auth code received (length={code.Length})");
|
|
await ExchangeCodeForTokenAsync(code);
|
|
return _token!.EbayUsername;
|
|
}
|
|
|
|
private static readonly string LogFile = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"EbayListingTool", "auth_log.txt");
|
|
|
|
private static void Log(string msg)
|
|
{
|
|
try { File.AppendAllText(LogFile, $"{DateTime.Now:HH:mm:ss} {msg}\n"); } catch { }
|
|
}
|
|
|
|
private async Task ExchangeCodeForTokenAsync(string code)
|
|
{
|
|
var tokenUrl = _settings.Sandbox
|
|
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
|
|
: "https://api.ebay.com/identity/v1/oauth2/token";
|
|
|
|
var credentials = Convert.ToBase64String(
|
|
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
|
|
|
|
using var codeRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
|
|
codeRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
|
codeRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["grant_type"] = "authorization_code",
|
|
["code"] = code,
|
|
["redirect_uri"] = _settings.RuName
|
|
});
|
|
|
|
Log($"Token exchange → POST {tokenUrl}");
|
|
var response = await _http.SendAsync(codeRequest);
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
Log($"Token exchange ← {(int)response.StatusCode}: {json}");
|
|
|
|
System.Diagnostics.Debug.WriteLine($"[eBay token exchange] {(int)response.StatusCode}: {json}");
|
|
if (!response.IsSuccessStatusCode)
|
|
throw new HttpRequestException($"Token exchange failed ({(int)response.StatusCode}): {json}");
|
|
|
|
var obj = JObject.Parse(json);
|
|
|
|
var accessToken = obj["access_token"]?.ToString()
|
|
?? throw new InvalidOperationException($"No access_token in response: {json}");
|
|
var expiresIn = obj["expires_in"]?.Value<int>() ?? 7200;
|
|
var refreshToken = obj["refresh_token"]?.ToString() ?? "";
|
|
var refreshExpiresIn = obj["refresh_token_expires_in"]?.Value<int>() ?? 0;
|
|
|
|
_token = new EbayToken
|
|
{
|
|
AccessToken = accessToken,
|
|
RefreshToken = refreshToken,
|
|
AccessTokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn),
|
|
RefreshTokenExpiry = refreshExpiresIn > 0
|
|
? DateTime.UtcNow.AddSeconds(refreshExpiresIn)
|
|
: DateTime.UtcNow.AddDays(18 * 30), // eBay default: 18 months
|
|
};
|
|
Log($"Token set — AccessToken length={accessToken.Length}, Expiry={_token.AccessTokenExpiry:HH:mm:ss}, IsValid={_token.IsAccessTokenValid}");
|
|
|
|
// Fetch username
|
|
_token.EbayUsername = await FetchUsernameAsync(_token.AccessToken);
|
|
SaveToken(_token);
|
|
}
|
|
|
|
private async Task RefreshAccessTokenAsync()
|
|
{
|
|
var tokenUrl = _settings.Sandbox
|
|
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
|
|
: "https://api.ebay.com/identity/v1/oauth2/token";
|
|
|
|
var credentials = Convert.ToBase64String(
|
|
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
|
|
|
|
using var refreshRequest = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
|
|
refreshRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
|
refreshRequest.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["grant_type"] = "refresh_token",
|
|
["refresh_token"] = _token!.RefreshToken,
|
|
["scope"] = string.Join(" ", Scopes)
|
|
});
|
|
|
|
var response = await _http.SendAsync(refreshRequest);
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
throw new HttpRequestException($"Token refresh failed: {json}");
|
|
|
|
var obj = JObject.Parse(json);
|
|
_token.AccessToken = obj["access_token"]!.ToString();
|
|
_token.AccessTokenExpiry = DateTime.UtcNow.AddSeconds(obj["expires_in"]!.Value<int>());
|
|
SaveToken(_token);
|
|
}
|
|
|
|
private async Task<string> FetchUsernameAsync(string accessToken)
|
|
{
|
|
try
|
|
{
|
|
var url = _settings.Sandbox
|
|
? "https://apiz.sandbox.ebay.com/commerce/identity/v1/user/"
|
|
: "https://apiz.ebay.com/commerce/identity/v1/user/";
|
|
using var idRequest = new HttpRequestMessage(HttpMethod.Get, url);
|
|
idRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
var idResponse = await _http.SendAsync(idRequest);
|
|
var json = await idResponse.Content.ReadAsStringAsync();
|
|
var obj = JObject.Parse(json);
|
|
return obj["username"]?.ToString() ?? "Unknown";
|
|
}
|
|
catch
|
|
{
|
|
return "Connected";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns an application-level OAuth token (client credentials grant).
|
|
/// Does NOT require the user to be logged in — uses Client ID + Secret only.
|
|
/// Cached for the token lifetime minus a 5-minute safety margin.
|
|
/// </summary>
|
|
public async Task<string> GetAppTokenAsync()
|
|
{
|
|
// Fast path — no lock needed for a read when token is valid
|
|
if (_appToken != null && DateTime.UtcNow < _appTokenExpiry)
|
|
return _appToken;
|
|
|
|
await _appTokenLock.WaitAsync();
|
|
try
|
|
{
|
|
// Re-check inside lock (another thread may have refreshed while we waited)
|
|
if (_appToken != null && DateTime.UtcNow < _appTokenExpiry)
|
|
return _appToken;
|
|
|
|
if (string.IsNullOrEmpty(_settings.ClientId) || string.IsNullOrEmpty(_settings.ClientSecret))
|
|
throw new InvalidOperationException("eBay Client ID / Secret not configured in appsettings.json.");
|
|
|
|
var tokenUrl = _settings.Sandbox
|
|
? "https://api.sandbox.ebay.com/identity/v1/oauth2/token"
|
|
: "https://api.ebay.com/identity/v1/oauth2/token";
|
|
|
|
var credentials = Convert.ToBase64String(
|
|
Encoding.UTF8.GetBytes($"{_settings.ClientId}:{_settings.ClientSecret}"));
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Post, tokenUrl);
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
|
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["grant_type"] = "client_credentials",
|
|
["scope"] = "https://api.ebay.com/oauth/api_scope"
|
|
});
|
|
|
|
var response = await _http.SendAsync(request);
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
throw new HttpRequestException($"App token request failed ({(int)response.StatusCode})");
|
|
|
|
var obj = JObject.Parse(json);
|
|
_appToken = obj["access_token"]!.ToString();
|
|
var expiresIn = obj["expires_in"]?.Value<int>() ?? 7200;
|
|
_appTokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn - 300); // 5-min buffer
|
|
return _appToken;
|
|
}
|
|
finally
|
|
{
|
|
_appTokenLock.Release();
|
|
}
|
|
}
|
|
|
|
public void Disconnect()
|
|
{
|
|
_token = null;
|
|
if (File.Exists(_tokenPath))
|
|
File.Delete(_tokenPath);
|
|
}
|
|
|
|
public void TryLoadSavedToken()
|
|
{
|
|
_token = LoadToken();
|
|
}
|
|
|
|
private EbayToken? LoadToken()
|
|
{
|
|
if (!File.Exists(_tokenPath)) return null;
|
|
try
|
|
{
|
|
var json = File.ReadAllText(_tokenPath);
|
|
return JsonConvert.DeserializeObject<EbayToken>(json);
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
private void SaveToken(EbayToken token)
|
|
{
|
|
File.WriteAllText(_tokenPath, JsonConvert.SerializeObject(token, Formatting.Indented));
|
|
}
|
|
}
|