From cba9828223ac6a94a1736eccbfc46ffcc224c5c4 Mon Sep 17 00:00:00 2001 From: Ole Kristian Losvik Date: Thu, 26 Feb 2026 18:06:21 +0100 Subject: [PATCH] Auth: Implement authorization code flow --- .../Controllers/AuthorizationController.cs | 14 +- src/Ignis.Api/Program.cs | 3 +- src/Ignis.Api/appsettings.json | 5 +- src/Ignis.Auth/AuthSettings.cs | 12 +- src/Ignis.Auth/AuthorizationHandler.cs | 59 ++++++- .../Extensions/AuthServerExtensions.cs | 68 ++++++-- src/Ignis.Auth/README.md | 90 +++++----- .../Services/ClientSyncInitializer.cs | 23 ++- .../Ignis.Api.Tests/AuthConfigurationTests.cs | 25 ++- .../AuthorizationControllerTests.cs | 164 ++++++++++++++++++ tests/Ignis.Api.Tests/IgnisApiFactory.cs | 8 + tests/Ignis.Api.Tests/IntegrationFixture.cs | 4 + 12 files changed, 404 insertions(+), 71 deletions(-) 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); }