diff --git a/Makefile b/Makefile index 44597b9fc..57b21132f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: broker broker-aspire compose +.PHONY: broker broker-aspire compose keycloak broker-aspire: docker compose up broker otel-collector aspire-dashboard --build @@ -8,3 +8,7 @@ compose: broker: docker compose up broker --build + +# Local OpenID Connect issuer, an alternative to Entra ID. See backend/README.md. +keycloak: + docker compose --profile keycloak up keycloak diff --git a/backend/README.md b/backend/README.md index 1136450cd..5597fec93 100644 --- a/backend/README.md +++ b/backend/README.md @@ -45,6 +45,42 @@ dotnet ef migrations remove - **Development**: after merging a PR that touches `backend/api/Migrations`, manually run the ["Run database migrations (Development)"](https://github.com/equinor/flotilla/actions/workflows/run_development_migrations.yml) workflow. - **Staging / Production**: applied automatically by the [deploy_to_staging](https://github.com/equinor/flotilla/blob/main/.github/workflows/deploy_to_staging.yml) and [promote_to_production](https://github.com/equinor/flotilla/blob/main/.github/workflows/promote_to_production.yml) workflows. +## Authentication + +The backend validates access tokens against Microsoft Entra ID by default. `Authentication:Provider` selects the issuer: `EntraId` (default), or `Oidc` for any conformant OpenID Connect issuer given by `AzureAd:Authority`. + +This is not a way to turn authentication off — issuer, audience, signature, lifetime and roles are validated under either value. An `http://` authority is accepted only in the `Local` and `IntegrationTest` environments and fails at startup anywhere else. Note that the frontend still signs in against Entra ID; only the backend is covered here. + +### Running against a local Keycloak + +```bash +make keycloak # docker compose --profile keycloak up keycloak +``` + +Keycloak comes up on `http://localhost:8080` with the same realm the integration tests use. The realm is read from `../armada/robotics_integration_tests/custom_realms`; set `KEYCLOAK_REALM_DIR` if armada is not checked out beside this repository. Then add to `backend/api/.env`: + +``` +Authentication__Provider=Oidc +AzureAd__Authority=http://localhost:8080/realms/robotics +AzureAd__ClientId=flotilla-test +AzureAd__ClientSecret=flotilla-test-secret +Isar__Scopes__0=isar-api +SARA__Scopes__0=sara-api +Pointilla__Scopes__0=pointilla-api +``` + +The realm's `dev` user (password `dev`) holds `Role.Admin` and the per-installation roles. A token for calling the API directly, or from Swagger: + +```bash +curl -s -X POST http://localhost:8080/realms/robotics/protocol/openid-connect/token \ + -d grant_type=client_credentials \ + -d client_id=integration-tests \ + -d client_secret=integration-tests-secret \ + -d scope=flotilla-api | jq -r .access_token +``` + +Request one `*-api` scope at a time: two audience mappers make Keycloak emit `aud` as an array, which ISAR rejects. + ## Formatting Formatting rules are defined in the [.editorconfig](../.editorconfig). We use [CSharpier](https://csharpier.com/) to auto-format on save (see [installation](https://csharpier.com/docs/About)). To check formatting locally: diff --git a/backend/api.test/Mocks/SignalRServiceMock.cs b/backend/api.test/Mocks/SignalRServiceMock.cs index a8f3cfdef..340b15372 100644 --- a/backend/api.test/Mocks/SignalRServiceMock.cs +++ b/backend/api.test/Mocks/SignalRServiceMock.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Api.Database.Models; using Api.Services; @@ -7,7 +8,32 @@ namespace Api.Test.Mocks { public class MockSignalRService : ISignalRService { - public List LatestMessages { get; set; } = []; + // Messages arrive on background threads -- the MQTT event handler raises them + // while a test is polling this collection from another thread. An unguarded + // List throws "Collection was modified; enumeration operation may not execute" + // in the middle of a foreach, so writes take a lock and reads hand back a + // snapshot that the caller can safely enumerate. + private readonly List _latestMessages = []; + private readonly Lock _lock = new(); + + public IReadOnlyList LatestMessages + { + get + { + lock (_lock) + { + return [.. _latestMessages]; + } + } + } + + private void Record(string label, object messageObject) + { + lock (_lock) + { + _latestMessages.Add(new { Label = label, Message = messageObject }); + } + } public async Task SendMessageAsync( string label, @@ -15,7 +41,7 @@ public async Task SendMessageAsync( T messageObject ) { - LatestMessages.Add(new { Label = label, Message = messageObject }); + Record(label, messageObject!); await Task.CompletedTask; } @@ -25,7 +51,7 @@ public async Task SendMessageAsync( T messageObject ) { - LatestMessages.Add(new { Label = label, Message = messageObject }); + Record(label, messageObject!); await Task.CompletedTask; } diff --git a/backend/api.test/Security/AuthenticationConfigurationsTests.cs b/backend/api.test/Security/AuthenticationConfigurationsTests.cs new file mode 100644 index 000000000..52cb7dd05 --- /dev/null +++ b/backend/api.test/Security/AuthenticationConfigurationsTests.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using Api.Configurations; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Abstractions; +using Xunit; + +namespace Api.Test.Security +{ + /// + /// Guard rails for the generic OpenID Connect path: that Entra ID remains the + /// default with its issuer validator intact, and that an unencrypted issuer is + /// refused outside Local and IntegrationTest. + /// + public class AuthenticationConfigurationsTests + { + private const string KeycloakAuthority = "http://keycloak:8080/realms/robotics"; + + private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + public string ApplicationName { get; set; } = "Api.Test"; + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } + + private static ServiceProvider BuildProvider( + string environmentName, + string? provider = null, + string? authority = null + ) + { + var settings = new Dictionary + { + ["AzureAd:Instance"] = "https://login.microsoftonline.com", + ["AzureAd:TenantId"] = "00000000-0000-0000-0000-000000000000", + ["AzureAd:ClientId"] = "flotilla-test", + ["Redis:UseRedis"] = "false", + ["Isar:Scopes:0"] = "isar-api", + ["SARA:Scopes:0"] = "sara-api", + ["Pointilla:Scopes:0"] = "pointilla-api", + }; + + if (provider is not null) + { + settings[AuthenticationConfigurations.ProviderKey] = provider; + } + + if (authority is not null) + { + settings["AzureAd:Authority"] = authority; + } + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(configuration); + services.ConfigureAuthentication( + configuration, + new StubHostEnvironment(environmentName) + ); + + return services.BuildServiceProvider(); + } + + private static ServiceProvider BuildOidcProvider( + string environmentName, + string authority = KeycloakAuthority + ) => BuildProvider(environmentName, AuthenticationConfigurations.OidcProvider, authority); + + [Theory] + [InlineData("Development")] + [InlineData("Staging")] + [InlineData("Production")] + [InlineData("Local")] + [InlineData("Test")] + [InlineData(AuthenticationConfigurations.IntegrationTestEnvironment)] + public void EntraIdIsTheDefaultProviderInEveryEnvironment(string environmentName) + { + using var provider = BuildProvider(environmentName); + + var headerProvider = provider.GetService(); + + Assert.IsNotType(headerProvider); + } + + [Theory] + [InlineData("Development")] + [InlineData("Staging")] + [InlineData("Production")] + [InlineData("Local")] + [InlineData("Test")] + [InlineData(AuthenticationConfigurations.IntegrationTestEnvironment)] + public void EntraIssuerValidatorSurvivesUnderTheDefaultProvider(string environmentName) + { + using var provider = BuildProvider(environmentName); + + var options = provider + .GetRequiredService>() + .Get(JwtBearerDefaults.AuthenticationScheme); + + // AddMicrosoftIdentityWebApi installs the Entra-aware issuer validator. If + // this is ever null while the provider is EntraId, issuer validation has + // been weakened for a real deployment. + Assert.NotNull(options.TokenValidationParameters.IssuerValidator); + } + + [Theory] + [InlineData("Local")] + [InlineData(AuthenticationConfigurations.IntegrationTestEnvironment)] + public void OidcProviderRedirectsTokenValidation(string environmentName) + { + using var provider = BuildOidcProvider(environmentName); + + var options = provider + .GetRequiredService>() + .Get(JwtBearerDefaults.AuthenticationScheme); + + Assert.Equal(KeycloakAuthority, options.Authority); + Assert.Equal("flotilla-test", options.Audience); + Assert.False(options.RequireHttpsMetadata); + + var parameters = options.TokenValidationParameters; + Assert.True(parameters.ValidateIssuer); + Assert.True(parameters.ValidateAudience); + Assert.True(parameters.ValidateLifetime); + // The Entra-specific validator must be gone, otherwise the issuer would be + // rejected and instance discovery would hit login.microsoftonline.com. + Assert.Null(parameters.IssuerValidator); + } + + [Theory] + [InlineData("Local")] + [InlineData(AuthenticationConfigurations.IntegrationTestEnvironment)] + public void OidcProviderRegistersTheGenericHeaderProvider(string environmentName) + { + using var provider = BuildOidcProvider(environmentName); + + var headerProvider = provider.GetRequiredService(); + + Assert.IsType(headerProvider); + } + + [Theory] + [InlineData("Development")] + [InlineData("Staging")] + [InlineData("Production")] + [InlineData("Test")] + public void UnencryptedIssuerIsRefusedInDeployedEnvironments(string environmentName) + { + var exception = Assert.Throws(() => + BuildOidcProvider(environmentName) + ); + + Assert.Contains("must use HTTPS", exception.Message); + } + + [Theory] + [InlineData("Development")] + [InlineData("Staging")] + [InlineData("Production")] + public void EncryptedIssuerIsAcceptedInDeployedEnvironments(string environmentName) + { + using var provider = BuildOidcProvider( + environmentName, + "https://keycloak.example.com/realms/robotics" + ); + + var options = provider + .GetRequiredService>() + .Get(JwtBearerDefaults.AuthenticationScheme); + + // Metadata over HTTPS is required, which is the whole point of the rail. + Assert.True(options.RequireHttpsMetadata); + Assert.Null(options.TokenValidationParameters.IssuerValidator); + } + + [Fact] + public void OidcProviderWithoutAnAuthorityFailsLoudly() + { + var exception = Assert.Throws(() => + BuildProvider("Local", AuthenticationConfigurations.OidcProvider, authority: null) + ); + + Assert.Contains("AzureAd:Authority is required", exception.Message); + } + + [Fact] + public void SignalRQueryStringTokenHookIsAppliedInEveryEnvironment() + { + foreach ( + var environmentName in new[] + { + "Development", + "Production", + AuthenticationConfigurations.IntegrationTestEnvironment, + } + ) + { + using var provider = BuildProvider(environmentName); + + var options = provider + .GetRequiredService>() + .Get(JwtBearerDefaults.AuthenticationScheme); + + Assert.NotNull(options.Events?.OnMessageReceived); + } + + using var oidcProvider = BuildOidcProvider("Local"); + var oidcOptions = oidcProvider + .GetRequiredService>() + .Get(JwtBearerDefaults.AuthenticationScheme); + + Assert.NotNull(oidcOptions.Events?.OnMessageReceived); + } + } +} diff --git a/backend/api/Configurations/AuthenticationConfigurations.cs b/backend/api/Configurations/AuthenticationConfigurations.cs new file mode 100644 index 000000000..ec15b8a2a --- /dev/null +++ b/backend/api/Configurations/AuthenticationConfigurations.cs @@ -0,0 +1,186 @@ +using Api.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web; + +namespace Api.Configurations +{ + public static class AuthenticationConfigurations + { + /// + /// Selects the identity provider: EntraId (default) or Oidc, any + /// conformant OpenID Connect issuer. Validation is unchanged either way. + /// + public const string ProviderKey = "Authentication:Provider"; + public const string EntraIdProvider = "EntraId"; + public const string OidcProvider = "Oidc"; + + /// The environment used by the armada integration tests. + public const string IntegrationTestEnvironment = "IntegrationTest"; + + public const string LocalEnvironment = "Local"; + + public static bool UsesGenericOidc(this IConfiguration configuration) => + string.Equals( + configuration[ProviderKey], + OidcProvider, + StringComparison.OrdinalIgnoreCase + ); + + /// + /// Whether a plain-HTTP authority is tolerated, which is only where the issuer + /// is necessarily local. Elsewhere an http:// authority fails at startup. + /// + public static bool AllowsInsecureMetadata(this IHostEnvironment environment) => + environment.IsEnvironment(LocalEnvironment) + || environment.IsEnvironment(IntegrationTestEnvironment); + + /// + /// Registers JWT bearer authentication, the MSAL token caches and the + /// downstream API clients for ISAR, SARA and Pointilla. + /// + public static IServiceCollection ConfigureAuthentication( + this IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment + ) + { + bool useRedis = configuration.GetSection("Redis").GetValue("UseRedis"); + if (useRedis) + { + services.ConfigureRedisCache(configuration); + } + + var authenticationBuilder = services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(configuration.GetSection("AzureAd")) + .EnableTokenAcquisitionToCallDownstreamApi(); + + if (useRedis) + { + authenticationBuilder.AddDistributedTokenCaches(); + } + else + { + authenticationBuilder.AddInMemoryTokenCaches(); + } + + authenticationBuilder + .AddDownstreamApi(InspectionService.ServiceName, configuration.GetSection("SARA")) + .AddDownstreamApi(IsarService.ServiceName, configuration.GetSection("Isar")) + .AddDownstreamApi( + PointillaService.ServiceName, + configuration.GetSection("Pointilla") + ); + + if (configuration.UsesGenericOidc()) + { + ConfigureGenericOidcOverrides(services, configuration, environment); + } + + ConfigureSignalRQueryStringToken(services); + + return services; + } + + /// + /// Redirects both halves of authentication at the configured OpenID issuer. + /// Outbound, replacing IAuthorizationHeaderProvider covers all three downstream + /// APIs, since IDownstreamApi resolves every bearer token through it. + /// + /// Inbound, the split across both options phases is required: + /// JwtBearerPostConfigureOptions rejects a plain HTTP authority and runs before + /// any post-configuration we can register, while Microsoft.Identity.Web installs + /// its Entra-specific AadIssuerValidator during post-configuration, so only a + /// later post-configure can remove it. + /// + private static void ConfigureGenericOidcOverrides( + IServiceCollection services, + IConfiguration configuration, + IHostEnvironment environment + ) + { + string authority = + configuration["AzureAd:Authority"] + ?? throw new InvalidOperationException( + $"AzureAd:Authority is required when {ProviderKey} is {OidcProvider}" + ); + string audience = + configuration["AzureAd:ClientId"] + ?? throw new InvalidOperationException( + $"AzureAd:ClientId is required when {ProviderKey} is {OidcProvider}" + ); + + bool allowInsecureMetadata = environment.AllowsInsecureMetadata(); + if ( + !allowInsecureMetadata + && !authority.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ) + { + throw new InvalidOperationException( + $"AzureAd:Authority must use HTTPS in the {environment.EnvironmentName} " + + $"environment, but was '{authority}'. Plain HTTP is only accepted in " + + $"the {LocalEnvironment} and {IntegrationTestEnvironment} environments." + ); + } + + services.Configure( + JwtBearerDefaults.AuthenticationScheme, + options => + { + options.Authority = authority; + options.Audience = audience; + options.RequireHttpsMetadata = !allowInsecureMetadata; + } + ); + + services.PostConfigure( + JwtBearerDefaults.AuthenticationScheme, + options => + { + var parameters = options.TokenValidationParameters; + parameters.ValidateIssuer = true; + parameters.ValidateAudience = true; + parameters.ValidateLifetime = true; + parameters.ValidAudience = audience; + parameters.ValidAudiences = [audience]; + // Drop the Entra-specific issuer validator; the issuer is taken from + // the provider's discovery document instead. + parameters.IssuerValidator = null; + parameters.ValidIssuers = null; + } + ); + + services.AddSingleton< + IAuthorizationHeaderProvider, + GenericOidcAuthorizationHeaderProvider + >(); + } + + /// + /// Browsers cannot set headers on WebSocket connections, so SignalR passes the + /// access token in the query string instead. + /// + private static void ConfigureSignalRQueryStringToken(IServiceCollection services) + { + services.Configure( + JwtBearerDefaults.AuthenticationScheme, + options => + { + options.Events ??= new JwtBearerEvents(); + options.Events.OnMessageReceived = context => + { + if ( + context.HttpContext.Request.Path.StartsWithSegments("/hub") + && context.Request.Query.TryGetValue("access_token", out var token) + ) + { + context.Token = token; + } + return Task.CompletedTask; + }; + } + ); + } + } +} diff --git a/backend/api/Configurations/GenericOidcAuthorizationHeaderProvider.cs b/backend/api/Configurations/GenericOidcAuthorizationHeaderProvider.cs new file mode 100644 index 000000000..ab7e2e0c7 --- /dev/null +++ b/backend/api/Configurations/GenericOidcAuthorizationHeaderProvider.cs @@ -0,0 +1,218 @@ +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Security.Claims; +using Microsoft.Identity.Abstractions; +using Microsoft.Identity.Web.Extensibility; + +namespace Api.Configurations +{ + /// + /// Acquires downstream API tokens from a generic OpenID Connect issuer instead of + /// Microsoft Entra ID, when Authentication:Provider is Oidc. Always an + /// application token; no on-behalf-of flow is involved. + /// + /// The token endpoint is read from the discovery document rather than assumed: + /// Entra publishes it under /oauth2/v2.0/token and Keycloak under + /// /protocol/openid-connect/token. + /// + public class GenericOidcAuthorizationHeaderProvider( + IServiceProvider serviceProvider, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger + ) : BaseAuthorizationHeaderProvider(serviceProvider) + { + private const string BearerScheme = "Bearer"; + + // Renew a little before expiry so a token cannot lapse mid-request. + private static readonly TimeSpan ExpiryMargin = TimeSpan.FromSeconds(60); + + private readonly ConcurrentDictionary _cache = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly SemaphoreSlim _discoveryLock = new(1, 1); + + private string? _tokenEndpoint; + + private string Authority => + configuration["AzureAd:Authority"] + ?? throw new InvalidOperationException("AzureAd:Authority is not configured"); + + private string ClientId => + configuration["AzureAd:ClientId"] + ?? throw new InvalidOperationException("AzureAd:ClientId is not configured"); + + public override Task CreateAuthorizationHeaderForAppAsync( + string scopes, + AuthorizationHeaderProviderOptions? downstreamApiOptions = null, + CancellationToken cancellationToken = default + ) => GetAuthorizationHeaderAsync(scopes, cancellationToken); + + public override Task CreateAuthorizationHeaderForUserAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? authorizationHeaderProviderOptions = null, + ClaimsPrincipal? claimsPrincipal = null, + CancellationToken cancellationToken = default + ) => GetAuthorizationHeaderAsync(string.Join(' ', scopes), cancellationToken); + + public override Task CreateAuthorizationHeaderAsync( + IEnumerable scopes, + AuthorizationHeaderProviderOptions? options = null, + ClaimsPrincipal? claimsPrincipal = null, + CancellationToken cancellationToken = default + ) => GetAuthorizationHeaderAsync(string.Join(' ', scopes), cancellationToken); + + private async Task GetAuthorizationHeaderAsync( + string scopes, + CancellationToken cancellationToken + ) + { + if ( + _cache.TryGetValue(scopes, out var cached) + && cached.ExpiresAt - ExpiryMargin > DateTimeOffset.UtcNow + ) + { + return $"{BearerScheme} {cached.AccessToken}"; + } + + await _lock.WaitAsync(cancellationToken); + try + { + // Another caller may have refreshed while this one waited. + if ( + _cache.TryGetValue(scopes, out cached) + && cached.ExpiresAt - ExpiryMargin > DateTimeOffset.UtcNow + ) + { + return $"{BearerScheme} {cached.AccessToken}"; + } + + var token = await RequestTokenAsync(scopes, cancellationToken); + _cache[scopes] = token; + return $"{BearerScheme} {token.AccessToken}"; + } + finally + { + _lock.Release(); + } + } + + /// + /// Resolve the token endpoint from the issuer's OpenID configuration, once. + /// + private async Task GetTokenEndpointAsync(CancellationToken cancellationToken) + { + if (_tokenEndpoint is not null) + { + return _tokenEndpoint; + } + + await _discoveryLock.WaitAsync(cancellationToken); + try + { + if (_tokenEndpoint is not null) + { + return _tokenEndpoint; + } + + string configurationUrl = + $"{Authority.TrimEnd('/')}/.well-known/openid-configuration"; + + using var client = httpClientFactory.CreateClient(); + var document = await client.GetFromJsonAsync( + configurationUrl, + cancellationToken + ); + + _tokenEndpoint = + document?.TokenEndpoint + ?? throw new InvalidOperationException( + $"The OpenID configuration at {configurationUrl} advertises no token_endpoint" + ); + + logger.LogInformation( + "Resolved token endpoint {TokenEndpoint} from {ConfigurationUrl}", + _tokenEndpoint, + configurationUrl + ); + + return _tokenEndpoint; + } + finally + { + _discoveryLock.Release(); + } + } + + private async Task RequestTokenAsync( + string scopes, + CancellationToken cancellationToken + ) + { + string tokenEndpoint = await GetTokenEndpointAsync(cancellationToken); + + logger.LogInformation( + "Acquiring token for scope '{Scopes}' from {TokenEndpoint}", + scopes, + tokenEndpoint + ); + + var form = new Dictionary + { + ["grant_type"] = "client_credentials", + ["scope"] = scopes, + ["client_id"] = ClientId, + }; + + // The client credentials grant requires an authenticated client. Keycloak, + // for one, refuses it outright from a public client. + string? clientSecret = configuration["AzureAd:ClientSecret"]; + if (!string.IsNullOrEmpty(clientSecret)) + { + form["client_secret"] = clientSecret; + } + + using var client = httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) + { + Content = new FormUrlEncodedContent(form), + }; + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var response = await client.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ); + + if (string.IsNullOrEmpty(payload?.AccessToken)) + { + throw new InvalidOperationException( + $"Token endpoint at {tokenEndpoint} returned no access_token for scope '{scopes}'" + ); + } + + return new CachedToken( + payload.AccessToken, + DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn ?? 3600) + ); + } + + private sealed record CachedToken(string AccessToken, DateTimeOffset ExpiresAt); + + private sealed class OpenIdConfiguration + { + [System.Text.Json.Serialization.JsonPropertyName("token_endpoint")] + public string? TokenEndpoint { get; set; } + } + + private sealed class TokenResponse + { + [System.Text.Json.Serialization.JsonPropertyName("access_token")] + public string? AccessToken { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("expires_in")] + public int? ExpiresIn { get; set; } + } + } +} diff --git a/backend/api/Program.cs b/backend/api/Program.cs index ffdbae631..c6dbf6971 100644 --- a/backend/api/Program.cs +++ b/backend/api/Program.cs @@ -17,10 +17,8 @@ using Azure.Identity; using DotEnv.Core; using Hangfire; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Rewrite; -using Microsoft.Identity.Web; var builder = WebApplication.CreateBuilder(args); new EnvLoader().Load(); @@ -130,55 +128,9 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.ConfigureSwagger(builder.Configuration); -// Configure Redis with Microsoft Entra Authentication -if (builder.Configuration.GetSection("Redis").GetValue("UseRedis")) -{ - builder.Services.ConfigureRedisCache(builder.Configuration); - builder - .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) - .EnableTokenAcquisitionToCallDownstreamApi() - .AddDistributedTokenCaches() - .AddDownstreamApi(InspectionService.ServiceName, builder.Configuration.GetSection("SARA")) - .AddDownstreamApi(IsarService.ServiceName, builder.Configuration.GetSection("Isar")) - .AddDownstreamApi( - PointillaService.ServiceName, - builder.Configuration.GetSection("Pointilla") - ); -} -else -{ - builder - .Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")) - .EnableTokenAcquisitionToCallDownstreamApi() - .AddInMemoryTokenCaches() - .AddDownstreamApi(InspectionService.ServiceName, builder.Configuration.GetSection("SARA")) - .AddDownstreamApi(IsarService.ServiceName, builder.Configuration.GetSection("Isar")) - .AddDownstreamApi( - PointillaService.ServiceName, - builder.Configuration.GetSection("Pointilla") - ); -} - -builder.Services.Configure( - JwtBearerDefaults.AuthenticationScheme, - options => - { - options.Events ??= new JwtBearerEvents(); - options.Events.OnMessageReceived = context => - { - if ( - context.HttpContext.Request.Path.StartsWithSegments("/hub") - && context.Request.Query.TryGetValue("access_token", out var token) - ) - { - context.Token = token; - } - return Task.CompletedTask; - }; - } -); +// Configures JWT bearer authentication, the token caches (Redis-backed when +// Redis:UseRedis is set) and the ISAR / SARA / Pointilla downstream API clients. +builder.Services.ConfigureAuthentication(builder.Configuration, builder.Environment); builder .Services.AddAuthorizationBuilder() diff --git a/backend/api/appsettings.IntegrationTest.json b/backend/api/appsettings.IntegrationTest.json new file mode 100644 index 000000000..5027997af --- /dev/null +++ b/backend/api/appsettings.IntegrationTest.json @@ -0,0 +1,39 @@ +{ + "AppName": "FlotillaBackendIntegrationTest", + "Authentication": { + "Provider": "Oidc" + }, + "AzureAd": { + "ClientId": "flotilla-test", + "ClientSecret": "flotilla-test-secret", + "Authority": "http://keycloak:8080/realms/robotics" + }, + "KeyVault": { + "UseKeyVault": false + }, + "Redis": { + "UseRedis": false + }, + "OpenTelemetry": { + "Enabled": false + }, + "Isar": { + "Scopes": ["isar-api"] + }, + "SARA": { + "BaseUrl": "http://sara:8100/", + "Scopes": ["sara-api"] + }, + "Pointilla": { + "BaseUrl": "http://pointilla:8200/", + "Scopes": ["pointilla-api"] + }, + "AllowedHosts": "*", + "AllowedOrigins": ["http://localhost:3001", "https://localhost:3001"], + "Database": { + "UseInMemoryDatabase": false + }, + "TeamsNotification": { + "WebhookUrl": "" + } +} diff --git a/docker-compose.yml b/docker-compose.yml index aa872b8d6..bbfa9406c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,19 @@ services: + # Local OpenID Connect issuer, an alternative to Microsoft Entra ID. See backend/README.md. + # Behind a profile, so the default `docker compose up` is unchanged. The realm is the + # one the armada integration tests import; override the path with KEYCLOAK_REALM_DIR. + keycloak: + profiles: ["keycloak"] + image: quay.io/keycloak/keycloak:26.4 + command: ["start-dev", "--import-realm"] + ports: + - "8080:8080" + environment: + - KC_BOOTSTRAP_ADMIN_USERNAME=admin + - KC_BOOTSTRAP_ADMIN_PASSWORD=admin + volumes: + - ${KEYCLOAK_REALM_DIR:-../armada/robotics_integration_tests/custom_realms}:/opt/keycloak/data/import:ro + frontend: build: frontend ports: