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
4 changes: 2 additions & 2 deletions samples/Controllers/ApiKeySample/ApiKeySample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions samples/Controllers/JwtBearerSample/JwtBearerSample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions samples/MinimalApis/ApiKeySample/ApiKeySample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions samples/MinimalApis/JwtBearerSample/JwtBearerSample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

<ItemGroup>
<PackageReference Include="SimpleAuthenticationTools.Abstractions" Version="3.1.10" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.5" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.7" />
</ItemGroup>

<ItemGroup>
Expand Down
65 changes: 40 additions & 25 deletions src/SimpleAuthentication/JwtBearer/JwtBearerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,40 @@

namespace SimpleAuthentication.JwtBearer;

internal class JwtBearerService(IOptions<JwtBearerSettings> jwtBearerSettingsOptions) : IJwtBearerService
/// <summary>
/// Default implementation of <see cref="IJwtBearerService"/> that provides JWT Bearer token generation and validation.
/// </summary>
/// <param name="jwtBearerSettingsOptions">The JWT Bearer settings.</param>
public class JwtBearerService(IOptions<JwtBearerSettings> jwtBearerSettingsOptions) : IJwtBearerService
{
private readonly JwtBearerSettings jwtBearerSettings = jwtBearerSettingsOptions.Value;
/// <summary>
/// Gets the JWT Bearer settings used by this service.
/// </summary>
protected JwtBearerSettings JwtBearerSettings { get; } = jwtBearerSettingsOptions?.Value ?? throw new ArgumentNullException(nameof(jwtBearerSettingsOptions));

Comment on lines +18 to 19
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

JwtBearerSettings initialization uses jwtBearerSettingsOptions?.Value ?? throw new ArgumentNullException(nameof(jwtBearerSettingsOptions)), which throws an ArgumentNullException for the options parameter even when jwtBearerSettingsOptions is non-null but .Value is null. Consider validating jwtBearerSettingsOptions explicitly (per project style) and failing with a more accurate exception if the bound settings instance is null.

Suggested change
protected JwtBearerSettings JwtBearerSettings { get; } = jwtBearerSettingsOptions?.Value ?? throw new ArgumentNullException(nameof(jwtBearerSettingsOptions));
protected JwtBearerSettings JwtBearerSettings { get; } = GetJwtBearerSettings(jwtBearerSettingsOptions);
private static JwtBearerSettings GetJwtBearerSettings(IOptions<JwtBearerSettings> jwtBearerSettingsOptions)
{
ArgumentNullException.ThrowIfNull(jwtBearerSettingsOptions);
return jwtBearerSettingsOptions.Value ?? throw new InvalidOperationException($"{nameof(JwtBearerSettings)} options must be configured.");
}

Copilot uses AI. Check for mistakes.
public Task<string> CreateTokenAsync(string userName, IList<Claim>? claims = null, string? issuer = null, string? audience = null, DateTime? absoluteExpiration = null)
/// <inheritdoc />
public virtual Task<string> CreateTokenAsync(string userName, IList<Claim>? claims = null, string? issuer = null, string? audience = null, DateTime? absoluteExpiration = null)
{
var now = DateTime.UtcNow;

if (absoluteExpiration.HasValue && absoluteExpiration.Value < now)
{
throw new ArgumentException("The expiration date must be greater than or equal to the current date and time.", nameof(absoluteExpiration));
}
Comment on lines +23 to +28
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The absoluteExpiration validation compares a user-provided DateTime against DateTime.UtcNow without normalizing time zones. If callers pass a DateTime with Kind = Local/Unspecified, the comparison can be wrong (since DateTime comparisons don't convert between kinds). Consider requiring UTC explicitly (e.g., validate Kind and/or convert to UTC) and clarifying the exception message accordingly.

Copilot uses AI. Check for mistakes.

claims ??= [];
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

claims ??= [] will most likely materialize as an array assigned to IList<Claim>. Arrays implement IList<T> but are fixed-size, so subsequent calls to claims.Update(...) (which uses Add/Remove) can throw NotSupportedException when claims is initially null. Use a mutable collection (e.g., initialize to a List<Claim>) before mutating the list.

Suggested change
claims ??= [];
claims = claims switch
{
null => new global::System.Collections.Generic.List<Claim>(),
{ IsReadOnly: true } => new global::System.Collections.Generic.List<Claim>(claims),
_ => claims
};

Copilot uses AI. Check for mistakes.
claims.Update(jwtBearerSettings.NameClaimType, userName);
claims.Update(JwtBearerSettings.NameClaimType, userName);
claims.Update(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString());

var now = DateTime.UtcNow;

var securityTokenDescriptor = new SecurityTokenDescriptor()
{
Subject = new ClaimsIdentity(claims, jwtBearerSettings.SchemeName, jwtBearerSettings.NameClaimType, jwtBearerSettings.RoleClaimType),
Issuer = issuer ?? jwtBearerSettings.Issuers?.FirstOrDefault(),
Audience = audience ?? jwtBearerSettings.Audiences?.FirstOrDefault(),
Subject = new ClaimsIdentity(claims, JwtBearerSettings.SchemeName, JwtBearerSettings.NameClaimType, JwtBearerSettings.RoleClaimType),
Issuer = issuer ?? JwtBearerSettings.Issuers?.FirstOrDefault(),
Audience = audience ?? JwtBearerSettings.Audiences?.FirstOrDefault(),
IssuedAt = now,
NotBefore = now.Add(-jwtBearerSettings.ClockSkew),
Expires = absoluteExpiration ?? (jwtBearerSettings.ExpirationTime.GetValueOrDefault() > TimeSpan.Zero ? now.Add(jwtBearerSettings.ExpirationTime!.Value) : DateTime.MaxValue),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtBearerSettings.SecurityKey)), jwtBearerSettings.Algorithm)
NotBefore = now.Add(-JwtBearerSettings.ClockSkew),
Expires = absoluteExpiration ?? (JwtBearerSettings.ExpirationTime.GetValueOrDefault() > TimeSpan.Zero ? now.Add(JwtBearerSettings.ExpirationTime!.Value) : DateTime.MaxValue),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtBearerSettings.SecurityKey)), JwtBearerSettings.Algorithm)
};

var tokenHandler = new JsonWebTokenHandler();
Expand All @@ -35,7 +48,8 @@ public Task<string> CreateTokenAsync(string userName, IList<Claim>? claims = nul
return Task.FromResult(token);
}

public async Task<ClaimsPrincipal> ValidateTokenAsync(string token, bool validateLifetime = true)
/// <inheritdoc />
public virtual async Task<ClaimsPrincipal> ValidateTokenAsync(string token, bool validateLifetime = true)
{
var tokenHandler = new JsonWebTokenHandler();

Expand All @@ -46,23 +60,23 @@ public async Task<ClaimsPrincipal> ValidateTokenAsync(string token, bool validat

var tokenValidationParameters = new TokenValidationParameters
{
AuthenticationType = jwtBearerSettings.SchemeName,
NameClaimType = jwtBearerSettings.NameClaimType,
RoleClaimType = jwtBearerSettings.RoleClaimType,
ValidateIssuer = jwtBearerSettings.Issuers?.Any() ?? false,
ValidIssuers = jwtBearerSettings.Issuers,
ValidateAudience = jwtBearerSettings.Audiences?.Any() ?? false,
ValidAudiences = jwtBearerSettings.Audiences,
AuthenticationType = JwtBearerSettings.SchemeName,
NameClaimType = JwtBearerSettings.NameClaimType,
RoleClaimType = JwtBearerSettings.RoleClaimType,
ValidateIssuer = JwtBearerSettings.Issuers?.Any() ?? false,
ValidIssuers = JwtBearerSettings.Issuers,
ValidateAudience = JwtBearerSettings.Audiences?.Any() ?? false,
ValidAudiences = JwtBearerSettings.Audiences,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtBearerSettings.SecurityKey)),
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtBearerSettings.SecurityKey)),
RequireExpirationTime = true,
ValidateLifetime = validateLifetime,
ClockSkew = jwtBearerSettings.ClockSkew
ClockSkew = JwtBearerSettings.ClockSkew
};

var validationResult = await tokenHandler.ValidateTokenAsync(token, tokenValidationParameters);

if (!validationResult.IsValid || validationResult.SecurityToken is not JsonWebToken jsonWebToken || jsonWebToken.Alg != jwtBearerSettings.Algorithm)
if (!validationResult.IsValid || validationResult.SecurityToken is not JsonWebToken jsonWebToken || jsonWebToken.Alg != JwtBearerSettings.Algorithm)
{
throw new SecurityTokenException("Token is expired or invalid", validationResult.Exception);
}
Expand All @@ -71,12 +85,13 @@ public async Task<ClaimsPrincipal> ValidateTokenAsync(string token, bool validat
return principal;
}

public async Task<string> RefreshTokenAsync(string token, bool validateLifetime, DateTime? absoluteExpiration = null)
/// <inheritdoc />
public virtual async Task<string> RefreshTokenAsync(string token, bool validateLifetime, DateTime? absoluteExpiration = null)
{
var principal = await ValidateTokenAsync(token, validateLifetime);
var claims = (principal.Identity as ClaimsIdentity)!.Claims.ToList();

var userName = claims.First(c => c.Type == jwtBearerSettings.NameClaimType).Value;
var userName = claims.First(c => c.Type == JwtBearerSettings.NameClaimType).Value;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

RefreshTokenAsync assumes the validated token always contains a claim with Type == JwtBearerSettings.NameClaimType and uses First(...), which will throw InvalidOperationException if the claim is missing. Since ValidateTokenAsync can succeed even when that claim isn't present, consider handling the missing-claim case explicitly and throwing a more appropriate exception (e.g., SecurityTokenException/ArgumentException) with a clear message.

Suggested change
var userName = claims.First(c => c.Type == JwtBearerSettings.NameClaimType).Value;
var nameClaim = claims.FirstOrDefault(c => c.Type == JwtBearerSettings.NameClaimType);
if (string.IsNullOrEmpty(nameClaim?.Value))
{
throw new SecurityTokenException($"Token does not contain the required '{JwtBearerSettings.NameClaimType}' claim.");
}
var userName = nameClaim.Value;

Copilot uses AI. Check for mistakes.
var issuer = claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Iss)?.Value;
var audience = claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Aud)?.Value;

Expand Down
2 changes: 1 addition & 1 deletion src/SimpleAuthentication/SimpleAuthentication.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/SimpleAuthentication/SimpleAuthenticationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ static void CheckAddJwtBearer(AuthenticationBuilder builder, IConfigurationSecti
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SecurityKey)),
RequireExpirationTime = true,
ValidateLifetime = settings.ExpirationTime.GetValueOrDefault() > TimeSpan.Zero,
ValidateLifetime = true,
ClockSkew = settings.ClockSkew
};
});
Expand Down
Loading