All checks were successful
Build, Push and Run Container / build (push) Successful in 30s
324 lines
15 KiB
C#
324 lines
15 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Options;
|
|
using ProofOfConcept.Models;
|
|
using ProofOfConcept.Utilities;
|
|
using SzakatsA.Result;
|
|
|
|
namespace ProofOfConcept.Services;
|
|
|
|
public interface ITeslaAuthenticatorService
|
|
{
|
|
/// Asynchronously retrieves an authentication token from the Tesla partner API.
|
|
/// This method sends a POST request with client credentials to obtain an
|
|
/// OAuth2 access token that grants access to Tesla partner services.
|
|
/// <return>Returns a string containing the authentication token received from the API.</return>
|
|
/// <exception cref="HttpRequestException">Thrown when the HTTP request to the API fails.</exception>
|
|
Task<Result<Token>> AcquirePartnerAuthenticationTokenAsync();
|
|
|
|
/// Retrieves a cached partner authentication token or acquires a new one if not available.
|
|
/// This method first attempts to fetch the token from the memory cache. If the token is not found
|
|
/// or has expired, it invokes the method to retrieve a new token from the Tesla partner API and stores it in the cache.
|
|
/// The cached token is set to expire slightly earlier than its actual expiration time to avoid unexpected token expiry during usage.
|
|
/// <return>Returns a Token object containing the authentication token and its expiration details.</return>
|
|
/// <exception cref="HttpRequestException">Thrown when the HTTP request to acquire a new partner token fails.</exception>
|
|
/// <exception cref="Exception">Thrown for any unexpected errors during the token retrieval or caching process.</exception>
|
|
Task<Result<Token>> GetPartnerAuthenticationTokenAsync();
|
|
|
|
Task<Result> RegisterApplicationAsync(CancellationToken cancellationToken = default(CancellationToken));
|
|
Task<Result<bool>> CheckApplicationRegistrationAsync(CancellationToken cancellationToken = default(CancellationToken));
|
|
string GetAplicationAuthorizationURL();
|
|
}
|
|
|
|
public class TeslaAuthenticatorService : ITeslaAuthenticatorService
|
|
{
|
|
private readonly ILogger<TeslaAuthenticatorService> logger;
|
|
private readonly TeslaAuthenticatorServiceConfiguration configuration;
|
|
private readonly IHttpClientFactory httpClientFactory;
|
|
private readonly IMemoryCache memoryCache;
|
|
|
|
const string authEndpointURL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token";
|
|
const string euBaseURL = "https://fleet-api.prd.eu.vn.cloud.tesla.com";
|
|
|
|
public TeslaAuthenticatorService(ILogger<TeslaAuthenticatorService> logger, IOptions<TeslaAuthenticatorServiceConfiguration> options, IHttpClientFactory httpClientFactory, IMemoryCache memoryCache)
|
|
{
|
|
this.logger = logger;
|
|
this.httpClientFactory = httpClientFactory;
|
|
this.memoryCache = memoryCache;
|
|
this.configuration = options.Value;
|
|
}
|
|
|
|
/// Asynchronously retrieves an authentication token from the Tesla partner API.
|
|
/// This method sends a POST request with client credentials to obtain an
|
|
/// OAuth2 access token that grants access to Tesla partner services.
|
|
/// <return>Returns a string containing the authentication token received from the API.</return>
|
|
/// <exception cref="HttpRequestException">Thrown when the HTTP request to the API fails.</exception>
|
|
public async Task<Result<Token>> AcquirePartnerAuthenticationTokenAsync()
|
|
{
|
|
this.logger.LogTrace("Acquiring authentication token from Tesla partner API...");
|
|
|
|
using HttpClient client = httpClientFactory.CreateClient();
|
|
|
|
// URL to request the token
|
|
const string audience = euBaseURL;
|
|
|
|
// Prepare the form-urlencoded body
|
|
Dictionary<string, string> formData = new Dictionary<string, string>
|
|
{
|
|
{ "grant_type", "client_credentials" },
|
|
{ "client_id", this.configuration.ClientID },
|
|
{ "client_secret", this.configuration.ClientSecret },
|
|
{ "scope", "openid offline_access vehicle_device_data vehicle_location" },
|
|
{ "audience", audience }
|
|
};
|
|
|
|
FormUrlEncodedContent content = new FormUrlEncodedContent(formData);
|
|
|
|
string json = "";
|
|
try
|
|
{
|
|
// Send POST request
|
|
HttpResponseMessage response = await client.PostAsync(authEndpointURL, content);
|
|
this.logger.LogTrace("Response status code: {0}", response.StatusCode);
|
|
|
|
// Throw if not successful
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
// Read the response body as a string
|
|
json = await response.Content.ReadAsStringAsync();
|
|
}
|
|
|
|
catch (HttpRequestException httpRequestException)
|
|
{
|
|
this.logger.LogError(httpRequestException, "HTTP Request error while acquiring partner authentication token from Tesla API");
|
|
return new Result<Token>(httpRequestException);
|
|
}
|
|
|
|
catch (Exception e)
|
|
{
|
|
this.logger.LogError(e, "Unexpected communication error while acquiring partner authentication token from Tesla API");
|
|
return new Result<Token>(e);
|
|
}
|
|
|
|
try
|
|
{
|
|
// Check if the response is empty
|
|
if (String.IsNullOrWhiteSpace(json))
|
|
throw new JsonException("Response could not be deserialized (empty).");
|
|
|
|
// Deserializer options: {"access_token":"999 random chars including some special characters","expires_in":28800,"token_type":"Bearer"}
|
|
JsonSerializerOptions serializerOptions = new JsonSerializerOptions()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
|
};
|
|
|
|
// Deserialize the JSON response
|
|
Token? result = JsonSerializer.Deserialize<Token>(json, serializerOptions);
|
|
|
|
if (result is null)
|
|
throw new JsonException("Response could not be deserialized (null).");
|
|
|
|
this.logger.LogInformation("Authentication token acquired from Tesla partner API");
|
|
return new Result<Token>(result);
|
|
}
|
|
|
|
catch (JsonException jsonException)
|
|
{
|
|
this.logger.LogError(jsonException, "JSON deserialization error while acquiring partner authentication token from Tesla API");
|
|
return new Result<Token>(jsonException);
|
|
}
|
|
|
|
catch (Exception e)
|
|
{
|
|
this.logger.LogError(e, "Unexpected serialization error while acquiring partner authentication token from Tesla API");
|
|
return new Result<Token>(e);
|
|
}
|
|
}
|
|
|
|
/// Retrieves a cached partner authentication token or acquires a new one if not available.
|
|
/// This method first attempts to fetch the token from the memory cache. If the token is not found
|
|
/// or has expired, it invokes the method to retrieve a new token from the Tesla partner API and stores it in the cache.
|
|
/// The cached token is set to expire slightly earlier than its actual expiration time to avoid unexpected token expiry during usage.
|
|
/// <return>Returns a Token object containing the authentication token and its expiration details.</return>
|
|
/// <exception cref="HttpRequestException">Thrown when the HTTP request to acquire a new partner token fails.</exception>
|
|
/// <exception cref="Exception">Thrown for any unexpected errors during the token retrieval or caching process.</exception>
|
|
public async Task<Result<Token>> GetPartnerAuthenticationTokenAsync()
|
|
{
|
|
this.logger.LogTrace("Getting partner authentication token...");
|
|
|
|
//Token is available in cache
|
|
if (this.memoryCache.TryGetValue(Keys.TeslaPartnerToken, out Token? token) && token is not null)
|
|
{
|
|
this.logger.LogInformation("Partner authentication token provided from cache");
|
|
return Result.Success(token);
|
|
}
|
|
|
|
//Acquire token
|
|
Result<Token> aquirationResult = await AcquirePartnerAuthenticationTokenAsync();
|
|
|
|
//Save to cache
|
|
if (aquirationResult.IsSuccessful)
|
|
{
|
|
token = aquirationResult.Value;
|
|
this.memoryCache.Set(Keys.TeslaPartnerToken, token, token.Expires.Subtract(this.configuration.MemoryCacheDelta));
|
|
|
|
this.logger.LogInformation("Partner authentication token provided by acquiring from API");
|
|
}
|
|
|
|
else
|
|
this.logger.LogError("Failed to get partner authentication token");
|
|
|
|
return aquirationResult;
|
|
}
|
|
|
|
public async Task<Result> RegisterApplicationAsync(CancellationToken cancellationToken = default(CancellationToken))
|
|
{
|
|
this.logger.LogTrace("Registering application to Tesla fleet API...");
|
|
|
|
//Create client and get partner token
|
|
HttpClient httpClient = httpClientFactory.CreateClient();
|
|
Result<Token> partnerTokenResult = await GetPartnerAuthenticationTokenAsync();
|
|
|
|
if (!partnerTokenResult.IsSuccessful)
|
|
return Result.Fail(partnerTokenResult.Exception ?? new Exception("Partner authentication token not found"));
|
|
|
|
//Set partner token
|
|
Token partnerToken = partnerTokenResult.Value;
|
|
|
|
//Set headers
|
|
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", partnerToken.AccessToken);
|
|
|
|
//Prepare body
|
|
var postBody = new { domain = this.configuration.Domain };
|
|
|
|
//Send request
|
|
HttpResponseMessage response = await httpClient.PostAsJsonAsync($"{euBaseURL}/api/1/partner_accounts", postBody, cancellationToken);
|
|
bool success = response.IsSuccessStatusCode;
|
|
|
|
logger.LogInformation("Application registration result: {Success}", success ? "Success" : "Failed");
|
|
|
|
//Load response from server
|
|
string json = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
this.logger.LogTrace("Registration response from server: {Response}", json);
|
|
|
|
//{"isSuccessful":true,"isFailed":false,"exceptions":[],"exception":null}
|
|
//Parse JSON
|
|
JsonNode? root = JsonNode.Parse(json);
|
|
bool isSuccessful = root?["isSuccessful"]?.GetValue<bool>() ?? false;
|
|
bool isFailed = root?["isFailed"]?.GetValue<bool>() ?? false;
|
|
|
|
if (success && isSuccessful && !isFailed)
|
|
{
|
|
logger.LogInformation("Application registered successfully");
|
|
return Result.Success();
|
|
}
|
|
|
|
else
|
|
{
|
|
logger.LogError("Application registration failed");
|
|
|
|
return Result.Fail(new AggregateException(new Exception(root?["exception"]?.ToJsonString()), new Exception(root?["exceptions"]?.ToJsonString())));
|
|
}
|
|
}
|
|
|
|
public async Task<Result<bool>> CheckApplicationRegistrationAsync(CancellationToken cancellationToken = default(CancellationToken))
|
|
{
|
|
this.logger.LogTrace("Checking application registration...");
|
|
|
|
//Create client and get partner token
|
|
HttpClient httpClient = httpClientFactory.CreateClient();
|
|
Result<Token> partnerTokenResult = await GetPartnerAuthenticationTokenAsync();
|
|
|
|
if (!partnerTokenResult.IsSuccessful)
|
|
return Result.Fail(partnerTokenResult.Exception ?? new Exception("Partner authentication token not found"));
|
|
|
|
//Set partner token
|
|
Token partnerToken = partnerTokenResult.Value;
|
|
|
|
//Set headers
|
|
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", partnerToken.AccessToken);
|
|
|
|
try
|
|
{
|
|
//Send request
|
|
HttpResponseMessage response = await httpClient.GetAsync($"{euBaseURL}/api/1/partner_accounts/public_key?domain={this.configuration.Domain}", cancellationToken);
|
|
string responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
logger.LogInformation("Application registration result: {Result}", responseBody);
|
|
|
|
//Parse response
|
|
using JsonDocument? doc = JsonDocument.Parse(responseBody);
|
|
string publicKeyHex = doc.RootElement.GetProperty("response").GetProperty("public_key").GetString() ?? throw new JsonException("Public key not found in response");
|
|
|
|
//Public key bytes
|
|
byte[] bytes = Convert.FromHexString(publicKeyHex);
|
|
|
|
//Get bytes from PEM key
|
|
string pem = await File.ReadAllTextAsync("Resources/Signature/public-key.pem", cancellationToken: cancellationToken);;
|
|
string[] lines = pem.Split('\n')
|
|
.Select(l => l.Trim())
|
|
.Where(l => !string.IsNullOrEmpty(l) &&
|
|
!l.StartsWith("-----"))
|
|
.ToArray();
|
|
|
|
string base64 = string.Join("", lines);
|
|
byte[] pemBytes = Convert.FromBase64String(base64);
|
|
|
|
// Parse the PEM with ECDsa to get the raw Q.X||Q.Y
|
|
using var ecdsa = ECDsa.Create();
|
|
ecdsa.ImportSubjectPublicKeyInfo(pemBytes, out _);
|
|
ECParameters parameters = ecdsa.ExportParameters(false);
|
|
byte[]? x = parameters.Q.X;
|
|
byte[]? y = parameters.Q.Y;
|
|
|
|
if (x is null || y is null)
|
|
throw new CryptographicException("Invalid PEM file");
|
|
|
|
// Assemble into uncompressed SEC1 format
|
|
byte[] pemKeyBytes = new byte[1 + x.Length + y.Length];
|
|
pemKeyBytes[0] = 0x04; // uncompressed marker
|
|
Buffer.BlockCopy(x, 0, pemKeyBytes, 1, x.Length);
|
|
Buffer.BlockCopy(y, 0, pemKeyBytes, 1 + x.Length, y.Length);
|
|
|
|
// Compare
|
|
bool match = bytes.SequenceEqual(pemKeyBytes);
|
|
|
|
return Result.Success(match);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
logger.LogError(e, "Error while checking application registration");
|
|
return Result.Fail(e);
|
|
}
|
|
}
|
|
|
|
public string GetAplicationAuthorizationURL()
|
|
{
|
|
//https://auth.tesla.com/oauth2/v3/authorize?&client_id=$CLIENT_ID&locale=en-US&prompt=login&redirect_uri=$REDIRECT_URI&response_type=code&scope=openid%20vehicle_device_data%20offline_access&state=$STATE
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.Append("https://auth.tesla.com/oauth2/v3/authorize?response_type=code");
|
|
sb.AppendFormat("&client_id={0}", this.configuration.ClientID);
|
|
sb.AppendFormat("&redirect_uri={0}", "https://automatic-parking.app/token-exchange");
|
|
sb.AppendFormat("&scope=openid offline_access vehicle_device_data vehicle_location");
|
|
sb.AppendFormat("&state={0}", "");
|
|
sb.AppendFormat("&nonce={0}", "");
|
|
sb.AppendFormat("&prompt_missing_scopes=true");
|
|
sb.AppendFormat("&require_requested_scopes=true");
|
|
sb.AppendFormat("&show_keypair_step=true");
|
|
return sb.ToString();
|
|
}
|
|
}
|
|
|
|
public class TeslaAuthenticatorServiceConfiguration
|
|
{
|
|
public string ClientID { get; set; } = "b2240ee4-332a-4252-91aa-bbcc24f78fdb";
|
|
public string ClientSecret { get; set; } = "ta-secret.YG+XSdlvr6Lv8U-x";
|
|
public TimeSpan MemoryCacheDelta { get; set; } = TimeSpan.FromSeconds(5);
|
|
public string Domain { get; set; } = "tesla-connector.automatic-parking.app";
|
|
} |