// EnfonicaCredentials.cs — Enfonica service account authentication (reference implementation) // // Exchanges an Enfonica service account key for an OAuth2 bearer access token // using the JWT Bearer Grant (RFC 7523), and caches the token in memory. // Copy this single file into your project and change the namespace to suit. // // Requires: .NET 6+. No NuGet packages. // // Creating credentials (pick one): // // var creds = EnfonicaCredentials.FromEnvironment(); // path in ENFONICA_APPLICATION_CREDENTIALS // var creds = EnfonicaCredentials.FromServiceAccountFile("key.json"); // var creds = EnfonicaCredentials.FromServiceAccountInfo(keyJson); // e.g. from a secret manager // // Using them on each API request: // // request.Headers.Authorization = await creds.GetAuthorizationHeaderAsync(); // Bearer // string token = await creds.GetAccessTokenAsync(); // just the token // // Notes: // - Create ONE instance and reuse it for the life of your application (e.g. // register it as a singleton). Tokens are cached in memory and refreshed // automatically after 30 minutes (the server issues 60-minute tokens), so // a new instance per request means a token request per API call. // - Instances are thread-safe. // - A 401 from the API is a real error (bad key, revoked key, missing // permission). Do not retry it by fetching a new token. // - Tokens are never written to disk. using System; using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace Enfonica.Auth; /// Thrown when a key file is missing or invalid, or a token request fails. public sealed class EnfonicaCredentialsException : Exception { public EnfonicaCredentialsException(string message, Exception? inner = null) : base(message, inner) { } } /// Service account credentials that produce cached Enfonica access tokens. public sealed class EnfonicaCredentials : IDisposable { private const string Scope = "https://api.enfonica.com/auth/platform"; private const string GrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer"; private const string EnvVar = "ENFONICA_APPLICATION_CREDENTIALS"; private static readonly TimeSpan JwtLifetime = TimeSpan.FromHours(1); private static readonly TimeSpan TokenRefresh = TimeSpan.FromMinutes(30); private static readonly string[] RequiredFields = { "type", "project_id", "private_key_id", "private_key", "client_email", "token_uri" }; private readonly string _privateKeyId; private readonly RSA _privateKey; private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) }; private readonly SemaphoreSlim _lock = new(1, 1); private string? _token; private DateTime _fetchedAtUtc; public string ProjectId { get; } public string ClientEmail { get; } /// Always read from the key file, never hardcoded. public string TokenUri { get; } /// Load the key file named by the ENFONICA_APPLICATION_CREDENTIALS environment variable. public static EnfonicaCredentials FromEnvironment() { var path = Environment.GetEnvironmentVariable(EnvVar); if (string.IsNullOrEmpty(path)) throw new EnfonicaCredentialsException( $"{EnvVar} is not set. Set it to the path of a service account key file, " + "or use FromServiceAccountFile() / FromServiceAccountInfo()."); return FromServiceAccountFile(path); } /// Load a service account key file from disk. public static EnfonicaCredentials FromServiceAccountFile(string path) { string json; try { json = File.ReadAllText(path); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { throw new EnfonicaCredentialsException($"Could not read key file '{path}': {e.Message}", e); } return FromServiceAccountInfo(json); } /// Load a service account key from its JSON contents. public static EnfonicaCredentials FromServiceAccountInfo(string json) { Dictionary? fields; try { fields = JsonSerializer.Deserialize>(json); } catch (JsonException e) { throw new EnfonicaCredentialsException($"Invalid key file: not valid JSON: {e.Message}", e); } if (fields is null) throw new EnfonicaCredentialsException("Invalid key file: expected a JSON object"); string Field(string name) => fields.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : ""; if (Field("type") != "service_account") throw new EnfonicaCredentialsException( $"Invalid key file: expected type 'service_account', got '{Field("type")}'"); var missing = Array.FindAll(RequiredFields, f => Field(f) == ""); if (missing.Length > 0) throw new EnfonicaCredentialsException($"Invalid key file: missing field(s): {string.Join(", ", missing)}"); return new EnfonicaCredentials( Field("project_id"), Field("client_email"), Field("token_uri"), Field("private_key_id"), LoadPrivateKey(Field("private_key"))); } private EnfonicaCredentials(string projectId, string clientEmail, string tokenUri, string privateKeyId, RSA privateKey) { ProjectId = projectId; ClientEmail = clientEmail; TokenUri = tokenUri; _privateKeyId = privateKeyId; _privateKey = privateKey; } /// Return a valid access token, fetching a new one if none is cached or it is 30+ minutes old. public async Task GetAccessTokenAsync(CancellationToken cancellationToken = default) { await _lock.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_token is null || DateTime.UtcNow - _fetchedAtUtc >= TokenRefresh) { _token = await FetchTokenAsync(cancellationToken).ConfigureAwait(false); _fetchedAtUtc = DateTime.UtcNow; } return _token; } finally { _lock.Release(); } } /// Return an Authorization header value: Bearer <token>. public async Task GetAuthorizationHeaderAsync(CancellationToken cancellationToken = default) => new("Bearer", await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false)); public void Dispose() { _httpClient.Dispose(); _privateKey.Dispose(); _lock.Dispose(); } private async Task FetchTokenAsync(CancellationToken cancellationToken) { using var content = new FormUrlEncodedContent(new Dictionary { ["grant_type"] = GrantType, ["assertion"] = SignedAssertion(), }); using var request = new HttpRequestMessage(HttpMethod.Post, TokenUri) { Content = content }; request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); HttpResponseMessage response; try { response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); } catch (HttpRequestException e) { throw new EnfonicaCredentialsException($"Token request to {TokenUri} failed: {e.Message}", e); } using (response) { var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); if (!response.IsSuccessStatusCode) throw new EnfonicaCredentialsException( $"Token request failed: HTTP {(int)response.StatusCode} from {TokenUri}: {body}"); try { using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("access_token", out var token) && token.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(token.GetString())) return token.GetString()!; } catch (JsonException) { // Fall through to the malformed-response error below. } throw new EnfonicaCredentialsException($"Token response was malformed: {body}"); } } /// Build the RS256-signed JWT assertion (RFC 7523 §3). private string SignedAssertion() { var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var header = new Dictionary { ["alg"] = "RS256", ["typ"] = "JWT", ["kid"] = _privateKeyId }; var payload = new Dictionary { ["iss"] = ClientEmail, ["aud"] = TokenUri, ["scope"] = Scope, ["iat"] = now, ["exp"] = now + (long)JwtLifetime.TotalSeconds, }; var signingInput = $"{Base64Url(JsonSerializer.SerializeToUtf8Bytes(header))}.{Base64Url(JsonSerializer.SerializeToUtf8Bytes(payload))}"; var signature = _privateKey.SignData( Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return $"{signingInput}.{Base64Url(signature)}"; } // Decode the PEM body directly so keys wrapped at any line width (or not // at all) load correctly. private static RSA LoadPrivateKey(string pem) { var body = Regex.Replace(pem, @"-----(BEGIN|END) [A-Z ]+-----|\s+", ""); var rsa = RSA.Create(); try { rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(body), out _); return rsa; } catch (Exception e) when (e is FormatException or CryptographicException) { rsa.Dispose(); throw new EnfonicaCredentialsException( $"Invalid key file: private_key is not a valid PKCS#8 RSA key: {e.Message}", e); } } private static string Base64Url(byte[] data) => Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); }