Files
EbayListingTool/EbayListingTool/Services/EbayAuthService.cs
Peter Foster da0efc1374 Replace IE WebBrowser with WebView2 for eBay OAuth login
- 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>
2026-04-14 17:06:39 +01:00

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));
}
}