@@ -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