Skip to content
Merged
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
17 changes: 17 additions & 0 deletions src/Ignis.Api/Controllers/AuthorizationController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Ignis.Auth;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace Ignis.Api.Controllers;

[ApiController]
public class AuthorizationController(AuthorizationHandler handler) : ControllerBase
{
/// <summary>Exchange credentials for an access token (OAuth 2.0 client_credentials grant).</summary>
[HttpPost("~/connect/token")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK, "application/json")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public Task<IActionResult> Exchange() => handler.ExchangeAsync(HttpContext);
}
36 changes: 9 additions & 27 deletions src/Ignis.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Ignis.Auth;
using Ignis.Auth.Extensions;
using Ignis.Auth.Services;

using Spark.Engine;
using Spark.Engine.Extensions;
Expand All @@ -16,14 +15,13 @@
var storeSettings = new StoreSettings();
builder.Configuration.Bind("StoreSettings", storeSettings);

// Bind Auth settings (optional OAuth 2.0 server)
// Bind Auth settings
var authSettings = new AuthSettings();
builder.Configuration.Bind("AuthSettings", authSettings);

if (authSettings.Enabled)
{
builder.Services.AddIgnisAuth(authSettings, builder.Environment.IsDevelopment());
}
builder.Services
.AddIgnisAuthServer(authSettings, builder.Environment.IsDevelopment())
.AddIgnisClientSync();

// Set up CORS policy
builder.Services.AddCors(options =>
Expand All @@ -49,34 +47,14 @@
// Register Spark FHIR engine (also registers controllers + FHIR formatters)
builder.Services.AddFhir(sparkSettings);

// The project reference to Ignis.Auth causes auto-discovery of its controllers.
// Remove them when auth is disabled to avoid DI resolution failures.
builder.Services.AddControllers()
.ConfigureApplicationPartManager(manager =>
{
if (!authSettings.Enabled)
{
var authAssemblyName = typeof(AuthSettings).Assembly.GetName().Name;
var authPart = manager.ApplicationParts
.FirstOrDefault(p => p.Name == authAssemblyName);
if (authPart != null)
manager.ApplicationParts.Remove(authPart);
}
});
builder.Services.AddControllers();

Comment on lines 48 to 51
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Controllers are now always registered, and the /connect/token controller lives in Ignis.Api, so the token endpoint will show up in API explorer/OpenAPI even when AuthSettings.Enabled is false (it will just return 404). If the intent is to hide the token endpoint when auth is disabled (as the previous ApplicationPart removal did), consider conditionally mapping the token endpoint only when auth is enabled (e.g., via a minimal API route) or otherwise excluding it from OpenAPI when disabled.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — the controller now uses constructor injection and AuthSettings.Enabled has been removed entirely. Auth is always registered when Ignis.Auth is referenced, so there's no longer a state where the handler is missing. The OpenAPI visibility follows naturally from that.

// OpenAPI document generation
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();

var app = builder.Build();

if (authSettings.Enabled)
{
await using var scope = app.Services.CreateAsyncScope();
var clientSyncInitializer = scope.ServiceProvider.GetRequiredService<ClientSyncInitializer>();
await clientSyncInitializer.RunAsync(app.Lifetime.ApplicationStopping);
}

if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
Expand All @@ -85,6 +63,10 @@
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();

await app.SyncOAuthClientsAsync();
app.MapControllers();
app.MapGet("/healthz", () => Results.Ok("ok"));

Expand Down
1 change: 0 additions & 1 deletion src/Ignis.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"ConnectionString": "mongodb://localhost:27017/ignis"
},
"AuthSettings": {
"Enabled": false,
"ConnectionString": "mongodb://localhost:27017/ignis",
"Clients": [
{
Expand Down
1 change: 0 additions & 1 deletion src/Ignis.Auth/AuthSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ namespace Ignis.Auth;

public class AuthSettings
{
public bool Enabled { get; set; }
public string ConnectionString { get; set; } = "";
public List<ClientDefinition> Clients { get; set; } = [];
public AuthEndpointSettings Endpoints { get; set; } = new();
Expand Down
76 changes: 76 additions & 0 deletions src/Ignis.Auth/AuthorizationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Security.Claims;

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;

using static OpenIddict.Abstractions.OpenIddictConstants;

namespace Ignis.Auth;

/// <summary>
/// Contains the OpenIddict token endpoint logic.
/// Designed to be called from a thin controller in the host application.
/// </summary>
public class AuthorizationHandler
{
private readonly IOpenIddictApplicationManager _applicationManager;

public AuthorizationHandler(IOpenIddictApplicationManager applicationManager)
{
_applicationManager = applicationManager;
}

public async Task<IActionResult> ExchangeAsync(HttpContext httpContext)
{
var request = httpContext.GetOpenIddictServerRequest()
?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

if (request.IsClientCredentialsGrantType())
{
return await ExchangeClientCredentialsAsync(request);
}

return ForbidWithError(Errors.UnsupportedGrantType, "The specified grant type is not supported.");
}

private async Task<IActionResult> ExchangeClientCredentialsAsync(OpenIddictRequest request)
{
if (string.IsNullOrEmpty(request.ClientId))
{
return ForbidWithError(Errors.InvalidClient, "The client identifier is missing.");
}

var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
if (application is null)
{
return ForbidWithError(Errors.InvalidClient, "The specified client application was not found.");
}

var identity = new ClaimsIdentity(
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
Claims.Name, Claims.Role);

identity.SetClaim(Claims.Subject, await _applicationManager.GetClientIdAsync(application));
identity.SetClaim(Claims.Name, await _applicationManager.GetDisplayNameAsync(application));

identity.SetScopes(request.GetScopes());
identity.SetDestinations(static claim => [Destinations.AccessToken]);

return new SignInResult(
OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity));
}

private static ForbidResult ForbidWithError(string error, string description) =>
new([OpenIddictServerAspNetCoreDefaults.AuthenticationScheme],
new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = error,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description,
}));
}
83 changes: 0 additions & 83 deletions src/Ignis.Auth/Controllers/AuthorizationController.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using System.Security.Cryptography.X509Certificates;

using Ignis.Auth.Services;

using Microsoft.Extensions.DependencyInjection;

using MongoDB.Driver;
Expand All @@ -10,26 +8,46 @@

namespace Ignis.Auth.Extensions;

public static class ServiceCollectionExtensions
public static class AuthServerExtensions
{
public static IServiceCollection AddIgnisAuth(
/// <summary>
/// Registers the OpenIddict authorization server and certificates.
/// Use this when the application acts as an authorization server.
/// </summary>
public static IServiceCollection AddIgnisAuthServer(
this IServiceCollection services,
AuthSettings settings,
bool useDevelopmentCertificates)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(settings.ConnectionString, "AuthSettings:ConnectionString is required when auth is enabled.");
ArgumentNullException.ThrowIfNull(settings.Endpoints?.TokenEndpointPath, "AuthSettings:Endpoints:TokenEndpointPath is required when auth is enabled.");
ArgumentException.ThrowIfNullOrWhiteSpace(settings.ConnectionString);

services.Configure<AuthSettings>(options =>
{
options.Enabled = settings.Enabled;
options.ConnectionString = settings.ConnectionString;
options.Clients = settings.Clients;
options.Endpoints = settings.Endpoints;
options.Certificates = settings.Certificates;
});

services.AddOpenIddictServer(settings, useDevelopmentCertificates);
services.AddOpenIddict()
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});

services.AddTransient<AuthorizationHandler>();

return services;
}

private static void AddOpenIddictServer(
this IServiceCollection services,
AuthSettings settings,
bool useDevelopmentCertificates)
{
services.AddOpenIddict()
.AddCore(options =>
{
Expand All @@ -43,31 +61,20 @@ public static IServiceCollection AddIgnisAuth(
.SetTokenEndpointUris(settings.Endpoints.TokenEndpointPath)
.AllowClientCredentialsFlow();

ConfigureCertificates(options, settings, useDevelopmentCertificates);
ConfigureCertificates(options, settings.Certificates, useDevelopmentCertificates);

var aspNetCoreBuilder = options
.UseAspNetCore()
.EnableTokenEndpointPassthrough();

if (useDevelopmentCertificates)
{
aspNetCoreBuilder.DisableTransportSecurityRequirement();
}
})
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});

services.AddTransient<ClientSyncInitializer>();

return services;
}

private static void ConfigureCertificates(
OpenIddictServerBuilder options,
AuthSettings settings,
AuthCertificateSettings certs,
bool useDevelopmentCertificates)
{
if (useDevelopmentCertificates)
Expand All @@ -78,8 +85,6 @@ private static void ConfigureCertificates(
return;
}

var certs = settings.Certificates;

options
.AddSigningCertificate(LoadCertificate(
certs.SigningCertificatePath,
Expand Down
Loading