Merge pull request 'fic/jwt-validation' (#7) from fic/jwt-validation into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled

Reviewed-on: #7
This commit is contained in:
Paul Schneider 2026-07-12 06:46:05 +01:00
commit c354cbab78
16 changed files with 153 additions and 111 deletions

View file

@ -0,0 +1,22 @@
info:
name: blog post
type: http
seq: 2
http:
method: POST
url: "{{Blogs}}/api/v1/blog"
body:
type: json
data: |-
{
"Title": "lkijlk",
"Article": "test"
}
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View file

@ -25,7 +25,7 @@ request:
credentials:
clientId: postit
placement: basic_auth_header
scope: openid blogs
scope: openid blogs profile
pkce: {}
tokenConfig:
id: credentials

View file

@ -57,6 +57,8 @@ namespace Yavsc
public const string StreamingPath = "/api/stream/put";
public static string NameClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/name";
public static string RoleClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
}
}

View file

@ -28,7 +28,7 @@ namespace Yavsc.Abstract.Identity
/// </remarks>
public static string AvatarSrc(IApplicationUser? user)
{
if (string.IsNullOrWhiteSpace(user?.UserName))
if (user==null || string.IsNullOrWhiteSpace(user?.UserName))
return YavscConstants.DefaultAvatar;
return $"{YavscConstants.AvatarsPath}/{user!.UserName}.s.png";
}

View file

@ -83,7 +83,7 @@ namespace Yavsc.Blogs.Controllers
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
// POST: api/BlogApi
// POST: api/v1/blog
[HttpPost]
public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog)
{

View file

@ -37,22 +37,16 @@ internal class Program
.AddYavscCors(builder.Configuration)
.AddControllers();
String authority = builder.Configuration.GetValue<string>("Site:Authority");
String audience = builder.Configuration.GetValue<string>("Site:Audience");
if (string.IsNullOrEmpty(authority))
{
throw new Exception("Site:Authority is not configured in appsettings.json");
}
// AuthenticationBuilder
services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration,
options =>
{
options.Authority = authority;
options.Audience = builder.Configuration.GetValue<string>
("Site:Audience");
});
.AddYavscJwtBearer(builder.Configuration);
// DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options =>
@ -100,7 +94,7 @@ internal class Program
.UseCors("default")
;
app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")
.LogInformation($"Yavsc.Blogs started, Authority is '{authority}', Audience is '{audience}'");
.LogInformation($"Yavsc.Blogs started, Authority is '{authority}''");
app.MapControllers();
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("BlogScope")
.WithHttpLogging(Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All)

View file

@ -4,6 +4,7 @@
"Description": "A collection of blogs about software development, technology, and programming.",
"Keywords": "software development, technology, programming, coding, blogs",
"Authority": "https://localhost:5001",
"Audience": ["blogs"],
"CorsAllowedOrigins": [
"https://localhost:5005"
]

View file

@ -8,32 +8,32 @@ namespace Yavsc.Org.Tests
[Trait("regression", "oui")]
public class Remoting : BaseTestContext, IClassFixture<WebServerFixture>
{
private readonly ITestOutputHelper _output;
public Remoting(WebServerFixture serverFixture, ITestOutputHelper output)
: base(output, serverFixture)
{
_output = output;
}
[Fact]
public async Task ObtainServiceToken()
{
var serverUrl = _serverFixture.SiteSettings.Authority;
if (string.IsNullOrEmpty(serverUrl))
throw new InvalidOperationException("No HTTPS server address found");
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
HttpClient client = NewHttpClient();
var disco = await client.GetDiscoveryDocumentAsync(serverUrl);
if (disco.IsError) throw new Exception(disco.Error);
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = _serverFixture.TestClientId,
ClientSecret = _serverFixture.TestClientSecret,
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
Scope = "test",
GrantType = "client_credentials"
});
}, cancellationToken);
if (response.IsError) throw new Exception(response.Error);
}
@ -45,27 +45,25 @@ namespace Yavsc.Org.Tests
[Fact]
public async Task ObtainResourceOwnerPasswordToken()
{
var serverUrl = _serverFixture.SiteSettings.Authority;
if (string.IsNullOrEmpty(serverUrl))
throw new InvalidOperationException("No HTTPS server address found");
var serverUrl = GetServerUrl();
var cancellationToken = TestContext.Current.CancellationToken;
var client = NewHttpClient();
var disco = await client.GetDiscoveryDocumentAsync(serverUrl);
if (disco.IsError) throw new Exception(disco.Error);
var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken);
var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest
{
Address = disco.TokenEndpoint,
ClientId = _serverFixture.TestClientId,
ClientSecret = _serverFixture.TestClientSecret,
UserName = _serverFixture.TestingUserName,
Password = _serverFixture.TestingUserPassword,
Address = tokenEndpoint,
ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)),
ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)),
UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)),
Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)),
Scope = "test",
Parameters =
{
{ "acr_values", "tenant:custom_account_store1 foo bar quux" }
}
});
}, cancellationToken);
if (response.IsError) throw new Exception(response.Error);
@ -76,6 +74,36 @@ namespace Yavsc.Org.Tests
return new object[][] { new object[] { "testuser", "test" } };
}
private async Task<string> ResolveTokenEndpointAsync(HttpClient client, string serverUrl, CancellationToken cancellationToken)
{
var disco = await client.GetDiscoveryDocumentAsync(serverUrl, cancellationToken);
if (!disco.IsError && !string.IsNullOrWhiteSpace(disco.TokenEndpoint))
{
return disco.TokenEndpoint;
}
// Some full-suite runs intermittently return 500 on the OIDC
// discovery document while /connect/token remains available.
var fallback = new Uri(new Uri(serverUrl), "/connect/token").ToString();
_output.WriteLine($"WARNING: OIDC discovery failed ({disco.Error}). Fallback token endpoint: {fallback}");
return fallback;
}
private string GetServerUrl()
{
return RequireNonEmpty(_serverFixture.SiteSettings?.Authority, "SiteSettings.Authority");
}
private static string RequireNonEmpty(string? value, string name)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException($"Missing required test setting: {name}");
}
return value;
}
}
internal class BypassSslValidationHandler : HttpClientHandler

View file

@ -10,7 +10,7 @@ using Yavsc.Server.Helpers;
// For more information on enabling Web API for empty projects, visit http://go.microsoft.com/fwlink/?LinkID=397860
namespace Yavsc.Controllers
namespace Yavsc.Org.Controllers
{
public class BlogSpotController : Controller
{
@ -28,7 +28,7 @@ namespace Yavsc.Controllers
BlogSpotService blogSpotService)
{
_context = context;
_logger = loggerFactory.CreateLogger<AccountController>();
_logger = loggerFactory.CreateLogger<BlogSpotController>();
_authorizationService = authorizationService;
_localisationOptions = localisationOptions.Value;
this.blogSpotService = blogSpotService;
@ -76,7 +76,7 @@ namespace Yavsc.Controllers
return View(blog);
}
catch (AuthorizationFailureException ex)
catch (AuthorizationFailureException)
{
return Challenge();
}

View file

@ -157,8 +157,7 @@ public static class HostingExtensions
services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration,
configure: o => o.Audience = builder.Configuration.GetSection("Site")["ExternalUrl"]);
.AddYavscJwtBearer(builder.Configuration);
services.AddTransient<RoleManager<IdentityRole>>();
services.AddTransient<IRoleStore<IdentityRole>, RoleStore<IdentityRole, ApplicationDbContext>>();

View file

@ -1,26 +1,24 @@
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.DotNet.Scaffolding.Shared;
using Microsoft.EntityFrameworkCore;
using Yavsc;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Helpers;
using Yavsc.Services;
using Yavsc.ViewModels.Auth;
using Yavsc.Abstract.Helpers;
using Microsoft.AspNetCore.Http;
public class BlogSpotService
[Obsolete]
public class OldBlogSpotService
{
private readonly ApplicationDbContext _context;
private readonly IAuthorizationService _authorizationService;
private readonly IFileSystemAuthManager fileSystemAuthManager;
public BlogSpotService(ApplicationDbContext context,
public OldBlogSpotService(ApplicationDbContext context,
IAuthorizationService authorizationService,
IFileSystemAuthManager fileSystemAuthManager)
{

View file

@ -1,14 +1,19 @@
@using Yavsc.Abstract.Identity
@model ApplicationUser
@{
// Le helper défend contre Model null et contre UserName vide
// ou whitespace. Sans cette garde, Razor lève
// NullReferenceException ici, ce qui propage un 500 et
// masque aussi la page d'erreur.
var avuri = UserDisplayHelpers.AvatarSrc(Model);
}
<div class="userinfo">
@if (Model != null && !string.IsNullOrWhiteSpace(Model.UserName))
{
string avuri = UserDisplayHelpers.AvatarSrc(Model);
<a title="Posts" asp-controller="Blogspot" asp-action="Index" asp-route-id="@Model.UserName" class="btn btn-primary">
<img src="@avuri" asp-append-version="true" class="smalltofhol" alt="@Model.UserName" title="@Model.UserName" />
</a>
} else {
<div class="alert alert-warning">
<strong>Utilisateur inconnu</strong>
<img src="@YavscConstants.DefaultAvatar" class="smalltofhol" alt="Utilisateur inconnu" title="Utilisateur inconnu" />
</div>
}
</div>

View file

@ -74,23 +74,31 @@ public static class ServiceExtensions
public static AuthenticationBuilder AddYavscJwtBearer(
this AuthenticationBuilder builder,
IConfiguration configuration,
Action<JwtBearerOptions>? configure = null,
string schemeName = "Bearer")
{
var authority = configuration.GetSection("Site")["Authority"]
?? throw new InvalidOperationException(
"Site:Authority is required to configure Yavsc JWT Bearer authentication.");
return builder.AddJwtBearer(schemeName, options =>
string[] audiences = configuration.GetSection("Site").GetSection("Audience").Get<string[]>() ?? Array.Empty<string>();
AuthenticationBuilder result = builder;
foreach (var audience in audiences)
{
result = builder.AddJwtBearer(schemeName, options =>
{
options.IncludeErrorDetails = true;
options.Authority = authority;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false,
RoleClaimType = YavscConstants.RoleClaimType
ValidateAudience = true,
ValidAudience = audience,
RoleClaimType = YavscConstants.RoleClaimType,
NameClaimType = YavscConstants.NameClaimType,
};
options.MapInboundClaims = true;
options.ClaimsIssuer = authority;
options.Audience = audience;
// Dev: every Yavsc resource service (Yavsc.Api, Yavsc.Blogs,
// Yavsc.Org itself) validates JWTs against the OP that runs
@ -115,7 +123,10 @@ public static class ServiceExtensions
(_, _, _, _) => true
};
}
configure?.Invoke(options);
});
}
return result;
}
}

View file

@ -10,7 +10,7 @@ namespace cli
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string Authority { get; set; }
public string Audience { get; set; }
public string[] Audience { get; set; }
public string SiteAccessSheme { get; set; } = "http";
public int Port { get; set; }
public string Scope { get; set; } = "profile";

View file

@ -1,18 +0,0 @@
{
"UserConnection": {
"UserName": "Paul",
"AccessToken": "IvvrnJye2b7CSFp2Hj3mEEK1-7EMbLLGJATQHAwOlRt5sIcm9aMH85kJMAxDb4yIzL76G7maoIlaWuSyhhS3qfcIB04WcVnCVdmw22ncMs8rk_L0yGuLdNBnF3HuhZXpjD-AjpLJ1TdE0q3c-yakkP_EWTjM9I00a1gGop4bgE_-LAa2uoLeaCj0dbe95OSvVKJWJ4MU0fjlvqAaUx-EC4HYicUMEQvJviR0FyyMKPYYyx4jyu_bgYbhmKX9LoE5VeEDEMsyXZE0fxqdfnflICduUqrprCvQfLoG4DZ8o7ivjoak3-xiP7CFaF1vou89mO1c9BcJYUjcZXPgNYC7K9QHwFC5sPDkOHdijUo9xfieRaaqge5tEHlZAs3H_u-bGWft7xjxHOVUPSRzzYygR27alVSUIt6rt28FoLsEGYQaVK5QNzIbOE0RVU40vQb5a6JzcTcjTtRxCMn3FneKZtilEHb2TooUy4YbmhbnDIKsa0ZMMBEshyEel8oCWz9SITjm24FbdCmUvSKOlkHhIcnSM8oez9qhDlzzcsKzahbNeO-EBINW-6qBlKISQbFmfy-zO134JEkcpfA9NybGcYPZWLWu1IrF9pDzO66X2Rh-H45pHQWRWFodHcoQh0p9ZBNwo5k_EkU731uwQZF5zgGyZnUU2VpxdjorzrpexZXpStzYnYRbGGK7kGAUZH9v16zL-nB4t55Mu12iK9jsUIS7cmqOSTj4mJWFd03dwe4xkNrLgcGSGPebmsfSWYaIk7qAmWMsPKKv_vnOpi-1sZuOgdVyH-RmuyW83IPCUiEAf63a40PuqpVxFiMmbcRqSKvh5TedjWUiEZvBvz786-atYF_b0dxCj8fDQbyWiMj-4Lj7FV4gUJFvrDX4DsRns6wL6Cs3urL4PmKHLEhRma-i589pEKmzinO7l4S2atB6uqMS_xNU7Y0um_GQ51oPk8PxxCggI2aLR4PJbuBGSJBoTDW1OV9HNa_BybrKamN8cuc8IT3LBJiHH_ImHyW1n94C2oFcaUJ1dVVt6JDJqt4EVvFiwk15SlRJMXSLWQWxPKeOTCGb-So2Gcy_DwGu8_AlVL4mOdQI0GiGRkpaWNIF3N6ek-B_7Lz1zxKX7BCx1ISurAzG7jK1FDydovJ2LOHO6QbqVH1JKRdRDCLOKZl3TpcAyOQHDo5rAl84pwf5UROMb5SfVDJtdekb7SRR",
"TokenType": "bearer",
"ExpiresIn": "1199",
"RefreshToken": "CfDJ8LSG6cXzxTtFuur3SY2RDINpvQVezgrIru8TEIfKUpNmUXe5lA4tCQ_nlkklgarH5UocJ6b95NLRon0uvVy5mE-7EY3ld_-bjLgjDnV9oPxY7Xdgb94_5HdDWcyG_Zaw67T0G_t21bvNEgtw0zewbdisPpjKopfH7avQWxlRrMbN9Wboj2ZXYVCbmu2EkeyG5665-B16UEQFF7dVT6qU4qnSldIfhx5ex8Ii5aGg0pDAEnRUNla_iObls2yAf18bmTAfF-34kmClqqLCr7z4Cy65DwQ8EHNA9oIEvgCEuj04IG5wC79vycZrw0NLQWAz7fTahRreD_WsmbIKdUqkvlDjV1W3Dq7KUCi4bAk7oAJuqdl3qZg4SohmQ31IKD08aKSOPa4_jtJ-eKOlT6fdDhpoaP3DyKS09OkvAwbMA86937IrCCFZsGBhHAm-yszHXjVjPCr7gAJYaBjneAUAoseEYK0GaOVGXfhxQcURXtM9TyH3pgHjccOzq300Djv-BpfQZcSZnQf59kulL_gt--451r8UIw3wxQ6BD5fGVF_MrilUluLyhoqoGZJbiHFyL-miKjW7feGFuogVyee0nIO1Me-7T-aTQnn-LivP9avyHab4eGwg1nfUbEspA21vmz2UXkUvKvT-JRGmSxlYineeJURQMcGGbcvr9GbFy1A_vbImUSw_Tx0u7_7jwxkDwlTt5WvGVsFFHsj9vgtNSnWHRzbGhbOSBocg0eWylgmYy3yZtdajRx6xQluRUewP_K66GsmA6xVmekUo0_ZzUPj61VQzTMR-knYE-pCd6V_qGS4qtUNeQ5nPLWT6hRPvnd3kK7CS22ijEErm3LaOY2DogGYIoBLvmdande226KW48hUgQPxsmOLoUl1tXMYsiRqHKAnWlzg4-e_BfRe3YsMUwlNPcqYf0hQ-mE-j4fVo79XJFFSWF8WxYKed-imDYeK9b6bQPP3ZPaqiMy7_d9Fxnb3i2Oo4XQPmrTpDkHbMovslBfBoJW4RSpXo3cn0QqcCDG2KfFS8HIYr68jXnyx7Ws3MeswdyEFSqx-XWmZa"
},
"Connection": {
"ClientId": "53f4d5da-93a9-4584-82f9-b8fdf243b002",
"ClientSecret": "blouh",
"Authority": "localhost",
"Audience": "localhost",
"SiteAccessSheme": "http",
"Port": 5000,
"Scope": "profile"
}
}