diff --git a/src/Ignis.Api/Controllers/AuthorizationController.cs b/src/Ignis.Api/Controllers/AuthorizationController.cs
index 3b64cbb..ed4729a 100644
--- a/src/Ignis.Api/Controllers/AuthorizationController.cs
+++ b/src/Ignis.Api/Controllers/AuthorizationController.cs
@@ -8,10 +8,22 @@ namespace Ignis.Api.Controllers;
[ApiController]
public class AuthorizationController(AuthorizationHandler handler) : ControllerBase
{
- /// Exchange credentials for an access token (OAuth 2.0 client_credentials grant).
+ /// Authorization endpoint.
+ [HttpGet("~/connect/authorize")]
+ [HttpPost("~/connect/authorize")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public Task Authorize() => handler.AuthorizeAsync(HttpContext);
+
+ /// Exchange credentials for an access token (OAuth 2.0 client_credentials or authorization_code grant).
[HttpPost("~/connect/token")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK, "application/json")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public Task Exchange() => handler.ExchangeAsync(HttpContext);
+
+ /// Logout endpoint.
+ [HttpGet("~/connect/logout")]
+ [HttpPost("~/connect/logout")]
+ [ApiExplorerSettings(IgnoreApi = true)]
+ public Task Logout() => handler.LogoutAsync(HttpContext);
}
diff --git a/src/Ignis.Api/Program.cs b/src/Ignis.Api/Program.cs
index 9f379d7..0ecd1f2 100644
--- a/src/Ignis.Api/Program.cs
+++ b/src/Ignis.Api/Program.cs
@@ -63,10 +63,11 @@
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors();
+
+await app.SyncOAuthClientsAsync();
app.UseAuthentication();
app.UseAuthorization();
-await app.SyncOAuthClientsAsync();
app.MapControllers();
app.MapGet("/healthz", () => Results.Ok("ok"));
diff --git a/src/Ignis.Api/appsettings.json b/src/Ignis.Api/appsettings.json
index fe44edb..691415a 100644
--- a/src/Ignis.Api/appsettings.json
+++ b/src/Ignis.Api/appsettings.json
@@ -18,7 +18,10 @@
"DisplayName": "Ignis Client",
"AllowedGrantTypes": ["client_credentials"]
}
- ]
+ ],
+ "Endpoints": {
+ "LoginPath": "/connect/login"
+ }
},
"SparkSettings": {
"Endpoint": "https://localhost:5201/fhir",
diff --git a/src/Ignis.Auth/AuthSettings.cs b/src/Ignis.Auth/AuthSettings.cs
index 9e03a78..4806302 100644
--- a/src/Ignis.Auth/AuthSettings.cs
+++ b/src/Ignis.Auth/AuthSettings.cs
@@ -8,6 +8,11 @@ public class AuthSettings
public AuthCertificateSettings Certificates { get; set; } = new();
}
+public class AuthEndpointSettings
+{
+ public string LoginPath { get; set; } = "/connect/login";
+}
+
public class AuthCertificateSettings
{
public string SigningCertificatePath { get; set; } = "";
@@ -16,15 +21,12 @@ public class AuthCertificateSettings
public string EncryptionCertificatePassword { get; set; } = "";
}
-public class AuthEndpointSettings
-{
- public string TokenEndpointPath { get; set; } = "connect/token";
-}
-
public class ClientDefinition
{
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string DisplayName { get; set; } = "";
public List AllowedGrantTypes { get; set; } = [];
+ public List RedirectUris { get; set; } = [];
+ public List PostLogoutRedirectUris { get; set; } = [];
}
diff --git a/src/Ignis.Auth/AuthorizationHandler.cs b/src/Ignis.Auth/AuthorizationHandler.cs
index aa8e52b..9119539 100644
--- a/src/Ignis.Auth/AuthorizationHandler.cs
+++ b/src/Ignis.Auth/AuthorizationHandler.cs
@@ -13,7 +13,7 @@
namespace Ignis.Auth;
///
-/// Contains the OpenIddict token endpoint logic.
+/// Contains the OpenIddict authorization, token and logout endpoint logic.
/// Designed to be called from a thin controller in the host application.
///
public class AuthorizationHandler
@@ -25,11 +25,62 @@ public AuthorizationHandler(IOpenIddictApplicationManager applicationManager)
_applicationManager = applicationManager;
}
+ public async Task AuthorizeAsync(HttpContext httpContext)
+ {
+ var request = httpContext.GetOpenIddictServerRequest()
+ ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
+
+ var result = await httpContext.AuthenticateAsync(AuthConstants.SessionScheme);
+ if (!result.Succeeded || result.Principal?.Identity?.IsAuthenticated != true)
+ {
+ var httpRequest = httpContext.Request;
+ var parameters = httpRequest.HasFormContentType
+ ? httpRequest.Form.Where(p => p.Key != "__RequestVerificationToken").ToList()
+ : httpRequest.Query.ToList();
+
+ return new ChallengeResult(
+ [AuthConstants.SessionScheme],
+ new AuthenticationProperties
+ {
+ RedirectUri = httpRequest.PathBase + httpRequest.Path + QueryString.Create(parameters),
+ });
+ }
+
+ var identity = new ClaimsIdentity(
+ OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
+ Claims.Name, Claims.Role);
+
+ identity.SetClaim(Claims.Subject,
+ result.Principal.FindFirst(Claims.Subject)?.Value ??
+ result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
+ identity.SetClaim(Claims.Name,
+ result.Principal.FindFirst(Claims.Name)?.Value ??
+ result.Principal.Identity?.Name);
+
+ identity.SetScopes(request.GetScopes());
+ identity.SetDestinations(static claim => [Destinations.AccessToken]);
+
+ return new SignInResult(
+ OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
+ new ClaimsPrincipal(identity));
+ }
+
public async Task ExchangeAsync(HttpContext httpContext)
{
var request = httpContext.GetOpenIddictServerRequest()
?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
+ if (request.IsAuthorizationCodeGrantType())
+ {
+ var principal = (await httpContext.AuthenticateAsync(
+ OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal
+ ?? throw new InvalidOperationException("The authorization code principal cannot be retrieved.");
+
+ return new SignInResult(
+ OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
+ principal);
+ }
+
if (request.IsClientCredentialsGrantType())
{
return await ExchangeClientCredentialsAsync(request);
@@ -38,6 +89,12 @@ public async Task ExchangeAsync(HttpContext httpContext)
return ForbidWithError(Errors.UnsupportedGrantType, "The specified grant type is not supported.");
}
+ public async Task LogoutAsync(HttpContext httpContext)
+ {
+ await httpContext.SignOutAsync(AuthConstants.SessionScheme);
+ return new RedirectResult("/");
+ }
+
private async Task ExchangeClientCredentialsAsync(OpenIddictRequest request)
{
if (string.IsNullOrEmpty(request.ClientId))
diff --git a/src/Ignis.Auth/Extensions/AuthServerExtensions.cs b/src/Ignis.Auth/Extensions/AuthServerExtensions.cs
index 7986bf8..f96a900 100644
--- a/src/Ignis.Auth/Extensions/AuthServerExtensions.cs
+++ b/src/Ignis.Auth/Extensions/AuthServerExtensions.cs
@@ -1,17 +1,17 @@
using System.Security.Cryptography.X509Certificates;
+using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
-using OpenIddict.Server;
-
namespace Ignis.Auth.Extensions;
public static class AuthServerExtensions
{
///
- /// Registers the OpenIddict authorization server and certificates.
+ /// Registers the OpenIddict authorization server, token validation,
+ /// session cookie authentication and certificates.
/// Use this when the application acts as an authorization server.
///
public static IServiceCollection AddIgnisAuthServer(
@@ -20,7 +20,7 @@ public static IServiceCollection AddIgnisAuthServer(
bool useDevelopmentCertificates)
{
ArgumentNullException.ThrowIfNull(settings);
- ArgumentException.ThrowIfNullOrWhiteSpace(settings.ConnectionString);
+ ArgumentException.ThrowIfNullOrWhiteSpace(settings.ConnectionString, "AuthSettings:ConnectionString is required");
services.Configure(options =>
{
@@ -30,20 +30,38 @@ public static IServiceCollection AddIgnisAuthServer(
options.Certificates = settings.Certificates;
});
- services.AddOpenIddictServer(settings, useDevelopmentCertificates);
- services.AddOpenIddict()
- .AddValidation(options =>
- {
- options.UseLocalServer();
- options.UseAspNetCore();
- });
+ services
+ .AddSessionCookieAuthentication(settings.Endpoints.LoginPath)
+ .AddOpenIddictServer(settings, useDevelopmentCertificates)
+ .AddOpenIddictValidation();
services.AddTransient();
return services;
}
- private static void AddOpenIddictServer(
+ private static IServiceCollection AddSessionCookieAuthentication(
+ this IServiceCollection services,
+ string loginPath)
+ {
+ services.AddAuthentication()
+ .AddCookie(AuthConstants.SessionScheme, options =>
+ {
+ options.LoginPath = loginPath;
+ // Always issue a 302 redirect; the default cookie handler
+ // returns 401 for non-browser requests (missing Accept: text/html),
+ // which breaks the OAuth 2.0 authorization endpoint flow.
+ options.Events.OnRedirectToLogin = context =>
+ {
+ context.Response.Redirect(context.RedirectUri);
+ return Task.CompletedTask;
+ };
+ });
+
+ return services;
+ }
+
+ private static IServiceCollection AddOpenIddictServer(
this IServiceCollection services,
AuthSettings settings,
bool useDevelopmentCertificates)
@@ -58,18 +76,38 @@ private static void AddOpenIddictServer(
.AddServer(options =>
{
options
- .SetTokenEndpointUris(settings.Endpoints.TokenEndpointPath)
- .AllowClientCredentialsFlow();
+ .SetTokenEndpointUris("connect/token")
+ .SetAuthorizationEndpointUris("connect/authorize")
+ .SetPushedAuthorizationEndpointUris("connect/par")
+ .AllowClientCredentialsFlow()
+ .AllowAuthorizationCodeFlow()
+ .RequireProofKeyForCodeExchange()
+ .RequirePushedAuthorizationRequests();
ConfigureCertificates(options, settings.Certificates, useDevelopmentCertificates);
var aspNetCoreBuilder = options
.UseAspNetCore()
- .EnableTokenEndpointPassthrough();
+ .EnableTokenEndpointPassthrough()
+ .EnableAuthorizationEndpointPassthrough();
if (useDevelopmentCertificates)
aspNetCoreBuilder.DisableTransportSecurityRequirement();
});
+
+ return services;
+ }
+
+ private static IServiceCollection AddOpenIddictValidation(this IServiceCollection services)
+ {
+ services.AddOpenIddict()
+ .AddValidation(options =>
+ {
+ options.UseLocalServer();
+ options.UseAspNetCore();
+ });
+
+ return services;
}
private static void ConfigureCertificates(
diff --git a/src/Ignis.Auth/README.md b/src/Ignis.Auth/README.md
index 27761ed..5dec3db 100644
--- a/src/Ignis.Auth/README.md
+++ b/src/Ignis.Auth/README.md
@@ -1,8 +1,6 @@
# Ignis.Auth
-OAuth 2.0 token service for Ignis, built on [OpenIddict](https://documentation.openiddict.com/) with MongoDB storage.
-
-The library provides an `AuthorizationHandler` containing the token endpoint logic, while the host application supplies a thin controller that delegates to it. Currently supports the `client_credentials` grant type.
+OAuth 2.0 authorization server built on [OpenIddict](https://documentation.openiddict.com/) with MongoDB storage. Supports `client_credentials` and `authorization_code` (with mandatory PKCE + PAR) grant types.
## Configuration
@@ -11,12 +9,21 @@ The library provides an `AuthorizationHandler` containing the token endpoint log
"AuthSettings": {
"Enabled": true,
"ConnectionString": "mongodb://localhost:27017/ignis",
+ "LoginPath": "/connect/login",
"Clients": [
{
- "ClientId": "my-client",
+ "ClientId": "my-backend",
"ClientSecret": "my-secret",
- "DisplayName": "My Client",
+ "DisplayName": "Backend Service",
"AllowedGrantTypes": ["client_credentials"]
+ },
+ {
+ "ClientId": "my-web-app",
+ "ClientSecret": "web-secret",
+ "DisplayName": "My Web App",
+ "AllowedGrantTypes": ["authorization_code"],
+ "RedirectUris": ["https://app.example.com/callback"],
+ "PostLogoutRedirectUris": ["https://app.example.com"]
}
],
"Certificates": {
@@ -29,56 +36,51 @@ The library provides an `AuthorizationHandler` containing the token endpoint log
}
```
-## Certificates
+All clients are confidential and require a `ClientSecret`. `AllowedGrantTypes` is required.
-OpenIddict requires a signing certificate and an encryption certificate for token generation and validation.
+## Endpoints
-### Development
+| Endpoint | Path |
+|----------|------|
+| Token | `POST /connect/token` |
+| Authorization | `GET/POST /connect/authorize` |
+| PAR | `POST /connect/par` — handled by OpenIddict middleware |
+| Logout | `GET/POST /connect/logout` |
+| Login | `LoginPath` (default `/connect/login`) — implement in host application |
-In development mode, OpenIddict automatically generates ephemeral development certificates. No configuration needed.
+## Authorization code flow
-### Production
+PAR and PKCE are always required. The flow:
-Generate self-signed PFX certificates for signing and encryption:
+1. Push authorization parameters to `/connect/par` → receive `request_uri`
+2. Redirect user to `/connect/authorize?client_id=...&request_uri=...`
+3. Unauthenticated users are redirected to `LoginPath` — sign them in with:
+ ```csharp
+ await HttpContext.SignInAsync(
+ AuthConstants.SessionScheme,
+ new ClaimsPrincipal(identity)); // needs NameIdentifier + Name claims
+ ```
+4. OpenIddict issues an authorization code and redirects to `redirect_uri`
+5. Exchange code for token at `/connect/token` with `code_verifier`
-```bash
-mkdir -p certs
-
-# Signing certificate
-openssl req -x509 -nodes -newkey rsa:2048 \
- -keyout certs/signing-key.pem -out certs/signing-cert.pem \
- -days 365 -subj "/CN=Ignis Token Signing"
-openssl pkcs12 -export -out certs/signing.pfx \
- -inkey certs/signing-key.pem -in certs/signing-cert.pem \
- -passout pass:
+## Certificates
-# Encryption certificate
-openssl req -x509 -nodes -newkey rsa:2048 \
- -keyout certs/encryption-key.pem -out certs/encryption-cert.pem \
- -days 365 -subj "/CN=Ignis Token Encryption"
-openssl pkcs12 -export -out certs/encryption.pfx \
- -inkey certs/encryption-key.pem -in certs/encryption-cert.pem \
- -passout pass:
+Development mode uses ephemeral auto-generated certificates. For production, generate PFX certificates:
-# Clean up PEM files
+```bash
+mkdir -p certs
+for NAME in signing encryption; do
+ openssl req -x509 -nodes -newkey rsa:2048 \
+ -keyout certs/$NAME-key.pem -out certs/$NAME-cert.pem \
+ -days 365 -subj "/CN=Ignis ${NAME^}"
+ openssl pkcs12 -export -out certs/$NAME.pfx \
+ -inkey certs/$NAME-key.pem -in certs/$NAME-cert.pem -passout pass:
+done
rm certs/*.pem
```
-The `certs/` directory is gitignored. Mount certificates via volume or secrets in production.
+The `certs/` directory is gitignored. Mount via volume or secrets in production.
## Client sync
-Clients defined in `AuthSettings.Clients` are synced to MongoDB on startup:
-
-- New clients are created
-- Existing clients are updated (secret, display name)
-- Clients not in config are removed
-
-## Usage
-
-```bash
-curl -X POST https://localhost:5201/connect/token \
- -d grant_type=client_credentials \
- -d client_id=my-client \
- -d client_secret=my-secret
-```
+Clients in `AuthSettings.Clients` are synced to MongoDB on startup — created, updated, or removed to match configuration.
diff --git a/src/Ignis.Auth/Services/ClientSyncInitializer.cs b/src/Ignis.Auth/Services/ClientSyncInitializer.cs
index d4dd425..d948056 100644
--- a/src/Ignis.Auth/Services/ClientSyncInitializer.cs
+++ b/src/Ignis.Auth/Services/ClientSyncInitializer.cs
@@ -88,6 +88,8 @@ private OpenIddictApplicationDescriptor BuildDescriptor(ClientDefinition client)
}
};
+ var hasRedirectUris = client.RedirectUris.Count > 0;
+
foreach (var grantType in client.AllowedGrantTypes)
{
switch (grantType)
@@ -97,8 +99,25 @@ private OpenIddictApplicationDescriptor BuildDescriptor(ClientDefinition client)
break;
case GrantTypes.AuthorizationCode:
- throw new NotImplementedException(
- $"Grant type '{GrantTypes.AuthorizationCode}' is not yet supported.");
+ if (!hasRedirectUris)
+ {
+ _logger.LogWarning(
+ "Client {ClientId} allows authorization_code but has no RedirectUris configured – skipping auth code permissions.",
+ client.ClientId);
+ break;
+ }
+
+ descriptor.Permissions.Add(Permissions.Endpoints.Authorization);
+ descriptor.Permissions.Add(Permissions.Endpoints.PushedAuthorization);
+ descriptor.Permissions.Add(Permissions.GrantTypes.AuthorizationCode);
+ descriptor.Permissions.Add(Permissions.ResponseTypes.Code);
+
+ foreach (var uri in client.RedirectUris)
+ descriptor.RedirectUris.Add(new Uri(uri));
+
+ foreach (var uri in client.PostLogoutRedirectUris)
+ descriptor.PostLogoutRedirectUris.Add(new Uri(uri));
+ break;
default:
_logger.LogWarning(
diff --git a/tests/Ignis.Api.Tests/AuthConfigurationTests.cs b/tests/Ignis.Api.Tests/AuthConfigurationTests.cs
index 0c5aaeb..f8d0920 100644
--- a/tests/Ignis.Api.Tests/AuthConfigurationTests.cs
+++ b/tests/Ignis.Api.Tests/AuthConfigurationTests.cs
@@ -58,7 +58,13 @@ private WebApplicationFactory CreateFactory(
{
return new WebApplicationFactory().WithWebHostBuilder(builder =>
{
- builder.ConfigureAppConfiguration((_, c) => c.AddInMemoryCollection(config));
+ builder.ConfigureAppConfiguration((_, c) =>
+ {
+ // Re-add environment variables then override with in-memory config,
+ // ensuring test values take precedence over leaked env vars.
+ c.AddEnvironmentVariables();
+ c.AddInMemoryCollection(config);
+ });
builder.UseEnvironment(environment);
});
}
@@ -74,6 +80,23 @@ private static string CreateTempCertificate(string subject, string password)
return path;
}
+ ///
+ /// All auth-related env var keys that IntegrationFixture may have set.
+ /// We must clear these before testing with auth disabled, since env vars
+ /// have higher priority than appsettings.json in the config chain.
+ ///
+ private static readonly string[] AllAuthEnvVarKeys =
+ [
+ "AuthSettings__ConnectionString",
+ "AuthSettings__Clients__0__ClientId",
+ "AuthSettings__Clients__0__ClientSecret",
+ "AuthSettings__Clients__0__DisplayName",
+ "AuthSettings__Clients__0__AllowedGrantTypes__0",
+ "AuthSettings__Clients__0__AllowedGrantTypes__1",
+ "AuthSettings__Clients__0__RedirectUris__0",
+ "StoreSettings__ConnectionString",
+ ];
+
[Fact]
public async Task TokenEndpoint_ReturnsAccessToken()
{
diff --git a/tests/Ignis.Api.Tests/AuthorizationControllerTests.cs b/tests/Ignis.Api.Tests/AuthorizationControllerTests.cs
index d112332..5d30028 100644
--- a/tests/Ignis.Api.Tests/AuthorizationControllerTests.cs
+++ b/tests/Ignis.Api.Tests/AuthorizationControllerTests.cs
@@ -1,8 +1,14 @@
+using System.Buffers.Text;
using System.Net;
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
+using System.Web;
using FluentAssertions;
+using Microsoft.AspNetCore.Mvc.Testing;
+
namespace Ignis.Api.Tests;
[Collection("IntegrationTests")]
@@ -84,4 +90,162 @@ public async Task Token_WithUnsupportedGrantType_ReturnsBadRequest()
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
+
+ [Fact]
+ public async Task Authorize_WithoutSession_RedirectsToLogin()
+ {
+ using var client = _fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ });
+
+ var (_, codeChallenge) = GeneratePkce();
+
+ // Push the authorization request via PAR.
+ var parResponse = await client.PostAsync("/connect/par",
+ new FormUrlEncodedContent(new Dictionary
+ {
+ ["response_type"] = "code",
+ ["client_id"] = "test-client",
+ ["client_secret"] = "test-secret",
+ ["redirect_uri"] = "http://localhost/callback",
+ ["code_challenge"] = codeChallenge,
+ ["code_challenge_method"] = "S256",
+ }), CT);
+
+ parResponse.StatusCode.Should().Be(HttpStatusCode.Created);
+ var parJson = JsonDocument.Parse(await parResponse.Content.ReadAsStringAsync(CT));
+ var requestUri = parJson.RootElement.GetProperty("request_uri").GetString();
+ requestUri.Should().NotBeNullOrEmpty();
+
+ // Redirect to authorize with the request_uri — should redirect to login.
+ var authorizeUrl = $"/connect/authorize?client_id=test-client&request_uri={Uri.EscapeDataString(requestUri!)}";
+ var response = await client.GetAsync(authorizeUrl, CT);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect);
+ response.Headers.Location!.AbsolutePath.Should().Be("/connect/login");
+ }
+
+ [Fact]
+ public async Task AuthCodeFlow_WithPkce_ReturnsAccessToken()
+ {
+ using var client = _fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ HandleCookies = true,
+ });
+
+ // 1. Sign in as a test user to establish a session cookie.
+ var loginResponse = await client.GetAsync("/test-login", CT);
+ loginResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ // 2. Push the authorization request via PAR.
+ var (codeVerifier, codeChallenge) = GeneratePkce();
+ var parResponse = await client.PostAsync("/connect/par",
+ new FormUrlEncodedContent(new Dictionary
+ {
+ ["response_type"] = "code",
+ ["client_id"] = "test-client",
+ ["client_secret"] = "test-secret",
+ ["redirect_uri"] = "http://localhost/callback",
+ ["code_challenge"] = codeChallenge,
+ ["code_challenge_method"] = "S256",
+ }), CT);
+
+ parResponse.StatusCode.Should().Be(HttpStatusCode.Created);
+ var parJson = JsonDocument.Parse(await parResponse.Content.ReadAsStringAsync(CT));
+ var requestUri = parJson.RootElement.GetProperty("request_uri").GetString();
+ requestUri.Should().NotBeNullOrEmpty();
+
+ // 3. Redirect to authorize with the request_uri.
+ var authorizeUrl = $"/connect/authorize?client_id=test-client&request_uri={Uri.EscapeDataString(requestUri!)}";
+ var authorizeResponse = await client.GetAsync(authorizeUrl, CT);
+
+ authorizeResponse.StatusCode.Should().Be(HttpStatusCode.Redirect);
+ var location = authorizeResponse.Headers.Location!;
+ var queryParams = HttpUtility.ParseQueryString(location.Query);
+ var code = queryParams["code"];
+ code.Should().NotBeNullOrEmpty("the authorization endpoint should issue a code");
+
+ // 4. Exchange the authorization code + client_secret for an access token.
+ var tokenResponse = await client.PostAsync("/connect/token",
+ new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "authorization_code",
+ ["code"] = code!,
+ ["redirect_uri"] = "http://localhost/callback",
+ ["client_id"] = "test-client",
+ ["client_secret"] = "test-secret",
+ ["code_verifier"] = codeVerifier,
+ }), CT);
+
+ tokenResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var json = JsonDocument.Parse(await tokenResponse.Content.ReadAsStringAsync(CT));
+ json.RootElement.GetProperty("access_token").GetString().Should().NotBeNullOrEmpty();
+ json.RootElement.GetProperty("token_type").GetString().Should().Be("Bearer");
+ }
+
+ [Fact]
+ public async Task AuthCodeFlow_WithoutPkce_ReturnsBadRequest()
+ {
+ using var client = _fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ HandleCookies = true,
+ });
+
+ // Sign in first
+ await client.GetAsync("/test-login", CT);
+
+ // Push authorization request without code_challenge => should fail
+ var parResponse = await client.PostAsync("/connect/par",
+ new FormUrlEncodedContent(new Dictionary
+ {
+ ["response_type"] = "code",
+ ["client_id"] = "test-client",
+ ["client_secret"] = "test-secret",
+ ["redirect_uri"] = "http://localhost/callback",
+ }), CT);
+
+ parResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task Authorize_WithoutPar_ReturnsBadRequest()
+ {
+ using var client = _fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ AllowAutoRedirect = false,
+ HandleCookies = true,
+ });
+
+ await client.GetAsync("/test-login", CT);
+
+ var (_, codeChallenge) = GeneratePkce();
+
+ // Direct authorize request without using PAR => should be rejected
+ var authorizeUrl = "/connect/authorize?" + string.Join("&",
+ "response_type=code",
+ "client_id=test-client",
+ $"redirect_uri={Uri.EscapeDataString("http://localhost/callback")}",
+ $"code_challenge={codeChallenge}",
+ "code_challenge_method=S256");
+
+ var response = await client.GetAsync(authorizeUrl, CT);
+
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ private static (string codeVerifier, string codeChallenge) GeneratePkce()
+ {
+ var verifierBytes = new byte[32];
+ RandomNumberGenerator.Fill(verifierBytes);
+ var codeVerifier = Base64Url.EncodeToString(verifierBytes);
+
+ var challengeBytes = SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier));
+ var codeChallenge = Base64Url.EncodeToString(challengeBytes);
+
+ return (codeVerifier, codeChallenge);
+ }
}
diff --git a/tests/Ignis.Api.Tests/IgnisApiFactory.cs b/tests/Ignis.Api.Tests/IgnisApiFactory.cs
index 833ce8c..2b4e982 100644
--- a/tests/Ignis.Api.Tests/IgnisApiFactory.cs
+++ b/tests/Ignis.Api.Tests/IgnisApiFactory.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
namespace Ignis.Api.Tests;
@@ -28,9 +29,16 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
["AuthSettings:Clients:0:ClientSecret"] = "test-secret",
["AuthSettings:Clients:0:DisplayName"] = "Test Client",
["AuthSettings:Clients:0:AllowedGrantTypes:0"] = "client_credentials",
+ ["AuthSettings:Clients:0:AllowedGrantTypes:1"] = "authorization_code",
+ ["AuthSettings:Clients:0:RedirectUris:0"] = "http://localhost/callback",
});
});
+ builder.ConfigureServices(services =>
+ {
+ services.AddSingleton();
+ });
+
builder.UseEnvironment("Development");
}
}
diff --git a/tests/Ignis.Api.Tests/IntegrationFixture.cs b/tests/Ignis.Api.Tests/IntegrationFixture.cs
index a498b1a..e146cbb 100644
--- a/tests/Ignis.Api.Tests/IntegrationFixture.cs
+++ b/tests/Ignis.Api.Tests/IntegrationFixture.cs
@@ -38,6 +38,8 @@ private static string BuildConnectionString(string raw)
"AuthSettings__Clients__0__ClientSecret",
"AuthSettings__Clients__0__DisplayName",
"AuthSettings__Clients__0__AllowedGrantTypes__0",
+ "AuthSettings__Clients__0__AllowedGrantTypes__1",
+ "AuthSettings__Clients__0__RedirectUris__0",
];
public async ValueTask InitializeAsync()
@@ -51,6 +53,8 @@ public async ValueTask InitializeAsync()
Environment.SetEnvironmentVariable("AuthSettings__Clients__0__ClientSecret", "test-secret");
Environment.SetEnvironmentVariable("AuthSettings__Clients__0__DisplayName", "Test Client");
Environment.SetEnvironmentVariable("AuthSettings__Clients__0__AllowedGrantTypes__0", "client_credentials");
+ Environment.SetEnvironmentVariable("AuthSettings__Clients__0__AllowedGrantTypes__1", "authorization_code");
+ Environment.SetEnvironmentVariable("AuthSettings__Clients__0__RedirectUris__0", "http://localhost/callback");
Factory = new IgnisApiFactory(connectionString);
}