From ff18c42211aefa7363e3ac7cf22c50c73cb623c5 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Mon, 29 Sep 2025 13:24:36 +0100 Subject: [PATCH] Use new extensibility points for federated authentication --- CHANGELOG.md | 2 + .../DfeAnalyticsAspNetCoreConfigureOptions.cs | 6 +- .../DfeAnalyticsAspNetCoreOptions.cs | 4 +- ...AnalyticsAspNetCorePostConfigureOptions.cs | 2 - .../AspNetCore/DfeAnalyticsMiddleware.cs | 7 +- src/Dfe.Analytics/Constants.cs | 2 +- src/Dfe.Analytics/Dfe.Analytics.csproj | 5 +- .../DfeAnalyticsConfigureOptions.cs | 73 ++++-- src/Dfe.Analytics/DfeAnalyticsOptions.cs | 11 + .../DfeAnalyticsPostConfigureOptions.cs | 2 - src/Dfe.Analytics/Extensions.cs | 33 --- ...eratedAksAuthenticationConfigureOptions.cs | 59 ----- .../FederatedAksAuthenticationOptions.cs | 25 +- .../FederatedAksBigQueryClientProvider.cs | 221 ------------------ .../FederatedAksSubjectTokenProvider.cs | 62 +++++ src/Dfe.Analytics/IBigQueryClientProvider.cs | 15 -- .../OptionsBigQueryClientProvider.cs | 31 --- 17 files changed, 144 insertions(+), 416 deletions(-) delete mode 100644 src/Dfe.Analytics/FederatedAksAuthenticationConfigureOptions.cs delete mode 100644 src/Dfe.Analytics/FederatedAksBigQueryClientProvider.cs create mode 100644 src/Dfe.Analytics/FederatedAksSubjectTokenProvider.cs delete mode 100644 src/Dfe.Analytics/IBigQueryClientProvider.cs delete mode 100644 src/Dfe.Analytics/OptionsBigQueryClientProvider.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 08bb341..ddc3458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +Overhauls authentication to use new extensibility points in the Google SDK and simplify setup by auto-detecting authentication mechanism to use. + Removes the option to pseudonymize the user ID. ## 0.2.6 diff --git a/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCoreConfigureOptions.cs b/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCoreConfigureOptions.cs index c5ec5cc..dd63020 100644 --- a/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCoreConfigureOptions.cs +++ b/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCoreConfigureOptions.cs @@ -3,17 +3,13 @@ namespace Dfe.Analytics.AspNetCore; -#pragma warning disable CA1812 internal class DfeAnalyticsAspNetCoreConfigureOptions(IConfiguration configuration) : IConfigureOptions -#pragma warning restore CA1812 { - private readonly IConfiguration _configuration = configuration; - public void Configure(DfeAnalyticsAspNetCoreOptions options) { ArgumentNullException.ThrowIfNull(options); - var section = _configuration.GetSection(Constants.RootConfigurationSectionName).GetSection("AspNetCore"); + var section = configuration.GetSection(Constants.ConfigurationSectionName).GetSection("AspNetCore"); section.AssignConfigurationValueIfNotEmpty("UserIdClaimType", v => options.UserIdClaimType = v); section.AssignConfigurationValueIfNotEmpty("RestoreOriginalPathAndQueryString", v => options.RestoreOriginalPathAndQueryString = bool.Parse(v)); diff --git a/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCoreOptions.cs b/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCoreOptions.cs index 73cfa84..fd77fe8 100644 --- a/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCoreOptions.cs +++ b/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCoreOptions.cs @@ -10,7 +10,7 @@ namespace Dfe.Analytics.AspNetCore; public class DfeAnalyticsAspNetCoreOptions { /// - /// A delegate that returns the signed in user's ID, if any. + /// A delegate that returns the signed-in user's ID, if any. /// /// /// The default returns the value of the first claim from property. @@ -18,7 +18,7 @@ public class DfeAnalyticsAspNetCoreOptions public Func? GetUserIdFromRequest { get; set; } /// - /// The claim type that contains the signed in user's ID. + /// The claim type that contains the signed-in user's ID. /// public string? UserIdClaimType { get; set; } diff --git a/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCorePostConfigureOptions.cs b/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCorePostConfigureOptions.cs index ec58ea0..83a782e 100644 --- a/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCorePostConfigureOptions.cs +++ b/src/Dfe.Analytics/AspNetCore/DfeAnalyticsAspNetCorePostConfigureOptions.cs @@ -3,9 +3,7 @@ namespace Dfe.Analytics.AspNetCore; -#pragma warning disable CA1812 internal class DfeAnalyticsAspNetCorePostConfigureOptions : IPostConfigureOptions -#pragma warning restore CA1812 { public void PostConfigure(string? name, DfeAnalyticsAspNetCoreOptions options) { diff --git a/src/Dfe.Analytics/AspNetCore/DfeAnalyticsMiddleware.cs b/src/Dfe.Analytics/AspNetCore/DfeAnalyticsMiddleware.cs index 6e052c4..16cd897 100644 --- a/src/Dfe.Analytics/AspNetCore/DfeAnalyticsMiddleware.cs +++ b/src/Dfe.Analytics/AspNetCore/DfeAnalyticsMiddleware.cs @@ -15,7 +15,6 @@ namespace Dfe.Analytics.AspNetCore; public class DfeAnalyticsMiddleware { private readonly RequestDelegate _next; - private readonly IBigQueryClientProvider _bigQueryClientProvider; private readonly IEnumerable _webRequestEventEnrichers; private readonly ILogger _logger; @@ -23,7 +22,6 @@ public class DfeAnalyticsMiddleware /// Creates a new . /// /// The representing the next middleware in the pipeline. - /// The . /// The . /// The configuration options. /// The middleware configuration options. @@ -31,7 +29,6 @@ public class DfeAnalyticsMiddleware /// The logger instance. public DfeAnalyticsMiddleware( RequestDelegate next, - IBigQueryClientProvider bigQueryClientProvider, TimeProvider timeProvider, IOptions optionsAccessor, IOptions aspNetCoreOptionsAccessor, @@ -39,7 +36,6 @@ public DfeAnalyticsMiddleware( ILogger logger) { ArgumentNullException.ThrowIfNull(next); - ArgumentNullException.ThrowIfNull(bigQueryClientProvider); ArgumentNullException.ThrowIfNull(timeProvider); ArgumentNullException.ThrowIfNull(optionsAccessor); ArgumentNullException.ThrowIfNull(aspNetCoreOptionsAccessor); @@ -47,7 +43,6 @@ public DfeAnalyticsMiddleware( ArgumentNullException.ThrowIfNull(logger); _next = next; - _bigQueryClientProvider = bigQueryClientProvider; TimeProvider = timeProvider; _webRequestEventEnrichers = webRequestEventEnrichers; Options = optionsAccessor.Value; @@ -123,7 +118,7 @@ public async Task InvokeAsync(HttpContext context) } } - var bigQueryClient = await _bigQueryClientProvider.GetBigQueryClientAsync(); + var bigQueryClient = Options.BigQueryClient; var row = @event.ToBigQueryInsertRow(); diff --git a/src/Dfe.Analytics/Constants.cs b/src/Dfe.Analytics/Constants.cs index 6402007..55c00db 100644 --- a/src/Dfe.Analytics/Constants.cs +++ b/src/Dfe.Analytics/Constants.cs @@ -2,5 +2,5 @@ namespace Dfe.Analytics; internal static class Constants { - public const string RootConfigurationSectionName = "DfeAnalytics"; + public const string ConfigurationSectionName = "DfeAnalytics"; } diff --git a/src/Dfe.Analytics/Dfe.Analytics.csproj b/src/Dfe.Analytics/Dfe.Analytics.csproj index 6576824..e8ff605 100644 --- a/src/Dfe.Analytics/Dfe.Analytics.csproj +++ b/src/Dfe.Analytics/Dfe.Analytics.csproj @@ -11,16 +11,17 @@ git A port of the DfE Analytics gem for .NET. v - 0.2 + 0.3 true snupkg true All - $(NoWarn);CA1716;CA1852;CA1848 + $(NoWarn);CA1716;CA1852;CA1848;CA1812 + diff --git a/src/Dfe.Analytics/DfeAnalyticsConfigureOptions.cs b/src/Dfe.Analytics/DfeAnalyticsConfigureOptions.cs index a85434a..3920551 100644 --- a/src/Dfe.Analytics/DfeAnalyticsConfigureOptions.cs +++ b/src/Dfe.Analytics/DfeAnalyticsConfigureOptions.cs @@ -6,42 +6,85 @@ namespace Dfe.Analytics; -#pragma warning disable CA1812 internal class DfeAnalyticsConfigureOptions(IConfiguration configuration) : IConfigureOptions -#pragma warning restore CA1812 { - private readonly IConfiguration _configuration = configuration; - public void Configure(DfeAnalyticsOptions options) { ArgumentNullException.ThrowIfNull(options); - var section = _configuration.GetSection(Constants.RootConfigurationSectionName); + var section = configuration.GetSection(Constants.ConfigurationSectionName); section.AssignConfigurationValueIfNotEmpty("DatasetId", v => options.DatasetId = v); section.AssignConfigurationValueIfNotEmpty("Environment", v => options.Environment = v); section.AssignConfigurationValueIfNotEmpty("Namespace", v => options.Namespace = v); section.AssignConfigurationValueIfNotEmpty("TableId", v => options.TableId = v); section.AssignConfigurationValueIfNotEmpty("ProjectId", v => options.ProjectId = v); + section.AssignConfigurationValueIfNotEmpty("Audience", v => + { + options.FederatedAksAuthentication ??= new(); + options.FederatedAksAuthentication.Audience = v; + }); + section.AssignConfigurationValueIfNotEmpty("GenerateAccessTokenUrl", v => + { + options.FederatedAksAuthentication ??= new(); + options.FederatedAksAuthentication.ServiceAccountImpersonationUrl = v; + }); var credentialsJson = section["CredentialsJson"]; - if (!string.IsNullOrEmpty(credentialsJson)) { using var credentialsJsonDoc = JsonDocument.Parse(credentialsJson); + AssignConfigurationFromCredentialsJson(options, credentialsJsonDoc); + } + } + + private void AssignConfigurationFromCredentialsJson(DfeAnalyticsOptions options, JsonDocument credentialsJson) + { + if (options.ProjectId is null && + credentialsJson.RootElement.TryGetProperty("project_id", out var projectIdElement)) + { + options.ProjectId = projectIdElement.GetString(); + } - // We don't have ProjectId configured explicitly; see if it's set in the JSON credentials - if (options.ProjectId is null && - credentialsJsonDoc.RootElement.TryGetProperty("project_id", out var projectIdElement) && - projectIdElement.ValueKind == JsonValueKind.String) + if (options.FederatedAksAuthentication?.Audience is null && + credentialsJson.RootElement.TryGetProperty("audience", out var audienceElement)) + { + options.FederatedAksAuthentication ??= new(); + options.FederatedAksAuthentication.Audience = audienceElement.GetString()!; + } + + if (options.FederatedAksAuthentication?.ServiceAccountImpersonationUrl is null && + credentialsJson.RootElement.TryGetProperty("service_account_impersonation_url", out var impersonationUrlElement)) + { + options.FederatedAksAuthentication ??= new(); + options.FederatedAksAuthentication.ServiceAccountImpersonationUrl = impersonationUrlElement.GetString()!; + } + + if (options.BigQueryClient is null && options.ProjectId is { } projectId) + { + if (credentialsJson.RootElement.TryGetProperty("private_key", out _)) { - options.ProjectId = projectIdElement.GetString(); + options.BigQueryClient = BigQueryClient.Create( + projectId, + GoogleCredential.FromJson(credentialsJson.ToString())); } - - if (credentialsJsonDoc.RootElement.TryGetProperty("private_key", out _) && options.ProjectId is string projectId) + else if (Environment.GetEnvironmentVariable(FederatedAksSubjectTokenProvider.TokenPathEnvironmentVariableName) is not null && + options.FederatedAksAuthentication is { Audience: { } audience, ServiceAccountImpersonationUrl: { } serviceAccountImpersonationUrl }) { - var creds = GoogleCredential.FromJson(credentialsJson); - options.BigQueryClient = BigQueryClient.Create(projectId, creds); + options.BigQueryClient = BigQueryClient.Create( + projectId, + GoogleCredential.FromProgrammaticExternalAccountCredential( + new ProgrammaticExternalAccountCredential( + new ProgrammaticExternalAccountCredential.Initializer( + tokenUrl: "https://sts.googleapis.com/v1/token", + audience, + FederatedAksSubjectTokenProvider.SubjectTokenType, +#pragma warning disable CA2000 + new FederatedAksSubjectTokenProvider()) + { + ServiceAccountImpersonationUrl = serviceAccountImpersonationUrl + }))); +#pragma warning restore CA2000 } } } diff --git a/src/Dfe.Analytics/DfeAnalyticsOptions.cs b/src/Dfe.Analytics/DfeAnalyticsOptions.cs index 2a23308..9cb7b52 100644 --- a/src/Dfe.Analytics/DfeAnalyticsOptions.cs +++ b/src/Dfe.Analytics/DfeAnalyticsOptions.cs @@ -47,11 +47,22 @@ public class DfeAnalyticsOptions /// public string? ProjectId { get; set; } + /// + /// The federated AKS authentication options. + /// + public FederatedAksAuthenticationOptions? FederatedAksAuthentication { get; set; } + + [MemberNotNull(nameof(BigQueryClient))] [MemberNotNull(nameof(DatasetId))] [MemberNotNull(nameof(TableId))] [MemberNotNull(nameof(Environment))] internal void ValidateOptions() { + if (BigQueryClient is null) + { + throw new InvalidOperationException($"{nameof(BigQueryClient)} has not been configured."); + } + if (DatasetId is null) { throw new InvalidOperationException($"{nameof(DatasetId)} has not been configured."); diff --git a/src/Dfe.Analytics/DfeAnalyticsPostConfigureOptions.cs b/src/Dfe.Analytics/DfeAnalyticsPostConfigureOptions.cs index 79a760c..80f19a0 100644 --- a/src/Dfe.Analytics/DfeAnalyticsPostConfigureOptions.cs +++ b/src/Dfe.Analytics/DfeAnalyticsPostConfigureOptions.cs @@ -3,9 +3,7 @@ namespace Dfe.Analytics; -#pragma warning disable CA1812 internal class DfeAnalyticsPostConfigureOptions : IPostConfigureOptions -#pragma warning restore CA1812 { public void PostConfigure(string? name, DfeAnalyticsOptions options) { diff --git a/src/Dfe.Analytics/Extensions.cs b/src/Dfe.Analytics/Extensions.cs index bf4e055..92e8d0a 100644 --- a/src/Dfe.Analytics/Extensions.cs +++ b/src/Dfe.Analytics/Extensions.cs @@ -40,40 +40,7 @@ public static DfeAnalyticsBuilder AddDfeAnalytics(this IServiceCollection servic services.TryAddEnumerable(ServiceDescriptor.Singleton, DfeAnalyticsPostConfigureOptions>()); services.TryAddSingleton(_ => TimeProvider.System); services.Configure(setupAction); - services.TryAddSingleton(); return new DfeAnalyticsBuilder(services); } - - /// - /// Registers as the . - /// - /// The . - /// The so that additional calls can be chained. - public static DfeAnalyticsBuilder UseFederatedAksBigQueryClientProvider(this DfeAnalyticsBuilder builder) - { - ArgumentNullException.ThrowIfNull(builder); - - return builder.UseFederatedAksBigQueryClientProvider(_ => { }); - } - - /// - /// Registers and configures as the . - /// - /// The . - /// - /// An to configure the provided . - /// - /// The so that additional calls can be chained. - public static DfeAnalyticsBuilder UseFederatedAksBigQueryClientProvider(this DfeAnalyticsBuilder builder, Action setupAction) - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(setupAction); - - builder.Services.AddSingleton(); - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, FederatedAksAuthenticationConfigureOptions>()); - builder.Services.Configure(setupAction); - - return builder; - } } diff --git a/src/Dfe.Analytics/FederatedAksAuthenticationConfigureOptions.cs b/src/Dfe.Analytics/FederatedAksAuthenticationConfigureOptions.cs deleted file mode 100644 index 13cb4c4..0000000 --- a/src/Dfe.Analytics/FederatedAksAuthenticationConfigureOptions.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; - -namespace Dfe.Analytics; - -#pragma warning disable CA1812 -internal class FederatedAksAuthenticationConfigureOptions(IConfiguration configuration) : IConfigureOptions -#pragma warning restore CA1812 -{ - private readonly IConfiguration _configuration = configuration; - - public void Configure(FederatedAksAuthenticationOptions options) - { - ArgumentNullException.ThrowIfNull(options); - - var section = _configuration.GetSection(Constants.RootConfigurationSectionName); - - section.AssignConfigurationValueIfNotEmpty("Audience", v => options.Audience = v); - section.AssignConfigurationValueIfNotEmpty("GenerateAccessTokenUrl", v => options.GenerateAccessTokenUrl = v); - - if (options.Audience is null && - section["ProjectNumber"] is string projectNumber && - section["WorkloadIdentityPoolName"] is string workloadIdentityPoolName && - section["WorkloadIdentityPoolProviderName"] is string workloadIdentityPoolProviderName) - { - options.Audience = $"//iam.googleapis.com/projects/{Uri.EscapeDataString(projectNumber)}/" + - $"locations/global/workloadIdentityPools/{Uri.EscapeDataString(workloadIdentityPoolName)}/" + - $"providers/{Uri.EscapeDataString(workloadIdentityPoolProviderName)}"; - } - - if (options.GenerateAccessTokenUrl is null && - section["ServiceAccountEmail"] is string serviceAccountEmail) - { - options.GenerateAccessTokenUrl = $"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{Uri.EscapeDataString(serviceAccountEmail)}:generateAccessToken"; - } - - var credentialsJson = section["CredentialsJson"]; - - if (!string.IsNullOrEmpty(credentialsJson)) - { - using var credentialsJsonDoc = JsonDocument.Parse(credentialsJson); - - if (options.Audience is null && - credentialsJsonDoc.RootElement.TryGetProperty("audience", out var audienceElement) && - audienceElement.ValueKind == JsonValueKind.String) - { - options.Audience = audienceElement.GetString()!; - } - - if (options.GenerateAccessTokenUrl is null && - credentialsJsonDoc.RootElement.TryGetProperty("service_account_impersonation_url", out var impersonationUrlElement) && - impersonationUrlElement.ValueKind == JsonValueKind.String) - { - options.GenerateAccessTokenUrl = impersonationUrlElement.GetString()!; - } - } - } -} diff --git a/src/Dfe.Analytics/FederatedAksAuthenticationOptions.cs b/src/Dfe.Analytics/FederatedAksAuthenticationOptions.cs index 2bb6c21..deac470 100644 --- a/src/Dfe.Analytics/FederatedAksAuthenticationOptions.cs +++ b/src/Dfe.Analytics/FederatedAksAuthenticationOptions.cs @@ -1,38 +1,19 @@ -using System.Diagnostics.CodeAnalysis; - namespace Dfe.Analytics; /// -/// Configuration for . +/// Configuration for federated AKS authentication. /// public class FederatedAksAuthenticationOptions { /// /// The workflow identity pool provider audience. /// - [DisallowNull] public string? Audience { get; set; } /// - /// The URL for retrieving an access token for accessing BigQuery. + /// The URL for the service account impersonation request. /// - [DisallowNull] #pragma warning disable CA1056 - public string? GenerateAccessTokenUrl { get; set; } + public string? ServiceAccountImpersonationUrl { get; set; } #pragma warning restore CA1056 - - [MemberNotNull(nameof(Audience))] - [MemberNotNull(nameof(GenerateAccessTokenUrl))] - internal void ValidateOptions() - { - if (Audience is null) - { - throw new InvalidOperationException($"{nameof(Audience)} has not been configured."); - } - - if (GenerateAccessTokenUrl is null) - { - throw new InvalidOperationException($"{nameof(GenerateAccessTokenUrl)} has not been configured."); - } - } } diff --git a/src/Dfe.Analytics/FederatedAksBigQueryClientProvider.cs b/src/Dfe.Analytics/FederatedAksBigQueryClientProvider.cs deleted file mode 100644 index 4395817..0000000 --- a/src/Dfe.Analytics/FederatedAksBigQueryClientProvider.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System.Net.Http.Json; -using System.Text.Json; -using System.Text.Json.Nodes; -using Google.Apis.Auth.OAuth2; -using Google.Cloud.BigQuery.V2; -using Microsoft.Extensions.Options; - -namespace Dfe.Analytics; - -/// -/// An implementation of for workloads running in Azure Kubernetes Service -/// with workload identity federation. -/// -public sealed class AksFederatedBigQueryClientProvider : IBigQueryClientProvider, IDisposable -{ - /// - /// The duration before the token actually expires at which we should refresh the credentials. - /// - private static readonly TimeSpan _expirationAllowance = TimeSpan.FromMinutes(1); - - /// - /// The cached . - /// - private BigQueryClient? _client; - - /// - /// The time that expires. - /// - private DateTimeOffset _clientExpiry; - - private readonly TimeProvider _timeProvider; - private readonly FederatedAksAuthenticationOptions _options; - private readonly HttpClient _httpClient; - private bool _disposed; - - private string _clientId = default!; - private string _tokenPath = default!; - private string _tenantId = default!; - - /// - /// Initializes a new instance of . - /// - /// The . - /// The . - public AksFederatedBigQueryClientProvider( - TimeProvider timeProvider, - IOptions optionsAccessor) - { - ArgumentNullException.ThrowIfNull(timeProvider); - ArgumentNullException.ThrowIfNull(optionsAccessor); - - optionsAccessor.Value.ValidateOptions(); - - _timeProvider = timeProvider; - _options = optionsAccessor.Value; - GetConfigurationFromEnvironment(); -#pragma warning disable CA2000 - _httpClient = new HttpClient(new HttpClientHandler() { PreAuthenticate = true, CheckCertificateRevocationList = true }); -#pragma warning restore CA2000 - } - - /// - public void Dispose() - { - if (_disposed) - { - return; - } - - _client?.Dispose(); - _httpClient.Dispose(); - _client = null; - _disposed = true; - } - - /// - public ValueTask GetBigQueryClientAsync() - { - ObjectDisposedException.ThrowIf(_disposed, this); - - var now = _timeProvider.GetUtcNow(); - return _client is not null && (_clientExpiry - _expirationAllowance) > now - ? new ValueTask(_client) - : CreateClientAsync(); - - async ValueTask CreateClientAsync() - { - await CreateAndAssignClientAsync(); - return _client!; - } - } - - private void GetConfigurationFromEnvironment() - { - _clientId = GetRequiredEnvironmentVariable("AZURE_CLIENT_ID"); - _tokenPath = GetRequiredEnvironmentVariable("AZURE_FEDERATED_TOKEN_FILE"); - _tenantId = GetRequiredEnvironmentVariable("AZURE_TENANT_ID"); - - static string GetRequiredEnvironmentVariable(string name) => - Environment.GetEnvironmentVariable(name) ?? - throw new InvalidOperationException($"The {name} environment variable is missing."); - } - - private async Task CreateAndAssignClientAsync() - { - _client?.Dispose(); - - var projectId = GetProjectId(); - - var (token, expires) = await GetAccessTokenAsync(); - var credential = GoogleCredential.FromAccessToken(token); - _client = await BigQueryClient.CreateAsync(projectId, credential); - _clientExpiry = expires; - } - - private string GetProjectId() - { - if (!_options.Audience!.StartsWith("//iam.googleapis.com/projects/", StringComparison.Ordinal)) - { - throw new InvalidOperationException($"Unexpected {nameof(_options.Audience)} format."); - } - - var projectId = _options.Audience["//iam.googleapis.com/projects/".Length..].Split('/')[0]; - return projectId; - } - - private async Task<(string AccessToken, DateTimeOffset Expires)> GetAccessTokenAsync() - { - var azureToken = await GetAzureAccessTokenAsync(); - var googleToken = await ExchangeTokenAsync(azureToken); - return await GetBigQueryTokenAsync(googleToken); - - async Task GetAzureAccessTokenAsync() - { - var assertion = await File.ReadAllTextAsync(_tokenPath); - - using var request = new HttpRequestMessage(HttpMethod.Post, $"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token"); - request.Content = new FormUrlEncodedContent(new Dictionary() - { - { "client_id", _clientId }, - { "scope", "api://AzureADTokenExchange/.default" }, - { "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, - { "client_assertion", assertion }, - { "grant_type", "client_credentials" } - }); - - var response = await _httpClient.SendAsync(request); - response.EnsureTokenAcquisitionSucceeded(exceptionMessage: "Failed acquiring access token from Azure."); - - var responseBody = await response.Content.ReadAsStringAsync(); - var responseBodyJson = JsonDocument.Parse(responseBody); - var token = responseBodyJson.GetRequiredProperty("access_token").GetString()!; - return token; - } - - async Task ExchangeTokenAsync(string azureToken) - { - var audience = _options.Audience; - - using var request = new HttpRequestMessage(HttpMethod.Post, "https://sts.googleapis.com/v1/token"); - request.Content = JsonContent.Create(new JsonObject() - { - { "grantType", "urn:ietf:params:oauth:grant-type:token-exchange" }, - { "audience", audience }, - { "scope", "https://www.googleapis.com/auth/cloud-platform" }, - { "requestedTokenType", "urn:ietf:params:oauth:token-type:access_token" }, - { "subjectToken", azureToken }, - { "subjectTokenType", "urn:ietf:params:oauth:token-type:jwt" } - }); - - var response = await _httpClient.SendAsync(request); - response.EnsureTokenAcquisitionSucceeded(exceptionMessage: "Failed exchanging access token."); - - var responseBody = await response.Content.ReadAsStringAsync(); - var responseBodyJson = JsonDocument.Parse(responseBody); - var token = responseBodyJson.GetRequiredProperty("access_token").GetString()!; - return token; - } - - async Task<(string AccessToken, DateTimeOffset Expires)> GetBigQueryTokenAsync(string googleToken) - { - using var request = new HttpRequestMessage(HttpMethod.Post, _options.GenerateAccessTokenUrl); - request.Headers.Authorization = new("Bearer", googleToken); - request.Content = JsonContent.Create(new JsonObject() - { - { "scope", new JsonArray("https://www.googleapis.com/auth/cloud-platform") } - }); - - var response = await _httpClient.SendAsync(request); - response.EnsureTokenAcquisitionSucceeded(exceptionMessage: "Failed acquiring access token from Google."); - - var responseBody = await response.Content.ReadAsStringAsync(); - var responseBodyJson = JsonDocument.Parse(responseBody); - var token = responseBodyJson.GetRequiredProperty("accessToken").GetString()!; - var expireTime = responseBodyJson.GetRequiredProperty("expireTime").GetDateTimeOffset(); - return (token, expireTime); - } - } -} - -file static class Extensions -{ - public static JsonElement GetRequiredProperty(this JsonDocument doc, string propertyName) - { - return !doc.RootElement.TryGetProperty(propertyName, out var result) || result.ValueKind == JsonValueKind.Null - ? throw new InvalidOperationException($"Document was missing expected property: '{propertyName}'.") - : result; - } - - public static void EnsureTokenAcquisitionSucceeded(this HttpResponseMessage response, string exceptionMessage) - { - try - { - response.EnsureSuccessStatusCode(); - } - catch (Exception ex) - { - throw new DfeAnalyticsAuthenticationException(exceptionMessage, ex); - } - } -} diff --git a/src/Dfe.Analytics/FederatedAksSubjectTokenProvider.cs b/src/Dfe.Analytics/FederatedAksSubjectTokenProvider.cs new file mode 100644 index 0000000..35cc3f9 --- /dev/null +++ b/src/Dfe.Analytics/FederatedAksSubjectTokenProvider.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using Google.Apis.Auth.OAuth2; + +namespace Dfe.Analytics; + +internal sealed class FederatedAksSubjectTokenProvider : + ProgrammaticExternalAccountCredential.ISubjectTokenProvider, IDisposable +{ + public const string ClientIdEnvironmentVariableName = "AZURE_CLIENT_ID"; + public const string TokenPathEnvironmentVariableName = "AZURE_FEDERATED_TOKEN_FILE"; + public const string TenantIdEnvironmentVariableName = "AZURE_TENANT_ID"; + + public const string SubjectTokenType = "urn:ietf:params:oauth:token-type:jwt"; + + private readonly HttpClient _httpClient = new(new SocketsHttpHandler { PreAuthenticate = true }); + + public async Task GetSubjectTokenAsync(ProgrammaticExternalAccountCredential caller, CancellationToken taskCancellationToken) + { + var clientId = GetRequiredEnvironmentVariable(ClientIdEnvironmentVariableName); + var tokenPath = GetRequiredEnvironmentVariable(TokenPathEnvironmentVariableName); + var tenantId = GetRequiredEnvironmentVariable(TenantIdEnvironmentVariableName); + + var assertion = await File.ReadAllTextAsync(tokenPath, taskCancellationToken); + + using var request = new HttpRequestMessage(HttpMethod.Post, $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"); + request.Content = new FormUrlEncodedContent(new Dictionary + { + { "client_id", clientId }, + { "scope", "api://AzureADTokenExchange/.default" }, + { "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" }, + { "client_assertion", assertion }, + { "grant_type", "client_credentials" } + }); + + var response = await _httpClient.SendAsync(request, taskCancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new DfeAnalyticsAuthenticationException( + $"Failed acquiring access token from {request.RequestUri!.Host}; " + + $"response status code does not indicate success: {response.StatusCode}.\n\n" + + await response.Content.ReadAsStringAsync(taskCancellationToken)); + } + + var responseBody = await response.Content.ReadAsStringAsync(taskCancellationToken); + using var responseBodyJson = JsonDocument.Parse(responseBody); + var token = responseBodyJson.RootElement.TryGetProperty("access_token", out var accessTokenProperty) && + accessTokenProperty.GetString() is string accessToken + ? accessToken + : throw new InvalidOperationException($"Document was missing expected property: 'access_token'."); + + return token; + + static string GetRequiredEnvironmentVariable(string name) => + Environment.GetEnvironmentVariable(name) ?? + throw new InvalidOperationException($"The {name} environment variable is missing."); + } + + public void Dispose() + { + _httpClient.Dispose(); + } +} diff --git a/src/Dfe.Analytics/IBigQueryClientProvider.cs b/src/Dfe.Analytics/IBigQueryClientProvider.cs deleted file mode 100644 index fb85b6d..0000000 --- a/src/Dfe.Analytics/IBigQueryClientProvider.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Google.Cloud.BigQuery.V2; - -namespace Dfe.Analytics; - -/// -/// Represents a type that can return an authenticated . -/// -public interface IBigQueryClientProvider -{ - /// - /// Gets a . - /// - /// An authenticated . - ValueTask GetBigQueryClientAsync(); -} diff --git a/src/Dfe.Analytics/OptionsBigQueryClientProvider.cs b/src/Dfe.Analytics/OptionsBigQueryClientProvider.cs deleted file mode 100644 index ce15ad9..0000000 --- a/src/Dfe.Analytics/OptionsBigQueryClientProvider.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Google.Cloud.BigQuery.V2; -using Microsoft.Extensions.Options; - -namespace Dfe.Analytics; - -/// -/// An implementation of that retrieves a from . -/// -public class OptionsBigQueryClientProvider : IBigQueryClientProvider -{ - private readonly IOptions _optionsAccessor; - - /// - /// Initializes a new instance of . - /// - /// The . - public OptionsBigQueryClientProvider(IOptions optionsAccessor) - { - ArgumentNullException.ThrowIfNull(optionsAccessor); - _optionsAccessor = optionsAccessor; - } - - /// - public ValueTask GetBigQueryClientAsync() - { - var configuredClient = _optionsAccessor.Value.BigQueryClient ?? - throw new InvalidOperationException($"No {nameof(DfeAnalyticsOptions.BigQueryClient)} has been configured."); - - return new ValueTask(configuredClient); - } -}