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
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,45 @@ public static class UAuthUrlBuilder
public static string Build(string authority, string relativePath, UAuthClientMultiTenantOptions tenant)
{
var baseAuthority = authority.TrimEnd('/');
var endpoint = relativePath.TrimStart('/');

// TODO: Correct route-based tenant URL construction when authority
// is an absolute URL (e.g. https://localhost:6110/auth).
// Preserve the scheme and host, and insert the tenant into the path.
if (tenant.Enabled && tenant.Transport == TenantTransport.Route)
if (!tenant.Enabled || tenant.Transport != TenantTransport.Route)
return baseAuthority + "/" + endpoint;

if (string.IsNullOrWhiteSpace(tenant.Tenant))
throw new InvalidOperationException("Tenant is enabled for route transport but no tenant value is provided.");

var tenantId = tenant.Tenant.Trim('/');

if (tenantId.Length == 0 ||
tenantId is "." or ".." ||
tenantId.Contains('/') ||
tenantId.Contains('\\') ||
tenantId.Contains('?') ||
tenantId.Contains('#'))
{
if (string.IsNullOrWhiteSpace(tenant.Tenant))
throw new ArgumentException("Invalid tenant route segment.", nameof(tenant));
}

var segment = Uri.EscapeDataString(tenantId);

if (baseAuthority.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || baseAuthority.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
if (Uri.TryCreate(baseAuthority, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException("Tenant is enabled for route transport but no tenant value is provided.");
}
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
throw new ArgumentException("Authority must use HTTP or HTTPS.", nameof(authority));

if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
throw new ArgumentException("Authority cannot contain a query or fragment.", nameof(authority));

baseAuthority = "/" + tenant.Tenant.Trim('/') + baseAuthority;
var origin = uri.GetLeftPart(UriPartial.Authority);
var path = uri.AbsolutePath.TrimEnd('/');

return origin + "/" + segment + path + "/" + endpoint;
}
}

return baseAuthority + "/" + relativePath.TrimStart('/');
return "/" + segment + "/" + baseAuthority.TrimStart('/') + "/" + endpoint;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
Expand All @@ -8,7 +8,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="10.1.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageReference Include="bunit" Version="2.11.3" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="coverlet.collector" Version="10.1.0" />
<PackageReference Include="AwesomeAssertions" Version="9.6.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="coverlet.collector" Version="10.1.0" />
<PackageReference Include="AwesomeAssertions" Version="9.6.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="10.0.12" />
Expand Down
106 changes: 41 additions & 65 deletions tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,8 @@ public async Task Logout_ShouldNotRevokeDeviceChain()
var chainBefore = before!.Items.Single(x => x.IsCurrentDevice);
var chainId = chainBefore.ChainId;

await CsrfTestClient.PostAsync(client, "/auth/logout", null);
var logout = await CsrfTestClient.PostAsync(client, "/auth/logout", null);
logout.StatusCode.Should().Be(HttpStatusCode.Found);

using var reloginClient = CreateClient(device);

Expand Down Expand Up @@ -979,46 +980,6 @@ await CsrfTestClient.PostJsonAsync(device2Client,
device2After.IsRevoked.Should().BeFalse();
}

[Fact]
public async Task Logout_WithAlreadyLoggedOutSession_ShouldRemainSafe()
{
_factory.Clock.Reset();

var user = await _factory.CreateLoginUserAsync();
var device = $"logout-idempotent-{Guid.NewGuid():N}";

using var client = CreateClient(device);

var login = await LoginAsync(
client,
user.Identifier,
user.Secret);

login.StatusCode.Should().Be(HttpStatusCode.Found);

var cookie = GetSessionCookie(login);

client.DefaultRequestHeaders.Add("Cookie", cookie);

var firstLogout = await CsrfTestClient.PostAsync(client, "/auth/logout", null);

firstLogout.StatusCode.Should().Be(HttpStatusCode.Found);

var secondLogout = await CsrfTestClient.PostAsync(client, "/auth/logout", null);

secondLogout.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found);

var authenticatedRequest = await CsrfTestClient.PostJsonAsync(client,
"/auth/me/sessions/chains",
new PageRequest
{
PageNumber = 1,
PageSize = 50
});

authenticatedRequest.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task Logout_ShouldNotReturnAuthenticationCredentials()
{
Expand Down Expand Up @@ -1276,15 +1237,23 @@ public async Task ConcurrentLogout_WithSameSession_ShouldLeaveSessionInvalid()
CsrfTestClient.PostAsync(client1, "/auth/logout", null),
CsrfTestClient.PostAsync(client2, "/auth/logout", null));

//
// Depending on where stale-session rejection occurs,
// one request may observe the session after the other revoked it.
//
// What must never happen is an infrastructure failure.
//
responses.Should().OnlyContain(x =>
x.StatusCode == HttpStatusCode.Found ||
x.StatusCode == HttpStatusCode.Unauthorized);
foreach (var response in responses)
{
if (response.StatusCode == HttpStatusCode.BadRequest)
{
var body = await response.Content.ReadAsStringAsync();

body.Should().Contain("invalid_antiforgery_token");
continue;
}

response.StatusCode.Should().BeOneOf(
HttpStatusCode.Found,
HttpStatusCode.Unauthorized);
}

responses.Should().Contain(x =>
x.StatusCode == HttpStatusCode.Found);

//
// Final state is the actual security invariant.
Expand Down Expand Up @@ -3475,10 +3444,7 @@ await LoginAsync(
user.Identifier,
user.Secret));

//
// Two independent HTTP requests start with the same
// authenticated actor authority.
//
// Two independent HTTP requests start with the same authenticated actor authority.
using var request1 = CreateClient(actorDevice);
using var request2 = CreateClient(actorDevice);

Expand All @@ -3499,22 +3465,30 @@ await LoginAsync(
"/auth/me/logout-all",
null));

//
// Depending on where authentication is linearized,
// the second request may observe either the pre-logout
// or post-logout authority.
//
responses.Should().OnlyContain(x =>
x.StatusCode == HttpStatusCode.OK ||
x.StatusCode == HttpStatusCode.Unauthorized);
foreach (var response in responses)
{
if (response.StatusCode == HttpStatusCode.BadRequest)
{
var body = await response.Content
.ReadFromJsonAsync<Dictionary<string, string>>();

body.Should().NotBeNull();
body.Should().ContainKey("error")
.WhoseValue.Should().Be("invalid_antiforgery_token");

continue;
}

response.StatusCode.Should().BeOneOf(
HttpStatusCode.OK,
HttpStatusCode.Unauthorized);
}

responses.Should().Contain(x =>
x.StatusCode == HttpStatusCode.OK);

//
// Final state is the security invariant that matters:
// no old authority may survive.
//
using var actorVerification =
CreateClient(actorDevice);

Expand Down Expand Up @@ -3588,7 +3562,9 @@ private static string GetSessionCookie(HttpResponseMessage response)

cookies.Should().NotBeNullOrEmpty();

var cookie = cookies!.First();
var cookie = cookies!
.Select(x => x.Split(';', 2)[0])
.First(x => x.StartsWith("uas=", StringComparison.OrdinalIgnoreCase));
cookie.Should().NotBeNullOrWhiteSpace();

return cookie;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="coverlet.collector" Version="10.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<Using Include="Xunit" />
<ProjectReference Include="../../src/bundle/CodeBeam.UltimateAuth.InMemory.Bundle/CodeBeam.UltimateAuth.InMemory.Bundle.csproj" />
<!-- Reuse the actual pipeline contracts, without the net10-only sample host or duplicated test code. -->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using CodeBeam.UltimateAuth.Client.Contracts;
using CodeBeam.UltimateAuth.Client.Infrastructure;
using CodeBeam.UltimateAuth.Client.Options;

namespace CodeBeam.UltimateAuth.Tests.Unit.Client;

public class UAuthUrlBuilderTests
{
[Theory]
[InlineData("/auth", "/login", "/acme/auth/login")]
[InlineData("/auth/", "/login", "/acme/auth/login")]
[InlineData("https://localhost:6110/auth", "/login", "https://localhost:6110/acme/auth/login")]
[InlineData("https://example.com/api/auth", "/refresh", "https://example.com/acme/api/auth/refresh")]
[InlineData("https://example.com/auth/", "/pkce/authorize", "https://example.com/acme/auth/pkce/authorize")]
public void Build_RouteTenant_InsertsTenantBeforeBasePath(string basePath, string endpoint, string expected)
{
var tenant = new UAuthClientMultiTenantOptions
{
Enabled = true,
Tenant = "acme",
Transport = TenantTransport.Route
};

var result = UAuthUrlBuilder.Build(
basePath, endpoint, tenant);

Assert.Equal(expected, result);
}

[Theory]
[InlineData(false, TenantTransport.Route)]
[InlineData(true, TenantTransport.Header)]
[InlineData(true, TenantTransport.None)]
public void Build_WithoutRouteTenant_PreservesUrl(
bool enabled, TenantTransport transport)
{
var tenant = new UAuthClientMultiTenantOptions
{
Enabled = enabled,
Tenant = "acme",
Transport = transport
};

var result = UAuthUrlBuilder.Build(
"https://example.com/auth", "/login", tenant);

Assert.Equal("https://example.com/auth/login", result);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Build_RouteTenantWithoutValue_Throws(string? value)
{
var tenant = new UAuthClientMultiTenantOptions
{
Enabled = true,
Tenant = value,
Transport = TenantTransport.Route
};

Assert.Throws<InvalidOperationException>(() =>
UAuthUrlBuilder.Build("/auth", "/login", tenant));
}

[Theory]
[InlineData("foo/bar")]
[InlineData("../admin")]
[InlineData("foo?bar")]
[InlineData("foo#bar")]
public void Build_InvalidTenantSegment_Throws(string value)
{
var tenant = new UAuthClientMultiTenantOptions
{
Enabled = true,
Tenant = value,
Transport = TenantTransport.Route
};

Assert.Throws<ArgumentException>(() =>
UAuthUrlBuilder.Build("/auth", "/login", tenant));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

<ItemGroup>
<PackageReference Include="bunit" Version="2.11.3" />
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PackageReference Include="coverlet.collector" Version="10.1.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand Down
Loading