Login redirect not working after upgrade 5.12 -> 6.1

We are experiencing a regression after updating our application from 5.12 to 6.1 on .NET Framework 4.8:

We are using AuthFeature with two auth providers and the HtmlRedirect property set to “/login”.
If a non-authenticated user accesses the app per browser, he will be redirected to /login when running with 5.12. With 6.1 he gets the browsers Windows auth frontend. IIS is configured to use anonymous.

The returned headers are as follows:

HTTP/1.1 401 Unauthorized
Cache-Control: private
Content-Type: application/json; charset=utf-8
Server: Microsoft-IIS/10.0
X-Compatible-With: 9.0
WWW-Authenticate: apphost realm="/auth/apphost"
X-Content-Type-Options: nosniff
WWW-Authenticate: Negotiate
WWW-Authenticate: NTLM
X-Powered-By: ASP.NET
Date: Thu, 16 Jun 2022 08:43:40 GMT
Content-Length: 84

/auth/apphost is the realm of our first auth provider.

Acessing the login endpoint directly works, as does the login over the auth provider. Only the redirect seems to be affected.

Is there any option that needs to be set now to enforce the old behavior?

Move one of the other configured Auth Providers as the first auth provider.

I’ve switched the two modules, now I still get a 401 with this header:

WWW-Authenticate: apphost realm="/auth/apphost"

but without the Negotiate and NTLM headers. Very strange.
The auth modules are our own, as we have an own auth handling in the underlying business logic layer.

Our authentication provider looks like this. I’ve remove all of our own specific code and left only the ServiceStack relevant parts:

internal class SessionAuthProvider : AuthProvider
{
    public SessionAuthProvider(...)
    {
        // ...

        Provider = "apphost";
        AuthRealm = "/auth/" + Provider;
    }

    public override async Task<object> AuthenticateAsync(IServiceBase authService, IAuthSession session, Authenticate request,
        CancellationToken ct)
    {
        // Do authenticattion  against our business layer
        // ...

        session.IsAuthenticated = true;
        await authService.SaveSessionAsync(session, SessionExpiry, ct).ConfigureAwait(false);

        return new AppServerAuthResponse    // inherits AuthenticateResponse
            {
                // ...
            };
    }

    public override async Task<object> LogoutAsync(IServiceBase service, Authenticate request, CancellationToken ct)
    {
        // Remove our internal session
        // ...
        service.RemoveSession();

        return new AuthenticateResponse();
    }

    public override bool IsAuthorized(IAuthSession session, IAuthTokens tokens, Authenticate request = null)
    {
        if (session?.Id == null)
            return false;

        // Check agains our internal session
        // ...
        var data = ...

        return data?.Session != null;
    }
}

If this is no longer the first AuthProvider registered than I’m not clear why it’s returning its AuthProvider headers if it’s enforced by the built-in Auth attributes.

Can you override your custom Auth Providers OnFailedAuthentication() and log Environment.StackTrace so it shows where it’s being called from.

I’ve bisected this problem with the available NuGet versions:
It works up to 5.12 and fails with 5.13ff.

Here is the stacktrace:

at QBM.AppServer.Base.SessionAuthProvider.OnFailedAuthentication(IAuthSession session, IRequest httpReq, IResponse httpRes)
   at ServiceStack.AuthenticateAttribute.<ExecuteAsync>d__12.MoveNext()
   at System.Runtime.CompilerServices.AsyncTaskMethodBuilder.Start[TStateMachine](TStateMachine& stateMachine)
   at ServiceStack.AuthenticateAttribute.ExecuteAsync(IRequest req, IResponse res, Object requestDto)
   at ServiceStack.ServiceStackHost.<ApplyRequestFiltersSingleAsync>d__426.MoveNext()
   at System.Runtime.CompilerServices.AsyncTaskMethodBuilder.Start[TStateMachine](TStateMachine& stateMachine)
   at ServiceStack.ServiceStackHost.ApplyRequestFiltersSingleAsync(IRequest req, IResponse res, Object requestDto)
   at ServiceStack.ServiceStackHost.<ApplyRequestFiltersAsync>d__425.MoveNext()
   at System.Runtime.CompilerServices.AsyncTaskMethodBuilder.Start[TStateMachine](TStateMachine& stateMachine)
   at ServiceStack.ServiceStackHost.ApplyRequestFiltersAsync(IRequest req, IResponse res, Object requestDto)
   at ServiceStack.Host.RestHandler.<ProcessRequestAsync>d__14.MoveNext()
   at System.Runtime.CompilerServices.AsyncTaskMethodBuilder.Start[TStateMachine](TStateMachine& stateMachine)
   at ServiceStack.Host.RestHandler.ProcessRequestAsync(IRequest req, IResponse httpRes, String operationName)
   at ServiceStack.Host.Handlers.HttpAsyncTaskHandler.System.Web.IHttpAsyncHandler.BeginProcessRequest(HttpContext context, AsyncCallback cb, Object extraData)
   at System.Web.HttpApplication.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute()
   at System.Web.HttpApplication.ExecuteStepImpl(IExecutionStep step)
   at System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously)
   at System.Web.HttpApplication.PipelineStepManager.ResumeSteps(Exception error)
   at System.Web.HttpApplication.BeginProcessRequestNotification(HttpContext context, AsyncCallback cb)
   at System.Web.HttpRuntime.ProcessRequestNotificationPrivate(IIS7WorkerRequest wr, HttpContext context)
   at System.Web.Hosting.PipelineRuntime.ProcessRequestNotificationHelper(IntPtr rootedObjectsPointer, IntPtr nativeRequestContext, IntPtr moduleData, Int32 flags)
   at System.Web.Hosting.PipelineRuntime.ProcessRequestNotification(IntPtr rootedObjectsPointer, IntPtr nativeRequestContext, IntPtr moduleData, Int32 flags)
   at System.Web.Hosting.UnsafeIISMethods.MgdIndicateCompletion(IntPtr pHandler, RequestNotificationStatus& notificationStatus)
   at System.Web.Hosting.UnsafeIISMethods.MgdIndicateCompletion(IntPtr pHandler, RequestNotificationStatus& notificationStatus)
   at System.Web.Hosting.PipelineRuntime.ProcessRequestNotificationHelper(IntPtr rootedObjectsPointer, IntPtr nativeRequestContext, IntPtr moduleData, Int32 flags)
   at System.Web.Hosting.PipelineRuntime.ProcessRequestNotification(IntPtr rootedObjectsPointer, IntPtr nativeRequestContext, IntPtr moduleData, Int32 flags)

So it is being called from the [Authenticate] attribute, which calls OnFailedAuthentication() on the first registered AuthProvider:

What does your AuthFeature look like? i.e. What is the first AuthProvider registered?

It looks like this:

var auth = new AuthFeature(
	() => new AuthUserSession(),
	new IAuthProvider[]
		{
			new SessionAuthProvider(/* ... */),
			new OAuth2AuthProvider(/* ... */)
		},
	_GetVirtualPath("login"));

The SessionAuthProvider without our custom code is in post 4.

What is the Provider for OAuth2AuthProvider, is it also a custom AuthProvider that inherits OAuth2Provider?

Can you also provide the HTTP Request headers of the failed request?

It is also a custom provider using our own auth logic:

internal class OAuth2AuthProvider : AuthProvider, IAuthWithRequest
{
  // ...
}

These are the request headers:

GET / HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate, br
Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: no-cache
Connection: keep-alive
Cookie: ss-opt=temp; ss-id=...; ss-pid=...
Host: localhost:4000
Pragma: no-cache
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="102", "Google Chrome";v="102"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
sec-gpc: 1

The problem persists even if I remove the second authentication provider from the AuthFeature.

What WWW-Authenticate: apphost realm="/auth/apphost" does it return in that case?

Do they both use the same apphost Provider name, because they should be unique.

Failed HTML Requests should redirect to the configured AuthFeature.HtmlRedirect which defaults to /login. Can you try overriding your AuthProvider’s OnFailedAuthentication so that it runs the error handling explicitly, e.g:

public override Task OnFailedAuthentication(IAuthSession session, IRequest httpReq, IResponse httpRes)
{
    var feature = HostContext.AssertPlugin<AuthFeature>();
    if (feature.HtmlRedirect != null && req.ResponseContentType.MatchesContentType(MimeTypes.Html))
    {
        var url = feature.GetHtmlRedirectUrl(req, feature.HtmlRedirect, includeRedirectParam:true);
        res.RedirectToUrl(url);
        return TypeConstants.EmptyTask;
    }
}

They have different provider names: apphost vs. oauth2.
In the header there was always the /auth/apphost realm.
And as I said: it failes also if I remove the OAuth2 provider completely.

Overriding OnFailedAuthentication works for us, now the redirect to the /login page is done like before.
I could live with this workaround.

This is the code that should be getting run, more specifically the AuthFeature registers custom Error HttpHandlers for different Auth Failure response codes, e.g:

appHost.CustomErrorHttpHandlers[HttpStatusCode.Unauthorized] = 
    new AuthFeatureUnauthorizedHttpHandler(feature);

The AuthFeatureUnauthorizedHttpHandler (which can be overridden) is what executes the above logic, i.e:

public class AuthFeatureUnauthorizedHttpHandler : HttpAsyncTaskHandler
{
    private readonly AuthFeature feature;
    public AuthFeatureUnauthorizedHttpHandler(AuthFeature feature) => this.feature = feature;
    
    public override Task ProcessRequestAsync(IRequest req, IResponse res, string operationName)
    {
        if (feature.HtmlRedirect != null && req.ResponseContentType.MatchesContentType(MimeTypes.Html))
        {
            var url = feature.GetHtmlRedirectUrl(req, feature.HtmlRedirect, includeRedirectParam:true);
            res.RedirectToUrl(url);
            return TypeConstants.EmptyTask;
        }
        //...
    }
}

Somewhere along the failure path that’s not happening, it’s also unclear why apphost is being returned when it’s not the first AuthProvider registered, since that’s what it uses.

Anyway OnFailedAuthentication is how you can override the default behavior, I would also return the base method for non HTML Requests, e.g:

public override Task OnFailedAuthentication(IAuthSession session, IRequest httpReq, IResponse httpRes)
{
    var feature = HostContext.AssertPlugin<AuthFeature>();
    if (feature.HtmlRedirect != null && req.ResponseContentType.MatchesContentType(MimeTypes.Html))
    {
        var url = feature.GetHtmlRedirectUrl(req, feature.HtmlRedirect, includeRedirectParam:true);
        res.RedirectToUrl(url);
        return TypeConstants.EmptyTask;
    }
    return base.OnFailedAuthentication(session, httpReq, httpRes);
}

OK, will do it that way.
Thanks for your help!

1 Like