using System; using System.Collections.Generic; using System.Globalization; using System.Net.Http; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Floatplane.Configuration; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Floatplane.Api; /// /// OAuth 2.0 device-code login against Floatplane's Keycloak /// (auth.floatplane.com), the way Hydravion / Wasserflug / floatplane-downloader /// log in. It needs a client_id registered by Floatplane; without one the server /// answers invalid_client. Tokens are kept in the plugin configuration. DPoP-bound /// clients are not supported (plain Bearer only). /// public class FloatplaneAuthService { public const string Issuer = "https://auth.floatplane.com/realms/floatplane"; private const string DeviceEndpoint = Issuer + "/protocol/openid-connect/auth/device"; private const string TokenEndpoint = Issuer + "/protocol/openid-connect/token"; private const string Scope = "openid profile email offline_access"; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; private readonly SemaphoreSlim _refreshLock = new(1, 1); private PendingDeviceLogin? _pending; public FloatplaneAuthService(IHttpClientFactory httpClientFactory, ILogger logger) { _httpClientFactory = httpClientFactory; _logger = logger; } private static PluginConfiguration Config => Plugin.Instance?.Configuration ?? new PluginConfiguration(); /// True when a refresh token is stored (OAuth login completed at some point). public static bool HasOAuthSession => !string.IsNullOrEmpty(Config.OAuthRefreshToken); /// Starts a device login: returns the code and URL the user must open. public async Task StartDeviceLoginAsync(string clientId, CancellationToken ct) { var form = new Dictionary { ["client_id"] = clientId, ["scope"] = Scope }; using var response = await PostFormAsync(DeviceEndpoint, form, ct).ConfigureAwait(false); var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var err = Parse(json); throw new FloatplaneApiException(response.StatusCode, err?.Error, err?.ErrorDescription ?? "Device authorization failed"); } var start = Parse(json) ?? throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected device authorization response"); _pending = new PendingDeviceLogin(clientId, start.DeviceCode, DateTimeOffset.UtcNow.AddSeconds(start.ExpiresIn), Math.Max(5, start.Interval)); _logger.LogInformation("Floatplane device login started; user code {Code}", start.UserCode); return start; } /// One poll of the token endpoint; "pending" until the user has approved. public async Task PollDeviceLoginAsync(CancellationToken ct) { var pending = _pending; if (pending == null) { return new DeviceLoginStatus { State = "none", Message = "No login in progress" }; } if (pending.ExpiresAt < DateTimeOffset.UtcNow) { _pending = null; return new DeviceLoginStatus { State = "expired", Message = "The code expired; start again" }; } var form = new Dictionary { ["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code", ["client_id"] = pending.ClientId, ["device_code"] = pending.DeviceCode }; using var response = await PostFormAsync(TokenEndpoint, form, ct).ConfigureAwait(false); var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (response.IsSuccessStatusCode) { var tokens = Parse(json) ?? throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected token response"); StoreTokens(tokens, pending.ClientId); _pending = null; return new DeviceLoginStatus { State = "done", Message = "Logged in" }; } var err = Parse(json); switch (err?.Error) { case "authorization_pending": return new DeviceLoginStatus { State = "pending", Message = "Waiting for approval…", Interval = pending.Interval }; case "slow_down": pending.Interval += 5; return new DeviceLoginStatus { State = "pending", Message = "Waiting (slower)…", Interval = pending.Interval }; default: _pending = null; return new DeviceLoginStatus { State = "error", Message = err?.ErrorDescription ?? err?.Error ?? "Login failed" }; } } /// Forgets the OAuth tokens (local only; Floatplane keeps the session until it expires). public static void Logout() { var cfg = Config; cfg.OAuthAccessToken = string.Empty; cfg.OAuthRefreshToken = string.Empty; cfg.OAuthAccessTokenExpiry = 0; Plugin.Instance?.SaveConfiguration(); } /// /// Access token for API calls, refreshed when it has less than a minute left. Never /// refresh per request: ~40 refreshes in a row trip Cloudflare's 429 on auth.floatplane.com. /// public async Task GetAccessTokenAsync(CancellationToken ct) { var cfg = Config; if (string.IsNullOrEmpty(cfg.OAuthRefreshToken)) { return null; } var expiry = DateTimeOffset.FromUnixTimeSeconds(cfg.OAuthAccessTokenExpiry); if (!string.IsNullOrEmpty(cfg.OAuthAccessToken) && expiry > DateTimeOffset.UtcNow.AddSeconds(60)) { return cfg.OAuthAccessToken; } await _refreshLock.WaitAsync(ct).ConfigureAwait(false); try { cfg = Config; expiry = DateTimeOffset.FromUnixTimeSeconds(cfg.OAuthAccessTokenExpiry); if (!string.IsNullOrEmpty(cfg.OAuthAccessToken) && expiry > DateTimeOffset.UtcNow.AddSeconds(60)) { return cfg.OAuthAccessToken; } var form = new Dictionary { ["grant_type"] = "refresh_token", ["client_id"] = cfg.OAuthClientId, ["refresh_token"] = cfg.OAuthRefreshToken }; using var response = await PostFormAsync(TokenEndpoint, form, ct).ConfigureAwait(false); var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var err = Parse(json); _logger.LogWarning("Floatplane token refresh failed: {Error} {Description}", err?.Error, err?.ErrorDescription); if (err?.Error == "invalid_grant") { Logout(); } throw new FloatplaneAuthException(response.StatusCode, err?.Error ?? "refreshFailed", err?.ErrorDescription ?? "Token refresh failed"); } var tokens = Parse(json) ?? throw new FloatplaneApiException(response.StatusCode, "parseError", "Unexpected token response"); StoreTokens(tokens, cfg.OAuthClientId); return tokens.AccessToken; } finally { _refreshLock.Release(); } } private static void StoreTokens(TokenResponse tokens, string clientId) { var cfg = Config; cfg.OAuthClientId = clientId; cfg.OAuthAccessToken = tokens.AccessToken ?? string.Empty; if (!string.IsNullOrEmpty(tokens.RefreshToken)) { cfg.OAuthRefreshToken = tokens.RefreshToken; // rotated on every refresh } cfg.OAuthAccessTokenExpiry = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, tokens.ExpiresIn)).ToUnixTimeSeconds(); Plugin.Instance?.SaveConfiguration(); } private async Task PostFormAsync(string url, Dictionary form, CancellationToken ct) { var client = _httpClientFactory.CreateClient(PluginServiceRegistrator.HttpClientName); using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = new FormUrlEncodedContent(form) }; return await client.SendAsync(request, ct).ConfigureAwait(false); } private static T? Parse(string json) { try { return JsonSerializer.Deserialize(json); } catch (JsonException) { return default; } } private sealed class PendingDeviceLogin { public PendingDeviceLogin(string clientId, string deviceCode, DateTimeOffset expiresAt, int interval) { ClientId = clientId; DeviceCode = deviceCode; ExpiresAt = expiresAt; Interval = interval; } public string ClientId { get; } public string DeviceCode { get; } public DateTimeOffset ExpiresAt { get; } public int Interval { get; set; } } } public class DeviceLoginStart { [JsonPropertyName("device_code")] public string DeviceCode { get; set; } = string.Empty; [JsonPropertyName("user_code")] public string UserCode { get; set; } = string.Empty; [JsonPropertyName("verification_uri")] public string VerificationUri { get; set; } = string.Empty; [JsonPropertyName("verification_uri_complete")] public string? VerificationUriComplete { get; set; } [JsonPropertyName("expires_in")] public int ExpiresIn { get; set; } = 600; [JsonPropertyName("interval")] public int Interval { get; set; } = 5; } public class DeviceLoginStatus { public string State { get; set; } = string.Empty; public string? Message { get; set; } public int Interval { get; set; } = 5; } public class TokenResponse { [JsonPropertyName("access_token")] public string? AccessToken { get; set; } [JsonPropertyName("refresh_token")] public string? RefreshToken { get; set; } [JsonPropertyName("expires_in")] public int ExpiresIn { get; set; } [JsonPropertyName("token_type")] public string? TokenType { get; set; } } public class OAuthError { [JsonPropertyName("error")] public string? Error { get; set; } [JsonPropertyName("error_description")] public string? ErrorDescription { get; set; } }