Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
36 changes: 36 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 29 additions & 3 deletions backend/api.test/Mocks/SignalRServiceMock.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Api.Database.Models;
using Api.Services;
Expand All @@ -7,15 +8,40 @@ namespace Api.Test.Mocks
{
public class MockSignalRService : ISignalRService
{
public List<object> 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<object> _latestMessages = [];
private readonly Lock _lock = new();

public IReadOnlyList<object> 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<T>(
string label,
Installation? installation,
T messageObject
)
{
LatestMessages.Add(new { Label = label, Message = messageObject });
Record(label, messageObject!);
await Task.CompletedTask;
}

Expand All @@ -25,7 +51,7 @@ public async Task SendMessageAsync<T>(
T messageObject
)
{
LatestMessages.Add(new { Label = label, Message = messageObject });
Record(label, messageObject!);
await Task.CompletedTask;
}

Expand Down
223 changes: 223 additions & 0 deletions backend/api.test/Security/AuthenticationConfigurationsTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<string, string?>
{
["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<IConfiguration>(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<IAuthorizationHeaderProvider>();

Assert.IsNotType<GenericOidcAuthorizationHeaderProvider>(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<IOptionsMonitor<JwtBearerOptions>>()
.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<IOptionsMonitor<JwtBearerOptions>>()
.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<IAuthorizationHeaderProvider>();

Assert.IsType<GenericOidcAuthorizationHeaderProvider>(headerProvider);
}

[Theory]
[InlineData("Development")]
[InlineData("Staging")]
[InlineData("Production")]
[InlineData("Test")]
public void UnencryptedIssuerIsRefusedInDeployedEnvironments(string environmentName)
{
var exception = Assert.Throws<InvalidOperationException>(() =>
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<IOptionsMonitor<JwtBearerOptions>>()
.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<InvalidOperationException>(() =>
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<IOptionsMonitor<JwtBearerOptions>>()
.Get(JwtBearerDefaults.AuthenticationScheme);

Assert.NotNull(options.Events?.OnMessageReceived);
}

using var oidcProvider = BuildOidcProvider("Local");
var oidcOptions = oidcProvider
.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
.Get(JwtBearerDefaults.AuthenticationScheme);

Assert.NotNull(oidcOptions.Events?.OnMessageReceived);
}
}
}
Loading
Loading