Initial commit: IronLicensing.Client SDK
Software licensing SDK for .NET MAUI applications with: - License validation and activation - Feature gating - Trial support - Offline validation with signature verification - In-app purchase integration
This commit is contained in:
commit
7d453f8f63
|
|
@ -0,0 +1,6 @@
|
|||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
*.DotSettings.user
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
namespace IronLicensing.Client.Exceptions;
|
||||
|
||||
public class LicenseRequiredException : Exception
|
||||
{
|
||||
public string FeatureKey { get; }
|
||||
|
||||
public LicenseRequiredException(string featureKey)
|
||||
: base($"Feature '{featureKey}' requires a valid license")
|
||||
{
|
||||
FeatureKey = featureKey;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IronLicensing.Client.Extensions;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds IronLicensing client services to the service collection
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection</param>
|
||||
/// <param name="configure">Configuration action for licensing options</param>
|
||||
/// <returns>The service collection for chaining</returns>
|
||||
public static IServiceCollection AddIronLicensing(this IServiceCollection services, Action<LicensingOptions> configure)
|
||||
{
|
||||
services.Configure(configure);
|
||||
|
||||
// Core services
|
||||
services.AddHttpClient<ILicenseManager, LicenseManager>();
|
||||
services.AddSingleton<IMachineIdentifier, MachineIdentifier>();
|
||||
services.AddSingleton<ILicenseCache, SecureStorageLicenseCache>();
|
||||
services.AddSingleton<ISignatureVerifier, RsaSignatureVerifier>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds IronLicensing client services with simplified configuration
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection</param>
|
||||
/// <param name="publicKey">Your public API key (pk_live_xxx or pk_test_xxx)</param>
|
||||
/// <param name="productSlug">Your product slug identifier</param>
|
||||
/// <returns>The service collection for chaining</returns>
|
||||
public static IServiceCollection AddIronLicensing(this IServiceCollection services, string publicKey, string productSlug)
|
||||
{
|
||||
return services.AddIronLicensing(options =>
|
||||
{
|
||||
options.PublicKey = publicKey;
|
||||
options.ProductSlug = productSlug;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds IronLicensing client services with custom API URL (for self-hosted installations)
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection</param>
|
||||
/// <param name="publicKey">Your public API key</param>
|
||||
/// <param name="productSlug">Your product slug identifier</param>
|
||||
/// <param name="apiBaseUrl">Custom API base URL</param>
|
||||
/// <returns>The service collection for chaining</returns>
|
||||
public static IServiceCollection AddIronLicensing(
|
||||
this IServiceCollection services,
|
||||
string publicKey,
|
||||
string productSlug,
|
||||
string apiBaseUrl)
|
||||
{
|
||||
return services.AddIronLicensing(options =>
|
||||
{
|
||||
options.PublicKey = publicKey;
|
||||
options.ProductSlug = productSlug;
|
||||
options.ApiBaseUrl = apiBaseUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using IronLicensing.Client.Models;
|
||||
|
||||
namespace IronLicensing.Client;
|
||||
|
||||
public interface ILicenseCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the cached license information
|
||||
/// </summary>
|
||||
Task<CachedLicenseData?> GetCachedLicenseAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Saves license information with optional signature for offline verification
|
||||
/// </summary>
|
||||
Task SaveLicenseAsync(LicenseInfo license, string? signature, string? signingPublicKey);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all cached license data
|
||||
/// </summary>
|
||||
Task ClearAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the cached signing public key
|
||||
/// </summary>
|
||||
Task<string?> GetSigningPublicKeyAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Saves the signing public key
|
||||
/// </summary>
|
||||
Task SaveSigningPublicKeyAsync(string publicKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached license data including signature for offline verification
|
||||
/// </summary>
|
||||
public class CachedLicenseData
|
||||
{
|
||||
public LicenseInfo License { get; set; } = null!;
|
||||
public string? Signature { get; set; }
|
||||
public string? SigningPublicKey { get; set; }
|
||||
public string? RawLicenseJson { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
using IronLicensing.Client.Models;
|
||||
|
||||
namespace IronLicensing.Client;
|
||||
|
||||
public interface ILicenseManager
|
||||
{
|
||||
// State
|
||||
LicenseInfo? CurrentLicense { get; }
|
||||
LicenseStatus Status { get; }
|
||||
bool IsLicensed { get; }
|
||||
bool IsTrial { get; }
|
||||
int TrialDaysRemaining { get; }
|
||||
|
||||
// Events
|
||||
event EventHandler<LicenseChangedEventArgs>? LicenseChanged;
|
||||
event EventHandler<LicenseValidationEventArgs>? ValidationCompleted;
|
||||
|
||||
// Operations
|
||||
Task<LicenseResult> ValidateAsync(string? licenseKey = null);
|
||||
Task<LicenseResult> ActivateAsync(string licenseKey);
|
||||
Task<bool> DeactivateAsync();
|
||||
Task<CheckoutResult> StartPurchaseAsync(string tierId, string customerEmail);
|
||||
|
||||
// Feature Gating
|
||||
bool HasFeature(string featureKey);
|
||||
Task<bool> HasFeatureAsync(string featureKey);
|
||||
void RequireFeature(string featureKey);
|
||||
|
||||
// Trial
|
||||
Task<bool> StartTrialAsync(string email);
|
||||
|
||||
// Cache management
|
||||
Task ClearCacheAsync();
|
||||
}
|
||||
|
||||
public class LicenseChangedEventArgs : EventArgs
|
||||
{
|
||||
public LicenseInfo? OldLicense { get; init; }
|
||||
public LicenseInfo? NewLicense { get; init; }
|
||||
}
|
||||
|
||||
public class LicenseValidationEventArgs : EventArgs
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public LicenseInfo? License { get; init; }
|
||||
public string? ErrorCode { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
namespace IronLicensing.Client;
|
||||
|
||||
public interface IMachineIdentifier
|
||||
{
|
||||
string GetMachineId();
|
||||
string GetMachineName();
|
||||
string GetPlatform();
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
namespace IronLicensing.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies license signatures for offline validation
|
||||
/// </summary>
|
||||
public interface ISignatureVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that the data was signed with the corresponding private key
|
||||
/// </summary>
|
||||
/// <param name="publicKeyPem">The RSA public key in PEM format</param>
|
||||
/// <param name="data">The original data that was signed</param>
|
||||
/// <param name="signature">The base64-encoded signature</param>
|
||||
/// <returns>True if signature is valid</returns>
|
||||
bool Verify(string publicKeyPem, string data, string signature);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<!-- NuGet Package Properties -->
|
||||
<PackageId>IronLicensing.Client</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>David H Friedel Jr</Authors>
|
||||
<Company>MarketAlly</Company>
|
||||
<Description>Client SDK for IronLicensing - Software Licensing Platform</Description>
|
||||
<PackageTags>licensing;software;activation;license-key</PackageTags>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 IronServices
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,448 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using IronLicensing.Client.Exceptions;
|
||||
using IronLicensing.Client.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace IronLicensing.Client;
|
||||
|
||||
public class LicenseManager : ILicenseManager
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly LicensingOptions _options;
|
||||
private readonly IMachineIdentifier _machineIdentifier;
|
||||
private readonly ILicenseCache _cache;
|
||||
private readonly ISignatureVerifier _signatureVerifier;
|
||||
|
||||
private LicenseInfo? _currentLicense;
|
||||
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
||||
|
||||
public LicenseInfo? CurrentLicense => _currentLicense;
|
||||
|
||||
public LicenseStatus Status => _currentLicense?.Status ?? LicenseStatus.Unknown;
|
||||
|
||||
public bool IsLicensed => _currentLicense != null &&
|
||||
(_currentLicense.Status == LicenseStatus.Active || _currentLicense.Status == LicenseStatus.Trial);
|
||||
|
||||
public bool IsTrial => _currentLicense?.Status == LicenseStatus.Trial;
|
||||
|
||||
public int TrialDaysRemaining
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_currentLicense?.Status != LicenseStatus.Trial || !_currentLicense.ExpiresAt.HasValue)
|
||||
return 0;
|
||||
var remaining = (_currentLicense.ExpiresAt.Value - DateTime.UtcNow).Days;
|
||||
return Math.Max(0, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
public event EventHandler<LicenseChangedEventArgs>? LicenseChanged;
|
||||
public event EventHandler<LicenseValidationEventArgs>? ValidationCompleted;
|
||||
|
||||
public LicenseManager(
|
||||
HttpClient httpClient,
|
||||
IOptions<LicensingOptions> options,
|
||||
IMachineIdentifier machineIdentifier,
|
||||
ILicenseCache cache,
|
||||
ISignatureVerifier signatureVerifier)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_options = options.Value;
|
||||
_machineIdentifier = machineIdentifier;
|
||||
_cache = cache;
|
||||
_signatureVerifier = signatureVerifier;
|
||||
|
||||
ConfigureHttpClient();
|
||||
}
|
||||
|
||||
private void ConfigureHttpClient()
|
||||
{
|
||||
_httpClient.BaseAddress = new Uri(_options.ApiBaseUrl.TrimEnd('/') + "/");
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Public-Key", _options.PublicKey);
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Product-Slug", _options.ProductSlug);
|
||||
_httpClient.Timeout = _options.HttpTimeout;
|
||||
}
|
||||
|
||||
public async Task<LicenseResult> ValidateAsync(string? licenseKey = null)
|
||||
{
|
||||
await _syncLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var key = licenseKey ?? _currentLicense?.LicenseKey;
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
// Try to load from cache
|
||||
var cached = await _cache.GetCachedLicenseAsync();
|
||||
if (cached != null)
|
||||
{
|
||||
key = cached.License.LicenseKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
return LicenseResult.Fail("NO_LICENSE", "No license key provided");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
licenseKey = key,
|
||||
machineId = _machineIdentifier.GetMachineId(),
|
||||
machineName = _machineIdentifier.GetMachineName(),
|
||||
platform = _machineIdentifier.GetPlatform(),
|
||||
appVersion = GetAppVersion()
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("licenses/validate", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<ValidationResponse>();
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return await HandleOfflineValidation(key);
|
||||
}
|
||||
|
||||
if (result.Valid && result.License != null)
|
||||
{
|
||||
var licenseInfo = MapToLicenseInfo(key, result.License);
|
||||
await UpdateLicense(licenseInfo);
|
||||
|
||||
// Cache with signature for offline validation
|
||||
var rawJson = JsonSerializer.Serialize(result.License);
|
||||
await _cache.SaveLicenseAsync(licenseInfo, result.Signature, result.SigningPublicKey);
|
||||
|
||||
// Save signing public key if provided
|
||||
if (!string.IsNullOrEmpty(result.SigningPublicKey))
|
||||
{
|
||||
await _cache.SaveSigningPublicKeyAsync(result.SigningPublicKey);
|
||||
}
|
||||
|
||||
RaiseValidationCompleted(true, licenseInfo, null, null);
|
||||
return LicenseResult.Ok(licenseInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
RaiseValidationCompleted(false, null, result.Error?.Code, result.Error?.Message);
|
||||
return LicenseResult.Fail(result.Error?.Code ?? "UNKNOWN", result.Error?.Message ?? "Validation failed");
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return await HandleOfflineValidation(key);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return await HandleOfflineValidation(key);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LicenseResult> HandleOfflineValidation(string licenseKey)
|
||||
{
|
||||
var cached = await _cache.GetCachedLicenseAsync();
|
||||
if (cached == null || cached.License.LicenseKey != licenseKey)
|
||||
{
|
||||
RaiseValidationCompleted(false, null, "OFFLINE", "Cannot validate license while offline");
|
||||
return LicenseResult.Fail("OFFLINE", "Cannot validate license while offline");
|
||||
}
|
||||
|
||||
// Check offline grace period
|
||||
var daysSinceValidation = (DateTime.UtcNow - cached.License.LastValidated).TotalDays;
|
||||
if (daysSinceValidation > _options.OfflineGraceDays)
|
||||
{
|
||||
RaiseValidationCompleted(false, null, "OFFLINE_EXPIRED", "Offline grace period expired. Please connect to the internet to validate your license.");
|
||||
return LicenseResult.Fail("OFFLINE_EXPIRED", "Offline grace period expired. Please connect to the internet to validate your license.");
|
||||
}
|
||||
|
||||
// Verify signature if required
|
||||
if (_options.RequireSignatureForOffline)
|
||||
{
|
||||
var publicKey = _options.SigningPublicKey ?? cached.SigningPublicKey ?? await _cache.GetSigningPublicKeyAsync();
|
||||
|
||||
if (string.IsNullOrEmpty(publicKey) || string.IsNullOrEmpty(cached.Signature) || string.IsNullOrEmpty(cached.RawLicenseJson))
|
||||
{
|
||||
// No signature available - check if we allow this
|
||||
if (_options.RequireSignatureForOffline)
|
||||
{
|
||||
RaiseValidationCompleted(false, null, "NO_SIGNATURE", "Offline validation requires a signed license");
|
||||
return LicenseResult.Fail("NO_SIGNATURE", "Offline validation requires a signed license");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Verify signature
|
||||
var isValid = _signatureVerifier.Verify(publicKey, cached.RawLicenseJson, cached.Signature);
|
||||
if (!isValid)
|
||||
{
|
||||
RaiseValidationCompleted(false, null, "INVALID_SIGNATURE", "License signature verification failed");
|
||||
return LicenseResult.Fail("INVALID_SIGNATURE", "License signature verification failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Valid offline license
|
||||
await UpdateLicense(cached.License);
|
||||
RaiseValidationCompleted(true, cached.License, null, null);
|
||||
return LicenseResult.Ok(cached.License);
|
||||
}
|
||||
|
||||
public async Task<LicenseResult> ActivateAsync(string licenseKey)
|
||||
{
|
||||
await _syncLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
licenseKey,
|
||||
machineId = _machineIdentifier.GetMachineId(),
|
||||
machineName = _machineIdentifier.GetMachineName(),
|
||||
platform = _machineIdentifier.GetPlatform(),
|
||||
appVersion = GetAppVersion()
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("licenses/activate", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<ActivationResponse>();
|
||||
|
||||
if (result?.Success == true)
|
||||
{
|
||||
// Release lock before calling ValidateAsync (it acquires its own lock)
|
||||
_syncLock.Release();
|
||||
try
|
||||
{
|
||||
return await ValidateAsync(licenseKey);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _syncLock.WaitAsync();
|
||||
}
|
||||
}
|
||||
|
||||
return LicenseResult.Fail(result?.Error?.Code ?? "UNKNOWN", result?.Error?.Message ?? "Activation failed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return LicenseResult.Fail("ERROR", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeactivateAsync()
|
||||
{
|
||||
if (_currentLicense == null)
|
||||
return false;
|
||||
|
||||
await _syncLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
licenseKey = _currentLicense.LicenseKey,
|
||||
machineId = _machineIdentifier.GetMachineId()
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("licenses/deactivate", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<ActivationResponse>();
|
||||
|
||||
if (result?.Success == true)
|
||||
{
|
||||
await ClearCacheAsync();
|
||||
await UpdateLicense(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_syncLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CheckoutResult> StartPurchaseAsync(string tierId, string customerEmail)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
tierId,
|
||||
customerEmail,
|
||||
successUrl = _options.SuccessUrl,
|
||||
cancelUrl = _options.CancelUrl,
|
||||
metadata = new Dictionary<string, string>
|
||||
{
|
||||
["machineId"] = _machineIdentifier.GetMachineId()
|
||||
}
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("checkout/create", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CheckoutApiResponse>();
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
return new CheckoutResult
|
||||
{
|
||||
Success = true,
|
||||
SessionId = result.SessionId,
|
||||
CheckoutUrl = result.CheckoutUrl,
|
||||
ExpiresAt = result.ExpiresAt
|
||||
};
|
||||
}
|
||||
|
||||
return new CheckoutResult { Success = false, ErrorMessage = "Failed to create checkout session" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new CheckoutResult { Success = false, ErrorMessage = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasFeature(string featureKey)
|
||||
{
|
||||
if (_currentLicense == null)
|
||||
return false;
|
||||
|
||||
return _currentLicense.Features.Contains(featureKey) || _currentLicense.Features.Contains("*");
|
||||
}
|
||||
|
||||
public async Task<bool> HasFeatureAsync(string featureKey)
|
||||
{
|
||||
if (_currentLicense == null)
|
||||
return false;
|
||||
|
||||
// Check local first
|
||||
if (HasFeature(featureKey))
|
||||
return true;
|
||||
|
||||
// Check with server
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"licenses/{_currentLicense.LicenseKey}/features/{featureKey}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
return result.TryGetProperty("enabled", out var enabled) && enabled.GetBoolean();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to local check
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void RequireFeature(string featureKey)
|
||||
{
|
||||
if (!HasFeature(featureKey))
|
||||
{
|
||||
throw new LicenseRequiredException(featureKey);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> StartTrialAsync(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
email,
|
||||
machineId = _machineIdentifier.GetMachineId(),
|
||||
machineName = _machineIdentifier.GetMachineName(),
|
||||
platform = _machineIdentifier.GetPlatform()
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("trial/start", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<TrialResponse>();
|
||||
|
||||
if (result?.Success == true && !string.IsNullOrEmpty(result.LicenseKey))
|
||||
{
|
||||
// Activate the trial license
|
||||
var activationResult = await ActivateAsync(result.LicenseKey);
|
||||
return activationResult.Success;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClearCacheAsync()
|
||||
{
|
||||
await _cache.ClearAsync();
|
||||
}
|
||||
|
||||
private async Task UpdateLicense(LicenseInfo? license)
|
||||
{
|
||||
var oldLicense = _currentLicense;
|
||||
_currentLicense = license;
|
||||
|
||||
LicenseChanged?.Invoke(this, new LicenseChangedEventArgs
|
||||
{
|
||||
OldLicense = oldLicense,
|
||||
NewLicense = license
|
||||
});
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void RaiseValidationCompleted(bool success, LicenseInfo? license, string? errorCode, string? errorMessage)
|
||||
{
|
||||
ValidationCompleted?.Invoke(this, new LicenseValidationEventArgs
|
||||
{
|
||||
Success = success,
|
||||
License = license,
|
||||
ErrorCode = errorCode,
|
||||
ErrorMessage = errorMessage
|
||||
});
|
||||
}
|
||||
|
||||
private static LicenseInfo MapToLicenseInfo(string licenseKey, ValidationLicenseInfo validation)
|
||||
{
|
||||
return new LicenseInfo
|
||||
{
|
||||
LicenseKey = licenseKey,
|
||||
Tier = validation.Tier,
|
||||
Status = ParseStatus(validation.Status),
|
||||
ExpiresAt = validation.ExpiresAt,
|
||||
Features = validation.Features,
|
||||
ActivationsUsed = validation.ActivationsUsed,
|
||||
ActivationsMax = validation.ActivationsMax,
|
||||
LastValidated = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static LicenseStatus ParseStatus(string status)
|
||||
{
|
||||
return status.ToLowerInvariant() switch
|
||||
{
|
||||
"trial" => LicenseStatus.Trial,
|
||||
"active" => LicenseStatus.Active,
|
||||
"expired" => LicenseStatus.Expired,
|
||||
"suspended" => LicenseStatus.Suspended,
|
||||
"revoked" => LicenseStatus.Revoked,
|
||||
_ => LicenseStatus.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetAppVersion()
|
||||
{
|
||||
var assembly = System.Reflection.Assembly.GetEntryAssembly();
|
||||
return assembly?.GetName().Version?.ToString() ?? "1.0.0";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
namespace IronLicensing.Client;
|
||||
|
||||
public class LicensingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Public API key for client authentication (pk_live_xxx or pk_test_xxx)
|
||||
/// </summary>
|
||||
public string PublicKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Product identifier slug (e.g., "git-cleaner")
|
||||
/// </summary>
|
||||
public string ProductSlug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for the IronLicensing API
|
||||
/// </summary>
|
||||
public string ApiBaseUrl { get; set; } = "https://ironlicensing.com/api/v1";
|
||||
|
||||
/// <summary>
|
||||
/// Number of days to allow offline usage before requiring online validation
|
||||
/// </summary>
|
||||
public int OfflineGraceDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// How often to re-validate license when online (in minutes)
|
||||
/// </summary>
|
||||
public int CacheValidationMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP request timeout
|
||||
/// </summary>
|
||||
public TimeSpan HttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Optional signing public key for offline signature verification.
|
||||
/// If not provided, will be fetched from server and cached.
|
||||
/// </summary>
|
||||
public string? SigningPublicKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enable strict offline validation requiring valid signatures.
|
||||
/// If false, allows offline validation without signature verification.
|
||||
/// </summary>
|
||||
public bool RequireSignatureForOffline { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Custom success URL for purchase redirects (deep link)
|
||||
/// </summary>
|
||||
public string SuccessUrl { get; set; } = "ironlicensing://purchase-success";
|
||||
|
||||
/// <summary>
|
||||
/// Custom cancel URL for purchase redirects (deep link)
|
||||
/// </summary>
|
||||
public string CancelUrl { get; set; } = "ironlicensing://purchase-cancelled";
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace IronLicensing.Client;
|
||||
|
||||
public partial class MachineIdentifier : IMachineIdentifier
|
||||
{
|
||||
public string GetMachineId()
|
||||
{
|
||||
var components = GetMachineComponents();
|
||||
var combined = string.Join("|", components.Where(c => !string.IsNullOrEmpty(c)));
|
||||
|
||||
if (string.IsNullOrEmpty(combined))
|
||||
{
|
||||
// Fallback to a device-specific identifier
|
||||
combined = DeviceInfo.Current.Idiom.ToString() + "|" +
|
||||
DeviceInfo.Current.Manufacturer + "|" +
|
||||
DeviceInfo.Current.Model;
|
||||
}
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(combined));
|
||||
return Convert.ToBase64String(hash)[..32];
|
||||
}
|
||||
|
||||
public string GetMachineName()
|
||||
{
|
||||
return DeviceInfo.Current.Name ?? Environment.MachineName ?? "Unknown";
|
||||
}
|
||||
|
||||
public string GetPlatform()
|
||||
{
|
||||
return DeviceInfo.Current.Platform.ToString();
|
||||
}
|
||||
|
||||
// Platform-specific implementation
|
||||
private partial List<string> GetMachineComponents();
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IronLicensing.Client.Models;
|
||||
|
||||
internal class ValidationResponse
|
||||
{
|
||||
[JsonPropertyName("valid")]
|
||||
public bool Valid { get; set; }
|
||||
|
||||
[JsonPropertyName("license")]
|
||||
public ValidationLicenseInfo? License { get; set; }
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string? Signature { get; set; }
|
||||
|
||||
[JsonPropertyName("signingPublicKey")]
|
||||
public string? SigningPublicKey { get; set; }
|
||||
|
||||
[JsonPropertyName("signedAt")]
|
||||
public DateTime? SignedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public ApiError? Error { get; set; }
|
||||
}
|
||||
|
||||
internal class ValidationLicenseInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("tier")]
|
||||
public string Tier { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("customerEmail")]
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
[JsonPropertyName("features")]
|
||||
public List<string> Features { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("activationsUsed")]
|
||||
public int ActivationsUsed { get; set; }
|
||||
|
||||
[JsonPropertyName("activationsMax")]
|
||||
public int ActivationsMax { get; set; }
|
||||
}
|
||||
|
||||
internal class ActivationResponse
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("activation")]
|
||||
public ActivationInfo? Activation { get; set; }
|
||||
|
||||
[JsonPropertyName("license")]
|
||||
public ActivationLicenseInfo? License { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public ApiError? Error { get; set; }
|
||||
}
|
||||
|
||||
internal class ActivationInfo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[JsonPropertyName("activatedAt")]
|
||||
public DateTime ActivatedAt { get; set; }
|
||||
}
|
||||
|
||||
internal class ActivationLicenseInfo
|
||||
{
|
||||
[JsonPropertyName("activationsUsed")]
|
||||
public int ActivationsUsed { get; set; }
|
||||
|
||||
[JsonPropertyName("activationsMax")]
|
||||
public int ActivationsMax { get; set; }
|
||||
}
|
||||
|
||||
internal class ApiError
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
internal class CheckoutApiResponse
|
||||
{
|
||||
[JsonPropertyName("sessionId")]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("checkoutUrl")]
|
||||
public string CheckoutUrl { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
internal class TrialResponse
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("licenseKey")]
|
||||
public string? LicenseKey { get; set; }
|
||||
|
||||
[JsonPropertyName("trialEndsAt")]
|
||||
public DateTime? TrialEndsAt { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public ApiError? Error { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
namespace IronLicensing.Client.Models;
|
||||
|
||||
public class LicenseInfo
|
||||
{
|
||||
public string LicenseKey { get; set; } = string.Empty;
|
||||
public string Tier { get; set; } = string.Empty;
|
||||
public LicenseStatus Status { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public List<string> Features { get; set; } = [];
|
||||
public int ActivationsUsed { get; set; }
|
||||
public int ActivationsMax { get; set; }
|
||||
public DateTime LastValidated { get; set; }
|
||||
}
|
||||
|
||||
public enum LicenseStatus
|
||||
{
|
||||
Unknown,
|
||||
Trial,
|
||||
Active,
|
||||
Expired,
|
||||
Suspended,
|
||||
Revoked
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
namespace IronLicensing.Client.Models;
|
||||
|
||||
public class LicenseResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public LicenseInfo? License { get; set; }
|
||||
public string? ErrorCode { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public static LicenseResult Ok(LicenseInfo license) => new() { Success = true, License = license };
|
||||
public static LicenseResult Fail(string code, string message) => new() { Success = false, ErrorCode = code, ErrorMessage = message };
|
||||
}
|
||||
|
||||
public class CheckoutResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? SessionId { get; set; }
|
||||
public string? CheckoutUrl { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using Android.Provider;
|
||||
|
||||
namespace IronLicensing.Client;
|
||||
|
||||
public partial class MachineIdentifier
|
||||
{
|
||||
private partial List<string> GetMachineComponents()
|
||||
{
|
||||
var components = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Android ID - unique to each app/device combination
|
||||
var context = Android.App.Application.Context;
|
||||
var androidId = Settings.Secure.GetString(context.ContentResolver, Settings.Secure.AndroidId);
|
||||
if (!string.IsNullOrEmpty(androidId))
|
||||
{
|
||||
components.Add(androidId);
|
||||
}
|
||||
|
||||
components.Add(Android.OS.Build.Brand ?? "");
|
||||
components.Add(Android.OS.Build.Model ?? "");
|
||||
components.Add(Android.OS.Build.Serial ?? "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using UIKit;
|
||||
|
||||
namespace IronLicensing.Client;
|
||||
|
||||
public partial class MachineIdentifier
|
||||
{
|
||||
private partial List<string> GetMachineComponents()
|
||||
{
|
||||
var components = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// macOS/Catalyst vendor identifier
|
||||
var vendorId = UIDevice.CurrentDevice.IdentifierForVendor?.ToString();
|
||||
if (!string.IsNullOrEmpty(vendorId))
|
||||
{
|
||||
components.Add(vendorId);
|
||||
}
|
||||
|
||||
components.Add(UIDevice.CurrentDevice.Model);
|
||||
components.Add(UIDevice.CurrentDevice.SystemName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
namespace IronLicensing.Client;
|
||||
|
||||
public partial class MachineIdentifier
|
||||
{
|
||||
private partial List<string> GetMachineComponents()
|
||||
{
|
||||
var components = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Try to get Windows-specific identifiers
|
||||
// Note: Full WMI access requires additional setup
|
||||
components.Add(Environment.MachineName);
|
||||
components.Add(Environment.OSVersion.ToString());
|
||||
|
||||
// Use registry for additional machine identification
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography");
|
||||
var machineGuid = key?.GetValue("MachineGuid")?.ToString();
|
||||
if (!string.IsNullOrEmpty(machineGuid))
|
||||
{
|
||||
components.Add(machineGuid);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback if registry access fails
|
||||
components.Add(Environment.MachineName);
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using UIKit;
|
||||
|
||||
namespace IronLicensing.Client;
|
||||
|
||||
public partial class MachineIdentifier
|
||||
{
|
||||
private partial List<string> GetMachineComponents()
|
||||
{
|
||||
var components = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// iOS vendor identifier
|
||||
var vendorId = UIDevice.CurrentDevice.IdentifierForVendor?.ToString();
|
||||
if (!string.IsNullOrEmpty(vendorId))
|
||||
{
|
||||
components.Add(vendorId);
|
||||
}
|
||||
|
||||
components.Add(UIDevice.CurrentDevice.Model);
|
||||
components.Add(UIDevice.CurrentDevice.SystemName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
# IronLicensing.Client
|
||||
|
||||
Software licensing SDK for .NET MAUI applications. Add license validation, activation management, feature gating, and trial support to your apps.
|
||||
|
||||
[](https://www.nuget.org/packages/IronLicensing.Client)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
dotnet add package IronLicensing.Client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Register Services
|
||||
|
||||
```csharp
|
||||
using IronLicensing.Client.Extensions;
|
||||
|
||||
builder.Services.AddIronLicensing(options =>
|
||||
{
|
||||
options.PublicKey = "pk_live_xxxxxxxxxx";
|
||||
options.ProductSlug = "my-product";
|
||||
options.OfflineGraceDays = 7;
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Validate a License
|
||||
|
||||
```csharp
|
||||
using IronLicensing.Client;
|
||||
|
||||
public class MyViewModel
|
||||
{
|
||||
private readonly ILicenseManager _license;
|
||||
|
||||
public MyViewModel(ILicenseManager license)
|
||||
{
|
||||
_license = license;
|
||||
}
|
||||
|
||||
public async Task ValidateLicenseAsync()
|
||||
{
|
||||
var result = await _license.ValidateAsync("IRON-XXXX-XXXX-XXXX-XXXX");
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
Console.WriteLine($"Licensed! Tier: {result.License.Tier}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Error: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Feature Gating
|
||||
|
||||
```csharp
|
||||
// Check if feature is available
|
||||
if (_license.HasFeature("advanced-reports"))
|
||||
{
|
||||
ShowAdvancedReports();
|
||||
}
|
||||
|
||||
// Or throw if feature is required
|
||||
_license.RequireFeature("advanced-reports"); // throws LicenseRequiredException
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```csharp
|
||||
builder.Services.AddIronLicensing(options =>
|
||||
{
|
||||
// Required
|
||||
options.PublicKey = "pk_live_xxxxxxxxxx";
|
||||
options.ProductSlug = "my-product";
|
||||
|
||||
// Offline support
|
||||
options.OfflineGraceDays = 7; // Days license is valid offline
|
||||
options.RequireSignatureForOffline = true; // Require signed license for offline
|
||||
|
||||
// API settings
|
||||
options.ApiBaseUrl = "https://ironlicensing.com";
|
||||
options.HttpTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// In-app purchase URLs
|
||||
options.SuccessUrl = "myapp://purchase/success";
|
||||
options.CancelUrl = "myapp://purchase/cancel";
|
||||
});
|
||||
```
|
||||
|
||||
## License Activation
|
||||
|
||||
```csharp
|
||||
// Activate a license key
|
||||
var result = await _license.ActivateAsync("IRON-XXXX-XXXX-XXXX-XXXX");
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// License is now active on this device
|
||||
Console.WriteLine($"Activated! {_license.CurrentLicense.ActivationsUsed}/{_license.CurrentLicense.ActivationsMax}");
|
||||
}
|
||||
|
||||
// Deactivate (free up activation slot)
|
||||
await _license.DeactivateAsync();
|
||||
```
|
||||
|
||||
## Trial Support
|
||||
|
||||
```csharp
|
||||
// Start a trial
|
||||
var success = await _license.StartTrialAsync("user@example.com");
|
||||
|
||||
if (success)
|
||||
{
|
||||
Console.WriteLine($"Trial started! {_license.TrialDaysRemaining} days remaining");
|
||||
}
|
||||
|
||||
// Check trial status
|
||||
if (_license.IsTrial)
|
||||
{
|
||||
ShowTrialBanner(_license.TrialDaysRemaining);
|
||||
}
|
||||
```
|
||||
|
||||
## In-App Purchase
|
||||
|
||||
```csharp
|
||||
// Start checkout flow
|
||||
var checkout = await _license.StartPurchaseAsync("tier-id", "customer@example.com");
|
||||
|
||||
if (checkout.Success)
|
||||
{
|
||||
// Open checkout URL in browser
|
||||
await Launcher.OpenAsync(checkout.CheckoutUrl);
|
||||
}
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
```csharp
|
||||
// Listen for license changes
|
||||
_license.LicenseChanged += (sender, args) =>
|
||||
{
|
||||
Console.WriteLine($"License changed from {args.OldLicense?.Status} to {args.NewLicense?.Status}");
|
||||
};
|
||||
|
||||
// Listen for validation results
|
||||
_license.ValidationCompleted += (sender, args) =>
|
||||
{
|
||||
if (!args.Success)
|
||||
{
|
||||
Console.WriteLine($"Validation failed: {args.ErrorCode} - {args.ErrorMessage}");
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## License Status
|
||||
|
||||
```csharp
|
||||
// Quick status checks
|
||||
bool isLicensed = _license.IsLicensed; // Active or Trial
|
||||
bool isTrial = _license.IsTrial;
|
||||
LicenseStatus status = _license.Status; // Active, Trial, Expired, Suspended, Revoked
|
||||
|
||||
// Full license info
|
||||
var license = _license.CurrentLicense;
|
||||
Console.WriteLine($"Tier: {license.Tier}");
|
||||
Console.WriteLine($"Expires: {license.ExpiresAt}");
|
||||
Console.WriteLine($"Features: {string.Join(", ", license.Features)}");
|
||||
```
|
||||
|
||||
## Offline Support
|
||||
|
||||
Licenses are cached locally with cryptographic signatures for secure offline validation:
|
||||
|
||||
- **Grace Period**: Configurable days the license is valid without server contact
|
||||
- **Signature Verification**: Optional RSA signature verification for offline licenses
|
||||
- **Automatic Sync**: License is re-validated when connectivity is restored
|
||||
|
||||
```csharp
|
||||
// Clear cached license
|
||||
await _license.ClearCacheAsync();
|
||||
```
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Minimum Version |
|
||||
|----------|-----------------|
|
||||
| Android | 5.0 (API 21) |
|
||||
| iOS | 15.0 |
|
||||
| macOS (Catalyst) | 15.0 |
|
||||
| Windows | 10.0.17763.0 |
|
||||
|
||||
## Links
|
||||
|
||||
- [Documentation](https://www.ironlicensing.com/docs)
|
||||
- [Dashboard](https://www.ironlicensing.com)
|
||||
- [API Reference](https://www.ironlicensing.com/docs/api)
|
||||
- [Support](https://www.ironlicensing.com/app/tickets)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace IronLicensing.Client;
|
||||
|
||||
/// <summary>
|
||||
/// RSA signature verifier for offline license validation
|
||||
/// </summary>
|
||||
public class RsaSignatureVerifier : ISignatureVerifier
|
||||
{
|
||||
public bool Verify(string publicKeyPem, string data, string signature)
|
||||
{
|
||||
if (string.IsNullOrEmpty(publicKeyPem) ||
|
||||
string.IsNullOrEmpty(data) ||
|
||||
string.IsNullOrEmpty(signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
|
||||
// Import the public key
|
||||
var keyBytes = ParsePemPublicKey(publicKeyPem);
|
||||
rsa.ImportSubjectPublicKeyInfo(keyBytes, out _);
|
||||
|
||||
// Verify the signature
|
||||
var dataBytes = Encoding.UTF8.GetBytes(data);
|
||||
var signatureBytes = Convert.FromBase64String(signature);
|
||||
|
||||
return rsa.VerifyData(
|
||||
dataBytes,
|
||||
signatureBytes,
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ParsePemPublicKey(string pem)
|
||||
{
|
||||
// Remove PEM headers/footers and whitespace
|
||||
var base64 = pem
|
||||
.Replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.Replace("-----END PUBLIC KEY-----", "")
|
||||
.Replace("-----BEGIN RSA PUBLIC KEY-----", "")
|
||||
.Replace("-----END RSA PUBLIC KEY-----", "")
|
||||
.Replace("\n", "")
|
||||
.Replace("\r", "")
|
||||
.Replace(" ", "")
|
||||
.Trim();
|
||||
|
||||
return Convert.FromBase64String(base64);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
using System.Text.Json;
|
||||
using IronLicensing.Client.Models;
|
||||
|
||||
namespace IronLicensing.Client;
|
||||
|
||||
public class SecureStorageLicenseCache : ILicenseCache
|
||||
{
|
||||
private const string LicenseCacheKey = "iron_license_cache";
|
||||
private const string SignatureCacheKey = "iron_license_signature";
|
||||
private const string PublicKeyCacheKey = "iron_signing_public_key";
|
||||
private const string RawJsonCacheKey = "iron_license_raw_json";
|
||||
|
||||
public async Task<CachedLicenseData?> GetCachedLicenseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cached = await SecureStorage.Default.GetAsync(LicenseCacheKey);
|
||||
if (string.IsNullOrEmpty(cached))
|
||||
return null;
|
||||
|
||||
var license = JsonSerializer.Deserialize<LicenseInfo>(cached);
|
||||
if (license == null)
|
||||
return null;
|
||||
|
||||
var signature = await SecureStorage.Default.GetAsync(SignatureCacheKey);
|
||||
var publicKey = await SecureStorage.Default.GetAsync(PublicKeyCacheKey);
|
||||
var rawJson = await SecureStorage.Default.GetAsync(RawJsonCacheKey);
|
||||
|
||||
return new CachedLicenseData
|
||||
{
|
||||
License = license,
|
||||
Signature = signature,
|
||||
SigningPublicKey = publicKey,
|
||||
RawLicenseJson = rawJson
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveLicenseAsync(LicenseInfo license, string? signature, string? signingPublicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(license);
|
||||
await SecureStorage.Default.SetAsync(LicenseCacheKey, json);
|
||||
await SecureStorage.Default.SetAsync(RawJsonCacheKey, json);
|
||||
|
||||
if (!string.IsNullOrEmpty(signature))
|
||||
{
|
||||
await SecureStorage.Default.SetAsync(SignatureCacheKey, signature);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(signingPublicKey))
|
||||
{
|
||||
await SecureStorage.Default.SetAsync(PublicKeyCacheKey, signingPublicKey);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetSigningPublicKeyAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await SecureStorage.Default.GetAsync(PublicKeyCacheKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveSigningPublicKeyAsync(string publicKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SecureStorage.Default.SetAsync(PublicKeyCacheKey, publicKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
public Task ClearAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureStorage.Default.Remove(LicenseCacheKey);
|
||||
SecureStorage.Default.Remove(SignatureCacheKey);
|
||||
SecureStorage.Default.Remove(PublicKeyCacheKey);
|
||||
SecureStorage.Default.Remove(RawJsonCacheKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore storage errors
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue