fix(security): first-party JwtBearer with HS256 pinned; /auth/token mints for a cookie session only
The hand-rolled bearer handler is replaced by AddJwtBearer (Microsoft.AspNetCore.Authentication.JwtBearer 10.0.7, pinned on the same servicing line as the other AspNetCore packages). Validation parameters come from JwtTokenService.BuildValidationParameters, which now pins ValidAlgorithms = HS256 for both the bearer scheme and TryValidate; MapInboundClaims is off so the minted Role/zb:username keys resolve. /auth/token requires the cookie scheme, so a bearer can no longer mint a fresh token for itself. Refs #733 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -35,6 +35,10 @@
|
||||
<PackageVersion Include="Grpc.Tools" Version="2.76.0" />
|
||||
<PackageVersion Include="libplctag" Version="1.5.2" />
|
||||
<PackageVersion Include="MessagePack" Version="2.5.301" />
|
||||
<!-- First-party bearer validation for /auth/token's JWTs (R03:2.4, Lane C fix round 1). Same 10.0.x servicing
|
||||
line as the other Microsoft.AspNetCore.* pins; its Microsoft.IdentityModel.* dependencies resolve UP to the
|
||||
8.11.0 pins below (no downgrade, no NU1605). -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
|
||||
|
||||
+8
-5
@@ -6,11 +6,14 @@
|
||||
> auth + role-grant pages live in `OtOpcUa.AdminUI`), and Admin auth policies
|
||||
> register from `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth`
|
||||
> (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) rather
|
||||
> than in a separate Admin process. The Admin UI uses a **single Cookie
|
||||
> authentication scheme** — there is no `AddJwtBearer` pipeline. The
|
||||
> `Security:Jwt` section configures `JwtTokenService`, which mints a JWT at the
|
||||
> `/auth/token` endpoint for **external** consumers (OPC UA clients / automation
|
||||
> scripts); the cookie itself stores the `ClaimsPrincipal` directly. DataProtection
|
||||
> than in a separate Admin process. The Admin UI session is a **Cookie**
|
||||
> scheme; the cookie stores the `ClaimsPrincipal` directly. JWT is **opt-in by
|
||||
> `Security:Jwt:SigningKey`** (validated at host start, ≥32 bytes): with a key,
|
||||
> `/auth/token` mints an HS256 token for a **cookie session only** (a bearer
|
||||
> cannot renew itself), and a first-party `AddJwtBearer` scheme (`Bearer`,
|
||||
> `ValidAlgorithms = HS256`, parameters from `JwtTokenService.BuildValidationParameters`)
|
||||
> accepts it on `/api/*` and every `RequireAuthorization` endpoint through the
|
||||
> `OtOpcUa.CookieOrBearer` default policy scheme. Without a key neither exists. DataProtection
|
||||
> keys persist to the shared Config DB (`PersistKeysToDbContext<OtOpcUaConfigDbContext>`)
|
||||
> so cookies survive failover between admin-role nodes.
|
||||
>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>The authentication scheme names OtOpcUa registers.</summary>
|
||||
public static class OtOpcUaAuthSchemes
|
||||
{
|
||||
/// <summary>
|
||||
/// The bearer scheme that validates the HS256 tokens <c>/auth/token</c> mints, through
|
||||
/// <see cref="JwtTokenService.BuildValidationParameters"/>. Registered only when
|
||||
/// <see cref="JwtOptions.IsConfigured"/>.
|
||||
/// </summary>
|
||||
public const string Bearer = "Bearer";
|
||||
|
||||
/// <summary>
|
||||
/// The default scheme: a policy scheme that forwards to <see cref="Bearer"/> when the request
|
||||
/// carries an <c>Authorization: Bearer</c> header and to the cookie scheme otherwise, so one
|
||||
/// <c>RequireAuthorization()</c> / policy accepts a browser session AND a machine token.
|
||||
/// </summary>
|
||||
public const string CookieOrBearer = "OtOpcUa.CookieOrBearer";
|
||||
|
||||
/// <summary>Whether the request presents a bearer token (the selector's whole rule).</summary>
|
||||
/// <param name="context">The request.</param>
|
||||
/// <returns><see langword="true"/> when an <c>Authorization: Bearer …</c> header is present.</returns>
|
||||
public static bool HasBearerToken(HttpContext context) =>
|
||||
TryGetBearerToken(context.Request.Headers.Authorization, out _);
|
||||
|
||||
internal static bool TryGetBearerToken(StringValues authorization, out string token)
|
||||
{
|
||||
token = string.Empty;
|
||||
var header = authorization.ToString();
|
||||
const string prefix = "Bearer ";
|
||||
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
token = header[prefix.Length..].Trim();
|
||||
return token.Length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an <c>Authorization: Bearer</c> token minted by <c>/auth/token</c> (R03:2.4). The JWT
|
||||
/// feature used to be a dead end: tokens were issued and nothing in the product accepted them. This
|
||||
/// handler is the consuming side, built on <see cref="JwtTokenService.TryValidate"/> so the validation
|
||||
/// parameters can never drift from the minting side (<see cref="JwtTokenService.BuildValidationParameters"/>
|
||||
/// sets <c>RoleClaimType</c>/<c>NameClaimType</c> to the minted keys, so <c>[Authorize(Roles)]</c> and
|
||||
/// <c>IsInRole</c> resolve).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fail closed: no header ⇒ <c>NoResult</c> (the cookie scheme is not consulted by this handler — the
|
||||
/// policy scheme selects one or the other); a malformed, expired, tampered or wrong-issuer token ⇒
|
||||
/// <c>Fail</c>, never a fall-through to the cookie. A direct <c>AddJwtBearer</c> needs the
|
||||
/// <c>Microsoft.AspNetCore.Authentication.JwtBearer</c> package, which the central package list does
|
||||
/// not carry; this handler is ~40 lines over the already-referenced <c>System.IdentityModel.Tokens.Jwt</c>
|
||||
/// and is a drop-in to replace once that package is pinned.
|
||||
/// </remarks>
|
||||
public sealed class JwtBearerAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
private readonly JwtTokenService _jwt;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="JwtBearerAuthenticationHandler"/> class.</summary>
|
||||
/// <param name="options">The scheme options monitor.</param>
|
||||
/// <param name="logger">The logger factory.</param>
|
||||
/// <param name="encoder">The URL encoder.</param>
|
||||
/// <param name="jwt">The token service whose validation parameters mirror its minting side.</param>
|
||||
public JwtBearerAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
JwtTokenService jwt)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
_jwt = jwt;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!OtOpcUaAuthSchemes.TryGetBearerToken(Request.Headers.Authorization, out var token))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
if (!_jwt.TryValidate(token, out var principal) || principal is null)
|
||||
return Task.FromResult(AuthenticateResult.Fail("Invalid bearer token."));
|
||||
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||
{
|
||||
Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
Response.Headers.WWWAuthenticate = OtOpcUaAuthSchemes.Bearer;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
|
||||
|
||||
/// <summary>The authentication scheme names OtOpcUa registers.</summary>
|
||||
public static class OtOpcUaAuthSchemes
|
||||
{
|
||||
/// <summary>
|
||||
/// The bearer scheme that validates the HS256 tokens <c>/auth/token</c> mints: the first-party
|
||||
/// <c>AddJwtBearer</c> handler over <see cref="JwtTokenService.BuildValidationParameters"/>.
|
||||
/// Registered only when <see cref="JwtOptions.IsConfigured"/>.
|
||||
/// </summary>
|
||||
public const string Bearer = "Bearer";
|
||||
|
||||
/// <summary>
|
||||
/// The default scheme: a policy scheme that forwards to <see cref="Bearer"/> when the request
|
||||
/// carries an <c>Authorization: Bearer</c> header and to the cookie scheme otherwise, so one
|
||||
/// <c>RequireAuthorization()</c> / policy accepts a browser session AND a machine token.
|
||||
/// </summary>
|
||||
public const string CookieOrBearer = "OtOpcUa.CookieOrBearer";
|
||||
|
||||
/// <summary>Whether the request presents a bearer token (the selector's whole rule).</summary>
|
||||
/// <param name="context">The request.</param>
|
||||
/// <returns><see langword="true"/> when an <c>Authorization: Bearer …</c> header is present.</returns>
|
||||
public static bool HasBearerToken(HttpContext context) =>
|
||||
TryGetBearerToken(context.Request.Headers.Authorization, out _);
|
||||
|
||||
internal static bool TryGetBearerToken(StringValues authorization, out string token)
|
||||
{
|
||||
token = string.Empty;
|
||||
var header = authorization.ToString();
|
||||
const string prefix = "Bearer ";
|
||||
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
token = header[prefix.Length..].Trim();
|
||||
return token.Length > 0;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,17 @@ public static class AuthEndpoints
|
||||
/// <summary>Response for a token issue request.</summary>
|
||||
public sealed record TokenResponse(string Token);
|
||||
|
||||
/// <summary>
|
||||
/// The <c>/auth/token</c> policy: an authenticated principal from the COOKIE scheme. Naming the scheme
|
||||
/// makes the authorization middleware authenticate that scheme alone, so an <c>Authorization: Bearer</c>
|
||||
/// header is never consulted here (with login disabled the auto-login handler sits under the same
|
||||
/// scheme name, so the dev rig is unchanged).
|
||||
/// </summary>
|
||||
internal static readonly AuthorizationPolicy CookieSessionOnly =
|
||||
new AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.RequireAuthenticatedUser()
|
||||
.Build();
|
||||
|
||||
/// <summary>Maps OtOpcUa authentication endpoints to the application route builder.</summary>
|
||||
/// <param name="app">The endpoint route builder.</param>
|
||||
/// <returns>The endpoint route builder for chaining.</returns>
|
||||
@@ -40,7 +51,10 @@ public static class AuthEndpoints
|
||||
// Mapped only when a signing key is configured (JwtOptions.IsConfigured): an unconfigured
|
||||
// endpoint used to answer HTTP 500 from the JwtTokenService constructor on every call.
|
||||
if (app.ServiceProvider.GetRequiredService<IOptions<JwtOptions>>().Value.IsConfigured)
|
||||
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization();
|
||||
// COOKIE scheme only (fix round 1 / I2): the default scheme also accepts a bearer, and a bearer
|
||||
// that could mint a fresh token would renew itself forever — outliving its own expiry and the
|
||||
// user's LDAP membership. A token is minted only for an interactive (cookie) session.
|
||||
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization(CookieSessionOnly);
|
||||
else
|
||||
app.ServiceProvider.GetService<ILoggerFactory>()?
|
||||
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
||||
|
||||
@@ -25,24 +25,12 @@ public sealed class JwtTokenService
|
||||
/// <summary>
|
||||
/// Role claim type used in the JWT payload.
|
||||
/// <para>
|
||||
/// <b>Issued-only / no internal JwtBearer scheme:</b> OtOpcUa uses a single Cookie
|
||||
/// authentication scheme; the JWT is minted by the <c>/auth/token</c> endpoint and
|
||||
/// consumed externally (e.g. by OPC-UA clients or automation scripts). There is no
|
||||
/// <c>AddJwtBearer</c> pipeline in OtOpcUa — the cookie stores the
|
||||
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> directly. Because no internal
|
||||
/// bearer validation path exists, the short "Role" key is intentionally used here rather
|
||||
/// than the long <see cref="ClaimTypes.Role"/> URI; external consumers receive exactly the
|
||||
/// key they expect.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>If a JwtBearer scheme is ever added:</b> the
|
||||
/// <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> passed to
|
||||
/// <c>AddJwtBearer</c> MUST set <c>RoleClaimType = JwtTokenService.RoleClaimType</c> (and
|
||||
/// <c>NameClaimType = JwtTokenService.UsernameClaimType</c>) so that
|
||||
/// <c>[Authorize(Roles=...)]</c> and <c>ClaimsPrincipal.IsInRole</c> resolve correctly.
|
||||
/// <see cref="BuildValidationParameters"/> is already wired to do this and MUST be used
|
||||
/// rather than constructing <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/>
|
||||
/// ad hoc.
|
||||
/// The short "Role" key is what <see cref="Issue"/> mints and what external consumers of the token
|
||||
/// expect. The internal bearer scheme (<c>AddJwtBearer</c> in <c>AddOtOpcUaAuth</c>) validates with
|
||||
/// <see cref="BuildValidationParameters"/>, which sets <c>RoleClaimType = RoleClaimType</c> and
|
||||
/// <c>NameClaimType = UsernameClaimType</c>, and turns <c>MapInboundClaims</c> off, so
|
||||
/// <c>[Authorize(Roles=...)]</c> and <c>ClaimsPrincipal.IsInRole</c> resolve against this key. Never
|
||||
/// construct <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> ad hoc.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public const string RoleClaimType = "Role";
|
||||
@@ -150,5 +138,9 @@ public sealed class JwtTokenService
|
||||
// if this is ever passed to AddJwtBearer. See RoleClaimType doc comment for rationale.
|
||||
RoleClaimType = RoleClaimType,
|
||||
NameClaimType = UsernameClaimType,
|
||||
// Pinned to the one algorithm Issue signs with: an unsigned (alg:none) token or one signed with a
|
||||
// different HMAC variant over the same key is refused on both the bearer and TryValidate paths.
|
||||
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
|
||||
RequireSignedTokens = true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -108,8 +109,21 @@ public static class ServiceCollectionExtensions
|
||||
});
|
||||
if (jwtConfigured)
|
||||
{
|
||||
authBuilder.AddScheme<AuthenticationSchemeOptions, JwtBearerAuthenticationHandler>(
|
||||
OtOpcUaAuthSchemes.Bearer, "OtOpcUa bearer token", _ => { });
|
||||
// First-party JwtBearer (R03:2.4, fix round 1 / I1) — the earlier hand-rolled handler is gone.
|
||||
// Validation parameters come from the SAME JwtTokenService that mints, so issuer/audience/key/
|
||||
// claim types can never drift; BuildValidationParameters pins ValidAlgorithms = HS256 (an
|
||||
// alg:none or a different-HMAC token is refused). MapInboundClaims=false keeps the minted
|
||||
// "Role"/"zb:username" claim keys verbatim, which RoleClaimType/NameClaimType name. A bad token
|
||||
// is AuthenticateResult.Fail ⇒ 401 + WWW-Authenticate: Bearer, never a 500.
|
||||
authBuilder.AddJwtBearer(OtOpcUaAuthSchemes.Bearer, "OtOpcUa bearer token", _ => { });
|
||||
services.AddOptions<JwtBearerOptions>(OtOpcUaAuthSchemes.Bearer)
|
||||
.Configure<JwtTokenService>((o, jwt) =>
|
||||
{
|
||||
o.TokenValidationParameters = jwt.BuildValidationParameters();
|
||||
o.MapInboundClaims = false;
|
||||
o.SaveToken = false;
|
||||
o.IncludeErrorDetails = false;
|
||||
});
|
||||
}
|
||||
if (disableLogin)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
|
||||
|
||||
@@ -12,6 +12,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
@@ -208,6 +209,98 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
|
||||
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 / I1 — the first-party JwtBearer handler pins HS256: an unsigned (<c>alg: none</c>)
|
||||
/// token carrying the right issuer/audience/roles, and a token signed with a different HMAC
|
||||
/// variant, both fail CLOSED with a 401 — never a 500, never a pass.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("none")]
|
||||
[InlineData("HS384")]
|
||||
public async Task Bearer_with_a_non_HS256_algorithm_is_rejected_with_401(string alg)
|
||||
{
|
||||
var token = alg == "none" ? UnsignedToken() : SignedToken(SecurityAlgorithms.HmacSha384);
|
||||
var client = NewClient();
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
|
||||
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await client.SendAsync(req, Ct);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
|
||||
|
||||
// Positive control: the same claims signed HS256 with the configured key ARE accepted, so the
|
||||
// 401 above measured the algorithm and not a broken token shape.
|
||||
var good = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
|
||||
good.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||
"Bearer", SignedToken(SecurityAlgorithms.HmacSha256));
|
||||
(await client.SendAsync(good, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fix round 1 / I2 — <c>/auth/token</c> mints only for a COOKIE session. A bearer token used to
|
||||
/// be able to mint a fresh token for itself, so a stolen token could renew itself forever and
|
||||
/// outlive both its own expiry and the user's LDAP membership. Positive control: the cookie
|
||||
/// session that minted the bearer still gets a token.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Auth_token_refuses_a_bearer_only_caller_and_serves_a_cookie_session()
|
||||
{
|
||||
var client = NewClient();
|
||||
var loginResponse = await client.PostAsJsonAsync("/auth/login",
|
||||
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var cookieReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||
AttachCookies(cookieReq, loginResponse);
|
||||
var cookieResp = await client.SendAsync(cookieReq, Ct);
|
||||
cookieResp.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
var bearer = (await cookieResp.Content.ReadFromJsonAsync<JsonElement>(Ct)).GetProperty("token").GetString()!;
|
||||
|
||||
// The bearer authenticates elsewhere (positive control on the token itself) …
|
||||
var root = new HttpRequestMessage(HttpMethod.Get, "/");
|
||||
root.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
|
||||
(await client.SendAsync(root, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
// … but cannot renew itself.
|
||||
var renew = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
|
||||
renew.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
|
||||
renew.Headers.Add("X-Requested-With", "XMLHttpRequest");
|
||||
var renewResp = await client.SendAsync(renew, Ct);
|
||||
renewResp.StatusCode.ShouldBeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden);
|
||||
(await renewResp.Content.ReadAsStringAsync(Ct)).ShouldNotContain("token");
|
||||
}
|
||||
|
||||
private const string TestSigningKey = "test-signing-key-with-at-least-32-bytes-of-utf8-content";
|
||||
|
||||
private static IEnumerable<System.Security.Claims.Claim> ViewerClaims() =>
|
||||
[
|
||||
new(JwtTokenService.UsernameClaimType, "mallory"),
|
||||
new(JwtTokenService.RoleClaimType, "Viewer"),
|
||||
];
|
||||
|
||||
/// <summary>A token signed with <paramref name="algorithm"/> using the configured key bytes.</summary>
|
||||
private static string SignedToken(string algorithm)
|
||||
{
|
||||
var keyBytes = System.Text.Encoding.UTF8.GetBytes(TestSigningKey);
|
||||
// The SAME key bytes for every algorithm, so a rejection measures the algorithm pin alone.
|
||||
var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
|
||||
issuer: "otopcua-test", audience: "otopcua-test", claims: ViewerClaims(),
|
||||
expires: DateTime.UtcNow.AddMinutes(5),
|
||||
signingCredentials: new SigningCredentials(new SymmetricSecurityKey(keyBytes), algorithm));
|
||||
return new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
/// <summary>An <c>alg: none</c> token with otherwise valid issuer/audience/lifetime/roles.</summary>
|
||||
private static string UnsignedToken()
|
||||
{
|
||||
static string B64(string json) => Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(json);
|
||||
var exp = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds();
|
||||
return B64("{\"alg\":\"none\",\"typ\":\"JWT\"}") + "." +
|
||||
B64($"{{\"zb:username\":\"mallory\",\"Role\":\"Viewer\",\"iss\":\"otopcua-test\",\"aud\":\"otopcua-test\",\"exp\":{exp}}}") +
|
||||
".";
|
||||
}
|
||||
|
||||
private async Task<string> LoginAndIssueTokenAsync()
|
||||
{
|
||||
var client = NewClient();
|
||||
|
||||
@@ -65,7 +65,7 @@ public sealed class JwtConfigurationGateTests
|
||||
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
|
||||
var bearer = await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer);
|
||||
bearer.ShouldNotBeNull();
|
||||
bearer.HandlerType.ShouldBe(typeof(JwtBearerAuthenticationHandler));
|
||||
bearer.HandlerType.ShouldBe(typeof(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler));
|
||||
await host.StopAsync(Ct);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user