From f536258a00d097d8513bd429d7705e169183bab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 7 Nov 2026 13:31:55 +0300 Subject: [PATCH 1/2] Enforce Device Binding in Session Validation --- .../ResourceApi/RemoteSessionValidator.cs | 5 + .../Services/UAuthSessionValidator.cs | 19 +--- .../RemoteSessionValidatorTests.cs | 52 ++++++++- .../Server/UAuthSessionValidatorTests.cs | 100 ++++++++++++++++-- 4 files changed, 151 insertions(+), 25 deletions(-) diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs index 14429d4c..ac1f2f71 100644 --- a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs @@ -36,6 +36,11 @@ public async Task 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) diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs index c46cceaa..ad9617f0 100644 --- a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -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 @@ -61,21 +60,13 @@ public async Task 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); diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs index 040f6718..afde4863 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/ResourceApi/RemoteSessionValidatorTests.cs @@ -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; @@ -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) @@ -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 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) { diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs index 270ce6ea..e7a39459 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/UAuthSessionValidatorTests.cs @@ -356,13 +356,66 @@ public async Task ValidateSessionAsync_WhenDeviceDoesNotMatchAndBehaviorIsReject } [Fact] - public async Task ValidateSessionAsync_WhenRequestHasNoDeviceId_AllowsValidation() + public async Task ValidateSessionAsync_WhenRequestHasNoDeviceIdAndBehaviorIsReject_ReturnsDeviceMismatch() + { + var fixture = CreateFixture(requestDevice: DeviceContext.Anonymous(), deviceMismatchBehavior: DeviceMismatchBehavior.Reject); + + SetupValidAggregate(fixture); + + var result = await fixture.Sut.ValidateSessionAsync( + fixture.Context, + ct: TestContext.Current.CancellationToken); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.DeviceMismatch); + result.UserKey.Should().Be(fixture.User); + result.SessionId.Should().Be(fixture.Session.SessionId); + result.ChainId.Should().Be(fixture.Chain.ChainId); + result.RootId.Should().Be(fixture.Root.RootId); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + + [Theory] + [InlineData(DeviceMismatchBehavior.Allow)] + [InlineData(DeviceMismatchBehavior.AllowAndRebind)] + public async Task ValidateSessionAsync_WhenDeviceDoesNotMatchAndBehaviorAllows_ReturnsActive( + DeviceMismatchBehavior behavior) { var fixture = CreateFixture( - requestDevice: DeviceContext.Anonymous()); + requestDevice: TestDevice.Alternative(), + deviceMismatchBehavior: behavior); - var claims = ClaimsSnapshot.From( - ("uauth:permission", "orders.read")); + SetupValidAggregate(fixture); + + fixture.ClaimsProvider + .Setup(x => x.GetClaimsAsync( + fixture.Tenant, + fixture.User, + It.IsAny())) + .ReturnsAsync(ClaimsSnapshot.Empty); + + var result = await fixture.Sut.ValidateSessionAsync( + fixture.Context, + ct: TestContext.Current.CancellationToken); + + result.IsValid.Should().BeTrue(); + result.State.Should().Be(SessionState.Active); + result.BoundDeviceId.Should().Be(fixture.Chain.Device.DeviceId); + } + + [Theory] + [InlineData(DeviceMismatchBehavior.Allow)] + [InlineData(DeviceMismatchBehavior.AllowAndRebind)] + public async Task ValidateSessionAsync_WhenRequestHasNoDeviceIdAndBehaviorAllows_ReturnsActive(DeviceMismatchBehavior behavior) + { + var fixture = CreateFixture(requestDevice: DeviceContext.Anonymous(), deviceMismatchBehavior: behavior); SetupValidAggregate(fixture); @@ -371,9 +424,11 @@ public async Task ValidateSessionAsync_WhenRequestHasNoDeviceId_AllowsValidation fixture.Tenant, fixture.User, It.IsAny())) - .ReturnsAsync(claims); + .ReturnsAsync(ClaimsSnapshot.Empty); - var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); + var result = await fixture.Sut.ValidateSessionAsync( + fixture.Context, + ct: TestContext.Current.CancellationToken); result.IsValid.Should().BeTrue(); result.State.Should().Be(SessionState.Active); @@ -417,6 +472,33 @@ public async Task ValidateSessionAsync_WhenAggregateIsValid_ReturnsActiveResult( result.BoundDeviceId.Should().Be(fixture.Chain.Device.DeviceId); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ValidateSessionAsync_WhenStoredDeviceIdIsMissingAndBehaviorIsReject_ReturnsDeviceMismatch(bool requestDeviceMissing) + { + var fixture = CreateFixture( + storedDevice: DeviceContext.Anonymous(), + requestDevice: requestDeviceMissing + ? DeviceContext.Anonymous() + : TestDevice.Default(), + deviceMismatchBehavior: DeviceMismatchBehavior.Reject); + + SetupValidAggregate(fixture); + + var result = await fixture.Sut.ValidateSessionAsync(fixture.Context, ct: TestContext.Current.CancellationToken); + + result.IsValid.Should().BeFalse(); + result.State.Should().Be(SessionState.DeviceMismatch); + + fixture.ClaimsProvider.Verify( + x => x.GetClaimsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + [Fact] public async Task ValidateSessionAsync_PropagatesCancellationToken() { @@ -514,8 +596,8 @@ private static Fixture CreateFixture( DateTimeOffset? chainExpiresAt = null, TimeSpan? idleTimeout = null, DeviceContext? requestDevice = null, - DeviceMismatchBehavior deviceMismatchBehavior = - DeviceMismatchBehavior.Reject) + DeviceMismatchBehavior deviceMismatchBehavior = DeviceMismatchBehavior.Reject, + DeviceContext? storedDevice = null) { var tenant = TenantKey.Single; var user = UserKey.New(); @@ -532,7 +614,7 @@ private static Fixture CreateFixture( user, chainCreatedAt ?? Now.AddHours(-1), chainExpiresAt ?? Now.AddDays(7), - TestDevice.Default(), + storedDevice ?? TestDevice.Default(), ClaimsSnapshot.Empty, root.SecurityVersion); From e398fd9ac15f244e1f13d1ddad799eaa1505e7fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Can=20Karag=C3=B6z?= Date: Sat, 7 Nov 2026 14:14:53 +0300 Subject: [PATCH 2/2] Fix Antiforgery Behavior on Device Hardening --- .../TScripts/uauth.js | 5 +- .../wwwroot/uauth.min.js | 2 +- .../LogoutTests.cs | 127 ++++++++++++++++++ .../javascript/uauth.test.cjs | 50 +++++++ 4 files changed, 182 insertions(+), 2 deletions(-) diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js index b2710b91..237bb830 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -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) { diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js index 9c32b1a2..5c205de1 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -1 +1 @@ -window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=async function(n,t=null){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const r=window.uauth.validateAuthUrl(n.action,t);n.action=r;let i=n.querySelector("input[name='__uauth_device']");i||(i=document.createElement("input"),i.type="hidden",i.name="__uauth_device",n.appendChild(i));i.value=window.uauth.deviceId;await window.uauth.antiforgery.appendToForm(n,t);n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:c,commitUrl:l,data:e,clientProfile:o,trustedOrigin:u}=n,a=window.uauth.validateAuthUrl(c,u),v=window.uauth.validateAuthUrl(l,u),s=window.uauth.deviceId;if(!s)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:a,payload:e,clientProfile:o,trustedOrigin:u});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status;if(i===429)return{isSuccess:!1,status:429,errorCode:"rate_limited",retryAfter:r.retryAfter??null};const y=i>=200&&i<300,h=i>=400&&i<500;if(!y&&!h)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(h&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const f={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(f.isSuccess){const n=document.createElement("form");n.method="POST";n.action=v;for(const t of Object.keys(e)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=e[t]??"";n.appendChild(i)}if(f.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=f.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=o??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=s;n.appendChild(i);document.body.appendChild(n);try{await window.uauth.submitForm(n,u)}catch(r){n.remove();throw r;}}return f};window.uauth.post=async function(n){const{url:e,mode:t,data:i,clientProfile:u,trustedOrigin:r}=n,f=window.uauth.validateAuthUrl(e,r);if(t==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=u??"";n.appendChild(t);const e=document.createElement("input");if(e.type="hidden",e.name="__uauth_device",e.value=window.uauth.deviceId,n.appendChild(e),i)for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t];n.appendChild(r)}document.body.appendChild(n);try{await window.uauth.submitForm(n,r)}catch(o){n.remove();throw o;}return null}const o=t==="logout"||t==="logout-observe";return window.uauth.request({url:f,method:"POST",payload:i,clientProfile:u,contentType:"form",credentials:"include",allowCrossOriginCredentials:!0,antiforgery:!0,trustedOrigin:r,navigateOnSuccess:t==="logout",headers:o?{"X-UAuth-Logout-Mode":"observe"}:{}})};window.uauth.postJson=function(n){const t=window.uauth.validateAuthUrl(n.url,n.trustedOrigin);return window.uauth.request({url:t,method:"POST",payload:n.payload,clientProfile:n.clientProfile,contentType:"json",credentials:"include",allowCrossOriginCredentials:!0,antiforgery:!0,trustedOrigin:n.trustedOrigin})};window.uauth.request=async function(n){const{followRedirects:a=!1,navigateOnSuccess:v=!1,url:h,method:y="GET",payload:r=null,clientProfile:p="",contentType:c="json",credentials:s="same-origin",headers:w={}}=n;if(!h)throw new Error("Request URL is required.");const u=new URL(h,document.baseURI);if(!["http:","https:"].includes(u.protocol))throw new Error("Unsupported request URL protocol.");const i=y.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(i))throw new Error(`Unsupported HTTP method: ${i}`);if(!["same-origin","include","omit"].includes(s))throw new Error("Invalid credentials policy.");if(s==="include"&&u.origin!==window.location.origin&&!n.allowCrossOriginCredentials)throw new Error("Cross-origin credentials require explicit authorization.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const f={...w,"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":p,"X-Requested-With":"UAuth"};let e=null;if(r!==null&&r!==undefined){if(i==="GET")throw new Error("GET requests cannot contain a body.");if(c==="json")f["Content-Type"]="application/json",e=JSON.stringify(r);else if(c==="form"){f["Content-Type"]="application/x-www-form-urlencoded";e=new URLSearchParams;for(const[n,t]of Object.entries(r))e.append(n,t??"")}else throw new Error("Unsupported content type.");}if(i!=="GET"&&n.antiforgery===!0){const i=window.uauth.validateAuthUrl(window.uauth.antiforgery.tokenUrl,n.trustedOrigin);if(new URL(i).origin!==u.origin)throw new Error("UAuth antiforgery token origin differs from request origin.");const t=await window.uauth.antiforgery.getToken(n.trustedOrigin);f[t.headerName]=t.requestToken}let t;try{t=await fetch(u.href,{method:i,credentials:s,redirect:a?"follow":"error",headers:f,body:e})}catch(l){if(l instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw l;}let o=null;if(t.status!==204&&t.status!==205){const n=await t.text();if(n)try{o=JSON.parse(n)}catch{o=n}}if(v&&t.ok){const n=o?.redirectUrl;if(typeof n=="string"&&n.length>0){const t=new URL(n,window.location.origin);if(!["http:","https:"].includes(t.protocol))throw new Error("Invalid logout redirect protocol.");window.location.assign(t.href)}}return{retryAfter:t.headers.get("Retry-After"),ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:o}};window.uauth.resourceRequest=async function(n){const{url:o,baseAddress:h,method:v="GET",payload:f=null,clientProfile:y="",credentials:p="Auto",requestId:u}=n;if(!o||typeof o!="string")throw new Error("Resource request URL is required.");const i=h?new URL(h):new URL(window.location.origin+"/");if(!["http:","https:"].includes(i.protocol))throw new Error("Invalid Resource API protocol.");if(i.username||i.password||i.search||i.hash)throw new Error("Invalid Resource API base address.");const t=new URL(o,i);if(t.origin!==i.origin)throw new Error("Resource request target differs from configured API origin.");if(t.username||t.password)throw new Error("Resource URL cannot contain credentials.");if(!["http:","https:"].includes(t.protocol))throw new Error("Unsupported Resource API protocol.");const s=v.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(s))throw new Error("Unsupported Resource API method.");if(s==="GET"&&f!==null)throw new Error("GET requests cannot contain a body.");let r;switch(p.toLowerCase()){case"auto":r=t.origin===window.location.origin?"same-origin":"include";break;case"sameorigin":r="same-origin";break;case"include":r="include";break;case"omit":r="omit";break;default:throw new Error("Invalid Resource credentials mode.");}if(r==="include"&&t.protocol!=="https:"&&!["localhost","127.0.0.1","[::1]"].includes(t.hostname))throw new Error("Cross-origin credential requests require HTTPS.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const c={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":y,"X-Requested-With":"UAuth"};let l=null;f!==null&&f!==undefined&&(c["Content-Type"]="application/json",l=JSON.stringify(f));const a=new AbortController;if(u){if(window.uauth.resourceRequests.has(u))throw new Error("Duplicate Resource request ID.");window.uauth.resourceRequests.set(u,a)}try{const n=await fetch(t.href,{method:s,credentials:r,redirect:"error",headers:c,body:l,signal:a.signal});let i=null;if(n.status!==204&&n.status!==205){const t=await n.text();t.length>0&&(i=t)}return{retryAfter:n.headers.get("Retry-After"),ok:n.ok,status:n.status,body:i,contentType:n.headers.get("Content-Type")}}catch(e){if(e?.name==="AbortError")throw e;if(e instanceof TypeError)return{ok:!1,status:0,body:null,contentType:null};throw e;}finally{u&&window.uauth.resourceRequests.delete(u)}};window.uauth.resourceRequests=new Map;window.uauth.cancelResourceRequest=function(n){const t=window.uauth.resourceRequests.get(n);return t?(t.abort(),!0):!1};window.uauth.validateAuthUrl=function(n,t=null){if(!n||typeof n!="string")throw new Error("Authentication URL is required.");const r=t?new URL(t,window.location.origin):new URL(window.location.origin),i=new URL(n,document.baseURI);if(!["http:","https:"].includes(r.protocol))throw new Error("Unsupported trusted origin protocol.");if(!["http:","https:"].includes(i.protocol))throw new Error("Unsupported authentication URL protocol.");if(r.username||r.password)throw new Error("Trusted origin cannot contain credentials.");if(i.username||i.password)throw new Error("Authentication URL cannot contain credentials.");if(i.origin!==r.origin)throw new Error("Authentication request target differs from the trusted UAuthHub origin.");return i.href};window.uauth.antiforgery={tokenUrl:null,configure:function(n){if(!n?.tokenUrl)throw new Error("UAuth antiforgery token URL is required.");this.tokenUrl=window.uauth.validateAuthUrl(n.tokenUrl,n.trustedOrigin)},getToken:async function(n){if(!this.tokenUrl)throw new Error("UAuth antiforgery is not configured.");const r=window.uauth.validateAuthUrl(this.tokenUrl,n),i=await fetch(r,{method:"GET",credentials:"include",cache:"no-store",redirect:"error"});if(!i.ok)throw new Error(`Unable to obtain UAuth antiforgery token: HTTP ${i.status}.`);const t=await i.json();if(!t?.requestToken||!t?.headerName||!t?.formFieldName)throw new Error("Invalid UAuth antiforgery response.");return t},appendToForm:async function(n,t){const r=await this.getToken(t);let i=Array.from(n.elements).find(n=>n.name===r.formFieldName);i||(i=document.createElement("input"),i.type="hidden",i.name=r.formFieldName,n.appendChild(i));i.value=r.requestToken}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file +window.uauth=window.uauth||{};window.uauth.storage={set:function(n,t,i){const r=n==="local"?window.localStorage:window.sessionStorage;r.setItem(t,i)},get:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)},remove:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;i.removeItem(t)},exists:function(n,t){const i=n==="local"?window.localStorage:window.sessionStorage;return i.getItem(t)!==null}};window.uauth.submitForm=async function(n,t=null){if(!n)return;if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const r=window.uauth.validateAuthUrl(n.action,t);n.action=r;let i=n.querySelector("input[name='__uauth_device']");i||(i=document.createElement("input"),i.type="hidden",i.name="__uauth_device",n.appendChild(i));i.value=window.uauth.deviceId;await window.uauth.antiforgery.appendToForm(n,t);n.submit()};window.uauth.tryAndCommit=async function(n){const{tryUrl:c,commitUrl:l,data:e,clientProfile:o,trustedOrigin:u}=n,a=window.uauth.validateAuthUrl(c,u),v=window.uauth.validateAuthUrl(l,u),s=window.uauth.deviceId;if(!s)throw new Error("UAuth deviceId is not initialized.");const r=await window.uauth.postJson({url:a,payload:e,clientProfile:o,trustedOrigin:u});if(!r||!Number.isInteger(r.status))throw new Error("Invalid UAuth transport response.");const i=r.status;if(i===429)return{isSuccess:!1,status:429,errorCode:"rate_limited",retryAfter:r.retryAfter??null};const y=i>=200&&i<300,h=i>=400&&i<500;if(!y&&!h)throw new Error(`Unexpected UAuth try response: HTTP ${i}.`);const t=r.body;if(!t||typeof t!="object"||Array.isArray(t)||typeof t.isSuccess!="boolean")throw new Error("Invalid UAuth try response body.");if(h&&t.isSuccess)throw new Error("Successful authentication result returned with HTTP error.");const f={isSuccess:t.isSuccess,reason:t.reason??null,remainingAttempts:t.remainingAttempts??null,lockoutUntilUtc:t.lockoutUntilUtc??null,requiresMfa:t.requiresMfa??!1,retryWithNewPkce:t.retryWithNewPkce??!1,previewReceipt:t.previewReceipt??null};if(f.isSuccess){const n=document.createElement("form");n.method="POST";n.action=v;for(const t of Object.keys(e)){const i=document.createElement("input");i.type="hidden";i.name=t;i.value=e[t]??"";n.appendChild(i)}if(f.previewReceipt){const t=document.createElement("input");t.type="hidden";t.name="PreviewReceipt";t.value=f.previewReceipt;n.appendChild(t)}const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=o??"";n.appendChild(t);const i=document.createElement("input");i.type="hidden";i.name="__uauth_device";i.value=s;n.appendChild(i);document.body.appendChild(n);try{await window.uauth.submitForm(n,u)}catch(r){n.remove();throw r;}}return f};window.uauth.post=async function(n){const{url:e,mode:t,data:i,clientProfile:u,trustedOrigin:r}=n,f=window.uauth.validateAuthUrl(e,r);if(t==="navigate"){const n=document.createElement("form");n.method="POST";n.action=f;const t=document.createElement("input");t.type="hidden";t.name="__uauth_client_profile";t.value=u??"";n.appendChild(t);const e=document.createElement("input");if(e.type="hidden",e.name="__uauth_device",e.value=window.uauth.deviceId,n.appendChild(e),i)for(const t in i){const r=document.createElement("input");r.type="hidden";r.name=t;r.value=i[t];n.appendChild(r)}document.body.appendChild(n);try{await window.uauth.submitForm(n,r)}catch(o){n.remove();throw o;}return null}const o=t==="logout"||t==="logout-observe";return window.uauth.request({url:f,method:"POST",payload:i,clientProfile:u,contentType:"form",credentials:"include",allowCrossOriginCredentials:!0,antiforgery:!0,trustedOrigin:r,navigateOnSuccess:t==="logout",headers:o?{"X-UAuth-Logout-Mode":"observe"}:{}})};window.uauth.postJson=function(n){const t=window.uauth.validateAuthUrl(n.url,n.trustedOrigin);return window.uauth.request({url:t,method:"POST",payload:n.payload,clientProfile:n.clientProfile,contentType:"json",credentials:"include",allowCrossOriginCredentials:!0,antiforgery:!0,trustedOrigin:n.trustedOrigin})};window.uauth.request=async function(n){const{followRedirects:a=!1,navigateOnSuccess:v=!1,url:h,method:y="GET",payload:r=null,clientProfile:p="",contentType:c="json",credentials:s="same-origin",headers:w={}}=n;if(!h)throw new Error("Request URL is required.");const u=new URL(h,document.baseURI);if(!["http:","https:"].includes(u.protocol))throw new Error("Unsupported request URL protocol.");const i=y.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(i))throw new Error(`Unsupported HTTP method: ${i}`);if(!["same-origin","include","omit"].includes(s))throw new Error("Invalid credentials policy.");if(s==="include"&&u.origin!==window.location.origin&&!n.allowCrossOriginCredentials)throw new Error("Cross-origin credentials require explicit authorization.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const f={...w,"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":p,"X-Requested-With":"UAuth"};let e=null;if(r!==null&&r!==undefined){if(i==="GET")throw new Error("GET requests cannot contain a body.");if(c==="json")f["Content-Type"]="application/json",e=JSON.stringify(r);else if(c==="form"){f["Content-Type"]="application/x-www-form-urlencoded";e=new URLSearchParams;for(const[n,t]of Object.entries(r))e.append(n,t??"")}else throw new Error("Unsupported content type.");}if(i!=="GET"&&n.antiforgery===!0){const i=window.uauth.validateAuthUrl(window.uauth.antiforgery.tokenUrl,n.trustedOrigin);if(new URL(i).origin!==u.origin)throw new Error("UAuth antiforgery token origin differs from request origin.");const t=await window.uauth.antiforgery.getToken(n.trustedOrigin);f[t.headerName]=t.requestToken}let t;try{t=await fetch(u.href,{method:i,credentials:s,redirect:a?"follow":"error",headers:f,body:e})}catch(l){if(l instanceof TypeError)return{ok:!1,status:0,refreshOutcome:null,body:null};throw l;}let o=null;if(t.status!==204&&t.status!==205){const n=await t.text();if(n)try{o=JSON.parse(n)}catch{o=n}}if(v&&t.ok){const n=o?.redirectUrl;if(typeof n=="string"&&n.length>0){const t=new URL(n,window.location.origin);if(!["http:","https:"].includes(t.protocol))throw new Error("Invalid logout redirect protocol.");window.location.assign(t.href)}}return{retryAfter:t.headers.get("Retry-After"),ok:t.ok,status:t.status,refreshOutcome:t.headers.get("X-UAuth-Refresh"),body:o}};window.uauth.resourceRequest=async function(n){const{url:o,baseAddress:h,method:v="GET",payload:f=null,clientProfile:y="",credentials:p="Auto",requestId:u}=n;if(!o||typeof o!="string")throw new Error("Resource request URL is required.");const i=h?new URL(h):new URL(window.location.origin+"/");if(!["http:","https:"].includes(i.protocol))throw new Error("Invalid Resource API protocol.");if(i.username||i.password||i.search||i.hash)throw new Error("Invalid Resource API base address.");const t=new URL(o,i);if(t.origin!==i.origin)throw new Error("Resource request target differs from configured API origin.");if(t.username||t.password)throw new Error("Resource URL cannot contain credentials.");if(!["http:","https:"].includes(t.protocol))throw new Error("Unsupported Resource API protocol.");const s=v.toUpperCase();if(!["GET","POST","PUT","PATCH","DELETE"].includes(s))throw new Error("Unsupported Resource API method.");if(s==="GET"&&f!==null)throw new Error("GET requests cannot contain a body.");let r;switch(p.toLowerCase()){case"auto":r=t.origin===window.location.origin?"same-origin":"include";break;case"sameorigin":r="same-origin";break;case"include":r="include";break;case"omit":r="omit";break;default:throw new Error("Invalid Resource credentials mode.");}if(r==="include"&&t.protocol!=="https:"&&!["localhost","127.0.0.1","[::1]"].includes(t.hostname))throw new Error("Cross-origin credential requests require HTTPS.");if(!window.uauth.deviceId)throw new Error("UAuth deviceId is not initialized.");const c={"X-UDID":window.uauth.deviceId,"X-UAuth-ClientProfile":y,"X-Requested-With":"UAuth"};let l=null;f!==null&&f!==undefined&&(c["Content-Type"]="application/json",l=JSON.stringify(f));const a=new AbortController;if(u){if(window.uauth.resourceRequests.has(u))throw new Error("Duplicate Resource request ID.");window.uauth.resourceRequests.set(u,a)}try{const n=await fetch(t.href,{method:s,credentials:r,redirect:"error",headers:c,body:l,signal:a.signal});let i=null;if(n.status!==204&&n.status!==205){const t=await n.text();t.length>0&&(i=t)}return{retryAfter:n.headers.get("Retry-After"),ok:n.ok,status:n.status,body:i,contentType:n.headers.get("Content-Type")}}catch(e){if(e?.name==="AbortError")throw e;if(e instanceof TypeError)return{ok:!1,status:0,body:null,contentType:null};throw e;}finally{u&&window.uauth.resourceRequests.delete(u)}};window.uauth.resourceRequests=new Map;window.uauth.cancelResourceRequest=function(n){const t=window.uauth.resourceRequests.get(n);return t?(t.abort(),!0):!1};window.uauth.validateAuthUrl=function(n,t=null){if(!n||typeof n!="string")throw new Error("Authentication URL is required.");const r=t?new URL(t,window.location.origin):new URL(window.location.origin),i=new URL(n,document.baseURI);if(!["http:","https:"].includes(r.protocol))throw new Error("Unsupported trusted origin protocol.");if(!["http:","https:"].includes(i.protocol))throw new Error("Unsupported authentication URL protocol.");if(r.username||r.password)throw new Error("Trusted origin cannot contain credentials.");if(i.username||i.password)throw new Error("Authentication URL cannot contain credentials.");if(i.origin!==r.origin)throw new Error("Authentication request target differs from the trusted UAuthHub origin.");return i.href};window.uauth.antiforgery={tokenUrl:null,configure:function(n){if(!n?.tokenUrl)throw new Error("UAuth antiforgery token URL is required.");this.tokenUrl=window.uauth.validateAuthUrl(n.tokenUrl,n.trustedOrigin)},getToken:async function(n){if(!this.tokenUrl)throw new Error("UAuth antiforgery is not configured.");const r=window.uauth.validateAuthUrl(this.tokenUrl,n),i=await fetch(r,{method:"GET",credentials:"include",cache:"no-store",redirect:"error",headers:{"X-UDID":window.uauth.deviceId}});if(!i.ok)throw new Error(`Unable to obtain UAuth antiforgery token: HTTP ${i.status}.`);const t=await i.json();if(!t?.requestToken||!t?.headerName||!t?.formFieldName)throw new Error("Invalid UAuth antiforgery response.");return t},appendToForm:async function(n,t){const r=await this.getToken(t);let i=Array.from(n.elements).find(n=>n.name===r.formFieldName);i||(i=document.createElement("input"),i.type="hidden",i.name=r.formFieldName,n.appendChild(i));i.value=r.requestToken}};window.uauth.setDeviceId=function(n){window.uauth.deviceId=n};window.uauth.getDeviceInfo=function(){return{userAgent:navigator.userAgent,platform:navigator.platform,language:navigator.language}} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs index 76abc819..f1b757ed 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LogoutTests.cs @@ -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( + 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( + 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 @@ -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)); + } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs b/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs index bb289e9d..d3582c73 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/javascript/uauth.test.cjs @@ -614,3 +614,53 @@ test("observed logout rejects unsafe redirect protocols", async () => { assert.equal(navigation, null); }); + +test("authenticated logout uses the same Device ID for CSRF token and POST", async () => { + const deviceId = "test-device"; + let tokenDeviceId = null; + let logoutDeviceId = null; + + const uauth = createEnvironment(async (url, options) => { + if (url === authUrl("/auth/antiforgery/token")) { + tokenDeviceId = options.headers?.["X-UDID"] ?? null; + + return { + ok: true, + status: 200, + json: async () => ({ + requestToken: "csrf-test-token", + headerName: "X-UAuth-CSRF", + formFieldName: "__RequestVerificationToken" + }) + }; + } + + assert.equal(url, authUrl("/auth/logout")); + + logoutDeviceId = options.headers?.["X-UDID"] ?? null; + + return { + ok: true, + status: 200, + redirected: false, + headers: { get: () => null }, + text: async () => JSON.stringify({ + isSuccess: true, + redirectUrl: null + }) + }; + }); + + configureAntiforgery(uauth); + + const result = await uauth.post({ + url: authUrl("/auth/logout"), + mode: "logout-observe", + clientProfile: "BlazorServer", + trustedOrigin: HUB_ORIGIN + }); + + assert.equal(result.status, 200); + assert.equal(logoutDeviceId, deviceId); + assert.equal(tokenDeviceId, deviceId); +});