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 @@ -36,6 +36,11 @@ public async Task<SessionValidationResult> ValidateSessionAsync(SessionValidatio
request.Headers.Add("Cookie", cookie.ToString());
}

if (context.Device.DeviceId is { } deviceId)
{
request.Headers.TryAddWithoutValidation("X-UDID", deviceId.Value);
}

var response = await _http.SendAsync(request, ct);

if (!response.IsSuccessStatusCode)
Expand Down
19 changes: 5 additions & 14 deletions src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvi
_claimsProvider = claimsProvider;
}

// TODO: Improve Device binding
// Validate runs before AuthFlowContext is set, do not call _authFlow here.

// TODO: Add GetSessionAggregate store method to 1 call instead of 3 calls of root chain session
Expand Down Expand Up @@ -61,21 +60,13 @@ public async Task<SessionValidationResult> ValidateSessionAsync(SessionValidatio
if (session.SecurityVersionAtCreation != root.SecurityVersion)
return SessionValidationResult.Invalid(SessionState.SecurityMismatch, session.UserKey, session.SessionId, session.ChainId, root.RootId);

if (chain.Device.HasDeviceId && context.Device.HasDeviceId)
{
if (!Equals(chain.Device.DeviceId, context.Device.DeviceId))
{
if (options.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject)
return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId);
// TODO: Implement device mismatch behavior based on options, e.g., allow, reject or rebind.
var deviceMismatch = !chain.Device.HasDeviceId || !context.Device.HasDeviceId || !Equals(chain.Device.DeviceId, context.Device.DeviceId);

//if (options.Session.DeviceMismatchBehavior == AllowAndRebind)
}
if (deviceMismatch && options.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject)
{
return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId);
}
//else
//{
// // Add SessionValidatorOrigin to seperate UserRequest or background task
// return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId);
//}

var claims = await _claimsProvider.GetClaimsAsync(context.Tenant, session.UserKey, ct);
return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, session.CreatedAt, chain.Device.DeviceId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,10 @@ window.uauth.antiforgery = {
method: "GET",
credentials: "include",
cache: "no-store",
redirect: "error"
redirect: "error",
headers: {
"X-UDID": window.uauth.deviceId
}
});

if (!response.ok) {
Expand Down

Large diffs are not rendered by default.

127 changes: 127 additions & 0 deletions tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3511,6 +3511,114 @@ await LoginAsync(
x.StatusCode == HttpStatusCode.Unauthorized);
}

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

var ct = TestContext.Current.CancellationToken;
var user = await _factory.CreateLoginUserAsync(ct: ct);
var deviceId = $"csrf-device-{Guid.NewGuid():N}";

using var client = CreateClient(deviceId);

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

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

var sessionCookie = GetSessionCookie(login);

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

// First reproduce the old JS behavior:
// the antiforgery GET does not carry X-UDID.
client.DefaultRequestHeaders.Remove("X-UDID");

using var anonymousTokenResponse = await client.GetAsync(
"/auth/antiforgery/token", ct);

anonymousTokenResponse.StatusCode.Should().Be(HttpStatusCode.OK);

var anonymousToken = await anonymousTokenResponse.Content
.ReadFromJsonAsync<AntiforgeryTokenResponse>(
cancellationToken: ct);

anonymousToken.Should().NotBeNull();

var antiforgeryCookie = GetAntiforgeryCookie(
anonymousTokenResponse);

// The POST has the correct device, but its CSRF token
// was obtained in a different authentication context.
using var rejectedRequest = new HttpRequestMessage(
HttpMethod.Post, "/auth/logout");

rejectedRequest.Headers.Add("X-UDID", deviceId);
rejectedRequest.Headers.Add(
anonymousToken!.HeaderName,
anonymousToken.RequestToken);
rejectedRequest.Headers.Add(
"Cookie",
$"{sessionCookie}; {antiforgeryCookie}");
rejectedRequest.Headers.Add(
"X-UAuth-Logout-Mode", "observe");

using var rejected = await client.SendAsync(
rejectedRequest, ct);

rejected.StatusCode.Should().Be(HttpStatusCode.BadRequest);

var rejectedBody = await rejected.Content.ReadAsStringAsync(ct);
rejectedBody.Should().Contain("invalid_antiforgery_token");

// Correct behavior: obtain the token using the same
// Device ID that will be used by the logout POST.
using var tokenRequest = new HttpRequestMessage(
HttpMethod.Get, "/auth/antiforgery/token");

tokenRequest.Headers.Add("X-UDID", deviceId);
tokenRequest.Headers.Add("Cookie", sessionCookie);

using var tokenResponse = await client.SendAsync(
tokenRequest, ct);

tokenResponse.StatusCode.Should().Be(HttpStatusCode.OK);

var token = await tokenResponse.Content
.ReadFromJsonAsync<AntiforgeryTokenResponse>(
cancellationToken: ct);

token.Should().NotBeNull();

var validAntiforgeryCookie = GetAntiforgeryCookie(
tokenResponse);

using var logoutRequest = new HttpRequestMessage(
HttpMethod.Post, "/auth/logout");

logoutRequest.Headers.Add("X-UDID", deviceId);
logoutRequest.Headers.Add(
token!.HeaderName,
token.RequestToken);
logoutRequest.Headers.Add(
"Cookie",
$"{sessionCookie}; {validAntiforgeryCookie}");
logoutRequest.Headers.Add(
"X-UAuth-Logout-Mode", "observe");

using var logout = await client.SendAsync(
logoutRequest, ct);

var logoutBody = await logout.Content.ReadAsStringAsync(ct);

logout.StatusCode.Should().Be(
HttpStatusCode.OK,
logoutBody);
}


// ------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -3592,4 +3700,23 @@ private static string GetSessionCookie(HttpResponseMessage response)

return firstSegment[(separator + 1)..];
}

private sealed record AntiforgeryTokenResponse(
string RequestToken,
string HeaderName,
string FormFieldName);

private static string GetAntiforgeryCookie(
HttpResponseMessage response)
{
response.Headers
.TryGetValues("Set-Cookie", out var cookies)
.Should().BeTrue();

return cookies!
.Select(x => x.Split(';', 2)[0])
.First(x => x.TrimStart().StartsWith(
".AspNetCore.Antiforgery.",
StringComparison.OrdinalIgnoreCase));
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using CodeBeam.UltimateAuth.Core.Contracts;
using AwesomeAssertions;
using CodeBeam.UltimateAuth.Core.Contracts;
using CodeBeam.UltimateAuth.Core.Domain;
using CodeBeam.UltimateAuth.Core.Infrastructure;
using CodeBeam.UltimateAuth.Core.MultiTenancy;
using CodeBeam.UltimateAuth.Core.Options;
using CodeBeam.UltimateAuth.Server.ResourceApi;
using AwesomeAssertions;
using CodeBeam.UltimateAuth.Tests.Unit.Helpers;
using Microsoft.AspNetCore.Http;
using System.Net;
using System.Net.Http.Json;
Expand Down Expand Up @@ -371,6 +372,51 @@ public void ToDomain_WhenIsValidTrueButStateIsRevoked_ShouldFailClosed()
}


[Fact]
public async Task ValidateSessionAsync_WhenDeviceIdExists_ShouldForwardDeviceId()
{
var handler = new TestHttpMessageHandler
{
Response = CreateFailureResponse(HttpStatusCode.Unauthorized)
};

var sut = CreateSut(handler);

var context = CreateValidationContext() with
{
Device = TestDevice.Default()
};

await sut.ValidateSessionAsync(
context,
ct: TestContext.Current.CancellationToken);

handler.DeviceId.Should().Be(context.Device.DeviceId!.Value.Value);
}

[Fact]
public async Task ValidateSessionAsync_WhenDeviceIdIsMissing_ShouldNotForwardDeviceId()
{
var handler = new TestHttpMessageHandler
{
Response = CreateFailureResponse(HttpStatusCode.Unauthorized)
};

var sut = CreateSut(handler);

var context = CreateValidationContext() with
{
Device = DeviceContext.Anonymous()
};

await sut.ValidateSessionAsync(
context,
ct: TestContext.Current.CancellationToken);

handler.DeviceId.Should().BeNull();
}


private static RemoteSessionValidator CreateSut(TestHttpMessageHandler handler, HttpContext? httpContext = null)
{
var httpClient = new HttpClient(handler)
Expand Down Expand Up @@ -418,11 +464,13 @@ private sealed class TestHttpMessageHandler : HttpMessageHandler
public Uri? RequestUri { get; private set; }
public string? Body { get; private set; }
public string? Cookie { get; private set; }
public string? DeviceId { get; private set; }

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Method = request.Method;
RequestUri = request.RequestUri;
DeviceId = request.Headers.TryGetValues("X-UDID", out var values) ? values.SingleOrDefault() : null;

if (request.Content is not null)
{
Expand Down
Loading
Loading