All checks were successful
Build, Push and Run Container / build (push) Successful in 32s
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.
301 lines
14 KiB
C#
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";
|
|
} |