Skip to content

Commit 622f400

Browse files
committed
Add test cases for edge cases
1 parent ec57fb6 commit 622f400

4 files changed

Lines changed: 216 additions & 16 deletions

File tree

src/Auth0.AspNetCore.Authentication/Auth0Constants.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ public class Auth0Constants
1515
/// </summary>
1616
internal static string DefaultCallbackPath = "/callback";
1717

18-
public static readonly string ResolvedDomainKey = "auth0:resolved-domain";
18+
/// <summary>
19+
/// Key used to store the resolved domain in the authentication properties.
20+
/// </summary>
21+
internal static readonly string ResolvedDomainKey = "auth0:resolved-domain";
1922
}
2023
}

src/Auth0.AspNetCore.Authentication/CustomDomains/Auth0CustomDomainsOpenIdConnectConfigurationManager.cs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ namespace Auth0.AspNetCore.Authentication.CustomDomains;
1818
/// Resolves configurations dynamically based on the domain associated with each request,
1919
/// enabling support for multiple Auth0 custom domains within a single application instance.
2020
/// Each domain's configuration is cached independently using the provided <see cref="IConfigurationManagerCache"/>.
21-
/// Registered as a singleton and maintain its cache throughout the application lifetime.
21+
/// Is registered as a singleton and maintain its cache throughout the application lifetime.
2222
/// </remarks>
2323
internal sealed class Auth0CustomDomainsOpenIdConnectConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>, IDisposable
2424
{
@@ -108,10 +108,41 @@ public void RequestRefresh()
108108
/// <exception cref="InvalidOperationException">Thrown when domain resolution fails.</exception>
109109
internal async Task<string> ResolveAuthorityAsync(HttpContext context)
110110
{
111+
var hasState = TryGetState(context, out var state);
112+
111113
// In case of a callback request, extracts the issuer from the state parameter.
112-
if (TryGetState(context, out var state) && TryGetIssuerFromState(state, out var stateIssuer))
114+
if (hasState && TryGetIssuerFromState(state, out var stateIssuer))
113115
{
114-
return Utils.ToAuthority(stateIssuer);
116+
var stateAuthority = Utils.ToAuthority(stateIssuer);
117+
118+
// Cross-validate: if the StartupFilter already resolved a domain for this request,
119+
// ensure it matches the domain stored in the encrypted state. A mismatch indicates
120+
// the request arrived on a different domain than the one that initiated the flow.
121+
if (context.Items[Auth0Constants.ResolvedDomainKey] is string middlewareDomain &&
122+
!string.IsNullOrWhiteSpace(middlewareDomain))
123+
{
124+
var middlewareAuthority = Utils.ToAuthority(middlewareDomain);
125+
if (!stateAuthority.Equals(middlewareAuthority, StringComparison.OrdinalIgnoreCase))
126+
{
127+
throw new InvalidOperationException(
128+
$"Domain mismatch: the callback request arrived on domain '{middlewareDomain}' " +
129+
$"but the authentication transaction was initiated with domain '{stateIssuer}'. " +
130+
"This may indicate a cross-domain replay or misconfigured routing.");
131+
}
132+
}
133+
134+
return stateAuthority;
135+
}
136+
137+
// If the request carries a state parameter (i.e. it looks like a callback) but the domain
138+
// could not be extracted from state, fail explicitly rather than falling back to the
139+
// DomainResolver, which could return a different domain than the one that started the flow.
140+
if (hasState)
141+
{
142+
throw new InvalidOperationException(
143+
"The request contains a 'state' parameter but the resolved domain could not be " +
144+
"extracted from it. This may indicate a tampered, expired, or malformed state. " +
145+
"The authentication transaction cannot be safely completed.");
115146
}
116147

117148
// Check if the domain was already resolved earlier in the request pipeline

src/Auth0.AspNetCore.Authentication/OpenIdConnectEventsFactory.cs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -168,21 +168,33 @@ private static Func<TokenValidatedContext, Task> CreateOnTokenValidated(Auth0Web
168168
.GetService<IOptionsMonitor<Auth0CustomDomainsOptions>>()
169169
?.Get(context.Scheme.Name);
170170

171-
if (customDomainsOptions is { IsMultipleCustomDomainsEnabled: true } &&
172-
context.Properties?.Items != null &&
173-
context.Properties.Items.TryGetValue(Auth0Constants.ResolvedDomainKey, out var expectedIssuer) &&
174-
!string.IsNullOrWhiteSpace(expectedIssuer))
171+
if (customDomainsOptions is { IsMultipleCustomDomainsEnabled: true })
175172
{
176-
var tokenIssuer = context.SecurityToken.Issuer;
177-
var expectedAuthority = Utils.ToAuthority(expectedIssuer);
178-
179-
var ok = tokenIssuer.Equals(expectedAuthority, StringComparison.OrdinalIgnoreCase) ||
180-
tokenIssuer.Equals(expectedAuthority + "/", StringComparison.OrdinalIgnoreCase);
181-
182-
if (!ok)
173+
if (context.Properties?.Items == null ||
174+
!context.Properties.Items.TryGetValue(Auth0Constants.ResolvedDomainKey, out var expectedIssuer) ||
175+
string.IsNullOrWhiteSpace(expectedIssuer))
183176
{
177+
// In multi-domain mode, static issuer validation is disabled (ValidateIssuer = false).
178+
// The domain MUST be present in state to validate the token issuer.
179+
// If it's missing, we cannot verify the token came from the expected authority.
184180
context.Fail(
185-
$"Token issuer '{tokenIssuer}' does not match expected issuer '{expectedAuthority}'.");
181+
"Token validation failed: the resolved domain was not found in the authentication state. " +
182+
"In multi-domain mode, the domain must be stored in state during the authorization request " +
183+
"to validate the token issuer on callback.");
184+
}
185+
else
186+
{
187+
var tokenIssuer = context.SecurityToken.Issuer;
188+
var expectedAuthority = Utils.ToAuthority(expectedIssuer);
189+
190+
var ok = tokenIssuer.Equals(expectedAuthority, StringComparison.OrdinalIgnoreCase) ||
191+
tokenIssuer.Equals(expectedAuthority + "/", StringComparison.OrdinalIgnoreCase);
192+
193+
if (!ok)
194+
{
195+
context.Fail(
196+
$"Token issuer '{tokenIssuer}' does not match expected issuer '{expectedAuthority}'.");
197+
}
186198
}
187199
}
188200

tests/Auth0.AspNetCore.Authentication.IntegrationTests/Auth0CustomDomainsOpenIdConnectConfigurationManagerTests.cs

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,4 +699,158 @@ public async Task GetConfigurationAsync_WithMemoryCacheAndSlidingExpiration_Uses
699699

700700
Assert.NotNull(config);
701701
}
702+
703+
[Fact]
704+
public async Task ResolveAuthorityAsync_WithStateButNoIssuerInState_ThrowsInvalidOperationException()
705+
{
706+
// State parameter is present but doesn't contain the resolved domain key
707+
var state = "protected-state";
708+
_httpContext.Request.QueryString = new QueryString($"?state={state}");
709+
710+
var props = new AuthenticationProperties(); // No ResolvedDomainKey
711+
_stateDataFormatMock.Setup(x => x.Unprotect(state)).Returns(props);
712+
713+
var manager = CreateManager();
714+
715+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
716+
() => manager.ResolveAuthorityAsync(_httpContext));
717+
718+
Assert.Contains("state", exception.Message);
719+
Assert.Contains("resolved domain could not be extracted", exception.Message);
720+
}
721+
722+
[Fact]
723+
public async Task ResolveAuthorityAsync_WithStateButCorruptedState_ThrowsInvalidOperationException()
724+
{
725+
// State parameter is present but decryption throws (tampered state)
726+
var state = "corrupted-state";
727+
_httpContext.Request.QueryString = new QueryString($"?state={state}");
728+
729+
_stateDataFormatMock.Setup(x => x.Unprotect(state))
730+
.Throws<System.Security.Cryptography.CryptographicException>();
731+
732+
var manager = CreateManager();
733+
734+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
735+
() => manager.ResolveAuthorityAsync(_httpContext));
736+
737+
Assert.Contains("state", exception.Message);
738+
Assert.Contains("resolved domain could not be extracted", exception.Message);
739+
}
740+
741+
[Fact]
742+
public async Task ResolveAuthorityAsync_WithStateButNullProperties_ThrowsInvalidOperationException()
743+
{
744+
// State parameter is present but Unprotect returns null
745+
var state = "protected-state";
746+
_httpContext.Request.QueryString = new QueryString($"?state={state}");
747+
748+
_stateDataFormatMock.Setup(x => x.Unprotect(state)).Returns((AuthenticationProperties)null!);
749+
750+
var manager = CreateManager();
751+
752+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
753+
() => manager.ResolveAuthorityAsync(_httpContext));
754+
755+
Assert.Contains("state", exception.Message);
756+
}
757+
758+
[Fact]
759+
public async Task ResolveAuthorityAsync_DoesNotFallBackToDomainResolver_WhenStatePresent()
760+
{
761+
// Verify that when state is present but issuer extraction fails,
762+
// the domain resolver is NOT called (preventing domain swap attacks)
763+
var state = "protected-state";
764+
_httpContext.Request.QueryString = new QueryString($"?state={state}");
765+
766+
_stateDataFormatMock.Setup(x => x.Unprotect(state)).Returns((AuthenticationProperties)null!);
767+
_domainResolverMock.Setup(x => x(_httpContext)).ReturnsAsync("attacker-domain.auth0.com");
768+
769+
var manager = CreateManager();
770+
771+
await Assert.ThrowsAsync<InvalidOperationException>(
772+
() => manager.ResolveAuthorityAsync(_httpContext));
773+
774+
_domainResolverMock.Verify(x => x(It.IsAny<HttpContext>()), Times.Never);
775+
}
776+
777+
[Fact]
778+
public async Task ResolveAuthorityAsync_CrossValidation_MatchingDomains_Succeeds()
779+
{
780+
var issuer = "tenant.auth0.com";
781+
var state = "protected-state";
782+
_httpContext.Request.QueryString = new QueryString($"?state={state}");
783+
784+
// StartupFilter already resolved the same domain
785+
_httpContext.Items[Auth0Constants.ResolvedDomainKey] = issuer;
786+
787+
var props = new AuthenticationProperties();
788+
props.Items[Auth0Constants.ResolvedDomainKey] = issuer;
789+
_stateDataFormatMock.Setup(x => x.Unprotect(state)).Returns(props);
790+
791+
var manager = CreateManager();
792+
793+
var authority = await manager.ResolveAuthorityAsync(_httpContext);
794+
795+
Assert.Equal($"https://{issuer}/", authority);
796+
}
797+
798+
[Fact]
799+
public async Task ResolveAuthorityAsync_CrossValidation_MismatchedDomains_ThrowsInvalidOperationException()
800+
{
801+
var stateIssuer = "tenant-a.auth0.com";
802+
var middlewareIssuer = "tenant-b.auth0.com";
803+
var state = "protected-state";
804+
_httpContext.Request.QueryString = new QueryString($"?state={state}");
805+
806+
// StartupFilter resolved a different domain than what's in state
807+
_httpContext.Items[Auth0Constants.ResolvedDomainKey] = middlewareIssuer;
808+
809+
var props = new AuthenticationProperties();
810+
props.Items[Auth0Constants.ResolvedDomainKey] = stateIssuer;
811+
_stateDataFormatMock.Setup(x => x.Unprotect(state)).Returns(props);
812+
813+
var manager = CreateManager();
814+
815+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
816+
() => manager.ResolveAuthorityAsync(_httpContext));
817+
818+
Assert.Contains("Domain mismatch", exception.Message);
819+
Assert.Contains("tenant-a.auth0.com", exception.Message);
820+
Assert.Contains("tenant-b.auth0.com", exception.Message);
821+
}
822+
823+
[Fact]
824+
public async Task ResolveAuthorityAsync_CrossValidation_NoMiddlewareDomain_StillSucceeds()
825+
{
826+
// When the StartupFilter hasn't cached a domain yet (e.g., no middleware ran),
827+
// the cross-validation is skipped and the state domain is trusted.
828+
var issuer = "tenant.auth0.com";
829+
var state = "protected-state";
830+
_httpContext.Request.QueryString = new QueryString($"?state={state}");
831+
832+
var props = new AuthenticationProperties();
833+
props.Items[Auth0Constants.ResolvedDomainKey] = issuer;
834+
_stateDataFormatMock.Setup(x => x.Unprotect(state)).Returns(props);
835+
836+
var manager = CreateManager();
837+
838+
var authority = await manager.ResolveAuthorityAsync(_httpContext);
839+
840+
Assert.Equal($"https://{issuer}/", authority);
841+
}
842+
843+
[Fact]
844+
public async Task ResolveAuthorityAsync_WithoutState_StillUsesResolver()
845+
{
846+
// Non-callback requests (no state parameter) should still work via DomainResolver
847+
var expectedDomain = "tenant.auth0.com";
848+
_domainResolverMock.Setup(x => x(_httpContext)).ReturnsAsync(expectedDomain);
849+
var manager = CreateManager();
850+
851+
var authority = await manager.ResolveAuthorityAsync(_httpContext);
852+
853+
Assert.Equal($"https://{expectedDomain}/", authority);
854+
_domainResolverMock.Verify(x => x(_httpContext), Times.Once);
855+
}
702856
}

0 commit comments

Comments
 (0)