Files
Automatic-Parking/Source/ProofOfConcept/Services/TeslaAuthenticatorService.cs
Szakáts Alpár Zsolt 8c801c88ce
All checks were successful
Build, Push and Run Container / build (push) Successful in 32s
Adds application authorization endpoint
Implements the /Authorize endpoint to redirect users to the Tesla
authentication page. This allows users to grant the application
permission to access their Tesla account data.

Updates the public key resource to be copied on build, ensuring
it is always available at runtime.

Adds logic to validate the application registration by comparing the
public key retrieved from the Tesla API with the public key stored
locally.
2025-08-13 22:29:48 +02:00

301 lines
14 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 class TeslaAuthenticatorService
{
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}");
sb.AppendFormat("&scope=openid offline_access vehicle_device_data vehicle_location");
sb.AppendFormat("&state=1234567890");
sb.AppendFormat("&nonce=1234567890");
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";
}